@rome-os/node-core 0.1.0 → 0.1.1
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/README.md +7 -0
- package/dist/device-authorization.d.ts +8 -0
- package/dist/device-authorization.js +156 -0
- package/dist/device-session.d.ts +7 -0
- package/dist/device-session.js +16 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/login.d.ts +2 -5
- package/dist/login.js +5 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,13 @@ A custom credential store implements `load`, `save`, and `clear`. The built-in
|
|
|
137
137
|
login adapter uses a temporary loopback callback listener. Applications can
|
|
138
138
|
supply their own authorization function returning a device session.
|
|
139
139
|
|
|
140
|
+
For authorization from another device, import `loginDeviceCode` from the same
|
|
141
|
+
`/host` entry. It accepts the same origin, name, and abort signal as `loginDevice`.
|
|
142
|
+
Its callback receives `{ verificationUri, verificationUriComplete, userCode, expiresIn }`,
|
|
143
|
+
where `expiresIn` is in seconds. It returns a `DeviceSession` after approval and
|
|
144
|
+
rejects on denial, expiry, cancellation, or a terminal error. It opens no browser
|
|
145
|
+
or callback listener. The callback receives neither the device code nor the access token.
|
|
146
|
+
|
|
140
147
|
The host exposes `system.info` and `exec`. Programs run as the application's OS
|
|
141
148
|
user. Connection loss terminates direct child processes on a best-effort basis.
|
|
142
149
|
A local caller timeout does not cancel remote execution.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DeviceSession } from "./device-session.js";
|
|
2
|
+
export interface DeviceAuthorizationPrompt {
|
|
3
|
+
verificationUri: string;
|
|
4
|
+
verificationUriComplete: string;
|
|
5
|
+
userCode: string;
|
|
6
|
+
expiresIn: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function loginDeviceCode(cloudUrl: string, name: string, signal: AbortSignal, onAuthorizationRequired: (prompt: DeviceAuthorizationPrompt) => void): Promise<DeviceSession>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isRecord } from "./actions.js";
|
|
2
|
+
import { cloudOrigin } from "./cloud.js";
|
|
3
|
+
import { devicePlatform, deviceSessionFromToken } from "./device-session.js";
|
|
4
|
+
class DeviceAuthorizationError extends Error {
|
|
5
|
+
}
|
|
6
|
+
class RetryableRequestError extends Error {
|
|
7
|
+
}
|
|
8
|
+
function seconds(value) {
|
|
9
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 2_147_483;
|
|
10
|
+
}
|
|
11
|
+
function verificationUrl(value, origin) {
|
|
12
|
+
if (typeof value !== "string")
|
|
13
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
20
|
+
}
|
|
21
|
+
if (url.origin !== origin || url.username || url.password)
|
|
22
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
23
|
+
return url.href;
|
|
24
|
+
}
|
|
25
|
+
async function post(origin, path, body, signal) {
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(new URL(path, origin), {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body,
|
|
31
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
|
32
|
+
redirect: "error",
|
|
33
|
+
cache: "no-store",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new RetryableRequestError();
|
|
38
|
+
}
|
|
39
|
+
if (response.status === 429 || response.status >= 500) {
|
|
40
|
+
await response.body?.cancel().catch(() => { });
|
|
41
|
+
throw new RetryableRequestError();
|
|
42
|
+
}
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await response.json();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof SyntaxError)
|
|
49
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
50
|
+
throw new RetryableRequestError();
|
|
51
|
+
}
|
|
52
|
+
return { response, result };
|
|
53
|
+
}
|
|
54
|
+
function wait(milliseconds, signal) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
const abort = () => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(signal.reason);
|
|
60
|
+
};
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
signal.removeEventListener("abort", abort);
|
|
63
|
+
resolve();
|
|
64
|
+
}, milliseconds);
|
|
65
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export async function loginDeviceCode(cloudUrl, name, signal, onAuthorizationRequired) {
|
|
69
|
+
const origin = cloudOrigin(cloudUrl);
|
|
70
|
+
const expiry = new AbortController();
|
|
71
|
+
const pollingSignal = AbortSignal.any([signal, expiry.signal]);
|
|
72
|
+
let timer;
|
|
73
|
+
try {
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
const { response, result } = await post(origin, "/oauth2/device_authorization", new URLSearchParams({
|
|
76
|
+
client_id: "rome-computer",
|
|
77
|
+
display_name: name,
|
|
78
|
+
platform: devicePlatform(),
|
|
79
|
+
}), signal);
|
|
80
|
+
if (!response.ok ||
|
|
81
|
+
!isRecord(result) ||
|
|
82
|
+
typeof result.device_code !== "string" ||
|
|
83
|
+
!result.device_code ||
|
|
84
|
+
typeof result.user_code !== "string" ||
|
|
85
|
+
!/^[A-Za-z0-9-]{1,64}$/.test(result.user_code) ||
|
|
86
|
+
!seconds(result.expires_in) ||
|
|
87
|
+
!seconds(result.interval))
|
|
88
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
89
|
+
const prompt = {
|
|
90
|
+
verificationUri: verificationUrl(result.verification_uri, origin),
|
|
91
|
+
verificationUriComplete: verificationUrl(result.verification_uri_complete, origin),
|
|
92
|
+
userCode: result.user_code,
|
|
93
|
+
expiresIn: result.expires_in,
|
|
94
|
+
};
|
|
95
|
+
const deadline = Date.now() + result.expires_in * 1000;
|
|
96
|
+
timer = setTimeout(() => expiry.abort(), result.expires_in * 1000);
|
|
97
|
+
onAuthorizationRequired(prompt);
|
|
98
|
+
let interval = result.interval * 1000;
|
|
99
|
+
while (true) {
|
|
100
|
+
await wait(Math.min(interval, Math.max(0, deadline - Date.now())), pollingSignal);
|
|
101
|
+
if (Date.now() >= deadline)
|
|
102
|
+
expiry.abort();
|
|
103
|
+
pollingSignal.throwIfAborted();
|
|
104
|
+
let polled;
|
|
105
|
+
try {
|
|
106
|
+
polled = await post(origin, "/oauth2/token", new URLSearchParams({
|
|
107
|
+
client_id: "rome-computer",
|
|
108
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
109
|
+
device_code: result.device_code,
|
|
110
|
+
}), pollingSignal);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
pollingSignal.throwIfAborted();
|
|
114
|
+
if (!(error instanceof RetryableRequestError))
|
|
115
|
+
throw error;
|
|
116
|
+
interval = Math.max(interval, Math.min(interval * 2, 60_000));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { response, result: token } = polled;
|
|
120
|
+
pollingSignal.throwIfAborted();
|
|
121
|
+
if (response.ok) {
|
|
122
|
+
const session = deviceSessionFromToken(token, origin);
|
|
123
|
+
if (!session)
|
|
124
|
+
throw new DeviceAuthorizationError("Invalid token response.");
|
|
125
|
+
return session;
|
|
126
|
+
}
|
|
127
|
+
if (response.status === 400 && isRecord(token)) {
|
|
128
|
+
if (token.error === "authorization_pending")
|
|
129
|
+
continue;
|
|
130
|
+
if (token.error === "slow_down") {
|
|
131
|
+
interval += 5000;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (token.error === "access_denied")
|
|
135
|
+
throw new DeviceAuthorizationError("Device authorization denied.");
|
|
136
|
+
if (token.error === "expired_token")
|
|
137
|
+
throw new DeviceAuthorizationError("Device authorization expired.");
|
|
138
|
+
if (token.error === "invalid_grant")
|
|
139
|
+
throw new DeviceAuthorizationError("Device authorization is invalid.");
|
|
140
|
+
}
|
|
141
|
+
throw new DeviceAuthorizationError("Device authorization rejected.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
throw new DeviceAuthorizationError("Authorization canceled.");
|
|
147
|
+
if (expiry.signal.aborted)
|
|
148
|
+
throw new DeviceAuthorizationError("Device authorization expired.");
|
|
149
|
+
if (error instanceof DeviceAuthorizationError)
|
|
150
|
+
throw error;
|
|
151
|
+
throw new DeviceAuthorizationError("Could not complete device authorization. Try connecting again.");
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DeviceSession {
|
|
2
|
+
cloudUrl: string;
|
|
3
|
+
token: string;
|
|
4
|
+
deviceId: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function devicePlatform(): "macos" | "windows" | "linux";
|
|
7
|
+
export declare function deviceSessionFromToken(body: unknown, origin: string): DeviceSession | null;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { platform } from "node:os";
|
|
2
|
+
import { isRecord } from "./actions.js";
|
|
3
|
+
import { validId } from "./protocol.js";
|
|
4
|
+
export function devicePlatform() {
|
|
5
|
+
const value = platform();
|
|
6
|
+
return value === "darwin" ? "macos" : value === "win32" ? "windows" : "linux";
|
|
7
|
+
}
|
|
8
|
+
export function deviceSessionFromToken(body, origin) {
|
|
9
|
+
if (!isRecord(body) ||
|
|
10
|
+
typeof body.access_token !== "string" ||
|
|
11
|
+
!/^romedev_[A-Za-z0-9_-]{43}$/.test(body.access_token) ||
|
|
12
|
+
body.token_type !== "Bearer" ||
|
|
13
|
+
!validId(body.device_id))
|
|
14
|
+
return null;
|
|
15
|
+
return { cloudUrl: origin, token: body.access_token, deviceId: body.device_id };
|
|
16
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { connectHost, defaultDeviceName, type HostCredential, type HostEvent, type HostOptions, } from "./connect.js";
|
|
2
2
|
export { loginDevice, type DeviceSession } from "./login.js";
|
|
3
|
+
export { loginDeviceCode, type DeviceAuthorizationPrompt } from "./device-authorization.js";
|
|
3
4
|
export { createFileCredentialStore, type CredentialStore } from "./storage.js";
|
package/dist/host.js
CHANGED
package/dist/login.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
token: string;
|
|
4
|
-
deviceId: string;
|
|
5
|
-
}
|
|
1
|
+
import { type DeviceSession } from "./device-session.js";
|
|
2
|
+
export type { DeviceSession } from "./device-session.js";
|
|
6
3
|
export declare function loginDevice(cloudUrl: string, name: string, signal: AbortSignal, onAuthorizationRequired: (url: string) => void): Promise<DeviceSession>;
|
package/dist/login.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { platform } from "node:os";
|
|
4
3
|
import { cloudOrigin } from "./cloud.js";
|
|
5
|
-
import {
|
|
4
|
+
import { devicePlatform, deviceSessionFromToken } from "./device-session.js";
|
|
6
5
|
export async function loginDevice(cloudUrl, name, signal, onAuthorizationRequired) {
|
|
7
6
|
const cloud = new URL(cloudOrigin(cloudUrl));
|
|
8
7
|
const verifier = randomBytes(32).toString("base64url");
|
|
@@ -36,20 +35,18 @@ export async function loginDevice(cloudUrl, name, signal, onAuthorizationRequire
|
|
|
36
35
|
redirect_uri: redirectUri,
|
|
37
36
|
code: incoming.searchParams.get("code"),
|
|
38
37
|
code_verifier: verifier,
|
|
39
|
-
platform:
|
|
38
|
+
platform: devicePlatform(),
|
|
40
39
|
}),
|
|
41
40
|
});
|
|
42
41
|
if (!result.ok)
|
|
43
42
|
throw new Error("Token exchange rejected");
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
!/^romedev_[A-Za-z0-9_-]{43}$/.test(body.access_token) ||
|
|
47
|
-
!validId(body.device_id))
|
|
43
|
+
const session = deviceSessionFromToken(await result.json(), cloud.origin);
|
|
44
|
+
if (!session)
|
|
48
45
|
throw new Error("Invalid token response");
|
|
49
46
|
response
|
|
50
47
|
.writeHead(200, { "content-type": "text/plain", "cache-control": "no-store" })
|
|
51
48
|
.end("Device authorized. Return to Rome Node.");
|
|
52
|
-
resolve(
|
|
49
|
+
resolve(session);
|
|
53
50
|
}
|
|
54
51
|
catch {
|
|
55
52
|
response
|