@mason-data/cli 0.9.0 → 0.10.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/README.md +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +290 -157
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +1 -1
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +25 -2
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/serve.js +2 -2
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/package.json +6 -6
package/dist/src/cli/replica.js
CHANGED
|
@@ -4,14 +4,52 @@ import { isFailure, serviceText, whoAmI } from "./hosted.js";
|
|
|
4
4
|
export const REPLICA_PART = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
5
5
|
export const REPLICA_ENV = "MASON_REPLICA";
|
|
6
6
|
export const replicaText = (r) => `${r.org}/${r.name}`;
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
7
|
+
/**
|
|
8
|
+
* THE REPLICA THE COMMAND MEANS, ONE RESOLVER FOR EVERY COMMAND THAT NAMES ONE (`mason deploy`, `mason user …
|
|
9
|
+
* --replica`, `mason token create`, `mason replica create | status`): the command's own argument where it takes one
|
|
10
|
+
* (`mason replica status acme/shop`), else `--replica`, else `MASON_REPLICA`; null when none names one, a Failure when
|
|
11
|
+
* it is not `<org>/<name>` (never repeated beyond 200 characters, made printable), or when the argument and `--replica`
|
|
12
|
+
* name two replicas. `usage`: the command's, said after a wrong line.
|
|
13
|
+
*
|
|
14
|
+
* WHEN THE ENVIRONMENT CHOSE THE TARGET IT IS SAID, on stderr (`say`): an agent inherits an environment it may never
|
|
15
|
+
* read, and `MASON_REPLICA` exported turns `mason user add` from this project's users to a replica's.
|
|
16
|
+
*/
|
|
17
|
+
export function replicaOf(flag, usage, env = process.env, o = {}) {
|
|
18
|
+
if (o.argument !== undefined) {
|
|
19
|
+
const r = replicaNamed(o.argument, "argument", usage);
|
|
20
|
+
if (flag === undefined || "message" in r)
|
|
21
|
+
return r;
|
|
22
|
+
const f = replicaNamed(flag, "--replica", usage);
|
|
23
|
+
if ("message" in f)
|
|
24
|
+
return f;
|
|
25
|
+
if (replicaText(f) === replicaText(r))
|
|
26
|
+
return r;
|
|
27
|
+
return { message: `the replica is named twice: ${replicaText(r)} and --replica ${replicaText(f)}`, code: "usage", fix: `name one: ${replicaText(r)}, or --replica ${replicaText(f)}`, usage };
|
|
28
|
+
}
|
|
11
29
|
const from = flag !== undefined ? "--replica" : env[REPLICA_ENV] !== undefined ? REPLICA_ENV : null;
|
|
12
30
|
if (from === null)
|
|
13
31
|
return null;
|
|
14
|
-
|
|
32
|
+
const r = replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
|
|
33
|
+
if (!("message" in r) && r.from === REPLICA_ENV)
|
|
34
|
+
(o.say ?? sayStderr)(envChoseText(r));
|
|
35
|
+
return r;
|
|
36
|
+
}
|
|
37
|
+
/** What is said on stderr when `MASON_REPLICA` chose the replica (a name `REPLICA_PART` holds: safe to print). */
|
|
38
|
+
export const envChoseText = (r) => `using ${REPLICA_ENV}=${replicaText(r)} (--replica names another; unset ${REPLICA_ENV} for none)`;
|
|
39
|
+
const sayStderr = (line) => { process.stderr.write(`${line}\n`); };
|
|
40
|
+
/** The organisation the command means: its own argument (`mason replica list acme`), else `--org`; null when neither
|
|
41
|
+
* names one, a wrong line's Failure when one is not a slug or the two differ. */
|
|
42
|
+
export function orgOf(flag, argument, usage) {
|
|
43
|
+
const named = [argument, flag].filter((x) => x !== undefined).map((x) => orgNamed(x, usage));
|
|
44
|
+
const bad = named.find((x) => typeof x !== "string");
|
|
45
|
+
if (bad !== undefined)
|
|
46
|
+
return bad;
|
|
47
|
+
const [a, b] = named;
|
|
48
|
+
if (a === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
if (b !== undefined && a !== b)
|
|
51
|
+
return { message: `the organisation is named twice: ${a} and --org ${b}`, code: "usage", fix: `name one: ${a}, or --org ${b}`, usage };
|
|
52
|
+
return a;
|
|
15
53
|
}
|
|
16
54
|
/** `raw` as a replica's name, `<org>/<name>`, each part `REPLICA_PART`: the one grammar of `--replica`, `MASON_REPLICA`
|
|
17
55
|
* and a command's own argument (`mason replica status <org>/<name>`); a Failure otherwise, a wrong line's (`usage`)
|
|
@@ -165,6 +165,11 @@ export function dropStatements(o) {
|
|
|
165
165
|
return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
|
|
166
166
|
(o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
|
|
167
167
|
}
|
|
168
|
+
/** The statement that lists Mason's replication slots on a database (each named `mason_…`, `replicationNames`), for a
|
|
169
|
+
* person to find the one to drop when its name is not known (a hosted replica's record of it could not be read). */
|
|
170
|
+
export function listSlotsStatement() {
|
|
171
|
+
return "SELECT slot_name, database, active FROM pg_replication_slots WHERE left(slot_name, 6) = 'mason_';";
|
|
172
|
+
}
|
|
168
173
|
/**
|
|
169
174
|
* WHAT THE RECORD NAMES THAT THIS START NO LONGER READS, to drop before the record names the new one (each holds what
|
|
170
175
|
* Mason made on a database nothing would drop otherwise): a slot under another name (the sources file renamed) goes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OUTCOMES } from "../api/request-log.js";
|
|
2
2
|
import { parseRequestCursor, requestCursor } from "../duck/requests.js";
|
|
3
3
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
4
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
4
5
|
/** How many requests a read returns unless it says. */
|
|
5
6
|
export const DEFAULT_REQUEST_LIMIT = 40;
|
|
6
7
|
export const MAX_REQUEST_LIMIT = 10_000;
|
|
@@ -12,21 +13,30 @@ export const MAX_SEARCH = 200;
|
|
|
12
13
|
/** How many sync events a read returns unless it says, and the most it may. */
|
|
13
14
|
export const DEFAULT_EVENT_LIMIT = 40;
|
|
14
15
|
export const MAX_EVENT_LIMIT = MAX_REQUEST_LIMIT;
|
|
15
|
-
const AGE = /^(\d+)(s|m|h|d)$/;
|
|
16
|
-
const AGE_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
17
16
|
/** a date, or a date and time with its zone (a time without one would be read in this machine's zone: refused) */
|
|
18
17
|
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}))?$/;
|
|
18
|
+
/** The furthest a JavaScript Date reaches from 1970, either way (ECMA-262 21.4.1.1). */
|
|
19
|
+
const MAX_TIME_MS = 8.64e15;
|
|
19
20
|
const usage = (message, fix) => ({ message, code: "usage", ...(fix !== undefined ? { fix } : {}) });
|
|
20
21
|
export const QUERY_NAMES = (k) => k;
|
|
21
22
|
/**
|
|
22
23
|
* THE TIME GRAMMAR, ONE FOR EVERY ENTRANCE (`--since` / `--until` of mason logs and mason usage, and the run's `since` and
|
|
23
24
|
* `until` of /api/requests, /api/events, /api/usage and /api/logs): an ISO 8601 date or time with its zone, or an age
|
|
24
|
-
* before `now` (30s, 30m, 2h, 7d
|
|
25
|
+
* before `now` (30s, 30m, 2h, 7d, 2 hours), read by the command line's one duration grammar (src/lib/table-sync.ts
|
|
26
|
+
* `parseDuration`) with its unit written, as an ISO time; a time with no zone is refused, as is anything else. A bare
|
|
27
|
+
* number is refused here, as it always was: as a TIME it reads as well as a Unix timestamp as an age in seconds.
|
|
25
28
|
*/
|
|
26
29
|
export function timeOf(name, text, now) {
|
|
27
|
-
const age =
|
|
28
|
-
if (age !== null)
|
|
29
|
-
|
|
30
|
+
const age = Number.isFinite(Number(text.trim())) ? null : parseDuration(text, { zero: true });
|
|
31
|
+
if (age !== null && !("problem" in age)) {
|
|
32
|
+
// an age past what a time can hold (Date's ±8.64e15 ms) is the command line's mistake, refused as one: never a throw,
|
|
33
|
+
// which the run's API would answer 500
|
|
34
|
+
const at = now - age.seconds * 1000;
|
|
35
|
+
if (!Number.isFinite(at) || Math.abs(at) > MAX_TIME_MS) {
|
|
36
|
+
return usage(`${name} ${JSON.stringify(text)} is further back than a time reaches`, "a shorter age (30m, 2h, 7d), or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
|
|
37
|
+
}
|
|
38
|
+
return new Date(at).toISOString();
|
|
39
|
+
}
|
|
30
40
|
const t = ISO.test(text.trim()) ? Date.parse(text.trim()) : NaN;
|
|
31
41
|
if (Number.isNaN(t)) {
|
|
32
42
|
return usage(`${name} ${JSON.stringify(text)} is not a time`, "an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
|
|
@@ -34,7 +34,7 @@ import { phaseCounts, readiness, syncTotals, tableSync } from "./readiness.js";
|
|
|
34
34
|
import { restartsFact } from "./supervise.js";
|
|
35
35
|
import { withRestarts } from "../duck/events.js";
|
|
36
36
|
import { NO_ROLES } from "../duck/grants.js";
|
|
37
|
-
import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, listUsers, removeUser, REMOVED_NEXT, roleToAdd, withLastUsed } from "./user.js";
|
|
37
|
+
import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, fromNext, listUsers, rangesText, removeUser, REMOVED_NEXT, roleToAdd, setUserFrom, withLastUsed } from "./user.js";
|
|
38
38
|
import { userRole } from "./roles.js";
|
|
39
39
|
import { postgresFacts, postgresLink } from "./pg-url.js";
|
|
40
40
|
import { catalogTable, parseSourceText, shownPath } from "./sources.js";
|
|
@@ -776,6 +776,8 @@ export function createRunServer(ctx) {
|
|
|
776
776
|
throw new Refusal(400, "send { name, role }: the role's name, a string");
|
|
777
777
|
if (body.set !== undefined && !(Array.isArray(body.set) && body.set.every((x) => typeof x === "string")))
|
|
778
778
|
throw new Refusal(400, "send { name, role, set }: set is a list of \"attribute=value\" strings, as mason user add --set takes them");
|
|
779
|
+
if (body.from !== undefined && !(Array.isArray(body.from) && body.from.every((x) => typeof x === "string")))
|
|
780
|
+
throw new Refusal(400, "send { name, from }: from is a list of address ranges (\"10.0.0.0/16\"), as mason user add --from takes them");
|
|
779
781
|
// the role must be one the roles file names NOW (src/cli/user.ts roleToAdd): refused before anything is written
|
|
780
782
|
const roleProblem = body.role === undefined ? null : roleToAdd(body.role, ctx.roles?.() ?? NO_ROLES);
|
|
781
783
|
if (roleProblem !== null) {
|
|
@@ -791,7 +793,7 @@ export function createRunServer(ctx) {
|
|
|
791
793
|
}
|
|
792
794
|
// mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
|
|
793
795
|
const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database, roles: ctx.roles?.() ?? NO_ROLES,
|
|
794
|
-
...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values });
|
|
796
|
+
...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values, ...(Array.isArray(body.from) ? { from: body.from } : {}) });
|
|
795
797
|
if ("message" in r) {
|
|
796
798
|
sendFailure(res, statusOf(r), r);
|
|
797
799
|
return;
|
|
@@ -800,6 +802,27 @@ export function createRunServer(ctx) {
|
|
|
800
802
|
ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
|
|
801
803
|
sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
|
|
802
804
|
}
|
|
805
|
+
else if (req.method === "POST" && /^\/api\/users\/[^/]+\/from$/.test(url.pathname)) {
|
|
806
|
+
// mason user from --json's shape, through its own function: the user's address ranges replaced (null: any address)
|
|
807
|
+
let name;
|
|
808
|
+
try {
|
|
809
|
+
name = decodeURIComponent(url.pathname.split("/")[3]);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
throw new Refusal(400, `${url.pathname} does not name a user: POST /api/users/<name>/from`);
|
|
813
|
+
}
|
|
814
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
815
|
+
if (!(body.from === null || (Array.isArray(body.from) && body.from.every((x) => typeof x === "string"))))
|
|
816
|
+
throw new Refusal(400, "send { from }: a list of address ranges (\"10.0.0.0/16\"), or null for any address");
|
|
817
|
+
const r = setUserFrom(ctx.users.file, name, body.from);
|
|
818
|
+
if ("message" in r) {
|
|
819
|
+
sendFailure(res, statusOf(r), r);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (!r.already)
|
|
823
|
+
ctx.log(`user ${r.changed.name} connects from ${rangesText(r.changed.from)}`);
|
|
824
|
+
sendJson(res, 200, { ...r, next: nextJson(fromNext(r.changed.name, r.changed.from)) });
|
|
825
|
+
}
|
|
803
826
|
else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
|
|
804
827
|
// mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
|
|
805
828
|
const [, , , encoded, verb] = url.pathname.split("/");
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SECRET READ FROM THE PERSON OR THE PIPE, NEVER FROM A FLAG, AN ARGUMENT OR A FILE (`mason replica create`'s database
|
|
3
|
+
* URL, issue 720): on a terminal a hidden prompt, otherwise stdin read whole. Node's own TTY handling, no dependency.
|
|
4
|
+
*
|
|
5
|
+
* - THE PROMPT (stdin a terminal): the question on stderr, stdin in raw mode, so the terminal echoes nothing and keeps
|
|
6
|
+
* no line of it; each key is read here. Enter ends it; Ctrl-C, or Ctrl-D or Enter on an empty line, cancels; Backspace
|
|
7
|
+
* and Ctrl-U edit; a terminal's escape sequences (a bracketed paste's markers, an arrow) are dropped. The terminal is
|
|
8
|
+
* put back as it was on EVERY way out — the answer, a cancel, an error of the stream, its end, a signal (SIGINT,
|
|
9
|
+
* SIGTERM, SIGHUP while raw mode keeps Ctrl-C from being one), and the process's exit.
|
|
10
|
+
* - THE PIPE (stdin not a terminal): read whole, bounded at `max` bytes and by a time limit (a pipe left open and
|
|
11
|
+
* silent never holds the command), one trailing newline (`\n` or `\r\n`) trimmed; more than one line is refused.
|
|
12
|
+
*
|
|
13
|
+
* Nothing read is ever written anywhere by this module: what went wrong is said by kind, never by value.
|
|
14
|
+
*/
|
|
15
|
+
import { StringDecoder } from "node:string_decoder";
|
|
16
|
+
const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
17
|
+
/**
|
|
18
|
+
* THE HIDDEN PROMPT: `question` on `output` (stderr), a line read from `input` (a terminal) in raw mode, echoing nothing.
|
|
19
|
+
* At most `max` characters are kept; a longer line is read to its end (so nothing of it is left for the shell to echo)
|
|
20
|
+
* and refused.
|
|
21
|
+
*/
|
|
22
|
+
export function readHidden(input, output, question, max, signals = process) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const wasRaw = input.isRaw === true;
|
|
25
|
+
const decoder = new StringDecoder("utf8");
|
|
26
|
+
let line = [];
|
|
27
|
+
let tooLong = false;
|
|
28
|
+
let escape = "";
|
|
29
|
+
let ended = false;
|
|
30
|
+
const restore = () => { if (!wasRaw)
|
|
31
|
+
input.setRawMode?.(false); };
|
|
32
|
+
const finish = (r) => {
|
|
33
|
+
if (ended)
|
|
34
|
+
return;
|
|
35
|
+
ended = true;
|
|
36
|
+
input.removeListener("data", onData);
|
|
37
|
+
input.removeListener("end", onEnd);
|
|
38
|
+
input.removeListener("close", onEnd);
|
|
39
|
+
input.removeListener("error", onError);
|
|
40
|
+
for (const s of SIGNALS)
|
|
41
|
+
signals.removeListener(s, onSignal);
|
|
42
|
+
signals.removeListener("exit", restore);
|
|
43
|
+
try {
|
|
44
|
+
restore();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
input.pause();
|
|
48
|
+
// the person's Enter was not echoed: the next line starts on a line of its own
|
|
49
|
+
output.write("\n");
|
|
50
|
+
line = [];
|
|
51
|
+
if (r instanceof Error)
|
|
52
|
+
reject(r);
|
|
53
|
+
else
|
|
54
|
+
resolve(r);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const onSignal = () => finish({ none: "cancelled" });
|
|
58
|
+
const onEnd = () => finish({ none: line.length ? "closed" : "cancelled" });
|
|
59
|
+
const onError = (e) => finish(e);
|
|
60
|
+
const onData = (chunk) => {
|
|
61
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
62
|
+
for (const c of text) {
|
|
63
|
+
if (ended)
|
|
64
|
+
return;
|
|
65
|
+
// a terminal's escape sequence (an arrow, a bracketed paste's markers, a title report): dropped whole
|
|
66
|
+
if (escape === "esc") {
|
|
67
|
+
escape = c === "[" ? "csi" : c === "]" ? "osc" : "";
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (escape === "csi") {
|
|
71
|
+
if (c >= "@" && c <= "~")
|
|
72
|
+
escape = "";
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (escape === "osc") {
|
|
76
|
+
if (c === "\x07" || c === "\x1b")
|
|
77
|
+
escape = "";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === "\x1b") {
|
|
81
|
+
escape = "esc";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === "\r" || c === "\n") {
|
|
85
|
+
finish(tooLong ? { none: "too_long" } : line.length === 0 ? { none: "empty" } : { value: line.join("") });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (c === "\x03") {
|
|
89
|
+
finish({ none: "cancelled" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (c === "\x04") {
|
|
93
|
+
if (line.length === 0 && !tooLong) {
|
|
94
|
+
finish({ none: "cancelled" });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c === "\x7f" || c === "\b") {
|
|
100
|
+
line.pop();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === "\x15") {
|
|
104
|
+
line = [];
|
|
105
|
+
tooLong = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c < " ")
|
|
109
|
+
continue;
|
|
110
|
+
if (line.length >= max) {
|
|
111
|
+
tooLong = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
line.push(c);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
if (input.setRawMode === undefined)
|
|
119
|
+
throw new Error("stdin is a terminal that cannot turn its echo off");
|
|
120
|
+
// the question first, then echo off: nothing typed before it is shown as asked
|
|
121
|
+
output.write(question);
|
|
122
|
+
input.setRawMode(true);
|
|
123
|
+
for (const s of SIGNALS)
|
|
124
|
+
signals.on(s, onSignal);
|
|
125
|
+
signals.on("exit", restore);
|
|
126
|
+
input.on("data", onData);
|
|
127
|
+
input.on("end", onEnd);
|
|
128
|
+
input.on("close", onEnd);
|
|
129
|
+
input.on("error", onError);
|
|
130
|
+
input.resume();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
finish(e instanceof Error ? e : new Error(String(e)));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* THE PIPE: `input` read whole, at most `max` bytes after one trailing newline is trimmed, within `timeoutMs`. Empty,
|
|
139
|
+
* more than one line, longer than `max`, or nothing by the time limit, each said by kind.
|
|
140
|
+
*/
|
|
141
|
+
export function readPiped(input, max, timeoutMs) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let bytes = 0;
|
|
145
|
+
let ended = false;
|
|
146
|
+
const finish = (r) => {
|
|
147
|
+
if (ended)
|
|
148
|
+
return;
|
|
149
|
+
ended = true;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
input.removeListener("data", onData);
|
|
152
|
+
input.removeListener("end", onEnd);
|
|
153
|
+
input.removeListener("error", onError);
|
|
154
|
+
input.pause();
|
|
155
|
+
if (r instanceof Error)
|
|
156
|
+
reject(r);
|
|
157
|
+
else
|
|
158
|
+
resolve(r);
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(() => finish({ none: "timeout" }), timeoutMs);
|
|
161
|
+
// the newline a shell's echo or a file ends with is not the URL's: room for it, then the bound
|
|
162
|
+
const most = max + 2;
|
|
163
|
+
const onData = (chunk) => {
|
|
164
|
+
const b = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
165
|
+
bytes += b.length;
|
|
166
|
+
if (bytes > most) {
|
|
167
|
+
finish({ none: "too_long" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
chunks.push(b);
|
|
171
|
+
};
|
|
172
|
+
const onEnd = () => {
|
|
173
|
+
let text = Buffer.concat(chunks).toString("utf8");
|
|
174
|
+
chunks.length = 0;
|
|
175
|
+
if (text.endsWith("\r\n"))
|
|
176
|
+
text = text.slice(0, -2);
|
|
177
|
+
else if (text.endsWith("\n"))
|
|
178
|
+
text = text.slice(0, -1);
|
|
179
|
+
if (text === "")
|
|
180
|
+
return finish({ none: "empty" });
|
|
181
|
+
if (/[\r\n]/.test(text))
|
|
182
|
+
return finish({ none: "lines" });
|
|
183
|
+
if (Buffer.byteLength(text, "utf8") > max)
|
|
184
|
+
return finish({ none: "too_long" });
|
|
185
|
+
finish({ value: text });
|
|
186
|
+
};
|
|
187
|
+
const onError = (e) => finish(e);
|
|
188
|
+
input.on("data", onData);
|
|
189
|
+
input.on("end", onEnd);
|
|
190
|
+
input.on("error", onError);
|
|
191
|
+
input.resume();
|
|
192
|
+
});
|
|
193
|
+
}
|
package/dist/src/cli/serve.js
CHANGED
|
@@ -59,6 +59,8 @@ export const SERVE = {
|
|
|
59
59
|
DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
|
|
60
60
|
docker compose exec mason mason add public.orders beside it, in its container
|
|
61
61
|
|
|
62
|
+
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
63
|
+
|
|
62
64
|
Settings (the environment):
|
|
63
65
|
DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
|
|
64
66
|
the file names is required); read-only: SELECTs, or a replication slot
|
|
@@ -98,8 +100,6 @@ export const SERVE = {
|
|
|
98
100
|
UV_THREADPOOL_SIZE threads for running statements, one each, 1 to 1024 (default 32, lowered to fit a limit
|
|
99
101
|
on threads; one that does not fit, or is no such number, refuses the start)
|
|
100
102
|
|
|
101
|
-
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
102
|
-
|
|
103
103
|
Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
|
|
104
104
|
`,
|
|
105
105
|
options: { json: { type: "boolean", default: false } },
|
package/dist/src/cli/share.js
CHANGED
|
@@ -25,6 +25,7 @@ import { alive } from "./runs.js";
|
|
|
25
25
|
import { appEnvValue } from "../lib/dotenv.js";
|
|
26
26
|
import { newSharePassword, shareSecret } from "./share-run.js";
|
|
27
27
|
import { probeShare, startTunnel, sweepTunnelFiles, TOKEN_RE, TUNNELS, tunnelPlan } from "./tunnel.js";
|
|
28
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
28
29
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
29
30
|
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
30
31
|
const DEFAULT_TIMEOUT_S = 30;
|
|
@@ -35,12 +36,22 @@ export const SHARE = {
|
|
|
35
36
|
needs: "run",
|
|
36
37
|
name: "share",
|
|
37
38
|
summary: "share the synced data with a tool outside this machine: a temporary address, TLS end to end, a password for this run",
|
|
38
|
-
usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <
|
|
39
|
+
usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <duration>] [--json]
|
|
39
40
|
|
|
40
41
|
For a BI tool in the cloud, which cannot reach 127.0.0.1: opens a temporary public address for this project's Postgres
|
|
41
42
|
API, through a free tunnel service, and prints what to type into the tool. It stays in the foreground: Ctrl-C ends
|
|
42
43
|
the share. Your local tools notice nothing: the share has a socket of its own, and no session is dropped.
|
|
43
44
|
|
|
45
|
+
mason share
|
|
46
|
+
mason share --role sales
|
|
47
|
+
mason share --via bore
|
|
48
|
+
|
|
49
|
+
--role <role> what it reads: a role of mason/roles.yml
|
|
50
|
+
--via <tunnel> pinggy (default) or bore
|
|
51
|
+
--timeout <duration> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S}s; a bare number is
|
|
52
|
+
seconds; 30s, 2m); past it, exit 124
|
|
53
|
+
--json as data: its shape is at the end
|
|
54
|
+
|
|
44
55
|
The socket requires TLS of every connection, so a service that forwards bytes cannot read them. The certificate is
|
|
45
56
|
self-signed (the tool connects with sslmode=require, and checks no certificate), so a malicious service could sit in
|
|
46
57
|
the middle of the handshake: this is for trying Mason, on data you may send that way. mason serve on a host is the
|
|
@@ -56,17 +67,10 @@ export const SHARE = {
|
|
|
56
67
|
line is visible to every user of the machine), a paid Pinggy token, keeps the address, so a tunnel that drops is opened
|
|
57
68
|
again by itself, the password unchanged. --via bore needs bore (cargo install bore-cli).
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
--role <role> what it reads: a role of mason/roles.yml
|
|
64
|
-
--via <tunnel> pinggy (default) or bore
|
|
65
|
-
--timeout <s> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S})
|
|
66
|
-
--json as data, once the address is known: { share: { host, port, database, user, password, sslmode,
|
|
67
|
-
fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
|
|
68
|
-
share's certificate; false: it could not be reached from this machine) }, next }; the command
|
|
69
|
-
then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
70
|
+
The --json answer, once the address is known: { share: { host, port, database, user, password, sslmode,
|
|
71
|
+
fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
|
|
72
|
+
share's certificate; false: it could not be reached from this machine) }, next }; the command
|
|
73
|
+
then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
70
74
|
`,
|
|
71
75
|
options: { role: { type: "string" }, via: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
72
76
|
json: ["share", "next"],
|
|
@@ -135,9 +139,10 @@ export async function share(argv) {
|
|
|
135
139
|
const via = (v.via ?? "pinggy");
|
|
136
140
|
if (!TUNNELS.includes(via))
|
|
137
141
|
return failed("share", { message: `--via must be ${TUNNELS.join(" or ")}, not ${JSON.stringify(v.via)}`, code: "usage", usage: SHARE.usage }, { json });
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
return failed("share", { message: `--timeout
|
|
142
|
+
const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
|
|
143
|
+
if ("problem" in read)
|
|
144
|
+
return failed("share", { message: `--timeout ${read.problem}`, code: "usage", usage: SHARE.usage }, { json });
|
|
145
|
+
const timeoutS = read.seconds;
|
|
141
146
|
const w = await reach(SHARE, json);
|
|
142
147
|
if (w === null || !("run" in w))
|
|
143
148
|
return 1;
|
|
@@ -187,29 +187,28 @@ function via(x) {
|
|
|
187
187
|
view: ` (a view that writes ${x.entry})`, rule: ` (its rules write ${x.entry})` };
|
|
188
188
|
return said[x.kind];
|
|
189
189
|
}
|
|
190
|
-
const WHY = "Mason only reads,
|
|
190
|
+
const WHY = "Mason only reads, but, for security reasons, we recommend using a role that only reads";
|
|
191
191
|
/** The checks of the role, from its facts: one warning per code that applies, else one line saying it only reads. */
|
|
192
192
|
export function roleChecks(f, ask) {
|
|
193
193
|
const fix = readOnlyFix(f, ask);
|
|
194
|
-
const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}
|
|
194
|
+
const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}`, fix });
|
|
195
195
|
const out = [];
|
|
196
196
|
const supers = f.roles.filter((r) => r.superuser);
|
|
197
197
|
if (supers.length) {
|
|
198
198
|
// a superuser may do everything: what else it holds follows from it, and is not said again
|
|
199
199
|
const self = supers.some((r) => r.self);
|
|
200
|
-
out.push(warn("role_superuser", self ? `${f.user} is a superuser
|
|
201
|
-
: `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser
|
|
200
|
+
out.push(warn("role_superuser", self ? `${f.user} is a superuser`
|
|
201
|
+
: `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser`));
|
|
202
202
|
return out;
|
|
203
203
|
}
|
|
204
204
|
const bypass = f.roles.filter((r) => r.bypassrls);
|
|
205
205
|
if (bypass.length) {
|
|
206
|
-
out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}
|
|
206
|
+
out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}`));
|
|
207
207
|
}
|
|
208
208
|
const owned = f.relations.filter((x) => x.owned);
|
|
209
209
|
if (owned.length) {
|
|
210
210
|
const others = [...new Set(owned.map((x) => x.owner).filter((o) => o !== f.user))];
|
|
211
|
-
out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}
|
|
212
|
-
"an owner may write to, alter or drop a table whatever is granted"));
|
|
211
|
+
out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}`));
|
|
213
212
|
}
|
|
214
213
|
// an owner holds every privilege on its table: the owner's line said it
|
|
215
214
|
const writable = f.relations.filter((x) => !x.owned && x.privileges.length > 0);
|
|
@@ -223,18 +222,18 @@ export function roleChecks(f, ask) {
|
|
|
223
222
|
}
|
|
224
223
|
const creates = f.schemas.filter((s) => s.create.length > 0 || s.publicCreate);
|
|
225
224
|
if (creates.length) {
|
|
226
|
-
const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`}
|
|
227
|
-
out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}
|
|
225
|
+
const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`})` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
|
|
226
|
+
out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}`));
|
|
228
227
|
}
|
|
229
228
|
if (f.foreignTables.length || f.mappedServers.length) {
|
|
230
229
|
const held = [...(f.foreignTables.length ? [`may write the foreign table(s) ${list(f.foreignTables)}`] : []),
|
|
231
|
-
...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}
|
|
232
|
-
out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}
|
|
230
|
+
...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}`] : [])];
|
|
231
|
+
out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}`));
|
|
233
232
|
}
|
|
234
233
|
if (f.adminOf.length || f.createRole || f.serverRoles.length) {
|
|
235
|
-
const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}
|
|
236
|
-
...(f.createRole ? ["has CREATEROLE
|
|
237
|
-
...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}
|
|
234
|
+
const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}`] : []),
|
|
235
|
+
...(f.createRole ? ["has CREATEROLE"] : []),
|
|
236
|
+
...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}`] : [])];
|
|
238
237
|
out.push(warn("role_may_become_another", `${f.user} ${held.join("; ")}`));
|
|
239
238
|
}
|
|
240
239
|
// SECURITY DEFINER code: what it writes is in its body, which the catalog does not say, so it is LISTED for a person
|
|
@@ -263,7 +262,7 @@ function definerLine(f) {
|
|
|
263
262
|
return `${d.name} runs as ${d.owner}${d.ownerSuperuser ? ", a superuser" : ", which may write the synced tables"}: ${how.join("; ")}`;
|
|
264
263
|
});
|
|
265
264
|
return `may run ${f.definers.length} SECURITY DEFINER function(s) of roles that may write the synced tables — ${each.join("; ")}. ` +
|
|
266
|
-
"
|
|
265
|
+
"We recommend reviewing each: whether it writes a synced table, and who may call it";
|
|
267
266
|
}
|
|
268
267
|
/** The line a check is when the role could not be asked: said, never taken for "only reads". */
|
|
269
268
|
export function roleUnknown(why) {
|
package/dist/src/cli/sources.js
CHANGED
|
@@ -42,6 +42,15 @@ export const SOURCES_DIR = "sources";
|
|
|
42
42
|
const KEYS = ["kind", "url", "database", "sync", "publication", "tables"];
|
|
43
43
|
const ENTRY_KEYS = ["columns", "exclude", "key", "sync"];
|
|
44
44
|
const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
45
|
+
/**
|
|
46
|
+
* A `${NAME}` naming one of Mason's own settings (`MASON_*`, such as `MASON_API_KEY`): refused where the file is read,
|
|
47
|
+
* before anything resolves it, because a project may be someone else's and the value would be printed or sent as the
|
|
48
|
+
* publication's name (issue 718). `url:` keeps `${MASON_DATABASE_URL}`, the source's URL `mason init` reads and writes.
|
|
49
|
+
*/
|
|
50
|
+
const ownSetting = (name, key) => !/^MASON_/i.test(name) || (key === "url" && name === "MASON_DATABASE_URL") ? null :
|
|
51
|
+
`${key}: \${${name}} names one of Mason's own settings (MASON_*), which a sources file may not read — ` +
|
|
52
|
+
(key === "url" ? "write url: ${DATABASE_URL} (or ${MASON_DATABASE_URL}), or a postgres:// URL" :
|
|
53
|
+
"write publication: ${PG_PUBLICATION}, the publication's name, or publication: null and Mason makes its own");
|
|
45
54
|
/** The tables a file keeps current by replication, by their effective sync (`effectiveSync`): what streams. */
|
|
46
55
|
export function replicatedEntries(src) {
|
|
47
56
|
return src.tables.filter((e) => effectiveSync(src, e).sync.mode === "replication");
|
|
@@ -176,7 +185,10 @@ export function parseSourceText(text, name, file) {
|
|
|
176
185
|
else {
|
|
177
186
|
const m = ENV_REF.exec(v.url.trim());
|
|
178
187
|
const bare = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(v.url.trim());
|
|
179
|
-
|
|
188
|
+
const own = m ? ownSetting(m[1], "url") : null;
|
|
189
|
+
if (own !== null)
|
|
190
|
+
problems.push(own);
|
|
191
|
+
else if (m)
|
|
180
192
|
url = { env: m[1] };
|
|
181
193
|
else if (bare)
|
|
182
194
|
problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
|
|
@@ -201,8 +213,13 @@ export function parseSourceText(text, name, file) {
|
|
|
201
213
|
const m = ENV_REF.exec(p);
|
|
202
214
|
if (p === "")
|
|
203
215
|
problems.push("publication: write publication: ${PG_PUBLICATION}, or a publication's name (publication: null and Mason makes its own)");
|
|
204
|
-
else if (m)
|
|
205
|
-
|
|
216
|
+
else if (m) {
|
|
217
|
+
const own = ownSetting(m[1], "publication");
|
|
218
|
+
if (own !== null)
|
|
219
|
+
problems.push(own);
|
|
220
|
+
else
|
|
221
|
+
publication = { env: m[1] };
|
|
222
|
+
}
|
|
206
223
|
else if (p.includes("$"))
|
|
207
224
|
problems.push(`publication: ${JSON.stringify(p)} — write \${NAME} alone, like \${PG_PUBLICATION}, or the publication's name`);
|
|
208
225
|
else
|
package/dist/src/cli/style.js
CHANGED
|
@@ -158,6 +158,20 @@ export function stepRenderer(o) {
|
|
|
158
158
|
done(text) { end(`${s.green(live ? "✔" : "✓")} ${text ?? label ?? ""}`); },
|
|
159
159
|
fail(text) { end(`${s.red(live ? "✘" : "✗")} ${text ?? label ?? ""}`); },
|
|
160
160
|
print(text) { clear(); o.line(text); draw(); },
|
|
161
|
+
/** the step still turning, said in new words (a count that moves: how long it has waited); a step begun if none is */
|
|
162
|
+
relabel(text) {
|
|
163
|
+
if (label === null) {
|
|
164
|
+
label = text;
|
|
165
|
+
if (live) {
|
|
166
|
+
draw();
|
|
167
|
+
timer = setInterval(draw, 80);
|
|
168
|
+
timer.unref();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
label = text;
|
|
173
|
+
draw();
|
|
174
|
+
},
|
|
161
175
|
/** a step still turning: its label (null when none) */
|
|
162
176
|
open: () => label,
|
|
163
177
|
};
|