diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 36c65e1..aafee4f 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -3,8 +3,8 @@ - - + + @@ -52,4 +52,4 @@ - \ No newline at end of file + diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 123ada7..e3b50b8 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -211,6 +211,10 @@ "buildToolsVersion": "36.0.0", "compileSdkVersion": 36, "targetSdkVersion": 36, + "blockedPermissions": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO" + ], "extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }", "useLegacyPackaging": false, "manifestPlaceholders": { diff --git a/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts new file mode 100644 index 0000000..ad82e4e --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts @@ -0,0 +1,67 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { ActionSheetIOS, Alert, Platform } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as ImagePicker from 'expo-image-picker'; +import { useAvatarPicker } from '@/hooks/useAvatarPicker'; + +jest.mock('expo-file-system/legacy', () => ({ + getInfoAsync: jest.fn(), +})); + +jest.mock('expo-image-picker', () => ({ + requestCameraPermissionsAsync: jest.fn(), + requestMediaLibraryPermissionsAsync: jest.fn(), + launchCameraAsync: jest.fn(), + launchImageLibraryAsync: jest.fn(), +})); + +describe('useAvatarPicker', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined); + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] }); + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true }); + }); + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform }); + }); + + it('uses Android system picker without requesting broad media access', async () => { + const { result } = renderHook(() => useAvatarPicker()); + + act(() => result.current.showPicker()); + const chooseFromLibrary = (Alert.alert as jest.Mock).mock.calls[0][2].find( + (option: { text: string }) => option.text === 'Choose from Library' + ); + + await act(async () => chooseFromLibrary.onPress()); + + expect(ImagePicker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled(); + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + exif: false, + }); + }); + + it('keeps the iOS library permission request before opening the picker', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' }); + (ImagePicker.requestMediaLibraryPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + const { result } = renderHook(() => useAvatarPicker()); + + act(() => result.current.showPicker()); + const onSelect = (ActionSheetIOS.showActionSheetWithOptions as jest.Mock).mock.calls[0][1]; + + await act(async () => onSelect(2)); + + expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1); + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts index dcb01a3..35e54a7 100644 --- a/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts +++ b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts @@ -1,4 +1,4 @@ -import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { renderHook, act } from '@testing-library/react-native'; import { Alert } from 'react-native'; import * as MediaLibrary from 'expo-media-library'; import * as FileSystem from 'expo-file-system/legacy'; @@ -41,12 +41,13 @@ describe('useMediaSaver', () => { (FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined); }); - it('initializes with saving as false', () => { - const { result } = renderHook(() => useMediaSaver()); - - expect(result.current.saving).toBe(false); - expect(typeof result.current.saveMedia).toBe('function'); - }); + it('initializes with saving as false', () => { + const { result } = renderHook(() => useMediaSaver()); + + expect(result.current.saving).toBe(false); + expect(typeof result.current.saveMedia).toBe('function'); + expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true }); + }); it('saves local image successfully', async () => { const { result } = renderHook(() => useMediaSaver()); @@ -179,10 +180,8 @@ describe('useMediaSaver', () => { expect(result.current.saving).toBe(false); }); - it('sets saving to true during operation', async () => { - let savingDuringOperation = false; - - (MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => { + it('sets saving to true during operation', async () => { + (MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => { // This would capture the saving state during the async operation // but since we can't access result.current here synchronously, // we'll test this differently diff --git a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts new file mode 100644 index 0000000..789a070 --- /dev/null +++ b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts @@ -0,0 +1,46 @@ +import { act, renderHook } from '@testing-library/react-native'; +import * as MediaLibrary from 'expo-media-library'; +import * as FileSystem from 'expo-file-system/legacy'; +import { useCanvasCapture } from './useCanvasCapture'; + +jest.mock('expo-media-library', () => ({ + requestPermissionsAsync: jest.fn(), + saveToLibraryAsync: jest.fn(), +})); + +jest.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + writeAsStringAsync: jest.fn(), + EncodingType: { Base64: 'base64' }, +})); + +describe('useCanvasCapture', () => { + beforeEach(() => { + jest.clearAllMocks(); + (MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + (MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined); + }); + + it('requests add-only permission before saving the captured image', async () => { + const { result } = renderHook(() => useCanvasCapture()); + result.current.makeSnapshot.current = () => ({ + encodeToBase64: () => 'encoded-image', + }) as never; + + let saved = false; + await act(async () => { + saved = await result.current.saveToGallery(); + }); + + expect(saved).toBe(true); + expect(MediaLibrary.requestPermissionsAsync).toHaveBeenCalledWith(true); + expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith( + expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/) + ); + expect(FileSystem.writeAsStringAsync).toHaveBeenCalledWith( + expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/), + 'encoded-image', + { encoding: 'base64' } + ); + }); +}); diff --git a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts index 19df963..891161c 100644 --- a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts +++ b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts @@ -39,7 +39,9 @@ export function useCanvasCapture(): UseCanvasCaptureReturn { const fileUri = await captureToFile(); if (!fileUri) return false; - const { status } = await MediaLibrary.requestPermissionsAsync(); + // The capture is created by Sauci, so request add-only access rather than + // broad photo-library read access. + const { status } = await MediaLibrary.requestPermissionsAsync(true); if (status !== 'granted') return false; await MediaLibrary.saveToLibraryAsync(fileUri); diff --git a/apps/mobile/src/hooks/useAvatarPicker.ts b/apps/mobile/src/hooks/useAvatarPicker.ts index 53c46ff..5df7b96 100644 --- a/apps/mobile/src/hooks/useAvatarPicker.ts +++ b/apps/mobile/src/hooks/useAvatarPicker.ts @@ -100,13 +100,17 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick quality: 0.8, exif: false, }); - } else { - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { - Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); - return; - } - result = await ImagePicker.launchImageLibraryAsync({ + } else { + // Android's system photo picker grants the selected file directly, so + // selecting an avatar does not need broad media-library access. + if (Platform.OS !== 'android') { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); + return; + } + } + result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, aspect: [1, 1], diff --git a/apps/mobile/src/hooks/useMediaSaver.ts b/apps/mobile/src/hooks/useMediaSaver.ts index 499038f..5f9b5aa 100644 --- a/apps/mobile/src/hooks/useMediaSaver.ts +++ b/apps/mobile/src/hooks/useMediaSaver.ts @@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy'; export function useMediaSaver() { const [saving, setSaving] = useState(false); - const [permissionResponse, requestPermission] = MediaLibrary.usePermissions(); + // Saving app-created/downloaded media only needs add-only access. Passing + // writeOnly prevents this path from requesting broad library read access. + const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ writeOnly: true }); const saveMedia = async (uri: string, type: 'image' | 'video') => { try { diff --git a/docs/development/local-stack.md b/docs/development/local-stack.md index 084d011..c6018be 100644 --- a/docs/development/local-stack.md +++ b/docs/development/local-stack.md @@ -16,6 +16,22 @@ described by their environment examples. | MCP | Hono | 3002 | | Expo Metro | Expo | 8081 | +If another local project owns one of the application ports, add a different, +unique value to the ignored root `.env.local` before running `up`: + +```text +SAUCI_WEB_PORT=3100 +SAUCI_ADMIN_PORT=3101 +SAUCI_MCP_PORT=3102 +SAUCI_METRO_PORT=8181 +``` + +`SAUCI_POSTGRES_PORT` and `SAUCI_API_PORT` remain available for their existing +local overrides. Every configured port must be a unique integer from 1 through +65535. Before launching managed services, the launcher checks both IPv4 and IPv6 +listeners and fails closed with the owning PID, command, and working directory; +it never stops another project's process. + PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup, and stores data in a named Docker volume. The local connection string is: @@ -26,28 +42,56 @@ postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci Those credentials are for local development only. Deployed environments must inject unique secrets; they must never consume `infra/backend/.env.example`. -## First run +## Start the stack -1. Use Node from `.nvmrc` and run `npm ci`. -2. Install Docker Desktop or Rancher Desktop and tmux. -3. Configure the hosted Supabase Auth public URL/key for the mobile application - and the hosted Auth issuer/JWKS settings for the API. Do not configure any - local command with production credentials. Authenticated mobile E2E requires - the designated hosted non-production Auth project and a disposable, - onboarding-complete user whose subject has a matching local profile. Pass - its credentials as `E2E_EMAIL` and `E2E_PASSWORD` only to the Maestro process; - do not store them in an Expo environment file. - To exercise MCP tools, also set a local `ADMIN_API_SERVICE_TOKEN`, its - fixture `ADMIN_API_SERVICE_USER_ID`, and a separate `SAUCI_MCP_API_KEY`. - `scripts/dev-local.sh` passes the service token to MCP as - `SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service - credential. -4. Run `scripts/dev-local.sh reset` to recreate only the dedicated local Docker - volume, apply API migrations, and create configured fixtures. -5. Run `scripts/dev-local.sh up`. +One command starts the local database, applies migrations and fixtures, and +launches the application services: -`up` is idempotent. `down` stops tmux application processes and leaves the -database available. `down --all` also stops PostgreSQL but retains its volume. +```bash +scripts/dev-local.sh up +``` + +Before the first use, install Docker Desktop or Rancher Desktop, tmux, and the +repository dependencies with `npm ci`. The launcher reads `.nvmrc` itself. When +the active Node version differs, it loads an installed Homebrew `nvm` and selects +the exact version automatically. If that version has not been installed yet, run +the command the launcher prints once, then rerun `up`. + +Create the ignored root `.env.local` with the public Auth settings for the +designated non-production Supabase project (`itbzhrvlgvdmzbnhzhyx`). Set +`SUPABASE_AUTH_URL` (or `EXPO_PUBLIC_SUPABASE_URL`) and +`EXPO_PUBLIC_SUPABASE_ANON_KEY`; the launcher uses the URL for the API and both +values for Expo. It accepts only this designated hosted project or loopback Auth, +and explicitly rejects the production project and every other hosted project. +Environment variables exported in the shell take precedence over `.env.local`. +The launcher treats `.env.local` as data, never shell code: use unquoted +`KEY=VALUE` lines for only the documented launcher keys. Never place production +credentials or Auth issuer/JWKS, Auth service-role, RevenueCat, or other provider +secrets in `.env.local`; the launcher removes those inherited values from its +local API and worker processes. Each `up` replaces its managed tmux windows so +stale application processes cannot retain earlier configuration, while leaving +unrelated windows intact. Credentials are passed only to the local process that +needs them: MCP receives its local API token/key but never `DATABASE_URL`. +Before replacing a managed window, the launcher verifies and stops only its +current descendant processes. If those descendants do not exit promptly, it +leaves that window in place and fails rather than broadening process cleanup. +This includes a previously captured descendant that has left the pane lineage: +the launcher will not signal it again or replace the window until that captured +process identity is gone. + +Authenticated mobile E2E requires a disposable, onboarding-complete user whose +subject has a matching local profile. Pass its credentials as `E2E_EMAIL` and +`E2E_PASSWORD` only to the Maestro process; do not store them in an Expo +environment file. To exercise MCP tools, also set a local +`ADMIN_API_SERVICE_TOKEN`, its fixture `ADMIN_API_SERVICE_USER_ID`, and a +separate `SAUCI_MCP_API_KEY`. `scripts/dev-local.sh` passes the service token to +MCP as `SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service +credential. + +`up` is idempotent. `down` stops only the launcher's managed tmux application +windows and leaves unrelated windows and the database available. `down --all` +also stops PostgreSQL but retains its volume, and runs only after managed +application cleanup succeeds. Use `status`, `logs `, `restart `, and `attach` for diagnosis. `logs postgres` reads Compose logs; other names read their tmux panes. The `worker` pane owns scheduled jobs, notifications, moderation classification, diff --git a/docs/releasing.md b/docs/releasing.md index 4727911..abb9f84 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -85,6 +85,15 @@ may change: `app.json`, the platform native version file, and the iOS widget `Info.plist` for an iOS build. Any widget implementation, resource, or Xcode project rewrite means the source was regenerated and the artifact must be rebuilt from the clean tracked sources. + +### Android media permission policy + +Sauci uses the Android system photo picker for user-selected images and videos. +Android release manifests must not contain `READ_MEDIA_IMAGES` or +`READ_MEDIA_VIDEO`. Gallery save flows request add-only access and must not add +broad read access. After any Expo, media-library, image-picker, or native Android +change, run `:app:processReleaseMainManifest` and inspect the merged release +manifest before building an artifact for Google Play. ### Building an APK (for Testing) diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index 63926c8..8f7164e 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -5,8 +5,98 @@ SESSION="sauci-dev" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="$ROOT/infra/backend/compose.yaml" COMPOSE_PROJECT="sauci-local" +DESCENDANT_SHUTDOWN_ATTEMPTS=30 +DESCENDANT_SHUTDOWN_DELAY_SECONDS=0.1 + +load_env_local() { + local env_file="$ROOT/.env.local" line line_number=0 key value seen_keys=' ' + local safe_value_pattern='^[A-Za-z0-9._:/?&=+%@,#-]+$' + [ -f "$env_file" ] || return 0 + + # This is deliberately a data parser, not `source`: .env.local is ignored + # developer input and must never execute in the launcher's shell. + while IFS= read -r line || [ -n "$line" ]; do + line_number=$((line_number + 1)) + [[ -z "$line" || "$line" == \#* ]] && continue + if [[ ! "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]]; then + printf '✗ %s:%s must be KEY=VALUE with no quoting or shell syntax.\n' "$env_file" "$line_number" >&2 + exit 1 + fi + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + case " $key " in + " SAUCI_POSTGRES_PORT "|" SAUCI_API_PORT "|" SAUCI_WEB_PORT "|" SAUCI_ADMIN_PORT "|\ + " SAUCI_MCP_PORT "|" SAUCI_METRO_PORT "|" SAUCI_MOBILE_API_URL "|\ + " SUPABASE_AUTH_URL "|" EXPO_PUBLIC_SUPABASE_URL "|" EXPO_PUBLIC_SUPABASE_ANON_KEY "|\ + " ADMIN_API_SERVICE_USER_ID "|" ADMIN_API_SERVICE_TOKEN "|" SAUCI_MCP_API_KEY ") ;; + *) + printf '✗ %s:%s contains unsupported launcher key %s.\n' "$env_file" "$line_number" "$key" >&2 + exit 1 + ;; + esac + case "$seen_keys" in + *" $key "*) + printf '✗ %s:%s duplicates launcher key %s.\n' "$env_file" "$line_number" "$key" >&2 + exit 1 + ;; + esac + if [[ -z "$value" || ! "$value" =~ $safe_value_pattern ]]; then + printf '✗ %s:%s contains an unsafe or empty value.\n' "$env_file" "$line_number" >&2 + exit 1 + fi + # Explicit caller exports are authoritative over ignored local defaults. + printenv "$key" >/dev/null 2>&1 || export "$key=$value" + seen_keys+="$key " + done <"$env_file" +} + +load_nvm() { + command -v nvm >/dev/null 2>&1 && return + + local nvm_dir="${NVM_DIR:-$HOME/.nvm}" brew_nvm_prefix + local -a candidates=("$nvm_dir/nvm.sh") + if command -v brew >/dev/null 2>&1; then + brew_nvm_prefix="$(brew --prefix nvm 2>/dev/null || true)" + [ -n "$brew_nvm_prefix" ] && candidates+=("$brew_nvm_prefix/nvm.sh") + fi + + local candidate + for candidate in "${candidates[@]}"; do + if [ -s "$candidate" ]; then + # shellcheck disable=SC1090 + . "$candidate" + command -v nvm >/dev/null 2>&1 && return + fi + done + return 1 +} + +activate_required_node() { + local required_node current_node + required_node="$(tr -d '[:space:]' <"$ROOT/.nvmrc")" + [ -n "$required_node" ] || die ".nvmrc must specify a Node version." + + if command -v node >/dev/null 2>&1; then + current_node="$(node -p 'process.versions.node')" + [ "$current_node" = "$required_node" ] && return + fi + + load_nvm || die "Node $required_node is required. Install Homebrew nvm, then run: nvm install $required_node" + nvm use --silent "$required_node" >/dev/null 2>&1 || die "Node $required_node is required. Install it once with: nvm install $required_node" + current_node="$(node -p 'process.versions.node')" + [ "$current_node" = "$required_node" ] || die "Node $required_node is required; nvm selected $current_node instead." +} + +# Read the ignored developer configuration before computing launch settings. +# Explicit environment variables take precedence over .env.local values. +load_env_local + POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}" API_PORT="${SAUCI_API_PORT:-3003}" +WEB_PORT="${SAUCI_WEB_PORT:-3000}" +ADMIN_PORT="${SAUCI_ADMIN_PORT:-3001}" +MCP_PORT="${SAUCI_MCP_PORT:-3002}" +METRO_PORT="${SAUCI_METRO_PORT:-8081}" LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci" LOCAL_API_URL="http://127.0.0.1:${API_PORT}" MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}" @@ -17,21 +107,21 @@ LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service- LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}" SERVERS=( - "api|env PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL SUPABASE_AUTH_URL=$AUTH_URL ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api" - "worker|env DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api" - "web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port 3000" - "admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001" - "mcp|env PORT=3002 SAUCI_ADMIN_API_URL=$LOCAL_API_URL SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY npm run dev -w sauci-mcp-server" - "mobile|env EXPO_PUBLIC_API_URL=$MOBILE_API_URL EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY npm run dev -w @sauci/mobile -- --port 8081 --localhost" + "api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api" + "worker|DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api" + "web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port $WEB_PORT" + "admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port $ADMIN_PORT" + "mcp|env PORT=$MCP_PORT npm run dev -w sauci-mcp-server" + "mobile|npm run dev -w @sauci/mobile -- --port $METRO_PORT --localhost" ) PORTS=( "postgres:$POSTGRES_PORT" "api:$API_PORT" - "web:3000" - "admin:3001" - "mcp:3002" - "metro:8081" + "web:$WEB_PORT" + "admin:$ADMIN_PORT" + "mcp:$MCP_PORT" + "metro:$METRO_PORT" ) c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m' @@ -46,15 +136,21 @@ compose() { } preflight() { + activate_required_node + if ! node - "$POSTGRES_PORT" "$API_PORT" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT" "$METRO_PORT" <<'NODE' +const ports = process.argv.slice(2); +const valid = ports.every((port) => /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535); +if (!valid || new Set(ports.map(Number)).size !== ports.length) process.exitCode = 1; +NODE + then + die "Configured ports must be unique integers from 1 to 65535." + fi command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux" - command -v npm >/dev/null 2>&1 || die "npm not found. Install Node $(cat "$ROOT/.nvmrc")." + command -v npm >/dev/null 2>&1 || die "npm not found after activating Node $(cat "$ROOT/.nvmrc")." command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found." docker info >/dev/null 2>&1 || die "Container runtime is not running." docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required." [ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci" - local node_major - node_major="$(node -p 'process.versions.node.split(`.`)[0]')" - [ "$node_major" = "20" ] || die "Node 20 is required. Activate .nvmrc before starting the stack." } assert_local_database() { @@ -65,10 +161,212 @@ assert_local_database() { [ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320." } -assert_local_auth() { - case "$AUTH_URL" in - http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) ;; - *) die "Refusing local development against non-local Supabase Auth: $AUTH_URL" ;; +assert_allowed_auth() { + local verdict + verdict="$(node - "$AUTH_URL" <<'NODE' +const raw = process.argv[2]; +try { + const url = new URL(raw); + if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) throw new Error('shape'); + const isCanonicalRoot = raw === url.origin || raw === `${url.origin}/`; + const isLoopback = isCanonicalRoot && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname); + const isNonProduction = isCanonicalRoot && url.protocol === 'https:' && url.origin === 'https://itbzhrvlgvdmzbnhzhyx.supabase.co'; + if (isLoopback || isNonProduction) process.stdout.write('allowed'); + else if (url.hostname === 'ckjcrkjpmhqhiucifukx.supabase.co') process.stdout.write('production'); + else process.stdout.write('unapproved'); +} catch { + process.stdout.write('invalid'); +} +NODE +)" + case "$verdict" in + allowed) ;; + production) die "Refusing local development against the production Supabase Auth project." ;; + invalid) die "SUPABASE_AUTH_URL must be a valid root Auth URL without credentials, a path, query, or fragment." ;; + *) die "Refusing local development against an unapproved Supabase Auth URL." ;; + esac +} + +prepare_session() { + BOOTSTRAP_CREATED=false + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300' + BOOTSTRAP_CREATED=true + elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -qx '_bootstrap'; then + tmux new-window -d -t "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300' + BOOTSTRAP_CREATED=true + fi +} + +restart_managed_windows() { + local name failed=false + for name in api worker web admin mcp mobile; do + stop_managed_window "$name" || failed=true + done + [ "$failed" = false ] +} + +is_numeric_pid() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +pane_pid_for_window() { + local name="$1" pane_pid + pane_pid="$(tmux display-message -p -t "$SESSION:$name" '#{pane_pid}' 2>/dev/null || true)" + is_numeric_pid "$pane_pid" && printf '%s\n' "$pane_pid" +} + +process_start_time() { + local pid="$1" start_time + is_numeric_pid "$pid" || return 1 + start_time="$(ps -p "$pid" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$start_time" ] && printf '%s\n' "$start_time" +} + +has_pane_ancestry() { + local pid="$1" pane_pid="$2" parent_pid hops=0 + is_numeric_pid "$pid" && is_numeric_pid "$pane_pid" || return 1 + while [ "$hops" -lt 64 ]; do + parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d '[:space:]')" + is_numeric_pid "$parent_pid" || return 1 + [ "$parent_pid" = "$pane_pid" ] && return 0 + [ "$parent_pid" -gt 1 ] || return 1 + pid="$parent_pid" + hops=$((hops + 1)) + done + return 1 +} + +capture_descendants() { + local parent_pid="$1" child_pid + is_numeric_pid "$parent_pid" || return 0 + while IFS= read -r child_pid; do + is_numeric_pid "$child_pid" || continue + capture_descendants "$child_pid" + local start_time + start_time="$(process_start_time "$child_pid" || true)" + [ -n "$start_time" ] || continue + MANAGED_DESCENDANT_PIDS+=("$child_pid") + MANAGED_DESCENDANT_STARTS+=("$start_time") + record_captured_identity "$child_pid" "$start_time" + done < <(pgrep -P "$parent_pid" 2>/dev/null || true) +} + +record_captured_identity() { + local pid="$1" start_time="$2" index + for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do + [ "${CAPTURED_DESCENDANT_PIDS[$index]}" = "$pid" ] && + [ "${CAPTURED_DESCENDANT_STARTS[$index]}" = "$start_time" ] && return 0 + done + CAPTURED_DESCENDANT_PIDS+=("$pid") + CAPTURED_DESCENDANT_STARTS+=("$start_time") +} + +captured_identity_remains() { + local index pid start_time + for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do + pid="${CAPTURED_DESCENDANT_PIDS[$index]}" + start_time="${CAPTURED_DESCENDANT_STARTS[$index]}" + is_numeric_pid "$pid" || continue + [ "$(process_start_time "$pid" || true)" = "$start_time" ] && return 0 + done + return 1 +} + +signal_verified_descendants() { + local name="$1" expected_pane_pid="$2" index pid start_time current_pane_pid + for index in "${!MANAGED_DESCENDANT_PIDS[@]}"; do + pid="${MANAGED_DESCENDANT_PIDS[$index]}" + start_time="${MANAGED_DESCENDANT_STARTS[$index]}" + is_numeric_pid "$pid" || continue + current_pane_pid="$(pane_pid_for_window "$name" || true)" + [ "$current_pane_pid" = "$expected_pane_pid" ] || continue + [ "$(process_start_time "$pid" || true)" = "$start_time" ] || continue + has_pane_ancestry "$pid" "$expected_pane_pid" || continue + kill -TERM "$pid" 2>/dev/null || true + done +} + +descendants_remain() { + local pane_pid="$1" child_pid + is_numeric_pid "$pane_pid" || return 1 + child_pid="$(pgrep -P "$pane_pid" 2>/dev/null | head -1 || true)" + [ -n "$child_pid" ] +} + +stop_managed_window() { + local name="$1" pane_pid attempt=1 + case "$name" in api|worker|web|admin|mcp|mobile) ;; *) die "unknown managed window '$name'" ;; esac + tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qx "$name" || return 0 + + CAPTURED_DESCENDANT_PIDS=("") + CAPTURED_DESCENDANT_STARTS=("") + while [ "$attempt" -le "$DESCENDANT_SHUTDOWN_ATTEMPTS" ]; do + pane_pid="$(pane_pid_for_window "$name" || true)" + if ! is_numeric_pid "$pane_pid"; then + captured_identity_remains && return 1 + return 0 + fi + if ! descendants_remain "$pane_pid"; then + captured_identity_remains && { + warn "window '$name' has a captured descendant outside its pane lineage; leaving it running" + return 1 + } + tmux kill-window -t "$SESSION:$name" 2>/dev/null || true + return 0 + fi + + # Bash 3.2 plus nounset requires non-empty sentinels for these arrays. + MANAGED_DESCENDANT_PIDS=("") + MANAGED_DESCENDANT_STARTS=("") + capture_descendants "$pane_pid" + signal_verified_descendants "$name" "$pane_pid" + sleep "$DESCENDANT_SHUTDOWN_DELAY_SECONDS" + attempt=$((attempt + 1)) + done + pane_pid="$(pane_pid_for_window "$name" || true)" + if captured_identity_remains; then + warn "window '$name' still has captured descendants; leaving it running" + return 1 + fi + if is_numeric_pid "$pane_pid" && descendants_remain "$pane_pid"; then + warn "window '$name' still has managed descendants; leaving it running" + return 1 + fi + tmux kill-window -t "$SESSION:$name" 2>/dev/null || true +} + +window_environment() { + local name="$1" + # Bash 3.2 with nounset treats an empty array expansion as unbound. Keep a + # sentinel and skip it when building tmux's optional -e arguments. + WINDOW_ENV=("") + WINDOW_FORWARD='' + case "$name" in + api) + WINDOW_ENV=( + "SUPABASE_AUTH_URL=$AUTH_URL" + "ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID" + "ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN" + ) + WINDOW_FORWARD='SUPABASE_AUTH_URL="$SUPABASE_AUTH_URL" ADMIN_API_SERVICE_USER_ID="$ADMIN_API_SERVICE_USER_ID" ADMIN_API_SERVICE_TOKEN="$ADMIN_API_SERVICE_TOKEN"' + ;; + mcp) + WINDOW_ENV=( + "SAUCI_ADMIN_API_URL=$LOCAL_API_URL" + "SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN" + "SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY" + ) + WINDOW_FORWARD='SAUCI_ADMIN_API_URL="$SAUCI_ADMIN_API_URL" SAUCI_ADMIN_API_TOKEN="$SAUCI_ADMIN_API_TOKEN" SAUCI_MCP_API_KEY="$SAUCI_MCP_API_KEY"' + ;; + mobile) + WINDOW_ENV=( + "EXPO_PUBLIC_API_URL=$MOBILE_API_URL" + "EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL" + "EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY" + ) + WINDOW_FORWARD='EXPO_PUBLIC_API_URL="$EXPO_PUBLIC_API_URL" EXPO_PUBLIC_SUPABASE_URL="$EXPO_PUBLIC_SUPABASE_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$EXPO_PUBLIC_SUPABASE_ANON_KEY"' + ;; esac } @@ -85,7 +383,8 @@ api_workspace_exists() { run_api_script() { local script="$1" api_workspace_exists || die "apps/api is not present yet; cannot run db:$script" - (cd "$ROOT" && env DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api) + (cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api) } start_window() { @@ -98,8 +397,17 @@ start_window() { warn "apps/api is not present yet; skipping API window" return fi - tmux new-window -t "$SESSION" -n "$name" -c "$ROOT" - tmux send-keys -t "$SESSION:$name" "$cmd" C-m + window_environment "$name" + local entry escaped_path escaped_home escaped_tmpdir + printf -v escaped_path '%q' "$PATH" + printf -v escaped_home '%q' "$HOME" + printf -v escaped_tmpdir '%q' "${TMPDIR:-/tmp}" + set -- tmux new-window -d -t "$SESSION" -n "$name" -c "$ROOT" + for entry in "${WINDOW_ENV[@]}"; do + [ -n "$entry" ] && set -- "$@" -e "$entry" + done + set -- "$@" "env -i PATH=$escaped_path HOME=$escaped_home TMPDIR=$escaped_tmpdir $WINDOW_FORWARD $cmd" + "$@" } port_check() { @@ -115,20 +423,57 @@ port_check() { fi } +report_port_owner() { + local port="$1" pid command cwd raw_command + while IFS= read -r pid; do + [ -n "$pid" ] || continue + raw_command="$(ps -p "$pid" -o command= 2>/dev/null)" + command="$(printf '%s' "$raw_command" | LC_ALL=C tr -d '[:cntrl:]')" + command="${command#"${command%%[![:space:]]*}"}" + command="${command%%[[:space:]]*}" + [ -n "$command" ] && command="$(basename "$command")" + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)" + cwd="$(printf '%s' "$cwd" | LC_ALL=C tr -d '[:cntrl:]')" + printf ' PID %s\n command: %s\n cwd: %s\n' "$pid" "${command:-unknown}" "${cwd:-unknown}" >&2 + done < <(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u) +} + +assert_app_ports_available() { + local entry name port occupied=false + local -a app_ports=("api:$API_PORT" "web:$WEB_PORT" "admin:$ADMIN_PORT" "mcp:$MCP_PORT" "metro:$METRO_PORT") + for entry in "${app_ports[@]}"; do + name="${entry%%:*}"; port="${entry##*:}" + if port_up "$port"; then + printf '✗ Port %s for %s is already listening. Refusing to replace its owner.\n' "$port" "$name" >&2 + report_port_owner "$port" + occupied=true + fi + done + [ "$occupied" = false ] || return 1 +} + cmd_up() { preflight [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification." - assert_local_auth + assert_allowed_auth + prepare_session + if ! restart_managed_windows; then + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + die "Refusing to restart while a managed window still has descendants." + fi + if ! assert_app_ports_available; then + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + die "Refusing to start while a configured application port is occupied." + fi ensure_infra run_api_script migrate run_api_script seed - tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT" local service for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done - tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true ok "Stack starting in tmux session '$SESSION'" port_check - printf "\n API: %s/health/live\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n" "$LOCAL_API_URL" + printf "\n API: %s/health/live\n Web: http://127.0.0.1:%s\n Admin: http://127.0.0.1:%s\n MCP: http://127.0.0.1:%s/health\n" "$LOCAL_API_URL" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT" } cmd_status() { @@ -152,26 +497,52 @@ cmd_logs() { } cmd_restart() { - local name="${1:?usage: restart }" service + local name="${1:?usage: restart }" service="" candidate if [ "$name" = "postgres" ]; then compose restart postgres ok "restarted postgres" return fi - tmux has-session -t "$SESSION" 2>/dev/null || die "session not running" - tmux kill-window -t "$SESSION:$name" 2>/dev/null || true - for service in "${SERVERS[@]}"; do - if [ "${service%%|*}" = "$name" ]; then - start_window "$name" "${service#*|}" - ok "restarted $name" - return + for candidate in "${SERVERS[@]}"; do + if [ "${candidate%%|*}" = "$name" ]; then + service="$candidate" + break fi done - die "unknown service '$name'" + [ -n "$service" ] || die "unknown service '$name'" + + preflight + case "$name" in + api|mobile) + [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required." + assert_allowed_auth + ;; + esac + [ "$name" != "mobile" ] || [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client." + + tmux has-session -t "$SESSION" 2>/dev/null || die "session not running" + prepare_session + if ! stop_managed_window "$name"; then + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + die "Refusing to restart '$name' while its managed descendants remain." + fi + start_window "$name" "${service#*|}" + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + ok "restarted $name" } cmd_down() { - tmux kill-session -t "$SESSION" 2>/dev/null && ok "application services stopped" || warn "no session '$SESSION'" + if tmux has-session -t "$SESSION" 2>/dev/null; then + prepare_session + if ! restart_managed_windows; then + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + die "Refusing to stop infrastructure while a managed window still has descendants." + fi + [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true + ok "managed application services stopped" + else + warn "no session '$SESSION'" + fi if [ "${1:-}" = "--all" ]; then compose down ok "standalone backend infrastructure stopped (data retained)" @@ -203,9 +574,9 @@ cmd_native() { preflight [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required." [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client." - assert_local_auth + assert_allowed_auth ensure_infra - (cd "$ROOT" && env EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \ + (cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \ EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \ npm run "$platform" -w @sauci/mobile) }