Skip to content

feat(mobile): show real orgs, servers, detections and scans; register Expo push - #241

Merged
ralyodio merged 2 commits into
masterfrom
feat/mobile-real-data
Sep 25, 2026
Merged

ralyodio merged 2 commits into
masterfrom
feat/mobile-real-data

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

The mobile app shipped a placeholder: index.js rendered a "bundle isolation" splash screen, the real screens sat in app-disabled/, and src/stores/events.ts served hardcoded threats, modules and stats. This PR replaces all of it with an app that signs in against the real auth API and shows the user's actual orgs, servers, detections, remediations and scan results. The demo path is deleted outright, with no fallback to fake data.

Mobile (apps/mobile)

  • Entry is now expo-router/entry. Screens: login, then four tabs (Threats, Servers, Scans, Settings) plus an org switcher.
  • src/lib/api.ts is a thin client over existing routes. Its ApiError keeps the status and the server's own error message.
  • src/stores/auth.ts holds the Supabase session from /api/auth/login in SecureStore. It refreshes before expiry and on a 401. Refreshes are single-flight because refresh tokens rotate: three parallel 401s must not burn the token three times and log the user out. A rejected refresh signs the user out.
  • src/stores/workspace.ts loads orgs, picks the profile's current_org_id (the same one the web dashboard uses), then fetches servers, detections (paged), remediations and runs. Switching orgs drops the old org's data and ignores late replies from the previous org.
  • src/stores/push.ts handles Expo push (see P1 below).
  • The empty states say what is actually true: no org, no servers (with how to connect one), no detections (with why), no scans.
  • Removed: the demo stores and modules screen, the fake "E2E ENABLED" toggle (crypto.ts + tweetnacl, never wired to anything), the "Skip → use demo mode" login, App.tsx/index.js/app-disabled/.
  • vitest.config.ts and store tests added. The mobile typecheck and tests now run in PR Checks. Typecheck previously failed on process in app.config.ts; @types/node fixes that.

Web API (apps/web) — three small additions and one removal. They follow the existing pattern: bearer token through lib/api-auth.ts, an organization_members check, parsePaginationParam, and middleware rate limiting.

  • GET /api/orgs/:id/runs: recent scan/pentest runs across every property in the org, with the property name/kind/target. It leaves out the large output/findings columns. Limit is 1..100 (default 25), plus offset and an optional status filter. Without it, a feed would need one request per property.
  • POST /api/auth/refresh: {refresh_token} → a rotated session. Without it, every bearer client (mobile, and the CLI, which already stores refresh_token but never uses it) had to ask for the password every hour. It has its own rate-limit rule (30/min/IP).
  • POST / DELETE /api/orgs/:id/push-subscriptions: registers or forgets an Expo push token in the existing push_subscriptions table. No migration needed:
    • endpoint holds the token; keys = {provider: "expo", platform}.
    • Upsert is keyed on (user_id, endpoint).
    • Only real Expo(nent)PushToken[...] values are accepted, so the table can't be used to park arbitrary URLs for a future sender.
    • DELETE only touches the caller's own row and deliberately skips the membership check, so a user removed from the org can still sign out cleanly.
  • Removed GET /api/events. Its only consumer was the mobile demo client, and it returned events: [] with a "threats" count that was really the number of phone_verification_codes rows with attempts > 0.

New dependency: expo-notifications@~0.29.14 (the SDK 52 version). It is needed to get an Expo push token; there is no alternative in the tree. The lockfile change beyond it is pnpm re-resolving vite's optional jiti peer from 1.21.7 to 2.6.1 for electron-vite. apps/desktop still builds with it (pnpm build ✓).

Data map (what exists server-side today)

Table (migration) Written by Readable via (bearer + membership) Real today?
organizations, organization_members (20260412) web / threatcrush orgs create → POST /api/orgs (trigger adds owner) GET /api/orgs ✅
servers (20260412) web "Add Server" → POST /api/orgs/:id/servers GET /api/orgs/:id/servers ✅ rows. status/last_seen/threatcrushd_version only change via an /api/ingest heartbeat, which nothing sends, so every server reads offline / never seen
properties, property_runs (20260419*) web/CLI enqueue. threatcrushd runs-worker claims (POST /runs/pending) and PATCHes results GET /api/orgs/:id/properties, …/properties/:pid/runs, new GET /api/orgs/:id/runs ✅ the only daemon→cloud data path
detections (20260601) POST /api/ingest only GET /api/orgs/:id/detections (limit/offset, total), …/servers/:sid/detections ❌ nothing calls /api/ingest: not the daemon, CLI, desktop or extension
remediation_actions (20260601) web Remediations page → POST /api/orgs/:id/remediations GET /api/orgs/:id/remediations ⚠️ rows are real, but no daemon consumes them, so they stay pending
hardening_findings (20260601) nothing (only a status PATCH) GET …/servers/:sid/findings ❌
alert_destinations, alert_rules (20260601) CRUD routes CRUD routes ⚠️ configurable, but nothing evaluates them on ingest
push_subscriptions (20260601) new push-subscriptions route n/a table existed with no route
/api/servers/:id/events POST acknowledges and discards; GET always [] ❌ (left alone, not used by mobile)

The real gap: no daemon → cloud detection pipeline

threatcrushd detects locally and alerts through the TUI/IPC, SMTP, Discord and PagerDuty (apps/cli/src/daemon/alerts). It never uploads detections. /api/ingest exists and works (the smoke test below drives it), but has no producer. This blocks mobile, the extension and the web dashboard from showing live threats. It also blocks sending pushes, because there is nothing to push about. I did not build it here. Minimal design:

  1. Identity. The daemon already has the CLI session in ~/.threatcrush/config.json; runs-worker uses it. Add a server_id to that config: threatcrush servers link [name] matches or creates the org's server row by hostname. Longer term, use a per-server enrollment key (PRD-00 §9) instead of a user JWT, and refresh the token with /api/auth/refresh (added here) because access tokens last an hour.
  2. Uploader worker next to runs-worker. It subscribes to the event bus for detections at or above a configurable minimum severity (default medium). It batches up to 100 detections or 10 s into POST /api/ingest {events:[{type:"detection", server_id, rule_id, severity, title, description, source_ip, detected_at, raw_metadata}]} and sends a heartbeat every 60 s. Failed batches go to a bounded on-disk spool (drop oldest) so an outage doesn't grow memory. Uploading is independent of auto-defend, so no ban behaviour changes.
  3. Ingest hardening.
    • Look up server access once per batch instead of twice per event.
    • Dedupe on (server_id, rule_id, source_ip, 1-minute bucket) so a flood becomes one row with a count.
    • Validate with a shared schema.
  4. Fan-out, after insert and in the same request or a queue:
    • Evaluate alert_rules.
    • For push, select push_subscriptions where organization_id = … and keys->>'provider' = 'expo', POST https://exp.host/--/api/v2/push/send in chunks of 100, and delete tokens whose receipts say DeviceNotRegistered.

P1 push

Registration is done. After sign-in, if the OS permission is still undecided, the Threats tab shows a card before the system prompt. It says what alerts are for and that alerts aren't flowing yet because servers don't upload detections. Nothing prompts on launch. If permission is already granted, the token is registered silently for the current org, re-registered when the org changes, and unregistered on sign-out. Settings shows the real state (on / off / blocked in system settings / not available / error) with a link to system settings.

Server-side sending is intentionally not implemented: there is no real detection source to trigger it (see above). It is step 4 of the design.

How verified

  • Web route tests (new): orgs/[id]/runs 7, orgs/[id]/push-subscriptions 14, auth/refresh 7. They cover:
    • auth
    • non-member 403
    • org scoping
    • limit clamping and offset
    • input validation (URL instead of token, bad platform, malformed JSON)
    • upsert keying
    • caller-scoped delete, including after leaving the org
    • Supabase error handling
  • Mobile store tests (new, 23): fetch is stubbed at the HTTP boundary. Mutation-checked: removing the refresh single-flight fails "shares one refresh between concurrent 401s", and removing the org-switch guard fails the late-reply test.
  • pnpm --filter @profullstack/threatcrush-mobile typecheck ✓. npx expo export --platform android --platform ios ✓ (Android 1250 modules, iOS 1245, 3.54 MB Hermes bundle each; expo config --type prebuild resolves the expo-notifications plugin and the library manifest adds POST_NOTIFICATIONS).
  • Full web suite: 55 files, 476 tests ✓. next build ✓ (the new routes are in the route table, /api/events is gone).
  • End-to-end smoke against a real stack:
    • Setup: local Supabase (supabase start, every repo migration applied) and next dev from this branch.
    • Data: a confirmed test user and two orgs created through POST /api/orgs, then a server added through the servers route.
    • Ingest: two detections plus a heartbeat through the real POST /api/ingest.
    • Remediation: a block through POST …/remediations.
    • Property run: a property and run were enqueued, claimed through POST …/runs/pending, and completed through the PATCH route, the same way runs-worker does it.
    • The org choice persists: after the UI run switched to the empty org, the next sign-in opened on it, because current_org_id had been saved through PATCH /api/auth/me.
    • A valid refresh token → 200 with a rotated refresh_token.
    • Registering a push token through the route stores one row in the org with keys = {provider: "expo", platform: "android"}. The app-side flow is covered by the push store tests; the web build has no push, and Settings correctly shows "Not available in this build".
    • curl checks:
      • /runs returns {total: 1, runs: [{status: succeeded, findings_count: 3, severity_summary: {high:1, medium:2}, property: {...}}]} with no output field.
      • Bad status → 400; no token → 401; an outsider user → 403 on /runs and on push register.
      • Push upsert keeps one row; a URL token → 400; DELETE removes the row.
      • A garbage refresh token → 401. /api/events → 404.
      • These checks ran against the rebased branch head.
    • UI: the Expo web build, driven with headless Chromium at 390×844 through a same-origin proxy (react-native-web and a localStorage SecureStore shim were installed only for this run and are not committed). The run went through login, a wrong password (shows "Invalid login credentials"), then Threats (2 detections, 1 critical and 1 high, with server, source IP and rule), Servers (web-1 with its heartbeat and version, plus the pending block remediation) and Scans (the succeeded run with 3 findings, 1 high and 2 medium). It then switched to the empty org: Scans shows "No scans yet" with the add-property hint, and Threats shows "No servers connected yet" with install steps. After that it opened Settings (account, org role, push state, API URL) and signed out back to the login screen. The only console error was the expected 401 from the wrong password. This run caught a "1 runs" label, which is fixed.
    • The CLI commands in the empty states exist (threatcrush properties add <name> <target>, install.sh), as do the web pages the links open (/org/:slug/properties/new, /org/:slug/servers/new).
  • apps/desktop test ✓ and pnpm build ✓ with the lockfile change.
  • Docs: docs/SURFACES.md and docs/MOBILE_RELEASE_TODO.md now say the app shows real data and that push is registration-only.

Blocked on the account owner

  • Apple Developer Program membership ($99/yr) plus an App Store Connect app record for com.threatcrush.mobile. It is needed for any iOS build and for APNs. Then run eas credentials → iOS (distribution certificate and push key).
  • Google Play Console developer account ($25) and a service-account JSON for eas submit (eas.json → submit.production.android.serviceAccountKeyPath).
  • FCM for Android push: a Firebase project for com.threatcrush.mobile. Upload google-services.json as the EAS file variable GOOGLE_SERVICES_JSON (app.config.ts picks it up automatically when present) and the FCM V1 service-account key in eas credentials → Android → Push Notifications. Without it, the app builds and "Turn on alerts" shows Firebase's error instead of registering.
  • APNs key for iOS push: from the Apple account above, through eas credentials.
  • Store listing assets (screenshots, privacy policy URL, data-safety form).

Residual risks

  • The copy that says "servers don't upload detections yet" must change when the ingest uploader ships.
  • Existing refresh behaviour: GoTrue allows reusing a refresh token within its reuse interval (10 s by default). The single-flight covers the app itself.
  • A device registers for one org at a time (UNIQUE(user_id, endpoint) in the existing table). That is fine for alerts on the org you're viewing; multi-org alerts would need a schema change.
  • On-device behaviour (SecureStore, notification prompt, real push token) was verified by the store tests with those native modules mocked, and the screens were checked on web. No physical device or emulator was available here.

… Expo push

Replace the placeholder entry and demo stores with an expo-router app that
signs in against /api/auth/login and reads the user's orgs, servers,
detections, remediations and scan runs. Add GET /api/orgs/:id/runs,
POST /api/auth/refresh and POST/DELETE /api/orgs/:id/push-subscriptions;
remove the stub GET /api/events. Run mobile typecheck and tests in PR Checks.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedexpo-notifications@​0.29.1478100100100100

View full report

if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
refreshToken = "refresh_token" in body ? body.refresh_token : undefined;
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

13 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 6 | LOW: 6

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:104
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:108
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:265
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential apps/web/src/app/api/auth/refresh/route.ts:17
LOW secret-generic-credential PRD.md:269
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:47
LOW secret-aws-access-key scripts/smoke-test.sh:112

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 22b99bc into master Sep 25, 2026
12 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.

2 participants