@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 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
@@ -1,3 +1,4 @@
1
1
  export { connectHost, defaultDeviceName, } from "./connect.js";
2
2
  export { loginDevice } from "./login.js";
3
+ export { loginDeviceCode } from "./device-authorization.js";
3
4
  export { createFileCredentialStore } from "./storage.js";
package/dist/login.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- export interface DeviceSession {
2
- cloudUrl: string;
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 { validId } from "./protocol.js";
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: platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : "linux",
38
+ platform: devicePlatform(),
40
39
  }),
41
40
  });
42
41
  if (!result.ok)
43
42
  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))
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({ cloudUrl: cloud.origin, token: body.access_token, deviceId: body.device_id });
49
+ resolve(session);
53
50
  }
54
51
  catch {
55
52
  response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rome-os/node-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "JavaScript APIs for Rome device hosts and shared caller daemons",
5
5
  "license": "MIT",
6
6
  "type": "module",