← back to AbramsOS
routes/withings.js
103 lines
// Withings — connect a Withings account (OAuth2) and auto-sync device readings
// (weight, BP, heart rate, SpO2) into health_reading. Token stored encrypted in
// connector_account (provider='withings'); Withings rotates the refresh token each
// refresh, so we re-persist it every sync. Sensitive medical data; audited.
const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const { encrypt, decrypt } = require('../lib/crypto');
const w = require('../lib/withings-client');
const { insertReadings } = require('./vitals');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
async function getConnector() {
const r = await db.query(
`SELECT id, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag, sync_cursor, last_sync_at
FROM connector_account WHERE user_id = $1 AND provider = 'withings' LIMIT 1`, [DEV_USER_ID]);
return r.rows[0] || null;
}
async function selfPersonId() {
const r = await db.query(`SELECT id FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [DEV_USER_ID]);
return r.rows[0] && r.rows[0].id;
}
router.get('/api/withings/status', async (_req, res) => {
const c = await getConnector();
res.json({ configured: w.isConfigured(), connected: !!c, last_sync_at: c && c.last_sync_at });
});
// 1) Kick off OAuth — send the browser to Withings' consent screen.
router.get('/auth/withings', (_req, res) => {
if (!w.isConfigured()) return res.status(501).send('Withings not configured — set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET.');
res.redirect(w.authUrl('withings'));
});
// 2) Withings redirects back with a code → exchange, store, initial sync.
router.get('/api/withings/callback', async (req, res) => {
const { code } = req.query;
if (!code) return res.status(400).send('No authorization code');
try {
const tok = await w.exchangeCode(code);
await storeToken(tok);
const synced = await runSync().catch(() => ({ inserted: 0 }));
res.redirect(`/health?withings=connected&added=${synced.inserted || 0}`);
} catch (err) {
res.status(502).send(`Withings connect failed: ${err.message}`);
}
});
// 3) Manual re-sync.
router.post('/api/withings/sync', async (_req, res) => {
const c = await getConnector();
if (!c) return res.status(404).json({ ok: false, error: 'Withings not connected' });
try {
const out = await runSync();
res.json({ ok: true, ...out });
} catch (err) {
res.status(502).json({ ok: false, error: err.message });
}
});
async function storeToken(tok, connectorId) {
const enc = encrypt(tok.refresh_token);
if (connectorId) {
await db.query(
`UPDATE connector_account SET refresh_token_encrypted=$1, refresh_token_iv=$2, refresh_token_tag=$3 WHERE id=$4`,
[enc.ciphertext, enc.iv, enc.tag, connectorId]);
return connectorId;
}
const consentId = id('consent'); const cid = id('connector');
await db.tx(async (client) => {
await client.query(`INSERT INTO consent_grant (id, user_id, connector_type, scopes_json) VALUES ($1,$2,'withings',$3)`,
[consentId, DEV_USER_ID, JSON.stringify({ scope: 'user.metrics' })]);
await client.query(
`INSERT INTO connector_account (id, user_id, consent_grant_id, provider, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
VALUES ($1,$2,$3,'withings',$4,$5,$6,$7)
ON CONFLICT (provider, external_subject_id) DO UPDATE SET
refresh_token_encrypted=EXCLUDED.refresh_token_encrypted, refresh_token_iv=EXCLUDED.refresh_token_iv,
refresh_token_tag=EXCLUDED.refresh_token_tag, consent_grant_id=EXCLUDED.consent_grant_id`,
[cid, DEV_USER_ID, consentId, String(tok.userid || 'withings'), enc.ciphertext, enc.iv, enc.tag]);
});
return cid;
}
async function runSync() {
const c = await getConnector();
if (!c) throw new Error('not connected');
const refreshToken = decrypt(c.refresh_token_encrypted, c.refresh_token_iv, c.refresh_token_tag);
const tok = await w.refresh(refreshToken); // rotates the refresh token
await storeToken(tok, c.id); // re-persist the new one
const since = c.sync_cursor ? Number(c.sync_cursor) : null;
const { readings, newest } = await w.getMeasures(tok.access_token, since);
const personId = await selfPersonId();
const out = await insertReadings(DEV_USER_ID, personId, readings.map((r) => ({ ...r })));
await db.query(`UPDATE connector_account SET last_sync_at=now(), sync_cursor=$2 WHERE id=$1`, [c.id, String(newest || since || '')]);
await audit.log({ actorType: 'system', actorId: DEV_USER_ID, objectType: 'connector_account', objectId: c.id, eventType: 'withings_sync', metadata: { medical: true, fetched: readings.length, inserted: out.inserted } }).catch(() => {});
return { fetched: readings.length, inserted: out.inserted, skipped: out.skipped };
}
module.exports = router;