Skip to content

feat: add security notifications for remote access#17

Merged
TraderSamwise merged 9 commits into
masterfrom
feat/security-notifications
May 24, 2026
Merged

feat: add security notifications for remote access#17
TraderSamwise merged 9 commits into
masterfrom
feat/security-notifications

Conversation

@TraderSamwise

@TraderSamwise TraderSamwise commented May 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • add relay security state for remote client/device events
  • notify the local daemon, other clients, email, and push channels for first-time remote devices
  • add emergency lockdown links, CLI unlock recovery, and app inbox surfacing
  • document deployment vars and MVP security policy

Verification

  • yarn typecheck
  • yarn --cwd relay typecheck
  • yarn --cwd app typecheck
  • yarn --cwd relay wrangler deploy --dry-run --env production
  • pre-push: yarn typecheck && yarn lint && yarn test

Summary by CodeRabbit

  • New Features

    • Security inbox: new section with unread badge, per-alert rows, mark-all-read and clear actions
    • Combined inbox unread counts shown in mobile tab bar and bell button
    • Mobile push registration for security alerts and native push delivery
    • CLI: added aimux security unlock command for recovery after lockdown
    • Emergency lockdown links and delivery via email/push
  • Documentation

    • Added full security notifications guide and updated deployment instructions

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements remote-access security: stable client device IDs, relay durable security state, first-time device detection with multi-channel alerts, emergency lockdown with single-use action tokens, client-side persistence and UI for security events, push registration, and a CLI unlock flow.

Changes

Remote Access Security: Device Tracking, Notifications, and Lockdown

Layer / File(s) Summary
Security domain model and relay persistence
relay/src/security.ts, relay/src/types.ts
Defines security state, device/action/event record types, load/save/normalize helpers, IP hashing, action-token creation/validation, and lockdown state transitions.
Security alert delivery
relay/src/security-delivery.ts
Sends alerts via Resend email and Expo push concurrently; composes HTML/text emails and filters current device from push recipients.
Relay durable object: connections & actions
relay/src/relay-object.ts
Routes security endpoints, records client connections, emits security_events, enforces device policy, and implements emergency-lockdown action GET/POST flows that close sockets and persist lockdown.
Daemon token verification
relay/src/daemon-token.ts
Extracts full daemon JWT payload (including iat) via verifyDaemonTokenPayload and refactors verification usage.
Relay index: routing & header propagation
relay/src/index.ts
Adds /security/action/* and /security/push-token proxies, extends /cli/issue-token to accept unlockSecurity, and sets X-Aimux-User-Id / X-Aimux-Daemon-Iat on forwarded requests.
Relay transport: device-aware connect & events
app/lib/relay-transport.ts
Includes device info in /client/connect URL, validates security_event messages, and exposes onSecurityEvent subscription.
Client device identity
app/lib/client-device.ts
Provides getClientDeviceInfo() with persistent deviceId and platform-normalized kind/name.
Push token registration (client)
app/lib/push-registration.ts
Requests permissions, fetches Expo push token, and posts device token to relay /security/push-token with auth.
App layout: security subscription & push registration
app/app/(main)/_layout.tsx
Subscribes to relay security events to update Jotai store, triggers browser notifications when hidden, and registers native push tokens to relay.
Client-side security store
app/stores/security.ts, app/stores/security.test.ts
Jotai persisted atoms for security events with addSecurityEventAtom, markSecurityEventsReadAtom, clearSecurityEventsAtom, and securityUnreadCountAtom; tests validate persistence/unread behavior.
Security notifications UI & badges
app/app/(main)/(tabs)/(inbox)/notifications.tsx, app/components/*, app/package.json
Adds Security sub-inbox, SecurityEventRow UI, unread pill, and combines security unread counts into Inbox badges; adds expo-notifications dependency.
Daemon notifications & client handling
src/notify.ts, src/relay-client.ts, src/relay-client.test.ts
Adds notifyRemoteClientConnected for local notifications; relay client handles security_event.client_connected by calling it; tests assert notification dispatch.
CLI unlock and login-flow
src/main.ts, src/login-flow.ts, app/app/cli-auth.tsx
Adds aimux security unlock CLI command, extends login flow and CliAuthScreen to carry action=security-unlock and pass unlockSecurity during token issuance.
Docs and deployment
docs/security-notifications.md, docs/notification-system.md, docs/deployment.md, relay/wrangler.toml, .env.example
Adds full security-notifications spec, updates notification model guidance, deployment steps for Resend and relay env vars, and .env.example for IP hash secret.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • TraderSamwise/aimux#16: Modifies inbox notifications UI and tab badge wiring; this PR extends that area with a Security sub-inbox.
  • TraderSamwise/aimux#4: Related prior work on relay and CLI auth flows that this PR extends with security-unlock and security-event routing.

"🐰 A tiny rabbit guards the gate with care,

New shields and bells sound warnings in the air,
When strangers knock the sockets close with speed,
Then CLI keys return the door, the rabbits heed."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing security notifications for remote access events across the application, relay, and CLI components.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/security-notifications

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

app/lib/client-device.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

app/lib/push-registration.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

app/lib/relay-transport.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (4)
relay/src/security-delivery.ts (2)

72-79: ⚡ Quick win

Silent failure on push delivery may hide critical alert failures.

Same concern as email: the Expo push call doesn't check res.ok. Push notifications are a key alert channel for security events.

Proposed fix to add error logging
-  await fetch("https://exp.host/--/api/v2/push/send", {
+  const res = await fetch("https://exp.host/--/api/v2/push/send", {
     method: "POST",
     headers: {
       Accept: "application/json",
       "Content-Type": "application/json",
     },
     body: JSON.stringify(messages),
   });
+  if (!res.ok) {
+    console.error(`Security push delivery failed: ${res.status} ${res.statusText}`);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/src/security-delivery.ts` around lines 72 - 79, The Expo push fetch
currently ignores the response, causing silent failures; update the push-sending
logic (the fetch call that posts JSON.stringify(messages)) to await and capture
the response, check response.ok, and on failure call the existing logger/error
handler with contextual details (status, statusText and response body or text)
and surface or throw an error so callers know delivery failed; ensure you
reference the same messages payload and preserve headers while adding the
response check and logging.

31-44: ⚡ Quick win

Silent failure on email delivery may hide critical alert failures.

The fetch call to Resend doesn't check res.ok or log failures. For security alerts, silent failures could mean users never learn about unauthorized access attempts. Consider logging errors for observability.

Proposed fix to add error logging
-  await fetch("https://api.resend.com/emails", {
+  const res = await fetch("https://api.resend.com/emails", {
     method: "POST",
     headers: {
       Authorization: `Bearer ${input.env.RESEND_API_KEY}`,
       "Content-Type": "application/json",
     },
     body: JSON.stringify({
       from: input.env.SECURITY_EMAIL_FROM,
       to: [email],
       subject: input.event.title,
       html,
       text: securityEmailText(input.event, input.emergencyUrl),
     }),
   });
+  if (!res.ok) {
+    console.error(`Security email delivery failed: ${res.status} ${res.statusText}`);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/src/security-delivery.ts` around lines 31 - 44, The fetch call to
Resend that sends the security email should check the HTTP response and surface
failures instead of silently ignoring them; update the logic around the existing
fetch(...) call to capture the response, verify res.ok, and on non-ok responses
log the status and body (or error) along with contextual info (e.g.,
input.event.title, email, and input.emergencyUrl) using the project's logger (or
console.error if no logger is available); also handle fetch exceptions with a
try/catch to log the thrown error so failed deliveries for securityEmailText
notifications are observable.
relay/src/relay-object.ts (1)

382-388: 💤 Low value

Missing deviceId always causes rejection when policy is "enforce".

When SECURITY_DEVICE_POLICY === "enforce", a client without a deviceId (e.g., older client versions) will have all requests rejected with 403. This could break backward compatibility. Consider whether this is intentional or if you want a more graceful fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/src/relay-object.ts` around lines 382 - 388, The current
shouldRejectClientRequest in relay-object.ts rejects any client without a
deviceId when SECURITY_DEVICE_POLICY === "enforce", breaking older clients;
update shouldRejectClientRequest (and references to clientDeviceIds and
deviceIdFromTags) so that a missing deviceId does not automatically cause
rejection—return false (allow) instead of true for absent deviceId, and
optionally log a warning; keep the existing loadSecurityState and
isDeviceApproved flow for cases where deviceId exists so approved devices remain
enforced.
src/relay-client.test.ts (1)

31-50: ⚡ Quick win

Add a negative-path test for non-client_connected security events.

This ensures the notification bridge only fires for the intended event kind and prevents regressions as new security event kinds are added.

Suggested test addition
+  it("does not notify for other security_event kinds", async () => {
+    const daemon = { routeRequest: vi.fn() } as unknown as AimuxDaemon;
+    const client = new RelayClient("wss://relay.aimux.app/", "token", daemon);
+    const message = JSON.stringify({
+      type: "security_event",
+      event: {
+        kind: "lockdown_enabled",
+        title: "Lockdown enabled",
+        body: "All remote clients were disconnected",
+        createdAt: new Date().toISOString(),
+      },
+    });
+
+    await (client as unknown as { handleMessage(data: string): Promise<void> }).handleMessage(message);
+    expect(notifyRemoteClientConnected).not.toHaveBeenCalled();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/relay-client.test.ts` around lines 31 - 50, Add a negative-path unit test
that ensures RelayClient.handleMessage does not call notifyRemoteClientConnected
for security_event messages whose event.kind is not "client_connected": create a
RelayClient instance (as in the existing test), craft a JSON message with type
"security_event" and event.kind set to some other value (e.g.,
"client_disconnected"), call (client as unknown as { handleMessage(data:
string): Promise<void> }).handleMessage(message), and assert
notifyRemoteClientConnected was not called (and reset or spy on the mock as
needed). This should reference the RelayClient class, its handleMessage method,
and the notifyRemoteClientConnected mock used in the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/lib/client-device.ts`:
- Around line 30-36: getOrCreateDeviceId can race when called concurrently;
introduce a module-level cache/in-flight guard (e.g., cachedDeviceId and
deviceIdPromise) so concurrent callers share the same resolution: first check
cachedDeviceId and return if present, else if deviceIdPromise exists return it,
otherwise set deviceIdPromise to an async flow that calls readDeviceId,
generates next via randomId, writes via writeDeviceId, assigns cachedDeviceId,
clears deviceIdPromise, and returns the id; update references to
getOrCreateDeviceId to rely on this memoized behavior.

In `@app/lib/push-registration.ts`:
- Around line 19-21: Before calling Notifications.getExpoPushTokenAsync in
app/lib/push-registration.ts, ensure Android notification channel is configured
by calling Notifications.setNotificationChannelAsync first (only on Android).
Update the registration flow to detect Android (Platform.OS === 'android') and
call Notifications.setNotificationChannelAsync('default', { name: 'default',
importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250,
250], lightColor: '`#FF231F7C`' }) or similar channel settings, then call
Notifications.getExpoPushTokenAsync (preserving the existing projectId param) so
token retrieval won't be blocked on Android.

In `@app/lib/relay-transport.ts`:
- Around line 207-213: The handler trusts msg.event as a SecurityEventRecord and
may pass malformed payloads to this.securityEventListeners; update the block
that checks msg.type === "security_event" to validate that msg.event is a
non-null object and that required string fields defined by SecurityEventRecord
are present (e.g., check typeof field === "string" for each required property),
only then call listener(msg.event); otherwise skip notifying listeners and emit
a warning or debug log (use the existing logger) so invalid payloads are dropped
safely.
- Around line 92-93: connect() currently awaits this.getDeviceInfo() without
catching rejection, which can cause an unhandled rejection and skip the normal
disconnect/reconnect/status updates; wrap the getDeviceInfo() call in a
try/catch inside connect(), and on error set the transport status (e.g. via
this.setStatus or this.status) to a failed/disconnected state, call the same
disconnect/error handling path you use elsewhere (e.g. this.disconnect() or
this.handleDisconnect()), and ensure you schedule a reconnect (e.g.
this.scheduleReconnect()) before rethrowing or returning so the retry logic runs
consistently; keep the subsequent use of this.clientConnectUrl(deviceInfo)
inside the try block so it only runs when deviceInfo was obtained successfully.

In `@relay/src/security.ts`:
- Around line 149-153: hashIpAddress currently uses a raw SHA-256
(sha256Base64Url) which is reversible via dictionaries; change it to a keyed
HMAC using a secret key so IPs are pseudonymized. Replace the direct call to
sha256Base64Url inside hashIpAddress with an HMAC-SHA256 computed with a
securely-provided key (e.g. injected config or process.env IP_HASH_KEY) and
return the base64url result; ensure the function validates the presence of the
key and fails/throws or returns undefined if the key is missing, and keep the
normalization logic intact so callers still call hashIpAddress(ip).
- Around line 113-122: normalizeSecurityState currently preserves all entries in
state.actions causing unbounded growth; update normalizeSecurityState to compact
actions by filtering out expired or already-used/consumed actions and limit the
retained entries. Specifically, iterate over state.actions (the object passed
into normalizeSecurityState), remove any action where a TTL/expiry (e.g.,
expiresAt) is in the past or a used/consumed flag is true, and keep only the
most recent N actions (introduce a MAX_SECURITY_ACTIONS constant) before
returning the normalized actions map; this will prevent the token lookup routine
that scans actions from iterating over stale entries.
- Line 141: Don't collapse invalid/missing device IDs into the shared string
"unknown"; instead set deviceId to null/undefined (e.g., deviceId:
sanitizeId(input?.deviceId) ?? null) or generate a per-request distinct
placeholder and update the merge logic that currently combines clients (the
block that sets/merges approvedAt/blockedAt for device records) so it treats
null/undefined (or distinct placeholders) as non-equal and does not merge those
entries. Update any checks that detect "first-time-device" to rely on the
presence of a real deviceId (not equality to "unknown") and ensure sanitizeId,
deviceId, and the merging code that touches approvedAt/blockedAt are adjusted
accordingly.

In `@src/notify.ts`:
- Around line 96-98: The notifyRemoteClientConnected handler currently uses
truthy fallback (input.title || ...) and can pass non-string truthy values into
downstream AppleScript escaping (escapeAppleScriptString) causing runtime
crashes; change notifyRemoteClientConnected so it explicitly validates/coerces
input.title and input.body to strings before calling sendSecurity (e.g., check
typeof input.title === "string" and typeof input.body === "string" and otherwise
use the default literals), and ensure sendSecurity/AppleScript dispatch always
receives plain strings (reference notifyRemoteClientConnected, sendSecurity, and
escapeAppleScriptString when making the fix).

---

Nitpick comments:
In `@relay/src/relay-object.ts`:
- Around line 382-388: The current shouldRejectClientRequest in relay-object.ts
rejects any client without a deviceId when SECURITY_DEVICE_POLICY === "enforce",
breaking older clients; update shouldRejectClientRequest (and references to
clientDeviceIds and deviceIdFromTags) so that a missing deviceId does not
automatically cause rejection—return false (allow) instead of true for absent
deviceId, and optionally log a warning; keep the existing loadSecurityState and
isDeviceApproved flow for cases where deviceId exists so approved devices remain
enforced.

In `@relay/src/security-delivery.ts`:
- Around line 72-79: The Expo push fetch currently ignores the response, causing
silent failures; update the push-sending logic (the fetch call that posts
JSON.stringify(messages)) to await and capture the response, check response.ok,
and on failure call the existing logger/error handler with contextual details
(status, statusText and response body or text) and surface or throw an error so
callers know delivery failed; ensure you reference the same messages payload and
preserve headers while adding the response check and logging.
- Around line 31-44: The fetch call to Resend that sends the security email
should check the HTTP response and surface failures instead of silently ignoring
them; update the logic around the existing fetch(...) call to capture the
response, verify res.ok, and on non-ok responses log the status and body (or
error) along with contextual info (e.g., input.event.title, email, and
input.emergencyUrl) using the project's logger (or console.error if no logger is
available); also handle fetch exceptions with a try/catch to log the thrown
error so failed deliveries for securityEmailText notifications are observable.

In `@src/relay-client.test.ts`:
- Around line 31-50: Add a negative-path unit test that ensures
RelayClient.handleMessage does not call notifyRemoteClientConnected for
security_event messages whose event.kind is not "client_connected": create a
RelayClient instance (as in the existing test), craft a JSON message with type
"security_event" and event.kind set to some other value (e.g.,
"client_disconnected"), call (client as unknown as { handleMessage(data:
string): Promise<void> }).handleMessage(message), and assert
notifyRemoteClientConnected was not called (and reset or spy on the mock as
needed). This should reference the RelayClient class, its handleMessage method,
and the notifyRemoteClientConnected mock used in the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bc065c9-e609-4ba7-8a1a-3dade0d33821

📥 Commits

Reviewing files that changed from the base of the PR and between 6619b5e and 94a035d.

⛔ Files ignored due to path filters (1)
  • app/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (27)
  • app/app/(main)/(tabs)/(inbox)/notifications.tsx
  • app/app/(main)/_layout.tsx
  • app/app/cli-auth.tsx
  • app/components/MobileTabBar.tsx
  • app/components/NotificationBellButton.tsx
  • app/components/ProjectSidebar.tsx
  • app/lib/client-device.ts
  • app/lib/push-registration.ts
  • app/lib/relay-transport.ts
  • app/package.json
  • app/stores/security.test.ts
  • app/stores/security.ts
  • docs/deployment.md
  • docs/notification-system.md
  • docs/security-notifications.md
  • relay/src/daemon-token.ts
  • relay/src/index.ts
  • relay/src/relay-object.ts
  • relay/src/security-delivery.ts
  • relay/src/security.ts
  • relay/src/types.ts
  • relay/wrangler.toml
  • src/login-flow.ts
  • src/main.ts
  • src/notify.ts
  • src/relay-client.test.ts
  • src/relay-client.ts

Comment thread app/lib/client-device.ts
Comment thread app/lib/push-registration.ts
Comment thread app/lib/relay-transport.ts Outdated
Comment thread app/lib/relay-transport.ts
Comment thread relay/src/security.ts
Comment thread relay/src/security.ts Outdated
Comment thread relay/src/security.ts Outdated
Comment thread src/notify.ts Outdated
@TraderSamwise

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/notify.ts (1)

96-99: Rebuild dist/ before validating this runtime change.

Since this is a src/*.ts CLI/runtime change, run yarn build at repo root before runtime verification, and restart/reload any running daemon/runtime to avoid stale behavior.

As per coding guidelines: “changes to src/*.ts Node CLI require running yarn build at the repo root to update dist/ … [and] restart or reload the relevant runtime after the build.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/notify.ts` around lines 96 - 99, The runtime change in src/notify.ts
affecting the function notifyRemoteClientConnected requires rebuilding the
compiled outputs and restarting any running daemon to avoid stale behavior; run
yarn build at the repo root to update dist/, then restart or reload the relevant
CLI/runtime process so the updated notifyRemoteClientConnected (and its use of
sendSecurity) is picked up before validating the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/notify.ts`:
- Around line 96-99: The runtime change in src/notify.ts affecting the function
notifyRemoteClientConnected requires rebuilding the compiled outputs and
restarting any running daemon to avoid stale behavior; run yarn build at the
repo root to update dist/, then restart or reload the relevant CLI/runtime
process so the updated notifyRemoteClientConnected (and its use of sendSecurity)
is picked up before validating the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 649c803d-7c2c-4624-a253-da2773b809b8

📥 Commits

Reviewing files that changed from the base of the PR and between 94a035d and cb0095e.

📒 Files selected for processing (9)
  • .env.example
  • app/lib/client-device.ts
  • app/lib/push-registration.ts
  • app/lib/relay-transport.ts
  • docs/deployment.md
  • relay/src/relay-object.ts
  • relay/src/security.ts
  • relay/src/types.ts
  • src/notify.ts
✅ Files skipped from review due to trivial changes (1)
  • .env.example

@TraderSamwise
TraderSamwise merged commit b4a5fc5 into master May 24, 2026
1 check passed
@TraderSamwise
TraderSamwise deleted the feat/security-notifications branch May 24, 2026 06:43
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