@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.
- package/README.md +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
package/dist/src/cli/ui.js
CHANGED
|
@@ -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
|
|
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 <
|
|
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 <
|
|
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
|
-
--
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
177
|
-
if (
|
|
178
|
-
return wrong(`--timeout
|
|
179
|
-
|
|
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
|
|
203
|
-
const
|
|
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]],
|
package/dist/src/cli/usage.js
CHANGED
|
@@ -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 <
|
|
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 <
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
56
|
-
return
|
|
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
|
|
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? }
|
|
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(
|
|
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
|
}
|