@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
@@ -2,7 +2,8 @@
2
2
  * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
3
3
  * status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
4
4
  * logs`, `mason usage`), refreshed while the tab is seen. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
5
- * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
+ * it) and opens the browser when its stdout is a terminal (as `mason login` does: a pipe, a file or an agent's shell has
6
+ * no one at a browser, so it gets the address alone). The reader is the person at this terminal, whom `mason user add` already shows a
6
7
  * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
7
8
  * `mason status`, which prints the URL without it.
8
9
  *
@@ -12,6 +13,9 @@
12
13
  * `--ssh <user@server>` (src/cli/ui-ssh.ts, docs/WEB-UI.md "On a server") opens a server's page from this machine, through
13
14
  * the machine's own ssh, and stays in the foreground until Ctrl-C, as `mason share` does; it needs no project here. Its
14
15
  * URL carries a ticket for one use, never the run's token, which stays on the server. `--bridge` is what it runs there.
16
+ *
17
+ * `--replica <org>/<name>` (else `MASON_REPLICA`; src/cli/ui-hosted.ts, docs/HOSTED-API.md "A replica's page") opens a
18
+ * replica's page on a hosted Mason service: one request, whose one-use address is opened as this command opens a run's.
15
19
  */
16
20
  import { spawn } from "node:child_process";
17
21
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
@@ -19,6 +23,9 @@ import { reach } from "./state.js";
19
23
  import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
20
24
  import { version } from "./version.js";
21
25
  import { RunUnreachable } from "./control.js";
26
+ import { parseDuration } from "../lib/table-sync.js";
27
+ import { replicaOf } from "./replica.js";
28
+ import { hostedUi } from "./ui-hosted.js";
22
29
  const out = (m) => { process.stdout.write(`${m}\n`); };
23
30
  const say = (m) => { process.stderr.write(`${m}\n`); };
24
31
  const DEFAULT_TIMEOUT_S = 60;
@@ -27,7 +34,8 @@ export const UI = {
27
34
  name: "ui",
28
35
  summary: "the status, tables, users and statements, in your browser",
29
36
  usage: `usage: mason ui [--no-open] [--json]
30
- mason ui --ssh <user@server> [--mason <command>] [--timeout <seconds>] [--no-open] [--json]
37
+ mason ui --ssh <user@server> [--mason <command>] [--timeout <duration>] [--no-open] [--json]
38
+ mason ui --replica <org>/<name> [--no-open] [--json]
31
39
 
32
40
  Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
33
41
  says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
@@ -35,38 +43,51 @@ export const UI = {
35
43
  the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
36
44
  cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
37
45
 
38
- The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
39
- bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
40
- token, so after a restart run mason ui again.
41
-
42
- --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
43
- ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
44
- server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
45
- carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
46
- version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
47
-
48
46
  mason ui
49
47
  mason ui --no-open print the URL, open no browser
50
48
  mason ui --json
51
49
  mason ui --ssh deploy@mason.example.com
52
50
  mason ui --ssh deploy@mason.example.com --mason "docker exec -i mason-mason-1 mason"
53
51
  mason ui --ssh deploy@mason.example.com --mason "cd /var/lib/mason && mason" --timeout 120 --no-open
52
+ mason ui --replica acme/shop a replica's page on a hosted Mason service
54
53
  mason ui --bridge --local-port 51000 what --ssh runs on the server; its stdout is a stream, not for reading
55
54
 
56
- --no-open print the URL only
55
+ --no-open print the URL only (as when its output is not a terminal: a browser opens only for one)
57
56
  --ssh <target> the server, as you give it to ssh: user@server, or a Host of your ssh config
58
57
  --mason <command> how to run mason on the server (default mason); a shell's text, kept to ssh's command line
59
- --timeout <s> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}): a passphrase or a touch counts
58
+ --timeout <duration> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}s; a bare number is seconds;
59
+ 2m): a passphrase or a touch counts; past it, exit 124
60
60
  --bridge the server's end of --ssh, on stdin and stdout
61
61
  --local-port <n> the port the browser names, for --bridge: the laptop's
62
- --json as data: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
63
- before the server answered, as port is, exit 0), port, server, project,
64
- version (with --ssh: the server's Mason, for what its answer may lack) }, next; it never opens a
65
- browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
62
+ --replica <org>/<name> a replica on a hosted Mason service, as the service lists it
63
+ --json as data: its shape is at the end
64
+
65
+ The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
66
+ bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
67
+ token, so after a restart run mason ui again.
68
+
69
+ --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
70
+ ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
71
+ server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
72
+ carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
73
+ version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
74
+
75
+ --replica opens the page of a replica on a hosted Mason service (else MASON_REPLICA, unless --ssh or --bridge names
76
+ the target), for the organisation's owner alone: the service answers an address that opens it once, for a few
77
+ minutes. The page is read-only: the service refuses changes through it, so add or remove users with mason user …
78
+ --replica. It needs no project here, and goes with --no-open and --json alone. Opened in your browser, the address is
79
+ not printed: run it again for another tab. A replica not ready yet is said, with when to try again.
80
+
81
+ The --json answer: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
82
+ before the server answered, as port is, exit 0; with --replica the one-use address), port, server,
83
+ project, version (with --ssh: the server's Mason, for what its answer may lack), replica
84
+ (with --replica: "<org>/<name>"), expiresAt (with --replica: until when the address opens it)
85
+ }, next; it never opens a
86
+ browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
66
87
  `,
67
88
  options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false }, ssh: { type: "string" }, mason: { type: "string" },
68
- timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" } },
69
- json: ["url", "port", "server", "project", "version", "next"],
89
+ timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" }, replica: { type: "string" } },
90
+ json: ["url", "port", "server", "project", "version", "replica", "expiresAt", "next"],
70
91
  };
71
92
  const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
72
93
  /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
@@ -78,6 +99,9 @@ export function openCommand(platform, url) {
78
99
  return ["cmd", ["/c", "start", "", url]];
79
100
  return ["xdg-open", [url]];
80
101
  }
102
+ /** An address Mason hands the browser by itself (a sign-in page, a replica's page): a plain https (or this machine's
103
+ * http) URL, and none of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
104
+ export const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
81
105
  /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
82
106
  export function openBrowser(url) {
83
107
  const [command, args] = openCommand(process.platform, url);
@@ -87,6 +111,9 @@ export function openBrowser(url) {
87
111
  child.once("spawn", () => { child.unref(); resolve(null); });
88
112
  });
89
113
  }
114
+ /** Whether to open a browser: asked for (no --no-open) and stdout a terminal, a person in front of it (never a pipe,
115
+ * a file or a coding agent's shell, which get the address alone). */
116
+ const opens = (noOpen) => !noOpen && process.stdout.isTTY === true;
90
117
  /** Whether the run serves the page: its `GET /` answered, with the token the commands send. */
91
118
  async function servesPage(run) {
92
119
  let res;
@@ -155,6 +182,12 @@ export async function ui(argv) {
155
182
  const wrong = (message) => failed("ui", { message, code: "usage", usage: UI.usage }, { json });
156
183
  if (f.positionals.length)
157
184
  return wrong(`mason ui takes no argument: ${f.positionals.join(" ")}`);
185
+ if (v.replica !== undefined) {
186
+ const beside = [["--ssh", v.ssh !== undefined], ["--bridge", v.bridge], ["--mason", v.mason !== undefined], ["--timeout", v.timeout !== undefined],
187
+ ["--local-port", v["local-port"] !== undefined]].filter(([, given]) => given).map(([name]) => name);
188
+ if (beside.length)
189
+ return wrong(`--replica opens a hosted replica's page: it goes with --no-open and --json alone, not ${beside.join(", ")}`);
190
+ }
158
191
  if (v.bridge) {
159
192
  if (v.ssh !== undefined || v.mason !== undefined || v.timeout !== undefined || json)
160
193
  return wrong("--bridge is mason ui --ssh's own end on the server: it goes with --local-port alone");
@@ -173,11 +206,18 @@ export async function ui(argv) {
173
206
  return wrong(`--ssh: ${problem}`);
174
207
  if (v.mason !== undefined && (v.mason.trim() === "" || /[\r\n]/.test(v.mason)))
175
208
  return wrong("--mason is one line of shell that runs mason on the server");
176
- const seconds = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
177
- if (!Number.isFinite(seconds) || seconds <= 0)
178
- return wrong(`--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`);
179
- return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, !v["no-open"], json);
209
+ const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
210
+ if ("problem" in read)
211
+ return wrong(`--timeout ${read.problem}`);
212
+ const seconds = read.seconds;
213
+ return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, opens(v["no-open"]), json);
180
214
  }
215
+ // a replica on a hosted service (--replica, else MASON_REPLICA): --ssh and --bridge name their own target, above
216
+ const replica = replicaOf(v.replica, UI.usage);
217
+ if (replica !== null && "message" in replica)
218
+ return failed("ui", replica, { json });
219
+ if (replica !== null)
220
+ return hostedUi(replica, { open: opens(v["no-open"]), json });
181
221
  const w = await reach(UI, json);
182
222
  if (w === null || w.state !== "up")
183
223
  return 1;
@@ -199,8 +239,9 @@ export async function ui(argv) {
199
239
  out(jsonText(UI, { url, port }, NEXT));
200
240
  return 0;
201
241
  }
202
- const couldNot = f.values["no-open"] === true ? null : await openBrowser(url);
203
- const opened = f.values["no-open"] !== true && couldNot === null;
242
+ const open = opens(v["no-open"]);
243
+ const couldNot = open ? await openBrowser(url) : null;
244
+ const opened = open && couldNot === null;
204
245
  out(reportText({
205
246
  headline: opened ? `Mason's web page, opened in your browser` : `Mason's web page`,
206
247
  details: [["url", url], ["project", run.name]],
@@ -12,9 +12,10 @@ import { fileSize, guarded, runFailure } from "./commands.js";
12
12
  import { timeOf } from "./request-filter.js";
13
13
  import { duration, msText } from "./readiness.js";
14
14
  import { SIZE_EVERY_MS } from "../duck/usage.js";
15
+ import { parseDuration } from "../lib/table-sync.js";
15
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
16
17
  const n = (x) => x.toLocaleString("en-US");
17
- export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
18
+ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <duration>] [--json]
18
19
 
19
20
  What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
20
21
  (default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
@@ -29,15 +30,18 @@ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>]
29
30
 
30
31
  --since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
31
32
  --until <when> until when, the same way; default now
32
- --step <span> a line per span (1m to the days kept; default 5m), or 0 for every sample as it was taken
33
- --json as data: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
34
- (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
35
- tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
36
- rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
37
- heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
38
- slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
39
- sessionsOpen (the Postgres API's sessions logged in) }],
40
- copySizes: [{ table, bytes, at }], next }
33
+ --step <duration> a line per span (1m to the days kept; default 5m; a bare number is seconds), or 0 for every
34
+ sample as it was taken
35
+ --json as data: its shape is at the end
36
+
37
+ The --json answer: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
38
+ (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
39
+ tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
40
+ rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
41
+ heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
42
+ slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
43
+ sessionsOpen (the Postgres API's sessions logged in) }],
44
+ copySizes: [{ table, bytes, at }], next }
41
45
  `;
42
46
  export const USAGE = {
43
47
  needs: "run",
@@ -47,13 +51,13 @@ export const USAGE = {
47
51
  options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
48
52
  json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
49
53
  };
50
- const UNIT = { s: 1, m: 60, h: 3600, d: 86_400 };
51
- /** A span as the options write it (90s, 30m, 6h, 7d; 0), in seconds, or null when it is not one. */
54
+ /** A span as the options write it (the command line's one duration grammar, src/lib/table-sync.ts `parseDuration`:
55
+ * 90s, 30m, 6h, 7d, a bare number of seconds; 0 for every sample), in whole seconds, or null when it is not one. */
52
56
  export function spanSeconds(text) {
53
57
  if (text.trim() === "0")
54
58
  return 0;
55
- const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
56
- return m ? Number(m[1]) * UNIT[m[2]] : null;
59
+ const d = parseDuration(text, { zero: true });
60
+ return "problem" in d || !Number.isInteger(d.seconds) ? null : d.seconds;
57
61
  }
58
62
  /** One sample on one line: the time, the statements, those refused, failed or cancelled, p95, rows sent, the copy, its rows, the stalest copy, the lag, the memory. */
59
63
  export function sampleLine(s, withLag) {
@@ -87,7 +91,7 @@ export async function usage(argv, now = Date.now()) {
87
91
  return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
88
92
  const step = spanSeconds(v.step ?? "5m");
89
93
  if (step === null)
90
- return failed("usage", { message: `--step is a span (1m, 5m, 1h, 1d) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
94
+ return failed("usage", { message: `--step is a duration (300, 1m, 5m, 1h, 1d: a bare number is seconds) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
91
95
  const w = await reach(USAGE, json);
92
96
  if (w === null || !("run" in w))
93
97
  return 1;
@@ -4,7 +4,8 @@
4
4
  * server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
5
5
  *
6
6
  * list GET …/users?lastUsed=1 mason user list --json's shape, beside its next
7
- * add POST …/users { name, role?, set? } mason user add --json's shape (the run is up: its url)
7
+ * add POST …/users { name, role?, set?, from? } mason user add --json's shape (the run is up: its url)
8
+ * from POST …/users/{user}/from { from } { changed, already }: its ranges replaced (null: any address)
8
9
  * disable POST …/users/{user}/disable { disabled, already }
9
10
  * enable POST …/users/{user}/enable { enabled, already }
10
11
  * remove DELETE …/users/{user} { removed }
@@ -36,6 +37,10 @@
36
37
  * role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
37
38
  * roleKnown, live
38
39
  * attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
40
+ * from (a user's address ranges) rows, list's lines, add's row, --json null, or 1 to MAX_RANGES ranges each in the one form
41
+ * src/duck/users.ts `userRanges` writes back (`heldRanges`);
42
+ * on add and from, exactly the ranges sent, else the answer
43
+ * is refused whole (`sameRanges`, `unconfirmed`: fail closed)
39
44
  * lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
40
45
  * requestLogDays list's lines, --json a whole number, 1 to 36500
41
46
  * already the headline chosen, --json a boolean
@@ -57,12 +62,12 @@ import { replicaText, tokenFor } from "./replica.js";
57
62
  import { holdOutput } from "./output-gate.js";
58
63
  import { err as styleErr, out as styleOut } from "./style.js";
59
64
  import { connectionUrl, psqlLine } from "./pg-url.js";
60
- import { USER_NAME_RE } from "../duck/users.js";
65
+ import { MAX_RANGES, USER_NAME_RE, userRanges } from "../duck/users.js";
61
66
  import { ROLE_NAME_RE } from "./roles.js";
62
67
  import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
63
68
  import { databaseNameProblem } from "../lib/database-name.js";
64
69
  import { NO_ROLES } from "../duck/grants.js";
65
- import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
70
+ import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, fromNext, fromReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
66
71
  /** The bound of a list's answer (a replica's users, a row each), and of the number of users in it; every other answer
67
72
  * is `call`'s own 64 KiB. */
68
73
  export const MAX_LIST_BYTES = 1024 * 1024;
@@ -83,7 +88,9 @@ export function requestOf(r, o) {
83
88
  // a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
84
89
  switch (o.verb) {
85
90
  case "list": return { method: "GET", path: `${base}?lastUsed=1` };
86
- case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}) } };
91
+ case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}),
92
+ ...(o.from !== undefined && o.from !== null ? { from: [...o.from] } : {}) } };
93
+ case "from": return { method: "POST", path: `${base}/${o.name}/from`, json: { from: o.from === undefined || o.from === null ? null : [...o.from] } };
87
94
  case "disable":
88
95
  case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
89
96
  case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
@@ -125,6 +132,16 @@ function attributesOf(v) {
125
132
  }
126
133
  return outv;
127
134
  }
135
+ /** A user's address ranges as the service sends them: null (any address), or 1 to MAX_RANGES ranges, each in the one form
136
+ * the one grammar writes back (src/duck/users.ts `userRanges`); undefined when they are not that. */
137
+ export function heldRanges(v) {
138
+ if (v === null)
139
+ return null;
140
+ if (!Array.isArray(v) || v.length < 1 || v.length > MAX_RANGES || !v.every((x) => typeof x === "string" && x.length <= 64))
141
+ return undefined;
142
+ const r = userRanges(v);
143
+ return "problem" in r || r.from.length !== v.length || r.from.some((x, i) => x !== v[i]) ? undefined : r.from;
144
+ }
128
145
  const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
129
146
  const timeOrNull = (v) => v === null || isServiceTime(v);
130
147
  /** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
@@ -142,6 +159,10 @@ function rowOf(v) {
142
159
  const role = roleOf(u);
143
160
  if (role === null)
144
161
  return null;
162
+ // a replica's Mason before address ranges sends none: it has none, so any address
163
+ const from = u.from === undefined ? null : heldRanges(u.from);
164
+ if (from === undefined)
165
+ return null;
145
166
  const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
146
167
  if (attributes === null)
147
168
  return null;
@@ -150,7 +171,7 @@ function rowOf(v) {
150
171
  if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
151
172
  return null;
152
173
  return { name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt, disabledAt: u.disabledAt, live: u.live, lastUsedAt: u.lastUsedAt,
153
- url: u.url, ...role, ...(attributes !== undefined ? { attributes } : {}),
174
+ url: u.url, from, ...role, ...(attributes !== undefined ? { attributes } : {}),
154
175
  ...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
155
176
  ...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
156
177
  }
@@ -195,11 +216,15 @@ export function heldAdded(json, asked, secrets) {
195
216
  const role = roleOf(a);
196
217
  if (role === null)
197
218
  return null;
219
+ const from = a.from === undefined ? undefined : heldRanges(a.from);
220
+ // null (any address) is read as no ranges, as a list's row reads it: an add that sent ranges then refuses it unconfirmed
221
+ if (from === undefined && a.from !== undefined)
222
+ return null;
198
223
  const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
199
224
  if (attributes === null)
200
225
  return null;
201
226
  return { user: asked, id: a.id, addedAt: a.addedAt, password: a.password, database: a.database, url, psql: psqlLine(url), host: a.host, port: a.port, sslmode: a.sslmode,
202
- ...role, ...(attributes !== undefined ? { attributes } : {}) };
227
+ ...role, ...(attributes !== undefined ? { attributes } : {}), ...(from !== undefined && from !== null ? { from } : {}) };
203
228
  }
204
229
  /** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
205
230
  function userOf(v, asked, more) {
@@ -219,6 +244,14 @@ export function heldEnabled(json, asked, secrets) {
219
244
  const e = userOf(json.enabled, asked, []);
220
245
  return e === null ? null : { enabled: e, already: json.already };
221
246
  }
247
+ /** `POST …/users/{user}/from`'s answer: the user asked, its ranges now (null: any address), and `already`. */
248
+ export function heldFrom(json, asked, secrets) {
249
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
250
+ return null;
251
+ const c = userOf(json.changed, asked, []);
252
+ const from = obj(json.changed) ? heldRanges(json.changed.from) : undefined;
253
+ return c === null || from === undefined ? null : { changed: { ...c, from }, already: json.already };
254
+ }
222
255
  export function heldRemoved(json, asked, secrets) {
223
256
  if (!obj(json) || !carriesNoSecret(json, secrets))
224
257
  return null;
@@ -226,8 +259,25 @@ export function heldRemoved(json, asked, secrets) {
226
259
  return r === null ? null : { removed: r };
227
260
  }
228
261
  // ------------------------------------------------------------------ the command
262
+ /** Whether the ranges an answer carries are exactly those sent (each in its one form, in order; null: any address). */
263
+ export const sameRanges = (got, sent) => got !== undefined && JSON.stringify(got) === JSON.stringify(sent);
264
+ /**
265
+ * AN ADDRESS LIMIT THE SERVICE DID NOT CONFIRM: its 200 does not carry the ranges sent (none, null, or others). The
266
+ * answer is refused whole, never printed (an add's password included): the user may now connect from more addresses
267
+ * than asked, which only the replica's list can say, and the way out removes it or sets the ranges again.
268
+ */
269
+ export function unconfirmed(r, name, sent, verb) {
270
+ const on = `--replica ${replicaText(r)}`;
271
+ const asked = sent === null ? "any address" : sent.join(", ");
272
+ return { message: `the service did not confirm ${name}'s address limit: its answer does not carry the ranges sent (${asked})`, code: "failed",
273
+ why: verb === "add" ? `the replica may have added ${name} with no limit, so it would connect from any address, and its password was not shown`
274
+ : `the replica may have kept ${name}'s ranges as they were, or set others`,
275
+ fix: verb === "add" ? `mason user list ${on} says what ${name} has; if it was made without the limit, mason user remove ${name} ${on}`
276
+ : `mason user list ${on} says what ${name} has now; mason user from ${name} ${sent === null ? "--any" : sent.join(" ")} ${on} sets it again, or mason user remove ${name} ${on}`,
277
+ next: [[`mason user list ${on}`, "the replica's users and the addresses each connects from"], [`mason user remove ${name} ${on}`, "if it was made without the limit"]] };
278
+ }
229
279
  /** What each subcommand asked, in the words of a failure outside the contract (`unexpected`). */
230
- const STEP = { list: "the replica's users were asked for", add: "a user was added", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
280
+ const STEP = { list: "the replica's users were asked for", add: "a user was added", from: "a user's address ranges were set", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
231
281
  /** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
232
282
  export async function hostedUser(r, o) {
233
283
  const outStyle = serviceStyle(styleOut());
@@ -250,8 +300,10 @@ export async function hostedUser(r, o) {
250
300
  if (a.status !== 200)
251
301
  return fail(refused(a));
252
302
  const outside = () => fail(unexpected(service, a, STEP[o.verb]));
303
+ // the target in every answer: the replica's name in --json and in the headline
304
+ const on = replicaText(r);
253
305
  const print = (value, next, text) => {
254
- out(o.json ? jsonText(USER, value, next) : text());
306
+ out(o.json ? jsonText(USER, { replica: on, ...value }, next) : text());
255
307
  return 0;
256
308
  };
257
309
  switch (o.verb) {
@@ -260,7 +312,7 @@ export async function hostedUser(r, o) {
260
312
  if (l === null)
261
313
  return outside();
262
314
  const next = onReplica(listNext(l.users), r);
263
- return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
315
+ return print({ ...l }, next, () => listText(on, l, NO_ROLES, next, outStyle));
264
316
  }
265
317
  case "add": {
266
318
  const added = heldAdded(a.json, o.name, secrets);
@@ -271,29 +323,42 @@ export async function hostedUser(r, o) {
271
323
  fix: `mason user list --replica ${replicaText(r)} says whether it did; if so, mason user remove ${o.name} --replica ${replicaText(r)}, then add it again`,
272
324
  next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
273
325
  }
326
+ // FAIL CLOSED: ranges sent must come back as sent, or the user may have been made with none (a service that does
327
+ // not know `from` would make it connect from any address), and its password is not shown
328
+ if (o.from !== undefined && o.from !== null && !sameRanges(added.from, o.from))
329
+ return fail(unconfirmed(r, o.name, o.from, "add"));
274
330
  const next = onReplica(addedNext(added.user, true), r);
275
- return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
331
+ return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next, on }, outStyle));
332
+ }
333
+ case "from": {
334
+ const c = heldFrom(a.json, o.name, secrets);
335
+ if (c === null)
336
+ return outside();
337
+ if (!sameRanges(c.changed.from, o.from ?? null))
338
+ return fail(unconfirmed(r, o.name, o.from ?? null, "from"));
339
+ const next = onReplica(fromNext(c.changed.name, c.changed.from), r);
340
+ return print({ ...c }, next, () => fromReport(c, true, next, outStyle));
276
341
  }
277
342
  case "disable": {
278
343
  const d = heldDisabled(a.json, o.name, secrets);
279
344
  if (d === null)
280
345
  return outside();
281
346
  const next = onReplica(disabledNext(d.disabled.name), r);
282
- return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
347
+ return print({ ...d }, next, () => disabledReport(d, true, next, outStyle, on));
283
348
  }
284
349
  case "enable": {
285
350
  const e = heldEnabled(a.json, o.name, secrets);
286
351
  if (e === null)
287
352
  return outside();
288
353
  const next = onReplica(enabledNext(e.enabled.name), r);
289
- return print({ ...e }, next, () => enabledReport(e, next, outStyle));
354
+ return print({ ...e }, next, () => enabledReport(e, next, outStyle, on));
290
355
  }
291
356
  case "remove": {
292
357
  const x = heldRemoved(a.json, o.name, secrets);
293
358
  if (x === null)
294
359
  return outside();
295
360
  const next = onReplica(REMOVED_NEXT, r);
296
- return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
361
+ return print({ removedUser: x.removed, ...x }, next, () => removedReport(x, true, next, outStyle, on));
297
362
  }
298
363
  }
299
364
  }