@x-tech-laboratories/x-libs 0.14.1 → 0.14.2

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 (150) hide show
  1. package/dist/agent/client.d.ts +32 -0
  2. package/dist/agent/client.js +148 -0
  3. package/dist/agent/commands.d.ts +23 -0
  4. package/dist/agent/commands.js +118 -0
  5. package/dist/agent/keys.d.ts +14 -0
  6. package/dist/agent/keys.js +81 -0
  7. package/dist/agent/long_runs.d.ts +32 -0
  8. package/dist/agent/long_runs.js +316 -0
  9. package/dist/agent/protocol.d.ts +148 -0
  10. package/dist/agent/protocol.js +131 -0
  11. package/dist/agent/session.d.ts +50 -0
  12. package/dist/agent/session.js +225 -0
  13. package/dist/agent/tasks.d.ts +17 -0
  14. package/dist/agent/tasks.js +15 -0
  15. package/dist/backups.d.ts +31 -0
  16. package/dist/backups.js +43 -0
  17. package/dist/dates.d.ts +9 -0
  18. package/dist/dates.js +60 -0
  19. package/dist/durations.d.ts +11 -0
  20. package/dist/durations.js +49 -0
  21. package/dist/electron/app.d.ts +13 -0
  22. package/dist/electron/app.js +27 -0
  23. package/dist/electron/bridge.d.ts +55 -0
  24. package/dist/electron/bridge.js +105 -0
  25. package/dist/electron/events.d.ts +21 -0
  26. package/dist/electron/events.js +25 -0
  27. package/dist/electron/fetch.d.ts +52 -0
  28. package/dist/electron/fetch.js +175 -0
  29. package/dist/electron/window.d.ts +96 -0
  30. package/dist/electron/window.js +128 -0
  31. package/dist/log.d.ts +30 -0
  32. package/dist/log.js +79 -0
  33. package/dist/server/agents/agent.d.ts +27 -0
  34. package/dist/server/agents/agent.js +16 -0
  35. package/dist/server/agents/connection.d.ts +51 -0
  36. package/dist/server/agents/connection.js +239 -0
  37. package/dist/server/agents/hub.d.ts +86 -0
  38. package/dist/server/agents/hub.js +241 -0
  39. package/dist/server/agents/local.d.ts +24 -0
  40. package/dist/server/agents/local.js +62 -0
  41. package/dist/server/agents/route.d.ts +3 -0
  42. package/dist/server/agents/route.js +24 -0
  43. package/dist/server/backup_files.d.ts +3 -0
  44. package/dist/server/backup_files.js +25 -0
  45. package/dist/server/config.d.ts +39 -0
  46. package/dist/server/config.js +109 -0
  47. package/dist/server/cors.d.ts +8 -0
  48. package/dist/server/cors.js +13 -0
  49. package/dist/server/events.d.ts +33 -0
  50. package/dist/server/events.js +68 -0
  51. package/dist/server/hash_password.d.ts +1 -0
  52. package/dist/server/hash_password.js +46 -0
  53. package/dist/server/login_page.d.ts +8 -0
  54. package/dist/server/login_page.js +79 -0
  55. package/dist/server/passwords.d.ts +8 -0
  56. package/dist/server/passwords.js +59 -0
  57. package/dist/server/replies.d.ts +12 -0
  58. package/dist/server/replies.js +41 -0
  59. package/dist/server/sign_in.d.ts +27 -0
  60. package/dist/server/sign_in.js +95 -0
  61. package/dist/server/web_folder.d.ts +11 -0
  62. package/dist/server/web_folder.js +40 -0
  63. package/dist/web/dom.d.ts +7 -0
  64. package/dist/web/dom.js +32 -0
  65. package/dist/web/drag.d.ts +3 -0
  66. package/dist/web/drag.js +13 -0
  67. package/dist/web/errors.d.ts +1 -0
  68. package/dist/web/errors.js +27 -0
  69. package/dist/web/format.d.ts +11 -0
  70. package/dist/web/format.js +82 -0
  71. package/dist/web/menus.d.ts +17 -0
  72. package/dist/web/menus.js +40 -0
  73. package/dist/web/prefs.d.ts +12 -0
  74. package/dist/web/prefs.js +62 -0
  75. package/dist/web/react/badges.d.ts +35 -0
  76. package/dist/web/react/badges.js +32 -0
  77. package/dist/web/react/controls.d.ts +86 -0
  78. package/dist/web/react/controls.js +154 -0
  79. package/dist/web/react/dialog.d.ts +45 -0
  80. package/dist/web/react/dialog.js +82 -0
  81. package/dist/web/react/following.d.ts +25 -0
  82. package/dist/web/react/following.js +34 -0
  83. package/dist/web/react/layout.d.ts +111 -0
  84. package/dist/web/react/layout.js +85 -0
  85. package/dist/web/react/list_picker.d.ts +19 -0
  86. package/dist/web/react/list_picker.js +50 -0
  87. package/dist/web/react/markdown.d.ts +8 -0
  88. package/dist/web/react/markdown.js +40 -0
  89. package/dist/web/react/media.d.ts +24 -0
  90. package/dist/web/react/media.js +18 -0
  91. package/dist/web/react/menu_button.d.ts +10 -0
  92. package/dist/web/react/menu_button.js +16 -0
  93. package/dist/web/react/menus.d.ts +31 -0
  94. package/dist/web/react/page_view.d.ts +16 -0
  95. package/dist/web/react/page_view.js +6 -0
  96. package/dist/web/react/prefs.d.ts +2 -0
  97. package/dist/web/react/prefs.js +11 -0
  98. package/dist/web/react/progress.d.ts +10 -0
  99. package/dist/web/react/progress.js +9 -0
  100. package/dist/web/react/rating.d.ts +8 -0
  101. package/dist/web/react/rating.js +23 -0
  102. package/dist/web/react/reveal.d.ts +10 -0
  103. package/dist/web/react/reveal.js +30 -0
  104. package/dist/web/react/shell.d.ts +46 -0
  105. package/dist/web/react/shell.js +94 -0
  106. package/dist/web/react/shortcuts.d.ts +23 -0
  107. package/dist/web/react/shortcuts.js +95 -0
  108. package/dist/web/react/split.d.ts +23 -0
  109. package/dist/web/react/split.js +94 -0
  110. package/dist/web/react/states.d.ts +36 -0
  111. package/dist/web/react/states.js +64 -0
  112. package/dist/web/react/store.d.ts +4 -0
  113. package/dist/web/react/store.js +7 -0
  114. package/dist/web/react/suggestions.d.ts +23 -0
  115. package/dist/web/react/suggestions.js +57 -0
  116. package/dist/web/react/table.d.ts +78 -0
  117. package/dist/web/react/table.js +404 -0
  118. package/dist/web/react/test_stubs.d.ts +1 -0
  119. package/dist/web/react/test_stubs.js +25 -0
  120. package/dist/web/react/time.d.ts +5 -0
  121. package/dist/web/react/time.js +19 -0
  122. package/dist/web/react/toasts.d.ts +29 -0
  123. package/dist/web/react/toasts.js +72 -0
  124. package/dist/web/react/tree.d.ts +28 -0
  125. package/dist/web/react/tree.js +183 -0
  126. package/dist/web/react/volume.d.ts +8 -0
  127. package/dist/web/react/volume.js +42 -0
  128. package/dist/web/react/watch.d.ts +3 -0
  129. package/dist/web/react/watch.js +5 -0
  130. package/dist/web/router.d.ts +7 -0
  131. package/dist/web/router.js +29 -0
  132. package/dist/web/selection.d.ts +25 -0
  133. package/dist/web/selection.js +106 -0
  134. package/dist/web/split.d.ts +15 -0
  135. package/dist/web/split.js +104 -0
  136. package/dist/web/states.d.ts +3 -0
  137. package/dist/web/states.js +10 -0
  138. package/dist/web/store.d.ts +11 -0
  139. package/dist/web/store.js +17 -0
  140. package/dist/web/tables.d.ts +28 -0
  141. package/dist/web/tables.js +88 -0
  142. package/dist/web/theme.d.ts +14 -0
  143. package/dist/web/theme.js +52 -0
  144. package/dist/web/tree.d.ts +40 -0
  145. package/dist/web/tree.js +130 -0
  146. package/dist/web/virtual.d.ts +7 -0
  147. package/dist/web/virtual.js +10 -0
  148. package/dist/web/watch.d.ts +11 -0
  149. package/dist/web/watch.js +37 -0
  150. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ // Server-sent events: each open page keeps one stream and is told through it when the data changed, so it fetches at
2
+ // once instead of polling. Every stream gets a client id, so the page that made a change is not told about its own.
3
+ // ── Imports ──────────────────────────────────────────────────────────────────
4
+ import { randomUUID } from "node:crypto";
5
+ // ── Settings ─────────────────────────────────────────────────────────────────
6
+ export const default_client_id_header = "x-client-id";
7
+ // ── Stream ───────────────────────────────────────────────────────────────────
8
+ export class EventStream {
9
+ heartbeat_ms;
10
+ retry_ms;
11
+ hello_event;
12
+ changed_event;
13
+ client_id_header;
14
+ streams = new Map();
15
+ constructor(options = {}) {
16
+ this.heartbeat_ms = options.heartbeat_ms ?? 25_000;
17
+ this.retry_ms = options.retry_ms ?? 3000;
18
+ this.hello_event = options.hello_event === undefined ? "hello" : options.hello_event;
19
+ this.changed_event = options.changed_event ?? "changed";
20
+ this.client_id_header = options.client_id_header ?? default_client_id_header;
21
+ }
22
+ get client_count() {
23
+ return this.streams.size;
24
+ }
25
+ // The client id a request carries, or "" for one that sent none.
26
+ client_id_of(request) {
27
+ return String(request.headers[this.client_id_header] ?? "");
28
+ }
29
+ send(data, options = {}) {
30
+ const lines = data.split("\n").map((line) => `data: ${line}`).join("\n");
31
+ const text = `${options.event === undefined ? "" : `event: ${options.event}\n`}${lines}\n\n`;
32
+ for (const { client, response } of this.streams.values()) {
33
+ if (client.id === options.except_client_id)
34
+ continue;
35
+ if (options.to !== undefined && !options.to(client))
36
+ continue;
37
+ response.write(text);
38
+ }
39
+ }
40
+ // The page that made a change already shows it, so only the others are told.
41
+ announce_change(except_client_id = "") {
42
+ this.send("", { event: this.changed_event, except_client_id });
43
+ }
44
+ // Serves the stream on the path, and ends every stream when the app closes, since one never ends by itself.
45
+ register(app, path = "/events") {
46
+ app.get(path, (request, reply) => {
47
+ reply.hijack();
48
+ const response = reply.raw;
49
+ const id = randomUUID();
50
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" });
51
+ response.write(`retry: ${this.retry_ms}\n\n`);
52
+ if (this.hello_event !== null)
53
+ response.write(`event: ${this.hello_event}\ndata: ${id}\n\n`);
54
+ this.streams.set(id, { client: { id, request }, response });
55
+ const heartbeat = setInterval(() => response.write(": still here\n\n"), this.heartbeat_ms);
56
+ request.raw.on("close", () => {
57
+ clearInterval(heartbeat);
58
+ this.streams.delete(id);
59
+ });
60
+ });
61
+ app.addHook("onClose", async () => this.close());
62
+ }
63
+ close() {
64
+ for (const { response } of this.streams.values())
65
+ response.end();
66
+ this.streams.clear();
67
+ }
68
+ }
@@ -0,0 +1 @@
1
+ export declare function hash_password_command(variable: string, argv?: string[]): Promise<void>;
@@ -0,0 +1,46 @@
1
+ // The `hash-password` command: adds a user to the accounts file, or changes their password. Asks for the password at a
2
+ // terminal, or reads it from stdin, so it never lands in argv or shell history.
3
+ // ── Imports ──────────────────────────────────────────────────────────────────
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { text } from "node:stream/consumers";
6
+ import password from "@inquirer/password";
7
+ import { account_name_pattern, hash_password, set_account } from "./passwords.js";
8
+ // ── Asking ───────────────────────────────────────────────────────────────────
9
+ // Asks twice at a terminal without echoing, on stderr so stdout stays clean; reads one line from a pipe.
10
+ async function read_password() {
11
+ if (!process.stdin.isTTY)
12
+ return (await text(process.stdin)).replace(/\r?\n$/, "");
13
+ const ask = (message) => password({ message, mask: false }, { output: process.stderr });
14
+ try {
15
+ const first = await ask("Password:");
16
+ if (first !== (await ask("Again:"))) {
17
+ console.error("The passwords differ.");
18
+ process.exit(1);
19
+ }
20
+ return first;
21
+ }
22
+ catch (error) {
23
+ // Ctrl-C at the prompt ends the command as a shell would.
24
+ if (error.name === "ExitPromptError")
25
+ process.exit(130);
26
+ throw error;
27
+ }
28
+ }
29
+ // ── Command ──────────────────────────────────────────────────────────────────
30
+ // Runs the command with `<name> [file]` from argv; `variable` is the environment variable the app reads the file from.
31
+ export async function hash_password_command(variable, argv = process.argv.slice(2)) {
32
+ const name = argv[0] ?? "";
33
+ const file_path = argv[1] ?? "password.hash";
34
+ if (!account_name_pattern.test(name)) {
35
+ console.error("Usage: npm run hash-password -- <name> [file], the name of lowercase letters, digits, '.', '_' and '-'.");
36
+ process.exit(1);
37
+ }
38
+ const password = await read_password();
39
+ if (!password) {
40
+ console.error("The password is empty.");
41
+ process.exit(1);
42
+ }
43
+ const existing = existsSync(file_path) ? readFileSync(file_path, "utf8") : "";
44
+ writeFileSync(file_path, set_account(existing, name, hash_password(password)), { mode: 0o600 });
45
+ console.error(`Set ${name}'s password in ${file_path}; point ${variable} at it.`);
46
+ }
@@ -0,0 +1,8 @@
1
+ export interface LoginPageOptions {
2
+ title: string;
3
+ has_failed?: boolean;
4
+ action?: string;
5
+ prompt?: string;
6
+ failure_text?: string;
7
+ }
8
+ export declare function render_login_page(options: LoginPageOptions): string;
@@ -0,0 +1,79 @@
1
+ // The page a browser signs in on with the server's access token: a page of its own, so signing in needs nothing from
2
+ // the app's built files, with colours that follow the light and dark themes.
3
+ function escape_html(text) {
4
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5
+ }
6
+ export function render_login_page(options) {
7
+ const title = escape_html(options.title);
8
+ const action = escape_html(options.action ?? "login");
9
+ const prompt = escape_html(options.prompt ?? "Enter the access token this server was set up with.");
10
+ const failure_text = escape_html(options.failure_text ?? "That token is not right.");
11
+ return `<!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16
+ <title>${title}</title>
17
+ <style>
18
+ :root {
19
+ color-scheme: light dark;
20
+ --surface-root: #f1f5f9;
21
+ --surface-default: #ffffff;
22
+ --text-heading: #0f172a;
23
+ --text-secondary: #475569;
24
+ --border-control: #8792a3;
25
+ --accent: #2563eb;
26
+ --text-on-fill: #fff;
27
+ --status-danger: #dc2626;
28
+ }
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --surface-root: #060b14;
32
+ --surface-default: #16213a;
33
+ --text-heading: #f8fafc;
34
+ --text-secondary: #cbd5e1;
35
+ --border-control: #64748b;
36
+ --accent: #60a5fa;
37
+ --text-on-fill: #0b1220;
38
+ --status-danger: #f87171;
39
+ }
40
+ }
41
+ body {
42
+ margin: 0;
43
+ min-height: 100vh;
44
+ display: grid;
45
+ place-items: center;
46
+ padding: 0 16px;
47
+ background: var(--surface-root);
48
+ color: var(--text-heading);
49
+ font: 15px system-ui, sans-serif;
50
+ }
51
+ form {
52
+ width: min(340px, 100%);
53
+ display: grid;
54
+ gap: 12px;
55
+ padding: 24px;
56
+ border-radius: 12px;
57
+ background: var(--surface-default);
58
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
59
+ }
60
+ h1 { margin: 0; font-size: 20px; }
61
+ p { margin: 0; color: var(--text-secondary); }
62
+ .error { color: var(--status-danger); }
63
+ input, button { font: inherit; padding: 8px 10px; border-radius: 8px; }
64
+ input { border: 1px solid var(--border-control); background: transparent; color: inherit; }
65
+ button { border: 0; background: var(--accent); color: var(--text-on-fill); cursor: pointer; }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <form method="post" action="${action}">
70
+ <h1>${title}</h1>
71
+ <p>${prompt}</p>
72
+ ${options.has_failed ? `<p class="error">${failure_text}</p>` : ""}
73
+ <input type="password" name="token" autocomplete="current-password" aria-label="Access token" autofocus required />
74
+ <button type="submit">Sign in</button>
75
+ </form>
76
+ </body>
77
+ </html>
78
+ `;
79
+ }
@@ -0,0 +1,8 @@
1
+ export type Accounts = Map<string, string>;
2
+ export declare const account_name_pattern: RegExp;
3
+ export declare const default_account_name = "admin";
4
+ export declare function hash_password(password: string): string;
5
+ export declare function verify_password(password: string, stored: string): boolean;
6
+ export declare function parse_accounts(text: string, source?: string): Accounts;
7
+ export declare function load_accounts(variable: string, file_path?: string | undefined): Accounts;
8
+ export declare function set_account(text: string, name: string, stored: string): string;
@@ -0,0 +1,59 @@
1
+ // Password hashes: scrypt with a random salt, and the accounts file an app logs in with, one `name:hash` line per user.
2
+ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ const key_length = 64;
5
+ export const account_name_pattern = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
6
+ // A line holding only a hash logs in as this user.
7
+ export const default_account_name = "admin";
8
+ // Stored as `scrypt$<salt hex>$<hash hex>`.
9
+ export function hash_password(password) {
10
+ const salt = randomBytes(16);
11
+ return `scrypt$${salt.toString("hex")}$${scryptSync(password, salt, key_length).toString("hex")}`;
12
+ }
13
+ export function verify_password(password, stored) {
14
+ const [scheme, salt_hex, hash_hex] = stored.trim().split("$");
15
+ if (scheme !== "scrypt" || !salt_hex || !hash_hex)
16
+ return false;
17
+ const expected = Buffer.from(hash_hex, "hex");
18
+ const actual = scryptSync(password, Buffer.from(salt_hex, "hex"), expected.length);
19
+ return timingSafeEqual(actual, expected);
20
+ }
21
+ // Reads `name:hash` lines, skipping blank lines and `#` comments.
22
+ export function parse_accounts(text, source = "the accounts file") {
23
+ const accounts = new Map();
24
+ for (const [index, raw_line] of text.split("\n").entries()) {
25
+ const line = raw_line.trim();
26
+ if (line === "" || line.startsWith("#"))
27
+ continue;
28
+ const separator = line.indexOf(":");
29
+ const name = separator === -1 ? default_account_name : line.slice(0, separator);
30
+ const stored = separator === -1 ? line : line.slice(separator + 1);
31
+ const where = `${source} line ${index + 1}`;
32
+ if (!account_name_pattern.test(name))
33
+ throw new Error(`${where}: '${name}' is not a user name of lowercase letters, digits, '.', '_' and '-'.`);
34
+ if (!stored.startsWith("scrypt$"))
35
+ throw new Error(`${where} does not hold a hash from \`npm run hash-password\`.`);
36
+ if (accounts.has(name))
37
+ throw new Error(`${where} names '${name}' a second time.`);
38
+ accounts.set(name, stored);
39
+ }
40
+ return accounts;
41
+ }
42
+ // The accounts in the file the environment variable names, such as X_NIX_PASSWORD_HASH_FILE.
43
+ export function load_accounts(variable, file_path = process.env[variable]) {
44
+ if (!file_path)
45
+ throw new Error(`${variable} is not set; create one with \`npm run hash-password -- <name> <file>\`.`);
46
+ const accounts = parse_accounts(readFileSync(file_path, "utf8"), file_path);
47
+ if (accounts.size === 0)
48
+ throw new Error(`${file_path} holds no users; add one with \`npm run hash-password -- <name> ${file_path}\`.`);
49
+ return accounts;
50
+ }
51
+ // Sets one user's line in the accounts file's text, replacing their old one, whether it names them or holds only a hash.
52
+ export function set_account(text, name, stored) {
53
+ const names_user = (line) => {
54
+ const trimmed = line.trim();
55
+ return trimmed.startsWith(`${name}:`) || (name === default_account_name && trimmed.startsWith("scrypt$"));
56
+ };
57
+ const kept = text.split("\n").filter((line) => line.trim() !== "" && !names_user(line));
58
+ return [...kept, `${name}:${stored}`].join("\n") + "\n";
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { FastifyInstance, FastifyReply } from "fastify";
2
+ export interface ErrorRepliesOptions {
3
+ format?: "text" | "json";
4
+ not_found_text?: string;
5
+ not_found?: boolean;
6
+ log_failure?: (message: string, error: Error) => void;
7
+ }
8
+ export declare const BAD_JSON: unique symbol;
9
+ export declare function send_text(reply: FastifyReply, status: number, text: string): FastifyReply;
10
+ export declare function send_error(reply: FastifyReply, status: number, text: string, format?: "text" | "json"): FastifyReply;
11
+ export declare function register_error_replies(app: FastifyInstance, options?: ErrorRepliesOptions): void;
12
+ export declare function register_lenient_json_body(app: FastifyInstance): void;
@@ -0,0 +1,41 @@
1
+ // How a server answers what it could not do: every refusal in plain words, or as JSON { error }, and a JSON body
2
+ // parser that hands the route an empty body as no arguments and a bad one as a marker, so the route answers in its
3
+ // own words instead of Fastify's.
4
+ // What the JSON parser hands a route when the body was not JSON.
5
+ export const BAD_JSON = Symbol("bad json");
6
+ // ── Replies ──────────────────────────────────────────────────────────────────
7
+ export function send_text(reply, status, text) {
8
+ return reply.code(status).type("text/plain; charset=utf-8").send(text);
9
+ }
10
+ export function send_error(reply, status, text, format = "text") {
11
+ if (format === "json")
12
+ return reply.code(status).send({ error: text });
13
+ return send_text(reply, status, text);
14
+ }
15
+ // ── Registration ─────────────────────────────────────────────────────────────
16
+ // Errors answer with their own status and message, and unknown paths with 404, all in the one format.
17
+ export function register_error_replies(app, options = {}) {
18
+ const { format = "text", not_found_text = "not found", not_found = true } = options;
19
+ const log_failure = options.log_failure ?? ((message, error) => console.error(message, error));
20
+ app.setErrorHandler((error, request, reply) => {
21
+ const status = error.statusCode ?? 500;
22
+ if (status >= 500)
23
+ log_failure(`${request.method} ${request.url} failed:`, error);
24
+ return send_error(reply, status, error.message, format);
25
+ });
26
+ if (not_found)
27
+ app.setNotFoundHandler(async (_request, reply) => send_error(reply, 404, not_found_text, format));
28
+ }
29
+ // An empty body parses as undefined and a body that is not JSON as BAD_JSON, for the route to refuse in its own words.
30
+ export function register_lenient_json_body(app) {
31
+ app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
32
+ if (body === "")
33
+ return done(null, undefined);
34
+ try {
35
+ done(null, JSON.parse(body));
36
+ }
37
+ catch {
38
+ done(null, BAD_JSON);
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,27 @@
1
+ import type { FastifyInstance, FastifyRequest } from "fastify";
2
+ export interface SignInGuardOptions {
3
+ is_signed_in: (request: FastifyRequest) => boolean;
4
+ api_prefix?: string;
5
+ open_paths?: Iterable<string>;
6
+ login_path?: string | null;
7
+ refusal?: "text" | "json";
8
+ refusal_text?: string;
9
+ }
10
+ export interface TokenSignInOptions extends Omit<SignInGuardOptions, "is_signed_in"> {
11
+ secret: string;
12
+ title: string;
13
+ cookie_name?: string;
14
+ session_seconds?: number;
15
+ failed_login_delay_ms?: number;
16
+ login_path?: string;
17
+ logout_path?: string;
18
+ home_path?: string;
19
+ render_login_page?: (has_failed: boolean) => string;
20
+ }
21
+ export declare function same_secret(first: string, second: string): boolean;
22
+ export declare function session_value(secret: string, cookie_name: string): string;
23
+ export declare function cookie_name_for(title: string): string;
24
+ export declare function is_token_signed_in(request: FastifyRequest, secret: string, cookie_name: string): boolean;
25
+ export declare function path_of(request: FastifyRequest): string;
26
+ export declare function register_sign_in_guard(app: FastifyInstance, options: SignInGuardOptions): void;
27
+ export declare function register_token_sign_in(app: FastifyInstance, options: TokenSignInOptions): Promise<void>;
@@ -0,0 +1,95 @@
1
+ // Signing in to a server that has one secret: a browser posts it on the login page and keeps a cookie derived from it,
2
+ // other programs send it as a bearer header, and the guard turns away every request that carries neither. The guard
3
+ // alone also serves servers with their own sessions, which hand it the check.
4
+ // ── Imports ──────────────────────────────────────────────────────────────────
5
+ import { createHmac, timingSafeEqual } from "node:crypto";
6
+ import fastify_cookie from "@fastify/cookie";
7
+ import fastify_formbody from "@fastify/formbody";
8
+ import { render_login_page } from "./login_page.js";
9
+ // ── Settings ─────────────────────────────────────────────────────────────────
10
+ const default_session_seconds = 90 * 24 * 60 * 60;
11
+ const default_failed_login_delay_ms = 1000;
12
+ // ── Secrets ──────────────────────────────────────────────────────────────────
13
+ export function same_secret(first, second) {
14
+ const first_bytes = Buffer.from(first);
15
+ const second_bytes = Buffer.from(second);
16
+ return first_bytes.length === second_bytes.length && timingSafeEqual(first_bytes, second_bytes);
17
+ }
18
+ // The cookie holds a value derived from the secret, so changing the secret signs every browser out.
19
+ export function session_value(secret, cookie_name) {
20
+ return createHmac("sha256", secret).update(cookie_name).digest("hex");
21
+ }
22
+ export function cookie_name_for(title) {
23
+ return `${title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_").replaceAll(/^_|_$/g, "")}_session`;
24
+ }
25
+ // A browser signs in with its cookie; other programs can send the secret as a bearer header instead.
26
+ export function is_token_signed_in(request, secret, cookie_name) {
27
+ if (secret === "")
28
+ return true;
29
+ const bearer = /^Bearer (.+)$/.exec(request.headers.authorization ?? "")?.[1];
30
+ if (bearer !== undefined)
31
+ return same_secret(bearer, secret);
32
+ return same_secret(request.cookies[cookie_name] ?? "", session_value(secret, cookie_name));
33
+ }
34
+ // A proxy that ends TLS says so in a header, and the cookie is then kept to secure connections.
35
+ function is_secure(request) {
36
+ return request.headers["x-forwarded-proto"] === "https" || "encrypted" in request.raw.socket;
37
+ }
38
+ function set_session_cookie(request, reply, cookie_name, value, max_age_seconds) {
39
+ void reply.setCookie(cookie_name, value, { path: "/", httpOnly: true, sameSite: "strict", maxAge: max_age_seconds, secure: is_secure(request) });
40
+ }
41
+ // ── Guard ────────────────────────────────────────────────────────────────────
42
+ export function path_of(request) {
43
+ return request.url.split("?")[0] ?? "";
44
+ }
45
+ // Turns away every request that is not signed in: a page load goes to the login page, while a call gets a refusal it can act on.
46
+ export function register_sign_in_guard(app, options) {
47
+ const { is_signed_in, api_prefix = "/api/", login_path = "/login", refusal = "text", refusal_text = "sign in first" } = options;
48
+ const open_paths = new Set(options.open_paths ?? []);
49
+ app.addHook("onRequest", async (request, reply) => {
50
+ const path = path_of(request);
51
+ // Preflight checks carry no credentials, and the sign-in routes are open by nature.
52
+ if (request.method === "OPTIONS" || open_paths.has(path))
53
+ return;
54
+ if (is_signed_in(request))
55
+ return;
56
+ if (!path.startsWith(api_prefix)) {
57
+ if (login_path === null)
58
+ return;
59
+ return reply.redirect(login_path, 303);
60
+ }
61
+ void reply.code(401).header("cache-control", "no-store");
62
+ if (refusal === "json")
63
+ return reply.send({ error: refusal_text });
64
+ return reply.type("text/plain; charset=utf-8").send(refusal_text);
65
+ });
66
+ }
67
+ // ── Token sign-in ────────────────────────────────────────────────────────────
68
+ // Signs browsers in and out with the server's one secret, and guards everything else.
69
+ export async function register_token_sign_in(app, options) {
70
+ const { secret, title, login_path = "/login", logout_path = "/logout", home_path = "/", session_seconds = default_session_seconds, failed_login_delay_ms = default_failed_login_delay_ms, } = options;
71
+ const cookie_name = options.cookie_name ?? cookie_name_for(title);
72
+ const render = options.render_login_page ?? ((has_failed) => render_login_page({ title, has_failed, action: login_path }));
73
+ await app.register(fastify_cookie);
74
+ await app.register(fastify_formbody);
75
+ register_sign_in_guard(app, {
76
+ ...options,
77
+ login_path,
78
+ open_paths: [login_path, logout_path, ...(options.open_paths ?? [])],
79
+ is_signed_in: (request) => is_token_signed_in(request, secret, cookie_name),
80
+ });
81
+ app.get(login_path, async (_request, reply) => reply.type("text/html; charset=utf-8").send(render(false)));
82
+ app.post(login_path, async (request, reply) => {
83
+ const token = request.body?.token ?? "";
84
+ if (secret === "" || same_secret(token, secret)) {
85
+ set_session_cookie(request, reply, cookie_name, session_value(secret, cookie_name), session_seconds);
86
+ return reply.redirect(home_path, 303);
87
+ }
88
+ await new Promise((wait) => setTimeout(wait, failed_login_delay_ms));
89
+ return reply.code(401).type("text/html; charset=utf-8").send(render(true));
90
+ });
91
+ app.get(logout_path, async (request, reply) => {
92
+ set_session_cookie(request, reply, cookie_name, "", 0);
93
+ return reply.redirect(login_path, 303);
94
+ });
95
+ }
@@ -0,0 +1,11 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ export interface WebFolderOptions {
3
+ folder: string;
4
+ immutable_path?: string;
5
+ page_cache_control?: string;
6
+ when_missing?: "warn" | "throw" | "skip";
7
+ page_fallback?: boolean;
8
+ api_prefix?: string;
9
+ api_not_found?: (request: FastifyRequest, reply: FastifyReply) => unknown;
10
+ }
11
+ export declare function register_web_folder(app: FastifyInstance, options: WebFolderOptions): Promise<boolean>;
@@ -0,0 +1,40 @@
1
+ // The built web page served from a folder, tuned for files from the Nix store: those are all dated 1970 and a rebuilt
2
+ // page keeps its size, so the size-and-date ETag is off and the page is fetched whole each time, while the bundles
3
+ // carry a content hash in their name and are kept for a year.
4
+ // ── Imports ──────────────────────────────────────────────────────────────────
5
+ import { existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import fastify_static from "@fastify/static";
8
+ // ── Registration ─────────────────────────────────────────────────────────────
9
+ // Whether the folder was found and is being served.
10
+ export async function register_web_folder(app, options) {
11
+ const { immutable_path = "/assets/", page_cache_control = "no-cache", when_missing = "warn", api_prefix = "/api/" } = options;
12
+ const folder = resolve(options.folder);
13
+ if (!existsSync(folder)) {
14
+ if (when_missing === "throw")
15
+ throw new Error(`The web page is not built; nothing is at ${folder}`);
16
+ if (when_missing === "warn")
17
+ console.warn(`no web page found at ${folder}; only the API is served`);
18
+ return false;
19
+ }
20
+ await app.register(fastify_static, {
21
+ root: folder,
22
+ etag: false,
23
+ lastModified: false,
24
+ cacheControl: false,
25
+ setHeaders: (reply, path) => {
26
+ const immutable = path.includes(immutable_path);
27
+ void reply.header("cache-control", immutable ? "public, max-age=31536000, immutable" : page_cache_control);
28
+ },
29
+ });
30
+ if (options.page_fallback) {
31
+ const api_not_found = options.api_not_found ?? ((_request, reply) => reply.code(404).type("text/plain; charset=utf-8").send("not found"));
32
+ app.setNotFoundHandler((request, reply) => {
33
+ const path = request.url.split("?")[0] ?? "";
34
+ if (path.startsWith(api_prefix) || (request.method !== "GET" && request.method !== "HEAD"))
35
+ return api_not_found(request, reply);
36
+ return reply.header("cache-control", page_cache_control).sendFile("index.html");
37
+ });
38
+ }
39
+ return true;
40
+ }
@@ -0,0 +1,7 @@
1
+ export type ClassValue = string | false | null | undefined;
2
+ export declare function cx(...names: ClassValue[]): string;
3
+ export type Child = Node | string | number | null | undefined | false | Child[];
4
+ type Attributes = Record<string, string | number | boolean | EventListener | null | undefined>;
5
+ export declare function h<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attributes?: Attributes, ...children: Child[]): HTMLElementTagNameMap[Tag];
6
+ export declare function nodes(children: Child): (Node | string)[];
7
+ export {};
@@ -0,0 +1,32 @@
1
+ // Builds elements without innerHTML, so names from hosts and logs are always shown as text, and joins class names.
2
+ // Joins the class names that are set, so conditional classes read as one expression.
3
+ export function cx(...names) {
4
+ return names.filter(Boolean).join(" ");
5
+ }
6
+ export function h(tag, attributes = {}, ...children) {
7
+ const element = document.createElement(tag);
8
+ for (const [name, value] of Object.entries(attributes)) {
9
+ if (value === null || value === undefined || value === false)
10
+ continue;
11
+ if (typeof value === "function")
12
+ element.addEventListener(name.replace(/^on/, ""), value);
13
+ else if (value === true)
14
+ element.setAttribute(name, "");
15
+ else
16
+ element.setAttribute(name, String(value));
17
+ }
18
+ element.append(...nodes(children));
19
+ return element;
20
+ }
21
+ // The nodes a child stands for, with lists flattened and the empty values dropped.
22
+ export function nodes(children) {
23
+ if (!Array.isArray(children))
24
+ children = [children];
25
+ return children.flatMap((child) => {
26
+ if (Array.isArray(child))
27
+ return nodes(child);
28
+ if (child === null || child === undefined || child === false)
29
+ return [];
30
+ return [child instanceof Node ? child : String(child)];
31
+ });
32
+ }
@@ -0,0 +1,3 @@
1
+ export declare function set_drag_badge(event: {
2
+ dataTransfer: DataTransfer | null;
3
+ }, label: string, class_name?: string): void;
@@ -0,0 +1,13 @@
1
+ // What a drag shows under the pointer.
2
+ // Shows a small badge such as "3 tasks" under the pointer as the drag image, in place of a picture of the grabbed row.
3
+ export function set_drag_badge(event, label, class_name = "drag-badge") {
4
+ if (!event.dataTransfer)
5
+ return;
6
+ const badge = document.createElement("div");
7
+ badge.className = class_name;
8
+ badge.textContent = label;
9
+ document.body.append(badge);
10
+ event.dataTransfer.setDragImage(badge, -12, -12);
11
+ // The browser takes its picture of the badge during this event, so it can go once the event is over.
12
+ setTimeout(() => badge.remove(), 0);
13
+ }
@@ -0,0 +1 @@
1
+ export declare function describe_error(error: unknown): string;
@@ -0,0 +1,27 @@
1
+ // Words for anything thrown, since libraries throw plain objects and strings as well as Errors.
2
+ // An Error's message, a shaka-style object's category and code, a string as it is, and anything else as JSON.
3
+ export function describe_error(error) {
4
+ if (error instanceof Error)
5
+ return error.message || error.name;
6
+ if (typeof error === "string")
7
+ return error;
8
+ if (error === null || error === undefined)
9
+ return "Unknown error";
10
+ if (typeof error !== "object")
11
+ return String(error);
12
+ const coded = error;
13
+ if (typeof coded.code === "number") {
14
+ const name = typeof coded.category === "number" ? `${coded.category}.${coded.code}` : String(coded.code);
15
+ const text = typeof coded.message === "string" && coded.message ? coded.message : undefined;
16
+ const detail = text ?? (Array.isArray(coded.data) ? coded.data.find((item) => typeof item === "string") : undefined);
17
+ return detail ? `Error ${name}: ${detail}` : `Error ${name}`;
18
+ }
19
+ if (typeof coded.message === "string" && coded.message)
20
+ return coded.message;
21
+ try {
22
+ return JSON.stringify(error);
23
+ }
24
+ catch {
25
+ return String(error);
26
+ }
27
+ }
@@ -0,0 +1,11 @@
1
+ export declare function now_seconds(): number;
2
+ export declare function age(epoch: number | null | undefined): string;
3
+ export declare function until(epoch: number | null): string;
4
+ export declare function duration(seconds: number | null): string;
5
+ export declare function stamp(epoch: number): string;
6
+ export declare function bytes(count: number): string;
7
+ export declare function size(count: number | null | undefined): string;
8
+ export declare function size_change(count: number | null | undefined): string;
9
+ export declare function count_change(value: number | null | undefined): string;
10
+ export declare function plural(count: number, word: string): string;
11
+ export declare function compact_count(count: number): string;