@doubling/compound-sync 1.0.2 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +58 -30
  2. package/package.json +1 -1
  3. package/sync.js +112 -3
package/README.md CHANGED
@@ -1,44 +1,26 @@
1
- # Compound Sync Daemon
1
+ # Compound Sync
2
2
 
3
- Bidirectional sync between Firestore and local markdown files.
3
+ Bidirectional sync between Compound and local markdown files. Edit files locally in your favorite editor and they sync to Compound in real time.
4
4
 
5
- ## Prerequisites
6
-
7
- - Node.js
8
- - Google Cloud Application Default Credentials: `gcloud auth application-default login`
9
-
10
- ## Setup
5
+ ## Quick Start
11
6
 
12
7
  ```bash
13
- cd sync
14
- npm install
8
+ npx @doubling/compound-sync
15
9
  ```
16
10
 
17
- Run the interactive setup for each environment:
11
+ That's it. A browser window will open for sign-in (Google or email/password), then the setup wizard will walk you through selecting your organization and sync folder.
18
12
 
19
- ```bash
20
- node sync.js --config config-sandbox.json --setup # Project: doubling-compound-sandbox
21
- node sync.js --config config-dev.json --setup # Project: doubling-compound-dev
22
- node sync.js --config config-prod.json --setup # Project: doubling-compound-prod
23
- ```
13
+ ### Prerequisites
24
14
 
25
- The setup wizard will ask for:
26
- - **Firebase project ID** (e.g. `doubling-compound-sandbox`)
27
- - **Organization** to sync
28
- - **Email address** for private file sync
29
- - **Local sync folder** (e.g. `~/Compound-sandbox`)
15
+ - [Node.js](https://nodejs.org/) (v18 or later)
30
16
 
31
- Config files (`config-*.json`) are gitignored and stored locally.
32
-
33
- ## Running
17
+ ## What it does
34
18
 
35
- Run one or more environments in separate terminal tabs:
19
+ Compound Sync watches a local folder and your Compound workspace simultaneously. Changes in either direction are synced automatically:
36
20
 
37
- ```bash
38
- node sync.js --config config-sandbox.json
39
- node sync.js --config config-dev.json
40
- node sync.js --config config-prod.json
41
- ```
21
+ - Edit a file locally → it updates in Compound
22
+ - Edit a file in Compound → it updates locally
23
+ - Create or delete files in either place → synced
42
24
 
43
25
  ## Local folder structure
44
26
 
@@ -49,3 +31,49 @@ node sync.js --config config-prod.json
49
31
  Shared by Me/ -- symlinks to files you've shared
50
32
  Shared with Me/ -- files shared with you (read-only)
51
33
  ```
34
+
35
+ ## Running
36
+
37
+ After setup, start syncing with:
38
+
39
+ ```bash
40
+ npx @doubling/compound-sync
41
+ ```
42
+
43
+ The sync daemon runs until you press Ctrl+C.
44
+
45
+ ## Security
46
+
47
+ - No credentials are stored on disk
48
+ - Authentication happens via your browser each time you start the daemon
49
+ - All data access respects Compound's security rules
50
+
51
+ ---
52
+
53
+ ## Internal Development
54
+
55
+ For Doubling team members testing against sandbox or dev environments:
56
+
57
+ ```bash
58
+ # Setup
59
+ npx @doubling/compound-sync --env sandbox --config config-sandbox.json --setup
60
+ npx @doubling/compound-sync --env dev --config config-dev.json --setup
61
+
62
+ # Run
63
+ npx @doubling/compound-sync --env sandbox --config config-sandbox.json
64
+ npx @doubling/compound-sync --env dev --config config-dev.json
65
+ ```
66
+
67
+ Config files (`config-*.json`) are gitignored and stored locally.
68
+
69
+ ### Publishing to npm
70
+
71
+ After making changes to the sync daemon, publish a new version:
72
+
73
+ ```bash
74
+ cd sync
75
+ npm version patch # or minor/major
76
+ npm publish
77
+ ```
78
+
79
+ This is required whenever sync daemon code changes — the `npx` command pulls from npm, not from the repo. Consider automating this in CI for releases.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": "./sync.js",
package/sync.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  getFirestore, collection, doc, getDocs, getDoc, addDoc, updateDoc, deleteDoc,
21
21
  query, where, onSnapshot
22
22
  } from 'firebase/firestore';
23
+ import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
23
24
  import chokidar from 'chokidar';
24
25
  import fs from 'fs';
25
26
  import path from 'path';
@@ -60,6 +61,13 @@ const firebaseConfigs = {
60
61
  }
61
62
  };
62
63
 
64
+ // App Check token endpoints per environment
65
+ const appCheckEndpoints = {
66
+ 'doubling-compound-prod': 'https://mintappchecktoken-oaimzbv67a-uc.a.run.app',
67
+ 'doubling-compound-dev': 'https://mintappchecktoken-molg2wq42a-uc.a.run.app',
68
+ 'doubling-compound-sandbox': 'https://mintappchecktoken-pbpkn3igxa-uc.a.run.app',
69
+ };
70
+
63
71
  // --- Helpers ---
64
72
 
65
73
  function ask(question) {
@@ -121,9 +129,26 @@ function buildLoginPage(fbConfig) {
121
129
  import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-app.js';
122
130
  import { getAuth, signInWithPopup, signInWithEmailAndPassword, GoogleAuthProvider }
123
131
  from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-auth.js';
132
+ import { initializeAppCheck, ReCaptchaEnterpriseProvider }
133
+ from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-app-check.js';
124
134
 
125
135
  const fbConfig = ${JSON.stringify(fbConfig)};
126
136
  const app = initializeApp(fbConfig);
137
+
138
+ // Initialize App Check (required for Auth enforcement)
139
+ const recaptchaKeys = {
140
+ 'doubling-compound-prod': '6Ld9o7IsAAAAAJDAsa3YCUq8ZeCzuvB9Mly6vlxX',
141
+ 'doubling-compound-dev': '6Le56rIsAAAAAGfDgd1jZEoiDNgooWv8DkvoLEzu',
142
+ 'doubling-compound-sandbox': '6LdN6LIsAAAAAICw1QXRdtIGUPkkh5XLbXLf2LfB',
143
+ };
144
+ const rcKey = recaptchaKeys[fbConfig.projectId];
145
+ if (rcKey) {
146
+ initializeAppCheck(app, {
147
+ provider: new ReCaptchaEnterpriseProvider(rcKey),
148
+ isTokenAutoRefreshEnabled: true,
149
+ });
150
+ }
151
+
127
152
  const auth = getAuth(app);
128
153
 
129
154
  async function sendCredential(data) {
@@ -140,10 +165,12 @@ window.googleSignIn = async function() {
140
165
  try {
141
166
  const result = await signInWithPopup(auth, new GoogleAuthProvider());
142
167
  const credential = GoogleAuthProvider.credentialFromResult(result);
168
+ const firebaseIdToken = await result.user.getIdToken();
143
169
  await sendCredential({
144
170
  method: 'google',
145
171
  idToken: credential.idToken,
146
172
  accessToken: credential.accessToken,
173
+ firebaseIdToken,
147
174
  });
148
175
  } catch (err) {
149
176
  document.getElementById('error').textContent = err.message;
@@ -154,8 +181,9 @@ window.emailSignIn = async function() {
154
181
  const email = document.getElementById('email').value;
155
182
  const password = document.getElementById('password').value;
156
183
  try {
157
- await signInWithEmailAndPassword(auth, email, password);
158
- await sendCredential({ method: 'email', email, password });
184
+ const result = await signInWithEmailAndPassword(auth, email, password);
185
+ const firebaseIdToken = await result.user.getIdToken();
186
+ await sendCredential({ method: 'email', email, password, firebaseIdToken });
159
187
  } catch (err) {
160
188
  document.getElementById('error').textContent = err.message;
161
189
  }
@@ -314,6 +342,72 @@ async function setupConfig() {
314
342
  return savedConfig;
315
343
  }
316
344
 
345
+ // --- App Check (custom provider for CLI) ---
346
+
347
+ // Fetch an App Check token using a Firebase ID token (before Node.js sign-in)
348
+ async function fetchAppCheckToken(tokenEndpoint, firebaseIdToken) {
349
+ const response = await fetch(tokenEndpoint, {
350
+ method: 'POST',
351
+ headers: {
352
+ 'Authorization': `Bearer ${firebaseIdToken}`,
353
+ 'Content-Type': 'application/json',
354
+ },
355
+ });
356
+
357
+ if (!response.ok) {
358
+ const errBody = await response.text().catch(() => '');
359
+ console.error(`App Check token request failed (${response.status}): ${errBody}`);
360
+ throw new Error(`App Check token request failed: ${response.status}`);
361
+ }
362
+
363
+ return await response.json();
364
+ }
365
+
366
+ // Initialize App Check with a pre-fetched ID token (used on first sign-in)
367
+ async function initAppCheckWithIdToken(app, tokenEndpoint, firebaseIdToken) {
368
+ console.log('Initializing App Check...');
369
+ // Pre-fetch the first token
370
+ const initial = await fetchAppCheckToken(tokenEndpoint, firebaseIdToken);
371
+ let cachedToken = {
372
+ token: initial.token,
373
+ expireTimeMillis: Date.now() + initial.ttlMillis,
374
+ };
375
+
376
+ const customProvider = new CustomProvider({
377
+ getToken: async () => {
378
+ // If token is still valid, return cached
379
+ if (cachedToken.expireTimeMillis > Date.now() + 60000) {
380
+ return cachedToken;
381
+ }
382
+ // Otherwise refresh — at this point the user should be signed in
383
+ const auth = getAuth(app);
384
+ const user = auth.currentUser;
385
+ if (!user) return cachedToken; // fallback
386
+ const idToken = await user.getIdToken();
387
+ const refreshed = await fetchAppCheckToken(tokenEndpoint, idToken);
388
+ cachedToken = {
389
+ token: refreshed.token,
390
+ expireTimeMillis: Date.now() + refreshed.ttlMillis,
391
+ };
392
+ return cachedToken;
393
+ },
394
+ });
395
+
396
+ initializeAppCheck(app, {
397
+ provider: customProvider,
398
+ isTokenAutoRefreshEnabled: true,
399
+ });
400
+ console.log('App Check initialized.');
401
+ }
402
+
403
+ // Initialize App Check with an already-signed-in user (used during setup reuse)
404
+ async function initAppCheck(app, auth, tokenEndpoint) {
405
+ const user = auth.currentUser;
406
+ if (!user) throw new Error('Not signed in');
407
+ const idToken = await user.getIdToken();
408
+ await initAppCheckWithIdToken(app, tokenEndpoint, idToken);
409
+ }
410
+
317
411
  // --- Config ---
318
412
 
319
413
  const args = process.argv.slice(2);
@@ -347,6 +441,13 @@ if (config._app) {
347
441
  app = config._app;
348
442
  db = config._db;
349
443
  userId = config._userId;
444
+
445
+ // Initialize App Check if endpoint exists for this env
446
+ const appCheckUrl = appCheckEndpoints[config.projectId];
447
+ if (appCheckUrl) {
448
+ const auth = getAuth(app);
449
+ await initAppCheck(app, auth, appCheckUrl);
450
+ }
350
451
  } else {
351
452
  const fbConfig = firebaseConfigs[config.projectId];
352
453
  if (!fbConfig) {
@@ -358,10 +459,17 @@ if (config._app) {
358
459
  const auth = getAuth(app);
359
460
  db = getFirestore(app);
360
461
 
361
- // Sign in via browser
462
+ // Sign in via browser (browser handles reCAPTCHA App Check)
362
463
  console.log('');
363
464
  const authResult = await browserAuth(fbConfig);
364
465
 
466
+ // Initialize App Check BEFORE signing in on the Node.js side
467
+ // Use the Firebase ID token from the browser to get an App Check token
468
+ const appCheckUrl = appCheckEndpoints[config.projectId];
469
+ if (appCheckUrl && authResult.firebaseIdToken) {
470
+ await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
471
+ }
472
+
365
473
  if (authResult.method === 'google') {
366
474
  const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
367
475
  const cred = await signInWithCredential(auth, credential);
@@ -372,6 +480,7 @@ if (config._app) {
372
480
  userId = cred.user.uid;
373
481
  console.log(`Signed in as ${cred.user.email}`);
374
482
  }
483
+
375
484
  }
376
485
 
377
486
  const ORG_ID = config.orgId;