@mason-data/cli 0.0.0-stage → 0.7.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.
- package/LICENSE +202 -0
- package/README.md +268 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +477 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1771 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +588 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +311 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +378 -0
- package/dist/src/duck/plan.js +259 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +1001 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUN'S COMMAND SERVER (`mason start`; a server for the `mason` command alone, the CLI's UI part dropped
|
|
3
|
+
* completely). `mason status`, `mason wait`, `mason sync`, `mason logs --requests`, and `mason add` and `mason remove`
|
|
4
|
+
* while it is up, ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and removed into
|
|
5
|
+
* the project's sources file (src/cli/source-edit.ts, the check-and-write the commands call themselves while Mason is
|
|
6
|
+
* down) — the sync, told of the change, starts copying a table at once — and says where everything is.
|
|
7
|
+
*
|
|
8
|
+
* Every request passes the run's guard (src/cli/security.ts): loopback, the Host header, and the run's token, which
|
|
9
|
+
* the commands send as `Authorization: Bearer` from the run's settings file.
|
|
10
|
+
*
|
|
11
|
+
* What it knows it reads where the run keeps it: the copies from the sync (src/duck/sync.ts), the statements from the
|
|
12
|
+
* request log (src/duck/requests.ts). Added is a fact about the project's files: a table is added when its sources file
|
|
13
|
+
* names it.
|
|
14
|
+
*
|
|
15
|
+
* THE WEB PAGE (src/web/, `mason ui`, docs/WEB-UI.md): under `mason start` it also serves one page and its two files,
|
|
16
|
+
* from an allowlist read when it starts (`pageFiles`), and the users the page manages (`/api/users`, the functions
|
|
17
|
+
* `mason user` calls). The page reads the same JSON the commands read; a refusal carries `failure`, the CLI's Failure
|
|
18
|
+
* shape (message, why, fix, next), so the page says it as the command would. `mason serve`'s run serves no page.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as http from "node:http";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { readBody, sendJson } from "../lib/http-json.js";
|
|
25
|
+
import { projectFingerprint } from "../lib/project-files.js";
|
|
26
|
+
import { guard, SECURITY_HEADERS } from "./security.js";
|
|
27
|
+
import { maskedUrl } from "./preflight.js";
|
|
28
|
+
import { errorJson, nextJson } from "./command.js";
|
|
29
|
+
import { readiness } from "./readiness.js";
|
|
30
|
+
import { addedNext, addUser, listUsers, removeUser, REMOVED_NEXT } from "./user.js";
|
|
31
|
+
import { postgresLink } from "./pg-url.js";
|
|
32
|
+
import { catalogTable, shownPath } from "./sources.js";
|
|
33
|
+
import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
|
|
34
|
+
import { PollRefused } from "../duck/sync.js";
|
|
35
|
+
/** A statement answered in this many milliseconds or more is slow: what mason status lists (src/duck/requests.ts slow). */
|
|
36
|
+
export const SLOW_MS = 1000;
|
|
37
|
+
/**
|
|
38
|
+
* The run's state, in the shape mason status / wait read (`RunState`, src/cli/commands.ts): each table of the database,
|
|
39
|
+
* added or not, and each added one's copy as the sync has it, so `mason wait` is ready exactly when every added table is
|
|
40
|
+
* copied.
|
|
41
|
+
*/
|
|
42
|
+
export function runState(ctx, copies, stream) {
|
|
43
|
+
const folder = ctx.folder();
|
|
44
|
+
const sources = ctx.sources();
|
|
45
|
+
const by = new Map(copies.map((c) => [c.source, c]));
|
|
46
|
+
const project = folder && fs.existsSync(folder) ? projectFingerprint(folder) : null;
|
|
47
|
+
const facts = ctx.facts();
|
|
48
|
+
const tables = facts.tables.map((t) => {
|
|
49
|
+
const c = by.get(t.source);
|
|
50
|
+
return {
|
|
51
|
+
...catalogTable(t, sources.file?.tables.find((e) => e.table === t.source)),
|
|
52
|
+
// a table the sync has not reached is waiting for its copy (no copy yet), never "copying 0"
|
|
53
|
+
// a copy in a retired layout (src/duck/sync.ts RETIRED_LAYOUT) serves until it is copied again: done, and said
|
|
54
|
+
copy: c && c.phase !== "waiting" ? { phase: (c.phase === "copied" ? "done" : c.phase === "failed" ? "failed" : "copying"),
|
|
55
|
+
rowsCopied: c.phase === "copied" ? c.rows ?? 0 : c.rowsSoFar ?? 0, updatedAt: c.copiedAt ?? "",
|
|
56
|
+
// while its chunks are read: the chunks done and reading, the measured rate, the time left (src/duck/sync.ts
|
|
57
|
+
// copyProgressNow), so mason wait and status move between two commits
|
|
58
|
+
...(c.phase === "copying" && c.progress !== undefined ? { progress: c.progress } : {}),
|
|
59
|
+
...(c.phase === "copied" && c.retired !== undefined ? { retired: c.retired } : {}) } : null,
|
|
60
|
+
error: c?.phase === "failed" ? c.error : null,
|
|
61
|
+
// copied and served, but not kept current by the poll (src/duck/sync.ts POLL_ROW_LIMIT): mason status and wait say so
|
|
62
|
+
...(c?.unsynced ? { unsynced: c.unsynced } : {}),
|
|
63
|
+
// how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`):
|
|
64
|
+
// what `unsynced` and the stream's tables say for mason status, and EXPLAIN says over the wire, as data
|
|
65
|
+
...(c?.kept ? { kept: c.kept } : {}),
|
|
66
|
+
...(c ? { duck: { rows: c.rows, bytes: c.bytes, seconds: c.seconds, copiedAt: c.copiedAt, error: c.error } } : {}),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
// what mason wait waits on and mason status prints last (src/cli/readiness.ts): said by the run, so the page and the
|
|
71
|
+
// commands say one thing
|
|
72
|
+
readiness: readiness({ tables }),
|
|
73
|
+
// the run serves the web page (mason ui): mason status prints its URL
|
|
74
|
+
ui: ctx.page,
|
|
75
|
+
source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
|
|
76
|
+
version: facts.versionText, standby: facts.standby, changes: ctx.changes, name: sources.file?.name ?? null, file: sources.file?.file ?? null,
|
|
77
|
+
...(ctx.publication ? { replication: { slot: ctx.slot, publication: ctx.publication.name, owned: ctx.publication.owned } } : {}) },
|
|
78
|
+
// a sources file edited into a mistake while the run is up: the run copies what it read last, and says so
|
|
79
|
+
warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason copies the tables it read before; mason start reads the file again)`] : [])],
|
|
80
|
+
folder, project, tables,
|
|
81
|
+
engine: { name: "duckdb", file: ctx.duck.file, bytes: fileBytes(ctx.duck.file), pollSeconds: ctx.duck.pollSeconds },
|
|
82
|
+
jsonPaths: ctx.duck.jsonPaths?.() ?? [],
|
|
83
|
+
// replication mode: where the stream is, its lag and what it applied (mason status and wait render it, streamLines)
|
|
84
|
+
...(stream !== null ? { stream } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** a file's size, or null where it cannot be read (the run's own DuckDB file: it exists while the run does) */
|
|
88
|
+
function fileBytes(file) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.statSync(file).size;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
} // a size is a nicety of mason status: unknown, not an error
|
|
95
|
+
}
|
|
96
|
+
async function state(ctx) {
|
|
97
|
+
return runState(ctx, ctx.duck.copies(), await ctx.duck.stream());
|
|
98
|
+
}
|
|
99
|
+
/** The sources file as it is on disk now (src/cli/source-edit.ts sourceToEdit) — and the database it names NOW must be
|
|
100
|
+
* the one this run copies: its catalog is what an edit is checked against. */
|
|
101
|
+
function sourceOnDisk(ctx) {
|
|
102
|
+
const at = sourceToEdit(ctx.folder(), ctx.root, ctx.readSource);
|
|
103
|
+
if (at.read.resolved !== ctx.runUrl) {
|
|
104
|
+
throw new Refusal(409, `${shownPath(ctx.root, at.file)}: url: now names ${maskedUrl(at.read.resolved)} (${at.read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
|
|
105
|
+
"mason stop && mason start copies from the new one, then add or remove the table");
|
|
106
|
+
}
|
|
107
|
+
return at;
|
|
108
|
+
}
|
|
109
|
+
/** The database this run copies, as an edit is checked against it: the catalog it last read, and how it reads changes. */
|
|
110
|
+
const catalogOf = (ctx) => ({ tables: ctx.facts().tables, database: ctx.source.database, replication: ctx.changes === "replication" });
|
|
111
|
+
/**
|
|
112
|
+
* ADD A TABLE: the check-and-write every entrance shares (src/cli/source-edit.ts addEntry), against the file on disk and
|
|
113
|
+
* this run's catalog; the caller tells the run the file changed, and the sync starts the copy.
|
|
114
|
+
*/
|
|
115
|
+
export function pick(ctx, table, columns = ["*"], key = null) {
|
|
116
|
+
return addEntry(sourceOnDisk(ctx), catalogOf(ctx), table, columns, key);
|
|
117
|
+
}
|
|
118
|
+
/** REMOVE TABLES: the check-and-write every entrance shares (src/cli/source-edit.ts removeEntries); the run then drops their copies. */
|
|
119
|
+
export function removeTables(ctx, names) {
|
|
120
|
+
return removeEntries(sourceOnDisk(ctx), catalogOf(ctx), names);
|
|
121
|
+
}
|
|
122
|
+
/** The HTTP status of a Failure by its code: src/cli/source-edit.ts codeOf the other way round. */
|
|
123
|
+
const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404 : f.code === "failed" ? 500 : 409;
|
|
124
|
+
/**
|
|
125
|
+
* EVERY ERROR ANSWER: `error`, the sentence a command prints (src/cli/control.ts reads it), and `failure`, the same
|
|
126
|
+
* refusal in the CLI's Failure shape — { code, message, why?, fix?, next: [{ command, what }] }, what `--json` prints
|
|
127
|
+
* — which the web page renders as the CLI does.
|
|
128
|
+
*/
|
|
129
|
+
export function sendFailure(res, status, f, extra = {}) {
|
|
130
|
+
sendJson(res, status, { error: f.message, ...extra, failure: { ...errorJson(f), next: nextJson(f.next) } });
|
|
131
|
+
}
|
|
132
|
+
/** THE WEB PAGE'S ROUTES, AND THE FILE EACH IS: the only files the server ever reads for a URL. */
|
|
133
|
+
export const PAGE_ROUTES = {
|
|
134
|
+
"/": ["index.html", "text/html; charset=utf-8"],
|
|
135
|
+
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
|
|
136
|
+
"/app.css": ["app.css", "text/css; charset=utf-8"],
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The web page's files, READ ONCE when the server starts (src/web/ beside src/cli/, in a checkout and in the package
|
|
140
|
+
* alike: cli/build.ts copies them): a map of route to bytes. A request's path is looked up in it, never joined onto a
|
|
141
|
+
* folder, so no URL can name another file. A file missing is a broken install, said with the path.
|
|
142
|
+
*/
|
|
143
|
+
export function pageFiles(dir = fileURLToPath(new URL("../web/", import.meta.url))) {
|
|
144
|
+
return new Map(Object.entries(PAGE_ROUTES).map(([route, [file, type]]) => {
|
|
145
|
+
const at = path.join(dir, file);
|
|
146
|
+
let body;
|
|
147
|
+
try {
|
|
148
|
+
body = fs.readFileSync(at);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw new Error(`the web page's ${file} cannot be read (${at}: ${e.message}): this Mason is installed incompletely — npm i -g @mason-data/cli again`);
|
|
152
|
+
}
|
|
153
|
+
return [route, { body, type }];
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
export function createRunServer(ctx) {
|
|
157
|
+
const saidErrors = new Set();
|
|
158
|
+
// read once, here: nothing a request names is ever read from disk (mason serve's run serves no page)
|
|
159
|
+
const page = ctx.page ? pageFiles() : new Map();
|
|
160
|
+
return http.createServer((req, res) => {
|
|
161
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS))
|
|
162
|
+
res.setHeader(k, v);
|
|
163
|
+
const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token);
|
|
164
|
+
if (!v.ok) {
|
|
165
|
+
if (v.status === 303) {
|
|
166
|
+
res.writeHead(303, { Location: v.location, "Set-Cookie": v.setCookie }).end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.writeHead(v.status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${v.reason}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${ctx.port}`);
|
|
173
|
+
void (async () => {
|
|
174
|
+
try {
|
|
175
|
+
const file = req.method === "GET" || req.method === "HEAD" ? page.get(url.pathname) : undefined;
|
|
176
|
+
if (file !== undefined) {
|
|
177
|
+
res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length }).end(req.method === "HEAD" ? undefined : file.body);
|
|
178
|
+
}
|
|
179
|
+
else if (req.method === "GET" && url.pathname === "/api/ping") {
|
|
180
|
+
// what proves a run is up (src/cli/runs.ts answers): this token answered, by this process
|
|
181
|
+
sendJson(res, 200, { pid: process.pid });
|
|
182
|
+
}
|
|
183
|
+
else if (req.method === "GET" && url.pathname === "/api/state") {
|
|
184
|
+
sendJson(res, 200, await state(ctx));
|
|
185
|
+
}
|
|
186
|
+
else if (req.method === "GET" && url.pathname === "/api/slow") {
|
|
187
|
+
// the last day's slow statements, from the request log (src/duck/requests.ts)
|
|
188
|
+
sendJson(res, 200, { slow: await ctx.duck.requests.slow(SLOW_MS), slowMs: SLOW_MS });
|
|
189
|
+
}
|
|
190
|
+
else if (req.method === "GET" && url.pathname === "/api/refused") {
|
|
191
|
+
sendJson(res, 200, await ctx.duck.requests.refused());
|
|
192
|
+
}
|
|
193
|
+
else if (req.method === "GET" && url.pathname === "/api/requests") {
|
|
194
|
+
const limit = Number(url.searchParams.get("limit") ?? 40);
|
|
195
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
|
|
196
|
+
throw new Refusal(400, "limit is a whole number of requests, 1 to 10000");
|
|
197
|
+
sendJson(res, 200, { requests: await ctx.duck.requests.recent({ limit, failed: url.searchParams.get("failed") === "1" }), keptDays: ctx.duck.requests.days });
|
|
198
|
+
}
|
|
199
|
+
else if (req.method === "POST" && url.pathname === "/api/pick") {
|
|
200
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
201
|
+
if (typeof body.table !== "string" || typeof body.picked !== "boolean")
|
|
202
|
+
throw new Refusal(400, "send { table, picked, columns?, key? }");
|
|
203
|
+
if (body.columns !== undefined && !(Array.isArray(body.columns) && body.columns.every((c) => typeof c === "string")))
|
|
204
|
+
throw new Refusal(400, "columns is a list of column names, or [\"*\"]");
|
|
205
|
+
if (body.key !== undefined && !(Array.isArray(body.key) && body.key.length > 0 && body.key.every((c) => typeof c === "string")))
|
|
206
|
+
throw new Refusal(400, "key is a list of column names");
|
|
207
|
+
if (body.picked) {
|
|
208
|
+
const r = pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
|
|
209
|
+
if (!r.already)
|
|
210
|
+
ctx.log(`added ${r.table} to ${r.file}`);
|
|
211
|
+
ctx.sourcesChanged();
|
|
212
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const r = removeTables(ctx, [body.table]);
|
|
216
|
+
for (const t of r.removed)
|
|
217
|
+
ctx.log(`removed ${t} from ${r.file}`);
|
|
218
|
+
ctx.sourcesChanged();
|
|
219
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (req.method === "GET" && url.pathname === "/api/users") {
|
|
223
|
+
// mason user list --json's shape: the users, and whether each connects
|
|
224
|
+
const r = listUsers(ctx.users.file, ctx.users.apiKey);
|
|
225
|
+
if ("message" in r) {
|
|
226
|
+
sendFailure(res, statusOf(r), r);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sendJson(res, 200, { ...r, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
|
|
230
|
+
}
|
|
231
|
+
else if (req.method === "POST" && url.pathname === "/api/users") {
|
|
232
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
233
|
+
if (typeof body.name !== "string")
|
|
234
|
+
throw new Refusal(400, "send { name }: the user's name");
|
|
235
|
+
// mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
|
|
236
|
+
const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database });
|
|
237
|
+
if ("message" in r) {
|
|
238
|
+
sendFailure(res, statusOf(r), r);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const { replaced, ...added } = r;
|
|
242
|
+
ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
|
|
243
|
+
sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
|
|
244
|
+
}
|
|
245
|
+
else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
|
|
246
|
+
let name;
|
|
247
|
+
try {
|
|
248
|
+
name = decodeURIComponent(url.pathname.slice("/api/users/".length));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
throw new Refusal(400, `${url.pathname} does not name a user: DELETE /api/users/<name>`);
|
|
252
|
+
}
|
|
253
|
+
const r = removeUser(ctx.users.file, name);
|
|
254
|
+
if ("message" in r) {
|
|
255
|
+
sendFailure(res, statusOf(r), r);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
ctx.log(`user ${r.removed.name} removed`);
|
|
259
|
+
sendJson(res, 200, { ...r, next: nextJson(REMOVED_NEXT) });
|
|
260
|
+
}
|
|
261
|
+
else if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
262
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
263
|
+
if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((x) => typeof x === "string"))
|
|
264
|
+
throw new Refusal(400, "send { tables: [schema.table, …] }");
|
|
265
|
+
try {
|
|
266
|
+
sendJson(res, 200, await ctx.duck.poll(body.tables));
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
if (e instanceof PollRefused) {
|
|
270
|
+
sendFailure(res, e.code === "not_found" ? 404 : 409, { message: e.message, code: e.code }, { code: e.code });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else if (req.method === "POST" && url.pathname === "/api/remove") {
|
|
277
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
278
|
+
if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
|
|
279
|
+
throw new Refusal(400, "send { tables: [name, …] }");
|
|
280
|
+
const r = removeTables(ctx, body.tables);
|
|
281
|
+
// the sync stops copying a removed table, and drops its copy
|
|
282
|
+
ctx.sourcesChanged();
|
|
283
|
+
for (const t of r.removed)
|
|
284
|
+
ctx.log(`removed ${t} from ${r.file}`);
|
|
285
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
sendFailure(res, 404, { message: `no ${req.method} ${url.pathname}: this is mason start's server for the mason command (mason --help)`, code: "not_found" });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (e instanceof Refusal) {
|
|
293
|
+
sendFailure(res, e.status, e.failure);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (e instanceof SyntaxError) {
|
|
297
|
+
sendFailure(res, 400, { message: `the body is not JSON: ${e.message}`, code: "usage" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// said ONCE per distinct reason: a command may ask every second, and a stack a second buried the terminal
|
|
301
|
+
const why = (e.message.split("\n---\n").pop() ?? String(e)).split("\n")[0];
|
|
302
|
+
const key = `${url.pathname} ${why}`;
|
|
303
|
+
if (!saidErrors.has(key)) {
|
|
304
|
+
saidErrors.add(key);
|
|
305
|
+
ctx.log(`command server: ${req.method} ${url.pathname} failed: ${why}`);
|
|
306
|
+
}
|
|
307
|
+
sendFailure(res, 500, { message: e.message, code: "failed", next: [["mason logs", "the run's log: what it was doing"]] });
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHETHER MASON IS RUNNING FOR A PROJECT, and how a command reaches it (one run per project).
|
|
3
|
+
* `mason start` writes what its commands need into the project's config (`.mason/config.json` `running`, src/cli/project.ts):
|
|
4
|
+
* its pid, its Postgres API, and the command server it answers on with the run's token.
|
|
5
|
+
*
|
|
6
|
+
* UP MEANS IT ANSWERS: a pid alone is not proof — after a reboot the operating system hands the number to
|
|
7
|
+
* another process, and a `mason start` then refused as "already running" a run that did not exist. A run is up when
|
|
8
|
+
* its command server answers `/api/ping` with the run's own token and pid (`answers`).
|
|
9
|
+
*
|
|
10
|
+
* ALIVE AND SILENT IS A THIRD STATE (on the test server a run hung — its main thread blocked — with every port
|
|
11
|
+
* held; `mason stop` read the silence as "not running" and `mason destroy` deleted the copy under it, which the hung
|
|
12
|
+
* process kept open). A pid that is alive, whose command line is a Mason run's (`masonProcess`: after a reboot the
|
|
13
|
+
* number is another process's), and that does not answer is HUNG (`runState`): a command says so and names the way
|
|
14
|
+
* out, `mason stop` ends it (`endRun`: SIGTERM, then SIGKILL), and `mason destroy` removes nothing under it.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { theProject } from "./project.js";
|
|
18
|
+
/** Whether process `pid` is alive (signal 0 tests without signalling). */
|
|
19
|
+
export function alive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return e.code === "EPERM";
|
|
26
|
+
} // alive, and someone else's: still running
|
|
27
|
+
}
|
|
28
|
+
/** The run a project's config says is up (its pid alive), or null. Whether it ANSWERS is `answers`'. */
|
|
29
|
+
export function runOf(p, isAlive = alive) {
|
|
30
|
+
const r = p.config.running;
|
|
31
|
+
if (!r || !isAlive(r.pid))
|
|
32
|
+
return null;
|
|
33
|
+
return { name: p.config.name, root: p.root, home: p.dir, folder: p.folder, pid: r.pid, apiKey: p.config.apiKey,
|
|
34
|
+
control: { url: r.controlUrl, token: r.token }, postgresUrl: r.postgresUrl, postgresListener: r.postgresListener };
|
|
35
|
+
}
|
|
36
|
+
/** What the run's command server says of itself (`/api/ping`: its pid), or null when nothing answers with the run's token. */
|
|
37
|
+
export async function ping(run) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${run.control.url}/api/ping`, { headers: { Authorization: `Bearer ${run.control.token}` }, signal: AbortSignal.timeout(3_000) });
|
|
40
|
+
if (res.status !== 200)
|
|
41
|
+
return null;
|
|
42
|
+
return await res.json();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// nothing listening, a timeout, a body that is not JSON: not this run (whatever holds the port now). "Not up" is
|
|
46
|
+
// the answer the question wants: a command then says Mason is not running, with mason start as the way out
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Whether the run answers with its own token and pid: the only proof it is up. */
|
|
51
|
+
export async function answers(run) {
|
|
52
|
+
return (await ping(run))?.pid === run.pid;
|
|
53
|
+
}
|
|
54
|
+
/** What a command that needs Mason running says when it is not. */
|
|
55
|
+
export function notRunning(p) {
|
|
56
|
+
return { problem: `Mason is not running for ${p.config.name} (${p.root})`, code: "not_running", next: [["mason start", "start it, in the background"]] };
|
|
57
|
+
}
|
|
58
|
+
/** How long a hung run is given to leave on SIGTERM before SIGKILL (the test server's ignored SIGTERM for 15 s). */
|
|
59
|
+
export const END_GRACE_MS = 15_000;
|
|
60
|
+
/** What a command says of a run that is alive and does not answer: what to run, since nothing else reaches it. */
|
|
61
|
+
export function notAnswering(p, run) {
|
|
62
|
+
return { problem: `Mason for ${p.config.name} is running (pid ${run.pid}) but does not answer its commands (${run.control.url}, 3 s)`, code: "not_answering",
|
|
63
|
+
why: "a statement may be holding its main thread",
|
|
64
|
+
next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its copy stays and the next mason start resumes`], ["mason logs", "what it was doing"]] };
|
|
65
|
+
}
|
|
66
|
+
/** Whether `pid` is a Mason run, by its command line (`mason start … --attach`, src/cli/background.ts). ps exits 1 for a pid that is gone. */
|
|
67
|
+
export function masonProcess(pid) {
|
|
68
|
+
const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
|
|
69
|
+
return ps.status === 0 && / start\b.*--attach\b/.test(ps.stdout);
|
|
70
|
+
}
|
|
71
|
+
/** The project's run: up (it answers), hung (alive, a Mason run, and silent), or none. */
|
|
72
|
+
export async function runState(p, isAlive = alive, ask = answers, isMason = masonProcess) {
|
|
73
|
+
const r = runOf(p, isAlive);
|
|
74
|
+
if (r === null)
|
|
75
|
+
return { kind: "none", run: null };
|
|
76
|
+
if (await ask(r))
|
|
77
|
+
return { kind: "up", run: r };
|
|
78
|
+
return isMason(r.pid) ? { kind: "hung", run: r } : { kind: "none", run: null };
|
|
79
|
+
}
|
|
80
|
+
/** The project's run when it is up and answers; null when Mason is not running for it (a hung run is not up). */
|
|
81
|
+
export async function liveRun(p, isAlive = alive, ask = answers) {
|
|
82
|
+
const st = await runState(p, isAlive, ask);
|
|
83
|
+
return st.kind === "up" ? st.run : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* ENDS A RUN THAT DOES NOT ANSWER: SIGTERM, then SIGKILL once `graceMs` has passed with it still alive (a blocked main
|
|
87
|
+
* thread never sees SIGTERM), and the pid waited for. `killed` says whether SIGKILL was needed; a pid alive after it
|
|
88
|
+
* is said, never reported ended.
|
|
89
|
+
*/
|
|
90
|
+
export async function endRun(pid, deps = {}) {
|
|
91
|
+
const kill = deps.kill ?? ((p, sig) => process.kill(p, sig));
|
|
92
|
+
const isAlive = deps.isAlive ?? alive;
|
|
93
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
94
|
+
const grace = deps.graceMs ?? END_GRACE_MS;
|
|
95
|
+
kill(pid, "SIGTERM");
|
|
96
|
+
const until = Date.now() + grace;
|
|
97
|
+
while (isAlive(pid) && Date.now() < until)
|
|
98
|
+
await sleep(250);
|
|
99
|
+
if (!isAlive(pid))
|
|
100
|
+
return { killed: false };
|
|
101
|
+
kill(pid, "SIGKILL");
|
|
102
|
+
const gone = Date.now() + 10_000;
|
|
103
|
+
while (isAlive(pid) && Date.now() < gone)
|
|
104
|
+
await sleep(250);
|
|
105
|
+
if (isAlive(pid))
|
|
106
|
+
throw new Error(`pid ${pid} is still running after SIGKILL: it may be another user's, or the system is not reaping it (ps -p ${pid})`);
|
|
107
|
+
return { killed: true };
|
|
108
|
+
}
|
|
109
|
+
/** The run a command means: its project's (src/cli/project.ts theProject), up and answering; else why there is none. */
|
|
110
|
+
export async function theRun(cwd) {
|
|
111
|
+
const t = theProject(cwd);
|
|
112
|
+
if ("problem" in t)
|
|
113
|
+
return { ...t, project: null };
|
|
114
|
+
const st = await runState(t.project);
|
|
115
|
+
if (st.kind === "up")
|
|
116
|
+
return { run: st.run, project: t.project };
|
|
117
|
+
if (st.kind === "hung")
|
|
118
|
+
return { ...notAnswering(t.project, st.run), project: t.project, hung: st.run };
|
|
119
|
+
return { ...notRunning(t.project), project: t.project };
|
|
120
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOCAL SERVERS' GUARD (security comes first here). `mason start` serves the command server
|
|
3
|
+
* on the developer's machine, which reads the database and WRITES the sources file of a folder for the `mason`
|
|
4
|
+
* commands, and its web page (`mason ui`, src/web/), whose users section adds and removes the project's users. Any
|
|
5
|
+
* website the developer visits can make their browser send requests to 127.0.0.1, so each request must prove it comes
|
|
6
|
+
* from the run itself (a command) or from a page the run served:
|
|
7
|
+
*
|
|
8
|
+
* - LOOPBACK ONLY: the servers bind 127.0.0.1.
|
|
9
|
+
* - A TOKEN: 32 random bytes, made per run and printed in the URL `mason ui` opens. The first request trades it
|
|
10
|
+
* for an HttpOnly, SameSite=Strict cookie and is redirected to a URL without it; every request after needs the
|
|
11
|
+
* cookie. A page on another site cannot read it, and SameSite=Strict keeps the browser from sending it on a
|
|
12
|
+
* request another site starts.
|
|
13
|
+
* - THE HOST HEADER must name this server (127.0.0.1:<port> or localhost:<port>): a DNS-rebinding page reaches
|
|
14
|
+
* 127.0.0.1 under its own name, and is refused by that name.
|
|
15
|
+
* - THE ORIGIN of every request that changes something (anything but GET / HEAD) must be this server's own: a
|
|
16
|
+
* cross-site form or fetch carries the other site's origin.
|
|
17
|
+
* - No CORS headers, `frame-ancestors 'none'` (no other page may frame these), and `Referrer-Policy: no-referrer`
|
|
18
|
+
* (the token URL never leaks as a referrer).
|
|
19
|
+
*
|
|
20
|
+
* THE COMMAND LINE (`mason status`, `mason add`, …; src/cli/control.ts) sends the same token as `Authorization: Bearer`,
|
|
21
|
+
* read from the run's settings file, which only the developer can read (0600 in a 0700 folder). It needs no cookie and
|
|
22
|
+
* no Origin: a browser sends an Authorization header across sites only after a CORS preflight, which these servers
|
|
23
|
+
* never approve, so a page on another site cannot send one at all.
|
|
24
|
+
*
|
|
25
|
+
* Cookies are per host, not per port, so one cookie serves every page of a run; a page of another run, or one
|
|
26
|
+
* the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
|
|
27
|
+
* (tests/cli-security.test.ts).
|
|
28
|
+
*/
|
|
29
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
30
|
+
export const COOKIE = "mason_dev";
|
|
31
|
+
export function newToken() { return randomBytes(32).toString("hex"); }
|
|
32
|
+
/** Constant-time string equality (a token compared byte by byte leaks its prefix through timing). */
|
|
33
|
+
export function sameSecret(a, b) {
|
|
34
|
+
const x = Buffer.from(a), y = Buffer.from(b);
|
|
35
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
36
|
+
}
|
|
37
|
+
export function cookieOf(header, name = COOKIE) {
|
|
38
|
+
for (const part of (header ?? "").split(";")) {
|
|
39
|
+
const eq = part.indexOf("=");
|
|
40
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
41
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function setCookieHeader(token) {
|
|
46
|
+
return `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
|
47
|
+
}
|
|
48
|
+
/** Whether `req` may be served by the server on `port` whose run token is `token`. */
|
|
49
|
+
export function guard(req, port, token) {
|
|
50
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
51
|
+
if (!req.host || !hosts.includes(req.host.toLowerCase())) {
|
|
52
|
+
return { ok: false, status: 421, reason: `this server answers only as ${hosts[0]} (the request named ${JSON.stringify(req.host ?? "")})` };
|
|
53
|
+
}
|
|
54
|
+
if (req.authorization !== undefined) {
|
|
55
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(req.authorization)?.[1];
|
|
56
|
+
return bearer !== undefined && sameSecret(bearer, token) ? { ok: true }
|
|
57
|
+
: { ok: false, status: 403, reason: "the Authorization header does not carry this run's token" };
|
|
58
|
+
}
|
|
59
|
+
const url = new URL(req.url, `http://${req.host}`);
|
|
60
|
+
const offered = url.searchParams.get("token");
|
|
61
|
+
if (offered !== null) {
|
|
62
|
+
if (!sameSecret(offered, token))
|
|
63
|
+
return { ok: false, status: 403, reason: "the token in the URL is not this run's" };
|
|
64
|
+
url.searchParams.delete("token");
|
|
65
|
+
return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token) };
|
|
66
|
+
}
|
|
67
|
+
const cookie = cookieOf(req.cookie);
|
|
68
|
+
if (cookie === null || !sameSecret(cookie, token)) {
|
|
69
|
+
return { ok: false, status: 403, reason: "this is mason start's command server for the mason command: a request carries the run's token (Authorization: Bearer, from .mason/config.json)" };
|
|
70
|
+
}
|
|
71
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
72
|
+
const own = hosts.map((h) => `http://${h}`);
|
|
73
|
+
if (!req.origin || !own.includes(req.origin.toLowerCase())) {
|
|
74
|
+
return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
/** The headers every response of a guarded server carries. */
|
|
80
|
+
export const SECURITY_HEADERS = {
|
|
81
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
82
|
+
"X-Frame-Options": "DENY",
|
|
83
|
+
"X-Content-Type-Options": "nosniff",
|
|
84
|
+
"Referrer-Policy": "no-referrer",
|
|
85
|
+
"Cache-Control": "no-store",
|
|
86
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SELF-SIGNED X.509 CERTIFICATE, MADE WITH NODE ALONE (`mason start --self-signed-tls`, src/cli/pg-tls.ts).
|
|
3
|
+
* `node:crypto` generates the key and signs, but has no API that writes a certificate, and the tree has no library that
|
|
4
|
+
* does — so the certificate is DER written here: one shape, no extensions, nothing parsed. RFC 5280 §4.1:
|
|
5
|
+
*
|
|
6
|
+
* Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature BIT STRING }
|
|
7
|
+
* TBSCertificate ::= SEQUENCE { [0] version v3, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo }
|
|
8
|
+
*
|
|
9
|
+
* RSA 2048 with SHA-256: the key every TLS client takes (a hosted BI tool's JVM included); ECDSA would do for most.
|
|
10
|
+
* Node's own `X509Certificate` reads it back and verifies its signature (tests/pg-tls.test.ts), and a real handshake
|
|
11
|
+
* against the Postgres listener uses it. What a self-signed certificate gives is ENCRYPTION — a client with
|
|
12
|
+
* `sslmode=require` checks no certificate — never proof of whose server it is: that is a certificate a CA signed.
|
|
13
|
+
*/
|
|
14
|
+
import * as crypto from "node:crypto";
|
|
15
|
+
const tlv = (tag, content) => {
|
|
16
|
+
const n = content.length;
|
|
17
|
+
if (n < 0x80)
|
|
18
|
+
return Buffer.concat([Buffer.from([tag, n]), content]);
|
|
19
|
+
const len = [];
|
|
20
|
+
for (let m = n; m > 0; m >>= 8)
|
|
21
|
+
len.unshift(m & 0xff);
|
|
22
|
+
return Buffer.concat([Buffer.from([tag, 0x80 | len.length, ...len]), content]);
|
|
23
|
+
};
|
|
24
|
+
const seq = (...parts) => tlv(0x30, Buffer.concat(parts));
|
|
25
|
+
function oid(dotted) {
|
|
26
|
+
const [a, b, ...rest] = dotted.split(".").map(Number);
|
|
27
|
+
const bytes = [40 * a + b];
|
|
28
|
+
for (const n of rest) {
|
|
29
|
+
const groups = [];
|
|
30
|
+
for (let m = n;; m = Math.floor(m / 128)) {
|
|
31
|
+
groups.unshift(m % 128);
|
|
32
|
+
if (m < 128)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
bytes.push(...groups.map((g, i) => (i < groups.length - 1 ? g | 0x80 : g)));
|
|
36
|
+
}
|
|
37
|
+
return tlv(0x06, Buffer.from(bytes));
|
|
38
|
+
}
|
|
39
|
+
/** a positive INTEGER from its big-endian bytes */
|
|
40
|
+
const integer = (bytes) => tlv(0x02, bytes[0] & 0x80 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
|
|
41
|
+
/** UTCTime through 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5) */
|
|
42
|
+
function time(d) {
|
|
43
|
+
const iso = d.toISOString(); // 2026-10-26T12:34:56.789Z
|
|
44
|
+
const digits = iso.slice(0, 19).replace(/[-T:]/g, "");
|
|
45
|
+
return d.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${digits.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${digits}Z`));
|
|
46
|
+
}
|
|
47
|
+
const name = (commonName) => seq(tlv(0x31, seq(oid("2.5.4.3"), tlv(0x0c, Buffer.from(commonName, "utf8")))));
|
|
48
|
+
const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString("base64").replace(/.{1,64}/g, "$&\n")}-----END ${label}-----\n`;
|
|
49
|
+
/** A new RSA key and a certificate for it, signed by itself, valid from an hour ago (a client's clock behind) for
|
|
50
|
+
* `years`; both PEM. */
|
|
51
|
+
export function selfSignedCertificate(commonName, o = {}) {
|
|
52
|
+
const now = o.now ?? new Date();
|
|
53
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
54
|
+
const serial = crypto.randomBytes(16);
|
|
55
|
+
serial[0] = (serial[0] & 0x7f) | 0x40; // positive, and never a leading zero byte
|
|
56
|
+
const notAfter = new Date(now);
|
|
57
|
+
notAfter.setUTCFullYear(notAfter.getUTCFullYear() + (o.years ?? 10));
|
|
58
|
+
const sha256WithRsa = seq(oid("1.2.840.113549.1.1.11"), tlv(0x05, Buffer.alloc(0)));
|
|
59
|
+
const tbs = seq(tlv(0xa0, integer(Buffer.from([2]))), // [0] EXPLICIT version: v3
|
|
60
|
+
integer(serial), sha256WithRsa, name(commonName), seq(time(new Date(now.getTime() - 3_600_000)), time(notAfter)), name(commonName), publicKey.export({ type: "spki", format: "der" }));
|
|
61
|
+
const signature = crypto.sign("sha256", tbs, privateKey); // PKCS#1 v1.5, the algorithm named above
|
|
62
|
+
const der = seq(tbs, sha256WithRsa, tlv(0x03, Buffer.concat([Buffer.from([0]), signature])));
|
|
63
|
+
return { cert: pem("CERTIFICATE", der), key: privateKey.export({ type: "pkcs8", format: "pem" }).toString() };
|
|
64
|
+
}
|