@doubling/compound-sync 1.6.0 → 1.7.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.
@@ -0,0 +1,79 @@
1
+ // File-backed Firebase Auth persistence for Node.
2
+ //
3
+ // Firebase's Node SDK defaults to in-memory persistence, so the daemon
4
+ // re-authenticates (browser popup) on every launch. This persistence
5
+ // stores the auth state (including the refresh token Firebase uses to
6
+ // silently mint new ID tokens) in a JSON file, so a relaunch restores
7
+ // the session without a browser.
8
+ //
9
+ // Firebase consumes a persistence as a CLASS: it does `new P()` and
10
+ // calls the prototype methods below, and reads the static `type`
11
+ // ('LOCAL' = durable). We can't pass the file path through the
12
+ // zero-arg constructor the SDK uses, so `makeFilePersistence(path)` is
13
+ // a factory returning a class bound to that path. This lets the
14
+ // desktop app give each account its own credentials file.
15
+ //
16
+ // Security: the file holds a refresh token, so it's written 0600.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ // Where to store an account's persisted auth. The desktop app passes
22
+ // an explicit per-account path via the COMPOUND_AUTH_FILE env var
23
+ // (each account gets its own file). The CLI falls back to a stable
24
+ // per-(project, account) path under the user's config dir, keyed by
25
+ // email (or orgId) so two accounts on the same project don't collide.
26
+ export function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKey }) {
27
+ if (authFileEnv) return authFileEnv;
28
+ const safeAccount = String(accountKey || 'default').replace(/[^a-zA-Z0-9._@-]/g, '_');
29
+ const safeProject = String(projectId || 'default').replace(/[^a-zA-Z0-9._-]/g, '_');
30
+ return path.join(homeDir, '.config', 'compound-sync', `${safeProject}__${safeAccount}.json`);
31
+ }
32
+
33
+ export function makeFilePersistence(filePath) {
34
+ function readStore() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
37
+ } catch {
38
+ // Missing or unreadable file: start from an empty store.
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function writeStore(store) {
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ fs.writeFileSync(filePath, JSON.stringify(store), { mode: 0o600 });
46
+ }
47
+
48
+ return class FilePersistence {
49
+ // Read by the SDK both as a static and as an instance property in
50
+ // different code paths; set both. 'LOCAL' = survives restarts.
51
+ static type = 'LOCAL';
52
+ type = 'LOCAL';
53
+
54
+ async _isAvailable() {
55
+ return true;
56
+ }
57
+
58
+ async _set(key, value) {
59
+ const store = readStore();
60
+ store[key] = value;
61
+ writeStore(store);
62
+ }
63
+
64
+ async _get(key) {
65
+ const store = readStore();
66
+ return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
67
+ }
68
+
69
+ async _remove(key) {
70
+ const store = readStore();
71
+ delete store[key];
72
+ writeStore(store);
73
+ }
74
+
75
+ // Cross-tab change listeners are a browser concern; no-ops in Node.
76
+ _addListener(_key, _listener) {}
77
+ _removeListener(_key, _listener) {}
78
+ };
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,11 +14,12 @@
14
14
  "folder-chain.js",
15
15
  "storage-helpers.js",
16
16
  "team-registry.js",
17
+ "auth-persistence.js",
17
18
  "README.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "sync": "node sync.js",
21
- "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js",
22
+ "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js",
22
23
  "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
23
24
  "test": "npm run test:unit && npm run test:integration"
24
25
  },
@@ -80,6 +80,16 @@ const EXT_TO_MIME = {
80
80
  webm: 'video/webm',
81
81
  mov: 'video/quicktime',
82
82
  zip: 'application/zip',
83
+ // Office Open XML (modern Office). The full type strings are
84
+ // mandatory: tools that dispatch by mime (Excel, third-party readers)
85
+ // ignore alternative spellings.
86
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
87
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
88
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
89
+ // Legacy Office formats (pre-2007 binary containers).
90
+ xls: 'application/vnd.ms-excel',
91
+ doc: 'application/msword',
92
+ ppt: 'application/vnd.ms-powerpoint',
83
93
  };
84
94
 
85
95
  export function mimeTypeFromExt(ext) {
package/sync.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { initializeApp } from 'firebase/app';
20
- import { getAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
20
+ import { getAuth, initializeAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
21
21
  import {
22
22
  getFirestore, collection, doc, getDocs, getDoc, setDoc, addDoc, updateDoc, deleteDoc,
23
23
  query, where, onSnapshot
@@ -47,6 +47,7 @@ import {
47
47
  // Max file size enforced before upload. Mirrored in storage.rules.
48
48
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
49
49
  import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
50
+ import { makeFilePersistence, resolveAuthFilePath } from './auth-persistence.js';
50
51
  import {
51
52
  scopeFromLocalPath,
52
53
  teamFolderName,
@@ -527,29 +528,57 @@ if (config._app) {
527
528
  }
528
529
 
529
530
  app = initializeApp(fbConfig);
530
- const auth = getAuth(app);
531
531
  db = getFirestore(app);
532
532
 
533
- // Sign in via browser (browser handles reCAPTCHA App Check)
534
- console.log('');
535
- const authResult = await browserAuth(fbConfig);
533
+ // File-backed auth persistence. A previously-signed-in session is
534
+ // restored here with no browser. The desktop app sets
535
+ // COMPOUND_AUTH_FILE per account; the CLI derives a per-(project,
536
+ // account) path under ~/.config/compound-sync. initializeAuth must
537
+ // run before any getAuth(app) for this app.
538
+ const authFilePath = resolveAuthFilePath({
539
+ authFileEnv: process.env.COMPOUND_AUTH_FILE,
540
+ homeDir: os.homedir(),
541
+ projectId: config.projectId,
542
+ accountKey: config.email || config.orgId,
543
+ });
544
+ const auth = initializeAuth(app, { persistence: makeFilePersistence(authFilePath) });
545
+ await auth.authStateReady();
536
546
 
537
- // Initialize App Check BEFORE signing in on the Node.js side
538
- // Use the Firebase ID token from the browser to get an App Check token
539
547
  const appCheckUrl = appCheckEndpoints[config.projectId];
540
- if (appCheckUrl && authResult.firebaseIdToken) {
541
- await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
542
- }
543
548
 
544
- if (authResult.method === 'google') {
545
- const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
546
- const cred = await signInWithCredential(auth, credential);
547
- userId = cred.user.uid;
548
- console.log(`Signed in as ${cred.user.email}`);
549
+ if (auth.currentUser) {
550
+ // Silent re-auth: session restored from persisted credentials, no
551
+ // browser. App Check (custom provider) mints its token from a
552
+ // fresh ID token, which the refresh-token flow obtains without a
553
+ // browser.
554
+ userId = auth.currentUser.uid;
555
+ if (appCheckUrl) {
556
+ const idToken = await auth.currentUser.getIdToken();
557
+ await initAppCheckWithIdToken(app, appCheckUrl, idToken);
558
+ }
559
+ console.log(`Signed in (silent) as ${auth.currentUser.email || userId}`);
549
560
  } else {
550
- const cred = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
551
- userId = cred.user.uid;
552
- console.log(`Signed in as ${cred.user.email}`);
561
+ // First run for this account: interactive browser sign-in. The
562
+ // result is persisted, so later launches take the silent path.
563
+ console.log('');
564
+ const authResult = await browserAuth(fbConfig);
565
+
566
+ // Initialize App Check BEFORE signing in on the Node.js side, using
567
+ // the Firebase ID token from the browser.
568
+ if (appCheckUrl && authResult.firebaseIdToken) {
569
+ await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
570
+ }
571
+
572
+ if (authResult.method === 'google') {
573
+ const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
574
+ const cred = await signInWithCredential(auth, credential);
575
+ userId = cred.user.uid;
576
+ console.log(`Signed in as ${cred.user.email}`);
577
+ } else {
578
+ const cred = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
579
+ userId = cred.user.uid;
580
+ console.log(`Signed in as ${cred.user.email}`);
581
+ }
553
582
  }
554
583
 
555
584
  }