forge-remote 0.1.0 → 0.1.2
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 +4 -2
- package/src/cli.js +33 -14
- package/src/cloudflared-installer.js +164 -0
- package/src/desktop.js +17 -4
- package/src/google-auth.js +334 -0
- package/src/init.js +528 -261
- package/src/session-manager.js +116 -14
- package/src/tunnel-manager.js +226 -33
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
// AGPL-3.0 License — See LICENSE
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir, platform } from "os";
|
|
9
|
+
|
|
10
|
+
// ─── Firebase CLI OAuth credentials ─────────────────────────────────────────
|
|
11
|
+
// These are the same public client credentials embedded in the Firebase CLI.
|
|
12
|
+
// Using them to exchange the stored refresh token for an access token is
|
|
13
|
+
// equivalent to what the Firebase CLI does internally.
|
|
14
|
+
|
|
15
|
+
const FIREBASE_CLIENT_ID =
|
|
16
|
+
"563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com";
|
|
17
|
+
const FIREBASE_CLIENT_SECRET = "j9iVZfS8kkCEFUPaAeJV0sAi";
|
|
18
|
+
|
|
19
|
+
// ─── Token Management ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the path to the Firebase CLI's stored credentials file.
|
|
23
|
+
*/
|
|
24
|
+
function getFirebaseConfigPath() {
|
|
25
|
+
if (platform() === "win32") {
|
|
26
|
+
const appData =
|
|
27
|
+
process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
28
|
+
return join(appData, "configstore", "firebase-tools.json");
|
|
29
|
+
}
|
|
30
|
+
return join(homedir(), ".config", "configstore", "firebase-tools.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read the refresh token from the Firebase CLI's stored credentials.
|
|
35
|
+
* Returns null if no token is found.
|
|
36
|
+
*/
|
|
37
|
+
export function readRefreshToken() {
|
|
38
|
+
const configPath = getFirebaseConfigPath();
|
|
39
|
+
if (!existsSync(configPath)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
44
|
+
return config?.tokens?.refresh_token || null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exchange the Firebase CLI's refresh token for a fresh access token.
|
|
52
|
+
* Throws if the token exchange fails.
|
|
53
|
+
*/
|
|
54
|
+
export async function getAccessToken() {
|
|
55
|
+
const refreshToken = readRefreshToken();
|
|
56
|
+
if (!refreshToken) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"No Firebase CLI refresh token found. Run `firebase login` first.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
65
|
+
body: new URLSearchParams({
|
|
66
|
+
client_id: FIREBASE_CLIENT_ID,
|
|
67
|
+
client_secret: FIREBASE_CLIENT_SECRET,
|
|
68
|
+
refresh_token: refreshToken,
|
|
69
|
+
grant_type: "refresh_token",
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const err = await response.text();
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Token exchange failed (run \`firebase login\` again): ${err}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = await response.json();
|
|
81
|
+
return data.access_token;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Anonymous Authentication ───────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enable Anonymous Authentication for the given Firebase project.
|
|
88
|
+
* Uses the Identity Toolkit Admin v2 API.
|
|
89
|
+
*/
|
|
90
|
+
export async function enableAnonymousAuth(projectId) {
|
|
91
|
+
const token = await getAccessToken();
|
|
92
|
+
|
|
93
|
+
const response = await fetch(
|
|
94
|
+
`https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config?updateMask=signIn.anonymous.enabled`,
|
|
95
|
+
{
|
|
96
|
+
method: "PATCH",
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${token}`,
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"X-Goog-User-Project": projectId,
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
signIn: {
|
|
104
|
+
anonymous: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const err = await response.json().catch(() => ({}));
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Failed to enable Anonymous Auth: ${err.error?.message || response.statusText}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Google Cloud API Enablement ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Enable a Google Cloud API for the given project.
|
|
126
|
+
* Uses the Service Usage API. Idempotent — safe to call even if already enabled.
|
|
127
|
+
* Waits for the operation to complete (up to ~60s).
|
|
128
|
+
*/
|
|
129
|
+
export async function enableApi(projectId, apiName) {
|
|
130
|
+
const token = await getAccessToken();
|
|
131
|
+
|
|
132
|
+
const response = await fetch(
|
|
133
|
+
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services/${apiName}:enable`,
|
|
134
|
+
{
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${token}`,
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"X-Goog-User-Project": projectId,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const err = await response.json().catch(() => ({}));
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Failed to enable ${apiName}: ${err.error?.message || response.statusText}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The response is a long-running operation. Poll until done.
|
|
152
|
+
const op = await response.json();
|
|
153
|
+
if (op.done) return true;
|
|
154
|
+
|
|
155
|
+
const opName = op.name;
|
|
156
|
+
for (let i = 0; i < 12; i++) {
|
|
157
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
158
|
+
const pollResp = await fetch(
|
|
159
|
+
`https://serviceusage.googleapis.com/v1/${opName}`,
|
|
160
|
+
{
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${token}`,
|
|
163
|
+
"X-Goog-User-Project": projectId,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
if (pollResp.ok) {
|
|
168
|
+
const pollData = await pollResp.json();
|
|
169
|
+
if (pollData.done) return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`Timed out waiting for ${apiName} to be enabled.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Service Account Management ─────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Find the firebase-adminsdk service account for the given project.
|
|
180
|
+
* Returns the account object (with `.email`) or null if not found.
|
|
181
|
+
*/
|
|
182
|
+
export async function findFirebaseAdminSdkAccount(projectId) {
|
|
183
|
+
const token = await getAccessToken();
|
|
184
|
+
|
|
185
|
+
const response = await fetch(
|
|
186
|
+
`https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts?pageSize=100`,
|
|
187
|
+
{
|
|
188
|
+
headers: {
|
|
189
|
+
Authorization: `Bearer ${token}`,
|
|
190
|
+
"X-Goog-User-Project": projectId,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const err = await response.json().catch(() => ({}));
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Failed to list service accounts: ${err.error?.message || response.statusText}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const data = await response.json();
|
|
203
|
+
const accounts = data.accounts || [];
|
|
204
|
+
return (
|
|
205
|
+
accounts.find((a) => a.email && a.email.includes("firebase-adminsdk")) ||
|
|
206
|
+
null
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create a new private key for the given service account.
|
|
212
|
+
* Returns the parsed JSON key file content.
|
|
213
|
+
*/
|
|
214
|
+
export async function createServiceAccountKey(projectId, serviceAccountEmail) {
|
|
215
|
+
const token = await getAccessToken();
|
|
216
|
+
|
|
217
|
+
const response = await fetch(
|
|
218
|
+
`https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/${serviceAccountEmail}/keys`,
|
|
219
|
+
{
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: {
|
|
222
|
+
Authorization: `Bearer ${token}`,
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
"X-Goog-User-Project": projectId,
|
|
225
|
+
},
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
|
|
228
|
+
keyAlgorithm: "KEY_ALG_RSA_2048",
|
|
229
|
+
}),
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const err = await response.json().catch(() => ({}));
|
|
235
|
+
const msg = err.error?.message || response.statusText;
|
|
236
|
+
if (msg.includes("maximum number of keys")) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Service account has reached the 10-key limit. Delete old keys at:\n` +
|
|
239
|
+
` https://console.cloud.google.com/iam-admin/serviceaccounts/details/${encodeURIComponent(serviceAccountEmail)}/keys?project=${projectId}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
throw new Error(`Failed to create service account key: ${msg}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const data = await response.json();
|
|
246
|
+
const keyJson = Buffer.from(data.privateKeyData, "base64").toString("utf-8");
|
|
247
|
+
return JSON.parse(keyJson);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Firestore Security Rules ───────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Deploy Firestore security rules via the Firebase Rules REST API.
|
|
254
|
+
*
|
|
255
|
+
* Two-step process:
|
|
256
|
+
* 1. Create a new ruleset containing the rules source.
|
|
257
|
+
* 2. Update (or create) the `cloud.firestore` release to point to it.
|
|
258
|
+
*/
|
|
259
|
+
export async function deployFirestoreRules(projectId, rulesContent) {
|
|
260
|
+
const token = await getAccessToken();
|
|
261
|
+
const headers = {
|
|
262
|
+
Authorization: `Bearer ${token}`,
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
"X-Goog-User-Project": projectId,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Step 1: Create ruleset
|
|
268
|
+
const rulesetResponse = await fetch(
|
|
269
|
+
`https://firebaserules.googleapis.com/v1/projects/${projectId}/rulesets`,
|
|
270
|
+
{
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers,
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
source: {
|
|
275
|
+
files: [
|
|
276
|
+
{
|
|
277
|
+
content: rulesContent,
|
|
278
|
+
name: "firestore.rules",
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (!rulesetResponse.ok) {
|
|
287
|
+
const err = await rulesetResponse.json().catch(() => ({}));
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Failed to create ruleset: ${err.error?.message || rulesetResponse.statusText}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ruleset = await rulesetResponse.json();
|
|
294
|
+
const rulesetName = ruleset.name;
|
|
295
|
+
|
|
296
|
+
// Step 2: Update existing release, or create if it doesn't exist yet
|
|
297
|
+
const releaseName = `projects/${projectId}/releases/cloud.firestore`;
|
|
298
|
+
const releaseBody = {
|
|
299
|
+
release: {
|
|
300
|
+
name: releaseName,
|
|
301
|
+
rulesetName: rulesetName,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
let releaseResponse = await fetch(
|
|
306
|
+
`https://firebaserules.googleapis.com/v1/${releaseName}`,
|
|
307
|
+
{
|
|
308
|
+
method: "PATCH",
|
|
309
|
+
headers,
|
|
310
|
+
body: JSON.stringify(releaseBody),
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (!releaseResponse.ok && releaseResponse.status === 404) {
|
|
315
|
+
// Release doesn't exist yet — create it
|
|
316
|
+
releaseResponse = await fetch(
|
|
317
|
+
`https://firebaserules.googleapis.com/v1/projects/${projectId}/releases`,
|
|
318
|
+
{
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers,
|
|
321
|
+
body: JSON.stringify(releaseBody),
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!releaseResponse.ok) {
|
|
327
|
+
const err = await releaseResponse.json().catch(() => ({}));
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Failed to deploy rules release: ${err.error?.message || releaseResponse.statusText}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return true;
|
|
334
|
+
}
|