health-export-mcp 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -200,11 +200,57 @@ node server.mjs --help
200
200
  - **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
201
201
  - **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
202
202
  - **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
203
+ - **Encrypted exports (optional).** If you switch on Export encryption in the iOS app, set `HEALTH_EXPORT_PASSPHRASE` (or `HEALTH_EXPORT_PASSPHRASE_FILE`) to the same passphrase and the server decrypts on your machine. See [Encrypted exports](#encrypted-exports).
203
204
  - **Auditable.** Zero dependencies and a few hundred lines of readable JavaScript, read every line.
204
205
  - **Signed releases.** Hosted artifacts are minisign-signed and checksummed, see [Verifying releases](#verifying-releases).
205
206
 
206
207
  ---
207
208
 
209
+ ## Encrypted exports
210
+
211
+ The iOS app can encrypt what it exports with a passphrase you choose (Settings > Export encryption,
212
+ switched on per destination: iCloud Drive, a folder, your server, the local network). Give the server
213
+ the same passphrase and every file is decrypted transparently, in memory, on this machine:
214
+
215
+ ```bash
216
+ HEALTH_EXPORT_PASSPHRASE='your passphrase' HEALTH_DATA_DIR=... node server.mjs
217
+ # or keep it out of your shell history and process list:
218
+ HEALTH_EXPORT_PASSPHRASE_FILE=~/.config/metricbridge/passphrase HEALTH_DATA_DIR=... node server.mjs
219
+ ```
220
+
221
+ If both are set, `HEALTH_EXPORT_PASSPHRASE` wins; one trailing newline in the file is ignored and an
222
+ empty value counts as unset. The `.mcpb` bundle has an optional **Export passphrase** field. Plaintext
223
+ exports keep working either way. A missing or wrong passphrase, or a file that fails its integrity
224
+ check, is reported as such (`get_mcp_status` says `encrypted: true` plus the reason, `--doctor` prints
225
+ an `encryption:` line), never as "no data". The passphrase is never logged or echoed.
226
+
227
+ The LAN receiver (`HEALTH_LISTEN=1`) decrypts encrypted pushes with the same variable and answers
228
+ `422` with a machine `reason` (`passphrase_missing`, `passphrase_mismatch`, `tampered`, `malformed`,
229
+ `unsupported_version`, `passphrase_file_unreadable`, `encryption_required`). The pairing token rides
230
+ in a plain `http` header on that leg, so set `HEALTH_REQUIRE_ENCRYPTED=1` to refuse plaintext pushes
231
+ once the app encrypts that leg. To decrypt one body yourself (a webhook you run, a script):
232
+
233
+ ```bash
234
+ HEALTH_EXPORT_PASSPHRASE='...' node envelope.mjs open body.json # prints the decrypted JSON
235
+ ```
236
+
237
+ **Format** (`metricbridge.enc` v1, `envelope.mjs`, `node:crypto` only): a JSON object
238
+ `{format, v, alg: "A256GCM", kdf: "PBKDF2-HMAC-SHA256", iter, salt, kcv, nonce, ct}`.
239
+ `master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt[16], iter)` (600,000 iterations, the OWASP
240
+ figure); an AES-256-GCM key and an 8-byte key-check value come off it with HKDF-SHA256; each message
241
+ gets a random 12-byte nonce; every header field is bound in as AAD as the string
242
+ `metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>`.
243
+ Base64 is canonical with padding. `test/fixtures/` holds envelopes made by the iOS app's Swift encoder
244
+ and by this module; `envelope.test.mjs` proves both open and regenerate byte for byte.
245
+
246
+ **What it protects:** the export at rest in the destination (iCloud Drive, a synced folder, a
247
+ webhook host's storage) and the LAN push on the wire, plus integrity of every sealed file. **What it
248
+ does not:** an unlocked phone, this computer (it holds the passphrase), metadata (sizes, timing),
249
+ rollback or replay of a whole sealed file, files you share by hand from the app, or a weak passphrase
250
+ (anyone with the files can guess offline; the app requires at least 8 characters).
251
+
252
+ ---
253
+
208
254
  ## Verifying releases
209
255
 
210
256
  The server artifacts hosted at `healthexport.dev/mcp/` (used by the [setup skill](https://healthexport.dev/SKILL.md)) are **minisign-signed**, and every download is **SHA-256 checksummed**. The signing public key is published **in this repo** (`minisign.pub`) and in `SKILL.md`, **pin it from here, not only from the website**, so a compromise of the website alone cannot swap both an artifact and its key.
package/envelope.mjs ADDED
@@ -0,0 +1,239 @@
1
+ // envelope.mjs: reads (and, for tests and tooling, writes) the MetricBridge encrypted export
2
+ // envelope, format "metricbridge.enc" version 1. Zero dependencies: node:crypto only.
3
+ //
4
+ // The iOS app can seal what it exports (iCloud Drive / folder files, webhook and local-network
5
+ // bodies) under a passphrase the user chose. This module is the matching decrypt step, so the MCP
6
+ // server and the LAN receiver read encrypted exports transparently once they know the passphrase.
7
+ //
8
+ // Envelope (UTF-8 JSON, keys sorted):
9
+ // { "alg":"A256GCM", "ct":"<b64 ciphertext||16-byte tag>", "format":"metricbridge.enc",
10
+ // "iter":600000, "kcv":"<b64 8 bytes>", "kdf":"PBKDF2-HMAC-SHA256",
11
+ // "nonce":"<b64 12 bytes>", "salt":"<b64 16 bytes>", "v":1 }
12
+ //
13
+ // master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt, iter, 32)
14
+ // encKey = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/aes-256-gcm", 32)
15
+ // kcv = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/key-check", 8)
16
+ // AAD = "metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>"
17
+ // (base64 re-encoded from the decoded bytes, canonical with padding)
18
+ //
19
+ // Full specification and threat model: docs/ENCRYPTED-EXPORTS.md in the app repo, and the
20
+ // "Encrypted exports" section of README.md.
21
+ //
22
+ // Passphrase source, in order: HEALTH_EXPORT_PASSPHRASE, then the file named by
23
+ // HEALTH_EXPORT_PASSPHRASE_FILE (one trailing newline is stripped). The passphrase is never logged,
24
+ // never echoed in an error and never written anywhere.
25
+ //
26
+ // CLI (for webhook receivers and scripts that want the plaintext of one envelope):
27
+ // HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|-> # prints the decrypted JSON
28
+
29
+ import crypto from 'node:crypto';
30
+ import fs from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ export const FORMAT = 'metricbridge.enc';
34
+ export const VERSION = 1;
35
+ export const ALG = 'A256GCM';
36
+ export const KDF = 'PBKDF2-HMAC-SHA256';
37
+ export const DEFAULT_ITERATIONS = 600_000; // OWASP Password Storage Cheat Sheet, PBKDF2-HMAC-SHA256
38
+ export const MIN_ITERATIONS = 100_000; // a forged header cannot steer us into a cheap derivation
39
+ export const MAX_ITERATIONS = 10_000_000; // ...or pin the CPU for minutes
40
+ const SALT_LEN = 16, NONCE_LEN = 12, KCV_LEN = 8, TAG_LEN = 16, KEY_LEN = 32;
41
+ const ENC_INFO = Buffer.from('metricbridge.enc/v1/aes-256-gcm', 'utf8');
42
+ const KCV_INFO = Buffer.from('metricbridge.enc/v1/key-check', 'utf8');
43
+
44
+ /** Machine-readable failure. `code` is stable; `message` never contains the passphrase. */
45
+ export class EnvelopeError extends Error {
46
+ constructor(code, message) { super(message); this.name = 'EnvelopeError'; this.code = code; }
47
+ }
48
+
49
+ export const isEnvelope = (obj) =>
50
+ !!obj && typeof obj === 'object' && !Array.isArray(obj) && obj.format === FORMAT;
51
+
52
+ /** The configured passphrase, or null. Read on every call so tests (and a restarted config) see
53
+ * the current environment; it is cheap. Empty means unset: the .mcpb manifest maps an empty
54
+ * optional field to an empty string. */
55
+ export function configuredPassphrase(env = process.env) {
56
+ const direct = env.HEALTH_EXPORT_PASSPHRASE;
57
+ if (typeof direct === 'string' && direct.length > 0) return direct;
58
+ const file = env.HEALTH_EXPORT_PASSPHRASE_FILE;
59
+ if (typeof file === 'string' && file.length > 0) {
60
+ let text;
61
+ try { text = fs.readFileSync(file, 'utf8'); }
62
+ catch (e) {
63
+ throw new EnvelopeError('passphrase_file_unreadable',
64
+ `HEALTH_EXPORT_PASSPHRASE_FILE points at a file that could not be read (${e.code || 'error'}).`);
65
+ }
66
+ const p = text.replace(/\r?\n$/, '');
67
+ return p.length > 0 ? p : null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // PBKDF2 at 600,000 rounds is ~0.3 s; every file the app writes shares one salt, so derive once per
73
+ // (passphrase, salt, iter). Keyed by an HMAC of the passphrase under a per-process random key, so the
74
+ // map holds neither a second copy of the passphrase nor a fast unsalted hash of it.
75
+ // Bounded (LRU): the app uses one salt per passphrase, so a handful of entries is plenty, and an
76
+ // unbounded map would let a stream of fresh-salt envelopes grow memory without limit.
77
+ const keyCache = new Map();
78
+ const KEY_CACHE_MAX = 16;
79
+ const CACHE_ID_KEY = crypto.randomBytes(32);
80
+
81
+ function b64(buf) { return Buffer.from(buf).toString('base64'); }
82
+
83
+ function decodeField(env, name, len) {
84
+ const s = env[name];
85
+ // Canonical padded base64 only: Buffer.from is lenient (drops padding, ignores stray characters)
86
+ // and the Swift reader is not, so both apply the same rule and accept exactly the same envelopes.
87
+ if (typeof s !== 'string' || s.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(s)) {
88
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
89
+ }
90
+ const buf = Buffer.from(s, 'base64');
91
+ if (buf.toString('base64') !== s) {
92
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
93
+ }
94
+ if (len !== undefined && buf.length !== len) {
95
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}" length)`);
96
+ }
97
+ return buf;
98
+ }
99
+
100
+ function exactInt(v) { return Number.isInteger(v) ? v : null; }
101
+
102
+ /** Parse and validate the header of an envelope object. */
103
+ export function parseHeader(env) {
104
+ if (!isEnvelope(env)) throw new EnvelopeError('malformed', 'not an encrypted export');
105
+ const v = exactInt(env.v);
106
+ if (v === null) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "v")');
107
+ if (v !== VERSION) {
108
+ throw new EnvelopeError('unsupported_version',
109
+ `This export was encrypted with envelope version ${v}, but this server only reads version ${VERSION}. ` +
110
+ 'Update the MetricBridge MCP server.');
111
+ }
112
+ if (env.alg !== ALG) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "alg")');
113
+ if (env.kdf !== KDF) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "kdf")');
114
+ const iter = exactInt(env.iter);
115
+ if (iter === null || iter < MIN_ITERATIONS || iter > MAX_ITERATIONS) {
116
+ throw new EnvelopeError('malformed', 'encrypted export is malformed (field "iter")');
117
+ }
118
+ const salt = decodeField(env, 'salt', SALT_LEN);
119
+ const kcv = decodeField(env, 'kcv', KCV_LEN);
120
+ const nonce = decodeField(env, 'nonce', NONCE_LEN);
121
+ const sealed = decodeField(env, 'ct');
122
+ if (sealed.length < TAG_LEN) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "ct")');
123
+ return { v, iter, salt, kcv, nonce, sealed };
124
+ }
125
+
126
+ export function associatedData({ v, iter, salt, kcv, nonce }) {
127
+ return Buffer.from(
128
+ `${FORMAT}|v=${v}|alg=${ALG}|kdf=${KDF}|iter=${iter}|salt=${b64(salt)}|kcv=${b64(kcv)}|nonce=${b64(nonce)}`,
129
+ 'utf8');
130
+ }
131
+
132
+ /** Derive { encKey, kcv } for a passphrase + salt + iteration count (memoized). */
133
+ export function deriveKeys(passphrase, salt, iter) {
134
+ if (typeof passphrase !== 'string' || passphrase.length === 0) {
135
+ throw new EnvelopeError('passphrase_missing', 'no passphrase');
136
+ }
137
+ const pw = Buffer.from(passphrase.normalize('NFC'), 'utf8');
138
+ const id = crypto.createHmac('sha256', CACHE_ID_KEY).update(pw).digest('hex') + '|' + b64(salt) + '|' + iter;
139
+ const hit = keyCache.get(id);
140
+ if (hit) { keyCache.delete(id); keyCache.set(id, hit); return hit; }
141
+ const master = crypto.pbkdf2Sync(pw, salt, iter, KEY_LEN, 'sha256');
142
+ const keys = {
143
+ encKey: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), ENC_INFO, KEY_LEN)),
144
+ kcv: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), KCV_INFO, KCV_LEN)),
145
+ };
146
+ master.fill(0);
147
+ pw.fill(0);
148
+ keyCache.set(id, keys);
149
+ while (keyCache.size > KEY_CACHE_MAX) keyCache.delete(keyCache.keys().next().value);
150
+ return keys;
151
+ }
152
+
153
+ /** Decrypt an envelope object to a Buffer of plaintext. Throws EnvelopeError with code
154
+ * 'passphrase_missing' | 'passphrase_mismatch' | 'tampered' | 'malformed' | 'unsupported_version'. */
155
+ export function openEnvelope(env, passphrase = configuredPassphrase()) {
156
+ const h = parseHeader(env);
157
+ if (!passphrase) {
158
+ throw new EnvelopeError('passphrase_missing',
159
+ 'This health export is encrypted. Set HEALTH_EXPORT_PASSPHRASE (or HEALTH_EXPORT_PASSPHRASE_FILE) ' +
160
+ 'to the passphrase you chose in the MetricBridge app (Settings > Export encryption), then restart the server.');
161
+ }
162
+ const { encKey, kcv } = deriveKeys(passphrase, h.salt, h.iter);
163
+ if (!crypto.timingSafeEqual(kcv, h.kcv)) {
164
+ throw new EnvelopeError('passphrase_mismatch',
165
+ 'This health export is encrypted with a different passphrase than HEALTH_EXPORT_PASSPHRASE. ' +
166
+ 'Use the passphrase set in the MetricBridge app (Settings > Export encryption).');
167
+ }
168
+ const ct = h.sealed.subarray(0, h.sealed.length - TAG_LEN);
169
+ const tag = h.sealed.subarray(h.sealed.length - TAG_LEN);
170
+ try {
171
+ const d = crypto.createDecipheriv('aes-256-gcm', encKey, h.nonce, { authTagLength: TAG_LEN });
172
+ d.setAAD(associatedData(h));
173
+ d.setAuthTag(tag);
174
+ return Buffer.concat([d.update(ct), d.final()]);
175
+ } catch {
176
+ throw new EnvelopeError('tampered',
177
+ 'An encrypted health export failed its integrity check: the file is damaged or was modified. ' +
178
+ 'Run an export from the MetricBridge app to rewrite it.');
179
+ }
180
+ }
181
+
182
+ /** Decrypt and JSON.parse. */
183
+ export function openEnvelopeJSON(env, passphrase) {
184
+ return JSON.parse(openEnvelope(env, passphrase).toString('utf8'));
185
+ }
186
+
187
+ /** If `obj` is an envelope, return its decrypted JSON; otherwise return it unchanged. */
188
+ export function maybeOpen(obj, passphrase) {
189
+ return isEnvelope(obj) ? openEnvelopeJSON(obj, passphrase === undefined ? configuredPassphrase() : passphrase) : obj;
190
+ }
191
+
192
+ /** Seal plaintext (Buffer or string) into an envelope object. Used by tests, by the cross-language
193
+ * fixture and by the receiver when it rewrites a cache that was already sealed. `nonce` and `salt`
194
+ * are injectable for deterministic fixtures ONLY: production use must leave both to the CSPRNG. */
195
+ export function sealEnvelope(plaintext, passphrase, { salt, iter = DEFAULT_ITERATIONS, nonce } = {}) {
196
+ const s = salt ? Buffer.from(salt) : crypto.randomBytes(SALT_LEN);
197
+ const n = nonce ? Buffer.from(nonce) : crypto.randomBytes(NONCE_LEN);
198
+ if (s.length !== SALT_LEN || n.length !== NONCE_LEN) throw new EnvelopeError('malformed', 'bad salt or nonce length');
199
+ const { encKey, kcv } = deriveKeys(passphrase, s, iter);
200
+ const header = { v: VERSION, iter, salt: s, kcv, nonce: n };
201
+ const c = crypto.createCipheriv('aes-256-gcm', encKey, n, { authTagLength: TAG_LEN });
202
+ c.setAAD(associatedData(header));
203
+ const ct = Buffer.concat([c.update(Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf8')), c.final()]);
204
+ // Same key order the app writes (sorted), so a fixture regenerates byte-for-byte.
205
+ return {
206
+ alg: ALG,
207
+ ct: b64(Buffer.concat([ct, c.getAuthTag()])),
208
+ format: FORMAT,
209
+ iter,
210
+ kcv: b64(kcv),
211
+ kdf: KDF,
212
+ nonce: b64(n),
213
+ salt: b64(s),
214
+ v: VERSION,
215
+ };
216
+ }
217
+
218
+ // ---- CLI ----
219
+ const isEntryPoint = (() => {
220
+ if (!process.argv[1]) return false;
221
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
222
+ catch { return false; }
223
+ })();
224
+ if (isEntryPoint) {
225
+ const [cmd, file] = process.argv.slice(2);
226
+ if (cmd !== 'open' || !file) {
227
+ process.stderr.write('usage: HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|->\n');
228
+ process.exit(2);
229
+ }
230
+ try {
231
+ const text = fs.readFileSync(file === '-' ? 0 : file, 'utf8');
232
+ const obj = JSON.parse(text);
233
+ if (!isEnvelope(obj)) { process.stdout.write(text); process.exit(0); }
234
+ process.stdout.write(openEnvelope(obj).toString('utf8'));
235
+ } catch (e) {
236
+ process.stderr.write(`envelope: ${e instanceof EnvelopeError ? e.message : 'could not read input (' + (e.code || e.name) + ')'}\n`);
237
+ process.exit(1);
238
+ }
239
+ }
Binary file
package/healthstore.mjs CHANGED
@@ -26,6 +26,9 @@ import os from 'node:os';
26
26
  // functions, never at module-evaluation time.
27
27
  import { DEMO, demoData } from './demo.mjs';
28
28
  import * as ev from './events.mjs';
29
+ // Encrypted exports (opt-in in the iOS app): every data file may be a "metricbridge.enc" envelope.
30
+ // readJSON opens it with HEALTH_EXPORT_PASSPHRASE, so every reader below is transparent to it.
31
+ import { isEnvelope, openEnvelope, configuredPassphrase, EnvelopeError } from './envelope.mjs';
29
32
 
30
33
  // Expand a leading ~ (Node, unlike the shell, does not) so manual configs resolve correctly.
31
34
  const RAW_DIR = process.env.HEALTH_DATA_DIR || '.';
@@ -48,7 +51,9 @@ const MAX_BYTES = Number(process.env.HEALTH_MAX_CACHE_BYTES || 512 * 1024 * 1024
48
51
 
49
52
  class CacheTooLarge extends Error {}
50
53
 
51
- function readJSON(file, fallback) {
54
+ // `info.encrypted` is set when the file was an envelope, so callers that memoize the parse (and
55
+ // `encryptionStatus`) know without parsing a tens-of-MB file a second time.
56
+ function readJSON(file, fallback, info = {}) {
52
57
  let st;
53
58
  try {
54
59
  st = fs.statSync(file);
@@ -59,9 +64,54 @@ function readJSON(file, fallback) {
59
64
  `health cache is ${(st.size / 1048576).toFixed(1)} MB, above the ${(MAX_BYTES / 1048576).toFixed(0)} MB read limit. ` +
60
65
  `Raise HEALTH_MAX_CACHE_BYTES if this is expected.`);
61
66
  }
67
+ let parsed;
62
68
  try {
63
- return JSON.parse(fs.readFileSync(file, 'utf8'));
69
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
64
70
  } catch { return fallback; } // corrupt -> fall back rather than crash the server
71
+ // An encrypted export is NOT corrupt and must never read as "no data": no passphrase, the wrong
72
+ // passphrase and a failed integrity check each THROW an EnvelopeError whose message says exactly
73
+ // what to do. The passphrase itself never appears in any message.
74
+ if (isEnvelope(parsed)) {
75
+ info.encrypted = true;
76
+ const plain = openEnvelope(parsed);
77
+ // An envelope that authenticates but holds bytes that are not JSON (a truncated write the app
78
+ // sealed as raw bytes) is a damaged export, not a crash: reported as an EnvelopeError so
79
+ // get_mcp_status explains it instead of rethrowing a bare SyntaxError.
80
+ try { return JSON.parse(plain.toString('utf8')); }
81
+ catch {
82
+ throw new EnvelopeError('corrupt',
83
+ 'An encrypted health export opened with your passphrase but its contents are damaged. ' +
84
+ 'Run an export from the MetricBridge app to rewrite it.');
85
+ }
86
+ }
87
+ return parsed;
88
+ }
89
+
90
+ /** What the data directory's encryption looks like, for get_mcp_status and `doctor`. Reads only
91
+ * the envelope header (no decryption is attempted beyond what loading the data already does). */
92
+ export function encryptionStatus() {
93
+ if (DEMO) return { encrypted: false };
94
+ const file = path.join(DATA_DIR, '.health-cache.json');
95
+ // The cache was almost always just parsed (status() loads it first): reuse that answer rather
96
+ // than parsing a file that reaches tens of MB again. Fall back to a header read when it was not.
97
+ let encrypted;
98
+ const hit = _memo.get(file);
99
+ if (hit) {
100
+ try {
101
+ const st = fs.statSync(file);
102
+ if (hit.stamp === `${st.mtimeMs}:${st.size}`) encrypted = hit.encrypted;
103
+ } catch { return { encrypted: false }; }
104
+ }
105
+ if (encrypted === undefined) {
106
+ let raw;
107
+ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); }
108
+ catch { return { encrypted: false }; }
109
+ encrypted = isEnvelope(raw);
110
+ }
111
+ if (!encrypted) return { encrypted: false };
112
+ let configured = false;
113
+ try { configured = !!configuredPassphrase(); } catch { configured = false; }
114
+ return { encrypted: true, passphraseConfigured: configured };
65
115
  }
66
116
 
67
117
  // Pairing gate: if the iOS app wrote `.health-pair.json`, the configured PAIRING_SECRET must
@@ -103,8 +153,9 @@ export function readJSONCached(file, fallback) {
103
153
  const stamp = `${st.mtimeMs}:${st.size}`;
104
154
  const hit = _memo.get(file);
105
155
  if (hit && hit.stamp === stamp) return hit.value;
106
- const value = readJSON(file, fallback);
107
- _memo.set(file, { stamp, value });
156
+ const info = {};
157
+ const value = readJSON(file, fallback, info);
158
+ _memo.set(file, { stamp, value, encrypted: !!info.encrypted });
108
159
  return value;
109
160
  }
110
161
 
@@ -149,6 +200,76 @@ export async function loadMetrics() {
149
200
  /// invalidated exactly when `readJSONCached` re-reads the file.
150
201
  let viewCache = { raw: null, view: Object.create(null) };
151
202
 
203
+ /** The cache's `_meta` (schema, app, writtenAt, schemaMinor, recompute), or null. Absent on caches
204
+ * written before app 1.2, and in demo mode (no file is read). */
205
+ export function cacheMeta() {
206
+ if (DEMO || !pairing().ok) return null;
207
+ const raw = readJSONCached(path.join(DATA_DIR, '.health-cache.json'), {});
208
+ return raw && typeof raw._meta === 'object' ? raw._meta : null;
209
+ }
210
+
211
+ // ---- Late-arrival recompute provenance (cache schema minor 1, docs/SCHEMA-CONTRACTS.md s.9) ----
212
+ // The app re-reads only the trailing days on each run. When Apple Health later receives samples
213
+ // dated BEFORE that window (a scale or toothbrush syncing after a gap, a back-logged entry), or
214
+ // samples are deleted, the app rebuilds the affected past days from HealthKit and stamps each one
215
+ // with `r` (when it was rebuilt). Days older than its rebuild cap are not rebuilt; the app records
216
+ // that in `_meta.recompute.capped[metric]` instead of dropping it silently. Everything here is
217
+ // additive: an older cache has neither field and every answer is exactly what it was.
218
+ // A deletion marker has no dates and nothing resolves it, so it expires after this many days
219
+ // (the app drops it on its next write too).
220
+ const DELETION_MARKER_DAYS = 30;
221
+ const markerLive = (c) => !(c?.reason === 'deleted_samples'
222
+ && (!c.at || Date.parse(c.at) < Date.now() - DELETION_MARKER_DAYS * 86400000));
223
+ const recomputeCapped = (name) => {
224
+ const c = cacheMeta()?.recompute?.capped?.[name];
225
+ return c && typeof c === 'object' && markerLive(c) ? c : null;
226
+ };
227
+
228
+ /** Rebuild provenance for `points` (already filtered to the caller's range) of metric `name`, or
229
+ * null when nothing in range was rebuilt and no cap marker applies. `rangeStart`/`rangeEnd` decide
230
+ * whether an un-rebuilt (capped) stretch overlaps what the caller asked about. */
231
+ export function recomputeOf(name, points, rangeStart, rangeEnd, explicitStart) {
232
+ const rebuilt = points.filter((p) => typeof p.r === 'string');
233
+ const capped = recomputeCapped(name);
234
+ const capDays = cacheMeta()?.recompute?.capDays ?? null;
235
+ // A capped marker only matters when the question reaches back before the rebuild window. A
236
+ // deletion marker has no known dates at all, so it is shown only when the caller explicitly
237
+ // asked about days before it; otherwise every default-range answer would carry it for ever.
238
+ const isDeletion = capped?.reason === 'deleted_samples';
239
+ const cappedApplies = capped
240
+ && (!isDeletion || (!!explicitStart && !!capped.uncheckedBefore && explicitStart < capped.uncheckedBefore))
241
+ && (!rangeStart || !capped.uncheckedBefore || rangeStart < capped.uncheckedBefore)
242
+ && (!rangeEnd || !capped.oldest || rangeEnd >= capped.oldest);
243
+ if (!rebuilt.length && !cappedApplies) return null;
244
+ const latest = rebuilt.reduce((a, p) => (a == null || p.r > a ? p.r : a), null);
245
+ const notes = [];
246
+ if (rebuilt.length) {
247
+ notes.push(`${rebuilt.length} day(s) in this range were rebuilt from Apple Health after samples arrived late or were deleted (recomputed_at is the most recent rebuild). Days not listed in rebuiltDays were not touched by a rebuild.`);
248
+ }
249
+ if (cappedApplies) {
250
+ notes.push(capped.reason === 'deleted_samples'
251
+ ? `Samples were deleted in Apple Health and the app could not tell which days they were on. Days before ${capped.uncheckedBefore} were not re-checked, so they may still include the deleted samples.`
252
+ : `Samples dated ${capped.oldest ?? '?'} to ${capped.newest ?? '?'} arrived late but are older than the ${capDays ?? 'rebuild'}-day rebuild window, so those days were NOT rebuilt and may be stale. Exporting "Your full history" in the iOS app rebuilds them.`);
253
+ }
254
+ return {
255
+ backfilled_days: rebuilt.length,
256
+ recomputed_at: latest,
257
+ ...(rebuilt.length && { rebuiltDays: rebuilt.slice(-31).map((p) => p.d) }),
258
+ ...(rebuilt.length > 31 && { rebuiltDaysTruncated: rebuilt.length - 31 }),
259
+ ...(cappedApplies && {
260
+ capped: {
261
+ reason: capped.reason ?? 'late_samples',
262
+ ...(capped.oldest && { oldest: capped.oldest }),
263
+ ...(capped.newest && { newest: capped.newest }),
264
+ ...(capped.lastDays != null && { days: capped.lastDays }),
265
+ ...(capped.uncheckedBefore && { uncheckedBefore: capped.uncheckedBefore }),
266
+ ...(capped.at && { at: capped.at }),
267
+ },
268
+ }),
269
+ note: notes.join(' '),
270
+ };
271
+ }
272
+
152
273
  export async function loadWorkouts() {
153
274
  if (!pairing().ok) return [];
154
275
  if (DEMO) return demoData().workouts;
@@ -229,17 +350,24 @@ function resolveGranularity(pointCount, requested, limit) {
229
350
  // averages -- the same rule HealthCache uses on the writing side, so a monthly step total and a
230
351
  // monthly resting-HR average both mean what a reader expects.
231
352
  function rollUp(points, granularity, cumulative) {
232
- if (granularity === 'day') return points.map((p) => ({ date: p.d, value: round(p.v) }));
353
+ // A day rebuilt after late or deleted samples carries `recomputed_at`; an untouched day carries
354
+ // nothing extra, so the two are distinguishable point by point (schema minor 1).
355
+ if (granularity === 'day') {
356
+ return points.map((p) => ({ date: p.d, value: round(p.v), ...(typeof p.r === 'string' && { recomputed_at: p.r }) }));
357
+ }
233
358
  const buckets = new Map();
234
359
  for (const p of points) {
235
360
  const k = bucketKey(p.d, granularity);
236
- if (!buckets.has(k)) buckets.set(k, []);
237
- buckets.get(k).push(p.v);
361
+ if (!buckets.has(k)) buckets.set(k, { vs: [], rebuilt: 0 });
362
+ const b = buckets.get(k);
363
+ b.vs.push(p.v);
364
+ if (typeof p.r === 'string') b.rebuilt += 1;
238
365
  }
239
- return [...buckets.entries()].map(([k, vs]) => ({
366
+ return [...buckets.entries()].map(([k, { vs, rebuilt }]) => ({
240
367
  date: k,
241
368
  value: round(cumulative ? vs.reduce((a, b) => a + b, 0) : vs.reduce((a, b) => a + b, 0) / vs.length),
242
369
  days: vs.length,
370
+ ...(rebuilt && { backfilled_days: rebuilt }),
243
371
  }));
244
372
  }
245
373
 
@@ -281,8 +409,28 @@ const round = (n) => (n == null ? null : Math.round(n * 1000) / 1000);
281
409
 
282
410
  export async function status() {
283
411
  const p = pairing();
284
- const metrics = await loadMetrics();
285
- const workouts = await loadWorkouts();
412
+ let metrics, workouts;
413
+ try {
414
+ metrics = await loadMetrics();
415
+ workouts = await loadWorkouts();
416
+ } catch (e) {
417
+ // An encrypted export this server cannot open is reported, not thrown: get_mcp_status is the
418
+ // tool agents call first, and it is where the fix (the passphrase) has to be spelled out.
419
+ if (!(e instanceof EnvelopeError)) throw e;
420
+ return {
421
+ ok: false,
422
+ source: sourceLabel(),
423
+ encrypted: true,
424
+ encryptionError: e.code,
425
+ note: e.message,
426
+ paired: p.required,
427
+ locked: p.required && !p.ok,
428
+ metricCount: 0,
429
+ workoutCount: 0,
430
+ lastDataDate: null,
431
+ metrics: [],
432
+ };
433
+ }
286
434
  const names = Object.keys(metrics);
287
435
  let lastDate = null;
288
436
  for (const m of Object.values(metrics)) {
@@ -301,12 +449,40 @@ export async function status() {
301
449
  }),
302
450
  paired: p.required,
303
451
  locked: p.required && !p.ok,
452
+ // Additive: true when the app sealed the export with a passphrase (and this server opened it).
453
+ encrypted: encryptionStatus().encrypted,
304
454
  note: (p.required && !p.ok)
305
455
  ? 'Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing → scan/paste).'
306
456
  : undefined,
307
457
  metricCount: names.length,
308
458
  workoutCount: Array.isArray(workouts) ? workouts.length : 0,
309
459
  lastDataDate: lastDate,
460
+ // Late-arrival recompute (cache schema minor 1). `supported: false` means the app that wrote
461
+ // this cache predates it: past days are only as fresh as the run that last covered them.
462
+ recompute: (() => {
463
+ const meta = cacheMeta();
464
+ const rec = meta?.recompute ?? null;
465
+ let rebuiltDays = 0;
466
+ const rebuiltMetrics = [];
467
+ for (const [name, m] of Object.entries(metrics)) {
468
+ const n = (m?.daily || []).filter((p) => typeof p.r === 'string').length;
469
+ if (n) { rebuiltDays += n; rebuiltMetrics.push(name); }
470
+ }
471
+ const capped = Object.entries(rec?.capped ?? {}).filter(([, c]) => markerLive(c)).map(([metric, c]) => ({ metric, ...c }))
472
+ .sort((x, y) => x.metric.localeCompare(y.metric));
473
+ return {
474
+ // A LAN receiver cache has no _meta but does carry `r` stamps pushed by the app.
475
+ supported: Number(meta?.schemaMinor ?? 0) >= 1 || rebuiltDays > 0,
476
+ ...(rec?.capDays != null && { capDays: rec.capDays }),
477
+ ...(rec?.lastRebuiltAt && { recomputed_at: rec.lastRebuiltAt, lastRebuiltDays: rec.lastRebuiltDays ?? null }),
478
+ backfilled_days: rebuiltDays,
479
+ ...(rebuiltMetrics.length && { metricsWithRebuiltDays: rebuiltMetrics.sort() }),
480
+ ...(capped.length && {
481
+ capped,
482
+ cappedNote: 'Some late samples (or deletions) affect days older than the app rebuilds per run, so those days may be stale. Data tools flag them per metric; exporting "Your full history" in the iOS app rebuilds them.',
483
+ }),
484
+ };
485
+ })(),
310
486
  // Additive: lets an agent see whether a live hourly window exists without calling the tool.
311
487
  intraday: (() => {
312
488
  const st = intradayStat();
@@ -498,6 +674,8 @@ export async function getHealthMetrics({ metric, start, end, aggregation, granul
498
674
  pointsInRange: points.length,
499
675
  coverage: coverageOf(m),
500
676
  };
677
+ const rc = recomputeOf(name, points, start || all[0]?.d, end || all[all.length - 1]?.d, start);
678
+ if (rc) out[name].recompute = rc;
501
679
  if (gran !== 'day') {
502
680
  out[name].note = `${points.length} daily values rolled up to ${gran}. Narrow start/end, or pass granularity:"day" with a smaller range, for day-level detail.`;
503
681
  }
@@ -605,7 +783,13 @@ export async function getTrends({ metric, window = 7, excludeTravelDays } = {})
605
783
  const spanStart = Number.isFinite(anchor) ? new Date(priorFrom).toISOString().slice(0, 10) : null;
606
784
  const spanEnd = daily.length ? daily[daily.length - 1].d : null;
607
785
  const bounds = spanStart && spanEnd ? ev.boundariesIn(spanStart, spanEnd) : null;
786
+ // Rebuilt days inside the compared span: a trend whose prior window was just rebuilt from late
787
+ // samples is a different finding from one computed over settled days.
788
+ const trendRc = Number.isFinite(anchor)
789
+ ? recomputeOf(metric, daily.filter((p2) => inSpan(p2, priorFrom, anchor)), spanStart, spanEnd)
790
+ : null;
608
791
  return {
792
+ ...(trendRc && { recompute: trendRc }),
609
793
  ...(bounds && bounds.length && {
610
794
  segmentBoundaries: bounds,
611
795
  segmentNote: ev.SEGMENT_NOTE(bounds.length),
@@ -724,6 +908,13 @@ export async function comparePeriods({ metric, periodA, periodB, anchor, exclude
724
908
  const travelA = a[metric]?.excludedDays ?? 0, travelB = b[metric]?.excludedDays ?? 0;
725
909
  return {
726
910
  metric, unit: a[metric]?.unit || '',
911
+ // Per side, so an agent can see WHICH period contains rebuilt (or un-rebuilt) days.
912
+ ...((a[metric]?.recompute || b[metric]?.recompute) && {
913
+ recompute: {
914
+ ...(a[metric]?.recompute && { periodA: a[metric].recompute }),
915
+ ...(b[metric]?.recompute && { periodB: b[metric].recompute }),
916
+ },
917
+ }),
727
918
  ...(anchorInfo && { anchor: anchorInfo }),
728
919
  ...(bounds.length && {
729
920
  segmentBoundaries: bounds,
@@ -820,11 +1011,19 @@ export async function getStructuredExport({ metrics: names, start, end, granular
820
1011
  const rolled = rollUp(points, gran, !!m.cumulative);
821
1012
  // Stop BEFORE exceeding the budget, so a page is never half a metric.
822
1013
  if (used > 0 && used + rolled.length > cap) break;
1014
+ const rc = recomputeOf(name, points, start || m.daily?.[0]?.d, end || m.daily?.[m.daily.length - 1]?.d, start);
823
1015
  data[name] = {
824
1016
  unit: m.unit || '', cumulative: !!m.cumulative, granularity: gran,
825
- daily: rolled.map((p) => ({ d: p.date, v: p.value })),
1017
+ // `r` mirrors the cache's own per-day key: present only on days rebuilt after late or
1018
+ // deleted samples (rolled-up buckets carry a count instead).
1019
+ daily: rolled.map((p) => ({
1020
+ d: p.date, v: p.value,
1021
+ ...(p.recomputed_at && { r: p.recomputed_at }),
1022
+ ...(p.backfilled_days && { backfilled_days: p.backfilled_days }),
1023
+ })),
826
1024
  pointsInRange: points.length,
827
1025
  coverage: coverageOf(m),
1026
+ ...(rc && { recompute: rc }),
828
1027
  };
829
1028
  used += rolled.length;
830
1029
  }
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "health-export-ai",
4
4
  "display_name": "MetricBridge",
5
- "version": "1.4.4",
5
+ "version": "1.5.0",
6
6
  "description": "Query your Apple Health data, exported by the MetricBridge iOS app to iCloud Drive, from any MCP agent. Local-first, read-only, zero dependencies.",
7
7
  "long_description": "A zero-dependency MCP server that exposes 190 Apple Health metrics (exported by the MetricBridge iOS app into an iCloud Drive folder) to any MCP-compatible agent: Claude Desktop, opencode, Cursor, OpenClaw, Hermes. No bridge, no Docker, no HTTP receiver: the server reads the synced cache file directly over stdio. 14 tools: get_mcp_status, list_metrics, get_health_metrics, get_trends, compare_periods, get_structured_export, get_intraday, query_health_data, list_events, get_profile, get_workouts, get_sleep_sessions, get_cycle_context, correlate_metrics. Plus 22 MCP prompts and a --demo mode with a synthetic dataset. Get the companion iOS app on the App Store: https://apps.apple.com/app/id6784185201",
8
8
  "author": {
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "env": {
34
34
  "HEALTH_DATA_DIR": "${user_config.health_data_dir}",
35
- "PAIRING_SECRET": "${user_config.pairing_secret}"
35
+ "PAIRING_SECRET": "${user_config.pairing_secret}",
36
+ "HEALTH_EXPORT_PASSPHRASE": "${user_config.export_passphrase}"
36
37
  }
37
38
  }
38
39
  },
@@ -109,6 +110,13 @@
109
110
  "description": "If you turned on Agent pairing in the iOS app (Settings → Agent pairing), paste the code here. Leave blank if pairing is off.",
110
111
  "required": false,
111
112
  "sensitive": true
113
+ },
114
+ "export_passphrase": {
115
+ "type": "string",
116
+ "title": "Export passphrase (optional)",
117
+ "description": "Only if you turned on Export encryption in the iOS app (Settings > Export encryption): the same passphrase. It decrypts your exports on this computer and is never sent anywhere. Leave blank if encryption is off.",
118
+ "required": false,
119
+ "sensitive": true
112
120
  }
113
121
  }
114
122
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "health-export-mcp",
3
3
  "mcpName": "io.github.PhilipAD/health-export-mcp",
4
- "version": "1.4.4",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "description": "Open-source, zero-dependency Model Context Protocol (MCP) server to query Apple Health / HealthKit data (190 metrics) from Claude, ChatGPT, Cursor, OpenClaw, Hermes and any MCP agent.",
7
7
  "keywords": [
@@ -36,6 +36,7 @@
36
36
  "healthstore.mjs",
37
37
  "receiver.mjs",
38
38
  "events.mjs",
39
+ "envelope.mjs",
39
40
  "prompts.mjs",
40
41
  "demo.mjs",
41
42
  "apply-mcp-config.mjs",
package/receiver.mjs CHANGED
@@ -24,6 +24,7 @@ import path from 'node:path';
24
24
  import os from 'node:os';
25
25
  import net from 'node:net';
26
26
  import { fileURLToPath } from 'node:url';
27
+ import { isEnvelope, openEnvelopeJSON, sealEnvelope, parseHeader, configuredPassphrase, EnvelopeError } from './envelope.mjs';
27
28
 
28
29
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
29
30
  const MAX_BODY = 64 * 1024 * 1024;
@@ -34,16 +35,26 @@ const log = (...a) => process.stderr.write('[receiver] ' + a.map(String).join('
34
35
  function expandTilde(p) {
35
36
  return (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
36
37
  }
37
- function readJSON(file, fb) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fb; } }
38
38
  // The merge base for an accumulated cache. ABSENT means start fresh; PRESENT-BUT-CORRUPT
39
39
  // must never become {} and overwrite the whole history — quarantine it, then start fresh.
40
- function readMergeBase(file, fb) {
41
- if (!fs.existsSync(file)) return fb;
42
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
43
- catch { try { const bak = `${file}.corrupt-${process.pid}`; fs.renameSync(file, bak); log(`quarantined unreadable ${file} -> ${bak}`); } catch {} return fb; }
40
+ //
41
+ // Returns { value, sealedLike } where sealedLike is the envelope header when the existing cache was
42
+ // encrypted (the receiver's directory can be the same iCloud folder the app seals into): the merged
43
+ // result is then written back sealed under the SAME salt, so the app (which holds only the key for
44
+ // that salt) can still read it and nothing is left behind in plaintext. An envelope this receiver
45
+ // cannot open THROWS: quarantining it would restart a perfectly good history from one push.
46
+ function readMergeBase(file, fb, passphrase) {
47
+ if (!fs.existsSync(file)) return { value: fb, sealedLike: null };
48
+ let parsed;
49
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
50
+ catch { try { const bak = `${file}.corrupt-${process.pid}`; fs.renameSync(file, bak); log(`quarantined unreadable ${file} -> ${bak}`); } catch {} return { value: fb, sealedLike: null }; }
51
+ if (!isEnvelope(parsed)) return { value: parsed, sealedLike: null };
52
+ return { value: openEnvelopeJSON(parsed, passphrase), sealedLike: parseHeader(parsed) };
44
53
  }
45
54
  const isLoopback = (h) => h === '127.0.0.1' || h === '::1' || h === 'localhost';
46
55
 
56
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
57
+
47
58
  // Validate/sanitise a pushed summary so a malicious LAN client can't poison the agent's context.
48
59
  function sanitize(incoming) {
49
60
  const out = {};
@@ -58,7 +69,11 @@ function sanitize(incoming) {
58
69
  if (!p || typeof p.d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(p.d)) continue;
59
70
  const v = Number(p.v);
60
71
  if (!Number.isFinite(v)) continue;
61
- daily.push({ d: p.d, v });
72
+ // `r`: the app rebuilt this past day after late-arriving samples (cache schema minor 1).
73
+ // Kept only when it is a plain ISO 8601 instant, so a pushed payload cannot smuggle anything
74
+ // else through to what the MCP server reads.
75
+ const r = typeof p.r === 'string' && ISO_INSTANT.test(p.r) ? p.r : null;
76
+ daily.push(r ? { d: p.d, v, r } : { d: p.d, v });
62
77
  }
63
78
  if (!daily.length) continue;
64
79
  out[name] = { unit: typeof m.unit === 'string' ? m.unit.slice(0, 32) : '', cumulative: !!m.cumulative, daily };
@@ -87,7 +102,7 @@ function sanitizeAuthoritative(incoming) {
87
102
  // `HealthCache.merge(_:_:authoritativeDays:authoritativeMetrics:)`; without it a LAN-only cache
88
103
  // kept deletions and TZ ghost days forever. Metrics not listed (e.g. sensitive types excluded from
89
104
  // the run) are never pruned. `yyyy-MM-dd` is fixed-width, so string order IS chronological.
90
- function mergeCache(existing, incoming) {
105
+ export function mergeCache(existing, incoming) {
91
106
  const merged = { ...(existing || {}) };
92
107
  const cleaned = sanitize(incoming);
93
108
  const auth = sanitizeAuthoritative(incoming);
@@ -106,24 +121,36 @@ function mergeCache(existing, incoming) {
106
121
  }
107
122
 
108
123
  for (const [name, m] of Object.entries(cleaned)) {
109
- const byDay = new Map((merged[name]?.daily || []).map((p) => [p.d, p.v]));
124
+ // A day keeps its recompute stamp (`r`) across later pushes that re-send the same value without
125
+ // one, matching HealthCache.merge on the app side; a newer stamp replaces it.
126
+ const byDay = new Map((merged[name]?.daily || []).map((p) => [p.d, p]));
110
127
  if (auth && auth.metrics.has(name)) {
111
128
  const incomingDays = new Set(m.daily.map((p) => p.d));
112
129
  for (const d of [...byDay.keys()]) if (inRange(d) && !incomingDays.has(d)) byDay.delete(d);
113
130
  }
114
- for (const p of m.daily) byDay.set(p.d, p.v);
131
+ for (const p of m.daily) {
132
+ const prev = byDay.get(p.d);
133
+ const keep = !p.r && typeof prev?.r === 'string' && prev.v === p.v;
134
+ byDay.set(p.d, keep ? { ...p, r: prev.r } : p);
135
+ }
115
136
  merged[name] = { unit: m.unit || merged[name]?.unit || '', cumulative: !!m.cumulative,
116
- daily: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([d, v]) => ({ d, v })) };
137
+ daily: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))
138
+ .map(([d, p]) => (typeof p.r === 'string' ? { d, v: p.v, r: p.r } : { d, v: p.v })) };
117
139
  }
118
140
  return merged;
119
141
  }
120
142
 
121
- function writeCache(dir, json) {
143
+ function writeCache(dir, json, passphrase = configuredPassphrase()) {
122
144
  const file = path.join(dir, '.health-cache.json');
123
145
  const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; // unique → no concurrent-write race
124
146
  fs.mkdirSync(dir, { recursive: true });
125
- const merged = mergeCache(readMergeBase(file, {}), json);
126
- fs.writeFileSync(tmp, JSON.stringify(merged, null, 2));
147
+ const base = readMergeBase(file, {}, passphrase);
148
+ const merged = mergeCache(base.value, json);
149
+ const text = JSON.stringify(merged, null, 2);
150
+ const out = base.sealedLike
151
+ ? JSON.stringify(sealEnvelope(text, passphrase, { salt: base.sealedLike.salt, iter: base.sealedLike.iter }))
152
+ : text;
153
+ fs.writeFileSync(tmp, out, { mode: 0o600 });
127
154
  fs.renameSync(tmp, file);
128
155
  log(`merged ${Object.keys(sanitize(json)).length} -> ${Object.keys(merged).length} metrics in ${file}`);
129
156
  return Object.keys(merged).length;
@@ -135,7 +162,12 @@ export function startReceiver({
135
162
  host = process.env.HEALTH_LISTEN_HOST || '127.0.0.1',
136
163
  port = Number(process.env.HEALTH_LISTEN_PORT || 27184),
137
164
  token = process.env.HEALTH_LISTEN_TOKEN || '',
138
- onCache = (json) => writeCache(dir, json),
165
+ // The default merge uses the same passphrase source as the push decrypt below (the merge base
166
+ // can itself be sealed when this directory is the folder the app encrypts into).
167
+ onCache = (json) => writeCache(dir, json, passphrase()),
168
+ // A function, read per request: the environment is the source in production, tests inject one.
169
+ passphrase = () => configuredPassphrase(),
170
+ requireEncrypted = () => process.env.HEALTH_REQUIRE_ENCRYPTED === '1',
139
171
  } = {}) {
140
172
  // Fail closed: never expose an unauthenticated write endpoint on the network.
141
173
  if (!isLoopback(host) && !token) {
@@ -185,6 +217,22 @@ export function startReceiver({
185
217
  try { parsed = JSON.parse(body); }
186
218
  catch (e) { return json(res, 400, { ok: false, hae: true, error: 'invalid JSON' }); } // 400 = parse only
187
219
  if (parsed && parsed._test === true) return json(res, 200, { ok: true, hae: true, test: true }); // probe: never touch the cache
220
+ // HEALTH_REQUIRE_ENCRYPTED=1: refuse plaintext pushes. The pairing token travels in a plain
221
+ // http header on this leg, so anyone who sniffed one push could otherwise forge plaintext
222
+ // data with it; requiring envelopes means a forger also needs the passphrase.
223
+ if (requireEncrypted() && !isEnvelope(parsed)) {
224
+ return json(res, 422, { ok: false, hae: true, reason: 'encryption_required', error: 'this receiver only accepts encrypted pushes' });
225
+ }
226
+ // Encrypted push (the app's "Local network" encryption switch). 422 + a machine `reason`
227
+ // the app turns into a sentence that says where to fix it. Never echoes the passphrase.
228
+ if (isEnvelope(parsed)) {
229
+ try { parsed = openEnvelopeJSON(parsed, passphrase()); }
230
+ catch (e) {
231
+ const reason = e instanceof EnvelopeError ? e.code : 'malformed';
232
+ log('encrypted push refused:', reason);
233
+ return json(res, 422, { ok: false, hae: true, reason, error: e instanceof EnvelopeError ? e.message : 'could not decrypt' });
234
+ }
235
+ }
188
236
  try { const n = onCache(parsed); json(res, 200, { ok: true, hae: true, metrics: n }); }
189
237
  catch (e) { log('write failed', e.message); json(res, 500, { ok: false, hae: true, error: 'write failed: ' + (e.code || e.message) }); } // 500 = disk/write
190
238
  });
@@ -218,7 +266,13 @@ export function startReceiver({
218
266
  if (opcode === 0xa) { buf = Buffer.alloc(0); return; } // pong → ignore
219
267
  const msg = decodeTextFrame(buf);
220
268
  if (msg === null) return; // incomplete or unsupported → wait
221
- try { const n = onCache(JSON.parse(msg)); socket.write(encodeTextFrame(JSON.stringify({ ok: true, metrics: n }))); }
269
+ try {
270
+ let parsedMsg = JSON.parse(msg);
271
+ if (requireEncrypted() && !isEnvelope(parsedMsg)) throw new Error('plaintext push refused (HEALTH_REQUIRE_ENCRYPTED)');
272
+ if (isEnvelope(parsedMsg)) parsedMsg = openEnvelopeJSON(parsedMsg, passphrase());
273
+ const n = onCache(parsedMsg);
274
+ socket.write(encodeTextFrame(JSON.stringify({ ok: true, metrics: n })));
275
+ }
222
276
  catch (e) { log('bad WS json', e.message); socket.write(encodeTextFrame(JSON.stringify({ ok: false }))); }
223
277
  socket.end();
224
278
  });
package/server.json CHANGED
@@ -6,13 +6,13 @@
6
6
  "url": "https://github.com/PhilipAD/health-export-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.4.4",
9
+ "version": "1.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "registryBaseUrl": "https://registry.npmjs.org",
14
14
  "identifier": "health-export-mcp",
15
- "version": "1.4.4",
15
+ "version": "1.5.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
package/server.mjs CHANGED
@@ -21,7 +21,7 @@ import * as ev from './events.mjs';
21
21
  import { PROMPTS } from './prompts.mjs';
22
22
  import { DEMO } from './demo.mjs';
23
23
 
24
- const SERVER = { name: 'health-export-ai', version: '1.4.4' };
24
+ const SERVER = { name: 'health-export-ai', version: '1.5.0' };
25
25
  const DEFAULT_PROTOCOL = '2025-06-18';
26
26
  const log = (...a) => process.stderr.write('[mcp] ' + a.join(' ') + '\n');
27
27
 
@@ -57,7 +57,7 @@ const ANNOTATIONS = { readOnlyHint: true, idempotentHint: true, openWorldHint: f
57
57
  const TOOLS = [
58
58
  {
59
59
  name: 'get_mcp_status',
60
- description: 'Health check: data source, how many metrics/workouts are available, which optional context files exist, and the most recent data date. Call this first to confirm the bridge is connected.',
60
+ description: 'Health check: data source, how many metrics/workouts are available, which optional context files exist, the most recent data date, and a `recompute` summary of past days rebuilt after late-arriving samples (with any days too old to rebuild). Call this first to confirm the bridge is connected.',
61
61
  inputSchema: { type: 'object', properties: {} },
62
62
  handler: () => store.status(),
63
63
  },
@@ -71,7 +71,7 @@ const TOOLS = [
71
71
  },
72
72
  {
73
73
  name: 'get_health_metrics',
74
- description: "Get values for a metric (or all metrics) over an optional date range, with an aggregate (avg/sum/min/max/latest). The core data-retrieval tool. Every result carries a `coverage` block giving the metric's real firstDate/lastDate/days: check it before trusting a long window, and note that `aggregate` is always computed over the full range even when `points` are rolled up. Single-metric answers also list any logged point events inside the window as segmentBoundaries.",
74
+ description: "Get values for a metric (or all metrics) over an optional date range, with an aggregate (avg/sum/min/max/latest). The core data-retrieval tool. Every result carries a `coverage` block giving the metric's real firstDate/lastDate/days: check it before trusting a long window, and note that `aggregate` is always computed over the full range even when `points` are rolled up. Single-metric answers also list any logged point events inside the window as segmentBoundaries. A day the app rebuilt after samples arrived late (or were deleted) carries `recomputed_at`, and a metric with rebuilt or possibly stale past days carries a `recompute` block (backfilled_days, recomputed_at, capped); untouched days carry neither.",
75
75
  inputSchema: {
76
76
  type: 'object',
77
77
  properties: {
@@ -412,9 +412,17 @@ async function doctor() {
412
412
  if (!newest || st.mtimeMs > newest.mtimeMs) newest = { name, mtimeMs: st.mtimeMs };
413
413
  console.log(` ${name.padEnd(30)} ${fmtBytes(st.size).padStart(9)} ${new Date(st.mtimeMs).toISOString()}`);
414
414
  }
415
- const raw = statFile('.health-cache.json') ? store.readJSONCached(store.dataPath('.health-cache.json'), null) : null;
415
+ const enc = store.encryptionStatus();
416
+ console.log(` encryption: ${!enc.encrypted ? 'off (plaintext export)' : enc.passphraseConfigured ? 'on, passphrase configured' : 'on, NO PASSPHRASE: set HEALTH_EXPORT_PASSPHRASE to the passphrase from the iOS app'}`);
417
+ let raw = null;
418
+ try {
419
+ raw = statFile('.health-cache.json') ? store.readJSONCached(store.dataPath('.health-cache.json'), null) : null;
420
+ } catch (e) {
421
+ console.log(` cache: cannot be read: ${e.message}`);
422
+ }
416
423
  const meta = raw?._meta;
417
- console.log(` cache schema: ${meta?.schema ?? (raw ? '1 (no _meta: pre-1.2 app)' : 'no cache file')}${meta?.app ? ` (app ${meta.app})` : ''}`);
424
+ console.log(` cache schema: ${meta?.schema ?? (raw ? '1 (no _meta: pre-1.2 app)' : 'no cache file')}${meta?.schemaMinor != null ? `.${meta.schemaMinor}` : ''}${meta?.app ? ` (app ${meta.app})` : ''}`);
425
+ if (meta?.recompute?.capped && Object.keys(meta.recompute.capped).length) console.log(` recompute: days older than the ${meta.recompute.capDays ?? '?'}-day rebuild cap may be stale for: ${Object.keys(meta.recompute.capped).sort().join(', ')}`);
418
426
  try {
419
427
  const metrics = p.ok ? await store.listMetrics() : null;
420
428
  if (metrics) {