Skip to content

auth/env hardening and reliability updates#44

Merged
AustinKelsay merged 32 commits into
devfrom
feature/auth-env-hardening
Mar 5, 2026
Merged

auth/env hardening and reliability updates#44
AustinKelsay merged 32 commits into
devfrom
feature/auth-env-hardening

Conversation

@AustinKelsay

@AustinKelsay AustinKelsay commented Feb 26, 2026

Copy link
Copy Markdown
Member

Summary

  • hardens auth and credential save/rehydrate flows
  • tightens env and relay validation behavior
  • improves route/runtime reliability and related frontend handling
  • updates CI/release reliability checks

Verification

  • bun run typecheck
  • bun test tests/routes/env.db-mode.spec.ts

Notes

  • Playwright E2E suite has been removed from this branch/repo state per current direction.

Summary by CodeRabbit

  • New Features

    • Expanded NIP‑46 public APIs and OpenAPI tags; added CLI/workflow steps for type checks and unit tests; headless start script; new test:unit script.
  • Bug Fixes / Reliability

    • Hardened auth, rate‑limiting, body-size and WebSocket credential handling; stricter relay URL and env validation; PBKDF2 iteration increase; improved restart/cleanup and retry logic.
  • Documentation

    • Updated OpenAPI docs, deployment and release docs, and websocket/auth guidance.
  • Tests / CI

    • Pinned runtimes in CI, added unit test/job, many new and extended tests.

@coderabbitai

coderabbitai Bot commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f3743d0-d139-401c-b6c3-965e88378a8c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Updated CI/workflows and Docker images; pinned Bun; added unit tests and type checks; hardened security audits and rate-limit headers; tightened auth, env, relay, and NIP‑46 validation; increased PBKDF2 iterations; refactored frontend callbacks/accessibility; added many tests, migrations, and utilities.

Changes

Cohort / File(s) Summary
Workflows & Release
/.github/workflows/ci.yml, /.github/workflows/release.yml, /.github/workflows/umbrel-dev.yml
Switched triggers from mastermain, pinned Bun to 1.3.10, added unit/type-test steps, hardened security audit with retries, pinned TruffleHog action, removed continue-on-error, and tightened cleanup in umbrel-dev smoke tests.
Build / Docker / Packaging
Dockerfile, packages/umbrel/igloo/Dockerfile, compose.yml, scripts/fetch-swagger-ui.mjs, scripts/patch-zod-compat.mjs, scripts/release.sh, scripts/start-headless.js
Pinned oven/bun images to 1.3.10, adjusted compose DB_PATH, bumped Swagger UI version, made zod patching robust, added pre-release typecheck/tests to release flow, and added headless start script.
Package manifest
package.json
Added test:unit script, added runtime zod and dev ajv, bumped esbuild, removed @types/yaml, and added dependency overrides.
OpenAPI & Docs
docs/openapi/openapi.yaml, docs/openapi/openapi.json, docs/openapi/README.md, docs/DEPLOY.md, docs/*
Moved spec paths to docs/openapi/*; expanded NIP‑46 public schemas/endpoints; added Crypto/NIP‑46 tags; removed bearer from auth methods; standardized ErrorResponse usage; updated deployment/docs references.
Env / Ignore / Examples
.gitignore, env.example, docs/CONFIG.md, scripts/api/README.md
Added test-results/ ignore, updated env.example placeholders and ALLOW_QUERY_CREDENTIALS docs, and added guidance on WS query-credential changes.
Backend: Auth, Rate-limits & Routing
src/routes/auth.ts, src/routes/index.ts, src/routes/onboarding.ts, src/routes/status.ts, src/routes/auth-factory.ts
Added parse helpers and digest-based constant-time compares, exposed rate-limit resetAt, returned richer getAuthStatus, centralized auth finalization, tightened onboarding password policy and CLIENT_ID_TTL handling.
Backend: Env, Credentials & Node
src/routes/env.ts, src/routes/user.ts, src/routes/peers.ts, src/node/manager.ts, src/routes/node-manager.ts
Stricter relay/credential validation, admin-secret extraction, propagate env and restart node under lock on credential changes, added concurrency/retry for peer persistence, serialized SimplePool.subscribeMany patching, improved connectivity monitoring.
Backend: Nip46 & Service
src/db/nip46.ts, src/nip46/service.ts, src/nip46/index.ts, src/routes/nip46.ts
Normalized client pubkey handling, stricter JSON/body limits and policy parsing, guarded DB init with clear 404/403/413 semantics, added pending-request lookup, startup serialization and cleanup on agent failures, and an accessor for service instance.
Backend: DB, Migrations & UI event log
src/db/database.ts, src/db/migrator.ts, src/db/migrations/*, src/db/ui-event-log.ts
Improved DB path inference, switched admin checks to role='admin', added realpath security and MigrationDirSecurityError, made migrations idempotent/CLOB length casts, and lazy-initialized UI event-log store wrappers.
Backend: Relay class & tests
src/class/relay.ts, src/class/relay.test.ts
REQ normalization for nostr-tools 2.x, empty-filter rejection with NOTICE, composite subscription keys, two-key event sorting and correct limit counting; added comprehensive REQ handling tests and fake-socket helpers.
Backend: Crypto & Config
src/config/crypto.ts, src/const.ts
Increased PBKDF2 iterations 200k→600k, loosened password regex to require a non-alphanumeric char, hardened HOST_PORT parsing with range checks and removed env mutation for ADMIN_SECRET.
Backend: Utils & Rate limiter
src/routes/utils.ts, src/routes/utils.test.ts, src/utils/rate-limiter.ts
Added IPv4/IPv6 and loopback detection, ALLOW_LOCALHOST_RELAY-aware relay filtering, env-file path resolver, expanded localhost test cases, and DB-fallback behavior for rate-limiter operations.
Routes: nip04/nip44/sign/others
src/routes/nip04.ts, src/routes/nip44.ts, src/routes/sign.ts, src/routes/update.ts
Added auth checks, dynamic Retry-After calculation, enforced request body size limits, stricter secret length validation, and configurable prerelease acceptance for update checks.
Frontend: Auth, Onboarding & NIP‑46 UIs
frontend/App.tsx, frontend/components/{Login,Onboarding,ApiKeys,Recover,Configure,NIP46,Signer,QRScanner}.tsx
Stabilized callbacks with useCallback/useRef, normalized userId types, reintroduced auth-expiry logout handler, accessibility/focus fixes for modals, relay resolution/save refinements, one-time guards and copyable recovered NSEC UI.
Frontend: UI primitives & types
frontend/components/ui/*.tsx, frontend/types/*, frontend/tsconfig.json, frontend/styles.css
ARIA/useId improvements (collapsible, modal, peer-list), Tooltip typed as discriminated union with ariaLabel for focusable tooltips, CardTitle ref type -> heading, InputWithValidation omitted required, UpdateInfo.source added, removed forced dark-mode, and ts lib updated to ES2020.
Tests & Script Runner
tests/routes/*, tests/routes/helpers/script-runner.*, tests/routes/*spec.ts
Added many tests (relay, nip46 validation, peers rollback, env DB-mode, body-limit), introduced isolation keys/prefixes, buildScriptEnv and sanitized runRouteScript with result marker parsing and better error previews.
Misc frontend/type additions
frontend/types/nostr-connect.d.ts, frontend/components/ui/*
Added typed ambient module exports for '@cmdcode/nostr-connect' and assorted small UI improvements (icon/button prop handling, relay list keys).

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped from master over to main,

Pinned Bun, ran tests, retried the audit chain,
I normalized relays and tightened the locks,
Bumped PBKDF2 and fixed a few flocks,
Docs, schemas, and UI—now ready to reign.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/auth-env-hardening

@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Feb 26, 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: 13

🧹 Nitpick comments (18)
src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql (1)

41-43: Consider centralizing repeated size expressions via a CTE.

The same LENGTH(CAST(... AS BLOB)) expressions are repeated multiple times. Computing once reduces drift risk and makes future threshold updates safer.

♻️ Proposed refactor
+WITH session_sizes AS (
+  SELECT
+    id,
+    user_id,
+    client_pubkey,
+    COALESCE(LENGTH(CAST(relays AS BLOB)), 0) AS relay_size,
+    COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) AS methods_size,
+    COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) AS kinds_size
+  FROM nip46_sessions
+)
 INSERT INTO nip46_data_audit (
   session_id,
   user_id,
   client_pubkey,
   relay_size,
   methods_size,
   kinds_size,
   total_size,
   has_risk_old_limit,
   has_risk_new_limit
 )
 SELECT
-  id,
+  id,
   user_id,
   client_pubkey,
-  CASE WHEN relays IS NOT NULL THEN LENGTH(CAST(relays AS BLOB)) ELSE 0 END as relay_size,
-  CASE WHEN policy_methods IS NOT NULL THEN LENGTH(CAST(policy_methods AS BLOB)) ELSE 0 END as methods_size,
-  CASE WHEN policy_kinds IS NOT NULL THEN LENGTH(CAST(policy_kinds AS BLOB)) ELSE 0 END as kinds_size,
-  CASE
-    WHEN relays IS NOT NULL OR policy_methods IS NOT NULL OR policy_kinds IS NOT NULL
-    THEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) + COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) + COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0)
-    ELSE 0
-  END as total_size,
+  relay_size,
+  methods_size,
+  kinds_size,
+  relay_size + methods_size + kinds_size AS total_size,
   CASE
-    WHEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) > 10000
-      OR COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) > 10000
-      OR COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) > 10000
+    WHEN relay_size > 10000
+      OR methods_size > 10000
+      OR kinds_size > 10000
     THEN 1
     ELSE 0
   END as has_risk_old_limit,
   CASE
-    WHEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) > 50000
-      OR COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) > 50000
-      OR COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) > 50000
+    WHEN relay_size > 50000
+      OR methods_size > 50000
+      OR kinds_size > 50000
     THEN 1
     ELSE 0
   END as has_risk_new_limit
-FROM nip46_sessions;
+FROM session_sizes;

Also applies to: 46-46, 52-54, 60-62

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

In `@src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql` around lines 41 -
43, Repeated LENGTH(CAST(... AS BLOB)) expressions for relays, policy_methods,
and policy_kinds should be computed once in a CTE and then referenced in the
main SELECT to avoid duplication and drift; add a WITH block (e.g.,
audit_blob_sizes) that selects id plus computed relay_size, methods_size,
kinds_size using LENGTH(CAST(column AS BLOB)) and then join or select from that
CTE in place of repeating the expressions in the existing SELECT (referencing
the same column names: relays, policy_methods, policy_kinds and the output
aliases relay_size, methods_size, kinds_size); apply the same CTE approach to
the other duplicated regions noted (lines ~46, 52–54, 60–62).
src/routes/update.ts (1)

43-43: Make prerelease policy runtime-configurable.

Line 43 hard-codes prerelease behavior, so Lines 158 and 179 still require a code change to toggle policy. Prefer an env-driven value.

Proposed change
-const ALLOW_PRERELEASE_UPDATES = false;
+const ALLOW_PRERELEASE_UPDATES = parseBoolean(process.env['ALLOW_PRERELEASE_UPDATES']);

Also applies to: 158-158, 179-179

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

In `@src/routes/update.ts` at line 43, The code currently hard-codes prerelease
behavior via the constant ALLOW_PRERELEASE_UPDATES; make this a
runtime-configurable boolean by reading an environment/config value (e.g.,
process.env.ALLOW_PRERELEASE_UPDATES) parsed to boolean with a safe default of
false, replace the const declaration with that runtime value, and update any
places that reference ALLOW_PRERELEASE_UPDATES (the prerelease-check logic in
this file — around the update handling branches that currently use that
constant) to use the new runtime-configured variable so toggling prerelease
policy requires only an env change rather than a code change.
frontend/components/Onboarding.tsx (1)

147-147: Avoid any type; use an explicit type instead.

The any type violates the coding guideline requiring explicit types. Based on the usage at line 158, this should be typed with the expected response shape.

♻️ Suggested fix
-      let data: any = {};
+      let data: { error?: string } = {};

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/Onboarding.tsx` at line 147, Replace the loose any-typed
variable `data` in the Onboarding component with a concrete type matching the
expected response shape used later (e.g., define an interface like
`OnboardingResponse { /* fields used at usage site */ }` or use
`Partial<OnboardingResponse>` if initially empty) and change the declaration
`let data: any = {};` to `let data: OnboardingResponse` or `let data:
Partial<OnboardingResponse> = {}`; ensure the declared fields match the
properties accessed around the later usage (the code referencing `data` in the
Onboarding component).
frontend/components/ui/collapsible.tsx (1)

41-41: Pair aria-expanded with aria-controls for a complete disclosure relationship.

Nice accessibility improvement. Consider adding an id on the content container and aria-controls on this trigger so assistive tech can map controller → region explicitly.

🤖 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 41, The trigger currently
sets aria-expanded={isExpanded} but lacks aria-controls; add a stable id for the
collapsible content region (e.g., using React's useId or a generated contentId)
and assign that id to the content container element, then add
aria-controls={contentId} alongside aria-expanded on the trigger so the trigger
element (where aria-expanded is used) explicitly references the controlled
content region; update any props or state in the
Collapsible/CollapsibleTrigger/CollapsibleContent components (names from this
file) to pass and consume the contentId.
frontend/components/Configure.tsx (2)

331-363: Solid accessibility implementation for focus trapping.

The focus trap correctly handles:

  • Initial focus on the primary action button
  • Tab cycling within the dialog
  • Escape key to close
  • Focus restoration on unmount

One minor observation: Line 361's focus restoration (clearTriggerButtonRef.current ?? previousFocus) inverts the typical pattern where you'd prioritize the captured previousFocus. Since previousFocus already falls back to the trigger button (Line 333), you could simplify to just previousFocus?.focus().

♻️ Optional simplification
     return () => {
       window.cancelAnimationFrame(raf);
       window.removeEventListener('keydown', handleKeyDown);
-      (clearTriggerButtonRef.current ?? previousFocus)?.focus();
+      previousFocus?.focus();
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` around lines 331 - 363, The cleanup in the
useEffect focus-trap currently restores focus using
(clearTriggerButtonRef.current ?? previousFocus)?.focus(), which inverts the
intended priority because previousFocus already falls back to
clearTriggerButtonRef; update the cleanup to call previousFocus?.focus() instead
so the originally captured element (previousFocus) is prioritized when
unmounting; modify the return cleanup inside the useEffect that references
previousFocus, clearTriggerButtonRef, and clearConfirm/clearCancel refs
accordingly.

160-163: Avoid any in error handling.

The (error as any)?.name pattern can be replaced with a type guard to satisfy strict TypeScript requirements.

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

🔧 Suggested type-safe alternative
     } catch (error) {
-      if ((error as any)?.name === 'AbortError') {
+      if (error instanceof DOMException && error.name === 'AbortError') {
         return; // newer request superseded this one
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` around lines 160 - 163, Replace the unsafe
`(error as any)?.name === 'AbortError'` check with a proper type guard: add an
isAbortError(err: unknown): boolean function that returns true when err is a
DOMException or Error and its name equals 'AbortError', then use `if
(isAbortError(error)) return;` inside the catch in the Configure component (the
catch block around the request). This removes `any` and satisfies TypeScript
strict typing while preserving the abort-detection behavior.
src/routes/peers.ts (2)

131-137: Remove duplicate fallback persistence branch.

Both branches call saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null) identically, so the conditional is unnecessary.

Suggested refactor
     const success = await updateUserPeerPolicies(userId, sanitizedPolicies);
     if (!success) {
       console.warn('Failed to persist peer policies for user', userId);
-      await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null);
-    } else {
-      await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null);
     }
+    await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/peers.ts` around lines 131 - 137, The conditional around
saveFallbackPeerPolicies is redundant: after calling
updateUserPeerPolicies(userId, sanitizedPolicies) both the true and false
branches call saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null).
Remove the duplicate branch by calling saveFallbackPeerPolicies(hasPolicies ?
sanitizedPolicies : null) once after the updateUserPeerPolicies call and keep
the existing warning log when success is false (console.warn('Failed to persist
peer policies for user', userId)); ensure variables updateUserPeerPolicies,
saveFallbackPeerPolicies, userId, sanitizedPolicies and hasPolicies are used as
before.

646-649: Reuse safeNormalizePubkey in ping error paths.

Both catch blocks reimplement normalize+try/catch. Using safeNormalizePubkey keeps behavior consistent and reduces duplication.

Suggested refactor
-        try {
-          const normalizedPubkey = normalizePubkey(pubkey);
-          context.peerStatuses.set(normalizedPubkey, updatedStatus);
-        } catch {}
+        const normalizedPubkey = safeNormalizePubkey(pubkey);
+        if (normalizedPubkey) {
+          context.peerStatuses.set(normalizedPubkey, updatedStatus);
+        }
-    try {
-      const normalizedPubkey = normalizePubkey(target);
-      context.peerStatuses.set(normalizedPubkey, updatedStatus);
-    } catch {}
+    const normalizedPubkey = safeNormalizePubkey(target);
+    if (normalizedPubkey) {
+      context.peerStatuses.set(normalizedPubkey, updatedStatus);
+    }

Also applies to: 724-727

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

In `@src/routes/peers.ts` around lines 646 - 649, Replace the try/catch around
normalizePubkey in the ping error paths with the existing safeNormalizePubkey
helper: call safeNormalizePubkey(pubkey) and if it returns a falsy/undefined
normalized value, bail out (or skip setting status), otherwise use that value to
call context.peerStatuses.set(normalizedPubkey, updatedStatus); apply the same
change to both places that currently wrap normalizePubkey in try/catch (the
blocks handling the ping error paths noted around where pubkey,
context.peerStatuses and updatedStatus are used) so behavior is consistent and
duplication removed.
frontend/components/ui/tooltip.tsx (2)

41-45: Prefer a runtime fallback label when ariaLabel is missing.

At Line 41 the check only logs and still renders a focusable trigger; JS callers (or unsafe casts) can still ship an unlabeled control. Consider applying a safe fallback label for resilience.

Suggested patch
-  const ariaLabel = props.ariaLabel;
+  const ariaLabel = props.ariaLabel?.trim();
+  const resolvedAriaLabel = focusable ? (ariaLabel || 'Tooltip trigger') : undefined;

   useEffect(() => {
     if (focusable && !ariaLabel) {
       console.error('[Tooltip] focusable tooltips require ariaLabel for accessibility.');
     }
   }, [focusable, ariaLabel]);

...
-        aria-label={ariaLabel}
+        aria-label={resolvedAriaLabel}

Also applies to: 142-142

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

In `@frontend/components/ui/tooltip.tsx` around lines 41 - 45, The existing
useEffect in the Tooltip component only logs when focusable is true and
ariaLabel is missing but still renders an unlabeled focusable trigger; update
Tooltip so when focusable && !ariaLabel it computes a safe runtime fallback
(e.g., derive from the tooltip content or a generic string like "Tooltip") and
uses that fallback value as the aria-label passed to the focusable trigger
element; adjust the code paths where the trigger is rendered (the trigger
element around line ~142) to consume this computed ariaLabelFallback instead of
the possibly undefined ariaLabel, and keep the console.error for developer
visibility.

1-1: Rename this component file to PascalCase.

frontend/components/ui/tooltip.tsx should be Tooltip.tsx to match the repository convention for React component files.

As per coding guidelines, frontend/**/*.tsx: React component file names use PascalCase (e.g., Configure.tsx).

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

In `@frontend/components/ui/tooltip.tsx` at line 1, Rename the component file from
tooltip.tsx to Tooltip.tsx and ensure the component export (e.g., the React
component named Tooltip) keeps the same name; then update all imports and barrel
exports that reference './ui/tooltip' (or similar paths) to use './ui/Tooltip'
so module resolution remains correct throughout the codebase.
package.json (1)

76-82: Use exact versions in overrides for deterministic hardening.

npm best practices recommend pinning exact versions in overrides to enforce a known-good version and prevent drift. Currently, the caret ranges (^10.5.0, ^10.2.3, etc.) allow patch/minor updates, which can bypass the security/compatibility intent. The lock file confirms this—minimatch resolved to 10.2.4 instead of the ^10.2.3 range, and fast-xml-parser to 5.4.1 instead of ^5.3.8.

Suggested change
  "overrides": {
-    "glob": "^10.5.0",
-    "minimatch": "^10.2.3",
-    "js-yaml": "^4.1.1",
-    "undici": "^6.23.0",
-    "ajv": "^8.18.0",
-    "fast-xml-parser": "^5.3.8"
+    "glob": "10.5.0",
+    "minimatch": "10.2.3",
+    "js-yaml": "4.1.1",
+    "undici": "6.23.0",
+    "ajv": "8.18.0",
+    "fast-xml-parser": "5.3.8"
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 76 - 82, Update the package.json "overrides"
entries to use exact pinned versions (no caret ranges) so the override enforces
a single known-good version; replace "glob": "^10.5.0", "minimatch": "^10.2.3",
"js-yaml": "^4.1.1", "undici": "^6.23.0", "ajv": "^8.18.0", and
"fast-xml-parser": "^5.3.8" with exact versions (e.g., "glob": "10.5.0",
"minimatch": "10.2.4" or the audited/resolved version you intend to lock to)
inside the "overrides" object in package.json so npm will pin those transitive
dependencies deterministically.
src/routes/sign.ts (1)

130-134: Rate-limit header calculation looks correct, but Math.ceil is redundant.

Number.parseInt already returns an integer, so Math.ceil(retryAfterWindow) has no effect. Consider simplifying:

 const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10);
-const retryAfterSeconds = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900;
+const retryAfterSeconds = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900;

Adding retryAfterWindow > 0 also guards against negative or zero values that would be invalid for Retry-After.

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

In `@src/routes/sign.ts` around lines 130 - 134, The retry header calculation uses
Math.ceil unnecessarily and doesn't guard against non-positive values; update
the logic around retryAfterWindow/retryAfterSeconds so retryAfterSeconds is an
integer derived from Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10)
and replaced with a positive check (e.g., if retryAfterWindow > 0 then use
retryAfterWindow else fall back to 900) before converting to a string for the
'Retry-After' header in the Response.json call; adjust the code that sets
retryAfterSeconds and the headers passed to Response.json accordingly.
src/routes/nip04.ts (1)

90-96: Consider using resetAt from rate limit result for accurate Retry-After.

The checkRateLimit function returns resetAt (see src/routes/auth.ts lines 473-496), which represents the actual reset timestamp. Using this would be more accurate than re-parsing RATE_LIMIT_WINDOW:

 const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp });
 if (!rate.allowed) {
-  const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10)
-  const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900
+  const retryAfter = rate.resetAt 
+    ? Math.max(1, Math.ceil((rate.resetAt - Date.now()) / 1000))
+    : 900;
   return Response.json({ error: 'Rate limit exceeded. Try again later.' }, {
     status: 429,
     headers: { ...headers, 'Retry-After': retryAfter.toString() }
   })
 }

This applies to src/routes/sign.ts as well.

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

In `@src/routes/nip04.ts` around lines 90 - 96, The rate-limit handler currently
computes Retry-After from RATE_LIMIT_WINDOW; update the block that checks
rate.allowed to use the checkRateLimit result's resetAt timestamp (e.g., the
value returned alongside rate.allowed) to compute a precise Retry-After:
calculate seconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000)), fall
back to the existing RATE_LIMIT_WINDOW parsing if resetAt is missing or invalid,
and send that seconds value in the 'Retry-After' header on the Response.json
call (same change should be applied to the analogous logic in sign.ts).
tests/routes/admin.whoami.session.spec.ts (1)

45-47: Test teardown no longer closes the database.

The afterAll now only stops auth cleanup but doesn't call database.default.close(). While the temp directory is removed, the SQLite connection may remain open. If this causes resource warnings or flaky tests, consider adding:

 afterAll(async () => {
   try { const auth = await import('../../src/routes/auth'); auth.stopAuthCleanup(); } catch {}
+  try { const db = await import('../../src/db/database'); db.default.close(); } catch {}
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/admin.whoami.session.spec.ts` around lines 45 - 47, The test
teardown in afterAll currently only calls auth.stopAuthCleanup() and doesn't
close the SQLite connection; update the afterAll to also import and call
database.default.close() (or the appropriate close method on the database
export) after stopping auth cleanup, and wrap both calls in try/catch to swallow
errors during teardown so tests don't fail due to cleanup race conditions;
reference the existing afterAll, auth.stopAuthCleanup(), and
database.default.close() when making the change.
src/routes/onboarding.ts (1)

271-274: Limited special character set may restrict user password choices.

The regex only accepts @$!%*?& as special characters, rejecting common characters like #, ^, -, _, (, ), etc. This could frustrate users who use password managers generating broader character sets.

Consider whether this restriction is intentional for compatibility reasons or if the set should be expanded:

-const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/;
+const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{}|;':",./<>?]).*$/;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/onboarding.ts` around lines 271 - 274, PASSWORD_REGEX currently
requires one of @$!%*?& which unnecessarily limits allowed special characters;
update the regex used in onboarding.ts (PASSWORD_REGEX) to require at least one
non-alphanumeric (but allow whitespace) instead of restricting to that small
set—for example replace the lookahead (?=.*[@$!%*?&]) with (?=.*[^A-Za-z0-9\s])
so any punctuation/symbol counts as a special character while still preserving
whitespace and keeping the existing letter/number lookaheads.
src/routes/admin.ts (1)

456-467: Consider hoisting knownAdminPaths to module scope.

The Set is recreated on every request that falls through the switch. Moving it to module scope avoids repeated allocations:

+const KNOWN_ADMIN_PATHS = new Set([
+  '/api/admin/whoami',
+  '/api/admin/users',
+  '/api/admin/users/delete',
+  '/api/admin/api-keys',
+  '/api/admin/api-keys/revoke',
+  '/api/admin/status',
+]);
+
 export async function handleAdminRoute(...) {
   // ...
-    const knownAdminPaths = new Set([...]);
-    const isKnownPath = knownAdminPaths.has(url.pathname);
+    const isKnownPath = KNOWN_ADMIN_PATHS.has(url.pathname);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/admin.ts` around lines 456 - 467, Hoist the constant Set currently
created per-request by moving the declaration of knownAdminPaths out of the
request handler into module scope (top of the file) so it is instantiated once;
keep the same contents and usage (referenced by isKnownPath and the Response
block) and ensure it remains a const to preserve immutability and avoid repeated
allocations on each request.
src/routes/nip44.ts (1)

41-45: Verify Retry-After header calculation when env var is unset.

When RATE_LIMIT_WINDOW is undefined, parseInt(undefined) returns NaN, and Math.ceil(NaN) produces NaN, resulting in "NaN" in the header. Consider adding a fallback:

-      headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() }
+      headers: { ...headers, 'Retry-After': String(Math.ceil(Number(process.env.RATE_LIMIT_WINDOW) || 900)) }

The admin.ts file in this PR implements a more robust pattern with explicit validation—consider aligning for consistency.

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

In `@src/routes/nip44.ts` around lines 41 - 45, The Retry-After header generation
can produce "NaN" when process.env.RATE_LIMIT_WINDOW is undefined; update the
calculation in the rate-limit response to validate and default the window (e.g.,
read const windowSec = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10);
if (!Number.isFinite(windowSec)) use a safe default like 900) then set
'Retry-After': Math.ceil(windowSec).toString(); ensure you update the block that
constructs the Response.json (referencing headers and
process.env.RATE_LIMIT_WINDOW) so it uses the validated/defaulted window value.
tests/routes/helpers/script-runner.ts (1)

41-51: Good secret redaction in error previews.

The regex patterns in toSafePreview catch common secret patterns:

  • Key-value pairs for admin_secret, session_secret, password, api_key, token
  • Bearer tokens

This prevents accidental secret exposure in test failure logs.

Consider adding coverage for derived_key or encryption_key patterns if those appear in error output, though the current patterns likely cover most scenarios.

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

In `@tests/routes/helpers/script-runner.ts` around lines 41 - 51, Update
toSafePreview to also redact keys named derived_key and encryption_key
(including variants like derived-key, encryption-key, with or without quotes) by
adding them to the first secret-key alternation in the regex used to replace
key-value secrets; keep the existing flags and replacement string '$1<redacted>'
and preserve trimming/truncation behavior so other logic (compact,
ERROR_PREVIEW_MAX_CHARS) remains unchanged.
🤖 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:
- Line 32: CI pins Bun via the bun-version key (e.g., in release.yml and ci.yml)
but the main Dockerfile and Umbrel Dockerfile use different Bun images; update
either the CI workflows' bun-version to match the main Dockerfile's Bun (1.3.10)
and the Umbrel Dockerfile to the same version, or conversely update the
Dockerfiles to the CI pinned version—ensure bun-version in release.yml and
ci.yml and the FROM lines in the Dockerfile and Umbrel Dockerfile are
consistent, or add a clear comment documenting an intentional, justified
divergence.

In `@frontend/components/NIP46.tsx`:
- Around line 244-245: Before starting the async operations that call
fetch(`/api/nip46/sessions/${pubkey}`) (the try block at the start of the
session/revoke/update flows), clear any stale error state by resetting
connectError via setConnectError(null) (or the equivalent state setter)
immediately before the try. Do this in both locations referenced (the try around
the fetch using pubkey at line ~244 and the other operation at ~262-263) so
previous errors are not shown during a new attempt.
- Around line 245-246: The code interpolates raw pubkey into API paths (e.g.,
the fetch call using "/api/nip46/sessions/${pubkey}" and the second similar
call) which can produce malformed URLs; fix by encoding the pubkey before
inserting into the path (use encodeURIComponent(pubkey)) wherever
"/api/nip46/sessions/${pubkey}" is built (search for fetch calls and functions
in NIP46.tsx that reference that string) so both the DELETE and the other
request use the encoded value.

In `@frontend/components/Signer.tsx`:
- Around line 947-951: The code currently places raw backend/text error details
(the variable detail and message derived from response.text() or thrown errors)
directly into the UI via setCredentialSaveError; instead, keep the detailed text
only in internal logs (console.error or a logger) and set a generic, user-safe
message (e.g., "Unable to save relays. Please try again.") into
setCredentialSaveError; update all occurrences that follow the same pattern (the
blocks using response.text().catch(() => '') and setCredentialSaveError in this
file/Signer.tsx) to log the full detail internally and pass the sanitized
message to the UI.

In `@frontend/components/ui/event-log.tsx`:
- Line 40: The state declaration for isExpanded should include an explicit
boolean type to satisfy TS strict-mode rules; update the useState call that
initializes isExpanded (currently using useState(hideHeader)) to use
useState<boolean>(hideHeader) so isExpanded and setIsExpanded are typed as
boolean and (boolean)=>void respectively, matching the project guideline and the
adjacent explicitly typed state on the next line.

In `@src/const.ts`:
- Around line 26-27: The HOST_PORT parsing is too lenient: stop using parseInt
and instead validate process.env['HOST_PORT'] as a strictly numeric integer
string, convert with Number or parseInt only after verifying /^\d+$/ and then
ensure the resulting port is within valid bounds (1–65535); update the logic
that produces parsedHostPort and export HOST_PORT so malformed strings (e.g.
"8002abc"), empty values, zero, or out-of-range numbers fall back to the default
8002 and only assign a validated integer to HOST_PORT.

In `@src/db/database.ts`:
- Around line 12-21: The current isEnvPathFile heuristic misclassifies
dot-containing directory names as files; update the logic in the
isEnvPathFile/DB_DIR/DB_FILE block so a value is considered a file only when it
has an explicit extension or is explicitly a .db file (e.g. replace the
basename-includes-dot check with a stricter test such as path.extname(envPath)
!== '' || envPath.endsWith('.db')), keeping DB_DIR and DB_FILE assignment
semantics using that updated isEnvPathFile flag and still falling back to
defaultDbDir when envPath is falsy.

In `@src/db/migrator.ts`:
- Line 31: The lexical check using dir.startsWith(projectRoot + path.sep) in
src/db/migrator.ts is vulnerable to symlink traversal; canonicalize both paths
with fs.realpathSync (or fs.promises.realpath) before comparing to ensure
symlinks are resolved, but preserve current behavior for missing dirs by first
checking existence (fs.existsSync or try stat) and only calling realpathSync
inside a try/catch; update the comparison logic that references dir,
projectRoot, path.sep and the startsWith check to use the resolvedRealDir and
resolvedProjectRoot values so non-existent directories still return [] and
realpath errors are handled gracefully.

In `@src/node/manager.ts`:
- Around line 2188-2193: The patch replaces loose any typings that break strict
mode: change the occurrences of "any" used for poolProto, originalSubscribeMany,
and the normalizedSubscribeMany function parameters to "unknown" (or tighter
specific types) and then narrow/cast where needed; update SimplePool?.prototype
typing to a suitable unknown | Record<string, unknown> and type the
originalSubscribeMany variable as unknown before checking typeof, and give
normalizedSubscribeMany parameters (relays, filters, params, this) unknown types
and narrow them inside the function so the subscribeMany override compiles under
strict TS without noImplicitAny errors.

In `@src/routes/index.ts`:
- Around line 231-234: The 401 response is returning the full auth status object
via getAuthStatus(), but the API expects authMethods as a string[]; update the
Response.json call so authMethods returns only the array of method names (e.g.,
use getAuthStatus().methods or map the result to a string[]), and ensure the
value matches the documented Unauthorized response shape and typing for
authMethods in the handler that builds the 401 (the Response.json call inside
the route that uses authResult and getAuthStatus).

In `@src/routes/peers.ts`:
- Around line 125-128: The call to updateUserPeerPolicies(userId, null) is
currently ignored so failures silently leave stale DB state; modify the peers
route to explicitly handle failures from updateUserPeerPolicies and
saveFallbackPeerPolicies by awaiting their results and checking for error/false
return values or catching thrown exceptions, and if either fails return an error
response (or throw) instead of continuing—update the branch around the condition
that calls updateUserPeerPolicies and saveFallbackPeerPolicies to propagate or
surface errors from those functions (refer to updateUserPeerPolicies and
saveFallbackPeerPolicies in your changes).
- Around line 370-372: The catch block that builds the HTTP response currently
includes raw error text in the warnings array (see the catch (error) handler and
the Response.json call), which can leak internal parsing details; change it to
avoid returning the exception message to clients — instead return a generic
error/warning string (e.g. "Invalid credentials format") and move the detailed
error.message into server-side logging (use processLogger or console.error
inside the same catch). Ensure the Response.json payload uses only the generic
text and keep status 400 and existing headers unchanged.

In `@src/utils/rate-limiter.ts`:
- Around line 252-258: The identifier extraction using
key.slice(key.lastIndexOf(':') + 1) fails for identifiers containing ":" (e.g.,
IPv6); update the logic that iterates this.fallbackStore.keys() (the keys loop
with const keyIdentifier = ...) to split on the first colon instead of the last
one—e.g., find the first ':' via indexOf or use key.split(':', 2) and take the
second part—so keyIdentifier equals the original identifier even when it
contains colons, then continue deleting matching keys as before.

---

Nitpick comments:
In `@frontend/components/Configure.tsx`:
- Around line 331-363: The cleanup in the useEffect focus-trap currently
restores focus using (clearTriggerButtonRef.current ?? previousFocus)?.focus(),
which inverts the intended priority because previousFocus already falls back to
clearTriggerButtonRef; update the cleanup to call previousFocus?.focus() instead
so the originally captured element (previousFocus) is prioritized when
unmounting; modify the return cleanup inside the useEffect that references
previousFocus, clearTriggerButtonRef, and clearConfirm/clearCancel refs
accordingly.
- Around line 160-163: Replace the unsafe `(error as any)?.name ===
'AbortError'` check with a proper type guard: add an isAbortError(err: unknown):
boolean function that returns true when err is a DOMException or Error and its
name equals 'AbortError', then use `if (isAbortError(error)) return;` inside the
catch in the Configure component (the catch block around the request). This
removes `any` and satisfies TypeScript strict typing while preserving the
abort-detection behavior.

In `@frontend/components/Onboarding.tsx`:
- Line 147: Replace the loose any-typed variable `data` in the Onboarding
component with a concrete type matching the expected response shape used later
(e.g., define an interface like `OnboardingResponse { /* fields used at usage
site */ }` or use `Partial<OnboardingResponse>` if initially empty) and change
the declaration `let data: any = {};` to `let data: OnboardingResponse` or `let
data: Partial<OnboardingResponse> = {}`; ensure the declared fields match the
properties accessed around the later usage (the code referencing `data` in the
Onboarding component).

In `@frontend/components/ui/collapsible.tsx`:
- Line 41: The trigger currently sets aria-expanded={isExpanded} but lacks
aria-controls; add a stable id for the collapsible content region (e.g., using
React's useId or a generated contentId) and assign that id to the content
container element, then add aria-controls={contentId} alongside aria-expanded on
the trigger so the trigger element (where aria-expanded is used) explicitly
references the controlled content region; update any props or state in the
Collapsible/CollapsibleTrigger/CollapsibleContent components (names from this
file) to pass and consume the contentId.

In `@frontend/components/ui/tooltip.tsx`:
- Around line 41-45: The existing useEffect in the Tooltip component only logs
when focusable is true and ariaLabel is missing but still renders an unlabeled
focusable trigger; update Tooltip so when focusable && !ariaLabel it computes a
safe runtime fallback (e.g., derive from the tooltip content or a generic string
like "Tooltip") and uses that fallback value as the aria-label passed to the
focusable trigger element; adjust the code paths where the trigger is rendered
(the trigger element around line ~142) to consume this computed
ariaLabelFallback instead of the possibly undefined ariaLabel, and keep the
console.error for developer visibility.
- Line 1: Rename the component file from tooltip.tsx to Tooltip.tsx and ensure
the component export (e.g., the React component named Tooltip) keeps the same
name; then update all imports and barrel exports that reference './ui/tooltip'
(or similar paths) to use './ui/Tooltip' so module resolution remains correct
throughout the codebase.

In `@package.json`:
- Around line 76-82: Update the package.json "overrides" entries to use exact
pinned versions (no caret ranges) so the override enforces a single known-good
version; replace "glob": "^10.5.0", "minimatch": "^10.2.3", "js-yaml": "^4.1.1",
"undici": "^6.23.0", "ajv": "^8.18.0", and "fast-xml-parser": "^5.3.8" with
exact versions (e.g., "glob": "10.5.0", "minimatch": "10.2.4" or the
audited/resolved version you intend to lock to) inside the "overrides" object in
package.json so npm will pin those transitive dependencies deterministically.

In `@src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql`:
- Around line 41-43: Repeated LENGTH(CAST(... AS BLOB)) expressions for relays,
policy_methods, and policy_kinds should be computed once in a CTE and then
referenced in the main SELECT to avoid duplication and drift; add a WITH block
(e.g., audit_blob_sizes) that selects id plus computed relay_size, methods_size,
kinds_size using LENGTH(CAST(column AS BLOB)) and then join or select from that
CTE in place of repeating the expressions in the existing SELECT (referencing
the same column names: relays, policy_methods, policy_kinds and the output
aliases relay_size, methods_size, kinds_size); apply the same CTE approach to
the other duplicated regions noted (lines ~46, 52–54, 60–62).

In `@src/routes/admin.ts`:
- Around line 456-467: Hoist the constant Set currently created per-request by
moving the declaration of knownAdminPaths out of the request handler into module
scope (top of the file) so it is instantiated once; keep the same contents and
usage (referenced by isKnownPath and the Response block) and ensure it remains a
const to preserve immutability and avoid repeated allocations on each request.

In `@src/routes/nip04.ts`:
- Around line 90-96: The rate-limit handler currently computes Retry-After from
RATE_LIMIT_WINDOW; update the block that checks rate.allowed to use the
checkRateLimit result's resetAt timestamp (e.g., the value returned alongside
rate.allowed) to compute a precise Retry-After: calculate seconds = Math.max(0,
Math.ceil((resetAt - Date.now()) / 1000)), fall back to the existing
RATE_LIMIT_WINDOW parsing if resetAt is missing or invalid, and send that
seconds value in the 'Retry-After' header on the Response.json call (same change
should be applied to the analogous logic in sign.ts).

In `@src/routes/nip44.ts`:
- Around line 41-45: The Retry-After header generation can produce "NaN" when
process.env.RATE_LIMIT_WINDOW is undefined; update the calculation in the
rate-limit response to validate and default the window (e.g., read const
windowSec = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10); if
(!Number.isFinite(windowSec)) use a safe default like 900) then set
'Retry-After': Math.ceil(windowSec).toString(); ensure you update the block that
constructs the Response.json (referencing headers and
process.env.RATE_LIMIT_WINDOW) so it uses the validated/defaulted window value.

In `@src/routes/onboarding.ts`:
- Around line 271-274: PASSWORD_REGEX currently requires one of @$!%*?& which
unnecessarily limits allowed special characters; update the regex used in
onboarding.ts (PASSWORD_REGEX) to require at least one non-alphanumeric (but
allow whitespace) instead of restricting to that small set—for example replace
the lookahead (?=.*[@$!%*?&]) with (?=.*[^A-Za-z0-9\s]) so any
punctuation/symbol counts as a special character while still preserving
whitespace and keeping the existing letter/number lookaheads.

In `@src/routes/peers.ts`:
- Around line 131-137: The conditional around saveFallbackPeerPolicies is
redundant: after calling updateUserPeerPolicies(userId, sanitizedPolicies) both
the true and false branches call saveFallbackPeerPolicies(hasPolicies ?
sanitizedPolicies : null). Remove the duplicate branch by calling
saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null) once after the
updateUserPeerPolicies call and keep the existing warning log when success is
false (console.warn('Failed to persist peer policies for user', userId)); ensure
variables updateUserPeerPolicies, saveFallbackPeerPolicies, userId,
sanitizedPolicies and hasPolicies are used as before.
- Around line 646-649: Replace the try/catch around normalizePubkey in the ping
error paths with the existing safeNormalizePubkey helper: call
safeNormalizePubkey(pubkey) and if it returns a falsy/undefined normalized
value, bail out (or skip setting status), otherwise use that value to call
context.peerStatuses.set(normalizedPubkey, updatedStatus); apply the same change
to both places that currently wrap normalizePubkey in try/catch (the blocks
handling the ping error paths noted around where pubkey, context.peerStatuses
and updatedStatus are used) so behavior is consistent and duplication removed.

In `@src/routes/sign.ts`:
- Around line 130-134: The retry header calculation uses Math.ceil unnecessarily
and doesn't guard against non-positive values; update the logic around
retryAfterWindow/retryAfterSeconds so retryAfterSeconds is an integer derived
from Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) and replaced
with a positive check (e.g., if retryAfterWindow > 0 then use retryAfterWindow
else fall back to 900) before converting to a string for the 'Retry-After'
header in the Response.json call; adjust the code that sets retryAfterSeconds
and the headers passed to Response.json accordingly.

In `@src/routes/update.ts`:
- Line 43: The code currently hard-codes prerelease behavior via the constant
ALLOW_PRERELEASE_UPDATES; make this a runtime-configurable boolean by reading an
environment/config value (e.g., process.env.ALLOW_PRERELEASE_UPDATES) parsed to
boolean with a safe default of false, replace the const declaration with that
runtime value, and update any places that reference ALLOW_PRERELEASE_UPDATES
(the prerelease-check logic in this file — around the update handling branches
that currently use that constant) to use the new runtime-configured variable so
toggling prerelease policy requires only an env change rather than a code
change.

In `@tests/routes/admin.whoami.session.spec.ts`:
- Around line 45-47: The test teardown in afterAll currently only calls
auth.stopAuthCleanup() and doesn't close the SQLite connection; update the
afterAll to also import and call database.default.close() (or the appropriate
close method on the database export) after stopping auth cleanup, and wrap both
calls in try/catch to swallow errors during teardown so tests don't fail due to
cleanup race conditions; reference the existing afterAll,
auth.stopAuthCleanup(), and database.default.close() when making the change.

In `@tests/routes/helpers/script-runner.ts`:
- Around line 41-51: Update toSafePreview to also redact keys named derived_key
and encryption_key (including variants like derived-key, encryption-key, with or
without quotes) by adding them to the first secret-key alternation in the regex
used to replace key-value secrets; keep the existing flags and replacement
string '$1<redacted>' and preserve trimming/truncation behavior so other logic
(compact, ERROR_PREVIEW_MAX_CHARS) remains unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 12698ac and f23128a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (71)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/umbrel-dev.yml
  • .gitignore
  • Dockerfile
  • docs/DEPLOY.md
  • docs/openapi/README.md
  • docs/openapi/openapi.json
  • docs/openapi/openapi.yaml
  • env.example
  • frontend/App.tsx
  • frontend/components/ApiKeys.tsx
  • frontend/components/Configure.tsx
  • frontend/components/Login.tsx
  • frontend/components/NIP46.tsx
  • frontend/components/Onboarding.tsx
  • frontend/components/Recover.tsx
  • frontend/components/Signer.tsx
  • frontend/components/nip46/Permissions.tsx
  • frontend/components/nip46/RelaySettings.tsx
  • frontend/components/nip46/Requests.tsx
  • frontend/components/ui/card.tsx
  • frontend/components/ui/collapsible.tsx
  • frontend/components/ui/event-log.tsx
  • frontend/components/ui/icon-button.tsx
  • frontend/components/ui/input-with-validation.tsx
  • frontend/components/ui/peer-list.tsx
  • frontend/components/ui/tooltip.tsx
  • frontend/types/index.ts
  • llm/implementation/node-lifecycle-implementation.md
  • llm/implementation/umbrel-implementation.md
  • package.json
  • scripts/fetch-swagger-ui.mjs
  • scripts/patch-zod-compat.mjs
  • scripts/release.sh
  • src/class/relay.test.ts
  • src/class/relay.ts
  • src/config/crypto.ts
  • src/const.ts
  • src/db/database.ts
  • src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql
  • src/db/migrator.ts
  • src/db/nip46.ts
  • src/db/ui-event-log.ts
  • src/nip46/service.ts
  • src/node/manager.ts
  • src/routes/admin.ts
  • src/routes/auth.ts
  • src/routes/env.ts
  • src/routes/index.ts
  • src/routes/nip04.ts
  • src/routes/nip44.ts
  • src/routes/nip46.ts
  • src/routes/onboarding.ts
  • src/routes/peers.ts
  • src/routes/sign.ts
  • src/routes/status.ts
  • src/routes/update.ts
  • src/routes/user.ts
  • src/routes/utils.test.ts
  • src/routes/utils.ts
  • src/server.ts
  • src/utils/rate-limiter.ts
  • tests/routes/admin.whoami.session.spec.ts
  • tests/routes/auth.rehydrate.spec.ts
  • tests/routes/body-limit.spec.ts
  • tests/routes/env.db-mode.spec.ts
  • tests/routes/helpers/script-runner.spec.ts
  • tests/routes/helpers/script-runner.ts
  • tests/routes/onboarding.spec.ts
  • tests/routes/status-env.test.ts

Comment thread .github/workflows/release.yml Outdated
Comment thread frontend/components/NIP46.tsx Outdated
Comment thread frontend/components/NIP46.tsx Outdated
Comment thread frontend/components/Signer.tsx
Comment thread frontend/components/ui/event-log.tsx Outdated
Comment thread src/node/manager.ts Outdated
Comment thread src/routes/index.ts
Comment thread src/routes/peers.ts
Comment thread src/routes/peers.ts Outdated
Comment thread src/utils/rate-limiter.ts
@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Feb 27, 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 (1)
docs/openapi/openapi.json (1)

3565-3594: ⚠️ Potential issue | 🟠 Major

Potential breaking API change: client_pubkey renamed to pubkey without compatibility path.

At Line 3565 and Line 3593, requiring only pubkey can break existing NIP-46 clients still sending client_pubkey. If this isn’t a versioned breaking change, keep a deprecated alias during transition.

Suggested transitional schema patch
       "Nip46SessionInput": {
         "type": "object",
         "description": "Client-supplied fields when creating or updating a NIP‑46 session",
         "properties": {
           "pubkey": {
             "type": "string",
             "description": "Client public key (hex encoded)"
+          },
+          "client_pubkey": {
+            "type": "string",
+            "deprecated": true,
+            "description": "Deprecated alias for pubkey; retained for backward compatibility"
           },
           "status": {
             "type": "string",
             "enum": [
               "pending",
               "active",
               "revoked"
             ],
             "description": "Desired session status (defaults to pending)"
           },
           "profile": {
             "$ref": "#/components/schemas/Nip46Profile"
           },
           "relays": {
             "type": "array",
             "items": {
               "type": "string"
             },
             "description": "Preferred relays for the session"
           },
           "policy": {
             "$ref": "#/components/schemas/Nip46Policy"
           }
         },
-        "required": [
-          "pubkey"
-        ]
+        "anyOf": [
+          { "required": ["pubkey"] },
+          { "required": ["client_pubkey"] }
+        ]
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` around lines 3565 - 3594, Add a deprecated alias
property so existing clients sending client_pubkey continue working: in the same
object that defines "pubkey" add a "client_pubkey" property with the same schema
(type: string, description: "DEPRECATED: client_pubkey (hex encoded) — use
pubkey") and then replace the single "required": ["pubkey"] constraint with a
oneOf/anyOf that accepts either required ["pubkey"] or required
["client_pubkey"] (or otherwise accept either field), preserving "pubkey" as the
preferred name while allowing the deprecated "client_pubkey" during transition.
♻️ Duplicate comments (1)
src/db/database.ts (1)

12-15: ⚠️ Potential issue | 🟡 Minor

DB_PATH file detection still misclassifies dot-named directories.

Line 14 still treats values like /data/my.folder as files because path.extname(...) !== '', so directory targets with dots can resolve incorrectly.

🔧 Safer extension-based file detection
+const DB_FILE_EXTENSIONS = new Set(['.db', '.sqlite', '.sqlite3'])
+const envExt = envPath ? path.extname(envPath).toLowerCase() : ''
 const isEnvPathFile = !!envPath && (
-  envPath.endsWith('.db') ||
-  (path.extname(envPath) !== '' && !envPath.endsWith(path.sep))
+  !envPath.endsWith(path.sep) &&
+  DB_FILE_EXTENSIONS.has(envExt)
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 12 - 15, The current isEnvPathFile logic
misclassifies dot-containing directory names (e.g., /data/my.folder) as files
because path.extname(envPath) !== '' is true; update the condition used in
isEnvPathFile to only treat path.extname(envPath) as indicating a file when the
final path segment (path.basename(envPath)) does not start with a dot (to
exclude dot-named dirs) and/or when the path actually exists and
fs.statSync(envPath).isFile() confirms it's a file; modify the isEnvPathFile
computation to include a check on path.basename(envPath) (and optionally use
fs.existsSync + fs.statSync for existing paths) so envPath like
'/data/my.folder' is not misclassified while still recognizing real files and
.db endings.
🧹 Nitpick comments (14)
frontend/components/ui/input-with-validation.tsx (1)

1-55: Rename component file to PascalCase to match repo convention.

Please rename frontend/components/ui/input-with-validation.tsx to frontend/components/ui/InputWithValidation.tsx and update imports accordingly.

As per coding guidelines, frontend/**/*.tsx: React component file names use PascalCase (e.g., Configure.tsx).

🤖 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 1 - 55, Rename
the file to PascalCase and update all imports: move
frontend/components/ui/input-with-validation.tsx →
frontend/components/ui/InputWithValidation.tsx, keep the component name
InputWithValidation and its named export unchanged, then search the repo for any
imports using "input-with-validation" and update them to import {
InputWithValidation } from "./InputWithValidation" (or the correct relative
path); also update any barrel/index files or tests that re-export or reference
the old filename to use the new filename.
src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql (1)

41-43: Reduce repeated size expressions with a CTE.

The same COALESCE(LENGTH(CAST(... AS BLOB)), 0) expressions are repeated many times. Consider computing them once, then reusing aliases for total_size and risk checks.

♻️ Suggested refactor
-SELECT
-  id,
-  user_id,
-  client_pubkey,
-  CASE WHEN relays IS NOT NULL THEN LENGTH(CAST(relays AS BLOB)) ELSE 0 END as relay_size,
-  CASE WHEN policy_methods IS NOT NULL THEN LENGTH(CAST(policy_methods AS BLOB)) ELSE 0 END as methods_size,
-  CASE WHEN policy_kinds IS NOT NULL THEN LENGTH(CAST(policy_kinds AS BLOB)) ELSE 0 END as kinds_size,
-  CASE
-    WHEN relays IS NOT NULL OR policy_methods IS NOT NULL OR policy_kinds IS NOT NULL
-    THEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) + COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) + COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0)
-    ELSE 0
-  END as total_size,
+WITH sized AS (
+  SELECT
+    id,
+    user_id,
+    client_pubkey,
+    COALESCE(LENGTH(CAST(relays AS BLOB)), 0) AS relay_size,
+    COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) AS methods_size,
+    COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) AS kinds_size
+  FROM nip46_sessions
+)
+SELECT
+  id,
+  user_id,
+  client_pubkey,
+  relay_size,
+  methods_size,
+  kinds_size,
+  relay_size + methods_size + kinds_size AS total_size,
   CASE
-    WHEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) > 10000
-      OR COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) > 10000
-      OR COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) > 10000
+    WHEN relay_size > 10000 OR methods_size > 10000 OR kinds_size > 10000
     THEN 1
     ELSE 0
   END as has_risk_old_limit,
   CASE
-    WHEN COALESCE(LENGTH(CAST(relays AS BLOB)), 0) > 50000
-      OR COALESCE(LENGTH(CAST(policy_methods AS BLOB)), 0) > 50000
-      OR COALESCE(LENGTH(CAST(policy_kinds AS BLOB)), 0) > 50000
+    WHEN relay_size > 50000 OR methods_size > 50000 OR kinds_size > 50000
     THEN 1
     ELSE 0
   END as has_risk_new_limit
-FROM nip46_sessions;
+FROM sized;

Also applies to: 46-46, 52-54, 60-62

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

In `@src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql` around lines 41 -
43, Compute the repeated BLOB length expressions once in a CTE and reuse aliases
instead of repeating COALESCE(LENGTH(CAST(... AS BLOB)), 0): add a CTE (e.g.,
sized_rows) that SELECTs the primary key (id) and computes relay_size,
methods_size, kinds_size using COALESCE(LENGTH(CAST(relays AS BLOB)),0) etc.,
then in the main query reference those aliases for per-column sizes, SUM them
into a single total_size alias, and use total_size and the sized alias names in
the risk checks and CASE expressions (replace occurrences of the repeated
LENGTH(CAST(... AS BLOB)) for relays, policy_methods, policy_kinds with the CTE
aliases).
frontend/components/ui/icon-button.tsx (1)

55-55: Minor: Trailing whitespace on export line.

There's a trailing space at the end of this line. This is a very minor nit, but removing it maintains consistent formatting.

🧹 Suggested fix
-export { IconButton, iconButtonVariants }; 
+export { IconButton, iconButtonVariants };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/icon-button.tsx` at line 55, Remove the trailing
whitespace at the end of the export statement for IconButton and
iconButtonVariants (the line exporting IconButton, iconButtonVariants) so the
export reads without any trailing space; update the export line for the
IconButton/iconButtonVariants symbols to remove the extra space and save
formatting.
frontend/components/Configure.tsx (3)

1332-1332: Minor performance consideration.

Using JSON.stringify for object comparison on every render works for this small settings object but could be optimized with useMemo and a shallow/deep equality check if the settings object grows significantly.

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

In `@frontend/components/Configure.tsx` at line 1332, The code currently compares
advancedSettings and originalAdvancedSettings using JSON.stringify on every
render; replace this with a memoized equality check to avoid expensive stringify
calls by computing a boolean like areAdvancedSettingsDirty using React's useMemo
and a suitable shallow/deep equality comparer (e.g., lodash/isEqual or a custom
shallowEqual) and then use that memoized boolean in the JSX conditional; update
references to advancedSettings and originalAdvancedSettings inside useMemo so
the memo recomputes only when those objects change.

301-310: Reminder: Address the TODO for fetching existing keyset names.

The mock empty array means the duplicate name validation at line 375 (existingNames.indexOf(nameWithoutShare)) will never prevent duplicate names.

Would you like me to help implement the server API call for fetching existing keyset names, or open an issue to track this task?

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

In `@frontend/components/Configure.tsx` around lines 301 - 310, The code currently
uses a mock empty shares array which prevents duplicate-name validation from
working; update the Configure component to call the server API instead: replace
the mock shares variable with an awaited fetch to '/api/env/shares' (or the real
endpoint), parse JSON, extract names the same way (share.name.split(' share
')[0]), and call setExistingNames(names); ensure you handle fetch errors and
non-OK responses (log or surface via state) and keep existingNames and the
duplicate check (existingNames.indexOf(nameWithoutShare)) intact so validation
works correctly.

337-369: Good accessibility implementation with a minor consideration.

The focus trap correctly handles Escape key, Tab navigation, and focus restoration. Initial focus on the confirm button (destructive action) is intentional but some accessibility guidelines suggest focusing the cancel button first for destructive actions to reduce accidental confirmations.

Consider: The codebase already has a ConfirmModal component (used at line 1360). Consolidating both confirmation modals to use the same accessibility-enhanced pattern would improve consistency and reduce duplication.

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

In `@frontend/components/Configure.tsx` around lines 337 - 369, The focus-trap
useEffect for showClearConfirm currently sets initial focus to
clearConfirmButtonRef (destructive action) and duplicates modal logic; change
the initial focus to clearCancelButtonRef to reduce accidental confirmations and
ensure focus restoration via clearTriggerButtonRef remains, and refactor
Configure's clear-confirm flow to reuse the existing ConfirmModal component
(used elsewhere as ConfirmModal) instead of duplicating the keyboard/focus logic
so both modals share the same accessibility pattern and handlers.
frontend/components/Signer.tsx (1)

996-1002: Return values from save helpers are discarded.

Both saveRelaysToUserCredentials and saveRelaysToServerEnv now return boolean success indicators, but saveRelaysToEnv ignores these values. The callers (handleAddRelay, handleRemoveRelay) optimistically update local state regardless of save outcome.

This is acceptable because errors are surfaced via credentialSaveError state, but you may want to consider reverting the local relay list on failure for a more robust UX.

♻️ Optional: propagate success/failure and revert on error
   const saveRelaysToEnv = async (relays: string[]) => {
+    let success: boolean;
     if (isDatabaseMode()) {
-      await saveRelaysToUserCredentials(relays);
+      success = await saveRelaysToUserCredentials(relays);
     } else {
-      await saveRelaysToServerEnv(relays);
+      success = await saveRelaysToServerEnv(relays);
     }
+    return success;
   };

Then in handlers, revert on failure:

const handleAddRelay = async () => {
  // ...
  const prevRelays = relayUrls;
  setRelayUrls(newRelays);
  setNewRelayUrl("");
  const ok = await saveRelaysToEnv(newRelays);
  if (!ok) setRelayUrls(prevRelays); // revert
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Signer.tsx` around lines 996 - 1002, saveRelaysToEnv
currently ignores the boolean results from saveRelaysToUserCredentials and
saveRelaysToServerEnv, so update saveRelaysToEnv to return a boolean indicating
overall success (true only if the called helper returns true), then update
callers like handleAddRelay and handleRemoveRelay to await saveRelaysToEnv and,
on false, revert the optimistic local state change (e.g., restore previous
relayUrls) and rely on credentialSaveError for the error UI; reference the
functions saveRelaysToEnv, saveRelaysToUserCredentials, saveRelaysToServerEnv,
handleAddRelay, and handleRemoveRelay when making these changes.
src/class/relay.test.ts (1)

128-175: Good coverage of limit behavior, consider asserting returned event id.

The test correctly validates that limit applies only to matched events. For additional robustness, consider asserting the specific event id returned to verify the sorting order is respected (should be matchedB with highest created_at).

💡 Optional enhancement for stronger assertion
     const eventMessages = messages.filter((msg) => msg[0] === 'EVENT');
     expect(eventMessages).toHaveLength(1);
     expect(eventMessages[0]?.[1]).toBe('sub-limit');
     expect((eventMessages[0]?.[2] as { kind?: number }).kind).toBe(1);
+    // Verify sorting returns newest matching event (matchedB has created_at=3)
+    expect((eventMessages[0]?.[2] as { id?: string }).id).toBe('e'.repeat(64));
     expect(messages).toContainEqual(['EOSE', 'sub-limit']);
🤖 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 128 - 175, Add an assertion that the
single returned event is the expected newest matched event by checking the event
id equals matchedB.id: after decoding messages and filtering eventMessages,
assert eventMessages[0]?.[2].id === matchedB.id (or equivalent) to ensure
sorting by created_at returns matchedB when limit: 1 is used; reference
variables matchedB and eventMessages in the test to locate where to add this
check.
frontend/components/nip46/RelaySettings.tsx (1)

78-84: Floating promise in onClick handler.

The async arrow function returns a Promise, but React event handlers expect void. While this works in practice, it creates an unhandled promise. For consistency with line 33 (void handleAdd()), consider wrapping in void:

Suggested fix
               <IconButton
                 variant="ghost"
                 size="sm"
                 icon={<X className="h-3 w-3" />}
                 tooltip="Remove relay"
-                onClick={async () => {
-                  try {
-                    await onRemove(relay)
-                  } catch {
-                    // error surface handled via parent `error`
-                  }
-                }}
+                onClick={() => {
+                  void (async () => {
+                    try {
+                      await onRemove(relay)
+                    } catch {
+                      // error surface handled via parent `error`
+                    }
+                  })()
+                }}
                 disabled={saving}
               />

Alternatively, extract to a named handler like handleRemove for cleaner code.

🤖 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 78 - 84, The
onClick async arrow returns a Promise causing a floating promise; change it to
match the pattern used for add (e.g. create a named handler handleRemove or call
onRemove via void to ensure the event handler returns void). Specifically,
replace the inline async onClick={() => { try { await onRemove(relay) } catch {
... } }} with either a top-level async function handleRemove(relay) that
performs try/await/onRemove and is referenced as onClick={() => void
handleRemove(relay)} or call onClick={() => { try { void onRemove(relay) } catch
{ /* keep parent error handling */ } }}, making sure to reference the existing
onRemove and relay identifiers.
env.example (1)

255-255: Consider using obviously placeholder values.

The static analysis tool flagged these as potential secrets (false positive for an example file). However, realistic-looking hex strings could be accidentally copied to production. Consider using obviously fake placeholders like:

# SESSION_SECRET=<generate-64-hex-with-openssl-rand-hex-32>

This makes it clearer that users must generate their own values rather than copy these examples.

Also applies to: 264-264, 274-274

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

In `@env.example` at line 255, Replace realistic-looking hex example values for
SESSION_SECRET (and other similar example secret values in this file) with
clearly fake placeholders so users must generate their own keys; e.g., change
the commented value to something like "#
SESSION_SECRET=<generate-64-hex-with-openssl-rand-hex-32>" (and update the other
flagged example secret entries in the same file similarly) so the examples are
obviously placeholders and not copy-pasteable secrets.
src/nip46/service.ts (1)

404-409: Add defensive close on failed connect to avoid leaked transport state.

The new catch block resets references, but Line 404 can still leave partially opened resources if connect fails mid-handshake.

♻️ Suggested cleanup hardening
     } catch (error) {
+      try {
+        await this.agent?.close?.()
+      } catch (closeError) {
+        this.log('warn', 'Failed to close NIP-46 agent after connect failure', {
+          error: this.serializeError(closeError)
+        })
+      }
       this.removeAgentListeners()
       this.agent = null
       this.signer = null
       this.started = false
       throw error
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nip46/service.ts` around lines 404 - 409, The catch block in the connect
flow resets references but doesn't close the underlying transport, risking
leaked resources; update the catch to defensively close the transport and any
partially-initialized agent before nulling fields — e.g. call and await a close
method on the transport (this.transport?.close() or this.closeTransport()) and,
if available, close the agent (this.agent?.close()) inside a try/catch, then
call removeAgentListeners(), set this.agent = null, this.signer = null,
this.transport = null, and this.started = false before rethrowing the error so
partially-opened resources are always cleaned up.
src/db/nip46.ts (1)

57-59: Consider using normalizeClientPubkey in getSession for consistency.

The new helper is used consistently in updatePolicy, updateStatus, deleteSession, and logSessionEvent, but getSession (line 256) duplicates the normalization logic inline. Using the helper would improve maintainability.

♻️ Suggested refactor
 export function getSession(userId: number | bigint, client_pubkey: string): Nip46Session | null {
-  const key = (client_pubkey || '').trim().toLowerCase()
+  const key = normalizeClientPubkey(client_pubkey)
   if (!key || !/^[0-9a-f]{64}$/.test(key)) return null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 57 - 59, The getSession function duplicates
client pubkey normalization inline instead of reusing normalizeClientPubkey;
update getSession to call normalizeClientPubkey(clientPubkey) where it currently
trims/lowers the value so behavior matches updatePolicy, updateStatus,
deleteSession, and logSessionEvent and to centralize normalization logic in
normalizeClientPubkey for maintainability.
frontend/components/Onboarding.tsx (1)

108-117: Cleanup in useEffect doesn't match the timeout source.

The cleanup clears timeoutRef.current, but this timeout is set in createUser (line 256) for the auto-redirect, not in checkStatus. This cleanup will effectively be a no-op when the component unmounts during the status check phase. While not a bug (the timeout in createUser is still cleaned up on unmount), the comment is misleading.

Consider either removing the misleading comment or moving the cleanup to a separate effect that depends on the completion step.

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

In `@frontend/components/Onboarding.tsx` around lines 108 - 117, The cleanup in
the useEffect that invokes checkStatus is misleading because it clears
timeoutRef.current (which is actually set in createUser for auto-redirect), so
update the Onboarding component to either remove or correct the comment and move
the timeout cleanup into a separate effect that watches the completion/step
state (or the variable that createUser sets) so clearTimeout(timeoutRef.current)
runs only for the timer created by createUser; reference the existing
useEffect(checkStatus), createUser, timeoutRef.current, and the completion/step
state to locate and relocate or remove the cleanup logic and adjust the comment
accordingly.
tests/routes/helpers/script-runner.ts (1)

41-51: Consider redacting Basic auth tokens in preview output.

Line [49] redacts Bearer tokens, but Basic credentials can still appear in previews.

🔒 Proposed hardening
-    .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1<redacted>');
+    .replace(/((?:bearer|basic)\s+)[a-z0-9._~+/=-]+/ig, '$1<redacted>');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/helpers/script-runner.ts` around lines 41 - 51, toSafePreview
currently redacts bearer tokens but not HTTP Basic credentials, so add a second
replacement in the toSafePreview function to detect and redact Basic auth values
(e.g., "Basic <base64>") alongside the existing bearer redaction; update the
regex used in toSafePreview (the replacement after the secret key regex) to also
match /(basic\s+)[A-Za-z0-9._-]+=*/i and replace with '$1<redacted>' (or combine
into a single regex that handles both 'bearer' and 'basic') so previews never
expose Basic auth payloads.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/components/ApiKeys.tsx`:
- Around line 227-229: When isAdminUser flips to false the code only toggles
initialAdminLoadRef.current and returns but leaves sensitive state (keys and
issuedKey) populated; update the branch that checks isAdminUser to also clear
the admin-derived state by resetting keys and issuedKey (e.g. call the state
setters used in this component such as setKeys([]) and
setIssuedKey(null/undefined)) before returning so no admin secrets remain in
memory when access is lost.

In `@src/db/migrator.ts`:
- Around line 33-42: The catch block in the realpath validation wraps both
realpathSync errors and the explicit security Error thrown when resolvedRealDir
is outside resolvedProjectRoot, which hides the original security message/stack;
update the try/catch in migrator.ts (around realpathSync, resolvedRealDir,
resolvedProjectRoot and the thrown Error about "Migration directory must be
within project root") so that if the caught error is the specific security Error
(e.g., instanceof Error and message startsWith "Security:" or better: tag the
thrown security error with a unique type/class or symbol) you re-throw it
unchanged, otherwise wrap/throw a new error that includes the original error
details; ensure you reference the same identifiers (dir, projectRoot,
resolvedRealDir, resolvedProjectRoot) when implementing this conditional
re-throw.

In `@src/routes/onboarding.ts`:
- Around line 271-274: The password regex (PASSWORD_REGEX) currently allows any
non-alphanumeric, non-whitespace special character but the user-facing policy
text still references a limited set (e.g., "@$!%*?&"); update the displayed
message to match the validation by changing the password policy string to state
"a special character (any non-alphanumeric character, excluding whitespace)" or
similar wording, or alternatively restrict PASSWORD_REGEX to the exact
characters listed—ensure the change is made where the message/constant that
describes the password requirements is defined and that it references
PASSWORD_REGEX so both validation and text remain consistent.

In `@tests/routes/admin.whoami.session.spec.ts`:
- Around line 45-47: The afterAll block currently swallows errors from the
dynamic import and cleanup call (import('../../src/routes/auth') and
auth.stopAuthCleanup()), which can hide teardown failures; change it to await
the import and call stopAuthCleanup() inside a try/catch that does not swallow
the error—either log the error and rethrow (or call fail) so the test run
surface the failure. Ensure you reference the same symbols (afterAll,
import('../../src/routes/auth'), auth.stopAuthCleanup) and propagate the
exception instead of an empty catch to avoid masking leaked async work.

---

Outside diff comments:
In `@docs/openapi/openapi.json`:
- Around line 3565-3594: Add a deprecated alias property so existing clients
sending client_pubkey continue working: in the same object that defines "pubkey"
add a "client_pubkey" property with the same schema (type: string, description:
"DEPRECATED: client_pubkey (hex encoded) — use pubkey") and then replace the
single "required": ["pubkey"] constraint with a oneOf/anyOf that accepts either
required ["pubkey"] or required ["client_pubkey"] (or otherwise accept either
field), preserving "pubkey" as the preferred name while allowing the deprecated
"client_pubkey" during transition.

---

Duplicate comments:
In `@src/db/database.ts`:
- Around line 12-15: The current isEnvPathFile logic misclassifies
dot-containing directory names (e.g., /data/my.folder) as files because
path.extname(envPath) !== '' is true; update the condition used in isEnvPathFile
to only treat path.extname(envPath) as indicating a file when the final path
segment (path.basename(envPath)) does not start with a dot (to exclude dot-named
dirs) and/or when the path actually exists and fs.statSync(envPath).isFile()
confirms it's a file; modify the isEnvPathFile computation to include a check on
path.basename(envPath) (and optionally use fs.existsSync + fs.statSync for
existing paths) so envPath like '/data/my.folder' is not misclassified while
still recognizing real files and .db endings.

---

Nitpick comments:
In `@env.example`:
- Line 255: Replace realistic-looking hex example values for SESSION_SECRET (and
other similar example secret values in this file) with clearly fake placeholders
so users must generate their own keys; e.g., change the commented value to
something like "# SESSION_SECRET=<generate-64-hex-with-openssl-rand-hex-32>"
(and update the other flagged example secret entries in the same file similarly)
so the examples are obviously placeholders and not copy-pasteable secrets.

In `@frontend/components/Configure.tsx`:
- Line 1332: The code currently compares advancedSettings and
originalAdvancedSettings using JSON.stringify on every render; replace this with
a memoized equality check to avoid expensive stringify calls by computing a
boolean like areAdvancedSettingsDirty using React's useMemo and a suitable
shallow/deep equality comparer (e.g., lodash/isEqual or a custom shallowEqual)
and then use that memoized boolean in the JSX conditional; update references to
advancedSettings and originalAdvancedSettings inside useMemo so the memo
recomputes only when those objects change.
- Around line 301-310: The code currently uses a mock empty shares array which
prevents duplicate-name validation from working; update the Configure component
to call the server API instead: replace the mock shares variable with an awaited
fetch to '/api/env/shares' (or the real endpoint), parse JSON, extract names the
same way (share.name.split(' share ')[0]), and call setExistingNames(names);
ensure you handle fetch errors and non-OK responses (log or surface via state)
and keep existingNames and the duplicate check
(existingNames.indexOf(nameWithoutShare)) intact so validation works correctly.
- Around line 337-369: The focus-trap useEffect for showClearConfirm currently
sets initial focus to clearConfirmButtonRef (destructive action) and duplicates
modal logic; change the initial focus to clearCancelButtonRef to reduce
accidental confirmations and ensure focus restoration via clearTriggerButtonRef
remains, and refactor Configure's clear-confirm flow to reuse the existing
ConfirmModal component (used elsewhere as ConfirmModal) instead of duplicating
the keyboard/focus logic so both modals share the same accessibility pattern and
handlers.

In `@frontend/components/nip46/RelaySettings.tsx`:
- Around line 78-84: The onClick async arrow returns a Promise causing a
floating promise; change it to match the pattern used for add (e.g. create a
named handler handleRemove or call onRemove via void to ensure the event handler
returns void). Specifically, replace the inline async onClick={() => { try {
await onRemove(relay) } catch { ... } }} with either a top-level async function
handleRemove(relay) that performs try/await/onRemove and is referenced as
onClick={() => void handleRemove(relay)} or call onClick={() => { try { void
onRemove(relay) } catch { /* keep parent error handling */ } }}, making sure to
reference the existing onRemove and relay identifiers.

In `@frontend/components/Onboarding.tsx`:
- Around line 108-117: The cleanup in the useEffect that invokes checkStatus is
misleading because it clears timeoutRef.current (which is actually set in
createUser for auto-redirect), so update the Onboarding component to either
remove or correct the comment and move the timeout cleanup into a separate
effect that watches the completion/step state (or the variable that createUser
sets) so clearTimeout(timeoutRef.current) runs only for the timer created by
createUser; reference the existing useEffect(checkStatus), createUser,
timeoutRef.current, and the completion/step state to locate and relocate or
remove the cleanup logic and adjust the comment accordingly.

In `@frontend/components/Signer.tsx`:
- Around line 996-1002: saveRelaysToEnv currently ignores the boolean results
from saveRelaysToUserCredentials and saveRelaysToServerEnv, so update
saveRelaysToEnv to return a boolean indicating overall success (true only if the
called helper returns true), then update callers like handleAddRelay and
handleRemoveRelay to await saveRelaysToEnv and, on false, revert the optimistic
local state change (e.g., restore previous relayUrls) and rely on
credentialSaveError for the error UI; reference the functions saveRelaysToEnv,
saveRelaysToUserCredentials, saveRelaysToServerEnv, handleAddRelay, and
handleRemoveRelay when making these changes.

In `@frontend/components/ui/icon-button.tsx`:
- Line 55: Remove the trailing whitespace at the end of the export statement for
IconButton and iconButtonVariants (the line exporting IconButton,
iconButtonVariants) so the export reads without any trailing space; update the
export line for the IconButton/iconButtonVariants symbols to remove the extra
space and save formatting.

In `@frontend/components/ui/input-with-validation.tsx`:
- Around line 1-55: Rename the file to PascalCase and update all imports: move
frontend/components/ui/input-with-validation.tsx →
frontend/components/ui/InputWithValidation.tsx, keep the component name
InputWithValidation and its named export unchanged, then search the repo for any
imports using "input-with-validation" and update them to import {
InputWithValidation } from "./InputWithValidation" (or the correct relative
path); also update any barrel/index files or tests that re-export or reference
the old filename to use the new filename.

In `@src/class/relay.test.ts`:
- Around line 128-175: Add an assertion that the single returned event is the
expected newest matched event by checking the event id equals matchedB.id: after
decoding messages and filtering eventMessages, assert eventMessages[0]?.[2].id
=== matchedB.id (or equivalent) to ensure sorting by created_at returns matchedB
when limit: 1 is used; reference variables matchedB and eventMessages in the
test to locate where to add this check.

In `@src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql`:
- Around line 41-43: Compute the repeated BLOB length expressions once in a CTE
and reuse aliases instead of repeating COALESCE(LENGTH(CAST(... AS BLOB)), 0):
add a CTE (e.g., sized_rows) that SELECTs the primary key (id) and computes
relay_size, methods_size, kinds_size using COALESCE(LENGTH(CAST(relays AS
BLOB)),0) etc., then in the main query reference those aliases for per-column
sizes, SUM them into a single total_size alias, and use total_size and the sized
alias names in the risk checks and CASE expressions (replace occurrences of the
repeated LENGTH(CAST(... AS BLOB)) for relays, policy_methods, policy_kinds with
the CTE aliases).

In `@src/db/nip46.ts`:
- Around line 57-59: The getSession function duplicates client pubkey
normalization inline instead of reusing normalizeClientPubkey; update getSession
to call normalizeClientPubkey(clientPubkey) where it currently trims/lowers the
value so behavior matches updatePolicy, updateStatus, deleteSession, and
logSessionEvent and to centralize normalization logic in normalizeClientPubkey
for maintainability.

In `@src/nip46/service.ts`:
- Around line 404-409: The catch block in the connect flow resets references but
doesn't close the underlying transport, risking leaked resources; update the
catch to defensively close the transport and any partially-initialized agent
before nulling fields — e.g. call and await a close method on the transport
(this.transport?.close() or this.closeTransport()) and, if available, close the
agent (this.agent?.close()) inside a try/catch, then call
removeAgentListeners(), set this.agent = null, this.signer = null,
this.transport = null, and this.started = false before rethrowing the error so
partially-opened resources are always cleaned up.

In `@tests/routes/helpers/script-runner.ts`:
- Around line 41-51: toSafePreview currently redacts bearer tokens but not HTTP
Basic credentials, so add a second replacement in the toSafePreview function to
detect and redact Basic auth values (e.g., "Basic <base64>") alongside the
existing bearer redaction; update the regex used in toSafePreview (the
replacement after the secret key regex) to also match
/(basic\s+)[A-Za-z0-9._-]+=*/i and replace with '$1<redacted>' (or combine into
a single regex that handles both 'bearer' and 'basic') so previews never expose
Basic auth payloads.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 12698ac and 98a0d63.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (72)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/umbrel-dev.yml
  • .gitignore
  • Dockerfile
  • docs/DEPLOY.md
  • docs/openapi/README.md
  • docs/openapi/openapi.json
  • docs/openapi/openapi.yaml
  • env.example
  • frontend/App.tsx
  • frontend/components/ApiKeys.tsx
  • frontend/components/Configure.tsx
  • frontend/components/Login.tsx
  • frontend/components/NIP46.tsx
  • frontend/components/Onboarding.tsx
  • frontend/components/Recover.tsx
  • frontend/components/Signer.tsx
  • frontend/components/nip46/Permissions.tsx
  • frontend/components/nip46/RelaySettings.tsx
  • frontend/components/nip46/Requests.tsx
  • frontend/components/ui/card.tsx
  • frontend/components/ui/collapsible.tsx
  • frontend/components/ui/event-log.tsx
  • frontend/components/ui/icon-button.tsx
  • frontend/components/ui/input-with-validation.tsx
  • frontend/components/ui/peer-list.tsx
  • frontend/components/ui/tooltip.tsx
  • frontend/types/index.ts
  • llm/implementation/node-lifecycle-implementation.md
  • llm/implementation/umbrel-implementation.md
  • package.json
  • packages/umbrel/igloo/Dockerfile
  • scripts/fetch-swagger-ui.mjs
  • scripts/patch-zod-compat.mjs
  • scripts/release.sh
  • src/class/relay.test.ts
  • src/class/relay.ts
  • src/config/crypto.ts
  • src/const.ts
  • src/db/database.ts
  • src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql
  • src/db/migrator.ts
  • src/db/nip46.ts
  • src/db/ui-event-log.ts
  • src/nip46/service.ts
  • src/node/manager.ts
  • src/routes/admin.ts
  • src/routes/auth.ts
  • src/routes/env.ts
  • src/routes/index.ts
  • src/routes/nip04.ts
  • src/routes/nip44.ts
  • src/routes/nip46.ts
  • src/routes/onboarding.ts
  • src/routes/peers.ts
  • src/routes/sign.ts
  • src/routes/status.ts
  • src/routes/update.ts
  • src/routes/user.ts
  • src/routes/utils.test.ts
  • src/routes/utils.ts
  • src/server.ts
  • src/utils/rate-limiter.ts
  • tests/routes/admin.whoami.session.spec.ts
  • tests/routes/auth.rehydrate.spec.ts
  • tests/routes/body-limit.spec.ts
  • tests/routes/env.db-mode.spec.ts
  • tests/routes/helpers/script-runner.spec.ts
  • tests/routes/helpers/script-runner.ts
  • tests/routes/onboarding.spec.ts
  • tests/routes/status-env.test.ts

Comment thread frontend/components/ApiKeys.tsx
Comment thread src/db/migrator.ts
Comment thread src/routes/onboarding.ts
Comment thread tests/routes/admin.whoami.session.spec.ts Outdated
@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.

♻️ Duplicate comments (1)
frontend/components/ApiKeys.tsx (1)

227-231: ⚠️ Potential issue | 🟠 Major

Clear revoke modal state when admin access is lost.

On Line 227, you now clear keys and issuedKey, but revokeTarget/revokeReason are still retained. If the revoke modal was open, key metadata can remain visible after admin access is removed.

🔧 Suggested fix
   useEffect(() => {
     if (!isAdminUser) {
       initialAdminLoadRef.current = false
       setKeys([])
       setIssuedKey(null)
+      setRevokeTarget(null)
+      setRevokeReason('')
       return
     }
     if (initialAdminLoadRef.current) return
     initialAdminLoadRef.current = true
     loadKeys().catch(err => console.error('Failed to load keys:', err))
   }, [isAdminUser, loadKeys])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ApiKeys.tsx` around lines 227 - 231, When admin access is
lost in ApiKeys.tsx (the block using initialAdminLoadRef.current, setKeys,
setIssuedKey), also clear the revoke modal state so sensitive key metadata isn't
left visible: call the state setters that control the revoke modal (e.g.,
setRevokeTarget and setRevokeReason or clear revokeTarget/revokeReason) along
with setKeys([]) and setIssuedKey(null) to fully reset UI state when
!isAdminUser.
🟡 Minor comments (5)
frontend/components/ui/button.tsx-44-46 (1)

44-46: ⚠️ Potential issue | 🟡 Minor

type gets dropped in asChild mode.

Line 42 extracts type, but Line 45 only spreads props, so explicit type is lost when asChild is true.

Proposed fix
-    const componentProps = asChild
-      ? props
-      : { ...props, type: type ?? "button" }
+    const componentProps = asChild
+      ? { ...props, ...(type !== undefined ? { type } : {}) }
+      : { ...props, type: type ?? "button" }

Also applies to: 51-51

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

In `@frontend/components/ui/button.tsx` around lines 44 - 46, The component
currently drops the extracted `type` when `asChild` is true because it spreads
`props` directly; update the `componentProps` assignment(s) so the extracted
`type` is preserved in both modes: when `asChild` is true include the extracted
`type` (only if defined) in the spread, and when not `asChild` keep the fallback
`type: type ?? "button"`; apply this change to the `componentProps`
expression(s) (search for `componentProps`, `asChild`, `props`, and `type` in
this file, including the second occurrence around line 51) so the explicit
`type` is never lost.
src/routes/docs.ts-46-47 (1)

46-47: ⚠️ Potential issue | 🟡 Minor

Fallback guidance is inconsistent with the current asset allowlist.

Line 91 suggests vendoring full local Swagger UI assets, but Line 46 only permits serving swagger-ui.css from /api/docs/assets/*. Either re-enable JS asset serving or update the fallback text to avoid suggesting a non-working local path.

Also applies to: 91-92

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

In `@src/routes/docs.ts` around lines 46 - 47, The fallback message in the docs
route currently recommends vendoring full local Swagger UI assets under
/api/docs/assets/* but the asset allowlist only permits 'swagger-ui.css', so the
described local path won't serve required JS; either add the missing JS files
(e.g., 'swagger-ui-bundle.js' and 'swagger-ui-standalone-preset.js') to the same
allowlist where 'swagger-ui.css' is listed so the local vendor path works, or
alter the fallback text (the message around lines 91-92) to stop suggesting the
non-working /api/docs/assets/* path and instead point to the correct external
CDN or the single CSS-only option. Ensure changes reference the same allowlist
array that currently contains 'swagger-ui.css' and update the fallback string
accordingly.
tests/routes/env.db-mode.spec.ts-10-13 (1)

10-13: ⚠️ Potential issue | 🟡 Minor

Hard-coded fallback secret violates coding guidelines.

The fallback hex string on line 13 is a hard-coded secret. Per coding guidelines, secrets should be loaded from environment variables or data/ fixtures, not hard-coded—even in tests.

Consider moving this to a fixture file (e.g., data/test-keyset-secret.txt) or require the environment variable to be set for these tests:

🔧 Suggested approach
+import { readFileSync } from 'fs';
+import path from 'path';
+
+const FIXTURE_SECRET_PATH = path.join(process.cwd(), 'data', 'test-keyset-secret.txt');
+
+function loadTestKeysetSecret(): string {
+  const fromEnv = normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ??
+                  normalizeOptionalEnv(process.env.TEST_NSEC_HEX);
+  if (fromEnv) return fromEnv;
+  try {
+    return readFileSync(FIXTURE_SECRET_PATH, 'utf8').trim();
+  } catch {
+    throw new Error('TEST_KEYSET_SECRET env var or data/test-keyset-secret.txt fixture required');
+  }
+}
+
 const TEST_KEYSET_SECRET =
-  normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ??
-  normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ??
-  'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
+  loadTestKeysetSecret();

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 - 13, The
TEST_KEYSET_SECRET fallback is a hard-coded secret; update the
TEST_KEYSET_SECRET initialization (and any use of normalizeOptionalEnv) to load
the secret from a fixture or require the env var: add a data fixture (e.g.,
data/test-keyset-secret.txt) and read it when
normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) and
normalizeOptionalEnv(process.env.TEST_NSEC_HEX) are undefined, or fail the test
early with a clear error if neither env var nor fixture exists; ensure
references to TEST_KEYSET_SECRET and normalizeOptionalEnv are updated to use the
fixture-loading logic (or throw) rather than the hard-coded hex.
src/routes/index.ts-129-131 (1)

129-131: ⚠️ Potential issue | 🟡 Minor

Websocket upgrade detection should be case-insensitive (and tolerate token lists).

Upgrade header values can vary in casing and may contain multiple tokens. Current !== 'websocket' can incorrectly 404 legitimate websocket upgrades.

Proposed fix
-  if (url.pathname === '/api/events' && req.headers.get('upgrade') !== 'websocket') {
-    return Response.json({ error: 'Not Found' }, { status: 404, headers });
-  }
+  if (url.pathname === '/api/events') {
+    const upgrade = req.headers.get('upgrade');
+    const isWebsocket = upgrade
+      ?.split(',')
+      .some(token => token.trim().toLowerCase() === 'websocket');
+    if (!isWebsocket) {
+      return Response.json({ error: 'Not Found' }, { status: 404, headers });
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/index.ts` around lines 129 - 131, The current check in the
if-block that inspects url.pathname === '/api/events' uses
req.headers.get('upgrade') !== 'websocket', which fails for varied casing or
multiple tokens; update the condition in the same block to treat the Upgrade
header case-insensitively and accept token lists (e.g., normalize the header
value to lowercase, split on commas, trim tokens and check
includes('websocket')) before returning Response.json({ error: 'Not Found' },
...). Ensure you only reject when no token equals 'websocket' so legitimate
websocket upgrade requests are not 404'd.
src/routes/peers.ts-204-216 (1)

204-216: ⚠️ Potential issue | 🟡 Minor

Use typeof globalThis.structuredClone instead of bare structuredClone reference to avoid ReferenceError.

In JavaScript, referencing a bare identifier (like structuredClone) throws ReferenceError before the ternary operator can evaluate, if that identifier is undefined. This happens in runtimes that don't have structuredClone (available only in Node.js 17.3+). Use typeof globalThis.structuredClone === 'function' for safe feature detection.

Proposed fix
 function summarizePolicyForLog(policy: NodePolicyInput | null | undefined): Record<string, unknown> | null {
   if (!policy || typeof policy !== 'object') {
     return null;
   }
-  const summary = structuredClone ? structuredClone(policy) : { ...policy };
+  const summary = typeof globalThis.structuredClone === 'function'
+    ? globalThis.structuredClone(policy)
+    : { ...policy };
   if ('note' in summary) {
     delete (summary as { [key: string]: unknown }).note;
   }
   if ('metadata' in summary) {
     delete (summary as { [key: string]: unknown }).metadata;
   }
   return summary;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/peers.ts` around lines 204 - 216, The function
summarizePolicyForLog references structuredClone directly which can throw
ReferenceError in environments without it; update the feature check to use
typeof globalThis.structuredClone === 'function' and call
globalThis.structuredClone(policy) when available, otherwise fall back to
shallow copy ({ ...policy }); keep the existing removal of 'note' and 'metadata'
from the summary and preserve the function name summarizePolicyForLog and its
input/output shapes.
🧹 Nitpick comments (29)
frontend/components/ui/input-with-validation.tsx (1)

55-55: Trailing whitespace.

There appears to be a trailing space after the semicolon on this line.

🧹 Proposed fix
-export { InputWithValidation }; 
+export { InputWithValidation };
🤖 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` at line 55, Remove the
trailing whitespace after the semicolon in the export statement for
InputWithValidation; locate the export { InputWithValidation }; line and delete
the extra space so it ends immediately after the semicolon.
scripts/api/test-get-endpoints.ts (2)

14-14: Consider replacing any with unknown for type safety.

The any type is used in several places throughout this file (lines 14, 46, 56, 65, 86, 126), which bypasses TypeScript's type checking. Using unknown with explicit type narrowing would align with the coding guidelines.

♻️ Suggested approach
 type TestCase = {
   name: string;
   path: string;
   expectStatus?: number | ((status: number) => boolean);
   expectContentType?: RegExp; // e.g., /^application\/json/
-  validate?: (jsonOrText: any, res: Response) => void | Promise<void>;
+  validate?: (jsonOrText: unknown, res: Response) => void | Promise<void>;
 };

Then update validators to narrow the type:

   validate: (data: unknown) => {
+    const obj = data as Record<string, unknown>;
-    if (typeof data?.serverRunning !== 'boolean') throw new Error('serverRunning missing');
+    if (typeof obj?.serverRunning !== 'boolean') throw new Error('serverRunning missing');
   },

For error handling (line 126):

-  } catch (err: any) {
+  } catch (err: unknown) {
-    return { name: tc.name, path: tc.path, ok: false, status: -1, detail: err?.message || String(err) };
+    return { name: tc.name, path: tc.path, ok: false, status: -1, detail: err instanceof Error ? err.message : String(err) };
   }

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 `@scripts/api/test-get-endpoints.ts` at line 14, Replace the use of the any
type in the validate function signature and all validator functions with unknown
(e.g., change validate?: (jsonOrText: any, res: Response) => ... to validate?:
(jsonOrText: unknown, res: Response) => ...), then update each validator (the
functions referenced where validate is called/implemented) to perform explicit
type narrowing/guards (typeof checks, Array.isArray, instance checks, or schema
validation) before accessing properties or casting to concrete types; ensure the
error-handling block that processes validator failures also narrows the unknown
value before using it.

119-123: Silent JSON parse failure may obscure root cause.

The .catch(() => ({})) swallows JSON parse errors, which would then manifest as validation failures with "field missing" errors rather than indicating malformed JSON.

♻️ Optional: surface JSON parse errors explicitly
     if (tc.validate) {
       const isJson = /json/i.test(ctype);
-      const body = isJson ? await res.json().catch(() => ({})) : await res.text();
+      let body: unknown;
+      if (isJson) {
+        const text = await res.text();
+        try {
+          body = JSON.parse(text);
+        } catch {
+          return { name: tc.name, path: tc.path, ok: false, status, detail: 'Invalid JSON response' };
+        }
+      } else {
+        body = await res.text();
+      }
       await tc.validate(body, res);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/api/test-get-endpoints.ts` around lines 119 - 123, The current JSON
parsing in the test runner silently swallows parse errors (the isJson branch
using res.json().catch(() => ({}))) which hides malformed JSON; update the logic
in the block around tc.validate / isJson so that JSON parsing errors are not
swallowed—use a try/catch around await res.json() (or await res.json().catch(err
=> { throw new Error(`Invalid JSON response: ${err.message}`) })) and either
rethrow or attach the parse error to the body/result passed into tc.validate so
tests surface malformed JSON explicitly; locate the code around the tc.validate
check and the isJson/body assignment to implement this change.
frontend/components/ui/peer-list.tsx (2)

377-417: Good refactor with safer parsing and normalized key matching.

The useCallback wrapper is appropriate for the event listener usage. The Number.isFinite check prevents NaN latency values, and toPolicyKey normalization handles compressed pubkey format differences.

Consider extracting the repeated update logic to reduce duplication:

♻️ Optional: Extract update logic
+  const buildUpdatedPeer = (peer: PeerStatus, status: any): PeerStatus => {
+    const hasLatency = status.latency !== undefined && status.latency !== null;
+    const parsedLatency = Number(status.latency);
+    const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : peer.latency;
+    return {
+      ...peer,
+      online: Boolean(status.online),
+      lastSeen: parseDate(status.lastSeen) ?? peer.lastSeen,
+      latency,
+      lastPingAttempt: parseDate(status.lastPingAttempt) ?? peer.lastPingAttempt
+    };
+  };

   const handlePeerUpdate = useCallback((event: CustomEvent) => {
     const { pubkey, status } = event.detail;
     setPeers(prev => {
       const updated = prev.map(peer => {
         if (peer.pubkey === pubkey) {
-          // ... duplicated logic
+          return buildUpdatedPeer(peer, status);
         }
         const peerNormalized = toPolicyKey(peer.pubkey);
         const pingNormalized = toPolicyKey(pubkey);
         if (peerNormalized !== '' && peerNormalized === pingNormalized) {
-          // ... duplicated logic
+          return buildUpdatedPeer(peer, status);
         }
         return peer;
       });
       return updated;
     });
   }, []);
🤖 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 peer update
mapping in handlePeerUpdate duplicates the same logic for exact match and
normalized-match; refactor by extracting a helper (e.g., updatePeerStatus) that
accepts a peer and status and returns the updated PeerStatus (using parseDate,
Number parsing/Number.isFinite, and preserving fallback fields), then call that
helper from both match branches inside handlePeerUpdate and return peer
unchanged otherwise; keep helper name and handlePeerUpdate, toPolicyKey,
parseDate, PeerStatus, and setPeers references so it's easy to locate and
replace the duplicated blocks.

32-32: The any type violates strict mode guidelines.

While the comment indicates this is kept for compatibility, consider using a more specific type or unknown:

-  node: any; // Not used in server architecture, kept for compatibility
+  node: unknown; // Not used in server architecture, kept for compatibility

Using unknown is safer than any as it requires explicit type checks before use, though since it's unused this is a minor concern.

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

In `@frontend/components/ui/peer-list.tsx` at line 32, The declaration "node: any"
should not use any; change the type to a safer alternative (for example replace
with "node: unknown") or remove the unused declaration entirely; locate the
"node" symbol in peer-list.tsx and either change its type from any to unknown
(or a specific interface if you know the shape) or delete the unused
variable/comment to satisfy strict mode.
frontend/components/ui/modal.tsx (1)

83-110: Consider extracting the close button to reduce duplication.

The close button JSX is duplicated between lines 84-93 and 99-108. A small refactor could improve maintainability:

♻️ Optional refactor to extract close button
+  const closeButton = showCloseButton ? (
+    <Button
+      variant="ghost"
+      size="sm"
+      onClick={onClose}
+      aria-label="Close"
+      title="Close"
+      className="text-gray-400 hover:text-gray-300 hover:bg-gray-800 h-8 w-8 p-0"
+    >
+      <X className="h-4 w-4" />
+    </Button>
+  ) : null;

   {title && (
     <div className="flex justify-between items-center border-b border-gray-800 p-4">
       <h3 id={titleId} className="text-xl font-semibold text-blue-200">{title}</h3>
-      {showCloseButton && (
-        <Button
-          variant="ghost"
-          ...
-        </Button>
-      )}
+      {closeButton}
     </div>
   )}
-  {!title && showCloseButton && (
+  {!title && closeButton && (
     <div className="flex justify-end border-b border-gray-800 p-4">
-      <Button
-        ...
-      </Button>
+      {closeButton}
     </div>
   )}
🤖 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 83 - 110, The close button JSX
is duplicated; extract it into a small reusable component or render function
(e.g., const CloseButton = () => (...) or function renderCloseButton()) and use
that in place of the two duplicated blocks; preserve the same props and symbols
(showCloseButton, onClose, Button, X, aria-label/title/className/variant/size)
so both conditional branches render <CloseButton /> (or call
renderCloseButton()) without changing behavior.
frontend/components/ui/collapsible.tsx (1)

26-27: Good accessibility improvements with useId and ARIA attributes.

The addition of aria-expanded, aria-controls, and corresponding id attributes follows WAI-ARIA disclosure pattern correctly.

For TypeScript strict mode consistency, consider adding explicit type:

-  const [isExpanded, setIsExpanded] = useState(defaultOpen);
+  const [isExpanded, setIsExpanded] = useState<boolean>(defaultOpen);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/collapsible.tsx` around lines 26 - 27, Add an explicit
boolean type to the useState hook to satisfy TypeScript strict mode: change the
isExpanded/setIsExpanded state declaration (currently using
useState(defaultOpen)) to explicitly use useState<boolean>, and keep the
contentId from useId as-is; this ensures isExpanded is typed as boolean and
avoids implicit any/union types in the Collapsible component.
frontend/components/ui/event-log.tsx (1)

40-42: Explicit type annotation added for isExpanded - good fix.

Line 40 now correctly uses useState<boolean>(hideHeader) addressing the previous review feedback.

However, for consistency with the coding guidelines requiring explicit types, line 42 should also have an explicit type annotation:

-  const [showFilters, setShowFilters] = useState(false);
+  const [showFilters, setShowFilters] = useState<boolean>(false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/event-log.tsx` around lines 40 - 42, The showFilters
state lacks an explicit type annotation for consistency; update the useState
call that defines showFilters/setShowFilters to include a boolean generic (e.g.,
useState<boolean>(false)) so it matches the explicit typing used for isExpanded
and activeFilters and makes the types of showFilters and setShowFilters
explicit.
frontend/components/Recover.tsx (1)

561-586: Consider auto-hiding revealed NSEC after a short timeout.

Once shown, the secret stays visible until manually hidden. A short auto-hide timer would improve shoulder-surfing resistance without changing core UX.

🔐 Optional hardening snippet
useEffect(() => {
  if (!showRecoveredNsec || !recoveredNsec) return;
  const timer = window.setTimeout(() => {
    setShowRecoveredNsec(false);
  }, 30000); // 30s
  return () => window.clearTimeout(timer);
}, [showRecoveredNsec, recoveredNsec]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Recover.tsx` around lines 561 - 586, Add an auto-hide
effect so the revealed NSEC (recoveredNsec) is hidden after a short timeout when
showRecoveredNsec becomes true: implement a useEffect that watches
showRecoveredNsec and recoveredNsec, starts a timer (e.g., 30s) to call
setShowRecoveredNsec(false) when shown, and clears the timer on cleanup or when
showRecoveredNsec/recoveredNsec change; update the component that renders the
recovered NSEC and toggles visibility (references: showRecoveredNsec,
recoveredNsec, setShowRecoveredNsec) to include this effect so the secret
auto-hides.
Dockerfile (1)

2-24: Good version pin; consider digest pinning for immutable builds.

Lines 2 and 24 improve reproducibility by pinning Bun to 1.3.10. For stronger supply-chain guarantees, pin the image by digest as well.

Example diff (resolved digest)
-FROM oven/bun:1.3.10 AS build
+FROM oven/bun:1.3.10@sha256:b86c67b531d87b4db11470d9b2bd0c519b1976eee6fcd71634e73abfa6230d2e AS build
...
-FROM oven/bun:1.3.10 AS production
+FROM oven/bun:1.3.10@sha256:b86c67b531d87b4db11470d9b2bd0c519b1976eee6fcd71634e73abfa6230d2e AS production
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 2 - 24, Replace the two FROM lines that use the tag
"oven/bun:1.3.10" (the lines "FROM oven/bun:1.3.10 AS build" and "FROM
oven/bun:1.3.10 AS production") with the corresponding image digest pins (e.g.,
"oven/bun@sha256:<digest>") to ensure immutable builds; obtain the correct
sha256 digest for the 1.3.10 image from your registry or the image manifest and
use that digest in both FROM instructions so both the build and production
stages reference the exact same immutable image.
src/nip46/index.ts (1)

22-25: Prefer structured server logging over raw console.warn here.

When init options drift, logging through opts.addServerLog keeps these warnings observable in the same pipeline as other server events.

🔧 Suggested refinement
   if (service) {
     if (!serviceInitOptions || !areInitOptionsEquivalent(serviceInitOptions, opts)) {
-      console.warn('Reusing existing Nip46Service with different init options.')
+      try {
+        opts.addServerLog('warning', 'Reusing existing Nip46Service with different init options.')
+      } catch {
+        console.warn('Reusing existing Nip46Service with different init options.')
+      }
     }
     return service
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nip46/index.ts` around lines 22 - 25, Replace the console.warn call with
a structured server log via opts.addServerLog so the warning is captured in the
server pipeline: inside the block that checks service && (!serviceInitOptions ||
!areInitOptionsEquivalent(serviceInitOptions, opts)), call
opts.addServerLog(...) with a clear message and include contextual fields (e.g.,
previous init options from serviceInitOptions and current opts) and a
severity/category flag instead of console.warn; ensure opts.addServerLog is
present or gate the call if opts is undefined to avoid errors.
src/routes/nip44.ts (1)

41-46: Use limiter reset time for more accurate Retry-After when present.

Right now the header always reflects the configured window. Using rate.resetAt (if provided) improves client retry behavior and keeps this route consistent with /api/nip04/*.

🔁 Suggested Retry-After refinement
   if (!rate.allowed) {
-    const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10);
-    const retryAfterSeconds = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900;
+    const resetAt = typeof rate.resetAt === 'number' && Number.isFinite(rate.resetAt) ? rate.resetAt : null;
+    const retryAfterFromReset = resetAt !== null
+      ? Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
+      : null;
+    const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10);
+    const retryAfterFallback = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900;
+    const retryAfterSeconds = retryAfterFromReset !== null ? retryAfterFromReset : retryAfterFallback;
     return Response.json({ error: 'Rate limit exceeded. Try again later.' }, {
       status: 429,
       headers: { ...headers, 'Retry-After': Math.ceil(retryAfterSeconds).toString() }
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/nip44.ts` around lines 41 - 46, The Retry-After header should
prefer the limiter's reset time when available: in the Response.json call where
you compute retryAfterWindow/retryAfterSeconds, check for a provided
rate.resetAt (or rate?.resetAt) and compute retrySeconds = Math.max(0,
Math.ceil((new Date(rate.resetAt).getTime() - Date.now()) / 1000)); if that
yields a finite positive number use it, otherwise fall back to the existing
retryAfterWindow / retryAfterSeconds logic; then set the 'Retry-After' header to
retrySeconds.toString() in the Response.json response.
src/routes/onboarding.ts (1)

273-273: Use a single password regex source to prevent policy drift.

This file defines a local PASSWORD_REGEX while src/config/crypto.ts also defines password policy. Keeping both invites future mismatch between setup validation and shared/server validation behavior.

Proposed refactor
-const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).*$/;
+const PASSWORD_REGEX = VALIDATION.PASSWORD_REGEX;

Also applies to: 348-348

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

In `@src/routes/onboarding.ts` at line 273, Replace the local PASSWORD_REGEX in
onboarding.ts with the canonical password policy from the shared config so
validation stays consistent: remove the local const PASSWORD_REGEX and import
the password policy (e.g., the exported regex/constant defined in
src/config/crypto.ts) and use that in any places that currently reference
PASSWORD_REGEX (notably the checks around the symbols at lines shown, e.g.,
where PASSWORD_REGEX is referenced near the handlers in onboarding.ts). Ensure
the import name matches the exported identifier from crypto.ts and update both
occurrences (the current local declaration and the second usage) to use the
imported constant.
src/routes/user.ts (2)

241-241: Avoid any type per coding guidelines.

The body variable uses any type. Consider using a more specific type or unknown with runtime validation.

♻️ Suggested type improvement
-          let body: any;
+          let body: unknown;
           try {
             body = await parseJsonRequestBody(req);
           } catch (error) {

Then update the property accesses to use type guards or assertions after validation.

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 `@src/routes/user.ts` at line 241, The local variable "body" is declared as
any; replace it with a stricter type (e.g., unknown or a specific interface like
UserRequestBody) and perform runtime validation before accessing properties.
Update the code that assigns to and reads from body (the variable named "body"
in the route handler) to validate shape (using a validator function or type
guard) and then narrow the type (assert or use a typed variable) so subsequent
property accesses are type-safe and no longer use any. Ensure all references to
body use the narrowed/validated type or guarded access.

473-473: Avoid any type per coding guidelines.

Same issue as above - the body variable uses any type.

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 `@src/routes/user.ts` at line 473, The local variable "body" is typed as any;
replace it with an explicit type instead (e.g., define an interface like
UserRequestBody or use unknown and narrow it) and validate/cast before use.
Change "let body: any;" to either "let body: UserRequestBody;" (create the
UserRequestBody interface matching expected fields) or "let body: unknown;" then
add a type guard/validation step (e.g., isUserRequestBody(body) or runtime
checks) before assigning/using fields; update all downstream usages to rely on
the explicit type or the narrowed type. Ensure the new type or guard is
exported/declared near the route handler so the compiler can enforce strict
typing.
frontend/components/Configure.tsx (1)

603-633: Consider extracting parseRelayList to module scope for reuse.

The parseRelayList function is defined inside handleCreateKeyset but has no closure dependencies. Extracting it to module scope (near isAbortError) would improve readability and enable reuse if needed elsewhere.

♻️ Optional refactor
+function parseRelayList(raw: string): string[] | null {
+  try {
+    const parsed: unknown = JSON.parse(raw);
+    if (!Array.isArray(parsed)) return null;
+    const relays = parsed
+      .filter((relay): relay is string => typeof relay === 'string')
+      .map((relay) => relay.trim())
+      .filter((relay) => relay.length > 0);
+    return relays.length > 0 ? relays : null;
+  } catch {
+    return null;
+  }
+}
+
 const Configure: React.FC<ConfigureProps> = ({ onKeysetCreated, onCredentialsSaved, onBack, authHeaders = {} }) => {

Then remove the inline definition from handleCreateKeyset.

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

In `@frontend/components/Configure.tsx` around lines 603 - 633, The local
parseRelayList function should be moved to module scope for reuse: extract the
function currently declared inside handleCreateKeyset to the top-level (near
isAbortError), keep its signature and behavior unchanged, and remove the inline
declaration; verify handleCreateKeyset and resolveRelaysToSave still call
parseRelayList, and confirm there are no closure dependencies on isHeadlessMode,
existingRelays or advancedSettings.RELAYS before making it module-scoped.
tests/routes/user-peers.spec.ts (1)

257-322: Important concurrency isolation test.

This test verifies that when rollback occurs for peer A's failed update, it doesn't overwrite the concurrent successful update to peer B. The 50ms delay simulates the timing window where concurrent updates could interfere.

One consideration: The 50ms timeout is somewhat arbitrary and could be flaky on slow CI runners.

Consider documenting why 50ms was chosen, or use a more deterministic synchronization mechanism if flakiness is observed:

-      await new Promise(resolve => setTimeout(resolve, 50));
+      // Allow time for the first request to begin retrying before simulating concurrent update.
+      // This timing may need adjustment if test becomes flaky on slow runners.
+      await new Promise(resolve => setTimeout(resolve, 50));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/user-peers.spec.ts` around lines 257 - 322, The hard-coded 50ms
sleep is flaky; replace it with a deterministic synchronization: instead of
await new Promise(resolve => setTimeout(resolve, 50)), wait until the
in-progress PUT has reached the retry/persistence phase by polling or using a
test hook. Modify the test to either poll a clear condition (e.g., an
"inFlightUpdate" flag exposed on the peers module or context) or use a test-only
hook exported by peers (e.g., peers.__TEST__.waitForPersistenceRetry(pending))
so you call await peers.__TEST__.waitForPersistenceRetry(pending) before
applying the concurrent setNodePolicies(context.node, ...) update; keep using
pending (the Promise returned from peers.handlePeersRoute) and setNodePolicies
as the change points. Ensure the polling/hook has a reasonable timeout to fail
fast in CI.
src/routes/auth.ts (2)

967-980: Login 429 should ideally use resetAt/computed retryAfter (not always the full window).

Right now Retry-After is always the entire configured window (Line 975), even if the client is near the end of the window. Since checkRateLimit() now returns resetAt, you can compute an accurate Retry-After (like you do in authenticate()).

🤖 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 429 response always uses the
full AUTH_CONFIG.RATE_LIMIT_WINDOW; instead compute an accurate Retry-After
using the resetAt value returned by checkRateLimit(req). In the block where you
handle !rate.allowed (around the Response.json call), calculate
retryAfterSeconds = Math.max(1, Math.ceil((rate.resetAt - Date.now())/1000)) (or
fallback to Math.ceil(AUTH_CONFIG.RATE_LIMIT_WINDOW/1000) if resetAt is missing)
and use that value for the 'Retry-After' header (same approach used in
authenticate()). Ensure you still include the Set-Cookie and other headers
unchanged.

319-333: Constant-time compare + derived-key vault limits look like a solid hardening pass.

  • compareConstantTime() removes the “length leak” edge case from timingSafeEqual usage by hashing first (Line 319-323).
  • Clamping TTL and max reads (Line 326-333) helps keep the derived-key vault bounded.

Based on learnings: “Tune FROSTR_SIGN_TIMEOUT, SIGN_TIMEOUT_MS, AUTH_DERIVED_KEY_TTL_MS, and AUTH_DERIVED_KEY_MAX_READS per environment”.

Also applies to: 326-333, 348-350

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

In `@src/routes/auth.ts` around lines 319 - 333, compareConstantTime currently
hashes inputs before timingSafeEqual which is good; ensure it always
accepts/normalizes inputs (convert non-strings to strings) to avoid throws. For
the derived-key limits, ensure environment tuning is respected by validating and
clamping parsed values for AUTH_DERIVED_KEY_TTL_MS and
AUTH_DERIVED_KEY_MAX_READS (using parseEnvInt) as shown, and apply the same
validated/clamped pattern to FROSTR_SIGN_TIMEOUT and SIGN_TIMEOUT_MS so all
timeouts are parsed, bounded and safe per environment; locate and update the
compareConstantTime function and the constants AUTH_DERIVED_KEY_TTL_MS,
AUTH_DERIVED_KEY_MAX_READS, FROSTR_SIGN_TIMEOUT, SIGN_TIMEOUT_MS to normalize
inputs and enforce min/max bounds.
src/routes/nip46.ts (3)

329-344: Consider not returning raw parse/validation errors from transport PUT.

On Line 341-343, error.message can include low-level details (e.g., JSON parser text). Elsewhere in this file you return a generic "Invalid JSON" message, which is usually safer/cleaner.

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

In `@src/routes/nip46.ts` around lines 329 - 344, Replace returning raw
error.message in the PUT /api/nip46/transport handler with a generic validation
message: when parseJsonWithLimit (and related validation) fails, return
Response.json({ error: 'Invalid JSON' }, { status: 400, headers }) instead of
exposing error.message; keep the null-size check that returns 413, and still
call setTransportKey(userId, sk) on success. Update the catch around
parseJsonWithLimit/setTransportKey to always send the generic 'Invalid JSON'
response for parse/validation failures (or log the real error internally if
needed) rather than returning error.message.

582-590: Good: consistent 413/400 behavior for oversized or invalid JSON bodies.

Nice consistency across session creation and policy/status updates: oversize → 413, invalid JSON → 400.

Also applies to: 678-687, 720-728

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

In `@src/routes/nip46.ts` around lines 582 - 590, The JSON parsing block using
parseJsonWithLimit and NIP46_JSON_BODY_LIMIT_BYTES correctly returns 413 for
oversized payloads and 400 for invalid JSON; ensure the exact same pattern (use
parseJsonWithLimit, check for null -> Response.json(... status: 413), catch ->
Response.json(... status: 400)) is applied to the other occurrences noted
(around the handlers that reference session creation and policy/status update)
so that functions handling request bodies (e.g., the handlers that currently
contain the similar logic near lines 678-687 and 720-728) use the same
parsedBody/null check and catch behavior and the same headers variable for
consistency.

9-40: parseJsonWithLimit() is a good guard rail, but it’s still best-effort without streaming.

If content-length is missing (or lies), await req.text() (Line 24) can still read an arbitrarily large body into memory before the size check. If you already have (or can add) a shared streaming/size-limited body reader in src/routes/utils.ts, it’d be worth reusing here.

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

In `@src/routes/nip46.ts` around lines 9 - 40, The parseJsonWithLimit function
currently calls await req.text() which can load an arbitrarily large body into
memory if content-length is missing or incorrect; replace that call with a
streaming, size-limited reader (e.g., readRequestBodyWithLimit or
readStreamWithLimit) from your shared utilities and use it to obtain the body
text while enforcing maxBytes, keeping the existing content-length
short-circuit; if such a helper doesn't exist, implement it to consume req.body
as a ReadableStream, accumulate bytes up to maxBytes, abort and return null or
throw when exceeded, then JSON.parse the returned text inside parseJsonWithLimit
(preserve current error propagation).
src/routes/index.ts (3)

68-85: Prefer deriving Retry-After from AUTH_CONFIG (avoid parsing env twice).

AUTH_CONFIG.RATE_LIMIT_WINDOW already centralizes parsing/defaults. Re-parsing process.env.RATE_LIMIT_WINDOW here can drift from what the limiter actually uses.

Proposed refactor
-  const parsedRateLimitWindowSeconds = Number.parseInt(process.env.RATE_LIMIT_WINDOW ?? '', 10);
-  const retryAfterSeconds = (
-    Number.isFinite(parsedRateLimitWindowSeconds) && parsedRateLimitWindowSeconds > 0
-      ? Math.ceil(parsedRateLimitWindowSeconds)
-      : 900
-  ).toString();
+  const retryAfterSeconds = Math.ceil(AUTH_CONFIG.RATE_LIMIT_WINDOW / 1000).toString();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/index.ts` around lines 68 - 85, Replace the ad-hoc re-parsing of
process.env.RATE_LIMIT_WINDOW in the retryAfterSeconds calculation with the
centralized value in AUTH_CONFIG.RATE_LIMIT_WINDOW: use
AUTH_CONFIG.RATE_LIMIT_WINDOW (or its numeric equivalent) to compute
retryAfterSeconds (Math.ceil when >0, fallback to 900, then toString()), and
keep the hasAuthHint logic unchanged (it should still read req.headers
authorization, x-api-key, x-session-id and cookie). This ensures
parsedRateLimitWindowSeconds/retryAfterSeconds derive from the same source as
the limiter and avoids duplicating environment parsing.

139-165: Avoid retryAfterSeconds shadowing inside docs auth flow.

Minor readability: the inner const retryAfterSeconds shadows the outer retryAfterSeconds used elsewhere in this handler.

Proposed refactor
-          const retryAfterSeconds = typeof authResult.retryAfter === 'number' ? authResult.retryAfter : 60;
+          const docsRetryAfterSeconds = typeof authResult.retryAfter === 'number' ? authResult.retryAfter : 60;
           const retryHeaders = {
             ...headers,
-            'Retry-After': String(retryAfterSeconds)
+            'Retry-After': String(docsRetryAfterSeconds)
           };
           return Response.json(
             {
               error: 'Rate limit exceeded',
-              retryAfter: retryAfterSeconds,
+              retryAfter: docsRetryAfterSeconds,
               resetAt: authResult.resetAt
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/index.ts` around lines 139 - 165, The inner const
retryAfterSeconds in the docs auth rate-limit branch shadows an outer
retryAfterSeconds; update the rate-limit handling to use a distinct name (e.g.,
retryAfterForDocs or retryAfterSecondsForDocs) or reuse the outer variable
instead of redeclaring, and ensure the Retry-After header and JSON body use that
renamed symbol; locate the block that checks authResult.rateLimited and modify
the variable used when building retryHeaders and the Response.json call
(references: authResult, headers, getAuthStatus, Response.json).

234-375: For 429s, prefer authResult.retryAfter/resetAt over a fixed window value.

Good move putting finalizeAuth() in a single finally (Line 373-375). One tweak: authenticate() already computes accurate backoff metadata; using the fixed retryAfterSeconds here can mislead clients (especially if the limiter isn’t a simple fixed window).

Proposed fix (header accuracy)
       if (authResult.rateLimited) {
+        const retryAfter = typeof authResult.retryAfter === 'number'
+          ? authResult.retryAfter
+          : Number.parseInt(retryAfterSeconds, 10) || 60;
         return Response.json({ 
           error: 'Rate limit exceeded. Try again later.' 
         }, { 
           status: 429,
           headers: {
             ...headers,
-            'Retry-After': retryAfterSeconds
+            'Retry-After': String(retryAfter)
           }
         });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/index.ts` around lines 234 - 375, The 429 response uses a fixed
retryAfterSeconds which can be inaccurate; in the authenticate() branch where
you check authResult.rateLimited, use the backoff metadata provided by
authResult (e.g., authResult.retryAfter or authResult.resetAt) instead of the
fixed retryAfterSeconds: set the Retry-After header to authResult.retryAfter
(seconds) or convert authResult.resetAt to an HTTP-date string if present, and
ensure you merge it into the response headers in the same place where you return
the 429 inside the initial authenticate() block so clients receive the accurate
retry window.
src/db/nip46.ts (3)

387-419: Potential perf hot spot: JSON-scan up to 500 pending requests per lookup.

getPendingNip46RequestByClientId() can parse up to 500 row.params blobs per call (Line 407-417). If this is on a frequently-hit path, consider reducing payload and/or storing the client request id in a queryable column.

One low-impact improvement is selecting only id, params and fetching the full row only on match.

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

In `@src/db/nip46.ts` around lines 387 - 419, getPendingNip46RequestByClientId
currently JSON-parses up to 500 params blobs per call; change the DB access to
only SELECT id, params (and any minimal tie-breaker columns) in the initial
query inside getPendingNip46RequestByClientId to reduce data scanned, then
iterate rows, parse JSON and when payloadId matches normalizedClientRequestId
use the matching row id to run a second query (e.g. SELECT * FROM nip46_requests
WHERE id = ?) to fetch and return the full Nip46RequestRecord; keep the same
normalization logic (normalizeClientPubkey, normalizedClientRequestId) and error
handling for JSON.parse, and consider adding a dedicated client_request_id
column later for direct querying.

180-208: Tighten JSON parsing types + avoid __proto__ key hazards in boolean maps.

JSON.parse() yields implicit any. Also, assigning into a normal {} can behave oddly for special keys like __proto__. Using unknown + a null-prototype object makes this more robust.

Proposed hardening
 function parseRelayArray(raw: string): string[] {
-  const parsed = JSON.parse(raw)
+  const parsed: unknown = JSON.parse(raw)
   if (!Array.isArray(parsed)) {
     throw new Error('[nip46] Data integrity violation: relays must be an array')
   }
   const relays: string[] = []
-  for (const value of parsed) {
+  for (const value of parsed) {
     if (typeof value !== 'string') {
       throw new Error('[nip46] Data integrity violation: relay entries must be strings')
     }
     relays.push(value)
   }
   return relays
 }

 function parseBooleanMap(raw: string, label: string): Record<string, boolean> {
-  const parsed = JSON.parse(raw)
+  const parsed: unknown = JSON.parse(raw)
   if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
     throw new Error(`[nip46] Data integrity violation: ${label} must be an object`)
   }
-  const normalized: Record<string, boolean> = {}
-  for (const [key, value] of Object.entries(parsed)) {
+  const normalized: Record<string, boolean> = Object.create(null)
+  for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
     if (typeof value !== 'boolean') {
       throw new Error(`[nip46] Data integrity violation: ${label}.${key} must be a boolean`)
     }
     normalized[key] = value
   }
   return normalized
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 180 - 208, Use safer JSON parsing and a
null-prototype map: change both JSON.parse(raw) calls to parse into unknown
(e.g. const parsed = JSON.parse(raw) as unknown), then narrow via runtime checks
(Array.isArray(parsed) and typeof element === 'string' in parseRelayArray;
parsed !== null, typeof parsed === 'object' and !Array.isArray(parsed) in
parseBooleanMap). For parseBooleanMap create the accumulator with no prototype
(const normalized = Object.create(null) as Record<string, boolean>) and either
skip or reject keys named "__proto__" before assignment; keep the current
boolean-type check for values and assign into normalized. Also update variable
names parseRelayArray and parseBooleanMap accordingly where you perform the
narrowed checks and assignments.

57-59: Nice: consistent client_pubkey normalization across reads/writes.

This should eliminate a whole class of “same key, different casing/whitespace” bugs.

Small nit: upsertSession() still normalizes inline; consider reusing normalizeClientPubkey() there too for a single source of truth.

Also applies to: 294-300, 587-698, 731-743

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

In `@src/db/nip46.ts` around lines 57 - 59, The file adds
normalizeClientPubkey(clientPubkey) but several places still perform inline
trimming/lowercasing (notably upsertSession()); update upsertSession() to call
normalizeClientPubkey(clientPubkey) instead of duplicating trim()/toLowerCase(),
and replace any other inline normalizations in this module with calls to
normalizeClientPubkey to ensure a single source of truth for client_pubkey
handling (search for other places that call .trim().toLowerCase() on
clientPubkey and swap them to use the helper).
docs/openapi/openapi.yaml (1)

2875-2882: Constrain nostrconnect invite URI format in schema.

Nip46ConnectInput.uri currently accepts any string, so contract-level validation is weak for a security-sensitive entry point.

🧩 Proposed fix
     Nip46ConnectInput:
       type: object
       properties:
         uri:
           type: string
           description: nostrconnect:// invite URI
+          pattern: '^nostrconnect://'
       required: [uri]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.yaml` around lines 2875 - 2882, Nip46ConnectInput.uri
currently accepts any string; tighten the OpenAPI schema by adding a pattern
(and optionally format/example) to validate nostrconnect invite URIs: update the
Nip46ConnectInput object to include a "pattern" that enforces the
"nostrconnect://" scheme and a sensible character set (e.g.
^nostrconnect://[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$), and update the
"description" to mention the enforced pattern and provide an example value so
consumers and validators reject non‑nostrconnect URIs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 507e08b4-32c4-4c51-b8c9-52608b06c394

📥 Commits

Reviewing files that changed from the base of the PR and between 12698ac and 4592326.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (101)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/umbrel-dev.yml
  • .gitignore
  • Dockerfile
  • compose.yml
  • docs/CONFIG.md
  • docs/DEPLOY.md
  • docs/RELEASE.md
  • docs/SECURITY.md
  • docs/openapi/README.md
  • docs/openapi/openapi.json
  • docs/openapi/openapi.yaml
  • env.example
  • frontend/App.tsx
  • frontend/components/ApiKeys.tsx
  • frontend/components/Configure.tsx
  • frontend/components/Login.tsx
  • frontend/components/NIP46.tsx
  • frontend/components/Onboarding.tsx
  • frontend/components/Recover.tsx
  • frontend/components/Signer.tsx
  • frontend/components/nip46/Permissions.tsx
  • frontend/components/nip46/QRScanner.tsx
  • frontend/components/nip46/RelaySettings.tsx
  • frontend/components/nip46/Requests.tsx
  • frontend/components/ui/button.tsx
  • frontend/components/ui/card.tsx
  • frontend/components/ui/collapsible.tsx
  • frontend/components/ui/event-log.tsx
  • frontend/components/ui/icon-button.tsx
  • frontend/components/ui/input-with-validation.tsx
  • frontend/components/ui/modal.tsx
  • frontend/components/ui/peer-list.tsx
  • frontend/components/ui/relay-input.tsx
  • frontend/components/ui/tooltip.tsx
  • frontend/styles.css
  • frontend/tsconfig.json
  • frontend/types/index.ts
  • frontend/types/nostr-connect.d.ts
  • llm/implementation/auth-implementation.md
  • llm/implementation/node-lifecycle-implementation.md
  • llm/implementation/umbrel-implementation.md
  • llm/workflows/RELEASE_PROCESS.md
  • package.json
  • packages/umbrel/igloo/Dockerfile
  • scripts/api/README.md
  • scripts/api/test-get-endpoints.ts
  • scripts/api/test-ws-events.ts
  • scripts/fetch-swagger-ui.mjs
  • scripts/patch-zod-compat.mjs
  • scripts/release.sh
  • scripts/start-headless.js
  • src/class/relay.test.ts
  • src/class/relay.ts
  • src/config/crypto.ts
  • src/const.ts
  • src/db/database.ts
  • src/db/migrations/20250916_0004_audit_nip46_data_sizes.sql
  • src/db/migrations/20250922_0007_add_nip46_relays.sql
  • src/db/migrator.ts
  • src/db/nip46.ts
  • src/db/ui-event-log.ts
  • src/nip46/index.ts
  • src/nip46/service.ts
  • src/node/manager.ts
  • src/routes/admin.ts
  • src/routes/auth-factory.ts
  • src/routes/auth.ts
  • src/routes/docs.ts
  • src/routes/env.ts
  • src/routes/index.ts
  • src/routes/nip04.ts
  • src/routes/nip44.ts
  • src/routes/nip46.ts
  • src/routes/node-manager.ts
  • src/routes/onboarding.ts
  • src/routes/peers.ts
  • src/routes/sign.ts
  • src/routes/status.ts
  • src/routes/update.ts
  • src/routes/user.ts
  • src/routes/utils.test.ts
  • src/routes/utils.ts
  • src/server.ts
  • src/types/global.d.ts
  • src/utils/rate-limiter.ts
  • static/docs/swagger-ui-bundle.js
  • static/index.html
  • tests/routes/admin.api-keys.negatives.spec.ts
  • tests/routes/admin.whoami.session.spec.ts
  • tests/routes/auth.rehydrate.spec.ts
  • tests/routes/body-limit.spec.ts
  • tests/routes/env.db-mode.spec.ts
  • tests/routes/helpers/script-runner.spec.ts
  • tests/routes/helpers/script-runner.ts
  • tests/routes/nip46.spec.ts
  • tests/routes/onboarding.spec.ts
  • tests/routes/protected.api.spec.ts
  • tests/routes/status-env.test.ts
  • tests/routes/user-peers.spec.ts
💤 Files with no reviewable changes (1)
  • frontend/styles.css

Comment thread docs/openapi/openapi.yaml Outdated
Comment thread docs/openapi/openapi.yaml
Comment thread frontend/components/nip46/Requests.tsx
Comment thread frontend/components/Recover.tsx
Comment thread frontend/components/Recover.tsx Outdated
Comment thread src/routes/nip46.ts
Comment thread src/routes/onboarding.ts Outdated
Comment thread src/routes/sign.ts Outdated
Comment thread src/routes/sign.ts Outdated
Comment thread static/index.html
@AustinKelsay AustinKelsay merged commit f8a8e73 into dev Mar 5, 2026
5 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