From a29d9b775e4a382c5bd50ebe9443eb7072008d58 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:30:51 +0100 Subject: [PATCH] [feat](client): address one instance origin instead of connection modes Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it. --- __tests__/connection.test.ts | 144 ++++++++++++++++ scripts/android.sh | 14 +- src/screens/LoginScreen.tsx | 21 ++- src/services/alarmSocket.ts | 16 +- src/services/apiClient.ts | 94 ++++++----- src/services/connection.ts | 282 ++++++++------------------------ src/services/instanceUrl.ts | 55 +++++++ src/state/ConnectionContext.tsx | 10 +- src/state/useAlarmSocket.ts | 58 +++++-- 9 files changed, 398 insertions(+), 296 deletions(-) create mode 100644 __tests__/connection.test.ts create mode 100644 src/services/instanceUrl.ts diff --git a/__tests__/connection.test.ts b/__tests__/connection.test.ts new file mode 100644 index 0000000..91b3b5f --- /dev/null +++ b/__tests__/connection.test.ts @@ -0,0 +1,144 @@ +/** + * Single-origin transport contract. + * + * The client talks to one instance origin and four paths under it. These are + * pure-string assertions: they pin what the client will send, not that a live + * Maestro accepts the upgrade — that is the E2E harness's job. + */ +import { configureApi, get } from '../src/services/apiClient'; +import { alarmSocketUrl, normalizeInstanceUrl, servicePath } from '../src/services/instanceUrl'; +import { listSubscriptions, removeSubscription } from '../src/services/alarmApi'; +import { probe } from '../src/services/connection'; + +// The probe module reads settings from storage; the URL helpers above do not, +// which is why they live in a storage-free module. +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => undefined), +})); + +describe('normalizeInstanceUrl', () => { + it('accepts a bare host and adds http', () => { + expect(normalizeInstanceUrl('localhost:8080')).toBe('http://localhost:8080'); + }); + + it('keeps https and strips trailing slashes', () => { + expect(normalizeInstanceUrl(' https://dcntrlized.example/ ')).toBe('https://dcntrlized.example'); + expect(normalizeInstanceUrl('http://10.0.2.2:8080///')).toBe('http://10.0.2.2:8080'); + }); + + it('refuses non-http schemes, whitespace and empty values', () => { + expect(normalizeInstanceUrl('')).toBeNull(); + expect(normalizeInstanceUrl(' ')).toBeNull(); + expect(normalizeInstanceUrl('ftp://host')).toBeNull(); + expect(normalizeInstanceUrl('http://')).toBeNull(); + expect(normalizeInstanceUrl('http://host with space')).toBeNull(); + }); +}); + +describe('servicePath', () => { + it('prefixes actions and auth with the service name', () => { + expect(servicePath('/action/createAlert')).toBe('/api/alarm/action/createAlert'); + expect(servicePath('/auth/login')).toBe('/api/alarm/auth/login'); + expect(servicePath('action/getAlert')).toBe('/api/alarm/action/getAlert'); + }); +}); + +describe('alarmSocketUrl', () => { + it('carries the instance prefix and the frozen query', () => { + expect( + alarmSocketUrl({ baseUrl: 'https://instance.example' }, 'a1 b', 'tok/en'), + ).toBe('wss://instance.example/api/alarm/ws/alarm?alertId=a1%20b&token=tok%2Fen'); + }); + + it('keeps ws for a plain-http dev instance', () => { + expect(alarmSocketUrl({ baseUrl: 'http://127.0.0.1:8080' }, 'a', 't')).toBe( + 'ws://127.0.0.1:8080/api/alarm/ws/alarm?alertId=a&token=t', + ); + }); + + it('is null when no instance is configured', () => { + expect(alarmSocketUrl({ baseUrl: '' }, 'a', 't')).toBeNull(); + }); +}); + +describe('probe', () => { + it('reports offline with no base URL when nothing is configured', async () => { + await expect(probe({ instanceUrl: '' })).resolves.toEqual({ baseUrl: '', online: false }); + }); +}); + +describe('HTTP paths and bodies', () => { + const g = globalThis as unknown as { fetch: typeof fetch }; + const realFetch = g.fetch; + const seen: { url: string; body: string | undefined }[] = []; + + beforeEach(() => { + seen.length = 0; + configureApi({ + baseUrl: 'https://instance.example', + token: 'tok', + refreshToken: null, + onUnauthorized: () => {}, + onSessionUpdate: () => {}, + }); + g.fetch = (async (url: string, init?: { body?: string }) => { + seen.push({ url, body: init?.body }); + return { + ok: true, + status: 200, + json: async () => ({ items: [] }), + text: async () => '', + }; + }) as unknown as typeof fetch; + }); + + afterEach(() => { + g.fetch = realFetch; + }); + + it('sends actions through /api/alarm/action with the payload envelope', async () => { + await get('/action/getAlert?alertId=a1'); + expect(seen[0].url).toBe('https://instance.example/api/alarm/action/getAlert?alertId=a1'); + }); + + it('sends revocation with the wire subscription id inside the payload envelope', async () => { + // The service parses `subscriptionId` as a uuid; before the type matched the + // wire, the client sent `undefined` here and the revoke failed with a 400. + await removeSubscription({ alertId: 'a1', subscriptionId: 's1' }); + expect(seen[0].url).toBe( + 'https://instance.example/api/alarm/action/removeSubscription', + ); + expect(JSON.parse(String(seen[0].body))).toEqual({ + payload: { alertId: 'a1', subscriptionId: 's1' }, + }); + }); + + it('unwraps the listSubscriptions `{items}` envelope', async () => { + g.fetch = (async () => ({ + ok: true, + status: 200, + json: async () => ({ + items: [ + { + id: 's1', + deviceLabel: 'Android phone', + platform: 'android', + enabled: true, + created: '2026-10-18T21:00:00Z', + }, + ], + }), + text: async () => '', + })) as unknown as typeof fetch; + await expect(listSubscriptions('a1')).resolves.toEqual([ + { + id: 's1', + deviceLabel: 'Android phone', + platform: 'android', + enabled: true, + created: '2026-10-18T21:00:00Z', + }, + ]); + }); +}); diff --git a/scripts/android.sh b/scripts/android.sh index 92bb246..956ac78 100755 --- a/scripts/android.sh +++ b/scripts/android.sh @@ -4,17 +4,17 @@ # Expects metro already running on 127.0.0.1:8089 (start it with `bun start` # in another terminal). Reverses the adb tunnels so the device reaches the # dev machine via 127.0.0.1, then builds, installs and launches the app: -# - 8080 Maestro (dev default) -# - 8088 Alarm direct mode (bypasses Maestro) — the service's host port +# - 8080 the instance/Maestro origin (dev default; every service path lives +# under it — the client never opens the service's own 8088) # - 8089 the Metro packager (this client's port) -# Each reverse maps device 127.0.0.1: -> host 127.0.0.1:, so Metro -# and the service must keep distinct numbers; 8088 belongs to the service. +# Each reverse maps device 127.0.0.1: -> host 127.0.0.1:. # -# If your phone is on the same LAN instead, turn "Use adb reverse" off in -# Settings and it will fall back to the LAN host baked into src/services/connection.ts. +# The instance address is the one thing the app configures (Settings). Leave it +# at the default http://127.0.0.1:8080 for a USB phone with these reverses, or +# point it at a LAN/WAN origin; there is no direct-service mode to toggle. set -euo pipefail -for port in 8080 8088 8089; do +for port in 8080 8089; do adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true done diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 972e1f7..c43db83 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { useAuth } from '../state/AuthContext'; import { useConnection } from '../state/ConnectionContext'; +import { navigationRef } from '../navigationRef'; import { AppText, Button, @@ -17,7 +18,7 @@ type Mode = 'signin' | 'register'; export default function LoginScreen() { const { login, register } = useAuth(); - const { online, resolved } = useConnection(); + const { online, resolved, settings } = useConnection(); const [mode, setMode] = useState('signin'); const [username, setUsername] = useState(''); @@ -134,11 +135,25 @@ export default function LoginScreen() { }} /> + {/* A release build starts with no instance configured, and sign-in + is impossible until one is set — Settings also owns that field, + so the login screen must be able to reach it. Without this the + app is unconfigurable on a fresh install (Settings was only + reachable from Home, which needs a session). */} +