@doubling/compound-sync 1.7.1 → 1.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.7.1",
3
+ "version": "1.10.1",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "sync.js",
11
+ "org-sync.js",
12
+ "sync-state.js",
11
13
  "config.js",
12
14
  "paths.js",
13
15
  "files.js",
@@ -15,12 +17,14 @@
15
17
  "storage-helpers.js",
16
18
  "team-registry.js",
17
19
  "auth-persistence.js",
20
+ "pre-mint-app-check.js",
21
+ "setup-auth-with-pre-mint.js",
18
22
  "README.md"
19
23
  ],
20
24
  "scripts": {
21
25
  "sync": "node sync.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",
23
- "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
26
+ "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 pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.js",
27
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test --test-concurrency=1 files.test.js org-sync.test.js two-user-sync.test.js'",
24
28
  "test": "npm run test:unit && npm run test:integration"
25
29
  },
26
30
  "dependencies": {
@@ -0,0 +1,84 @@
1
+ // Pre-mint an App Check token BEFORE the Firebase Auth SDK validates
2
+ // the cached refresh token at startup. Background:
3
+ //
4
+ // The Firebase JS SDK, on `initializeAuth(...)` with a persisted user,
5
+ // calls identitytoolkit.googleapis.com/:lookup as part of authStateReady
6
+ // to validate the user. With App Check enforcement on Authentication
7
+ // enabled in Firebase, that :lookup call requires an App Check header.
8
+ // Our daemon initializes App Check (CustomProvider) only AFTER
9
+ // authStateReady, because the CustomProvider's mint flow needs an ID
10
+ // token from auth.currentUser. Result: :lookup fails, SDK marks the
11
+ // user invalid, calls persistence._remove(userKey), and the user is
12
+ // prompted for a browser sign-in on every relaunch.
13
+ //
14
+ // Fix: before authStateReady, read the persisted refresh token directly
15
+ // from the auth.json file, exchange it for a fresh ID token via
16
+ // securetoken.googleapis.com (which is NOT under App Check enforcement;
17
+ // it is Google's OAuth secure-token service), use that ID token to mint
18
+ // an App Check token from our mintappchecktoken cloud function, and
19
+ // initializeAppCheck. Now the SDK's :lookup carries the App Check
20
+ // header and silent restore succeeds.
21
+
22
+ import fs from 'node:fs';
23
+
24
+ // Read the refresh token stored by Firebase Auth's file persistence.
25
+ // The persistence stores a single key per app:
26
+ // 'firebase:authUser:<apiKey>:[DEFAULT]' → { uid, ..., stsTokenManager: { refreshToken, ... } }
27
+ // We don't care which apiKey is in the key (we don't have it handy at
28
+ // this layer): we walk the keys, pick the first 'firebase:authUser:'
29
+ // entry, and pull its refresh token. Returns null when there's nothing
30
+ // usable (file missing, corrupt, empty, or no refresh token field).
31
+ export function readPersistedRefreshToken(filePath) {
32
+ let raw;
33
+ try {
34
+ raw = fs.readFileSync(filePath, 'utf8');
35
+ } catch {
36
+ return null;
37
+ }
38
+ let store;
39
+ try {
40
+ store = JSON.parse(raw);
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!store || typeof store !== 'object') return null;
45
+ for (const key of Object.keys(store)) {
46
+ if (!key.startsWith('firebase:authUser:')) continue;
47
+ const user = store[key];
48
+ const rt = user?.stsTokenManager?.refreshToken;
49
+ if (typeof rt === 'string' && rt.length > 0) return rt;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // Exchange a Firebase refresh token for a fresh ID token via Google's
55
+ // secure token endpoint. This endpoint lives at securetoken.googleapis.com
56
+ // and is intentionally NOT under App Check enforcement (App Check
57
+ // enforcement on Auth covers identitytoolkit.googleapis.com only), so
58
+ // this call works even with no App Check token in flight, breaking the
59
+ // chicken-and-egg at daemon startup.
60
+ //
61
+ // Returns the new ID token string. Throws on non-OK responses: a
62
+ // revoked or expired refresh token will return 400 here, and the
63
+ // caller falls back to the browser sign-in path.
64
+ //
65
+ // `deps.fetch` is injectable for tests; defaults to the global fetch.
66
+ export async function exchangeRefreshTokenForIdToken(refreshToken, apiKey, deps = {}) {
67
+ const f = deps.fetch || fetch;
68
+ const url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
69
+ const body = new URLSearchParams({
70
+ grant_type: 'refresh_token',
71
+ refresh_token: refreshToken,
72
+ }).toString();
73
+ const resp = await f(url, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ body,
77
+ });
78
+ if (!resp.ok) {
79
+ const errBody = await resp.text().catch(() => '');
80
+ throw new Error(`secure-token exchange failed (${resp.status}): ${errBody}`);
81
+ }
82
+ const json = await resp.json();
83
+ return json.id_token;
84
+ }
@@ -0,0 +1,84 @@
1
+ // Orchestrates the daemon's auth startup with App Check enforcement on
2
+ // Authentication in mind. The contract this file enforces is the
3
+ // ordering: initializeAppCheck must happen BEFORE initializeAuth.
4
+ //
5
+ // Why ordering matters (the bug we fixed):
6
+ //
7
+ // initializeAuth(app, { persistence }) returns synchronously, but it
8
+ // schedules the cached-user validation (an identitytoolkit :lookup
9
+ // request) as a microtask chain that fires almost immediately. If
10
+ // App Check is initialized AFTER initializeAuth, that :lookup goes
11
+ // out without an X-Firebase-AppCheck header. Under Auth App Check
12
+ // enforcement the backend rejects, the SDK marks the cached user
13
+ // invalid, sets currentUser to null, and authStateReady resolves with
14
+ // no user. The daemon then falls through to the browser-auth path on
15
+ // every relaunch, even though we just spent ~500ms pre-minting an
16
+ // App Check token.
17
+ //
18
+ // Calling initializeAppCheck first registers the App Check provider on
19
+ // the app BEFORE the Auth SDK is constructed. When Auth then issues
20
+ // its :lookup, it picks up the provider and the header is on the
21
+ // request. Silent restore works.
22
+ //
23
+ // All Firebase SDK functions and the project's own helpers are passed
24
+ // in as deps so the call order can be unit-tested without standing up
25
+ // real network calls. sync.js wires the real deps in two places (the
26
+ // discover path and the main daemon path), eliminating the duplicated
27
+ // inline pre-mint blocks that previously lived in sync.js.
28
+
29
+ export async function setupAuthWithPreMint(
30
+ { app, authFilePath, apiKey, appCheckUrl },
31
+ deps,
32
+ ) {
33
+ const {
34
+ readPersistedRefreshToken,
35
+ exchangeRefreshTokenForIdToken,
36
+ initAppCheckWithIdToken,
37
+ makeFilePersistence,
38
+ initializeAuth,
39
+ } = deps;
40
+
41
+ // 1. Read the persisted refresh token directly, before involving the
42
+ // Firebase Auth SDK. If there's nothing persisted (first launch),
43
+ // this returns null and we skip pre-mint; the caller's
44
+ // browser-auth path handles first-run sign-in.
45
+ const refreshToken = readPersistedRefreshToken(authFilePath);
46
+
47
+ // 2. Pre-mint App Check on the app, if we have what we need. This
48
+ // MUST complete before step 3 (initializeAuth), or the SDK's
49
+ // startup :lookup races our pre-mint and goes out unauthed.
50
+ let appCheckPreMinted = false;
51
+ if (refreshToken && appCheckUrl) {
52
+ try {
53
+ const idToken = await exchangeRefreshTokenForIdToken(refreshToken, apiKey);
54
+ await initAppCheckWithIdToken(app, appCheckUrl, idToken);
55
+ appCheckPreMinted = true;
56
+ } catch (err) {
57
+ // Refresh token revoked, network blip, or App Check function
58
+ // error: don't block startup. The Auth SDK's :lookup will still
59
+ // fail under enforcement (and the SDK will null-out currentUser),
60
+ // and the caller's browser-auth path will take over. The
61
+ // persistence freeze guard keeps the on-disk file intact.
62
+ console.log(`Pre-mint App Check failed (falling back to browser): ${err.message}`);
63
+ }
64
+ }
65
+
66
+ // 3. Now initializeAuth. App Check (if it was pre-minted) is already
67
+ // registered on the app, so the SDK's :lookup carries the header.
68
+ // makeFilePersistence returns a CLASS (Firebase's persistence
69
+ // contract is `new Persistence()`), and the unfreeze method is a
70
+ // static on that class — we call it as PersistenceClass.unfreeze().
71
+ const PersistenceClass = makeFilePersistence(authFilePath);
72
+ const auth = initializeAuth(app, { persistence: PersistenceClass });
73
+
74
+ // 4. Wait for the SDK to finish its startup validation. If we
75
+ // pre-minted successfully and the refresh token is still valid,
76
+ // auth.currentUser will be the restored user.
77
+ await auth.authStateReady();
78
+
79
+ // 5. Unfreeze persistence so post-init _remove calls (e.g., an
80
+ // explicit sign-out by the user) actually clear the file.
81
+ PersistenceClass.unfreeze();
82
+
83
+ return { auth, persistence: PersistenceClass, appCheckPreMinted };
84
+ }
@@ -65,6 +65,13 @@ const EXT_TO_MIME = {
65
65
  htm: 'text/html',
66
66
  css: 'text/css',
67
67
  js: 'text/javascript',
68
+ // Obsidian Bases (.base) files are YAML. Mapping to text/yaml here
69
+ // (in addition to the web copy) ensures the daemon uploads .base as
70
+ // a UTF-8 text blob with the right Content-Type so the web client's
71
+ // eager-load path picks up entity.content for the Bases renderer.
72
+ base: 'text/yaml',
73
+ yaml: 'text/yaml',
74
+ yml: 'text/yaml',
68
75
  json: 'application/json',
69
76
  pdf: 'application/pdf',
70
77
  png: 'image/png',
package/sync-state.js ADDED
@@ -0,0 +1,32 @@
1
+ // Persisted sync-state store (DOU-154).
2
+ //
3
+ // Records, per file (keyed by `scope:docPath`), the content hash as of
4
+ // the last successful sync. The daemon loads this on start so it can
5
+ // decide pull/push direction by content even across restarts (the
6
+ // in-memory baseline alone is empty on a cold start, which forced an
7
+ // unreliable mtime fallback). Small JSON file; best-effort writes (a
8
+ // failed write must never take down sync).
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ // Load the baseline map ({ trackingKey: contentHash }). Missing or
14
+ // corrupt file reads as {} so a first run / damaged state can't crash.
15
+ export function loadSyncState(filePath) {
16
+ try {
17
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
18
+ return parsed && typeof parsed === 'object' ? parsed : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ export function saveSyncState(filePath, state) {
25
+ try {
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
+ fs.writeFileSync(filePath, JSON.stringify(state));
28
+ } catch {
29
+ // Best-effort: losing the baseline only costs us a cold-start
30
+ // reconciliation next launch, never data.
31
+ }
32
+ }