v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
Some checks failed
build-apk / build (push) Failing after 1h56m52s
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:
parent
463facbe5a
commit
4a5660e7e1
13 changed files with 351 additions and 68 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue