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)
}