[object Object]

← back to AbramsOS

withings: device-vitals auto-sync connector (OAuth2 → weight/BP/HR/SpO2 into Health)

567188579bf5ad6d59ba29ac20ec38bf46730257 · 2026-07-13 09:56:02 -0700 · Steve

- lib/withings-client.js: Withings Health API OAuth (authUrl/exchange/refresh) + getMeasures mapping (type 1=weight kg→lb, 9/10=BP pair, 11=HR, 54=SpO2); handles refresh-token rotation; graceful no-creds
- routes/withings.js: /auth/withings → consent → /api/withings/callback stores encrypted token in connector_account (provider=withings) + initial sync; /api/withings/sync re-syncs (dedup via health_reading external_id); reuses vitals.insertReadings; audited medical
- connectors.ejs: 'Health devices' section — Connect Withings / Sync now / setup-needed
- server.js wiring + .env WITHINGS_* (gated). One-set-of-creds-from-working

Files touched

Diff

commit 567188579bf5ad6d59ba29ac20ec38bf46730257
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 09:56:02 2026 -0700

    withings: device-vitals auto-sync connector (OAuth2 → weight/BP/HR/SpO2 into Health)
    
    - lib/withings-client.js: Withings Health API OAuth (authUrl/exchange/refresh) + getMeasures mapping (type 1=weight kg→lb, 9/10=BP pair, 11=HR, 54=SpO2); handles refresh-token rotation; graceful no-creds
    - routes/withings.js: /auth/withings → consent → /api/withings/callback stores encrypted token in connector_account (provider=withings) + initial sync; /api/withings/sync re-syncs (dedup via health_reading external_id); reuses vitals.insertReadings; audited medical
    - connectors.ejs: 'Health devices' section — Connect Withings / Sync now / setup-needed
    - server.js wiring + .env WITHINGS_* (gated). One-set-of-creds-from-working
---
 lib/withings-client.js |  74 +++++++++++++++++++++++++++++++++++
 routes/withings.js     | 102 +++++++++++++++++++++++++++++++++++++++++++++++++
 server.js              |   2 +
 views/connectors.ejs   |  35 +++++++++++++++++
 4 files changed, 213 insertions(+)

diff --git a/lib/withings-client.js b/lib/withings-client.js
new file mode 100644
index 0000000..5f2c09e
--- /dev/null
+++ b/lib/withings-client.js
@@ -0,0 +1,74 @@
+// withings-client.js — Withings Health API (OAuth2) client for auto-syncing device
+// readings (weight, blood pressure, heart rate, SpO2) into the Vitals module.
+//
+// One-set-of-creds-from-working: register an app at developer.withings.com → set
+// WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET (+ optional WITHINGS_REDIRECT_URI). No creds
+// → graceful "not configured". Withings ROTATES the refresh token on every refresh, so the
+// caller must persist the new refresh_token each sync. Never fabricates a reading.
+
+const CLIENT_ID = () => process.env.WITHINGS_CLIENT_ID;
+const CLIENT_SECRET = () => process.env.WITHINGS_CLIENT_SECRET;
+const REDIRECT = () => process.env.WITHINGS_REDIRECT_URI || 'http://localhost:9774/api/withings/callback';
+
+const AUTH_URL = 'https://account.withings.com/oauth2_user/authorize2';
+const TOKEN_URL = 'https://wbsapi.withings.net/v2/oauth2';
+const MEAS_URL = 'https://wbsapi.withings.net/measure';
+
+function isConfigured() {
+  return Boolean(CLIENT_ID() && CLIENT_SECRET());
+}
+
+function authUrl(state) {
+  const p = new URLSearchParams({
+    response_type: 'code', client_id: CLIENT_ID(), scope: 'user.metrics',
+    redirect_uri: REDIRECT(), state: state || 'withings',
+  });
+  return `${AUTH_URL}?${p.toString()}`;
+}
+
+async function tokenRequest(extra) {
+  const body = new URLSearchParams({
+    action: 'requesttoken', client_id: CLIENT_ID(), client_secret: CLIENT_SECRET(), ...extra,
+  });
+  const r = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
+  const j = await r.json();
+  if (j.status !== 0) throw new Error(`withings token error ${j.status} ${j.error || ''}`.trim());
+  return j.body; // { userid, access_token, refresh_token, expires_in, ... }
+}
+
+function exchangeCode(code) {
+  return tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT() });
+}
+function refresh(refresh_token) {
+  return tokenRequest({ grant_type: 'refresh_token', refresh_token });
+}
+
+// Withings measure type → our metric. BP comes as a group containing 9 + 10 (+ often 11).
+const REAL = (m) => m.value * Math.pow(10, m.unit);
+
+async function getMeasures(access_token, lastUpdateEpoch) {
+  const body = new URLSearchParams({ action: 'getmeas', access_token, meastypes: '1,9,10,11,54', category: '1' });
+  if (lastUpdateEpoch) body.set('lastupdate', String(lastUpdateEpoch));
+  const r = await fetch(MEAS_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
+  const j = await r.json();
+  if (j.status !== 0) throw new Error(`withings measure error ${j.status}`);
+
+  const readings = [];
+  let newest = lastUpdateEpoch || 0;
+  for (const g of (j.body.measuregrps || [])) {
+    if (g.date > newest) newest = g.date;
+    const when = new Date(g.date * 1000);
+    const t = {};
+    for (const m of g.measures) t[m.type] = REAL(m);
+    const dev = { source: 'device', device_name: 'Withings', measured_at: when };
+    if (t[10] != null && t[9] != null) {
+      readings.push({ metric: 'blood_pressure', systolic: t[10], diastolic: t[9], unit: 'mmHg', external_id: `withings:bp:${g.grpid}`, ...dev });
+    }
+    if (t[1] != null) readings.push({ metric: 'weight', value: Math.round(t[1] * 2.20462 * 10) / 10, unit: 'lb', external_id: `withings:wt:${g.grpid}`, ...dev });
+    if (t[11] != null) readings.push({ metric: 'heart_rate', value: t[11], unit: 'bpm', external_id: `withings:hr:${g.grpid}`, ...dev });
+    if (t[54] != null) readings.push({ metric: 'spo2', value: t[54] <= 1 ? t[54] * 100 : t[54], unit: '%', external_id: `withings:spo2:${g.grpid}`, ...dev });
+  }
+  return { readings, newest };
+}
+
+module.exports = { isConfigured, authUrl, exchangeCode, refresh, getMeasures, REDIRECT };
diff --git a/routes/withings.js b/routes/withings.js
new file mode 100644
index 0000000..b5edf35
--- /dev/null
+++ b/routes/withings.js
@@ -0,0 +1,102 @@
+// 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;
diff --git a/server.js b/server.js
index a901eaf..dac508a 100644
--- a/server.js
+++ b/server.js
@@ -29,6 +29,7 @@ const vitalsRouter = require('./routes/vitals').router;
 const assetsRouter = require('./routes/assets');
 const neighborhoodRouter = require('./routes/neighborhood');
 const biometricsRouter = require('./routes/biometrics');
+const withingsRouter = require('./routes/withings');
 const warrantiesRouter = require('./routes/warranties');
 const peopleRouter = require('./routes/people');
 const medicationsRouter = require('./routes/medications');
@@ -97,6 +98,7 @@ app.use(vitalsRouter);              // /health, /api/health/readings*
 app.use(assetsRouter);              // /assets, /api/assets*
 app.use(neighborhoodRouter);        // /neighborhood, /api/neighborhood/geocode
 app.use(biometricsRouter);          // /biometrics, /api/biometrics*
+app.use(withingsRouter);            // /auth/withings, /api/withings/*
 app.use(warrantiesRouter);          // /warranties, /api/warranties*
 app.use(peopleRouter);              // /household, /api/people*
 app.use(medicationsRouter);         // /medications, /api/medications*
diff --git a/views/connectors.ejs b/views/connectors.ejs
index 5b42ee2..2f0cad9 100644
--- a/views/connectors.ejs
+++ b/views/connectors.ejs
@@ -15,6 +15,17 @@
   </div>
   <div id="bankList" style="margin-top:.75rem"></div>
   <div id="bankStatus" class="subtle" style="margin-top:.5rem"></div>
+</section>
+
+<section class="glass" id="withingsSection" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
+  <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
+    <div>
+      <h2 style="margin:.2rem 0">Health devices</h2>
+      <p class="subtle" style="margin:0">Connect <strong>Withings</strong> (smart scale / BP cuff / watch) to auto-sync weight, blood pressure, heart rate &amp; SpO₂ into <a href="/health">Health</a>. Read-only via Withings OAuth.</p>
+    </div>
+    <div id="withAction"><span class="subtle">checking…</span></div>
+  </div>
+  <div id="withStatus" class="subtle" style="margin-top:.5rem"></div>
 
   <hr style="border:none;border-top:1px solid rgba(128,128,128,.2);margin:1rem 0">
   <div>
@@ -150,4 +161,28 @@
   });
 </script>
 
+<script>
+(async function withings(){
+  const action=document.getElementById('withAction'), s=document.getElementById('withStatus');
+  let st; try{ st=await (await fetch('/api/withings/status')).json(); }catch(e){ action.innerHTML='<span class="subtle">status unavailable</span>'; return; }
+  if(!st.configured){
+    action.innerHTML='<span class="badge">setup needed</span>';
+    s.textContent='Register an app at developer.withings.com, then set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET via the secrets skill and reload.';
+    return;
+  }
+  if(!st.connected){
+    action.innerHTML='<a class="button primary" href="/auth/withings">Connect Withings</a>';
+    return;
+  }
+  action.innerHTML='<button class="button" id="withSync">Sync now</button>';
+  s.textContent = st.last_sync_at ? ('Last sync: '+new Date(st.last_sync_at).toLocaleString()) : 'Connected — never synced';
+  document.getElementById('withSync').addEventListener('click', async (e)=>{
+    e.target.disabled=true; s.textContent='Syncing…';
+    try{ const j=await (await fetch('/api/withings/sync',{method:'POST'})).json();
+      s.textContent=j.ok?('fetched '+j.fetched+', +'+j.inserted+' new readings'):('error: '+(j.error||'?')); }
+    catch(err){ s.textContent='error: '+err.message; } finally{ e.target.disabled=false; }
+  });
+})();
+</script>
+
 <%- include('partials/footer') %>

← ba2e47d assets: RentCast home-value estimate wiring (one-key-from-wo  ·  back to AbramsOS  ·  chore: lint (node --check 21/21) + version bump v0.3.0 (sess fc3c624 →