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

@ -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'] });