chore(release): promote photo picker fix

Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
This commit is contained in:
jjbrunton authored and GitHub committed 2026-08-30 17:31:02 +01:00
commit 2cbe9e9c55
11 files changed
+611 -63

No files matched your search

@@ -3,8 +3,8 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
+4
View File
@@ -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": {
@@ -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);
});
});
@@ -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';
@@ -46,6 +46,7 @@ describe('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 () => {
@@ -180,8 +181,6 @@ describe('useMediaSaver', () => {
});
it('sets saving to true during operation', async () => {
let savingDuringOperation = false;
(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,
@@ -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' }
);
});
});
@@ -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);
+4
View File
@@ -101,11 +101,15 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
exif: false,
});
} 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,
+3 -1
View File
@@ -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 {
+63 -19
View File
@@ -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
One command starts the local database, applies migrations and fixtures, and
launches the application services:
```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.
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`.
`up` is idempotent. `down` stops tmux application processes and leaves the
database available. `down --all` also stops PostgreSQL but retains its volume.
`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 <service>`, `restart <service>`, and `attach` for diagnosis.
`logs postgres` reads Compose logs; other names read their tmux panes.
The `worker` pane owns scheduled jobs, notifications, moderation classification,
+9
View File
@@ -86,6 +86,15 @@ may change: `app.json`, the platform native version file, and the iOS widget
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)
The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing:
+408 -37
View File
@@ -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>}" service
local name="${1:?usage: restart <service>}" 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)
}