@rome-os/node 0.1.0
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/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/rome-node.js +2 -0
- package/dist/actions.d.ts +20 -0
- package/dist/actions.js +18 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +143 -0
- package/dist/cloud.d.ts +7 -0
- package/dist/cloud.js +53 -0
- package/dist/connect.d.ts +2 -0
- package/dist/connect.js +114 -0
- package/dist/connector.d.ts +24 -0
- package/dist/connector.js +148 -0
- package/dist/daemon.d.ts +11 -0
- package/dist/daemon.js +208 -0
- package/dist/entry.d.ts +1 -0
- package/dist/entry.js +127 -0
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +121 -0
- package/dist/help.d.ts +1 -0
- package/dist/help.js +156 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/local.d.ts +9 -0
- package/dist/local.js +33 -0
- package/dist/login.d.ts +6 -0
- package/dist/login.js +100 -0
- package/dist/protocol.d.ts +30 -0
- package/dist/protocol.js +40 -0
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +27 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +37 -0
- package/package.json +43 -0
package/dist/local.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { isRecord } from "./actions.js";
|
|
5
|
+
import { cloudOrigin } from "./cloud.js";
|
|
6
|
+
import { readPrivateJson } from "./storage.js";
|
|
7
|
+
export function configRoot() {
|
|
8
|
+
return resolve(process.env.ROME_NODE_CONFIG_DIR ||
|
|
9
|
+
(process.platform === "win32"
|
|
10
|
+
? join(process.env.LOCALAPPDATA || homedir(), "RomeNode")
|
|
11
|
+
: join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "rome-node")));
|
|
12
|
+
}
|
|
13
|
+
export const callerCredentialPath = () => join(configRoot(), "caller.json");
|
|
14
|
+
export const daemonStatePath = () => join(configRoot(), "daemon.json");
|
|
15
|
+
export function daemonPort() {
|
|
16
|
+
const override = process.env.ROME_NODE_DAEMON_PORT;
|
|
17
|
+
if (override !== undefined) {
|
|
18
|
+
const port = Number(override);
|
|
19
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
20
|
+
throw new Error("ROME_NODE_DAEMON_PORT must be a port between 1024 and 65535.");
|
|
21
|
+
return port;
|
|
22
|
+
}
|
|
23
|
+
return 20000 + (createHash("sha256").update(configRoot()).digest().readUInt32BE(0) % 40000);
|
|
24
|
+
}
|
|
25
|
+
export async function readCallerCredential() {
|
|
26
|
+
const value = await readPrivateJson(callerCredentialPath());
|
|
27
|
+
if (!isRecord(value) ||
|
|
28
|
+
typeof value.cloudUrl !== "string" ||
|
|
29
|
+
typeof value.token !== "string" ||
|
|
30
|
+
!/^romedev_[A-Za-z0-9_-]{43}$/.test(value.token))
|
|
31
|
+
throw new Error("Configure a communication token with rome-node auth --cloud <origin> using stdin first.");
|
|
32
|
+
return { cloudUrl: cloudOrigin(value.cloudUrl), token: value.token };
|
|
33
|
+
}
|
package/dist/login.d.ts
ADDED
package/dist/login.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { cloudOrigin } from "./cloud.js";
|
|
5
|
+
import { validId } from "./protocol.js";
|
|
6
|
+
export async function loginDevice(cloudUrl, name, signal) {
|
|
7
|
+
const cloud = new URL(cloudOrigin(cloudUrl));
|
|
8
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
9
|
+
const state = randomBytes(32).toString("base64url");
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
if (signal.aborted) {
|
|
12
|
+
reject(new Error("Authorization canceled."));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let redeeming = false;
|
|
16
|
+
const server = createServer(async (request, response) => {
|
|
17
|
+
const incoming = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
18
|
+
if (request.method !== "GET" ||
|
|
19
|
+
incoming.pathname !== "/callback" ||
|
|
20
|
+
incoming.searchParams.get("state") !== state ||
|
|
21
|
+
incoming.searchParams.get("iss") !== cloud.origin ||
|
|
22
|
+
!incoming.searchParams.get("code") ||
|
|
23
|
+
redeeming) {
|
|
24
|
+
response.writeHead(400).end("Invalid authorization callback");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
redeeming = true;
|
|
28
|
+
try {
|
|
29
|
+
const result = await fetch(`${cloud.origin}/oauth2/token`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
|
|
32
|
+
redirect: "error",
|
|
33
|
+
body: new URLSearchParams({
|
|
34
|
+
grant_type: "authorization_code",
|
|
35
|
+
client_id: "rome-computer",
|
|
36
|
+
redirect_uri: redirectUri,
|
|
37
|
+
code: incoming.searchParams.get("code"),
|
|
38
|
+
code_verifier: verifier,
|
|
39
|
+
platform: platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : "linux",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
if (!result.ok)
|
|
43
|
+
throw new Error("Token exchange rejected");
|
|
44
|
+
const body = (await result.json());
|
|
45
|
+
if (typeof body.access_token !== "string" ||
|
|
46
|
+
!/^romedev_[A-Za-z0-9_-]{43}$/.test(body.access_token) ||
|
|
47
|
+
!validId(body.device_id))
|
|
48
|
+
throw new Error("Invalid token response");
|
|
49
|
+
response
|
|
50
|
+
.writeHead(200, { "content-type": "text/plain", "cache-control": "no-store" })
|
|
51
|
+
.end("Device authorized. Return to Rome Node.");
|
|
52
|
+
resolve({ cloudUrl: cloud.origin, token: body.access_token, deviceId: body.device_id });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
response.writeHead(400).end("Device authorization failed. Run rome-node connect to retry.");
|
|
56
|
+
reject(new Error("Device authorization failed"));
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
signal.removeEventListener("abort", abort);
|
|
61
|
+
server.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
let redirectUri;
|
|
65
|
+
const abort = () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
server.closeAllConnections();
|
|
68
|
+
server.close();
|
|
69
|
+
reject(new Error("Authorization canceled."));
|
|
70
|
+
};
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
server.close();
|
|
73
|
+
signal.removeEventListener("abort", abort);
|
|
74
|
+
reject(new Error("Login timed out"));
|
|
75
|
+
}, 5 * 60_000);
|
|
76
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
77
|
+
server.on("error", () => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
signal.removeEventListener("abort", abort);
|
|
80
|
+
reject(new Error("Loopback listener failed"));
|
|
81
|
+
});
|
|
82
|
+
server.listen(0, "127.0.0.1", () => {
|
|
83
|
+
const address = server.address();
|
|
84
|
+
if (!address || typeof address === "string")
|
|
85
|
+
return;
|
|
86
|
+
redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
87
|
+
const url = new URL("/oauth2/authorize", cloud);
|
|
88
|
+
url.search = new URLSearchParams({
|
|
89
|
+
response_type: "code",
|
|
90
|
+
client_id: "rome-computer",
|
|
91
|
+
redirect_uri: redirectUri,
|
|
92
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
93
|
+
code_challenge_method: "S256",
|
|
94
|
+
state,
|
|
95
|
+
display_name: name,
|
|
96
|
+
}).toString();
|
|
97
|
+
process.stderr.write(`Open this URL in your browser to authorize ${name}:\n${url}\n`);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const MAX_MESSAGE_BYTES: number;
|
|
2
|
+
export declare const MAX_CLIENT_BUFFER_BYTES: number;
|
|
3
|
+
export declare const CLOSE: {
|
|
4
|
+
readonly revoked: 4001;
|
|
5
|
+
readonly superseded: 4002;
|
|
6
|
+
readonly outputLimit: 4003;
|
|
7
|
+
readonly invalid: 1008;
|
|
8
|
+
readonly tooLarge: 1009;
|
|
9
|
+
};
|
|
10
|
+
export interface OutboundEnvelope {
|
|
11
|
+
id: string;
|
|
12
|
+
to: string;
|
|
13
|
+
payload: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface InboundEnvelope {
|
|
16
|
+
id: string;
|
|
17
|
+
from: string;
|
|
18
|
+
payload: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface RouteError {
|
|
21
|
+
id: string;
|
|
22
|
+
type: "error";
|
|
23
|
+
code: "target_unavailable" | "invalid_message" | "message_too_large";
|
|
24
|
+
}
|
|
25
|
+
export type GatewayMessage = InboundEnvelope | RouteError;
|
|
26
|
+
export declare function validId(value: unknown): value is string;
|
|
27
|
+
export declare function outboundEnvelope(value: unknown): OutboundEnvelope | null;
|
|
28
|
+
export declare function gatewayMessage(value: unknown): GatewayMessage | null;
|
|
29
|
+
export declare function byteLength(value: string): number;
|
|
30
|
+
export {};
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MAX_MESSAGE_BYTES = 128 * 1024;
|
|
2
|
+
export const MAX_CLIENT_BUFFER_BYTES = 1024 * 1024;
|
|
3
|
+
export const CLOSE = {
|
|
4
|
+
revoked: 4001,
|
|
5
|
+
superseded: 4002,
|
|
6
|
+
outputLimit: 4003,
|
|
7
|
+
invalid: 1008,
|
|
8
|
+
tooLarge: 1009,
|
|
9
|
+
};
|
|
10
|
+
export function validId(value) {
|
|
11
|
+
return (typeof value === "string" &&
|
|
12
|
+
value.length > 0 &&
|
|
13
|
+
value.length <= 128 &&
|
|
14
|
+
!/[\u0000-\u0020\u007f]/.test(value));
|
|
15
|
+
}
|
|
16
|
+
export function outboundEnvelope(value) {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18
|
+
return null;
|
|
19
|
+
const item = value;
|
|
20
|
+
if (!validId(item.id) || !validId(item.to) || !Object.hasOwn(item, "payload"))
|
|
21
|
+
return null;
|
|
22
|
+
return { id: item.id, to: item.to, payload: item.payload };
|
|
23
|
+
}
|
|
24
|
+
export function gatewayMessage(value) {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
26
|
+
return null;
|
|
27
|
+
const item = value;
|
|
28
|
+
if (!validId(item.id))
|
|
29
|
+
return null;
|
|
30
|
+
if (item.type === "error" &&
|
|
31
|
+
["target_unavailable", "invalid_message", "message_too_large"].includes(String(item.code))) {
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
if (validId(item.from) && Object.hasOwn(item, "payload"))
|
|
35
|
+
return { id: item.id, from: item.from, payload: item.payload };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export function byteLength(value) {
|
|
39
|
+
return new TextEncoder().encode(value).byteLength;
|
|
40
|
+
}
|
package/dist/socket.d.ts
ADDED
package/dist/socket.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { MAX_MESSAGE_BYTES } from "./protocol.js";
|
|
3
|
+
export function createNodeSocket(url, authorization) {
|
|
4
|
+
const socket = new WebSocket(url, {
|
|
5
|
+
headers: { Authorization: authorization },
|
|
6
|
+
handshakeTimeout: 10_000,
|
|
7
|
+
maxPayload: MAX_MESSAGE_BYTES,
|
|
8
|
+
followRedirects: false,
|
|
9
|
+
});
|
|
10
|
+
let alive = true;
|
|
11
|
+
const heartbeat = setInterval(() => {
|
|
12
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
13
|
+
return;
|
|
14
|
+
if (!alive) {
|
|
15
|
+
socket.terminate();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
alive = false;
|
|
19
|
+
socket.ping();
|
|
20
|
+
}, 30_000);
|
|
21
|
+
heartbeat.unref();
|
|
22
|
+
socket.on("pong", () => {
|
|
23
|
+
alive = true;
|
|
24
|
+
});
|
|
25
|
+
socket.on("close", () => clearInterval(heartbeat));
|
|
26
|
+
return socket;
|
|
27
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
export async function readPrivateJson(path) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error instanceof SyntaxError || error.code === "ENOENT")
|
|
12
|
+
return null;
|
|
13
|
+
throw new Error("Could not read the local credential file.");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function writePrivateJson(path, value) {
|
|
17
|
+
const dir = dirname(path);
|
|
18
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
const { stdout } = await run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
|
|
22
|
+
const sid = stdout.match(/S-1-\d+(?:-\d+)+/)?.[0];
|
|
23
|
+
if (!sid)
|
|
24
|
+
throw new Error("Could not determine the Windows account identity.");
|
|
25
|
+
await run("icacls.exe", [dir, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`]);
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
await chmod(dir, 0o700);
|
|
29
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(temporary, JSON.stringify(value), { mode: 0o600, flag: "wx" });
|
|
32
|
+
await rename(temporary, path);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await rm(temporary, { force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rome-os/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rome device protocol, Gateway client, and remote computer CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"rome-node": "./bin/rome-node.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"ws": "^8.18.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@rstest/core": "^0.11.11",
|
|
29
|
+
"@types/node": "25.2.3",
|
|
30
|
+
"@types/ws": "^8.18.0",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/rome-os/rome.git",
|
|
36
|
+
"directory": "packages/rome-node"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "pnpm build && ../../scripts/test-env.sh rstest"
|
|
42
|
+
}
|
|
43
|
+
}
|