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.
@@ -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,9 +1,9 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
- "license": "AGPL-3.0",
6
+ "license": "UNLICENSED",
7
7
  "author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
8
8
  "repository": {
9
9
  "type": "git",
@@ -21,12 +21,13 @@
21
21
  },
22
22
  "files": [
23
23
  "src/",
24
- "LICENSE",
24
+ "firestore.rules",
25
25
  "README.md"
26
26
  ],
27
27
  "scripts": {
28
28
  "start": "node src/cli.js start",
29
- "pair": "node src/cli.js pair"
29
+ "pair": "node src/cli.js pair",
30
+ "prepublishOnly": "cp ../firestore.rules ./firestore.rules"
30
31
  },
31
32
  "keywords": [
32
33
  "claude",
package/src/cli.js CHANGED
@@ -3,10 +3,9 @@
3
3
  // Forge Remote Relay — Desktop Agent for Forge Remote
4
4
  // Copyright (c) 2025-2026 Iron Forge Apps
5
5
  // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
6
- // AGPL-3.0 License — See LICENSE
7
6
 
8
7
  import { program } from "commander";
9
- import { execSync } from "child_process";
8
+ import { execFileSync } from "child_process";
10
9
  import { readFileSync, existsSync, writeFileSync } from "fs";
11
10
  import { join } from "path";
12
11
  import { hostname, homedir } from "os";
@@ -67,10 +66,14 @@ function loadSdkConfig() {
67
66
  return null;
68
67
  }
69
68
 
69
+ // Validate project ID before using it in any command.
70
+ if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) return null;
71
+
70
72
  // 3. Fetch via Firebase CLI.
71
73
  try {
72
- const output = execSync(
73
- `firebase apps:sdkconfig web --project ${projectId}`,
74
+ const output = execFileSync(
75
+ "firebase",
76
+ ["apps:sdkconfig", "web", "--project", projectId],
74
77
  { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
75
78
  );
76
79
 
@@ -110,7 +113,7 @@ function loadSdkConfig() {
110
113
  if (!config.apiKey || !config.projectId) return null;
111
114
 
112
115
  // Cache for next time.
113
- writeFileSync(cachedPath, JSON.stringify(config, null, 2));
116
+ writeFileSync(cachedPath, JSON.stringify(config, null, 2), { mode: 0o600 });
114
117
  return config;
115
118
  } catch {
116
119
  return null;
@@ -0,0 +1,163 @@
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 { execSync } from "child_process";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ chmodSync,
10
+ createWriteStream,
11
+ unlinkSync,
12
+ } from "fs";
13
+ import { join } from "path";
14
+ import { homedir, platform, arch } from "os";
15
+ import { get as httpsGet } from "https";
16
+
17
+ const BASE_URL =
18
+ "https://github.com/cloudflare/cloudflared/releases/latest/download";
19
+
20
+ const PLATFORM_URLS = {
21
+ "darwin-arm64": `${BASE_URL}/cloudflared-darwin-arm64.tgz`,
22
+ "darwin-x64": `${BASE_URL}/cloudflared-darwin-amd64.tgz`,
23
+ "linux-x64": `${BASE_URL}/cloudflared-linux-amd64`,
24
+ "linux-arm64": `${BASE_URL}/cloudflared-linux-arm64`,
25
+ "win32-x64": `${BASE_URL}/cloudflared-windows-amd64.exe`,
26
+ };
27
+
28
+ const BIN_DIR = join(homedir(), ".forge-remote", "bin");
29
+
30
+ function getBinaryName() {
31
+ return platform() === "win32" ? "cloudflared.exe" : "cloudflared";
32
+ }
33
+
34
+ /**
35
+ * Return the absolute path to the local cloudflared binary, or null
36
+ * if it hasn't been installed yet.
37
+ */
38
+ export function getLocalCloudflaredPath() {
39
+ const binPath = join(BIN_DIR, getBinaryName());
40
+ return existsSync(binPath) ? binPath : null;
41
+ }
42
+
43
+ /**
44
+ * Check if cloudflared is functional by running `cloudflared --version`.
45
+ */
46
+ export function isCloudflaredWorking(binaryPath) {
47
+ try {
48
+ execSync(`"${binaryPath}" --version`, {
49
+ stdio: "pipe",
50
+ timeout: 10_000,
51
+ });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Download a file from a URL, following up to 5 redirects.
60
+ * Returns a promise that resolves to the destination file path.
61
+ */
62
+ function downloadFile(url, destPath, redirectsLeft = 5) {
63
+ return new Promise((resolve, reject) => {
64
+ if (redirectsLeft <= 0) {
65
+ return reject(new Error("Too many redirects"));
66
+ }
67
+
68
+ httpsGet(url, (res) => {
69
+ // Follow redirects (GitHub releases use 301/302).
70
+ if (
71
+ (res.statusCode === 301 || res.statusCode === 302) &&
72
+ res.headers.location
73
+ ) {
74
+ res.resume(); // Drain the response.
75
+ return resolve(
76
+ downloadFile(res.headers.location, destPath, redirectsLeft - 1),
77
+ );
78
+ }
79
+
80
+ if (res.statusCode !== 200) {
81
+ res.resume();
82
+ return reject(
83
+ new Error(`Download failed: HTTP ${res.statusCode} from ${url}`),
84
+ );
85
+ }
86
+
87
+ const file = createWriteStream(destPath);
88
+ res.pipe(file);
89
+ file.on("finish", () => {
90
+ file.close(() => resolve(destPath));
91
+ });
92
+ file.on("error", (err) => {
93
+ unlinkSync(destPath);
94
+ reject(err);
95
+ });
96
+ }).on("error", reject);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Download the cloudflared binary for the current platform to
102
+ * ~/.forge-remote/bin/cloudflared. No-op if already present and functional.
103
+ *
104
+ * @returns {Promise<string>} Absolute path to the cloudflared binary.
105
+ * @throws {Error} If the platform is unsupported or download fails.
106
+ */
107
+ export async function installCloudflared() {
108
+ const binPath = join(BIN_DIR, getBinaryName());
109
+
110
+ // Skip if already installed and working.
111
+ if (existsSync(binPath) && isCloudflaredWorking(binPath)) {
112
+ return binPath;
113
+ }
114
+
115
+ const key = `${platform()}-${arch()}`;
116
+ const url = PLATFORM_URLS[key];
117
+ if (!url) {
118
+ throw new Error(
119
+ `Unsupported platform: ${key}. ` +
120
+ `Install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`,
121
+ );
122
+ }
123
+
124
+ // Ensure bin directory exists.
125
+ mkdirSync(BIN_DIR, { recursive: true });
126
+
127
+ const isTarball = url.endsWith(".tgz");
128
+
129
+ if (isTarball) {
130
+ // macOS: download tarball, extract the binary.
131
+ const tarPath = join(BIN_DIR, "cloudflared.tgz");
132
+ await downloadFile(url, tarPath);
133
+
134
+ try {
135
+ execSync(`tar xzf "${tarPath}" -C "${BIN_DIR}"`, { stdio: "pipe" });
136
+ } finally {
137
+ // Clean up tarball.
138
+ try {
139
+ unlinkSync(tarPath);
140
+ } catch {
141
+ // Ignore cleanup failure.
142
+ }
143
+ }
144
+ } else {
145
+ // Linux/Windows: download binary directly.
146
+ await downloadFile(url, binPath);
147
+ }
148
+
149
+ // Make executable (not needed on Windows).
150
+ if (platform() !== "win32") {
151
+ chmodSync(binPath, 0o755);
152
+ }
153
+
154
+ // Verify it works.
155
+ if (!isCloudflaredWorking(binPath)) {
156
+ throw new Error(
157
+ "cloudflared was downloaded but failed verification. " +
158
+ "Try installing manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
159
+ );
160
+ }
161
+
162
+ return binPath;
163
+ }
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, "-");