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,489 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+
5
+ import { discoverN8n, discoverNetworks } from "../services/discovery.js";
6
+ import { installSidecar } from "../services/installer.js";
7
+ import {
8
+ getAuthStatus,
9
+ readAuthContents,
10
+ startOAuthLogin,
11
+ } from "../services/oauth.js";
12
+ import {
13
+ connectVerified,
14
+ scanHostFingerprint,
15
+ } from "../infrastructure/ssh.js";
16
+ import {
17
+ validateHostname,
18
+ validatePort,
19
+ } from "../domain/validation.js";
20
+ import { SIDECAR_HOSTNAME } from "../domain/templates.js";
21
+
22
+ const MAX_BODY_BYTES = 32 * 1024;
23
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
24
+ const RATE_LIMIT_MAX = 10;
25
+
26
+ const defaultServices = {
27
+ getAuthStatus,
28
+ readAuthContents,
29
+ startOAuthLogin,
30
+ scanHostFingerprint,
31
+ connectVerified,
32
+ discoverN8n,
33
+ discoverNetworks,
34
+ installSidecar,
35
+ };
36
+
37
+ function setSecurityHeaders(response) {
38
+ response.setHeader(
39
+ "Content-Security-Policy",
40
+ "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
41
+ );
42
+ response.setHeader("X-Content-Type-Options", "nosniff");
43
+ response.setHeader("X-Frame-Options", "DENY");
44
+ response.setHeader("Referrer-Policy", "no-referrer");
45
+ response.setHeader(
46
+ "Permissions-Policy",
47
+ "camera=(), microphone=(), geolocation=()",
48
+ );
49
+ response.setHeader("Cache-Control", "no-store");
50
+ }
51
+
52
+ function sendJson(response, statusCode, body) {
53
+ const contents = JSON.stringify(body);
54
+ response.writeHead(statusCode, {
55
+ "Content-Type": "application/json; charset=utf-8",
56
+ "Content-Length": Buffer.byteLength(contents),
57
+ });
58
+ response.end(contents);
59
+ }
60
+
61
+ function tokenMatches(actual, expected) {
62
+ if (typeof actual !== "string") {
63
+ return false;
64
+ }
65
+ const left = Buffer.from(actual);
66
+ const right = Buffer.from(expected);
67
+ return left.length === right.length && timingSafeEqual(left, right);
68
+ }
69
+
70
+ function requireApiToken(request, state) {
71
+ if (!tokenMatches(request.headers["x-setup-token"], state.sessionToken)) {
72
+ throw Object.assign(new Error("Unauthorized."), { statusCode: 401 });
73
+ }
74
+ }
75
+
76
+ function requireSameOrigin(request, state) {
77
+ if (request.method === "POST" && request.headers.origin !== state.origin) {
78
+ throw Object.assign(new Error("Cross-origin request rejected."), {
79
+ statusCode: 403,
80
+ });
81
+ }
82
+ }
83
+
84
+ function enforceRateLimit(state, key) {
85
+ const now = Date.now();
86
+ const previous = state.rateLimits.get(key) ?? [];
87
+ const recent = previous.filter((time) => now - time < RATE_LIMIT_WINDOW_MS);
88
+ if (recent.length >= RATE_LIMIT_MAX) {
89
+ throw Object.assign(
90
+ new Error("Too many attempts. Wait a few minutes and try again."),
91
+ { statusCode: 429 },
92
+ );
93
+ }
94
+ recent.push(now);
95
+ state.rateLimits.set(key, recent);
96
+ }
97
+
98
+ function readJsonBody(request) {
99
+ return new Promise((resolve, reject) => {
100
+ const chunks = [];
101
+ let bytes = 0;
102
+ let tooLarge = false;
103
+
104
+ request.on("data", (chunk) => {
105
+ bytes += chunk.length;
106
+ if (bytes > MAX_BODY_BYTES) {
107
+ tooLarge = true;
108
+ return;
109
+ }
110
+ chunks.push(Buffer.from(chunk));
111
+ });
112
+ request.once("error", () => {
113
+ reject(new Error("The request body could not be read."));
114
+ });
115
+ request.once("end", () => {
116
+ if (tooLarge) {
117
+ reject(
118
+ Object.assign(new Error("The request body is too large."), {
119
+ statusCode: 413,
120
+ }),
121
+ );
122
+ return;
123
+ }
124
+
125
+ try {
126
+ const text = Buffer.concat(chunks).toString("utf8");
127
+ resolve(text === "" ? {} : JSON.parse(text));
128
+ } catch {
129
+ reject(
130
+ Object.assign(new Error("The request body must be valid JSON."), {
131
+ statusCode: 400,
132
+ }),
133
+ );
134
+ }
135
+ });
136
+ });
137
+ }
138
+
139
+ function requireConnection(state) {
140
+ if (!state.connection) {
141
+ throw new Error("Connect to the VPS first.");
142
+ }
143
+ return state.connection;
144
+ }
145
+
146
+ function requireDiscoveredContainer(state, containerName) {
147
+ const container = state.discovery?.containers.find(
148
+ (candidate) => candidate.name === containerName,
149
+ );
150
+ if (!container) {
151
+ throw new Error("Select an n8n container found by this wizard.");
152
+ }
153
+ return container;
154
+ }
155
+
156
+ function requireDiscoveredNetwork(state, containerName, networkName) {
157
+ requireDiscoveredContainer(state, containerName);
158
+ const networks = state.networksByContainer.get(containerName)?.networks ?? [];
159
+ if (!networks.includes(networkName)) {
160
+ throw new Error("Select a Docker network found by this wizard.");
161
+ }
162
+ return networkName;
163
+ }
164
+
165
+ function safeErrorMessage(error) {
166
+ const message =
167
+ typeof error?.message === "string" ? error.message : "Request failed.";
168
+ if (
169
+ message.length > 240 ||
170
+ /[\r\n]/u.test(message) ||
171
+ /(?:access|refresh)[_-]?token|private[_-]?key|\/Users\//iu.test(message)
172
+ ) {
173
+ return "The request could not be completed safely.";
174
+ }
175
+ return message;
176
+ }
177
+
178
+ async function loadDefaultUiFiles() {
179
+ const files = await Promise.all([
180
+ readFile(new URL("../ui/index.html", import.meta.url), "utf8"),
181
+ readFile(new URL("../ui/app.js", import.meta.url), "utf8"),
182
+ readFile(new URL("../ui/time.js", import.meta.url), "utf8"),
183
+ readFile(new URL("../ui/styles.css", import.meta.url), "utf8"),
184
+ ]);
185
+
186
+ return {
187
+ "/": files[0],
188
+ "/app.js": files[1],
189
+ "/time.js": files[2],
190
+ "/styles.css": files[3],
191
+ };
192
+ }
193
+
194
+ async function handleApi(request, response, path, state) {
195
+ requireApiToken(request, state);
196
+ requireSameOrigin(request, state);
197
+
198
+ if (request.method === "GET" && path === "/api/status") {
199
+ const status = await state.services.getAuthStatus();
200
+ sendJson(response, 200, {
201
+ authExists: status.exists,
202
+ ...(status.exists ? { authUpdatedAt: status.updatedAt } : {}),
203
+ ...(state.previewMode ? { previewMode: true } : {}),
204
+ });
205
+ return;
206
+ }
207
+
208
+ if (request.method === "GET" && path === "/api/oauth/status") {
209
+ const login = state.oauthLogin;
210
+ sendJson(response, 200, {
211
+ status: login?.status ?? "idle",
212
+ ...(login?.status === "error" ? { error: login.error } : {}),
213
+ });
214
+ return;
215
+ }
216
+
217
+ if (request.method !== "POST") {
218
+ sendJson(response, 405, { error: "Method not allowed." });
219
+ return;
220
+ }
221
+
222
+ if (path === "/api/oauth/login") {
223
+ if (state.previewMode) {
224
+ throw Object.assign(
225
+ new Error(
226
+ "Live ChatGPT sign-in is disabled in sanitized preview mode.",
227
+ ),
228
+ { statusCode: 403 },
229
+ );
230
+ }
231
+ enforceRateLimit(state, path);
232
+ if (state.oauthLogin?.status === "pending") {
233
+ state.oauthLogin.attempt.cancel();
234
+ try {
235
+ await state.oauthLogin.attempt.completion;
236
+ } catch {
237
+ // Starting again intentionally replaces the previous local attempt.
238
+ }
239
+ }
240
+
241
+ const attempt = await state.services.startOAuthLogin();
242
+ const login = {
243
+ attempt,
244
+ error: null,
245
+ status: "pending",
246
+ };
247
+ state.oauthLogin = login;
248
+ attempt.completion.then(
249
+ () => {
250
+ if (state.oauthLogin === login) {
251
+ login.status = "success";
252
+ }
253
+ },
254
+ (error) => {
255
+ if (state.oauthLogin === login) {
256
+ login.status = "error";
257
+ login.error = safeErrorMessage(error);
258
+ }
259
+ },
260
+ );
261
+ sendJson(response, 200, { authorizationUrl: attempt.authorizationUrl });
262
+ return;
263
+ }
264
+
265
+ const body = await readJsonBody(request);
266
+
267
+ if (path === "/api/ssh/fingerprint") {
268
+ enforceRateLimit(state, path);
269
+ const host = validateHostname(body.host);
270
+ const port = validatePort(body.port);
271
+ const fingerprint = await state.services.scanHostFingerprint({
272
+ host,
273
+ port,
274
+ });
275
+ state.scannedHost = { host, port, fingerprint };
276
+ sendJson(response, 200, { fingerprint });
277
+ return;
278
+ }
279
+
280
+ if (path === "/api/ssh/connect") {
281
+ enforceRateLimit(state, path);
282
+ const host = validateHostname(body.host);
283
+ const port = validatePort(body.port);
284
+ const scannedHost = state.scannedHost;
285
+ if (
286
+ !scannedHost ||
287
+ scannedHost.host !== host ||
288
+ scannedHost.port !== port ||
289
+ !tokenMatches(body.expectedFingerprint, scannedHost.fingerprint)
290
+ ) {
291
+ throw new Error(
292
+ "The VPS identity confirmation is missing or no longer matches. Check it again.",
293
+ );
294
+ }
295
+
296
+ state.connection?.close();
297
+ state.connection = null;
298
+
299
+ try {
300
+ state.connection = await state.services.connectVerified({
301
+ host,
302
+ port,
303
+ username: body.username,
304
+ password: body.password,
305
+ agent: body.useAgent ? process.env.SSH_AUTH_SOCK : undefined,
306
+ expectedFingerprint: scannedHost.fingerprint,
307
+ });
308
+ } finally {
309
+ body.password = undefined;
310
+ }
311
+
312
+ state.scannedHost = null;
313
+ state.discovery = null;
314
+ state.networksByContainer.clear();
315
+ sendJson(response, 200, { connected: true });
316
+ return;
317
+ }
318
+
319
+ if (path === "/api/discover") {
320
+ const connection = requireConnection(state);
321
+ state.discovery = await state.services.discoverN8n(connection);
322
+ state.networksByContainer.clear();
323
+ sendJson(response, 200, state.discovery);
324
+ return;
325
+ }
326
+
327
+ if (path === "/api/networks") {
328
+ const connection = requireConnection(state);
329
+ requireDiscoveredContainer(state, body.containerName);
330
+ const networks = await state.services.discoverNetworks(
331
+ connection,
332
+ body.containerName,
333
+ );
334
+ state.networksByContainer.set(body.containerName, networks);
335
+ sendJson(response, 200, networks);
336
+ return;
337
+ }
338
+
339
+ if (path === "/api/plan") {
340
+ requireDiscoveredNetwork(
341
+ state,
342
+ body.containerName,
343
+ body.networkName,
344
+ );
345
+ sendJson(response, 200, {
346
+ installDirectory: "/docker/n8n-openai-oauth",
347
+ sidecarProject: "n8n-openai-oauth",
348
+ endpointHostname: SIDECAR_HOSTNAME,
349
+ networkName: body.networkName,
350
+ existingN8nChanges: [],
351
+ existingN8nRestarts: 0,
352
+ publishedPorts: [],
353
+ });
354
+ return;
355
+ }
356
+
357
+ if (path === "/api/install") {
358
+ const connection = requireConnection(state);
359
+ let result;
360
+ try {
361
+ enforceRateLimit(state, path);
362
+ requireDiscoveredNetwork(
363
+ state,
364
+ body.containerName,
365
+ body.networkName,
366
+ );
367
+ const authStatus = await state.services.getAuthStatus();
368
+ if (!authStatus.exists) {
369
+ throw new Error("Sign in with ChatGPT before installing.");
370
+ }
371
+ const authContents = await state.services.readAuthContents({
372
+ authPath: authStatus.path,
373
+ });
374
+ result = await state.services.installSidecar({
375
+ remote: connection,
376
+ networkName: body.networkName,
377
+ authContents,
378
+ confirmed: body.confirmed,
379
+ });
380
+ } finally {
381
+ connection.close();
382
+ if (state.connection === connection) {
383
+ state.connection = null;
384
+ }
385
+ }
386
+
387
+ sendJson(response, 200, result);
388
+ return;
389
+ }
390
+
391
+ if (path === "/api/disconnect") {
392
+ state.connection?.close();
393
+ state.connection = null;
394
+ sendJson(response, 200, { disconnected: true });
395
+ return;
396
+ }
397
+
398
+ sendJson(response, 404, { error: "Not found." });
399
+ }
400
+
401
+ function createRequestHandler(state) {
402
+ return async (request, response) => {
403
+ setSecurityHeaders(response);
404
+
405
+ try {
406
+ const url = new URL(request.url, state.origin);
407
+ const path = url.pathname;
408
+
409
+ if (path.startsWith("/api/")) {
410
+ await handleApi(request, response, path, state);
411
+ return;
412
+ }
413
+
414
+ if (request.method !== "GET" || !(path in state.uiFiles)) {
415
+ sendJson(response, 404, { error: "Not found." });
416
+ return;
417
+ }
418
+
419
+ const contentType =
420
+ path.endsWith(".js")
421
+ ? "text/javascript; charset=utf-8"
422
+ : path === "/styles.css"
423
+ ? "text/css; charset=utf-8"
424
+ : "text/html; charset=utf-8";
425
+ const contents = state.uiFiles[path];
426
+ response.writeHead(200, {
427
+ "Content-Type": contentType,
428
+ "Content-Length": Buffer.byteLength(contents),
429
+ });
430
+ response.end(contents);
431
+ } catch (error) {
432
+ if (!response.headersSent) {
433
+ sendJson(response, error.statusCode ?? 400, {
434
+ error: safeErrorMessage(error),
435
+ });
436
+ } else {
437
+ response.end();
438
+ }
439
+ }
440
+ };
441
+ }
442
+
443
+ export async function startWizardServer({
444
+ sessionToken,
445
+ services = defaultServices,
446
+ uiFiles,
447
+ port = 0,
448
+ previewMode = false,
449
+ } = {}) {
450
+ if (typeof sessionToken !== "string" || sessionToken.length < 32) {
451
+ throw new TypeError("A strong wizard session token is required.");
452
+ }
453
+
454
+ const state = {
455
+ sessionToken,
456
+ services,
457
+ uiFiles: uiFiles ?? (await loadDefaultUiFiles()),
458
+ origin: "http://127.0.0.1",
459
+ connection: null,
460
+ scannedHost: null,
461
+ discovery: null,
462
+ networksByContainer: new Map(),
463
+ oauthLogin: null,
464
+ rateLimits: new Map(),
465
+ previewMode: previewMode === true,
466
+ };
467
+ const server = createServer(createRequestHandler(state));
468
+ server.requestTimeout = 330_000;
469
+ server.headersTimeout = 10_000;
470
+ server.keepAliveTimeout = 5_000;
471
+
472
+ await new Promise((resolve, reject) => {
473
+ server.once("error", reject);
474
+ server.listen(port, "127.0.0.1", resolve);
475
+ });
476
+
477
+ const address = server.address();
478
+ state.origin = `http://127.0.0.1:${address.port}`;
479
+
480
+ return {
481
+ origin: state.origin,
482
+ async close() {
483
+ state.oauthLogin?.attempt.cancel();
484
+ state.connection?.close();
485
+ state.connection = null;
486
+ await new Promise((resolve) => server.close(resolve));
487
+ },
488
+ };
489
+ }