@mason-data/cli 0.9.0 → 0.11.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 (56) 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 +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -0,0 +1,177 @@
1
+ /**
2
+ * ADDRESSES AND ADDRESS RANGES, READ BY ONE GRAMMAR: `MASON_PG_PROXY_FROM` (src/api/postgres/proxy.ts) and a `mason
3
+ * user`'s address ranges (src/duck/users.ts `from`) both read an entry here. An entry is an IPv4 or IPv6 address, alone
4
+ * (one address: /32 or /128) or with its prefix length, held to its family (`10.0.0.0/33` and `999.999.999.999/99` are
5
+ * refused), with no bits set past the prefix (`10.0.1.5/24` is refused with its network as the guess), and no host name
6
+ * (never looked up) nor zone index. An address is first held to `node:net`'s own reading (`isIPv4`, `isIPv6`), then
7
+ * read into its bytes here, so nothing either reading refuses is ever taken.
8
+ *
9
+ * EVERY ADDRESS IN ONE SPACE, as Mason means one: IPv6's 128 bits, an IPv4 address at its v4-mapped place
10
+ * (::ffff:a.b.c.d), since a v4-mapped peer or header source IS that IPv4 client (`shownAddress`). What an entry names and
11
+ * what a list covers are both read here, never from how an entry is spelt: `0.0.0.0/1,128.0.0.0/1` is every IPv4
12
+ * address, as `0.0.0.0/0` is, and `::/64` holds every v4-mapped address, so every IPv4 one too.
13
+ */
14
+ import * as net from "node:net";
15
+ /** An address as Mason shows it: IPv4 dotted, IPv6 in RFC 5952's form, a v4-mapped IPv6 one as IPv4; null when it is no
16
+ * address (undefined, a zone index, a name). */
17
+ export function shownAddress(text) {
18
+ if (text === undefined)
19
+ return null;
20
+ const v4 = ipv4Bytes(text);
21
+ if (v4 !== null)
22
+ return Array.from(v4).join(".");
23
+ const v6 = ipv6Bytes(text);
24
+ return v6 === null ? null : ipv6Text(v6);
25
+ }
26
+ /** An IPv4 address's 4 bytes, from dotted decimal with no leading zero; null for anything else. */
27
+ function ipv4Bytes(text) {
28
+ if (!net.isIPv4(text))
29
+ return null;
30
+ const parts = text.split(".");
31
+ if (parts.length !== 4 || !parts.every((p) => /^(?:0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255))
32
+ return null;
33
+ return Uint8Array.from(parts.map(Number));
34
+ }
35
+ /** An IPv6 address's 16 bytes (`::` once at most, an IPv4 tail); null for anything else, a zone index included. */
36
+ export function ipv6Bytes(text) {
37
+ // node:net takes a zone index (fe80::1%eth0): an address with one names no place here
38
+ if (!/^[0-9A-Fa-f:.]+$/.test(text) || !net.isIPv6(text))
39
+ return null;
40
+ const halves = text.split("::");
41
+ if (halves.length > 2 || (halves.length === 2 && halves[0].includes(".")))
42
+ return null;
43
+ const groups = (s) => {
44
+ if (s === "")
45
+ return [];
46
+ const out = [];
47
+ const parts = s.split(":");
48
+ for (const [i, p] of parts.entries()) {
49
+ if (i === parts.length - 1 && p.includes(".")) {
50
+ const v4 = ipv4Bytes(p);
51
+ if (v4 === null)
52
+ return null;
53
+ out.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]);
54
+ }
55
+ else if (/^[0-9A-Fa-f]{1,4}$/.test(p))
56
+ out.push(parseInt(p, 16));
57
+ else
58
+ return null;
59
+ }
60
+ return out;
61
+ };
62
+ const head = groups(halves[0]), tail = halves.length === 2 ? groups(halves[1]) : [];
63
+ if (head === null || tail === null)
64
+ return null;
65
+ const n = head.length + tail.length;
66
+ if (halves.length === 1 ? n !== 8 : n > 7)
67
+ return null;
68
+ const all = [...head, ...new Array(8 - n).fill(0), ...tail];
69
+ return Uint8Array.from(all.flatMap((g) => [g >> 8, g & 0xff]));
70
+ }
71
+ /** whether 16 bytes are an IPv4 address mapped into IPv6 (::ffff:a.b.c.d) */
72
+ const v4Mapped = (b) => b.subarray(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
73
+ /** 16 bytes as Mason shows an IPv6 address: RFC 5952 (lower case, the longest run of two or more zero groups as `::`,
74
+ * the first of equal runs), and a v4-mapped one as its IPv4 address. */
75
+ export function ipv6Text(b) {
76
+ if (v4Mapped(b))
77
+ return Array.from(b.subarray(12, 16)).join(".");
78
+ const g = Array.from({ length: 8 }, (_, i) => (b[2 * i] << 8) | b[2 * i + 1]);
79
+ let at = -1, run = 0;
80
+ for (let i = 0; i < 8;) {
81
+ if (g[i] !== 0) {
82
+ i++;
83
+ continue;
84
+ }
85
+ let j = i;
86
+ while (j < 8 && g[j] === 0)
87
+ j++;
88
+ if (j - i > run) {
89
+ at = i;
90
+ run = j - i;
91
+ }
92
+ i = j;
93
+ }
94
+ const hex = g.map((x) => x.toString(16));
95
+ return run < 2 ? hex.join(":") : `${hex.slice(0, at).join(":")}::${hex.slice(at + run).join(":")}`;
96
+ }
97
+ export const V4_START = 0xffffn << 32n;
98
+ export const V4_END = V4_START + (1n << 32n) - 1n;
99
+ export const ALL_END = (1n << 128n) - 1n;
100
+ const bigOf = (b) => b.reduce((n, x) => (n << 8n) | BigInt(x), 0n);
101
+ /** an address's place (`V4_START`), or null when it is no address */
102
+ function placeOf(address) {
103
+ const v4 = ipv4Bytes(address);
104
+ if (v4 !== null)
105
+ return V4_START | bigOf(v4);
106
+ const v6 = ipv6Bytes(address);
107
+ return v6 === null ? null : bigOf(v6);
108
+ }
109
+ /** the places an entry names */
110
+ export function rangeOf(c) {
111
+ const start = c.family === 4 ? V4_START | bigOf(c.bytes) : bigOf(c.bytes);
112
+ return { start, end: start + (1n << BigInt((c.family === 4 ? 32 : 128) - c.prefix)) - 1n };
113
+ }
114
+ /** Whether `address` (as `shownAddress` shows one, or in any spelling of the same address) is in one of `ranges`; never
115
+ * for what is no address. */
116
+ export function inRanges(ranges, address) {
117
+ const at = placeOf(address);
118
+ return at !== null && ranges.some((r) => r.start <= at && at <= r.end);
119
+ }
120
+ /** whether `ranges` together hold every place from `lo` to `hi` */
121
+ export function covers(ranges, lo, hi) {
122
+ let next = lo;
123
+ for (const r of [...ranges].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0))) {
124
+ if (r.start > next)
125
+ return false;
126
+ if (r.end >= next)
127
+ next = r.end + 1n;
128
+ if (next > hi)
129
+ return true;
130
+ }
131
+ return next > hi;
132
+ }
133
+ function prefixEqual(a, b, bits) {
134
+ for (let i = 0; i * 8 < bits; i++) {
135
+ const mask = bits - i * 8 >= 8 ? 0xff : (0xff << (8 - (bits - i * 8))) & 0xff;
136
+ if ((a[i] & mask) !== (b[i] & mask))
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ /** the network `bytes` / `prefix` names, its host bits cleared */
142
+ function network(bytes, prefix) {
143
+ return bytes.map((x, i) => i * 8 >= prefix ? 0 : prefix - i * 8 >= 8 ? x : x & ((0xff << (8 - (prefix - i * 8))) & 0xff));
144
+ }
145
+ const text = (family, bytes) => family === 4 ? Array.from(bytes).join(".") : ipv6Text(bytes);
146
+ /** An entry as Mason writes it back: the network in `shownAddress`'s form, its prefix only when it is not one address. */
147
+ export const cidrText = (c) => `${text(c.family, c.bytes)}${c.prefix === (c.family === 4 ? 32 : 128) ? "" : `/${c.prefix}`}`;
148
+ /**
149
+ * One entry read, or what is wrong with it, with a did-you-mean where one is a fair guess: a guess is offered only
150
+ * once it is read as an entry itself, so it is never one that would be refused in turn. `everyRefused`: a prefix of 0
151
+ * (every address of a family) is refused with these words (a PROXY header believed from anyone); else it is taken.
152
+ */
153
+ export function cidrOf(entry, everyRefused = null) {
154
+ const quoted = JSON.stringify(entry);
155
+ const m = /^([^/]*)(?:\/(\d{1,3}))?$/.exec(entry);
156
+ const v4 = m === null ? null : ipv4Bytes(m[1]), v6 = m === null || v4 !== null ? null : ipv6Bytes(m[1]);
157
+ if (m === null || (v4 === null && v6 === null)) {
158
+ return { problem: `${quoted} is not an address or a CIDR (10.0.0.0/16, 2001:db8::/32, or one address such as 10.0.4.7; a host name is not looked up)` };
159
+ }
160
+ const family = v4 !== null ? 4 : 6, bytes = (v4 ?? v6), max = family === 4 ? 32 : 128;
161
+ const prefix = m[2] === undefined ? max : Number(m[2]);
162
+ if (prefix > max)
163
+ return { problem: `${quoted}: an IPv${family} prefix is at most /${max}` };
164
+ if (family === 6 && v4Mapped(bytes) && prefix >= 96) {
165
+ const p4 = prefix - 96, net4 = network(bytes.subarray(12, 16), p4);
166
+ const meant = `${text(4, net4)}${p4 === 32 ? "" : `/${p4}`}`, read = cidrOf(meant, everyRefused);
167
+ return { problem: "problem" in read
168
+ ? `${quoted} is IPv4 written as IPv6, which Mason reads as ${meant}, and ${read.problem}`
169
+ : `${quoted} is IPv4 written as IPv6, which Mason reads as IPv4: did you mean ${meant}?` };
170
+ }
171
+ if (prefix === 0 && everyRefused !== null)
172
+ return { problem: `${quoted} names every address, ${everyRefused}` };
173
+ const net = network(bytes, prefix);
174
+ if (!prefixEqual(net, bytes, max))
175
+ return { problem: `${quoted} has bits set past its prefix: did you mean ${text(family, net)}/${prefix}?` };
176
+ return { family, bytes: net, prefix };
177
+ }
@@ -5,7 +5,7 @@
5
5
  * in the same words at both entrances:
6
6
  *
7
7
  * off nothing after the first copy until `mason sync <table>`, which polls it once, now (the default)
8
- * poll <interval> the xmin poll every <interval>: <n>s, <n>m or <n>h, a whole number, at least 1s, at most 24h
8
+ * poll <interval> the xmin poll every <interval>: <n>s, <n>m, <n>h or <n>d, a whole number, at least 1s, at most 24h
9
9
  * replication the database's change log, through the project's ONE slot and publication (src/duck/stream.ts):
10
10
  * the publication carries the replicated tables only, and the polled ones are polled beside it
11
11
  *
@@ -34,11 +34,11 @@ export const BIG_TABLE_ROWS = 1_000_000;
34
34
  export const COSTLY_POLL_ROWS = 10_000_000;
35
35
  export const COSTLY_POLL_SECONDS = 60;
36
36
  const MODES = ["off", "poll", "replication"];
37
- const UNIT_SECONDS = { s: 1, m: 60, h: 3600 };
37
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 };
38
38
  /** the units a person may write for one of ours, said back as the one to write */
39
39
  const UNIT_WORDS = {
40
40
  s: "s", sec: "s", secs: "s", second: "s", seconds: "s", m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
41
- h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
41
+ h: "h", hr: "h", hrs: "h", hour: "h", hours: "h", d: "d", day: "d", days: "d",
42
42
  };
43
43
  const EXAMPLES = "poll 5s, poll 5m or poll 1h";
44
44
  /** what a sync is, said where one is wrong */
@@ -52,23 +52,24 @@ export function intervalOf(seconds) {
52
52
  const s = Math.max(MIN_POLL_SECONDS, Math.round(seconds));
53
53
  return s % 3600 === 0 ? `${s / 3600}h` : s % 60 === 0 ? `${s / 60}m` : `${s}s`;
54
54
  }
55
- /** an interval (`<n>s|m|h`) read, or why it is not one, with the interval to write when there is one. ONE grammar with three
56
- * entrances, each worded as it is written: a `sync:`'s `poll <interval>`, a generated table's `target_lag:`, and a
57
- * command's wait (`parseDuration`) */
55
+ /** an interval (`<n>s|m|h|d`) read, or why it is not one, with the interval to write when there is one. ONE grammar with
56
+ * two entrances, each worded as it is written: a `sync:`'s `poll <interval>` and a generated table's `target_lag:`. Its
57
+ * units are a command line's duration's too (`parseDuration`), which also takes a bare number of seconds: a file writes
58
+ * the unit, one spelling of each interval. */
58
59
  function parseInterval(v, write = "poll ") {
59
60
  const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(v);
60
61
  const examples = write === "" ? "5s, 5m or 1h" : EXAMPLES;
61
- const how = `an interval is <n>s, <n>m or <n>h, a whole number, at least ${MIN_POLL_SECONDS}s (${examples})`;
62
+ const how = `an interval is <n>s, <n>m, <n>h or <n>d, a whole number, at least ${MIN_POLL_SECONDS}s (${examples})`;
63
+ if (/^\d+$/.test(v))
64
+ return { problem: `${v} has no unit: write ${write}${Number(v)}s for seconds (or m, h, d)` };
62
65
  if (m === null)
63
66
  return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
64
67
  const n = Number(m[1]), word = m[2].toLowerCase();
65
68
  if (word === "ms")
66
69
  return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s${write === "" ? "" : " (a poll scans the table whole on your database)"}: write ${write}${MIN_POLL_SECONDS}s or longer` };
67
70
  const unit = UNIT_WORDS[word];
68
- if (unit === undefined) {
69
- const days = /^(d|day|days)$/.test(word) ? `: write ${write}${intervalOf(n * 86_400)}` : "";
70
- return { problem: `${v}: ${word} is not a unit of an interval — ${days ? `days are hours${days}` : how}` };
71
- }
71
+ if (unit === undefined)
72
+ return { problem: `${v}: ${word} is not a unit of an interval — ${how}` };
72
73
  const seconds = n * UNIT_SECONDS[unit];
73
74
  if (!Number.isInteger(n))
74
75
  return { problem: `${v}: an interval is a whole number — write ${write}${intervalOf(seconds)}` };
@@ -89,10 +90,41 @@ export function parseLag(v) {
89
90
  return { problem: `is ${v === null || v === undefined ? "nothing" : typeof v === "number" ? `the number ${String(v)}` : "not an interval"}: write how far behind the table may be, like 30s, 5m or 1h` };
90
91
  return parseInterval(v.trim(), "");
91
92
  }
92
- /** A command's own wait, as an interval (`mason deploy --timeout 10m`): the grammar and range a poll's and a `target_lag:`
93
- * take, so a span of time is written one way at every entrance. */
94
- export function parseDuration(v) {
95
- return parseInterval(v.trim(), "");
93
+ /** How a duration on a command line is written: said where one is wrong, and `<duration>` in each usage. */
94
+ export const DURATION_FORMS = "a number of seconds, or <n>s, <n>m, <n>h or <n>d (90, 30s, 10m, 1h, 7d)";
95
+ /**
96
+ * A DURATION ON A COMMAND LINE, ONE GRAMMAR FOR EVERY FLAG THAT TAKES ONE (`--timeout` of mason wait, share, ui --ssh,
97
+ * deploy and replica create; mason usage's `--step`; the age `--since` / `--until` take, src/cli/request-filter.ts
98
+ * `timeOf`, which refuses a bare number as a time): a bare number is seconds, as wait, share and ui read it before; `<n>s|m|h|d`, and the words a sync's
99
+ * interval takes (`5 min`, `2 hours`, `7 days`), as a `sync:` interval and a `target_lag:` are written. A superset of
100
+ * every form each flag took before, so nothing that parsed stops parsing. The range is the flag's (`min`, `max` in
101
+ * seconds; over 0, unless `zero`: an age of now, `--since 0s`, and `--step 0s`, every sample, as each took it before);
102
+ * the caller names the flag.
103
+ */
104
+ export function parseDuration(v, o = {}) {
105
+ const t = v.trim();
106
+ let seconds, every;
107
+ if (t !== "" && Number.isFinite(Number(t))) {
108
+ seconds = Number(t);
109
+ every = `${seconds}s`;
110
+ }
111
+ else {
112
+ const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(t);
113
+ const unit = m === null ? undefined : UNIT_WORDS[m[2].toLowerCase()];
114
+ if (m === null || unit === undefined)
115
+ return { problem: `${JSON.stringify(v)} is not a duration: write ${DURATION_FORMS}` };
116
+ seconds = Number(m[1]) * UNIT_SECONDS[unit];
117
+ every = `${Number(m[1])}${unit}`;
118
+ }
119
+ if (o.zero === true && seconds === 0 && !Object.is(seconds, -0))
120
+ return { every, seconds: 0 };
121
+ if (!(seconds > 0))
122
+ return { problem: `${JSON.stringify(v)} is not a duration over 0: write ${DURATION_FORMS}` };
123
+ if (o.min !== undefined && seconds < o.min)
124
+ return { problem: `${every} is under the shortest, ${intervalOf(o.min)}` };
125
+ if (o.max !== undefined && seconds > o.max)
126
+ return { problem: `${every} is over the longest, ${intervalOf(o.max)}` };
127
+ return { every, seconds };
96
128
  }
97
129
  /**
98
130
  * A `sync:` VALUE READ, or what is wrong with it in the words a person fixes it from (the key is the caller's to say):
@@ -186,9 +218,14 @@ function saidInTheFile(what) {
186
218
  * every one given on ONE report, one coherent way out (a publication goes with replication). Null when none is given.
187
219
  * Read from the raw command line, before the options are parsed, so a removed flag is answered as removed and never
188
220
  * as an unknown option.
221
+ *
222
+ * `--port` IS NOW `--ui-port` (issue 729: it is the web page's and the commands' port, as MASON_UI_PORT is on a server,
223
+ * and read as the Postgres API's): answered on the same report with the line to run — `start`, built from the flags
224
+ * mason start takes alone, so nothing else of the line is repeated. `sync`: a sources-file flag was given.
189
225
  */
190
226
  export function removedStartFlags(argv, top) {
191
- let replication = false, poll = false, publication;
227
+ let replication = false, poll = false, publication, port = false;
228
+ const kept = [];
192
229
  for (let i = 0; i < argv.length; i++) {
193
230
  const a = argv[i];
194
231
  if (a === "--")
@@ -203,10 +240,21 @@ export function removedStartFlags(argv, top) {
203
240
  const v = argv[i + 1];
204
241
  publication = v !== undefined && !v.startsWith("-") ? v : null;
205
242
  }
243
+ else if (a === "--port" || a.startsWith("--port=")) {
244
+ const v = a === "--port" ? argv[i + 1] : a.slice("--port=".length);
245
+ if (a === "--port" && v !== undefined && !v.startsWith("-"))
246
+ i++;
247
+ port = true;
248
+ kept.push(`--ui-port ${v !== undefined && /^\d{1,5}$/.test(v) ? v : "<n>"}`);
249
+ }
250
+ else if (a === "--attach" || a === "--self-signed-tls" || a === "--json")
251
+ kept.push(a);
206
252
  }
207
253
  const given = [...(replication ? ["--replication"] : []), ...(poll ? ["--poll"] : []), ...(publication !== undefined ? ["--publication"] : [])];
254
+ const start = port ? ["mason start", ...kept].join(" ") : "mason start";
255
+ const portSaid = { message: "--port is now --ui-port", why: "it is the port of the web page and the mason commands (MASON_UI_PORT on a server), never the Postgres API's (MASON_PG_PORT)" };
208
256
  if (given.length === 0)
209
- return null;
257
+ return port ? { ...portSaid, fix: start, start, sync: false } : null;
210
258
  const t = top();
211
259
  const named = given.length === 1 ? `${given[0]} is gone` : `${given.slice(0, -1).join(", ")} and ${given.at(-1)} are gone`;
212
260
  const pub = publication === undefined ? null : topEdit(t, "publication", [publication ?? "<name>"]);
@@ -230,8 +278,10 @@ export function removedStartFlags(argv, top) {
230
278
  fix = `${edit} (${edit.startsWith("keep ") ? "every table is polled already; a table's entry may say its own"
231
279
  : "any interval: poll 5s, poll 1h; or sync: off, the default, and mason sync <table> when you need it"})`;
232
280
  }
233
- return { message: `${named}: the sources file says it now`, why: saidInTheFile({ sync: replication || poll, publication: publication !== undefined }),
234
- fix: `${fix}, then mason start` };
281
+ const why = saidInTheFile({ sync: replication || poll, publication: publication !== undefined });
282
+ if (!port)
283
+ return { message: `${named}: the sources file says it now`, why, fix: `${fix}, then mason start`, start, sync: true };
284
+ return { message: `${named}: the sources file says it now; ${portSaid.message}`, why: `${why}; ${portSaid.why}`, fix: `${fix}, then ${start}`, start, sync: true };
235
285
  }
236
286
  /**
237
287
  * `MASON_CHANGES` SET IS REFUSED, never run on a default (CLAUDE.md: a removed setting refuses the start, naming the
@@ -497,6 +497,11 @@ table.catalog tbody tr:hover > td.act { background: var(--hover); }
497
497
  tr.removed > * { color: var(--muted); }
498
498
  form.formcard { margin: 0 0 var(--space-5); padding: var(--space-3) var(--space-4); background: var(--surface); border: 1px solid var(--frame); border-radius: var(--radius-lg); box-shadow: var(--drop); }
499
499
  form.formcard input { width: 18em; }
500
+ /* the Users form's Role: its label and select wrap together on a phone, the select shrinking to the card (a long role's
501
+ name cut in the closed box, whole in the open list), so the page never scrolls sideways */
502
+ #add-user .role-field { display: inline-flex; align-items: center; gap: var(--space-2); max-width: 100%; min-width: 0; vertical-align: middle; }
503
+ #add-user .role-field label { flex: none; }
504
+ #add-user .role-field select { flex: 0 1 auto; min-width: 0; max-width: 100%; text-overflow: ellipsis; }
500
505
  /* ---------------------------------------------------------------- Connect: the URL, its psql line, Create a user */
501
506
  /* a box whose title sits on its top edge, as a fieldset's legend does (Connect, Getting started, a chart's name) */
502
507
  .connect { background: var(--surface); border: 1px solid var(--frame); border-radius: var(--radius-lg); box-shadow: var(--drop); padding: var(--space-4) var(--space-5); margin: var(--space-6) 0 var(--space-6); }
@@ -62,7 +62,7 @@ import { page as usagePage } from "./pages/usage.js";
62
62
  import { page as syncsPage } from "./pages/syncs.js";
63
63
 
64
64
  /** What the shell itself reads when the page shown does not: the state (the sidebar's project, badge and version). */
65
- const SHELL_STATE = { path: "/api/state", everyMs: 60_000 };
65
+ const SHELL_STATE = { path: "api/state", everyMs: 60_000 };
66
66
 
67
67
  /** A text that moves with the clock (an age, a running time), and a part's HTML without them: `paint` sets those
68
68
  * texts in place when nothing else changed. */
@@ -76,7 +76,7 @@ export const PRESS_HOLD_MS = 2000;
76
76
  export * from "./shared.js";
77
77
  export { copyingText, tableLine, keptAge, keptWords, sourceRows, renderStatus, renderShare, createShare } from "./pages/status.js";
78
78
  export { tableFacts, addedLines, removedReport, pickBody, columnsPath, renderTableAdded, renderTablesRemoved, renderTables } from "./pages/tables.js";
79
- export { renderUsers, renderAdded, attributesText } from "./pages/users.js";
79
+ export { renderUsers, renderAdded, renderAddUser, attributesText } from "./pages/users.js";
80
80
  export { renderRequests } from "./pages/queries.js";
81
81
  export { renderRunning, renderSessions, renderClients, renderSlowest, queriesHref } from "./pages/usage.js";
82
82
 
@@ -182,7 +182,7 @@ export function createApp(fetchFn, now = () => new Date(), shown = "status") {
182
182
  // every answer passes here: on a server's page a failure the page words itself is the tunnel's (`overTunnel`)
183
183
  const ask = async (path, init) => {
184
184
  const r = await asked(path, init);
185
- return "failure" in r && store.get("/api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
185
+ return "failure" in r && store.get("api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
186
186
  };
187
187
  const first = parseHash(shown);
188
188
  const m = { shown: first.id, range: rangeOf(first.params.range), pollFailure: null, updatedAt: null, rangeReadAt: null };
@@ -236,7 +236,7 @@ export function createApp(fetchFn, now = () => new Date(), shown = "status") {
236
236
  afterRead();
237
237
  },
238
238
  view() {
239
- const st = store.get("/api/state")?.body;
239
+ const st = store.get("api/state")?.body;
240
240
  return {
241
241
  nav: renderNav(m.shown, m.range),
242
242
  // the range control in the shown view's header row (each group page's own slot; the others empty)
@@ -7,9 +7,9 @@
7
7
  <!-- no favicon request: an empty one, inline (img-src allows data:) -->
8
8
  <link rel="icon" href="data:,">
9
9
  <!-- every file Mason's own, from its allowlist (src/cli/run-server.ts PAGE_ROUTES): the fonts are app.css's, vendored -->
10
- <link rel="stylesheet" href="/app.css">
11
- <link rel="stylesheet" href="/vendor/uplot-1.6.32/uPlot.min.css">
12
- <script type="module" src="/app.js"></script>
10
+ <link rel="stylesheet" href="app.css">
11
+ <link rel="stylesheet" href="vendor/uplot-1.6.32/uPlot.min.css">
12
+ <script type="module" src="app.js"></script>
13
13
  </head>
14
14
  <body>
15
15
  <!-- the shell (src/web/app.js): the sidebar — the project, the nav, when it was updated and the version — and the page
@@ -69,11 +69,8 @@
69
69
  </section>
70
70
  <section id="page-users" aria-labelledby="users-h" hidden>
71
71
  <div class="page-head"><h2 id="users-h">Users</h2><p class="desc">A user is the name and password psql, a BI tool or your app connects to Mason with; it does not expire. The same users as <code>mason user</code>.</p></div>
72
- <form id="add-user" class="formcard" autocomplete="off">
73
- <label for="user-name">Name</label>
74
- <input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">
75
- <button type="submit" class="primary">Add a user</button>
76
- </form>
72
+ <!-- the add form is the page's (src/web/pages/users.js renderAddUser): none on a read-only page -->
73
+ <div id="add-user-box"></div>
77
74
  <div id="action"></div>
78
75
  <div id="added"></div>
79
76
  <div id="users-connect"></div>
@@ -14,7 +14,7 @@
14
14
  import { msText, agoText, bytesText, esc, firstLine, n, empty, renderFailure, rowsHtml, utcText, utcMsText, details, createOpenSet, status,
15
15
  clip, capped, TITLE_MAX, sortTh, sortedBy, nextSort, RANGES, DEFAULT_RANGE, clientText, renderConnect, copyClick, UNLISTED_STATEMENTS, statementCell, rangeEvery } from "../shared.js";
16
16
 
17
- const STATE = "/api/state";
17
+ const STATE = "api/state";
18
18
  const NEWEST_EVERY_MS = 5000;
19
19
  /** The newest page while a search is set: a search reads every statement's text of the range (7 days of a busy log,
20
20
  * ~0.6 s a read), so it is read every 30 s, not 5 — said under the log (SEARCH_NOTE). */
@@ -103,7 +103,7 @@ export function requestsPath(filters, before, range = DEFAULT_RANGE) {
103
103
  if (filters.slow) p.set("slow", "1");
104
104
  if (filters.search) p.set("search", filters.search);
105
105
  if (before) p.set("before", before);
106
- return `/api/requests?${p.toString()}`;
106
+ return `api/requests?${p.toString()}`;
107
107
  }
108
108
 
109
109
  export const page = {
@@ -156,7 +156,7 @@ export const page = {
156
156
  const s = ctx.read(STATE)?.body;
157
157
  return {
158
158
  "request-log": (log?.failure ? renderFailure(log.failure) : "") + (log?.body ? renderRequests(log.body, { paged: m.before !== null, filtered: filtered(), isOpen: opened.has, now: ctx.now().getTime(), sort: m.sort,
159
- span: m.filters.since ? "" : (RANGES[ctx.range()] ?? RANGES[DEFAULT_RANGE]).span, connect: renderConnect(s?.postgres, { first: true, table: s?.tables?.find((t) => t.added && t.copy?.phase === "done")?.table ?? null }) }) : "") +
159
+ span: m.filters.since ? "" : (RANGES[ctx.range()] ?? RANGES[DEFAULT_RANGE]).span, connect: renderConnect(s?.postgres, { first: true, table: s?.tables?.find((t) => t.added && t.copy?.phase === "done")?.table ?? null, readOnly: s?.pageReadOnly ?? null }) }) : "") +
160
160
  (log?.body && m.filters.search && m.before === null ? `<p class="dim refresh-note">${esc(SEARCH_NOTE)}</p>` : ""),
161
161
  };
162
162
  },
@@ -21,7 +21,7 @@ const LINKS = { tables: "#tables", failedTables: "#tables?show=failed", usage: "
21
21
  slow: "#observability/queries?slow=1&range=24h", failedQueries: "#observability/queries?status=refused-or-failed&range=24h", failedEvents: "#observability/syncs?failures=1&range=7d" };
22
22
  import { msText, bytesText, code, esc, fileSize, firstLine, n, percent, leftText, spanText, duration, entryName, streamRows, slotWarns, masonRow, sessionsRows, diskRows, restartsRows, utcText,
23
23
  utcMinute, oneLine, clientText, eventCells, unsupportedEntry, usagePath, POLL_MS, card, cards, details, createOpenSet, skeleton, renderConnect, copyClick,
24
- badge, button, copyable, pop, renderFailure, json } from "../shared.js";
24
+ badge, button, copyable, pop, renderFailure, json, readOnlyLine } from "../shared.js";
25
25
  import { spark, seriesOf, drawable } from "../charts.js";
26
26
 
27
27
  /** How many recent errors of each kind are shown: mason status's (src/cli/commands.ts `RECENT_ERRORS`, held equal by the tests). */
@@ -289,6 +289,8 @@ export function renderFacts(s, now = Date.now()) {
289
289
  const d = s.disk;
290
290
  rows.push(...diskRows(d).map(([k, v]) => factRow(k, v, d.freeBytes === null || (d.projectedBytes !== null && d.projectedBytes > d.freeBytes))));
291
291
  rows.push(...sessionsRows(s.sessions).map(([k, v]) => factRow(k, v)));
292
+ // `pageSocket` (mason serve with MASON_UI_SOCKET): said without its path, as the page shows no absolute path
293
+ if (typeof s.pageSocket === "string") rows.push(factRow("Web page", "served on a Unix socket on the server too (MASON_UI_SOCKET)"));
292
294
  return `<table class="facts-table"><caption>Mason</caption><tbody>${rows.join("")}</tbody></table>`;
293
295
  }
294
296
 
@@ -398,10 +400,12 @@ export function renderChecklist(s, queries, users) {
398
400
  const answered = queries.queries.week.completed > 0;
399
401
  if (copied && user && answered) return "";
400
402
  const copying = added && !copied && copyingCount(s) > 0;
403
+ // a read-only page (MASON_UI_READ_ONLY=1) sends no one to add what it would refuse to add
404
+ const looks = (s.pageReadOnly ?? null) !== null;
401
405
  const steps = [
402
406
  ["Mason is running", true, ""],
403
- ["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
404
- ["A user created", user, `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
407
+ ["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added || looks ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
408
+ ["A user created", user, looks ? "" : `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
405
409
  // the psql line is Connect's (rendered under the checklist once a table is added): its Copy copies it
406
410
  ["A first statement answered", answered, copied ? `${`<button type="button" class="small${user ? " primary" : ""}" data-copy="connect-psql">Copy the psql line</button>`} <span class="dim">then run a select on a synced table</span>` : ""],
407
411
  ];
@@ -423,8 +427,9 @@ export function renderStatus(s, o = {}) {
423
427
  // day's and week's statements, read as none), else after the facts, for the next tool
424
428
  const first = isFirstConnect(o.queries);
425
429
  const connect = added ? renderConnect(s.postgres, { first, served: s.served === true, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null,
426
- share: renderShare(s, o.share ?? NO_SHARE, now) }) : "";
427
- return renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
430
+ share: renderShare(s, o.share ?? NO_SHARE, now), readOnly: s.pageReadOnly ?? null }) : "";
431
+ // a read-only page (MASON_UI_READ_ONLY=1): why it offers no share, no user and no table to add, as Tables and Users say it
432
+ return readOnlyLine("Nothing is shared, added or created", s.pageReadOnly) + renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
428
433
  (added ? renderQueries(o.queries) : "") + (added ? renderRecentErrors(o.events, o.requests, o.unsupported) : "");
429
434
  }
430
435
 
@@ -446,7 +451,8 @@ const endsLine = (endsAt, now) => endsAt === null || endsAt === undefined ? "It
446
451
  * offered, the answer that opened it — the password is in it alone — and the failure). Nothing under `mason serve`.
447
452
  */
448
453
  export function renderShare(s, m = NO_SHARE, now = Date.now()) {
449
- if (s?.served === true) return "";
454
+ // a server's run has no share to open, and a read-only page (MASON_UI_READ_ONLY=1) opens nothing
455
+ if (s?.served === true || (s?.pageReadOnly ?? null) !== null) return "";
450
456
  const open = pageShareOf(s);
451
457
  const head = `<p class="connect-title share-title">Connect from anywhere</p>`;
452
458
  const ask = button("Connect from anywhere", { shareAsk: "" }, "", { disabled: m.phase === "asking" || m.phase === "opening" });
@@ -483,7 +489,7 @@ export const UNSUPPORTED_LIMIT = 100;
483
489
 
484
490
  /** What the page reads every 30 s beside the state: the request log's summary, the recent errors, and the last day's
485
491
  * unsupported SQL. */
486
- const SLOWER = ["/api/queries", `/api/events?failed=1&limit=${RECENT_ERRORS}`, `/api/requests?failed=1&limit=${RECENT_ERRORS}`, `/api/unsupported?limit=${UNSUPPORTED_LIMIT}`, "/api/users"];
492
+ const SLOWER = ["api/queries", `api/events?failed=1&limit=${RECENT_ERRORS}`, `api/requests?failed=1&limit=${RECENT_ERRORS}`, `api/unsupported?limit=${UNSUPPORTED_LIMIT}`, "api/users"];
487
493
 
488
494
  /** A read as `renderStatus` takes it: the body, or `{ failure }` when it could not be read; undefined before any read. */
489
495
  const asRequest = (r) => r === undefined ? undefined : r.failure ? { failure: r.failure } : r.body;
@@ -499,12 +505,12 @@ export const page = {
499
505
  const share = createShare(ctx);
500
506
  return {
501
507
  model: { open: opened.open, share: share.m },
502
- reads: () => [{ path: "/api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
508
+ reads: () => [{ path: "api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
503
509
  actions: share.actions,
504
- afterRead: () => share.afterRead(ctx.read("/api/state")?.body),
510
+ afterRead: () => share.afterRead(ctx.read("api/state")?.body),
505
511
  input: (target) => share.input(target),
506
512
  view() {
507
- const st = ctx.read("/api/state");
513
+ const st = ctx.read("api/state");
508
514
  if (!st?.body) return { status: st?.failure ? "" : skeleton(4) };
509
515
  const [queries, events, requests, unsupported, users] = SLOWER.map((p) => asRequest(ctx.read(p)));
510
516
  return { status: renderStatus(st.body, { now: ctx.now().getTime(), queries, events, requests, unsupported, users, usage: ctx.read(HOURLY)?.body, open: opened.has, shares: monotone.of(st.body), before: monotone.before, share: share.m }) };
@@ -535,8 +541,8 @@ export function createShare(ctx) {
535
541
  const beat = async () => {
536
542
  if (beating === null) return;
537
543
  const user = beating.user;
538
- const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}/beat`, json("POST"));
539
- if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["/api/state"]); }
544
+ const r = await ctx.ask(`api/share/${encodeURIComponent(user)}/beat`, json("POST"));
545
+ if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["api/state"]); }
540
546
  };
541
547
  const startBeats = (user, everyMs = BEAT_MS) => {
542
548
  if (beating?.user === user) return;
@@ -547,7 +553,7 @@ export function createShare(ctx) {
547
553
  /** Connect from anywhere: what a share would read, then the question */
548
554
  async shareAsk() {
549
555
  Object.assign(m, NO_SHARE, { phase: "asking" });
550
- const r = await ctx.ask("/api/share/choices");
556
+ const r = await ctx.ask("api/share/choices");
551
557
  if (m.phase !== "asking") return;
552
558
  if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
553
559
  m.choices = r.body.choices;
@@ -561,17 +567,17 @@ export function createShare(ctx) {
561
567
  if (m.phase !== "asking" || m.choices === null) return;
562
568
  const role = m.choices.length > 0 ? m.role : null;
563
569
  Object.assign(m, { phase: "opening", failure: null });
564
- const r = await ctx.ask("/api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
570
+ const r = await ctx.ask("api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
565
571
  if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
566
572
  Object.assign(m, { phase: "open", opened: { ...r.body, seen: false } });
567
573
  startBeats(r.body.user, r.body.beatMs);
568
- await ctx.refresh(["/api/state"]);
574
+ await ctx.refresh(["api/state"]);
569
575
  },
570
576
  async shareStop(user) {
571
- const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}`, json("DELETE"));
577
+ const r = await ctx.ask(`api/share/${encodeURIComponent(user)}`, json("DELETE"));
572
578
  if ("failure" in r && r.failure.code !== "not_found") { m.phase = "refused"; m.failure = r.failure; return; }
573
579
  ended(user, "Stop sharing, on this page");
574
- await ctx.refresh(["/api/state"]);
580
+ await ctx.refresh(["api/state"]);
575
581
  },
576
582
  shareBeat: beat,
577
583
  };
@@ -15,9 +15,9 @@ import { esc, agoText, utcText, badge, empty, panel, n, sortTh, nextSort, moreRo
15
15
  const EVENTS_LIMIT = 500;
16
16
  const EVENTS_SHOWN = 100;
17
17
  /** The events of a range: the newest EVENTS_LIMIT since its start. */
18
- const eventsPath = (range = DEFAULT_RANGE) => `/api/events?limit=${EVENTS_LIMIT}&since=${RANGES[rangeOf(range)].since}`;
19
- const LOGS = "/api/logs?lines=200";
20
- const STATE = "/api/state";
18
+ const eventsPath = (range = DEFAULT_RANGE) => `api/events?limit=${EVENTS_LIMIT}&since=${RANGES[rangeOf(range)].since}`;
19
+ const LOGS = "api/logs?lines=200";
20
+ const STATE = "api/state";
21
21
 
22
22
  const th = (cols) => cols.map((c) => `<th scope="col">${esc(c)}</th>`).join("");
23
23