forge-remote 0.1.1 → 0.1.3
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/firestore.rules +100 -0
- package/package.json +5 -4
- package/src/cli.js +8 -5
- package/src/cloudflared-installer.js +163 -0
- package/src/desktop.js +17 -4
- package/src/google-auth.js +436 -0
- package/src/init.js +527 -292
- package/src/project-scanner.js +116 -50
- package/src/session-manager.js +379 -27
- package/src/tunnel-manager.js +226 -33
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir, platform } from "os";
|
|
8
|
+
|
|
9
|
+
// ─── Firebase CLI OAuth credentials ─────────────────────────────────────────
|
|
10
|
+
// These are the same public client credentials embedded in the Firebase CLI.
|
|
11
|
+
// Using them to exchange the stored refresh token for an access token is
|
|
12
|
+
// equivalent to what the Firebase CLI does internally.
|
|
13
|
+
|
|
14
|
+
const FIREBASE_CLIENT_ID =
|
|
15
|
+
"563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com";
|
|
16
|
+
const FIREBASE_CLIENT_SECRET = "j9iVZfS8kkCEFUPaAeJV0sAi";
|
|
17
|
+
|
|
18
|
+
// ─── Token Management ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the path to the Firebase CLI's stored credentials file.
|
|
22
|
+
*/
|
|
23
|
+
function getFirebaseConfigPath() {
|
|
24
|
+
if (platform() === "win32") {
|
|
25
|
+
const appData =
|
|
26
|
+
process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
27
|
+
return join(appData, "configstore", "firebase-tools.json");
|
|
28
|
+
}
|
|
29
|
+
return join(homedir(), ".config", "configstore", "firebase-tools.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the refresh token from the Firebase CLI's stored credentials.
|
|
34
|
+
* Returns null if no token is found.
|
|
35
|
+
*/
|
|
36
|
+
export function readRefreshToken() {
|
|
37
|
+
const configPath = getFirebaseConfigPath();
|
|
38
|
+
if (!existsSync(configPath)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
43
|
+
return config?.tokens?.refresh_token || null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exchange the Firebase CLI's refresh token for a fresh access token.
|
|
51
|
+
* Throws if the token exchange fails.
|
|
52
|
+
*/
|
|
53
|
+
export async function getAccessToken() {
|
|
54
|
+
const refreshToken = readRefreshToken();
|
|
55
|
+
if (!refreshToken) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"No Firebase CLI refresh token found. Run `firebase login` first.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
64
|
+
body: new URLSearchParams({
|
|
65
|
+
client_id: FIREBASE_CLIENT_ID,
|
|
66
|
+
client_secret: FIREBASE_CLIENT_SECRET,
|
|
67
|
+
refresh_token: refreshToken,
|
|
68
|
+
grant_type: "refresh_token",
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const err = await response.text();
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Token exchange failed (run \`firebase login\` again): ${err}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
return data.access_token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Anonymous Authentication ───────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Enable Anonymous Authentication for the given Firebase project.
|
|
87
|
+
* Uses the Identity Toolkit Admin v2 API.
|
|
88
|
+
*
|
|
89
|
+
* If the project hasn't had Auth initialized yet (CONFIGURATION_NOT_FOUND),
|
|
90
|
+
* we first initialize the Identity Platform, then enable Anonymous.
|
|
91
|
+
*/
|
|
92
|
+
export async function enableAnonymousAuth(projectId) {
|
|
93
|
+
const token = await getAccessToken();
|
|
94
|
+
|
|
95
|
+
const patchAuth = async () => {
|
|
96
|
+
return fetch(
|
|
97
|
+
`https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config?updateMask=signIn.anonymous.enabled`,
|
|
98
|
+
{
|
|
99
|
+
method: "PATCH",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"X-Goog-User-Project": projectId,
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
signIn: {
|
|
107
|
+
anonymous: {
|
|
108
|
+
enabled: true,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let response = await patchAuth();
|
|
117
|
+
|
|
118
|
+
// If config doesn't exist yet, initialize Identity Platform first.
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const err = await response.json().catch(() => ({}));
|
|
121
|
+
const errMsg = err.error?.message || "";
|
|
122
|
+
|
|
123
|
+
if (errMsg.includes("CONFIGURATION_NOT_FOUND")) {
|
|
124
|
+
// Initialize Identity Platform for this project.
|
|
125
|
+
const initResp = await fetch(
|
|
126
|
+
`https://identitytoolkit.googleapis.com/v2/projects/${projectId}/identityPlatform:initializeAuth`,
|
|
127
|
+
{
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${token}`,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
"X-Goog-User-Project": projectId,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!initResp.ok) {
|
|
138
|
+
const initErr = await initResp.json().catch(() => ({}));
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Failed to initialize Identity Platform: ${initErr.error?.message || initResp.statusText}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Retry enabling Anonymous Auth after initialization.
|
|
145
|
+
response = await patchAuth();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const err = await response.json().catch(() => ({}));
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Failed to enable Anonymous Auth: ${err.error?.message || response.statusText}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── API Key Management ─────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove browser-only restrictions from the project's API key.
|
|
163
|
+
*
|
|
164
|
+
* Firebase creates a "Browser key" with browserKeyRestrictions, which blocks
|
|
165
|
+
* requests from mobile apps (they don't send HTTP referrers). We keep the
|
|
166
|
+
* API target restrictions but remove the platform restriction so the key
|
|
167
|
+
* works from iOS and Android.
|
|
168
|
+
*/
|
|
169
|
+
export async function removeBrowserKeyRestriction(projectId, projectNumber) {
|
|
170
|
+
const token = await getAccessToken();
|
|
171
|
+
|
|
172
|
+
// List all keys for the project.
|
|
173
|
+
const listResp = await fetch(
|
|
174
|
+
`https://apikeys.googleapis.com/v2/projects/${projectNumber}/locations/global/keys`,
|
|
175
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!listResp.ok) {
|
|
179
|
+
throw new Error("Failed to list API keys.");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const listData = await listResp.json();
|
|
183
|
+
const keys = listData.keys || [];
|
|
184
|
+
|
|
185
|
+
for (const key of keys) {
|
|
186
|
+
if (key.restrictions?.browserKeyRestrictions) {
|
|
187
|
+
// Remove browser restriction but keep API target restrictions.
|
|
188
|
+
const updateResp = await fetch(
|
|
189
|
+
`https://apikeys.googleapis.com/v2/${key.name}?updateMask=restrictions`,
|
|
190
|
+
{
|
|
191
|
+
method: "PATCH",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${token}`,
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
restrictions: {
|
|
198
|
+
apiTargets: key.restrictions.apiTargets || [],
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (!updateResp.ok) {
|
|
205
|
+
const err = await updateResp.json().catch(() => ({}));
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Failed to update API key: ${err.error?.message || updateResp.statusText}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return false; // No browser-restricted keys found.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Google Cloud API Enablement ─────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Enable a Google Cloud API for the given project.
|
|
221
|
+
* Uses the Service Usage API. Idempotent — safe to call even if already enabled.
|
|
222
|
+
* Waits for the operation to complete (up to ~60s).
|
|
223
|
+
*/
|
|
224
|
+
export async function enableApi(projectId, apiName) {
|
|
225
|
+
const token = await getAccessToken();
|
|
226
|
+
|
|
227
|
+
const response = await fetch(
|
|
228
|
+
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services/${apiName}:enable`,
|
|
229
|
+
{
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `Bearer ${token}`,
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
"X-Goog-User-Project": projectId,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
const err = await response.json().catch(() => ({}));
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Failed to enable ${apiName}: ${err.error?.message || response.statusText}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The response is a long-running operation. Poll until done.
|
|
247
|
+
const op = await response.json();
|
|
248
|
+
if (op.done) return true;
|
|
249
|
+
|
|
250
|
+
const opName = op.name;
|
|
251
|
+
for (let i = 0; i < 12; i++) {
|
|
252
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
253
|
+
const pollResp = await fetch(
|
|
254
|
+
`https://serviceusage.googleapis.com/v1/${opName}`,
|
|
255
|
+
{
|
|
256
|
+
headers: {
|
|
257
|
+
Authorization: `Bearer ${token}`,
|
|
258
|
+
"X-Goog-User-Project": projectId,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
if (pollResp.ok) {
|
|
263
|
+
const pollData = await pollResp.json();
|
|
264
|
+
if (pollData.done) return true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw new Error(`Timed out waiting for ${apiName} to be enabled.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Service Account Management ─────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Find the firebase-adminsdk service account for the given project.
|
|
275
|
+
* Returns the account object (with `.email`) or null if not found.
|
|
276
|
+
*/
|
|
277
|
+
export async function findFirebaseAdminSdkAccount(projectId) {
|
|
278
|
+
const token = await getAccessToken();
|
|
279
|
+
|
|
280
|
+
const response = await fetch(
|
|
281
|
+
`https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts?pageSize=100`,
|
|
282
|
+
{
|
|
283
|
+
headers: {
|
|
284
|
+
Authorization: `Bearer ${token}`,
|
|
285
|
+
"X-Goog-User-Project": projectId,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
const err = await response.json().catch(() => ({}));
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Failed to list service accounts: ${err.error?.message || response.statusText}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const data = await response.json();
|
|
298
|
+
const accounts = data.accounts || [];
|
|
299
|
+
return (
|
|
300
|
+
accounts.find((a) => a.email && a.email.includes("firebase-adminsdk")) ||
|
|
301
|
+
null
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Create a new private key for the given service account.
|
|
307
|
+
* Returns the parsed JSON key file content.
|
|
308
|
+
*/
|
|
309
|
+
export async function createServiceAccountKey(projectId, serviceAccountEmail) {
|
|
310
|
+
const token = await getAccessToken();
|
|
311
|
+
|
|
312
|
+
const response = await fetch(
|
|
313
|
+
`https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/${serviceAccountEmail}/keys`,
|
|
314
|
+
{
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Bearer ${token}`,
|
|
318
|
+
"Content-Type": "application/json",
|
|
319
|
+
"X-Goog-User-Project": projectId,
|
|
320
|
+
},
|
|
321
|
+
body: JSON.stringify({
|
|
322
|
+
privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
|
|
323
|
+
keyAlgorithm: "KEY_ALG_RSA_2048",
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const err = await response.json().catch(() => ({}));
|
|
330
|
+
const msg = err.error?.message || response.statusText;
|
|
331
|
+
if (msg.includes("maximum number of keys")) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Service account has reached the 10-key limit. Delete old keys at:\n` +
|
|
334
|
+
` https://console.cloud.google.com/iam-admin/serviceaccounts/details/${encodeURIComponent(serviceAccountEmail)}/keys?project=${projectId}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`Failed to create service account key: ${msg}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const data = await response.json();
|
|
341
|
+
const keyJson = Buffer.from(data.privateKeyData, "base64").toString("utf-8");
|
|
342
|
+
return JSON.parse(keyJson);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Firestore Security Rules ───────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Deploy Firestore security rules via the Firebase Rules REST API.
|
|
349
|
+
*
|
|
350
|
+
* Three-step process:
|
|
351
|
+
* 1. Create a new ruleset containing the rules source.
|
|
352
|
+
* 2. Update (or create) the `cloud.firestore` release for the (default) database.
|
|
353
|
+
* 3. Also deploy to `cloud.firestore/database/default` for the named "default"
|
|
354
|
+
* database (firebase CLI creates a named db, not the (default) alias).
|
|
355
|
+
*/
|
|
356
|
+
export async function deployFirestoreRules(projectId, rulesContent) {
|
|
357
|
+
const token = await getAccessToken();
|
|
358
|
+
const headers = {
|
|
359
|
+
Authorization: `Bearer ${token}`,
|
|
360
|
+
"Content-Type": "application/json",
|
|
361
|
+
"X-Goog-User-Project": projectId,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Step 1: Create ruleset
|
|
365
|
+
const rulesetResponse = await fetch(
|
|
366
|
+
`https://firebaserules.googleapis.com/v1/projects/${projectId}/rulesets`,
|
|
367
|
+
{
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers,
|
|
370
|
+
body: JSON.stringify({
|
|
371
|
+
source: {
|
|
372
|
+
files: [
|
|
373
|
+
{
|
|
374
|
+
content: rulesContent,
|
|
375
|
+
name: "firestore.rules",
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (!rulesetResponse.ok) {
|
|
384
|
+
const err = await rulesetResponse.json().catch(() => ({}));
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Failed to create ruleset: ${err.error?.message || rulesetResponse.statusText}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const ruleset = await rulesetResponse.json();
|
|
391
|
+
const rulesetName = ruleset.name;
|
|
392
|
+
|
|
393
|
+
// Step 2: Deploy the ruleset as the release for the (default) database.
|
|
394
|
+
const releaseNames = [`projects/${projectId}/releases/cloud.firestore`];
|
|
395
|
+
|
|
396
|
+
for (const releaseName of releaseNames) {
|
|
397
|
+
// Try creating the release first (works for new projects).
|
|
398
|
+
let releaseResponse = await fetch(
|
|
399
|
+
`https://firebaserules.googleapis.com/v1/projects/${projectId}/releases`,
|
|
400
|
+
{
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers,
|
|
403
|
+
body: JSON.stringify({
|
|
404
|
+
name: releaseName,
|
|
405
|
+
rulesetName: rulesetName,
|
|
406
|
+
}),
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
if (!releaseResponse.ok && releaseResponse.status === 409) {
|
|
411
|
+
// Release already exists — update it via PATCH (requires wrapper + updateMask).
|
|
412
|
+
releaseResponse = await fetch(
|
|
413
|
+
`https://firebaserules.googleapis.com/v1/${releaseName}?updateMask=rulesetName`,
|
|
414
|
+
{
|
|
415
|
+
method: "PATCH",
|
|
416
|
+
headers,
|
|
417
|
+
body: JSON.stringify({
|
|
418
|
+
release: {
|
|
419
|
+
name: releaseName,
|
|
420
|
+
rulesetName: rulesetName,
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!releaseResponse.ok) {
|
|
428
|
+
const err = await releaseResponse.json().catch(() => ({}));
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Failed to deploy rules release: ${err.error?.message || releaseResponse.statusText}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return true;
|
|
436
|
+
}
|