relmio 0.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/LICENSE +21 -0
  3. package/README.md +122 -0
  4. package/SPEC.md +140 -0
  5. package/docs/architecture.md +127 -0
  6. package/docs/brand.md +43 -0
  7. package/docs/images/brand/relmio-concept-source.png +0 -0
  8. package/docs/images/brand/relmio-mark.svg +16 -0
  9. package/docs/images/setup/01-local-sign-in-ready.png +0 -0
  10. package/docs/images/setup/02-vps-identity-confirmed.png +0 -0
  11. package/docs/images/setup/03-n8n-detected.png +0 -0
  12. package/docs/images/setup/04-install-plan.png +0 -0
  13. package/docs/images/setup/05-bridge-ready.png +0 -0
  14. package/docs/maintenance.md +157 -0
  15. package/docs/manual-install.md +282 -0
  16. package/docs/n8n-configuration.md +199 -0
  17. package/docs/npm-publish.md +285 -0
  18. package/docs/roadmap.md +109 -0
  19. package/docs/security.md +105 -0
  20. package/docs/troubleshooting.md +193 -0
  21. package/docs/video-outline.md +152 -0
  22. package/package.json +45 -0
  23. package/scripts/build-npm-package.js +112 -0
  24. package/scripts/check-release-metadata.js +100 -0
  25. package/scripts/check-syntax.js +59 -0
  26. package/scripts/preview.js +69 -0
  27. package/src/cli.js +57 -0
  28. package/src/domain/safety.js +59 -0
  29. package/src/domain/templates.js +65 -0
  30. package/src/domain/validation.js +76 -0
  31. package/src/infrastructure/ssh.js +268 -0
  32. package/src/services/discovery.js +96 -0
  33. package/src/services/installer.js +239 -0
  34. package/src/services/oauth.js +351 -0
  35. package/src/ui/app.js +526 -0
  36. package/src/ui/index.html +440 -0
  37. package/src/ui/styles.css +645 -0
  38. package/src/ui/time.js +15 -0
  39. package/src/web/server.js +489 -0
@@ -0,0 +1,239 @@
1
+ import {
2
+ INSTALL_ROOT,
3
+ MANAGED_MARKER_PATH,
4
+ PRECHECK_COMMAND,
5
+ assertSidecarOnlyCommands,
6
+ createDeploymentCommands,
7
+ createVerificationCommands,
8
+ } from "../domain/safety.js";
9
+ import {
10
+ SIDECAR_HOSTNAME,
11
+ createComposeFile,
12
+ createDockerfile,
13
+ } from "../domain/templates.js";
14
+
15
+ const MAX_AUTH_FILE_BYTES = 128 * 1024;
16
+ const MARKER_CONTENT = "Managed by n8n-openai-oauth-setup.\n";
17
+
18
+ function validateAuthContents(contents) {
19
+ if (!Buffer.isBuffer(contents)) {
20
+ throw new TypeError("The OAuth credential file is invalid.");
21
+ }
22
+ if (contents.length === 0 || contents.length > MAX_AUTH_FILE_BYTES) {
23
+ throw new TypeError("The OAuth credential file is invalid.");
24
+ }
25
+
26
+ try {
27
+ const parsed = JSON.parse(contents.toString("utf8"));
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
29
+ throw new TypeError();
30
+ }
31
+ } catch {
32
+ throw new TypeError("The OAuth credential file is invalid.");
33
+ }
34
+
35
+ return contents;
36
+ }
37
+
38
+ async function runOrThrow(remote, command, label) {
39
+ const result = await remote.exec(command);
40
+ if (result.code !== 0) {
41
+ throw new Error(
42
+ `${label} failed. The existing n8n deployment was not changed.`,
43
+ );
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function parseModels(output) {
49
+ try {
50
+ const parsed = JSON.parse(output);
51
+ if (!Array.isArray(parsed.data)) {
52
+ throw new TypeError();
53
+ }
54
+
55
+ const models = parsed.data
56
+ .map((model) => model?.id)
57
+ .filter(
58
+ (id) =>
59
+ typeof id === "string" &&
60
+ id.length > 0 &&
61
+ id.length <= 128 &&
62
+ /^[a-zA-Z0-9_.:-]+$/.test(id),
63
+ );
64
+ if (models.length === 0) {
65
+ throw new TypeError();
66
+ }
67
+ return models;
68
+ } catch {
69
+ throw new Error(
70
+ "The sidecar started, but its model response could not be verified.",
71
+ );
72
+ }
73
+ }
74
+
75
+ function hasPublishedHostPort(output) {
76
+ try {
77
+ const parsed = JSON.parse(output);
78
+ const services = Array.isArray(parsed) ? parsed : [parsed];
79
+ if (services.length === 0) {
80
+ throw new TypeError();
81
+ }
82
+
83
+ for (const service of services) {
84
+ if (!service || !Array.isArray(service.Publishers)) {
85
+ throw new TypeError();
86
+ }
87
+ for (const publisher of service.Publishers) {
88
+ if (
89
+ !publisher ||
90
+ !Number.isInteger(publisher.PublishedPort) ||
91
+ publisher.PublishedPort < 0 ||
92
+ typeof publisher.URL !== "string"
93
+ ) {
94
+ throw new TypeError();
95
+ }
96
+ if (publisher.PublishedPort > 0 || publisher.URL.trim() !== "") {
97
+ return true;
98
+ }
99
+ }
100
+ }
101
+ return false;
102
+ } catch {
103
+ throw new Error("The published-port safety check failed.");
104
+ }
105
+ }
106
+
107
+ async function failPublicationSafetyCheck(remote, cleanupCommand, reason) {
108
+ let cleanupSucceeded = false;
109
+ try {
110
+ cleanupSucceeded = (await remote.exec(cleanupCommand)).code === 0;
111
+ } catch {
112
+ cleanupSucceeded = false;
113
+ }
114
+ if (!cleanupSucceeded) {
115
+ throw new Error(
116
+ `${reason} Automatic cleanup could not be confirmed. Do not use the sidecar until it is removed from /docker/n8n-openai-oauth.`,
117
+ );
118
+ }
119
+ throw new Error(
120
+ `${reason} The sidecar was removed; the existing n8n deployment was not changed.`,
121
+ );
122
+ }
123
+
124
+ export async function installSidecar({
125
+ remote,
126
+ networkName,
127
+ authContents,
128
+ confirmed,
129
+ }) {
130
+ if (confirmed !== true) {
131
+ throw new Error("Confirm the sidecar-only deployment before installing.");
132
+ }
133
+
134
+ const safeAuthContents = validateAuthContents(authContents);
135
+ const dockerfile = createDockerfile();
136
+ const composeFile = createComposeFile({ networkName });
137
+ const deploymentCommands = createDeploymentCommands();
138
+ const verification = createVerificationCommands();
139
+
140
+ assertSidecarOnlyCommands([
141
+ PRECHECK_COMMAND,
142
+ ...deploymentCommands,
143
+ ...Object.values(verification),
144
+ ]);
145
+
146
+ const precheck = await remote.exec(PRECHECK_COMMAND);
147
+ if (precheck.code === 42) {
148
+ throw new Error(
149
+ "The install directory already exists and is unmanaged. Nothing was overwritten.",
150
+ );
151
+ }
152
+ if (precheck.code !== 0) {
153
+ throw new Error("The VPS install-directory check failed.");
154
+ }
155
+ const precheckState = precheck.stdout.trim();
156
+ if (!["managed", "new"].includes(precheckState)) {
157
+ throw new Error("The VPS install-directory check returned an invalid state.");
158
+ }
159
+ const deploymentMode =
160
+ precheckState === "managed" ? "updated" : "installed";
161
+
162
+ await runOrThrow(remote, deploymentCommands[0], "Sidecar directory creation");
163
+ await runOrThrow(remote, deploymentCommands[1], "Auth directory creation");
164
+
165
+ await remote.upload(MANAGED_MARKER_PATH, MARKER_CONTENT, 0o644);
166
+ await remote.upload(`${INSTALL_ROOT}/Dockerfile`, dockerfile, 0o644);
167
+ await remote.upload(
168
+ `${INSTALL_ROOT}/docker-compose.yml`,
169
+ composeFile,
170
+ 0o644,
171
+ );
172
+ await remote.upload(
173
+ `${INSTALL_ROOT}/auth/auth.json`,
174
+ safeAuthContents,
175
+ 0o600,
176
+ );
177
+
178
+ for (const command of deploymentCommands.slice(2)) {
179
+ await runOrThrow(remote, command, "Sidecar deployment");
180
+ }
181
+
182
+ const running = await runOrThrow(
183
+ remote,
184
+ verification.runningService,
185
+ "Sidecar status check",
186
+ );
187
+ if (!running.stdout.split(/\s+/u).includes("openai-oauth")) {
188
+ throw new Error("The sidecar did not reach the running state.");
189
+ }
190
+
191
+ let publication;
192
+ try {
193
+ publication = await remote.exec(verification.publicationState);
194
+ } catch {
195
+ await failPublicationSafetyCheck(
196
+ remote,
197
+ verification.cleanup,
198
+ "The published-port safety check could not be completed.",
199
+ );
200
+ }
201
+ if (publication.code !== 0) {
202
+ await failPublicationSafetyCheck(
203
+ remote,
204
+ verification.cleanup,
205
+ "The published-port safety check failed.",
206
+ );
207
+ }
208
+ let publishedHostPort;
209
+ try {
210
+ publishedHostPort = hasPublishedHostPort(publication.stdout);
211
+ } catch {
212
+ await failPublicationSafetyCheck(
213
+ remote,
214
+ verification.cleanup,
215
+ "The published-port safety check failed.",
216
+ );
217
+ }
218
+ if (publishedHostPort) {
219
+ await failPublicationSafetyCheck(
220
+ remote,
221
+ verification.cleanup,
222
+ "Safety check failed: the sidecar unexpectedly published a host port.",
223
+ );
224
+ }
225
+
226
+ const models = await runOrThrow(
227
+ remote,
228
+ verification.models,
229
+ "OAuth model check",
230
+ );
231
+
232
+ return {
233
+ baseUrl: `http://${SIDECAR_HOSTNAME}:10531/v1`,
234
+ apiKeyPlaceholder: "local-only",
235
+ useResponsesApi: true,
236
+ models: parseModels(models.stdout),
237
+ deploymentMode,
238
+ };
239
+ }
@@ -0,0 +1,351 @@
1
+ import * as defaultFileSystem from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { dirname, resolve } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+
7
+ const MAX_AUTH_FILE_BYTES = 128 * 1024;
8
+ const MAX_LOGIN_OUTPUT_BYTES = 32 * 1024;
9
+ const LOGIN_URL_TIMEOUT_MS = 15_000;
10
+ const LOGIN_TIMEOUT_MS = 300_000;
11
+ const PROCESS_TIMEOUT_MS = LOGIN_TIMEOUT_MS + 15_000;
12
+ const CREDENTIAL_POLL_INTERVAL_MS = 100;
13
+ const LOGIN_URL_PREFIX = "OpenAI OAuth login URL: ";
14
+ const OPENAI_AUTH_ORIGIN = "https://auth.openai.com";
15
+
16
+ const wait = (milliseconds) =>
17
+ new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
18
+
19
+ export function resolveAuthPath({
20
+ env = process.env,
21
+ homeDirectory = homedir(),
22
+ } = {}) {
23
+ if (
24
+ typeof env.N8N_OPENAI_OAUTH_HOME === "string" &&
25
+ env.N8N_OPENAI_OAUTH_HOME.trim() !== ""
26
+ ) {
27
+ return resolve(env.N8N_OPENAI_OAUTH_HOME, "auth.json");
28
+ }
29
+ return resolve(homeDirectory, ".n8n-openai-oauth", "auth.json");
30
+ }
31
+
32
+ export async function getAuthStatus({
33
+ fileSystem = defaultFileSystem,
34
+ env = process.env,
35
+ homeDirectory = homedir(),
36
+ } = {}) {
37
+ const path = resolveAuthPath({ env, homeDirectory });
38
+
39
+ try {
40
+ await fileSystem.access(path);
41
+ const metadata = await fileSystem.stat(path);
42
+ return {
43
+ exists: true,
44
+ path,
45
+ updatedAt: metadata.mtime.toISOString(),
46
+ };
47
+ } catch (error) {
48
+ if (error.code === "ENOENT") {
49
+ return { exists: false, path };
50
+ }
51
+ throw new Error("The local OAuth credential location could not be checked.");
52
+ }
53
+ }
54
+
55
+ export async function readAuthContents({
56
+ authPath,
57
+ fileSystem = defaultFileSystem,
58
+ }) {
59
+ let contents;
60
+ try {
61
+ contents = await fileSystem.readFile(authPath);
62
+ } catch {
63
+ throw new Error("The local OAuth credential file could not be read.");
64
+ }
65
+
66
+ if (
67
+ !Buffer.isBuffer(contents) ||
68
+ contents.length === 0 ||
69
+ contents.length > MAX_AUTH_FILE_BYTES
70
+ ) {
71
+ throw new Error("The local OAuth credential file is invalid.");
72
+ }
73
+
74
+ try {
75
+ const parsed = JSON.parse(contents.toString("utf8"));
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
77
+ throw new TypeError();
78
+ }
79
+ } catch {
80
+ throw new Error("The local OAuth credential file is invalid.");
81
+ }
82
+
83
+ return contents;
84
+ }
85
+
86
+ function validateAuthorizationUrl(value) {
87
+ let url;
88
+ try {
89
+ url = new URL(value);
90
+ } catch {
91
+ throw new Error("The sign-in command returned an invalid authorization URL.");
92
+ }
93
+
94
+ if (
95
+ url.origin !== OPENAI_AUTH_ORIGIN ||
96
+ url.pathname !== "/oauth/authorize" ||
97
+ url.searchParams.get("response_type") !== "code" ||
98
+ !url.searchParams.get("state") ||
99
+ !url.searchParams.get("code_challenge")
100
+ ) {
101
+ throw new Error("The sign-in command returned an unexpected destination.");
102
+ }
103
+
104
+ let redirect;
105
+ try {
106
+ redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
107
+ } catch {
108
+ throw new Error("The sign-in command returned an invalid callback.");
109
+ }
110
+ if (
111
+ !["localhost", "127.0.0.1", "::1"].includes(redirect.hostname) ||
112
+ redirect.port !== "1455" ||
113
+ redirect.pathname !== "/auth/callback"
114
+ ) {
115
+ throw new Error("The sign-in command returned an unexpected callback.");
116
+ }
117
+
118
+ return url.toString();
119
+ }
120
+
121
+ function extractAuthorizationUrl(output) {
122
+ for (const line of output.split(/\r?\n/u)) {
123
+ if (line.startsWith(LOGIN_URL_PREFIX)) {
124
+ return validateAuthorizationUrl(line.slice(LOGIN_URL_PREFIX.length));
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export async function startOAuthLogin({
131
+ fileSystem = defaultFileSystem,
132
+ env = process.env,
133
+ homeDirectory = homedir(),
134
+ platform = process.platform,
135
+ spawnProcess = spawn,
136
+ createPendingId = randomUUID,
137
+ waitForCredentialPoll = wait,
138
+ } = {}) {
139
+ const command = platform === "win32" ? "npx.cmd" : "npx";
140
+ const authPath = resolveAuthPath({ env, homeDirectory });
141
+ const authDirectory = dirname(authPath);
142
+ const pendingAuthPath = `${authPath}.pending-${createPendingId()}`;
143
+ const args = [
144
+ "--yes",
145
+ "--ignore-scripts",
146
+ "openai-oauth@2.0.0",
147
+ "login",
148
+ "--no-open",
149
+ "--login-timeout-ms",
150
+ String(LOGIN_TIMEOUT_MS),
151
+ "--oauth-file",
152
+ pendingAuthPath,
153
+ ];
154
+
155
+ await fileSystem.mkdir(authDirectory, { recursive: true, mode: 0o700 });
156
+ await fileSystem.chmod(authDirectory, 0o700);
157
+
158
+ const child = spawnProcess(command, args, {
159
+ env,
160
+ shell: false,
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ windowsHide: true,
163
+ });
164
+ child.stderr?.resume();
165
+
166
+ let loginOutput = "";
167
+ let resolveAuthorizationUrl;
168
+ let rejectAuthorizationUrl;
169
+ let authorizationUrlSettled = false;
170
+ const authorizationUrlPromise = new Promise((resolvePromise, rejectPromise) => {
171
+ resolveAuthorizationUrl = resolvePromise;
172
+ rejectAuthorizationUrl = rejectPromise;
173
+ });
174
+ const settleAuthorizationUrl = (error, authorizationUrl) => {
175
+ if (authorizationUrlSettled) {
176
+ return;
177
+ }
178
+ authorizationUrlSettled = true;
179
+ if (error) {
180
+ rejectAuthorizationUrl(error);
181
+ } else {
182
+ resolveAuthorizationUrl(authorizationUrl);
183
+ }
184
+ };
185
+
186
+ child.stdout?.on("data", (chunk) => {
187
+ if (authorizationUrlSettled) {
188
+ return;
189
+ }
190
+ loginOutput += Buffer.from(chunk).toString("utf8");
191
+ if (Buffer.byteLength(loginOutput) > MAX_LOGIN_OUTPUT_BYTES) {
192
+ settleAuthorizationUrl(
193
+ new Error("The sign-in command returned too much output."),
194
+ );
195
+ child.kill?.("SIGTERM");
196
+ return;
197
+ }
198
+ try {
199
+ const authorizationUrl = extractAuthorizationUrl(loginOutput);
200
+ if (authorizationUrl) {
201
+ settleAuthorizationUrl(null, authorizationUrl);
202
+ }
203
+ } catch (error) {
204
+ settleAuthorizationUrl(error);
205
+ child.kill?.("SIGTERM");
206
+ }
207
+ });
208
+
209
+ let resolveExit;
210
+ let rejectExit;
211
+ let exitSettled = false;
212
+ const exitPromise = new Promise((resolvePromise, rejectPromise) => {
213
+ resolveExit = resolvePromise;
214
+ rejectExit = rejectPromise;
215
+ });
216
+ const settleExit = (error, code) => {
217
+ if (exitSettled) {
218
+ return;
219
+ }
220
+ exitSettled = true;
221
+ if (error) {
222
+ rejectExit(error);
223
+ } else {
224
+ resolveExit(code);
225
+ }
226
+ };
227
+
228
+ child.once("error", () => {
229
+ const error = new Error(
230
+ "The local sign-in command could not start. Install Node.js 22 and try again.",
231
+ );
232
+ settleAuthorizationUrl(error);
233
+ settleExit(error);
234
+ });
235
+ child.once("exit", (code) => {
236
+ if (!authorizationUrlSettled) {
237
+ settleAuthorizationUrl(
238
+ new Error("The sign-in command did not return an authorization URL."),
239
+ );
240
+ }
241
+ settleExit(null, code);
242
+ });
243
+
244
+ const loginUrlTimeout = setTimeout(() => {
245
+ settleAuthorizationUrl(
246
+ new Error("The sign-in command did not provide a fresh login link."),
247
+ );
248
+ child.kill?.("SIGTERM");
249
+ }, LOGIN_URL_TIMEOUT_MS);
250
+
251
+ const savePendingCredential = async () => {
252
+ await readAuthContents({
253
+ authPath: pendingAuthPath,
254
+ fileSystem,
255
+ });
256
+ await fileSystem.chmod(pendingAuthPath, 0o600);
257
+ await fileSystem.copyFile(pendingAuthPath, authPath);
258
+ await fileSystem.chmod(authPath, 0o600);
259
+ };
260
+
261
+ let keepPollingForCredential = true;
262
+ const pendingCredentialPromise = (async () => {
263
+ await authorizationUrlPromise;
264
+ while (keepPollingForCredential) {
265
+ try {
266
+ await readAuthContents({
267
+ authPath: pendingAuthPath,
268
+ fileSystem,
269
+ });
270
+ } catch {
271
+ await waitForCredentialPoll(CREDENTIAL_POLL_INTERVAL_MS);
272
+ continue;
273
+ }
274
+ await savePendingCredential();
275
+ return { success: true };
276
+ }
277
+ throw new Error("ChatGPT sign-in did not finish. Start a fresh login.");
278
+ })();
279
+
280
+ const completion = (async () => {
281
+ let processTimeout;
282
+ try {
283
+ return await Promise.race([
284
+ pendingCredentialPromise,
285
+ exitPromise.then(async (code) => {
286
+ if (code !== 0) {
287
+ throw new Error(
288
+ "ChatGPT sign-in did not finish. Start a fresh login.",
289
+ );
290
+ }
291
+ await savePendingCredential();
292
+ return { success: true };
293
+ }),
294
+ new Promise((_, rejectPromise) => {
295
+ processTimeout = setTimeout(() => {
296
+ child.kill?.("SIGTERM");
297
+ rejectPromise(
298
+ new Error("The sign-in request expired. Start a fresh login."),
299
+ );
300
+ }, PROCESS_TIMEOUT_MS);
301
+ }),
302
+ ]);
303
+ } finally {
304
+ keepPollingForCredential = false;
305
+ clearTimeout(processTimeout);
306
+ clearTimeout(loginUrlTimeout);
307
+ if (!exitSettled) {
308
+ child.kill?.("SIGTERM");
309
+ }
310
+ try {
311
+ await fileSystem.rm(pendingAuthPath, { force: true });
312
+ } catch {
313
+ // A failed cleanup must not hide the actionable sign-in result.
314
+ }
315
+ }
316
+ })();
317
+ completion.catch(() => {});
318
+
319
+ try {
320
+ const authorizationUrl = await Promise.race([
321
+ authorizationUrlPromise,
322
+ completion.then(
323
+ () => {
324
+ throw new Error(
325
+ "The sign-in command finished without a fresh login link.",
326
+ );
327
+ },
328
+ (error) => {
329
+ throw error;
330
+ },
331
+ ),
332
+ ]);
333
+ clearTimeout(loginUrlTimeout);
334
+ return {
335
+ authorizationUrl,
336
+ completion,
337
+ cancel() {
338
+ child.kill?.("SIGTERM");
339
+ },
340
+ };
341
+ } catch (error) {
342
+ clearTimeout(loginUrlTimeout);
343
+ child.kill?.("SIGTERM");
344
+ try {
345
+ await completion;
346
+ } catch {
347
+ // Preserve the more specific authorization-link error.
348
+ }
349
+ throw error;
350
+ }
351
+ }