forge-remote 0.1.1 → 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.
@@ -0,0 +1,100 @@
1
+ rules_version = '2';
2
+
3
+ service cloud.firestore {
4
+ match /databases/{database}/documents {
5
+
6
+ // Helper: request is from an authenticated user (including anonymous).
7
+ // In BYOF mode, the user owns their own Firebase project, so anonymous
8
+ // auth IS the auth method — there is no "public" access.
9
+ function isSignedIn() {
10
+ return request.auth != null;
11
+ }
12
+
13
+ // Helper: request is from the document owner.
14
+ function isOwner(uid) {
15
+ return request.auth != null && request.auth.uid == uid;
16
+ }
17
+
18
+ // Helper: enforce reasonable document size.
19
+ function isValidSize() {
20
+ return request.resource.data.keys().size() < 50;
21
+ }
22
+
23
+ // ---- Desktops ----
24
+ match /desktops/{desktopId} {
25
+ allow read: if isSignedIn();
26
+ allow create: if isSignedIn()
27
+ && request.resource.data.keys().hasAll(['ownerUid', 'hostname', 'platform']);
28
+
29
+ // BYOF: user owns the entire Firebase project, so any authenticated
30
+ // user can update or delete desktops. No multi-user ownership needed.
31
+ allow update: if isSignedIn() && isValidSize();
32
+ allow delete: if isSignedIn();
33
+
34
+ // ---- Desktop commands (subcollection) ----
35
+ match /commands/{commandId} {
36
+ allow read: if isSignedIn();
37
+ allow create: if isSignedIn();
38
+ allow update: if isSignedIn();
39
+ }
40
+ }
41
+
42
+ // ---- Sessions ----
43
+ match /sessions/{sessionId} {
44
+ allow read: if isSignedIn();
45
+ allow create: if isSignedIn()
46
+ && request.resource.data.keys().hasAll(['ownerUid', 'desktopId', 'status']);
47
+ allow update: if isSignedIn()
48
+ && isValidSize();
49
+
50
+ // ---- Messages (subcollection) ----
51
+ match /messages/{messageId} {
52
+ allow read: if isSignedIn();
53
+ allow create: if isSignedIn()
54
+ && request.resource.data.size() < 500000; // 500KB max per message
55
+ }
56
+
57
+ // ---- Commands (subcollection) ----
58
+ match /commands/{commandId} {
59
+ allow read: if isSignedIn();
60
+ allow create: if isSignedIn();
61
+ allow update: if isSignedIn();
62
+ }
63
+
64
+ // ---- Permissions (subcollection) ----
65
+ match /permissions/{permId} {
66
+ allow read: if isSignedIn();
67
+ allow update: if isSignedIn();
68
+ }
69
+
70
+ // ---- Tool calls (subcollection) ----
71
+ match /toolCalls/{toolCallId} {
72
+ allow read: if isSignedIn();
73
+ }
74
+ }
75
+
76
+ // ---- Pairing tokens ----
77
+ match /pairingTokens/{tokenId} {
78
+ allow read: if isSignedIn();
79
+ allow update: if isSignedIn()
80
+ && !resource.data.used
81
+ && request.resource.data.used == true
82
+ && request.resource.data.keys().hasAll(['used', 'claimedBy'])
83
+ && request.resource.data.claimedBy == request.auth.uid;
84
+ }
85
+
86
+ // ---- User profiles (for preferences) ----
87
+ match /users/{userId} {
88
+ allow read, write: if request.auth != null && request.auth.uid == userId;
89
+
90
+ match /fcmTokens/{tokenId} {
91
+ allow read, write: if request.auth != null && request.auth.uid == userId;
92
+ }
93
+ }
94
+
95
+ // Deny everything else by default.
96
+ match /{document=**} {
97
+ allow read, write: if false;
98
+ }
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -21,12 +21,14 @@
21
21
  },
22
22
  "files": [
23
23
  "src/",
24
+ "firestore.rules",
24
25
  "LICENSE",
25
26
  "README.md"
26
27
  ],
27
28
  "scripts": {
28
29
  "start": "node src/cli.js start",
29
- "pair": "node src/cli.js pair"
30
+ "pair": "node src/cli.js pair",
31
+ "prepublishOnly": "cp ../firestore.rules ./firestore.rules"
30
32
  },
31
33
  "keywords": [
32
34
  "claude",
@@ -0,0 +1,164 @@
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 { execSync } from "child_process";
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ chmodSync,
11
+ createWriteStream,
12
+ unlinkSync,
13
+ } from "fs";
14
+ import { join } from "path";
15
+ import { homedir, platform, arch } from "os";
16
+ import { get as httpsGet } from "https";
17
+
18
+ const BASE_URL =
19
+ "https://github.com/cloudflare/cloudflared/releases/latest/download";
20
+
21
+ const PLATFORM_URLS = {
22
+ "darwin-arm64": `${BASE_URL}/cloudflared-darwin-arm64.tgz`,
23
+ "darwin-x64": `${BASE_URL}/cloudflared-darwin-amd64.tgz`,
24
+ "linux-x64": `${BASE_URL}/cloudflared-linux-amd64`,
25
+ "linux-arm64": `${BASE_URL}/cloudflared-linux-arm64`,
26
+ "win32-x64": `${BASE_URL}/cloudflared-windows-amd64.exe`,
27
+ };
28
+
29
+ const BIN_DIR = join(homedir(), ".forge-remote", "bin");
30
+
31
+ function getBinaryName() {
32
+ return platform() === "win32" ? "cloudflared.exe" : "cloudflared";
33
+ }
34
+
35
+ /**
36
+ * Return the absolute path to the local cloudflared binary, or null
37
+ * if it hasn't been installed yet.
38
+ */
39
+ export function getLocalCloudflaredPath() {
40
+ const binPath = join(BIN_DIR, getBinaryName());
41
+ return existsSync(binPath) ? binPath : null;
42
+ }
43
+
44
+ /**
45
+ * Check if cloudflared is functional by running `cloudflared --version`.
46
+ */
47
+ export function isCloudflaredWorking(binaryPath) {
48
+ try {
49
+ execSync(`"${binaryPath}" --version`, {
50
+ stdio: "pipe",
51
+ timeout: 10_000,
52
+ });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Download a file from a URL, following up to 5 redirects.
61
+ * Returns a promise that resolves to the destination file path.
62
+ */
63
+ function downloadFile(url, destPath, redirectsLeft = 5) {
64
+ return new Promise((resolve, reject) => {
65
+ if (redirectsLeft <= 0) {
66
+ return reject(new Error("Too many redirects"));
67
+ }
68
+
69
+ httpsGet(url, (res) => {
70
+ // Follow redirects (GitHub releases use 301/302).
71
+ if (
72
+ (res.statusCode === 301 || res.statusCode === 302) &&
73
+ res.headers.location
74
+ ) {
75
+ res.resume(); // Drain the response.
76
+ return resolve(
77
+ downloadFile(res.headers.location, destPath, redirectsLeft - 1),
78
+ );
79
+ }
80
+
81
+ if (res.statusCode !== 200) {
82
+ res.resume();
83
+ return reject(
84
+ new Error(`Download failed: HTTP ${res.statusCode} from ${url}`),
85
+ );
86
+ }
87
+
88
+ const file = createWriteStream(destPath);
89
+ res.pipe(file);
90
+ file.on("finish", () => {
91
+ file.close(() => resolve(destPath));
92
+ });
93
+ file.on("error", (err) => {
94
+ unlinkSync(destPath);
95
+ reject(err);
96
+ });
97
+ }).on("error", reject);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Download the cloudflared binary for the current platform to
103
+ * ~/.forge-remote/bin/cloudflared. No-op if already present and functional.
104
+ *
105
+ * @returns {Promise<string>} Absolute path to the cloudflared binary.
106
+ * @throws {Error} If the platform is unsupported or download fails.
107
+ */
108
+ export async function installCloudflared() {
109
+ const binPath = join(BIN_DIR, getBinaryName());
110
+
111
+ // Skip if already installed and working.
112
+ if (existsSync(binPath) && isCloudflaredWorking(binPath)) {
113
+ return binPath;
114
+ }
115
+
116
+ const key = `${platform()}-${arch()}`;
117
+ const url = PLATFORM_URLS[key];
118
+ if (!url) {
119
+ throw new Error(
120
+ `Unsupported platform: ${key}. ` +
121
+ `Install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`,
122
+ );
123
+ }
124
+
125
+ // Ensure bin directory exists.
126
+ mkdirSync(BIN_DIR, { recursive: true });
127
+
128
+ const isTarball = url.endsWith(".tgz");
129
+
130
+ if (isTarball) {
131
+ // macOS: download tarball, extract the binary.
132
+ const tarPath = join(BIN_DIR, "cloudflared.tgz");
133
+ await downloadFile(url, tarPath);
134
+
135
+ try {
136
+ execSync(`tar xzf "${tarPath}" -C "${BIN_DIR}"`, { stdio: "pipe" });
137
+ } finally {
138
+ // Clean up tarball.
139
+ try {
140
+ unlinkSync(tarPath);
141
+ } catch {
142
+ // Ignore cleanup failure.
143
+ }
144
+ }
145
+ } else {
146
+ // Linux/Windows: download binary directly.
147
+ await downloadFile(url, binPath);
148
+ }
149
+
150
+ // Make executable (not needed on Windows).
151
+ if (platform() !== "win32") {
152
+ chmodSync(binPath, 0o755);
153
+ }
154
+
155
+ // Verify it works.
156
+ if (!isCloudflaredWorking(binPath)) {
157
+ throw new Error(
158
+ "cloudflared was downloaded but failed verification. " +
159
+ "Try installing manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
160
+ );
161
+ }
162
+
163
+ return binPath;
164
+ }
package/src/desktop.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { getDb, FieldValue, Timestamp } from "./firebase.js";
2
- import { hostname, platform } from "os";
2
+ import { hostname, platform, homedir } from "os";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { join } from "path";
3
5
  import { v4 as uuidv4 } from "uuid";
4
6
  import * as log from "./logger.js";
5
7
 
@@ -102,11 +104,22 @@ export async function createPairingToken(desktopId) {
102
104
  }
103
105
 
104
106
  /**
105
- * Get a stable desktop ID based on hostname.
106
- * In production you'd store this in a config file.
107
+ * Get a stable desktop ID.
108
+ * Reads from ~/.forge-remote/config.json (written by `init`) so the ID
109
+ * stays consistent even when macOS changes the hostname across networks.
110
+ * Falls back to hostname-based ID if config doesn't exist yet.
107
111
  */
108
112
  export function getDesktopId() {
109
- // Use a consistent ID so the same machine always maps to the same doc.
113
+ const configPath = join(homedir(), ".forge-remote", "config.json");
114
+ if (existsSync(configPath)) {
115
+ try {
116
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
117
+ if (config.desktopId) return config.desktopId;
118
+ } catch {
119
+ // Fall through to hostname-based.
120
+ }
121
+ }
122
+
110
123
  const name = hostname()
111
124
  .toLowerCase()
112
125
  .replace(/[^a-z0-9]/g, "-");
@@ -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
+ }