health-sync 0.3.0 → 0.3.2

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
@@ -55,6 +55,12 @@ The wizard lets you:
55
55
  - enter credentials directly into `health-sync.toml`
56
56
  - run auth flows and save tokens in `.health-sync.creds`
57
57
 
58
+ Remote onboarding (for bot/operator handoff) is also available:
59
+
60
+ ```bash
61
+ health-sync init remote bootstrap
62
+ ```
63
+
58
64
  2. (Optional) re-run auth for a single provider later:
59
65
 
60
66
  ```bash
@@ -73,6 +79,57 @@ health-sync sync
73
79
  health-sync status
74
80
  ```
75
81
 
82
+ ## Remote Bootstrap Setup
83
+
84
+ This mode is designed for cross-device onboarding where the user runs setup locally and sends an encrypted bundle back to an operator/bot over untrusted transport (for example, Telegram).
85
+
86
+ ### 1) Operator creates bootstrap token
87
+
88
+ ```bash
89
+ health-sync init remote bootstrap --expires-in 24h
90
+ ```
91
+
92
+ This command:
93
+ - generates one bootstrap key/session
94
+ - prints a bootstrap token to share with the user
95
+ - stores private key material locally under `~/.health-sync/remote-bootstrap`
96
+
97
+ ### 2) User runs onboarding with the shared token
98
+
99
+ ```bash
100
+ health-sync init remote run <bootstrap-token>
101
+ ```
102
+
103
+ This command:
104
+ - runs normal guided `init` onboarding
105
+ - encrypts `health-sync.toml` + `.health-sync.creds` into an archive
106
+ - prints the archive path to send back to the operator
107
+ - purges local config/creds by default after archive creation
108
+
109
+ Options:
110
+ - `--output <path>`: choose archive output path
111
+ - `--keep-local`: keep local config/creds instead of purging
112
+
113
+ ### 3) Operator imports encrypted archive
114
+
115
+ ```bash
116
+ health-sync init remote finish <bootstrap-token-or-key-id> <archive-path>
117
+ ```
118
+
119
+ This command:
120
+ - decrypts archive using stored bootstrap private key
121
+ - safely imports config/creds with timestamped backups
122
+ - marks bootstrap session as consumed (one-time use)
123
+
124
+ Options:
125
+ - `--target-config <path>`
126
+ - `--target-creds <path>`
127
+
128
+ Compatibility aliases:
129
+ - `health-sync init --remote-bootstrap`
130
+ - `health-sync init --remote <token>`
131
+ - `health-sync init --remote-bootstrap-finish <ref> <archive>`
132
+
76
133
  ## Basic Configuration
77
134
 
78
135
  By default, `health-sync` reads `./health-sync.toml`.
@@ -105,6 +162,9 @@ See `health-sync.example.toml` for all provider options.
105
162
  ## CLI Commands
106
163
 
107
164
  - `health-sync init`: create a scaffolded config (from `health-sync.example.toml`), create DB tables, and launch interactive provider setup when running in a TTY
165
+ - `health-sync init remote bootstrap`: generate a remote bootstrap token/session
166
+ - `health-sync init remote run <token>`: run onboarding and emit encrypted remote archive
167
+ - `health-sync init remote finish <ref> <archive>`: decrypt and import remote archive
108
168
  - `health-sync init-db`: create DB tables only (legacy)
109
169
  - `health-sync auth <provider>`: run auth flow for one provider/plugin
110
170
  - `health-sync sync`: run sync for all enabled providers
@@ -133,6 +193,7 @@ The database keeps raw JSON payloads and sync metadata in generic tables:
133
193
  - `sync_state`: per-resource watermarks/cursors
134
194
  - `.health-sync.creds`: stored provider credentials and OAuth tokens
135
195
  - `sync_runs`: run history and per-sync counters
196
+ - `~/.health-sync/remote-bootstrap`: private bootstrap sessions/keys for remote onboarding
136
197
 
137
198
  This schema is intentionally generic so upstream API changes are less likely to require migrations.
138
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "health-sync",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Node.js port of health-sync",
6
6
  "files": [
package/src/cli.js CHANGED
@@ -11,6 +11,18 @@ import { openDb } from './db.js';
11
11
  import { PluginHelpers, providerEnabled } from './plugins/base.js';
12
12
  import { loadProviders } from './plugins/loader.js';
13
13
  import { setRequestJsonVerbose } from './util.js';
14
+ import {
15
+ BOOTSTRAP_TOKEN_PREFIX,
16
+ bootstrapStoreDir,
17
+ buildRemotePayloadFromFiles,
18
+ createBootstrapSession,
19
+ defaultRemoteArchivePath,
20
+ encryptRemotePayload,
21
+ importRemoteArchive,
22
+ parseBootstrapToken,
23
+ parseDurationToSeconds,
24
+ writeRemoteArchiveFile,
25
+ } from './remote-bootstrap.js';
14
26
  import {
15
27
  authProviderDisplayName,
16
28
  hasInteractiveAuthUi,
@@ -194,6 +206,179 @@ function parseProvidersArgs(args) {
194
206
  return out;
195
207
  }
196
208
 
209
+ function parseInitRemoteBootstrapArgs(args) {
210
+ const out = {
211
+ mode: 'remote-bootstrap',
212
+ expiresInSeconds: parseDurationToSeconds(null),
213
+ };
214
+
215
+ for (let i = 0; i < args.length; i += 1) {
216
+ const arg = args[i];
217
+ if (arg === '--expires-in') {
218
+ i += 1;
219
+ if (i >= args.length) {
220
+ throw new Error('--expires-in requires a value');
221
+ }
222
+ out.expiresInSeconds = parseDurationToSeconds(args[i]);
223
+ continue;
224
+ }
225
+ if (arg.startsWith('--expires-in=')) {
226
+ out.expiresInSeconds = parseDurationToSeconds(arg.slice('--expires-in='.length));
227
+ continue;
228
+ }
229
+ throw new Error(`Unknown init remote bootstrap option: ${arg}`);
230
+ }
231
+
232
+ return out;
233
+ }
234
+
235
+ function parseInitRemoteRunArgs(args) {
236
+ const out = {
237
+ mode: 'remote-run',
238
+ bootstrapToken: null,
239
+ outputPath: null,
240
+ purgeLocal: true,
241
+ };
242
+
243
+ for (let i = 0; i < args.length; i += 1) {
244
+ const arg = args[i];
245
+ if (arg === '--output') {
246
+ i += 1;
247
+ if (i >= args.length) {
248
+ throw new Error('--output requires a value');
249
+ }
250
+ out.outputPath = args[i];
251
+ continue;
252
+ }
253
+ if (arg.startsWith('--output=')) {
254
+ out.outputPath = arg.slice('--output='.length);
255
+ continue;
256
+ }
257
+ if (arg === '--keep-local') {
258
+ out.purgeLocal = false;
259
+ continue;
260
+ }
261
+ if (arg === '--purge-local') {
262
+ out.purgeLocal = true;
263
+ continue;
264
+ }
265
+ if (arg.startsWith('--')) {
266
+ throw new Error(`Unknown init remote run option: ${arg}`);
267
+ }
268
+ if (!out.bootstrapToken) {
269
+ out.bootstrapToken = arg;
270
+ continue;
271
+ }
272
+ throw new Error(`Unexpected init remote run argument: ${arg}`);
273
+ }
274
+
275
+ if (!out.bootstrapToken) {
276
+ throw new Error(`init remote run requires BOOTSTRAP_TOKEN (prefix: ${BOOTSTRAP_TOKEN_PREFIX})`);
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function parseInitRemoteFinishArgs(args) {
282
+ const out = {
283
+ mode: 'remote-finish',
284
+ sessionRef: null,
285
+ archivePath: null,
286
+ targetConfigPath: null,
287
+ targetCredsPath: null,
288
+ assumeYes: false,
289
+ };
290
+
291
+ for (let i = 0; i < args.length; i += 1) {
292
+ const arg = args[i];
293
+ if (arg === '--target-config') {
294
+ i += 1;
295
+ if (i >= args.length) {
296
+ throw new Error('--target-config requires a value');
297
+ }
298
+ out.targetConfigPath = args[i];
299
+ continue;
300
+ }
301
+ if (arg.startsWith('--target-config=')) {
302
+ out.targetConfigPath = arg.slice('--target-config='.length);
303
+ continue;
304
+ }
305
+ if (arg === '--target-creds') {
306
+ i += 1;
307
+ if (i >= args.length) {
308
+ throw new Error('--target-creds requires a value');
309
+ }
310
+ out.targetCredsPath = args[i];
311
+ continue;
312
+ }
313
+ if (arg.startsWith('--target-creds=')) {
314
+ out.targetCredsPath = arg.slice('--target-creds='.length);
315
+ continue;
316
+ }
317
+ if (arg === '--yes' || arg === '-y') {
318
+ out.assumeYes = true;
319
+ continue;
320
+ }
321
+ if (arg.startsWith('--')) {
322
+ throw new Error(`Unknown init remote finish option: ${arg}`);
323
+ }
324
+ if (!out.sessionRef) {
325
+ out.sessionRef = arg;
326
+ continue;
327
+ }
328
+ if (!out.archivePath) {
329
+ out.archivePath = arg;
330
+ continue;
331
+ }
332
+ throw new Error(`Unexpected init remote finish argument: ${arg}`);
333
+ }
334
+
335
+ if (!out.sessionRef || !out.archivePath) {
336
+ throw new Error('init remote finish requires SESSION_REF and ARCHIVE_PATH');
337
+ }
338
+
339
+ return out;
340
+ }
341
+
342
+ function parseInitArgs(args) {
343
+ if (!args.length) {
344
+ return { mode: 'local' };
345
+ }
346
+
347
+ if (args[0] === 'remote') {
348
+ if (args.length < 2) {
349
+ throw new Error('init remote requires a subcommand: bootstrap, run, or finish');
350
+ }
351
+ const subcommand = args[1];
352
+ const rest = args.slice(2);
353
+ if (subcommand === 'bootstrap') {
354
+ return parseInitRemoteBootstrapArgs(rest);
355
+ }
356
+ if (subcommand === 'run') {
357
+ return parseInitRemoteRunArgs(rest);
358
+ }
359
+ if (subcommand === 'finish') {
360
+ return parseInitRemoteFinishArgs(rest);
361
+ }
362
+ throw new Error(`Unknown init remote subcommand: ${subcommand}`);
363
+ }
364
+
365
+ if (args[0] === '--remote-bootstrap') {
366
+ return parseInitRemoteBootstrapArgs(args.slice(1));
367
+ }
368
+ if (args[0] === '--remote') {
369
+ return parseInitRemoteRunArgs(args.slice(1));
370
+ }
371
+ if (args[0].startsWith('--remote=')) {
372
+ const token = args[0].slice('--remote='.length);
373
+ return parseInitRemoteRunArgs([token, ...args.slice(1)]);
374
+ }
375
+ if (args[0] === '--remote-bootstrap-finish') {
376
+ return parseInitRemoteFinishArgs(args.slice(1));
377
+ }
378
+
379
+ throw new Error(`Unexpected init arguments: ${args.join(' ')}`);
380
+ }
381
+
197
382
  function parseArgs(argv) {
198
383
  const global = parseGlobalOptions(argv);
199
384
  const [command, ...rest] = global.remaining;
@@ -238,7 +423,11 @@ function parseArgs(argv) {
238
423
  parsed.options = parseProvidersArgs(rest);
239
424
  return parsed;
240
425
  }
241
- if (command === 'init' || command === 'init-db' || command === 'status') {
426
+ if (command === 'init') {
427
+ parsed.options = parseInitArgs(rest);
428
+ return parsed;
429
+ }
430
+ if (command === 'init-db' || command === 'status') {
242
431
  if (rest.length) {
243
432
  throw new Error(`Unexpected ${command} arguments: ${rest.join(' ')}`);
244
433
  }
@@ -573,6 +762,19 @@ function usage() {
573
762
  '',
574
763
  'Commands:',
575
764
  ' init Initialize config/database and launch interactive setup (TTY)',
765
+ ' init remote bootstrap Create one remote bootstrap token',
766
+ ' --expires-in <duration> Token expiry (default 24h; e.g. 12h, 2d, 3600)',
767
+ ' init remote run <token> Run onboarding and emit encrypted remote archive',
768
+ ' --output <path> Output archive path (.enc JSON envelope)',
769
+ ' --keep-local Keep local config/creds after archive creation',
770
+ ' init remote finish <ref> <archive> Decrypt archive and import files safely',
771
+ ' --target-config <path> Import destination for health-sync.toml',
772
+ ' --target-creds <path> Import destination for .health-sync.creds',
773
+ '',
774
+ ' Alias forms (compatible):',
775
+ ' init --remote-bootstrap',
776
+ ' init --remote <token>',
777
+ ' init --remote-bootstrap-finish <ref> <archive>',
576
778
  ' init-db Initialize database only',
577
779
  ' auth <provider> Run provider authentication flow for one provider',
578
780
  ' --listen-host <host> OAuth callback listen host (default 127.0.0.1)',
@@ -597,7 +799,150 @@ async function loadContext(configPath) {
597
799
  };
598
800
  }
599
801
 
802
+ function removeFileIfExists(filePath) {
803
+ if (!fs.existsSync(filePath)) {
804
+ return false;
805
+ }
806
+ fs.unlinkSync(filePath);
807
+ return true;
808
+ }
809
+
810
+ function purgeRemoteLocalSecrets(configPath, credsPath) {
811
+ const removed = [];
812
+ if (removeFileIfExists(configPath)) {
813
+ removed.push(configPath);
814
+ }
815
+ if (removeFileIfExists(credsPath)) {
816
+ removed.push(credsPath);
817
+ }
818
+ return removed;
819
+ }
820
+
821
+ async function cmdInitRemoteBootstrap(parsed) {
822
+ const session = createBootstrapSession({
823
+ expiresInSeconds: parsed.options.expiresInSeconds,
824
+ });
825
+
826
+ console.log('Created remote bootstrap session.');
827
+ console.log(`Session fingerprint: ${session.fingerprint}`);
828
+ console.log(`Session expires at: ${session.expiresAt}`);
829
+ console.log(`Bootstrap store: ${bootstrapStoreDir()}`);
830
+ console.log('');
831
+ console.log('Share this command with the user:');
832
+ console.log(` health-sync init --remote ${session.token}`);
833
+ console.log('');
834
+ console.log('Bootstrap token:');
835
+ console.log(session.token);
836
+
837
+ return 0;
838
+ }
839
+
840
+ async function cmdInitRemoteRun(parsed) {
841
+ const tokenDetails = parseBootstrapToken(parsed.options.bootstrapToken, {
842
+ requireNotExpired: true,
843
+ });
844
+
845
+ const configPath = path.resolve(parsed.configPath);
846
+ const credsPath = resolveCredsPath(configPath);
847
+ const outputPath = parsed.options.outputPath
848
+ ? path.resolve(parsed.options.outputPath)
849
+ : defaultRemoteArchivePath(configPath, tokenDetails.sessionId);
850
+
851
+ console.log('Remote onboarding mode enabled.');
852
+ console.log(`Bootstrap session: ${tokenDetails.keyId.slice(0, 12)}:${tokenDetails.sessionId.slice(0, 8)}`);
853
+ console.log(`Bootstrap expires at: ${tokenDetails.expiresAt}`);
854
+ console.log('');
855
+
856
+ const initCode = await cmdInitLocal(parsed);
857
+ if (initCode === 130) {
858
+ return 130;
859
+ }
860
+ if (initCode !== 0) {
861
+ console.warn('Init completed with warnings; packaging current config/creds anyway.');
862
+ }
863
+
864
+ const { payload } = buildRemotePayloadFromFiles({
865
+ configPath,
866
+ credsPath,
867
+ allowMissingCreds: true,
868
+ sourceVersion: cliVersion(),
869
+ });
870
+ const envelope = encryptRemotePayload(payload, parsed.options.bootstrapToken, {
871
+ requireNotExpired: true,
872
+ });
873
+ const archivePath = writeRemoteArchiveFile(envelope, outputPath);
874
+
875
+ console.log('');
876
+ console.log('Encrypted remote archive created successfully.');
877
+ console.log(`Archive path: ${archivePath}`);
878
+ console.log('Send this archive file to the bot/operator.');
879
+
880
+ if (parsed.options.purgeLocal) {
881
+ const removed = purgeRemoteLocalSecrets(configPath, credsPath);
882
+ if (removed.length) {
883
+ console.log('');
884
+ console.log('Purged local sensitive files after archive creation:');
885
+ for (const item of removed) {
886
+ console.log(` - ${item}`);
887
+ }
888
+ } else {
889
+ console.log('');
890
+ console.log('No local config/creds files found to purge.');
891
+ }
892
+ } else {
893
+ console.log('');
894
+ console.log('Kept local config/creds because --keep-local was set.');
895
+ }
896
+
897
+ return initCode;
898
+ }
899
+
900
+ async function cmdInitRemoteFinish(parsed) {
901
+ const configPath = path.resolve(parsed.configPath);
902
+ const targetConfigPath = parsed.options.targetConfigPath
903
+ ? path.resolve(parsed.options.targetConfigPath)
904
+ : configPath;
905
+ const targetCredsPath = parsed.options.targetCredsPath
906
+ ? path.resolve(parsed.options.targetCredsPath)
907
+ : resolveCredsPath(targetConfigPath);
908
+
909
+ const result = importRemoteArchive({
910
+ sessionRef: parsed.options.sessionRef,
911
+ archivePath: parsed.options.archivePath,
912
+ targetConfigPath,
913
+ targetCredsPath,
914
+ });
915
+
916
+ console.log('Remote bootstrap import complete.');
917
+ console.log(`Imported config: ${result.targetConfigPath}`);
918
+ console.log(`Imported creds: ${result.targetCredsPath}`);
919
+ if (result.backups.length) {
920
+ console.log('Backups created:');
921
+ for (const backup of result.backups) {
922
+ console.log(` - ${backup}`);
923
+ }
924
+ }
925
+ console.log(`Session consumed at: ${result.consumedAt}`);
926
+ console.log(`Imported token entries: ${result.tokenCount}`);
927
+
928
+ return 0;
929
+ }
930
+
600
931
  async function cmdInit(parsed) {
932
+ const mode = parsed.options?.mode || 'local';
933
+ if (mode === 'remote-bootstrap') {
934
+ return cmdInitRemoteBootstrap(parsed);
935
+ }
936
+ if (mode === 'remote-run') {
937
+ return cmdInitRemoteRun(parsed);
938
+ }
939
+ if (mode === 'remote-finish') {
940
+ return cmdInitRemoteFinish(parsed);
941
+ }
942
+ return cmdInitLocal(parsed);
943
+ }
944
+
945
+ async function cmdInitLocal(parsed) {
601
946
  const configPath = path.resolve(parsed.configPath);
602
947
  const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
603
948
 
@@ -0,0 +1,711 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { gunzipSync, gzipSync } from 'node:zlib';
6
+ import { dtToIsoZ, sha256Hex, stableJsonStringify, utcNowIso } from './util.js';
7
+
8
+ export const BOOTSTRAP_TOKEN_PREFIX = 'hsr1.';
9
+ export const REMOTE_ARCHIVE_SCHEMA = 'health-sync-remote-archive-v1';
10
+ export const REMOTE_PAYLOAD_SCHEMA = 'health-sync-remote-payload-v1';
11
+
12
+ const DEFAULT_BOOTSTRAP_EXPIRES_SECONDS = 24 * 60 * 60;
13
+ const SESSION_FILE_MODE = 0o600;
14
+
15
+ function hasText(value) {
16
+ return value !== null && value !== undefined && String(value).trim() !== '';
17
+ }
18
+
19
+ function ensureDir(dirPath) {
20
+ fs.mkdirSync(dirPath, { recursive: true });
21
+ }
22
+
23
+ function encodeBase64Url(buffer) {
24
+ return Buffer.from(buffer).toString('base64url');
25
+ }
26
+
27
+ function decodeBase64Url(value, label = 'base64url input') {
28
+ try {
29
+ return Buffer.from(String(value), 'base64url');
30
+ } catch {
31
+ throw new Error(`Invalid ${label}`);
32
+ }
33
+ }
34
+
35
+ function parseIsoDate(value, label) {
36
+ const normalized = dtToIsoZ(value);
37
+ if (!normalized) {
38
+ throw new Error(`Invalid ${label}`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function assertObject(value, label) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
45
+ throw new Error(`Invalid ${label}`);
46
+ }
47
+ return value;
48
+ }
49
+
50
+ function randomHex(bytes = 16) {
51
+ return crypto.randomBytes(bytes).toString('hex');
52
+ }
53
+
54
+ function defaultSessionStoreDir() {
55
+ if (hasText(process.env.HEALTH_SYNC_REMOTE_BOOTSTRAP_DIR)) {
56
+ return path.resolve(String(process.env.HEALTH_SYNC_REMOTE_BOOTSTRAP_DIR));
57
+ }
58
+ return path.join(os.homedir(), '.health-sync', 'remote-bootstrap');
59
+ }
60
+
61
+ function sessionPath(sessionId, storeDir = null) {
62
+ return path.join(path.resolve(storeDir || defaultSessionStoreDir()), `${sessionId}.json`);
63
+ }
64
+
65
+ function atomicWriteFile(filePath, content, mode = 0o600) {
66
+ ensureDir(path.dirname(path.resolve(filePath)));
67
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomHex(4)}`;
68
+ fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode });
69
+ fs.renameSync(tmpPath, filePath);
70
+ try {
71
+ fs.chmodSync(filePath, mode);
72
+ } catch {
73
+ // Ignore chmod failures on non-POSIX filesystems.
74
+ }
75
+ }
76
+
77
+ function parseDurationUnit(unitRaw) {
78
+ const unit = String(unitRaw || 's').trim().toLowerCase();
79
+ if (unit === '' || unit === 's' || unit === 'sec' || unit === 'secs' || unit === 'second' || unit === 'seconds') {
80
+ return 1;
81
+ }
82
+ if (unit === 'm' || unit === 'min' || unit === 'mins' || unit === 'minute' || unit === 'minutes') {
83
+ return 60;
84
+ }
85
+ if (unit === 'h' || unit === 'hr' || unit === 'hrs' || unit === 'hour' || unit === 'hours') {
86
+ return 60 * 60;
87
+ }
88
+ if (unit === 'd' || unit === 'day' || unit === 'days') {
89
+ return 24 * 60 * 60;
90
+ }
91
+ throw new Error(`Unsupported duration unit: ${unitRaw}`);
92
+ }
93
+
94
+ function normalizeSessionDoc(doc, label = 'bootstrap session') {
95
+ const value = assertObject(doc, label);
96
+ const sessionId = hasText(value.session_id) ? String(value.session_id) : null;
97
+ const keyId = hasText(value.key_id) ? String(value.key_id) : null;
98
+ const createdAt = parseIsoDate(value.created_at, `${label}.created_at`);
99
+ const expiresAt = parseIsoDate(value.expires_at, `${label}.expires_at`);
100
+ const privateKeyPem = hasText(value.private_key_pkcs8_pem) ? String(value.private_key_pkcs8_pem) : null;
101
+ const recipientPublicDer = hasText(value.recipient_pub_der_b64u) ? String(value.recipient_pub_der_b64u) : null;
102
+ const consumedAt = value.consumed_at ? parseIsoDate(value.consumed_at, `${label}.consumed_at`) : null;
103
+
104
+ if (!sessionId || !/^[a-f0-9]{16,64}$/i.test(sessionId)) {
105
+ throw new Error(`Invalid ${label}.session_id`);
106
+ }
107
+ if (!keyId || !/^[a-f0-9]{8,64}$/i.test(keyId)) {
108
+ throw new Error(`Invalid ${label}.key_id`);
109
+ }
110
+ if (!privateKeyPem) {
111
+ throw new Error(`Missing ${label}.private_key_pkcs8_pem`);
112
+ }
113
+ if (!recipientPublicDer) {
114
+ throw new Error(`Missing ${label}.recipient_pub_der_b64u`);
115
+ }
116
+
117
+ return {
118
+ version: Number(value.version) || 1,
119
+ sessionId,
120
+ keyId,
121
+ createdAt,
122
+ expiresAt,
123
+ privateKeyPem,
124
+ recipientPublicDer,
125
+ consumedAt,
126
+ };
127
+ }
128
+
129
+ function formatBackupTimestamp(value = new Date()) {
130
+ return value.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-');
131
+ }
132
+
133
+ function validatePayloadFiles(payload) {
134
+ const files = assertObject(payload?.files, 'remote payload.files');
135
+ const config = files['health-sync.toml'];
136
+ const creds = files['.health-sync.creds'];
137
+ if (!config || !hasText(config.content)) {
138
+ throw new Error('Remote payload is missing health-sync.toml');
139
+ }
140
+ if (!creds || !hasText(creds.content)) {
141
+ throw new Error('Remote payload is missing .health-sync.creds');
142
+ }
143
+ return { config, creds };
144
+ }
145
+
146
+ function emptyCredsFileContent() {
147
+ return `${stableJsonStringify({
148
+ version: 1,
149
+ updatedAt: utcNowIso(),
150
+ tokens: {},
151
+ })}\n`;
152
+ }
153
+
154
+ function deriveSymmetricKey({ sharedSecret, salt, sessionId, keyId }) {
155
+ const info = Buffer.from(`health-sync-remote-v1|${sessionId}|${keyId}`, 'utf8');
156
+ return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
157
+ }
158
+
159
+ function loadPublicKeyFromDerBase64Url(derBase64Url) {
160
+ return crypto.createPublicKey({
161
+ key: decodeBase64Url(derBase64Url, 'recipient public key'),
162
+ format: 'der',
163
+ type: 'spki',
164
+ });
165
+ }
166
+
167
+ function loadPrivateKeyFromPem(privateKeyPem) {
168
+ return crypto.createPrivateKey({
169
+ key: String(privateKeyPem),
170
+ format: 'pem',
171
+ type: 'pkcs8',
172
+ });
173
+ }
174
+
175
+ function parseArchiveEnvelope(raw) {
176
+ const envelope = assertObject(raw, 'remote archive envelope');
177
+ if (envelope.schema !== REMOTE_ARCHIVE_SCHEMA || Number(envelope.version) !== 1) {
178
+ throw new Error('Unsupported remote archive version');
179
+ }
180
+ if (!hasText(envelope.session_id) || !hasText(envelope.key_id)) {
181
+ throw new Error('Remote archive is missing session/key metadata');
182
+ }
183
+ if (!hasText(envelope.ephemeral_pub_der_b64u)) {
184
+ throw new Error('Remote archive is missing ephemeral key');
185
+ }
186
+ if (!hasText(envelope.salt_b64u) || !hasText(envelope.nonce_b64u) || !hasText(envelope.tag_b64u)) {
187
+ throw new Error('Remote archive is missing cryptographic parameters');
188
+ }
189
+ if (!hasText(envelope.ciphertext_b64u)) {
190
+ throw new Error('Remote archive is missing ciphertext');
191
+ }
192
+ if (!hasText(envelope.aad_json)) {
193
+ throw new Error('Remote archive is missing authenticated metadata');
194
+ }
195
+
196
+ try {
197
+ const aad = assertObject(JSON.parse(String(envelope.aad_json)), 'remote archive aad');
198
+ if (!hasText(aad.created_at) || !hasText(aad.payload_sha256)) {
199
+ throw new Error('Remote archive AAD is missing required fields');
200
+ }
201
+ } catch (err) {
202
+ if (err instanceof Error && err.message.includes('Remote archive AAD')) {
203
+ throw err;
204
+ }
205
+ throw new Error('Remote archive has invalid authenticated metadata');
206
+ }
207
+
208
+ return envelope;
209
+ }
210
+
211
+ export function parseDurationToSeconds(value, fallbackSeconds = DEFAULT_BOOTSTRAP_EXPIRES_SECONDS) {
212
+ if (value === null || value === undefined || value === '') {
213
+ return fallbackSeconds;
214
+ }
215
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
216
+ return Math.floor(value);
217
+ }
218
+
219
+ const text = String(value).trim();
220
+ if (!text) {
221
+ return fallbackSeconds;
222
+ }
223
+
224
+ const match = text.match(/^(\d+)\s*([a-zA-Z]+)?$/);
225
+ if (!match) {
226
+ throw new Error(`Invalid duration: ${text}`);
227
+ }
228
+ const quantity = Number.parseInt(match[1], 10);
229
+ if (!Number.isFinite(quantity) || quantity <= 0) {
230
+ throw new Error(`Invalid duration value: ${text}`);
231
+ }
232
+ const multiplier = parseDurationUnit(match[2] || 's');
233
+ return quantity * multiplier;
234
+ }
235
+
236
+ export function bootstrapStoreDir() {
237
+ return defaultSessionStoreDir();
238
+ }
239
+
240
+ export function parseBootstrapToken(token, options = {}) {
241
+ const requireNotExpired = options.requireNotExpired !== false;
242
+ const trimmed = String(token || '').trim();
243
+ if (!trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
244
+ throw new Error(`Bootstrap token must start with ${BOOTSTRAP_TOKEN_PREFIX}`);
245
+ }
246
+
247
+ const encoded = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length);
248
+ if (!encoded) {
249
+ throw new Error('Bootstrap token is empty');
250
+ }
251
+
252
+ let payload;
253
+ try {
254
+ payload = JSON.parse(decodeBase64Url(encoded, 'bootstrap token').toString('utf8'));
255
+ } catch {
256
+ throw new Error('Bootstrap token is not valid JSON');
257
+ }
258
+ assertObject(payload, 'bootstrap token payload');
259
+
260
+ const normalized = {
261
+ version: Number(payload.v) || 0,
262
+ sessionId: hasText(payload.session_id) ? String(payload.session_id) : '',
263
+ keyId: hasText(payload.key_id) ? String(payload.key_id) : '',
264
+ recipientPublicDer: hasText(payload.recipient_pub) ? String(payload.recipient_pub) : '',
265
+ createdAt: parseIsoDate(payload.created_at, 'bootstrap token created_at'),
266
+ expiresAt: parseIsoDate(payload.expires_at, 'bootstrap token expires_at'),
267
+ checksum: hasText(payload.checksum) ? String(payload.checksum) : '',
268
+ };
269
+
270
+ if (normalized.version !== 1) {
271
+ throw new Error('Unsupported bootstrap token version');
272
+ }
273
+ if (!normalized.sessionId || !normalized.keyId || !normalized.recipientPublicDer) {
274
+ throw new Error('Bootstrap token is missing required fields');
275
+ }
276
+
277
+ const checksumPayload = {
278
+ v: normalized.version,
279
+ session_id: normalized.sessionId,
280
+ key_id: normalized.keyId,
281
+ recipient_pub: normalized.recipientPublicDer,
282
+ created_at: normalized.createdAt,
283
+ expires_at: normalized.expiresAt,
284
+ };
285
+ const expectedChecksum = sha256Hex(stableJsonStringify(checksumPayload)).slice(0, 24);
286
+ if (normalized.checksum !== expectedChecksum) {
287
+ throw new Error('Bootstrap token checksum mismatch');
288
+ }
289
+
290
+ if (requireNotExpired) {
291
+ const nowMs = Date.now();
292
+ if (Date.parse(normalized.expiresAt) <= nowMs) {
293
+ throw new Error(`Bootstrap token expired at ${normalized.expiresAt}`);
294
+ }
295
+ }
296
+
297
+ return normalized;
298
+ }
299
+
300
+ export function createBootstrapSession(options = {}) {
301
+ const expiresInSeconds = parseDurationToSeconds(
302
+ options.expiresInSeconds,
303
+ DEFAULT_BOOTSTRAP_EXPIRES_SECONDS,
304
+ );
305
+ const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
306
+ ensureDir(storeDir);
307
+
308
+ const createdAt = utcNowIso();
309
+ const expiresAt = dtToIsoZ(new Date(Date.parse(createdAt) + (expiresInSeconds * 1000)));
310
+ const sessionId = randomHex(16);
311
+
312
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519');
313
+ const recipientPublicDer = encodeBase64Url(publicKey.export({ format: 'der', type: 'spki' }));
314
+ const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' });
315
+ const keyId = sha256Hex(recipientPublicDer).slice(0, 24);
316
+
317
+ const checksumPayload = {
318
+ v: 1,
319
+ session_id: sessionId,
320
+ key_id: keyId,
321
+ recipient_pub: recipientPublicDer,
322
+ created_at: createdAt,
323
+ expires_at: expiresAt,
324
+ };
325
+ const checksum = sha256Hex(stableJsonStringify(checksumPayload)).slice(0, 24);
326
+ const tokenPayload = { ...checksumPayload, checksum };
327
+ const token = `${BOOTSTRAP_TOKEN_PREFIX}${encodeBase64Url(Buffer.from(stableJsonStringify(tokenPayload), 'utf8'))}`;
328
+
329
+ const session = {
330
+ version: 1,
331
+ session_id: sessionId,
332
+ key_id: keyId,
333
+ created_at: createdAt,
334
+ expires_at: expiresAt,
335
+ recipient_pub_der_b64u: recipientPublicDer,
336
+ private_key_pkcs8_pem: String(privateKeyPem),
337
+ consumed_at: null,
338
+ };
339
+
340
+ const outPath = sessionPath(sessionId, storeDir);
341
+ atomicWriteFile(outPath, `${stableJsonStringify(session)}\n`, SESSION_FILE_MODE);
342
+
343
+ return {
344
+ token,
345
+ sessionId,
346
+ keyId,
347
+ createdAt,
348
+ expiresAt,
349
+ storePath: outPath,
350
+ fingerprint: `${keyId.slice(0, 12)}:${sessionId.slice(0, 8)}`,
351
+ };
352
+ }
353
+
354
+ export function loadBootstrapSession(refOrToken, options = {}) {
355
+ const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
356
+ if (!fs.existsSync(storeDir)) {
357
+ throw new Error(`Remote bootstrap store does not exist: ${storeDir}`);
358
+ }
359
+
360
+ let tokenDetails = null;
361
+ if (String(refOrToken || '').trim().startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
362
+ tokenDetails = parseBootstrapToken(refOrToken, { requireNotExpired: false });
363
+ }
364
+
365
+ const ref = tokenDetails
366
+ ? tokenDetails.sessionId
367
+ : String(refOrToken || '').trim();
368
+ if (!ref) {
369
+ throw new Error('Missing bootstrap session reference');
370
+ }
371
+
372
+ const candidate = sessionPath(ref, storeDir);
373
+ if (fs.existsSync(candidate)) {
374
+ const raw = JSON.parse(fs.readFileSync(candidate, 'utf8'));
375
+ const session = normalizeSessionDoc(raw, `bootstrap session ${candidate}`);
376
+ if (tokenDetails && (session.sessionId !== tokenDetails.sessionId || session.keyId !== tokenDetails.keyId)) {
377
+ throw new Error('Bootstrap token does not match stored bootstrap session');
378
+ }
379
+ return {
380
+ ...session,
381
+ filePath: candidate,
382
+ };
383
+ }
384
+
385
+ const fileNames = fs.readdirSync(storeDir).filter((name) => name.endsWith('.json'));
386
+ for (const fileName of fileNames) {
387
+ const filePath = path.join(storeDir, fileName);
388
+ let raw = null;
389
+ try {
390
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
391
+ } catch {
392
+ continue;
393
+ }
394
+ try {
395
+ const session = normalizeSessionDoc(raw, `bootstrap session ${filePath}`);
396
+ if (session.keyId === ref || session.sessionId === ref || session.recipientPublicDer === ref) {
397
+ if (tokenDetails && (session.sessionId !== tokenDetails.sessionId || session.keyId !== tokenDetails.keyId)) {
398
+ throw new Error('Bootstrap token does not match stored bootstrap session');
399
+ }
400
+ return {
401
+ ...session,
402
+ filePath,
403
+ };
404
+ }
405
+ } catch {
406
+ continue;
407
+ }
408
+ }
409
+
410
+ throw new Error(`Bootstrap session not found for reference: ${ref}`);
411
+ }
412
+
413
+ export function markBootstrapSessionConsumed(session, options = {}) {
414
+ const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
415
+ const loaded = session?.filePath
416
+ ? normalizeSessionDoc(JSON.parse(fs.readFileSync(session.filePath, 'utf8')))
417
+ : loadBootstrapSession(session?.sessionId || session?.keyId, { storeDir });
418
+ const filePath = session?.filePath || sessionPath(loaded.sessionId, storeDir);
419
+ const consumedAt = utcNowIso();
420
+ const updated = {
421
+ version: loaded.version,
422
+ session_id: loaded.sessionId,
423
+ key_id: loaded.keyId,
424
+ created_at: loaded.createdAt,
425
+ expires_at: loaded.expiresAt,
426
+ recipient_pub_der_b64u: loaded.recipientPublicDer,
427
+ private_key_pkcs8_pem: loaded.privateKeyPem,
428
+ consumed_at: consumedAt,
429
+ };
430
+ atomicWriteFile(filePath, `${stableJsonStringify(updated)}\n`, SESSION_FILE_MODE);
431
+ return consumedAt;
432
+ }
433
+
434
+ export function buildRemotePayloadFromFiles(options = {}) {
435
+ const configPath = path.resolve(String(options.configPath || 'health-sync.toml'));
436
+ const credsPath = path.resolve(String(options.credsPath || path.join(path.dirname(configPath), '.health-sync.creds')));
437
+ if (!fs.existsSync(configPath)) {
438
+ throw new Error(`Config file not found: ${configPath}`);
439
+ }
440
+
441
+ const configContent = fs.readFileSync(configPath, 'utf8');
442
+ let credsContent = null;
443
+ if (fs.existsSync(credsPath)) {
444
+ credsContent = fs.readFileSync(credsPath, 'utf8');
445
+ } else if (options.allowMissingCreds !== false) {
446
+ credsContent = emptyCredsFileContent();
447
+ } else {
448
+ throw new Error(`Creds file not found: ${credsPath}`);
449
+ }
450
+
451
+ const payload = {
452
+ schema: REMOTE_PAYLOAD_SCHEMA,
453
+ version: 1,
454
+ created_at: utcNowIso(),
455
+ source_version: hasText(options.sourceVersion) ? String(options.sourceVersion) : '0.0.0',
456
+ files: {
457
+ 'health-sync.toml': {
458
+ encoding: 'utf8',
459
+ content: configContent,
460
+ sha256: sha256Hex(configContent),
461
+ },
462
+ '.health-sync.creds': {
463
+ encoding: 'utf8',
464
+ content: credsContent,
465
+ sha256: sha256Hex(credsContent),
466
+ },
467
+ },
468
+ };
469
+
470
+ return {
471
+ payload,
472
+ configPath,
473
+ credsPath,
474
+ };
475
+ }
476
+
477
+ export function encryptRemotePayload(payload, bootstrapToken, options = {}) {
478
+ const token = parseBootstrapToken(bootstrapToken, {
479
+ requireNotExpired: options.requireNotExpired !== false,
480
+ });
481
+ const value = assertObject(payload, 'remote payload');
482
+ if (value.schema !== REMOTE_PAYLOAD_SCHEMA || Number(value.version) !== 1) {
483
+ throw new Error('Unsupported remote payload version');
484
+ }
485
+
486
+ const recipientPublicKey = loadPublicKeyFromDerBase64Url(token.recipientPublicDer);
487
+ const { privateKey: ephPrivateKey, publicKey: ephPublicKey } = crypto.generateKeyPairSync('x25519');
488
+ const sharedSecret = crypto.diffieHellman({
489
+ privateKey: ephPrivateKey,
490
+ publicKey: recipientPublicKey,
491
+ });
492
+
493
+ const salt = crypto.randomBytes(16);
494
+ const nonce = crypto.randomBytes(12);
495
+ const key = deriveSymmetricKey({
496
+ sharedSecret,
497
+ salt,
498
+ sessionId: token.sessionId,
499
+ keyId: token.keyId,
500
+ });
501
+
502
+ const payloadJson = stableJsonStringify(value);
503
+ const payloadSha256 = sha256Hex(payloadJson);
504
+ const compressed = gzipSync(Buffer.from(payloadJson, 'utf8'));
505
+
506
+ const archiveCreatedAt = utcNowIso();
507
+ const aad = {
508
+ schema: REMOTE_ARCHIVE_SCHEMA,
509
+ version: 1,
510
+ session_id: token.sessionId,
511
+ key_id: token.keyId,
512
+ created_at: archiveCreatedAt,
513
+ expires_at: token.expiresAt,
514
+ payload_sha256: payloadSha256,
515
+ };
516
+
517
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
518
+ cipher.setAAD(Buffer.from(stableJsonStringify(aad), 'utf8'));
519
+ const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
520
+ const tag = cipher.getAuthTag();
521
+
522
+ return {
523
+ schema: REMOTE_ARCHIVE_SCHEMA,
524
+ version: 1,
525
+ session_id: token.sessionId,
526
+ key_id: token.keyId,
527
+ aad_json: stableJsonStringify(aad),
528
+ ephemeral_pub_der_b64u: encodeBase64Url(ephPublicKey.export({ format: 'der', type: 'spki' })),
529
+ salt_b64u: encodeBase64Url(salt),
530
+ nonce_b64u: encodeBase64Url(nonce),
531
+ ciphertext_b64u: encodeBase64Url(ciphertext),
532
+ tag_b64u: encodeBase64Url(tag),
533
+ };
534
+ }
535
+
536
+ export function decryptRemoteArchiveEnvelope(envelopeRaw, sessionRef, options = {}) {
537
+ const envelope = parseArchiveEnvelope(envelopeRaw);
538
+ const aad = JSON.parse(String(envelope.aad_json));
539
+ const session = loadBootstrapSession(sessionRef, {
540
+ storeDir: options.storeDir || defaultSessionStoreDir(),
541
+ });
542
+
543
+ if (session.consumedAt) {
544
+ throw new Error(`Bootstrap session already consumed at ${session.consumedAt}`);
545
+ }
546
+ if (envelope.session_id !== session.sessionId || envelope.key_id !== session.keyId) {
547
+ throw new Error('Remote archive does not match the selected bootstrap session');
548
+ }
549
+
550
+ if (Date.parse(aad.created_at || '') > Date.parse(session.expiresAt)) {
551
+ throw new Error('Remote archive was created after bootstrap session expiry');
552
+ }
553
+
554
+ const ephPublicKey = loadPublicKeyFromDerBase64Url(envelope.ephemeral_pub_der_b64u);
555
+ const privateKey = loadPrivateKeyFromPem(session.privateKeyPem);
556
+ const sharedSecret = crypto.diffieHellman({
557
+ privateKey,
558
+ publicKey: ephPublicKey,
559
+ });
560
+
561
+ const salt = decodeBase64Url(envelope.salt_b64u, 'remote archive salt');
562
+ const nonce = decodeBase64Url(envelope.nonce_b64u, 'remote archive nonce');
563
+ const tag = decodeBase64Url(envelope.tag_b64u, 'remote archive auth tag');
564
+ const ciphertext = decodeBase64Url(envelope.ciphertext_b64u, 'remote archive ciphertext');
565
+
566
+ const key = deriveSymmetricKey({
567
+ sharedSecret,
568
+ salt,
569
+ sessionId: session.sessionId,
570
+ keyId: session.keyId,
571
+ });
572
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
573
+ decipher.setAAD(Buffer.from(String(envelope.aad_json), 'utf8'));
574
+ decipher.setAuthTag(tag);
575
+
576
+ let compressed = null;
577
+ try {
578
+ compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
579
+ } catch {
580
+ throw new Error('Failed to decrypt remote archive (auth/tag mismatch)');
581
+ }
582
+
583
+ const payloadJson = gunzipSync(compressed).toString('utf8');
584
+ let payload = null;
585
+ try {
586
+ payload = JSON.parse(payloadJson);
587
+ } catch {
588
+ throw new Error('Decrypted remote archive payload is not valid JSON');
589
+ }
590
+
591
+ if (aad.payload_sha256 !== sha256Hex(payloadJson)) {
592
+ throw new Error('Decrypted remote archive payload checksum mismatch');
593
+ }
594
+ if (payload.schema !== REMOTE_PAYLOAD_SCHEMA || Number(payload.version) !== 1) {
595
+ throw new Error('Unsupported remote archive payload schema');
596
+ }
597
+
598
+ return {
599
+ envelope,
600
+ payload,
601
+ session,
602
+ };
603
+ }
604
+
605
+ export function writeRemoteArchiveFile(envelope, outputPath) {
606
+ const target = path.resolve(String(outputPath));
607
+ atomicWriteFile(target, `${stableJsonStringify(envelope)}\n`, 0o600);
608
+ return target;
609
+ }
610
+
611
+ export function readRemoteArchiveFile(archivePath) {
612
+ const resolved = path.resolve(String(archivePath));
613
+ if (!fs.existsSync(resolved)) {
614
+ throw new Error(`Remote archive not found: ${resolved}`);
615
+ }
616
+ let parsed = null;
617
+ try {
618
+ parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
619
+ } catch {
620
+ throw new Error(`Remote archive is not valid JSON: ${resolved}`);
621
+ }
622
+ return {
623
+ archivePath: resolved,
624
+ envelope: parseArchiveEnvelope(parsed),
625
+ };
626
+ }
627
+
628
+ export function defaultRemoteArchivePath(configPath, sessionId) {
629
+ const safeSession = String(sessionId || '').slice(0, 12) || randomHex(6);
630
+ const dir = path.dirname(path.resolve(configPath));
631
+ return path.join(dir, `health-sync-remote-${safeSession}.enc`);
632
+ }
633
+
634
+ function backupIfExists(targetPath) {
635
+ const resolved = path.resolve(targetPath);
636
+ if (!fs.existsSync(resolved)) {
637
+ return null;
638
+ }
639
+ const backupPath = `${resolved}.bak-${formatBackupTimestamp()}`;
640
+ fs.copyFileSync(resolved, backupPath);
641
+ return backupPath;
642
+ }
643
+
644
+ function writeImportedFile(targetPath, content, mode) {
645
+ atomicWriteFile(path.resolve(targetPath), content, mode);
646
+ }
647
+
648
+ export function importRemoteArchive(options = {}) {
649
+ const archivePath = path.resolve(String(options.archivePath || ''));
650
+ if (!archivePath) {
651
+ throw new Error('archivePath is required');
652
+ }
653
+ const sessionRef = options.sessionRef;
654
+ if (!sessionRef) {
655
+ throw new Error('sessionRef is required');
656
+ }
657
+
658
+ const parsed = readRemoteArchiveFile(archivePath);
659
+ const decrypted = decryptRemoteArchiveEnvelope(parsed.envelope, sessionRef, {
660
+ storeDir: options.storeDir || defaultSessionStoreDir(),
661
+ });
662
+ const payload = decrypted.payload;
663
+ const { config, creds } = validatePayloadFiles(payload);
664
+
665
+ if (sha256Hex(config.content) !== config.sha256) {
666
+ throw new Error('health-sync.toml checksum mismatch in remote payload');
667
+ }
668
+ if (sha256Hex(creds.content) !== creds.sha256) {
669
+ throw new Error('.health-sync.creds checksum mismatch in remote payload');
670
+ }
671
+
672
+ const targetConfigPath = path.resolve(String(options.targetConfigPath || 'health-sync.toml'));
673
+ const targetCredsPath = path.resolve(String(
674
+ options.targetCredsPath || path.join(path.dirname(targetConfigPath), '.health-sync.creds'),
675
+ ));
676
+
677
+ const backups = [];
678
+ const configBackup = backupIfExists(targetConfigPath);
679
+ if (configBackup) {
680
+ backups.push(configBackup);
681
+ }
682
+ const credsBackup = backupIfExists(targetCredsPath);
683
+ if (credsBackup) {
684
+ backups.push(credsBackup);
685
+ }
686
+
687
+ writeImportedFile(targetConfigPath, String(config.content), 0o600);
688
+ writeImportedFile(targetCredsPath, String(creds.content), 0o600);
689
+ const consumedAt = markBootstrapSessionConsumed(decrypted.session, {
690
+ storeDir: options.storeDir || defaultSessionStoreDir(),
691
+ });
692
+
693
+ const tokenCount = (() => {
694
+ try {
695
+ const parsedCreds = JSON.parse(String(creds.content));
696
+ return Object.keys(parsedCreds?.tokens || {}).length;
697
+ } catch {
698
+ return 0;
699
+ }
700
+ })();
701
+
702
+ return {
703
+ targetConfigPath,
704
+ targetCredsPath,
705
+ backups,
706
+ consumedAt,
707
+ sessionId: decrypted.session.sessionId,
708
+ keyId: decrypted.session.keyId,
709
+ tokenCount,
710
+ };
711
+ }