@doubling/compound-sync 1.7.0 → 1.9.5
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/auth-persistence.js +34 -2
- package/files.js +21 -4
- package/org-sync.js +607 -0
- package/package.json +7 -3
- package/pre-mint-app-check.js +84 -0
- package/setup-auth-with-pre-mint.js +84 -0
- package/sync-state.js +32 -0
- package/sync.js +134 -455
|
@@ -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
|
+
}
|
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
|
+
}
|