@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.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +290 -157
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/doctor.js +14 -11
  9. package/dist/src/cli/guides.js +20 -11
  10. package/dist/src/cli/hosted.js +1 -1
  11. package/dist/src/cli/init.js +110 -25
  12. package/dist/src/cli/login.js +20 -17
  13. package/dist/src/cli/machine.js +125 -40
  14. package/dist/src/cli/main.js +51 -8
  15. package/dist/src/cli/mcp.js +15 -12
  16. package/dist/src/cli/project.js +1 -1
  17. package/dist/src/cli/registry.js +80 -35
  18. package/dist/src/cli/replica-hosted.js +761 -102
  19. package/dist/src/cli/replica.js +43 -5
  20. package/dist/src/cli/replication-mode.js +5 -0
  21. package/dist/src/cli/request-filter.js +16 -6
  22. package/dist/src/cli/run-server.js +25 -2
  23. package/dist/src/cli/secret-input.js +193 -0
  24. package/dist/src/cli/serve.js +2 -2
  25. package/dist/src/cli/share.js +20 -15
  26. package/dist/src/cli/source-role.js +14 -15
  27. package/dist/src/cli/sources.js +20 -3
  28. package/dist/src/cli/style.js +14 -0
  29. package/dist/src/cli/sync.js +17 -8
  30. package/dist/src/cli/token-hosted.js +30 -24
  31. package/dist/src/cli/tunnel.js +1 -1
  32. package/dist/src/cli/ui-hosted.js +163 -0
  33. package/dist/src/cli/ui-ssh.js +3 -3
  34. package/dist/src/cli/ui.js +67 -26
  35. package/dist/src/cli/usage.js +19 -15
  36. package/dist/src/cli/user-hosted.js +78 -13
  37. package/dist/src/cli/user.js +168 -50
  38. package/dist/src/duck/users.js +105 -9
  39. package/dist/src/lib/cidr.js +177 -0
  40. package/dist/src/lib/table-sync.js +69 -19
  41. package/package.json +6 -6
@@ -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
- /** The replica the command means: `--replica`, else `MASON_REPLICA`; null when neither names one, a Failure when it is
8
- * not `<org>/<name>` (never repeated beyond 200 characters, made printable). `usage`: the command's, said after a wrong
9
- * `--replica`. */
10
- export function replicaOf(flag, usage, env = process.env) {
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
- return replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
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), as an ISO time; a time with no zone is refused, as is anything else.
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 = AGE.exec(text.trim());
28
- if (age !== null)
29
- return new Date(now - Number(age[1]) * AGE_MS[age[2]]).toISOString();
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
+ }
@@ -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 } },
@@ -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 <seconds>] [--json]
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
- mason share
60
- mason share --role sales
61
- mason share --via bore
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 timeoutS = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
139
- if (!Number.isFinite(timeoutS) || timeoutS <= 0)
140
- return failed("share", { message: `--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`, code: "usage" }, { json });
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, so a role that may write lets a Mason bug or a stolen URL change your database";
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}: sync as a role that only reads`, fix });
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: it may write to, alter or drop anything on this database`
201
- : `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser: it may SET ROLE to it and write to, alter or drop anything on this database`));
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))}: row-level security does not apply to it, on any table`));
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`}: it may drop what is in it too)` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
227
- out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}: it may make tables and functions beside the synced ones`));
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)}, which it may use`] : [])];
232
- out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}: what it sends there runs as the mapping's user, and the server may be this very database`));
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)}: it may grant itself that role and its privileges`] : []),
236
- ...(f.createRole ? ["has CREATEROLE, which before Postgres 16 lets it grant itself any role but a superuser"] : []),
237
- ...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}: the server's programs or files, as its operating-system user`] : [])];
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
- "What they write is not known (it is in their bodies, which the catalog does not read): review each, whether it writes a synced table and who may reach it";
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) {
@@ -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
- if (m)
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
- publication = { env: m[1] };
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
@@ -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
  };