Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS, VisionCamera QR kiosk scanning with save/share, local session-expiry reminders, UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
7.1 KiB
JavaScript
176 lines
7.1 KiB
JavaScript
/**
|
|
* sweep.mjs — confirm real API response shapes WITHOUT running the app and
|
|
* WITHOUT leaking your personal data.
|
|
*
|
|
* It logs into the ParkSmarter API with the library, calls the safe READ-ONLY
|
|
* endpoints, and writes a "schema skeleton" (field names + value *types* only,
|
|
* never the values) to ./capture/. Share ./capture/ freely — it contains no PII.
|
|
*
|
|
* It NEVER calls anything that spends money or mutates your account
|
|
* (no start-session, add/update/delete card or vehicle, profile edits, etc.).
|
|
*
|
|
* Setup:
|
|
* 1) npm run build (already done if dist/ exists)
|
|
* 2) Create ./.creds.json (gitignored):
|
|
* {
|
|
* "phoneNumber": "5551234567", // digits only, no country code
|
|
* "password": "…",
|
|
* "environment": "prodv2", // optional
|
|
* "lat": 40.4406, // optional: a coordinate near real meters
|
|
* "lng": -79.9959 // optional
|
|
* }
|
|
* 3) node sweep.mjs
|
|
*/
|
|
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { ParkSmarterClient } from './dist/index.js';
|
|
|
|
/* ---- load creds (file or env) ---- */
|
|
let creds = {};
|
|
if (existsSync('./.creds.json')) {
|
|
creds = JSON.parse(readFileSync('./.creds.json', 'utf8'));
|
|
}
|
|
const phoneNumber = creds.phoneNumber ?? process.env.PS_PHONE;
|
|
const password = creds.password ?? process.env.PS_PASS;
|
|
const environment = creds.environment ?? process.env.PS_ENV ?? 'prodv2';
|
|
const lat = creds.lat ?? (process.env.PS_LAT ? Number(process.env.PS_LAT) : undefined);
|
|
const lng = creds.lng ?? (process.env.PS_LNG ? Number(process.env.PS_LNG) : undefined);
|
|
|
|
if (!phoneNumber || !password) {
|
|
console.error(
|
|
'Missing credentials. Create ./.creds.json {phoneNumber, password} or set PS_PHONE/PS_PASS.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
/* ---- schema skeleton: keep KEYS and TYPES, drop VALUES (no PII) ---- */
|
|
function skeleton(v, depth = 0) {
|
|
if (v === null) return 'null';
|
|
if (Array.isArray(v)) {
|
|
if (v.length === 0) return ['<empty>'];
|
|
// merge keys across up to 5 elements so we don't miss sparse fields
|
|
const sample = v.slice(0, 5).map((e) => skeleton(e, depth + 1));
|
|
if (typeof sample[0] === 'object' && sample[0] !== null) {
|
|
const merged = {};
|
|
for (const s of sample) Object.assign(merged, s);
|
|
return [merged, `<len:${v.length}>`];
|
|
}
|
|
return [sample[0], `<len:${v.length}>`];
|
|
}
|
|
if (typeof v === 'object') {
|
|
const out = {};
|
|
for (const k of Object.keys(v).sort()) out[k] = skeleton(v[k], depth + 1);
|
|
return out;
|
|
}
|
|
// primitives: report type only, plus a coarse hint for strings
|
|
if (typeof v === 'string') {
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(v)) return 'string<date>';
|
|
if (/^-?\d+(\.\d+)?$/.test(v)) return 'string<numeric>';
|
|
return 'string';
|
|
}
|
|
return typeof v; // number | boolean
|
|
}
|
|
|
|
const capDir = './capture';
|
|
mkdirSync(capDir, { recursive: true });
|
|
|
|
const ps = new ParkSmarterClient({ environment, timeoutMs: 20000 });
|
|
const results = {};
|
|
|
|
async function grab(name, fn) {
|
|
try {
|
|
const data = await fn();
|
|
const skel = skeleton(data);
|
|
results[name] = { ok: true, schema: skel };
|
|
writeFileSync(`${capDir}/${name}.json`, JSON.stringify(skel, null, 2));
|
|
const top = skel && typeof skel === 'object' ? Object.keys(skel) : skel;
|
|
console.log(`✓ ${name}:`, JSON.stringify(top));
|
|
} catch (e) {
|
|
results[name] = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
|
|
console.log(`✗ ${name}: ${e.name} ${e.status ?? ''} ${e.message}`);
|
|
}
|
|
}
|
|
|
|
/* ---- run ---- */
|
|
console.log(`\nEnvironment: ${environment}`);
|
|
await grab('applicationValidity', () => ps.getApplicationValidity());
|
|
|
|
console.log('\nLogging in…');
|
|
try {
|
|
const auth = await ps.loginWithPhone({ phoneNumber, password });
|
|
results.__login = { ok: true, schema: skeleton(auth) };
|
|
writeFileSync(`${capDir}/_authResponse.json`, JSON.stringify(skeleton(auth), null, 2));
|
|
console.log('✓ login: keys =', JSON.stringify(Object.keys(auth)));
|
|
} catch (e) {
|
|
console.log(`✗ login FAILED: ${e.name} ${e.status ?? ''} ${e.message}`);
|
|
console.log(' (a 201 here means the account needs verification — see README note.)');
|
|
process.exit(1);
|
|
}
|
|
|
|
/* authenticated READ-ONLY sweep */
|
|
await grab('userDetail', () => ps.getUserDetail());
|
|
await grab('notificationSettings', () => ps.getNotificationSettings());
|
|
await grab('activeSessions', () => ps.getActiveParkingSessions());
|
|
await grab('pastSessions', () => ps.getPastParkingSessions({ currentPage: 1, pageSize: 10 }));
|
|
await grab('parkingLots', () => ps.getParkingLots());
|
|
await grab('states', () => ps.getStates());
|
|
await grab('states_withId', () => ps.getStates(1));
|
|
|
|
let firstZone = null;
|
|
if (lat != null && lng != null) {
|
|
const meters = await (async () => {
|
|
try {
|
|
return await ps.getMetersByLocation({ latitude: lat, longitude: lng });
|
|
} catch (e) {
|
|
results.metersByLocation = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
|
|
console.log(`✗ metersByLocation: ${e.name} ${e.status ?? ''} ${e.message}`);
|
|
return null;
|
|
}
|
|
})();
|
|
if (meters) {
|
|
writeFileSync(`${capDir}/metersByLocation.json`, JSON.stringify(skeleton(meters), null, 2));
|
|
results.metersByLocation = { ok: true, schema: skeleton(meters) };
|
|
console.log('✓ metersByLocation:', JSON.stringify(Object.keys(meters)));
|
|
firstZone = meters?.Zones?.[0] ?? null;
|
|
}
|
|
await grab('limitedMetersByLocation', () =>
|
|
ps.getLimitedMetersByLocation({ latitude: lat, longitude: lng }),
|
|
);
|
|
} else {
|
|
console.log('\n(skip meters-by-location: add "lat"/"lng" to .creds.json to include them)');
|
|
}
|
|
|
|
/* estimates are read-only price quotes — try them from a real zone + the user's vehicle */
|
|
try {
|
|
const me = await ps.getUserDetail();
|
|
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
|
|
const zone = firstZone;
|
|
const space = zone?.Spaces?.[0];
|
|
const zoneId = zone?.ZoneId ?? zone?.ZoneID;
|
|
const spaceId = space?.SpaceID ?? space?.SpaceId;
|
|
const customerId = zone?.CustomerID ?? zone?.CustomerId;
|
|
if (zoneId != null && spaceId != null && customerId != null && vehicleId != null) {
|
|
const common = { zoneId, spaceId, customerId, vehicleId };
|
|
await grab('estimateItems', () => ps.getParkingEstimateItems(common));
|
|
await grab('estimateMulti', () => ps.getParkingEstimateMulti(common));
|
|
await grab('estimateSingle', () =>
|
|
ps.getParkingEstimateSingle({ ...common, durationInMinutes: 60, creditCardId: 0 }),
|
|
);
|
|
} else {
|
|
console.log('(skip estimates: no zone/space/vehicle available to quote against)');
|
|
}
|
|
} catch (e) {
|
|
console.log(`(skip estimates: ${e.message})`);
|
|
}
|
|
|
|
/* try a receipt shape from a past session id, if any (read-only) */
|
|
try {
|
|
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
|
|
const tid = past?.Session?.[0]?.TransactionID;
|
|
if (tid != null) await grab('parkingReceipt', () => ps.getParkingReceipt(tid));
|
|
} catch {}
|
|
|
|
/* public content shapes */
|
|
await grab('states_public', () => ps.getStates());
|
|
|
|
writeFileSync(`${capDir}/_summary.json`, JSON.stringify(results, null, 2));
|
|
console.log('\nDone. Schema skeletons (no PII) written to ./capture/. Share that folder with me.');
|