v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
Some checks failed
build-apk / build (push) Failing after 1h56m52s

Anonymous Mode — "Park without signing in" on the login screen (with a popup of
what works vs needs a login). Anonymous users browse parking areas from our
mirror, see labels, and start free check-in timers; paying, sessions, and
account screens prompt to sign in. AuthContext gains an 'anonymous' status +
enterAnonymous/requireLogin.

Zone mirror — server gains a `zones` table + public GET /api/zones and admin
POST /api/zones/sync. Signed-in admins push the zones they pull (authed) from
ParkSmarter after each map search, so anonymous users can read areas without a
ParkSmarter login. Map/Scan read the mirror when anonymous.

Also: the free "Check in" button is now always available with a 2h/3h/4h picker
(no longer gated on a prior label) — fixes "couldn't start a timer on a free
zone". CORS probe confirmed ParkSmarter allows any origin but only Content-Type,
so a future PWA can't auth to it — the mirror is what makes anonymous browsing
(and a PWA) possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 21:38:32 +00:00
parent 463facbe5a
commit 4a5660e7e1
13 changed files with 351 additions and 68 deletions

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.3.0",
"version": "0.4.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 17,
"versionCode": 18,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

34
app/src/api/zoneMirror.ts Normal file
View file

@ -0,0 +1,34 @@
import Constants from 'expo-constants';
import type { Zone } from 'parksmarter-client';
import { getAdminToken } from './adminStore';
/**
* The zone-location mirror on bigbrainparking.mowden.top. Anonymous users read
* areas from here (no ParkSmarter login). Admins push the zones they pull
* (authed) from ParkSmarter so the mirror stays populated.
*/
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export async function getMirrorZones(): Promise<Zone[]> {
if (!BASE_URL) return [];
const res = await fetch(`${BASE_URL}/api/zones`);
if (!res.ok) throw new Error(`zones fetch failed ${res.status}`);
const body = (await res.json()) as { zones?: Zone[] };
return body.zones ?? [];
}
/** Push authed-pulled zones to the mirror. No-ops unless an admin token is set. */
export async function syncZones(zones: Zone[]): Promise<void> {
if (!BASE_URL || zones.length === 0) return;
const token = await getAdminToken();
if (!token) return; // only admins populate the mirror
try {
await fetch(`${BASE_URL}/api/zones/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ zones }),
});
} catch {
/* best-effort — never block the UI on a mirror push */
}
}

View file

@ -9,13 +9,18 @@ import { ps } from '@/api/client';
import { authBus } from '@/auth/authBus';
import type { ApplicationValidityResponse } from 'parksmarter-client';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous';
interface AuthState {
status: AuthStatus;
validity: ApplicationValidityResponse | null;
login: (phoneNumber: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Enter the app without a ParkSmarter login (browse + free check-in only). */
enterAnonymous: () => void;
/** Leave anonymous mode and show the sign-in screen (e.g. to pay). */
requireLogin: () => void;
isAnonymous: boolean;
error: string | null;
}
@ -85,8 +90,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setStatus('signedOut');
};
const enterAnonymous = () => {
setError(null);
setStatus('anonymous');
};
const requireLogin = () => {
setError(null);
setStatus('signedOut');
};
const value = useMemo<AuthState>(
() => ({ status, validity, login, logout, error }),
() => ({
status,
validity,
login,
logout,
enterAnonymous,
requireLogin,
isAnonymous: status === 'anonymous',
error,
}),
[status, validity, error],
);

View file

@ -117,7 +117,7 @@ export function RootNavigator() {
return (
<NavigationContainer theme={navTheme}>
{status === 'signedIn' ? (
{status === 'signedIn' || status === 'anonymous' ? (
<Stack.Navigator>
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
<Stack.Screen

View file

@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function AccountScreen() {
const { colors, mode, toggle } = useTheme();
const { logout } = useAuth();
const { logout, isAnonymous, requireLogin } = useAuth();
const navigation = useNavigation<Nav>();
const Item = ({
@ -35,6 +35,17 @@ export function AccountScreen() {
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
{isAnonymous ? (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="log-in" label="Sign in to pay & sync" onPress={requireLogin} />
<Item
icon="notifications"
label="Notifications"
onPress={() => navigation.navigate('Notifications')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
) : (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
@ -50,6 +61,7 @@ export function AccountScreen() {
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
)}
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
<View style={[styles.card, { backgroundColor: colors.card }]}>
@ -66,12 +78,11 @@ export function AccountScreen() {
/>
</View>
<TouchableOpacity
style={[styles.logout, { borderColor: colors.danger }]}
onPress={logout}
>
{isAnonymous ? null : (
<TouchableOpacity style={[styles.logout, { borderColor: colors.danger }]} onPress={logout}>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
</TouchableOpacity>
)}
</ScrollView>
);
}

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
KeyboardAvoidingView,
Platform,
@ -13,7 +14,7 @@ import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
export function LoginScreen() {
const { login, error } = useAuth();
const { login, error, enterAnonymous } = useAuth();
const { colors } = useTheme();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
@ -30,6 +31,25 @@ export function LoginScreen() {
}
};
const onAnonymous = () => {
Alert.alert(
'Park without signing in',
'Works without a login:\n' +
' • Browse parking areas on the map\n' +
' • See free (2h/3h/4h) vs pay-immediately zones\n' +
' • Start free-parking check-in timers with reminders\n\n' +
'Needs a ParkSmarter login:\n' +
' • Paying for parking\n' +
' • Your active & past sessions\n' +
' • Saved vehicles & payment methods\n\n' +
'You can sign in anytime from the Account tab.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Continue', onPress: enterAnonymous },
],
);
};
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.bg }]}
@ -77,6 +97,10 @@ export function LoginScreen() {
)}
</TouchableOpacity>
<TouchableOpacity style={styles.secondary} onPress={onAnonymous} disabled={busy}>
<Text style={[styles.secondaryText, { color: colors.primary }]}>Park without signing in</Text>
</TouchableOpacity>
<Text style={[styles.hint, { color: colors.subtext }]}>
Forgot your password? Reset it via the official app this build reuses the same
account.
@ -101,5 +125,7 @@ const styles = StyleSheet.create({
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
secondary: { padding: 14, alignItems: 'center', marginTop: 4 },
secondaryText: { fontWeight: '700', fontSize: 15 },
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
});

View file

@ -14,6 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
import { useLocation, type Coords } from '@/features/location/useLocation';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client';
@ -48,6 +50,7 @@ export function MapScreen() {
const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const { coords, refresh } = useLocation();
const { isAnonymous } = useAuth();
const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
const [loading, setLoading] = useState(false);
@ -107,14 +110,25 @@ export function MapScreen() {
}
}, [mapReady, initialCenter]);
const searchAt = useCallback(async (c: Coords, label: string) => {
const searchAt = useCallback(
async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
if (isAnonymous) {
// No ParkSmarter login — read areas from our mirror (all of them; the
// covered area is small). The device GPS is never sent.
const all = await getMirrorZones();
const found = all.filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
return;
}
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
void syncZones(found); // admin-only; no-ops otherwise
} catch (e: any) {
setZones([]);
setStatus(
@ -125,7 +139,14 @@ export function MapScreen() {
} finally {
setLoading(false);
}
}, []);
},
[isAnonymous],
);
// Anonymous: load the mirrored areas once on open (no ParkSmarter needed).
useEffect(() => {
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
}, [isAnonymous, searchAt]);
// Search whatever the map is currently centered on. This only ever sends the
// map's center point — never the device GPS. (If you want to search your own

View file

@ -8,6 +8,7 @@ import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
import { useTheme } from '@/theme/ThemeContext';
import type { SpacePolicy } from 'parksmarter-client';
import { getAdminToken } from '@/api/adminStore';
import { useAuth } from '@/auth/AuthContext';
import {
deleteLabel,
getCachedLabel,
@ -87,6 +88,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MeterDetailScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { isAnonymous, requireLogin } = useAuth();
const { params } = useRoute<DetailRoute>();
const z = params.zone;
const [saved, setSaved] = useState(false);
@ -135,18 +137,38 @@ export function MeterDetailScreen() {
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
};
const freeHours = label ? labelHours(label.kind) : null;
const labeledHours = label ? labelHours(label.kind) : null;
const onCheckin = async () => {
if (!freeHours) return;
await startCheckin(z, freeHours);
const doCheckin = async (hours: number) => {
await startCheckin(z, hours);
Alert.alert(
'Checked in',
`Free parking for ${freeHours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
[{ text: 'OK', onPress: () => navigation.navigate('Tabs') }],
);
};
const onPay = () => {
if (isAnonymous) {
Alert.alert('Sign in to pay', 'Paying for parking needs a ParkSmarter login.', [
{ text: 'Not now', style: 'cancel' },
{ text: 'Sign in', onPress: requireLogin },
]);
return;
}
navigation.navigate('StartSession', { zone: z });
};
// Always available — pick a duration (defaults to the labeled limit if any).
const onCheckin = () => {
Alert.alert('Free check-in', 'Start a local timer for the free limit here:', [
{ text: labeledHours === 2 ? '2 hours ✓' : '2 hours', onPress: () => doCheckin(2) },
{ text: labeledHours === 3 ? '3 hours ✓' : '3 hours', onPress: () => doCheckin(3) },
{ text: labeledHours === 4 ? '4 hours ✓' : '4 hours', onPress: () => doCheckin(4) },
{ text: 'Cancel', style: 'cancel' },
]);
};
const firstSpace = z.Spaces?.[0];
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
@ -258,24 +280,19 @@ export function MeterDetailScreen() {
</Text>
</TouchableOpacity>
{freeHours ? (
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
<Text style={[styles.buttonText, { color: '#fff' }]}>
Check in (free · {freeHours}h)
{labeledHours ? `Check in (free · ${labeledHours}h)` : 'Check in (free timer)'}
</Text>
</TouchableOpacity>
) : null}
{label?.kind === 'pay_immediate' ? (
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately no free window here.</Text>
) : null}
<TouchableOpacity
style={[styles.button, { backgroundColor: freeHours ? colors.card : colors.primary }]}
onPress={() => navigation.navigate('StartSession', { zone: z })}
>
<Text style={[styles.buttonText, { color: freeHours ? colors.text : '#fff' }]}>
Start parking session
<TouchableOpacity style={[styles.button, { backgroundColor: colors.card }]} onPress={onPay}>
<Text style={[styles.buttonText, { color: colors.text }]}>
{isAnonymous ? 'Sign in to pay' : 'Start parking session (pay)'}
</Text>
</TouchableOpacity>
</ScrollView>

View file

@ -18,6 +18,8 @@ import {
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones } from '@/api/zoneMirror';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@ -52,6 +54,7 @@ export function parseScannedCode(raw: string): { code?: string; isAppLink?: bool
export function ScanScreen() {
const navigation = useNavigation<Nav>();
const { isAnonymous } = useAuth();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
@ -66,6 +69,29 @@ export function ScanScreen() {
const lookupCode = useCallback(
async (code: string) => {
setBusy(true);
// Anonymous: no ParkSmarter API — match against the mirrored areas.
if (isAnonymous) {
try {
const lc = code.toLowerCase();
const zone = (await getMirrorZones()).find(
(z) =>
String(z.ScannerCode ?? '').toLowerCase() === lc ||
String(z.ZoneName ?? '').toLowerCase() === lc ||
String(z.TerminalSerNo ?? '') === code,
);
if (zone) {
setManualOpen(false);
navigation.navigate('MeterDetail', { zone });
} else {
Alert.alert('Not found', `No mirrored area matches "${code}". Sign in to search live.`);
}
} catch (e: any) {
Alert.alert('Lookup failed', e?.message ?? 'Could not reach the area mirror.');
} finally {
setBusy(false);
}
return;
}
// A scanned/typed code can resolve by terminal serial, scanner code, or
// zone name. Unmatched lookups return an error envelope (which the client
// throws), and ZoneName is case-sensitive server-side — the official app
@ -103,7 +129,7 @@ export function ScanScreen() {
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
}
},
[navigation],
[navigation, isAnonymous],
);
const onScanned = useCallback(

View file

@ -3,6 +3,7 @@ import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View }
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { ActiveSession, PastSession } from 'parksmarter-client';
@ -12,11 +13,13 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function SessionsScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { isAnonymous, requireLogin } = useAuth();
const [active, setActive] = useState<ActiveSession[]>([]);
const [past, setPast] = useState<PastSession[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
if (isAnonymous) return;
setRefreshing(true);
try {
const [a, p] = await Promise.all([
@ -28,7 +31,7 @@ export function SessionsScreen() {
} finally {
setRefreshing(false);
}
}, []);
}, [isAnonymous]);
useFocusEffect(
useCallback(() => {
@ -36,6 +39,25 @@ export function SessionsScreen() {
}, [load]),
);
if (isAnonymous) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
Sign in to see your sessions
</Text>
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
Your active and past parking sessions live in your ParkSmarter account.
</Text>
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
onPress={requireLogin}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
</TouchableOpacity>
</View>
);
}
return (
<ScrollView
style={{ backgroundColor: colors.bg }}
@ -84,6 +106,7 @@ export function SessionsScreen() {
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
empty: { marginBottom: 8 },
card: { borderRadius: 10, padding: 14, marginBottom: 10 },

View file

@ -40,7 +40,8 @@ export async function buildApp(opts: BuildOptions) {
opts.blockCooldownMs ?? 60 * 60 * 1000,
);
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 16 * 1024 });
// 1 MB: zone-sync batches carry full Zone objects (policies, logos, …).
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 1024 * 1024 });
await app.register(rateLimit, {
max: opts.rateLimitMax ?? 120,
@ -111,6 +112,37 @@ export async function buildApp(opts: BuildOptions) {
},
);
// ---- Zone mirror (for anonymous browsing) --------------------------------
const coord = (v: unknown): number | null => {
const n = Number(v);
return Number.isFinite(n) && n !== 0 ? n : null;
};
app.get('/api/zones', async () => ({ zones: db.allZones(), count: db.zoneCount() }));
// The app pushes the Zone list it just pulled (authed) from ParkSmarter so
// anonymous users can read areas without a ParkSmarter login.
app.post(
'/api/zones/sync',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: { zones?: unknown[] } }>, reply) => {
const zones = Array.isArray(req.body?.zones) ? req.body!.zones : null;
if (!zones) return reply.code(400).send({ error: 'zones_required' });
const rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }> = [];
for (const z of zones as Array<Record<string, unknown>>) {
if (z == null || z.ZoneId == null) continue;
rows.push({
zoneId: String(z.ZoneId),
zoneName: z.ZoneName != null ? String(z.ZoneName) : null,
lat: coord(z.Lat),
long: coord(z.Long),
data: JSON.stringify(z),
});
}
return { synced: db.upsertZones(rows), total: db.zoneCount() };
},
);
app.addHook('onClose', async () => db.close());
return app;
}

View file

@ -62,9 +62,46 @@ export class LabelDb {
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS zones (
zone_id TEXT PRIMARY KEY,
zone_name TEXT,
lat REAL,
long REAL,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
upsertZones(
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,
): number {
const stmt = this.db.prepare(
`INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at)
VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt)
ON CONFLICT(zone_id) DO UPDATE SET
zone_name = excluded.zone_name,
lat = excluded.lat, long = excluded.long,
data = excluded.data, updated_at = excluded.updated_at`,
);
const now = Date.now();
this.db.transaction((items: typeof rows) => {
for (const it of items) stmt.run({ ...it, updatedAt: now });
})(rows);
return rows.length;
}
/** All mirrored zones as their original Zone objects. */
allZones(): unknown[] {
const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[];
return rows.map((r) => JSON.parse(r.data));
}
zoneCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n;
}
all(): ZoneLabel[] {
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
}

View file

@ -100,6 +100,38 @@ test('rate limit returns 429 past the threshold', async () => {
await app.close();
});
test('zone mirror: admin sync then public read', async () => {
const app = await make();
// sync requires auth
let r = await app.inject({
method: 'POST',
url: '/api/zones/sync',
payload: { zones: [{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55 }] },
});
assert.equal(r.statusCode, 401);
// authed sync (one zone lacks ZoneId → skipped)
r = await app.inject({
method: 'POST',
url: '/api/zones/sync',
headers: auth,
payload: {
zones: [
{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55, Spaces: [{ SpaceId: 1 }] },
{ ZoneName: 'no-id' },
],
},
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().synced, 1);
// public read returns full Zone objects
r = await app.inject({ method: 'GET', url: '/api/zones' });
assert.equal(r.statusCode, 200);
assert.equal(r.json().count, 1);
assert.equal(r.json().zones[0].ZoneName, 'DL');
assert.equal(r.json().zones[0].Spaces[0].SpaceId, 1);
await app.close();
});
test('seeded IP denylist blocks with 403', async () => {
// app.inject uses 127.0.0.1 as the client IP
const app = await make({ seedBlockedIps: ['127.0.0.1'] });