@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
@@ -19,6 +19,10 @@
19
19
  *
20
20
  * - ITS WEB PAGE ON LOOPBACK ALONE (src/cli/run-server.ts `listenOnLoopback`): the run's server, MASON_UI_PORT, reached
21
21
  * from a developer's machine with `mason ui --ssh`, never published. No setting moves it to another address.
22
+ * MASON_UI_SOCKET serves it on a Unix socket too, for a process on this machine (src/cli/ui-socket.ts): mode 0600, in
23
+ * a folder only Mason's user can write, behind the same guard as `mason ui --ssh`'s requests. MASON_UI_RUN_FILE
24
+ * writes the page's port and the run's token to a file for a supervisor on this machine (src/cli/ui-run-file.ts),
25
+ * held to the same bar, once the run answers; /healthz says from the start where the page is served (`PageServed`).
22
26
  *
23
27
  * The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
24
28
  */
@@ -36,7 +40,10 @@ import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/d
36
40
  import { appEnvValue } from "../lib/dotenv.js";
37
41
  import { imagePinProblem, PIN_FILE } from "../lib/project-version.js";
38
42
  import { commandPinProblem } from "./pin.js";
43
+ import { listenOnUiSocket, prepareUiSocket, uiSocketSetting } from "./ui-socket.js";
44
+ import { prepareUiRunFile, uiRunFileSetting, UI_RUN_FILE_SETTING, writeUiRunFile } from "./ui-run-file.js";
39
45
  import { recordRefusal } from "./replication-mode.js";
46
+ import { UI_READ_ONLY_SETTING, uiReadOnlySetting } from "./ui-read-only.js";
40
47
  import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
41
48
  import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
42
49
  import { DEFAULT_MEMORY_RULE, memoryProblems, memoryProblemText, memorySize, MEMORY_FLOOR_BYTES } from "../duck/store.js";
@@ -59,6 +66,8 @@ export const SERVE = {
59
66
  DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
60
67
  docker compose exec mason mason add public.orders beside it, in its container
61
68
 
69
+ --json a refusal to start as data: { error, next }; once it serves, its output is its log
70
+
62
71
  Settings (the environment):
63
72
  DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
64
73
  the file names is required); read-only: SELECTs, or a replication slot
@@ -79,6 +88,18 @@ export const SERVE = {
79
88
  MASON_UI_PORT the web page's and the commands' port, on 127.0.0.1 alone: no setting moves the address, and
80
89
  mason ui --ssh <user@server> opens the page from your machine (default ${DEFAULT_UI_PORT}, or the next
81
90
  free one; a port named here is that port, or the start is refused)
91
+ MASON_UI_SOCKET a Unix socket the web page is served on too, for a process on this machine (a supervisor,
92
+ a local reverse proxy): made mode 0600 in a folder only Mason's user can write (else the
93
+ start is refused), a stale one replaced, removed on exit; a request on it passes the same
94
+ guard as mason ui --ssh's (default unset: none)
95
+ MASON_UI_RUN_FILE a file Mason writes once it answers, for a process on this machine that supervises it:
96
+ { "port", "token" }, the web page's port and the run's token, which grants what the token
97
+ grants — the commands' writes (mason add, mason sync, mason user) as well as a tunnel to
98
+ the page; written whole, mode 0600, in a folder only Mason's user can write (else the
99
+ start is refused), a stale one replaced, removed on exit (default unset: none)
100
+ MASON_UI_READ_ONLY 1 when the web page should only show: it offers no control that changes anything, and
101
+ refuses every change it is sent, through mason ui --ssh or the socket alike; the mason
102
+ commands beside it are unchanged (default 0)
82
103
  MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
83
104
  MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
84
105
  for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
@@ -98,8 +119,6 @@ export const SERVE = {
98
119
  UV_THREADPOOL_SIZE threads for running statements, one each, 1 to 1024 (default 32, lowered to fit a limit
99
120
  on threads; one that does not fit, or is no such number, refuses the start)
100
121
 
101
- --json a refusal to start as data: { error, next }; once it serves, its output is its log
102
-
103
122
  Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
104
123
  `,
105
124
  options: { json: { type: "boolean", default: false } },
@@ -164,6 +183,19 @@ export function serveSettings(env, cwd) {
164
183
  else if (env.MASON_UI_PORT && uiPort === pgPortOf(env))
165
184
  wrong.push(`MASON_UI_PORT=${uiText} is the Postgres API's port too (MASON_PG_PORT): two ports`);
166
185
  // a host would only be believed: the page has no address but loopback, and running on while one is asked for would hide that
186
+ const socket = uiSocketSetting(env, cwd);
187
+ if (socket !== null && typeof socket !== "string")
188
+ wrong.push(socket.problem);
189
+ // the run's file (src/cli/ui-run-file.ts): never the socket's own path
190
+ const runFile = uiRunFileSetting(env, cwd);
191
+ if (runFile !== null && typeof runFile !== "string")
192
+ wrong.push(runFile.problem);
193
+ else if (runFile !== null && runFile === socket)
194
+ wrong.push(`${UI_RUN_FILE_SETTING} names the path MASON_UI_SOCKET names: two paths`);
195
+ // the read-only page (src/cli/ui-read-only.ts): 1 or 0, anything else refused here, never read as off
196
+ const pageReadOnly = uiReadOnlySetting(env);
197
+ if (typeof pageReadOnly !== "boolean")
198
+ wrong.push(pageReadOnly.problem);
167
199
  if (env.MASON_UI_HOST)
168
200
  wrong.push("MASON_UI_HOST is set, and the web page has no address but 127.0.0.1: remove it (mason ui --ssh <user@server> opens the page from your machine)");
169
201
  if (wrong.length) {
@@ -176,7 +208,7 @@ export function serveSettings(env, cwd) {
176
208
  name, apiKey, selfSignedTls: selfSigned === "1",
177
209
  project, data,
178
210
  health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
179
- ui: { port: uiPort, named: !!env.MASON_UI_PORT },
211
+ ui: { port: uiPort, named: !!env.MASON_UI_PORT, socket: typeof socket === "string" ? socket : null, runFile: typeof runFile === "string" ? runFile : null },
180
212
  };
181
213
  }
182
214
  /**
@@ -331,6 +363,7 @@ export function serveRecordRefusal(s, existing, env) {
331
363
  return null;
332
364
  return recordRefusal(existing.config, file, env, sourcesTop(path.dirname(s.project), s.project).shown, "mason serve");
333
365
  }
366
+ export const NO_PAGE_SERVED = { socket: null, readOnly: false };
334
367
  /** seconds since the serve started: /healthz's `uptimeSeconds` and /metrics' `mason_uptime_seconds`, one number */
335
368
  const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
336
369
  /**
@@ -338,8 +371,8 @@ const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
338
371
  * leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
339
372
  * endpoint takes no credential.
340
373
  */
341
- export function healthBody(state, startedAt, now = Date.now()) {
342
- const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now) };
374
+ export function healthBody(state, startedAt, now = Date.now(), page = NO_PAGE_SERVED) {
375
+ const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now), page: { socket: page.socket, readOnly: page.readOnly } };
343
376
  if (state.phase !== "ready")
344
377
  return { status: 503, body: base };
345
378
  return { status: 200, body: { ...base, tables: phaseCounts(state.tables()) } };
@@ -355,7 +388,7 @@ export async function metricsBody(state, startedAt, now = Date.now()) {
355
388
  return metricsText({ version: version(), run: { ...run, uptimeSeconds: uptimeSeconds(startedAt, now) } });
356
389
  }
357
390
  /** The health server: GET /healthz and GET /metrics, nothing else. `log` says a scrape whose numbers could not be read. */
358
- export function healthServer(state, startedAt, log = say) {
391
+ export function healthServer(state, startedAt, log = say, page = NO_PAGE_SERVED) {
359
392
  return http.createServer((req, res) => {
360
393
  const url = new URL(req.url ?? "/", "http://health");
361
394
  if (url.pathname !== "/healthz" && url.pathname !== "/metrics") {
@@ -381,7 +414,7 @@ export function healthServer(state, startedAt, log = say) {
381
414
  });
382
415
  return;
383
416
  }
384
- const h = healthBody(state(), startedAt);
417
+ const h = healthBody(state(), startedAt, Date.now(), page);
385
418
  res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
386
419
  });
387
420
  }
@@ -421,14 +454,57 @@ export async function serve(argv) {
421
454
  const held = s.ui.named ? await portHolder(s.ui.port) : null;
422
455
  if (held !== null)
423
456
  return failed("serve", { message: `MASON_UI_PORT: ${heldText(s.ui.port, held)}`, code: "refused", fix: "name another port, or leave MASON_UI_PORT unset for the next free one" }, { json });
457
+ // the page's socket, before anything listens: its folder and its path held to the bar, a stale one replaced, then made
458
+ let page = null;
459
+ // what making it says, under the start's heading below
460
+ const socketSaid = [];
461
+ if (s.ui.socket !== null) {
462
+ const prepared = await prepareUiSocket(s.ui.socket, (l) => socketSaid.push(l));
463
+ if (prepared !== null)
464
+ return failed("serve", prepared, { json });
465
+ const opened = await listenOnUiSocket(s.ui.socket, (l) => say(` ${l}`));
466
+ if ("message" in opened)
467
+ return failed("serve", opened, { json });
468
+ page = opened;
469
+ }
470
+ // the run's file: written once the run answers (asserted wide: they are set in the run's hooks, which the compiler
471
+ // does not follow)
472
+ let runFile = null;
473
+ let runFileFailed = null;
474
+ // a SIGTERM before the run has its own stop (the run file's check, the run still connecting) ends the process by its
475
+ // default action, which runs no finally: the socket is removed first, then the signal raised again as it was
476
+ const early = (signal) => {
477
+ if (process.listenerCount(signal) > 1)
478
+ return; // the run's stop is in place: it stops, and the finally removes it
479
+ page?.close();
480
+ runFile?.remove();
481
+ unhookEarly();
482
+ process.kill(process.pid, signal);
483
+ };
484
+ const unhookEarly = () => { process.removeListener("SIGTERM", early); process.removeListener("SIGINT", early); };
485
+ if (page !== null) {
486
+ process.on("SIGTERM", early);
487
+ process.on("SIGINT", early);
488
+ }
489
+ // its folder and its path held to the bar now, a stale one removed (its run asked, up to 2 s: the handler is in place)
490
+ if (s.ui.runFile !== null) {
491
+ const prepared = await prepareUiRunFile(s.ui.runFile, (l) => socketSaid.push(l));
492
+ if (prepared !== null) {
493
+ page?.close();
494
+ unhookEarly();
495
+ return failed("serve", prepared, { json });
496
+ }
497
+ }
424
498
  const startedAt = Date.now();
425
499
  let state = { phase: "starting" };
426
- const health = healthServer(() => state, startedAt);
500
+ const health = healthServer(() => state, startedAt, say, { socket: page?.file ?? null, readOnly: uiReadOnlySetting(process.env) === true });
427
501
  const listening = await new Promise((resolve) => {
428
502
  health.once("error", resolve);
429
503
  health.listen(s.health.port, s.health.host, () => resolve(null));
430
504
  });
431
505
  if (listening !== null) {
506
+ page?.close();
507
+ unhookEarly();
432
508
  return failed("serve", { message: `the health endpoint cannot listen on ${s.health.host}:${s.health.port}: ${listening.message}`, code: "refused",
433
509
  fix: "MASON_HEALTH_PORT names a port free on this machine" }, { json });
434
510
  }
@@ -440,18 +516,52 @@ export async function serve(argv) {
440
516
  say(` database ${made.database}, what a tool connects with`);
441
517
  say(` health http://${s.health.host}:${s.health.port}/healthz`);
442
518
  say(` metrics http://${s.health.host}:${s.health.port}/metrics`);
519
+ for (const l of socketSaid)
520
+ say(` ${l}`);
521
+ if (s.ui.runFile !== null)
522
+ say(` run file ${s.ui.runFile}: written once Mason answers (mode 0600; it holds the run's token)`);
523
+ if (page !== null)
524
+ say(` web page ${page.file} (a Unix socket, mode 0600: a request on it passes the same guard as mason ui --ssh's)`);
525
+ if (uiReadOnlySetting(process.env) === true)
526
+ say(` web page read-only (${UI_READ_ONLY_SETTING}=1): it refuses every change it is sent; the mason commands beside it are unchanged`);
443
527
  // the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
444
528
  process.chdir(s.data);
445
529
  let code;
446
530
  try {
447
531
  code = await dev({ port: s.ui.port, exactPort: s.ui.named, version: version(), selfSignedTls: s.selfSignedTls,
448
- serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
532
+ serve: { log: say,
533
+ ready: (run) => {
534
+ // the run's file before /healthz says ready: a supervisor that sees ready finds it
535
+ if (s.ui.runFile !== null) {
536
+ const w = writeUiRunFile(s.ui.runFile, run.page, (l) => say(` ${l}`));
537
+ if ("message" in w) {
538
+ // fail closed: a supervisor would wait on a file that never comes, so the run stops, never says it is
539
+ // ready, records why, and the serve exits 1
540
+ runFileFailed = w;
541
+ say(` ${w.message}: Mason stops (${w.fix})`);
542
+ state = { phase: "stopping" };
543
+ return `stopped: the run file (${UI_RUN_FILE_SETTING}) could not be written: ${w.message.slice(`${UI_RUN_FILE_SETTING}=`.length)}`;
544
+ }
545
+ runFile = w;
546
+ say(` run file ${w.file} (the web page's port and the run's token, mode 0600)`);
547
+ }
548
+ state = { phase: "ready", tables: run.tables, metrics: run.metrics };
549
+ return null;
550
+ },
551
+ stopping: () => { state = { phase: "stopping" }; page?.close(); runFile?.remove(); },
552
+ ...(page !== null ? { page: page.attach, socket: page.file } : {}) } });
449
553
  }
450
554
  finally {
555
+ // removed whatever ended the run: a stop, a crash of the engine's thread, or a start that failed
556
+ page?.close();
557
+ runFile?.remove();
558
+ unhookEarly();
451
559
  // a platform's health check may hold a keep-alive connection open, which close() alone would wait for
452
560
  health.closeAllConnections();
453
561
  await new Promise((resolve) => health.close(() => resolve()));
454
562
  }
563
+ if (runFileFailed !== null)
564
+ return failed("serve", runFileFailed, { json: false });
455
565
  if (code !== 0)
456
566
  return failed("serve", { message: "Mason did not start: what stopped it is said above", code: "failed",
457
567
  fix: "fix what it says, then start it again (a platform restarts it by itself)" }, { json: 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
  };
@@ -6,7 +6,7 @@
6
6
  * polled: its answer is where the stream is, beside the polled ones'. The run's `POST /api/sync` (src/cli/run-server.ts) answers it, through the sync's own
7
7
  * poll (`Sync.pollNow`).
8
8
  */
9
- import { failed, jsonText, parseCommand, reportText } from "./command.js";
9
+ import { failed, failureCode, jsonText, parseCommand, reportText } from "./command.js";
10
10
  import { out as styleOut } from "./style.js";
11
11
  import { callRun } from "./control.js";
12
12
  import { reach } from "./state.js";
@@ -15,7 +15,7 @@ import { didYouMean } from "../lib/did-you-mean.js";
15
15
  import { syncedText, syncWords } from "../lib/table-sync.js";
16
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
17
17
  const n = (x) => x.toLocaleString("en-US");
18
- export const SYNC_USAGE = `usage: mason sync <table>… [--json]
18
+ export const SYNC_USAGE = `usage: mason sync (<table>… | --all) [--json]
19
19
 
20
20
  Brings these synced tables up to date now: it reads what changed in your database since Mason last read them —
21
21
  the rows inserted or updated, and the ones deleted — in one transaction, as a poll does (each one scans its table
@@ -27,7 +27,9 @@ export const SYNC_USAGE = `usage: mason sync <table>… [--json]
27
27
 
28
28
  mason sync orders
29
29
  mason sync orders public.events
30
+ mason sync --all every table the sources file names (each one a scan of its table)
30
31
 
32
+ --all every table the sources file names, instead of naming them
31
33
  --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }] (the tables polled), streamed (the
32
34
  tables whose sync: is replication, not polled: [{ table, position, state, why }]), next }
33
35
  `;
@@ -36,7 +38,7 @@ export const SYNC = {
36
38
  name: "sync",
37
39
  summary: "bring these synced tables up to date now: mason sync orders (a table whose sync is off is read again only so)",
38
40
  usage: SYNC_USAGE,
39
- options: { json: { type: "boolean" } },
41
+ options: { json: { type: "boolean" }, all: { type: "boolean" } },
40
42
  json: ["syncedAt", "tables", "streamed", "next"],
41
43
  };
42
44
  /**
@@ -67,8 +69,11 @@ export async function sync(argv) {
67
69
  return f;
68
70
  const json = f.values.json === true;
69
71
  const names = [...new Set(f.positionals)];
70
- if (!names.length)
71
- return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones)", code: "usage", usage: SYNC_USAGE }, { json });
72
+ const all = f.values.all === true;
73
+ if (all && names.length)
74
+ return failed("sync", { message: `--all syncs every added table: name tables (${names.join(" ")}) or --all, not both`, code: "usage", usage: SYNC_USAGE }, { json });
75
+ if (!names.length && !all)
76
+ return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones), or mason sync --all", code: "usage", usage: SYNC_USAGE }, { json });
72
77
  const w = await reach(SYNC, json);
73
78
  if (w === null || !("run" in w))
74
79
  return 1;
@@ -76,7 +81,11 @@ export async function sync(argv) {
76
81
  return guarded("sync", json, async () => {
77
82
  const s = await readState(run);
78
83
  const added = s.tables.filter((t) => t.added);
79
- const picked = [];
84
+ // --all: every table the sources file names, as the run read it
85
+ const picked = all ? added.map((t) => t.table) : [];
86
+ if (all && !picked.length) {
87
+ return failed("sync", { message: "no table is added yet, so there is nothing to sync", code: "nothing_yet", next: [["mason tables", "the database's tables"], ["mason add <table>", "add one to the sources file"]] }, { json });
88
+ }
80
89
  for (const name of names) {
81
90
  const t = findTable(added, name);
82
91
  if (!("message" in t)) {
@@ -97,12 +106,12 @@ export async function sync(argv) {
97
106
  // a sync scans each table whole on the database (and waits for a copy under way): minutes, not the commands' 30 s
98
107
  const r = await callRun(run, "POST", "/api/sync", { tables: picked }, 60 * 60_000);
99
108
  if (r.status !== 200) {
100
- const code = typeof r.body.code === "string" ? r.body.code : r.status === 404 ? "not_found" : "failed";
109
+ const code = failureCode(r.body.code, r.status === 404 ? "not_found" : "failed");
101
110
  return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
102
111
  next: [["mason status", "each synced table"], ["mason logs", "the run's log"]] }, { json });
103
112
  }
104
113
  const b = r.body;
105
- const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
114
+ const next = [[all ? "mason sync --all" : `mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
106
115
  if (json) {
107
116
  out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables, ...(b.streamed ? { streamed: b.streamed } : {}) }, next));
108
117
  return 0;
@@ -28,9 +28,9 @@
28
28
  * createdAt, expiresAt, lastUsedAt rows, --json `isServiceTime` (lastUsedAt or null)
29
29
  * more a tip, --json a boolean
30
30
  */
31
- import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
31
+ import { endText, failed, jsonText, listFlag, parseCommand, reportText, serviceStyle, strict } from "./command.js";
32
32
  import { call, carriesNoSecret, echoesSecret, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
33
- import { loginOrgs, orgNamed, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
33
+ import { loginOrgs, orgNamed, orgOf, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
34
34
  import { refusedNext } from "./replica-hosted.js";
35
35
  import { holdOutput } from "./output-gate.js";
36
36
  import { didYouMean } from "../lib/did-you-mean.js";
@@ -53,7 +53,7 @@ export const TOKEN = {
53
53
  name: "token",
54
54
  summary: "a hosted Mason service's CI tokens: make one for a replica (printed once), list them, revoke one",
55
55
  usage: `usage: mason token create --replica <org>/<name> [--scope deploy|users …] [--name <name>] [--days <n>] [--json]
56
- mason token list [<org>] [--json]
56
+ mason token list [<org> | --org <org>] [--json]
57
57
  mason token revoke <id> [--org <org>] [--json]
58
58
 
59
59
  A CI token lets CI act on ONE replica of a hosted Mason service, for the scopes it was made with (deploy: mason
@@ -61,6 +61,19 @@ export const TOKEN = {
61
61
  an owner of the organisation makes, lists and revokes them, signed in as a person (mason login): a CI token
62
62
  makes none.
63
63
 
64
+ mason token create --replica acme/shop --name github-actions
65
+ mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
66
+ mason token list acme
67
+ mason token revoke 3f1c9a2e --org acme
68
+
69
+ --replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
70
+ --scope <scope,…> create: what it may do, deploy or users; both: --scope deploy,users or the flag twice
71
+ (default deploy)
72
+ --name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
73
+ --days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
74
+ --org <org> list: the organisation, in place of the argument; revoke: the organisation it is of
75
+ --json as data: its shape is at the end
76
+
64
77
  create makes one and prints it ONCE: the service does not keep it, and nothing shows it again, so store it at once
65
78
  (as MASON_TOKEN in CI's secrets). It is alone on the first line printed, never broken, or, with --json, in the
66
79
  one field token.value of the document, so a setup step can pipe it straight into a secret store. Nothing else
@@ -72,27 +85,18 @@ export const TOKEN = {
72
85
 
73
86
  The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
74
87
 
75
- mason token create --replica acme/shop --name github-actions
76
- mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
77
- mason token list acme
78
- mason token revoke 3f1c9a2e --org acme
79
-
80
- --replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
81
- --scope <scope> create: what it may do, deploy or users; repeat it for both (default deploy)
82
- --name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
83
- --days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
84
- --org <org> revoke: the organisation it is of
85
- --json as data: { token (create: { id, value (the token itself, this once), name,
86
- replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
87
- madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
88
- organisation has more than the service lists), revoked (revoke: { org, id, name }), next }
89
-
90
88
  Exit status: 0 done; 1 not signed in, refused by the service (its words printed as they came, the code the
91
89
  CLI's own), or the service answered outside its contract; 2 a wrong command line.
90
+
91
+ The --json answer: { token (create: { id, value (the token itself, this once), name,
92
+ replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
93
+ madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
94
+ organisation has more than the service lists), revokedToken (revoke: { org, id,
95
+ name }), revoked (revoke: the same, kept until 1.0), next }
92
96
  `,
93
97
  options: { replica: { type: "string" }, scope: { type: "string", multiple: true }, name: { type: "string" }, days: { type: "string" }, org: { type: "string" },
94
98
  json: { type: "boolean", default: false } },
95
- json: ["token", "tokens", "more", "revoked", "next"],
99
+ json: ["token", "tokens", "more", "revokedToken", "revoked", "next"],
96
100
  };
97
101
  const outStyle = () => serviceStyle(styleOut());
98
102
  const errStyle = () => serviceStyle(styleErr());
@@ -176,10 +180,10 @@ export async function token(argv) {
176
180
  for (const k of ["replica", "scope", "name", "days"])
177
181
  if (verb !== "create" && f.values[k] !== undefined)
178
182
  return usage(`--${k} goes with mason token create`);
179
- if (verb !== "revoke" && f.values.org !== undefined)
180
- return usage("--org goes with mason token revoke", verb === "list" ? "name the organisation: mason token list <org>" : "create names it: --replica <org>/<name>");
183
+ if (verb === "create" && f.values.org !== undefined)
184
+ return usage("--org goes with mason token list and revoke", "create names it: --replica <org>/<name>");
181
185
  if (verb === "list") {
182
- const org = arg === undefined ? null : orgNamed(arg, TOKEN.usage);
186
+ const org = orgOf(typeof f.values.org === "string" ? f.values.org : undefined, arg, TOKEN.usage);
183
187
  if (org !== null && isFailure(org))
184
188
  return fail(org, json);
185
189
  return list(org, json);
@@ -197,7 +201,9 @@ export async function token(argv) {
197
201
  return usage("mason token create names the replica it acts on", "--replica <org>/<name>, or MASON_REPLICA=<org>/<name>");
198
202
  if (isFailure(r))
199
203
  return fail(r, json);
200
- const asked = Array.isArray(f.values.scope) ? f.values.scope.filter((x) => typeof x === "string") : [];
204
+ // a comma list or the flag repeated, the same (src/cli/command.ts listFlag)
205
+ const listed = listFlag(f.values.scope);
206
+ const asked = listed === undefined ? [] : listed.split(",");
201
207
  for (const s of asked)
202
208
  if (!SCOPES.includes(s))
203
209
  return usage(`--scope ${JSON.stringify(serviceText(s, 100))} is not a scope${didYouMean(s, SCOPES)}`, `a CI token's scopes are ${SCOPES.join(" and ")}`);
@@ -329,7 +335,7 @@ async function revoke(id, named, json) {
329
335
  const revoked = { org, id, name: serviceText(j.revoked.name, 64, secrets) };
330
336
  const next = [listStep(org)];
331
337
  if (json) {
332
- out(jsonText(TOKEN, { revoked }, next));
338
+ out(jsonText(TOKEN, { revokedToken: revoked, revoked }, next));
333
339
  return 0;
334
340
  }
335
341
  out(reportText({ headline: `Revoked the CI token ${id} of ${org}: it acts no more`, details: [["name", revoked.name]], next }, outStyle()));