@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/cli/user.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* called by the run's command server; so are `disableUser` and `enableUser`.
|
|
12
12
|
*/
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
|
|
14
|
+
import { failed, jsonText, listFlag, parseCommand, reportText, strict, withEnd } from "./command.js";
|
|
15
15
|
import { reach } from "./state.js";
|
|
16
16
|
import { callRun, RunUnreachable } from "./control.js";
|
|
17
17
|
import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
|
|
18
|
-
import { changeUsers, isActive, isDisabled, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
|
|
18
|
+
import { changeUsers, isActive, isDisabled, keyFingerprint, newUserId, readUsers, underKey, userPassword, userRanges, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
|
|
19
19
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
20
20
|
import { out as styleOut } from "./style.js";
|
|
21
21
|
import { NO_ROLES } from "../duck/grants.js";
|
|
@@ -24,16 +24,19 @@ import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, whereAttributes, whereParts } f
|
|
|
24
24
|
import { readSource, shownPath } from "./sources.js";
|
|
25
25
|
import { readState } from "./commands.js";
|
|
26
26
|
import { projectDatabase } from "./project.js";
|
|
27
|
+
import { replicaOf } from "./replica.js";
|
|
28
|
+
import { hostedUser } from "./user-hosted.js";
|
|
27
29
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
28
30
|
export const USER = {
|
|
29
31
|
needs: "project",
|
|
30
32
|
name: "user",
|
|
31
33
|
summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
|
|
32
|
-
usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--json]
|
|
33
|
-
mason user
|
|
34
|
-
mason user
|
|
35
|
-
mason user
|
|
36
|
-
mason user
|
|
34
|
+
usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--from <range> …] [--replica <org>/<name>] [--json]
|
|
35
|
+
mason user from <name> <range> … | --any [--replica <org>/<name>] [--json]
|
|
36
|
+
mason user list [--replica <org>/<name>] [--json]
|
|
37
|
+
mason user disable <name> [--replica <org>/<name>] [--json]
|
|
38
|
+
mason user enable <name> [--replica <org>/<name>] [--json]
|
|
39
|
+
mason user remove <name> [--replica <org>/<name>] [--json]
|
|
37
40
|
|
|
38
41
|
A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
|
|
39
42
|
(Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
|
|
@@ -42,6 +45,28 @@ export const USER = {
|
|
|
42
45
|
The user name a tool sends must be the user's own. A user disabled is kept with its password: its logins are
|
|
43
46
|
refused (as Postgres refuses a role without LOGIN) and its sessions end at their next statement, until enable.
|
|
44
47
|
|
|
48
|
+
mason user add metabase a user for Metabase: its password and the URL to paste
|
|
49
|
+
mason user add looker --from 203.0.113.0/24 --from 2001:db8:7::/48 connects from these alone
|
|
50
|
+
mason user from looker 198.51.100.0/24 its ranges replaced
|
|
51
|
+
mason user from looker --any it connects from any address again
|
|
52
|
+
mason user add agent --role agent a user held to the role agent
|
|
53
|
+
mason user add acme --role tenant --set tenant=42 the rows of tenant 42
|
|
54
|
+
mason user add emea --role region --set regions=eu --set regions=uk
|
|
55
|
+
mason user list
|
|
56
|
+
mason user disable metabase metabase connects no more, until mason user enable metabase
|
|
57
|
+
mason user enable metabase metabase connects again, with the same password
|
|
58
|
+
mason user remove metabase metabase connects no more
|
|
59
|
+
docker compose exec mason mason user add grafana beside mason serve, in its data folder
|
|
60
|
+
mason user add metabase --replica acme/shop a user of a replica on a hosted Mason service
|
|
61
|
+
|
|
62
|
+
<name> ${USER_NAME_RULE}
|
|
63
|
+
--role <role> add: what the user reads, a role of mason/roles.yml (below)
|
|
64
|
+
--set <attribute>=<value> add: its value of an attribute its role declares (below)
|
|
65
|
+
--from <range> add: the addresses it connects from (below)
|
|
66
|
+
--any from: its ranges removed, so it connects from any address
|
|
67
|
+
--replica <org>/<name> a replica's users on a hosted Mason service, not this project's (below)
|
|
68
|
+
--json as data: its shape is at the end
|
|
69
|
+
|
|
45
70
|
The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
|
|
46
71
|
A removed user stays in it, marked removed, so the list shows who was removed and when; its name can be added again.
|
|
47
72
|
Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
|
|
@@ -82,32 +107,42 @@ export const USER = {
|
|
|
82
107
|
statement, never answered with no rows: add it again with the --set. Postgres refuses a where: it cannot read
|
|
83
108
|
when the running Mason reads the file, at its key.
|
|
84
109
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
110
|
+
--from <range> limits the addresses the user connects from (repeat --from for each, or a comma list): an IPv4 or IPv6 range with its
|
|
111
|
+
prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10). A connection from any other address is
|
|
112
|
+
refused before its password is checked, as Postgres refuses one no pg_hba.conf line takes (28000, no entry for host
|
|
113
|
+
…), and in the same words for a name that is no user while any user has ranges, so a user with ranges cannot be
|
|
114
|
+
told from a missing one (a user without ranges still answers a wrong password 28P01). An address that cannot be read (a public share's socket) is refused too. Behind a TCP proxy the address is
|
|
115
|
+
the client its PROXY header carries (MASON_PG_PROXY_FROM). A user added without --from connects from any address.
|
|
116
|
+
mason user from <name> <range> … replaces a user's ranges, and mason user from <name> --any removes them; the sessions
|
|
117
|
+
it holds from an address outside its new ranges end at their next statement.
|
|
118
|
+
|
|
119
|
+
--replica <org>/<name> manages the users of a replica on a hosted Mason service instead of this project's (else
|
|
120
|
+
MASON_REPLICA, said on stderr when it chose; each part lower case letters, digits and -): the same subcommands,
|
|
121
|
+
--role, --set and --from, the same output and --json, which names the replica in its headline and its replica key,
|
|
122
|
+
the replica's Mason checking each as it does here. It needs no project here, and reads and writes
|
|
123
|
+
nothing of one. The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else
|
|
124
|
+
the default. A refusal is the service's words, each one line, its code the CLI's own by the HTTP status (not_found,
|
|
125
|
+
refused, failed: never usage), exit 1.
|
|
94
126
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
The --json answer: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
|
|
128
|
+
or the file's default; absent when none), attributes? ({ name: value, or [values] for a list }), roleFrom (file: its own --role; default: the file's default;
|
|
129
|
+
none), readsNothing (true: no role under a file with no default), from? (its address ranges, as given
|
|
130
|
+
to --from in their one form; absent: any address), and once Mason runs url, psql (the
|
|
131
|
+
psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
|
|
132
|
+
not removed), disabledAt (null for a user not disabled), live (connects now), from (its address ranges;
|
|
133
|
+
null: any address), role?, attributes?,
|
|
134
|
+
missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
|
|
135
|
+
roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
|
|
136
|
+
requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
|
|
137
|
+
lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
|
|
138
|
+
(null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
|
|
139
|
+
before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; from { changed: { name, id,
|
|
140
|
+
addedAt, from (its ranges now; null: any address) }, already (they were these before) }; remove { removedUser: { name,
|
|
141
|
+
id, addedAt, removedAt }, removed (the same, kept until 1.0) }; next. With --replica (or MASON_REPLICA) every answer also has replica ("<org>/<name>":
|
|
142
|
+
the replica it acted on), and a project's never does
|
|
108
143
|
`,
|
|
109
|
-
options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true } },
|
|
110
|
-
json: ["user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
|
|
144
|
+
options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, from: { type: "string", multiple: true }, any: { type: "boolean", default: false }, replica: { type: "string" } },
|
|
145
|
+
json: ["replica", "user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removedUser", "removed", "from", "changed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
|
|
111
146
|
};
|
|
112
147
|
/** The users of a project and why one is not live: added under an API key since rotated. */
|
|
113
148
|
const liveOf = (u, apiKey) => underKey(u, apiKey);
|
|
@@ -194,6 +229,9 @@ export function attributesToAdd(name, role, sets, live) {
|
|
|
194
229
|
return { values: out };
|
|
195
230
|
}
|
|
196
231
|
// ------------------------------------------------------------------ the role a user is added with
|
|
232
|
+
/** Why `role` is not a role's name (a usage failure's message), or null: asked by `roleToAdd`, and by `mason user add
|
|
233
|
+
* --role --replica` before a byte is sent. */
|
|
234
|
+
export const roleNameProblem = (role) => ROLE_NAME_RE.test(role) ? null : `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`;
|
|
197
235
|
/**
|
|
198
236
|
* WHY `role` IS NOT ONE A USER CAN BE ADDED WITH, against the roles as they are now (`live`: the listener's while the
|
|
199
237
|
* run is up, the file's otherwise): a name of another shape, a file that cannot be read as it is, no roles file at
|
|
@@ -202,8 +240,9 @@ export function attributesToAdd(name, role, sets, live) {
|
|
|
202
240
|
* that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
|
|
203
241
|
*/
|
|
204
242
|
export function roleToAdd(role, live) {
|
|
205
|
-
|
|
206
|
-
|
|
243
|
+
const named = roleNameProblem(role);
|
|
244
|
+
if (named !== null)
|
|
245
|
+
return { message: named, code: "usage" };
|
|
207
246
|
const file = live.file ?? `mason/${ROLES_FILE}`;
|
|
208
247
|
if (live.problem !== null) {
|
|
209
248
|
return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
|
|
@@ -325,7 +364,7 @@ export function listUsers(file, apiKey, link, roles = NO_ROLES) {
|
|
|
325
364
|
fix: `repair ${file}, or move it aside, which removes every user` };
|
|
326
365
|
// roleKnown only where the file can be read: under a problem the listener refuses every user held to it for the FILE
|
|
327
366
|
return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt ?? null, disabledAt: u.disabledAt ?? null,
|
|
328
|
-
live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey),
|
|
367
|
+
live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey), from: u.from ?? null,
|
|
329
368
|
lastUsedAt: null, url: link !== null && isActive(u) ? connectionUrl(link, u.name, null) : null,
|
|
330
369
|
...roleFields(u, roles), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}),
|
|
331
370
|
...((m) => m === undefined ? {} : { missingAttributes: m.missing })(usersMissingAttributes([u], roles)[0]),
|
|
@@ -359,6 +398,9 @@ export function addUser(file, apiKey, name, o) {
|
|
|
359
398
|
const usage = userNameProblem("add", name);
|
|
360
399
|
if (usage !== null)
|
|
361
400
|
return { message: usage, code: "usage" };
|
|
401
|
+
const ranges = o.from === undefined ? null : rangesToSet(o.from, `mason user add ${name}`);
|
|
402
|
+
if (ranges !== null && "message" in ranges)
|
|
403
|
+
return ranges;
|
|
362
404
|
const fingerprint = keyFingerprint(apiKey);
|
|
363
405
|
let added;
|
|
364
406
|
let replaced = false;
|
|
@@ -372,7 +414,7 @@ export function addUser(file, apiKey, name, o) {
|
|
|
372
414
|
replaced = was !== undefined;
|
|
373
415
|
const now = new Date().toISOString();
|
|
374
416
|
added = { name, id: newUserId(), addedAt: now, key: fingerprint, ...(o.role !== undefined ? { role: o.role } : {}),
|
|
375
|
-
...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}) };
|
|
417
|
+
...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}), ...(ranges !== null ? { from: ranges.from } : {}) };
|
|
376
418
|
return [...users.map((u) => u === was ? { ...u, removedAt: now } : u), added];
|
|
377
419
|
});
|
|
378
420
|
}
|
|
@@ -390,8 +432,58 @@ export function addUser(file, apiKey, name, o) {
|
|
|
390
432
|
const { link } = o;
|
|
391
433
|
return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
|
|
392
434
|
...(link !== null ? { url: connectionUrl(link, u.name, password), psql: psqlLine(connectionUrl(link, u.name, password)), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}),
|
|
393
|
-
...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), replaced };
|
|
435
|
+
...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), ...(u.from !== undefined ? { from: u.from } : {}), replaced };
|
|
436
|
+
}
|
|
437
|
+
/** Address ranges as `--from` (or `/api/users`' `from`) gives them, each read by the one grammar (src/duck/users.ts
|
|
438
|
+
* `userRanges`) and in its one form; or a wrong line (`usage`), every entry that is not a range said, nothing written. */
|
|
439
|
+
export function rangesToSet(given, line) {
|
|
440
|
+
const r = userRanges(given);
|
|
441
|
+
if ("problem" in r)
|
|
442
|
+
return { message: `${line}: ${r.problem}`, code: "usage", fix: "an IPv4 or IPv6 range with its prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10), one --from each" };
|
|
443
|
+
return { from: r.from };
|
|
394
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* SET A USER'S ADDRESS RANGES in `file` under its lock, replacing those it had (`from`: the ranges as given; null: any
|
|
447
|
+
* address, its ranges removed). The listener reads them at the user's next connection and at each statement of a session
|
|
448
|
+
* it holds, so a session from outside the new ranges ends there. The same ranges as now are said so (`already`), nothing
|
|
449
|
+
* written. A range that is not one is a wrong line, nothing written.
|
|
450
|
+
*/
|
|
451
|
+
export function setUserFrom(file, name, from) {
|
|
452
|
+
const usage = userNameProblem("from", name);
|
|
453
|
+
if (usage !== null)
|
|
454
|
+
return { message: usage, code: "usage" };
|
|
455
|
+
const ranges = from === null ? null : rangesToSet(from, `mason user from ${name}`);
|
|
456
|
+
if (ranges !== null && "message" in ranges)
|
|
457
|
+
return ranges;
|
|
458
|
+
const next = ranges === null ? undefined : ranges.from;
|
|
459
|
+
let found;
|
|
460
|
+
let live = [];
|
|
461
|
+
let r;
|
|
462
|
+
try {
|
|
463
|
+
r = changeUsers(file, (users) => {
|
|
464
|
+
found = users.find((u) => u.name === name && isActive(u));
|
|
465
|
+
live = users.filter(isActive).map((u) => u.name);
|
|
466
|
+
if (found === undefined)
|
|
467
|
+
return { refused: "unknown" };
|
|
468
|
+
if (JSON.stringify(found.from ?? null) === JSON.stringify(next ?? null))
|
|
469
|
+
return { refused: "already" };
|
|
470
|
+
return users.map((u) => { if (u !== found)
|
|
471
|
+
return u; const { from: _, ...rest } = u; return next === undefined ? rest : { ...rest, from: next }; });
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
return cantChange(file, e);
|
|
476
|
+
}
|
|
477
|
+
if (found === undefined)
|
|
478
|
+
return noSuchUser(name, live);
|
|
479
|
+
const u = found;
|
|
480
|
+
return { changed: { name: u.name, id: u.id, addedAt: u.addedAt, from: next ?? null }, already: !Array.isArray(r) };
|
|
481
|
+
}
|
|
482
|
+
/** a user's ranges as `mason user` says them: `10.0.0.0/16, 2001:db8::/32`, or any address */
|
|
483
|
+
export const rangesText = (from) => from === null || from === undefined ? "any address" : from.join(", ");
|
|
484
|
+
/** What to run after a `mason user from`. */
|
|
485
|
+
export const fromNext = (name, from) => [["mason user list", "the users and the addresses each connects from"],
|
|
486
|
+
...(from !== null ? [[`mason user from ${name} --any`, "it connects from any address again"]] : [])];
|
|
395
487
|
/** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
|
|
396
488
|
export function removeUser(file, name) {
|
|
397
489
|
const usage = userNameProblem("remove", name);
|
|
@@ -484,6 +576,103 @@ export function enableUser(file, name) {
|
|
|
484
576
|
const u = found;
|
|
485
577
|
return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
|
|
486
578
|
}
|
|
579
|
+
/** What to run after a list. */
|
|
580
|
+
export const listNext = (users) => [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
|
|
581
|
+
...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
|
|
582
|
+
...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
|
|
583
|
+
/** `mason user list` for a person: the users of `of` (the project's name, or the replica's), a line each, then its tips
|
|
584
|
+
* and `next`. `roles`: the roles as a command beside them sees them (NO_ROLES where none can be read here). */
|
|
585
|
+
export function listText(of, l, roles, next, s = styleOut()) {
|
|
586
|
+
const { users, lastUsedNote, requestLogDays } = l;
|
|
587
|
+
if (!users.length)
|
|
588
|
+
return strict(withEnd(`${s.bold(`No users yet for ${of}.`)}`, { next }, s), s);
|
|
589
|
+
const active = users.filter((u) => u.removedAt === null);
|
|
590
|
+
const gone = users.filter((u) => u.removedAt !== null);
|
|
591
|
+
const width = Math.max(...users.map((u) => u.name.length));
|
|
592
|
+
const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
|
|
593
|
+
// the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
|
|
594
|
+
const used = (u) => { const t = lastUsedText(u.lastUsedAt, lastUsedNote, requestLogDays, u.addedAt); return t === "never" ? "never used" : t.startsWith("not used") ? t : `last used ${t}`; };
|
|
595
|
+
const lines = active.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${u.role !== undefined ? `role ${u.role}${u.roleFrom === "default" ? " (default)" : ""}${u.attributes !== undefined ? ` (${attributesText(u.attributes)})` : ""} ` : ""}${u.from !== null ? `from ${rangesText(u.from)} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
|
|
596
|
+
(u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
|
|
597
|
+
: u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
|
|
598
|
+
(u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
|
|
599
|
+
(u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
|
|
600
|
+
(u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
|
|
601
|
+
const disabled = active.filter((u) => u.disabledAt !== null);
|
|
602
|
+
const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
|
|
603
|
+
const rotated = active.filter((u) => !u.live && u.disabledAt === null);
|
|
604
|
+
const orphan = active.find((u) => u.roleKnown === false);
|
|
605
|
+
const roleless = active.find((u) => u.readsNothing === true);
|
|
606
|
+
const unset = active.find((u) => u.missingAttributes !== undefined);
|
|
607
|
+
return strict(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${of}${disabled.length ? `, ${disabled.length} disabled` : ""}${gone.length ? `, ${gone.length} removed` : ""}`)}\n${[...lines, ...removedLines].join("\n")}`, { tips: [...(rotated.length ? [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] : []),
|
|
608
|
+
...(orphan ? [`A role the roles file (mason/${ROLES_FILE}) does not name reads nothing: add ${orphan.role} to it, or mason user remove ${orphan.name}, then mason user add ${orphan.name} --role <role>.`] : []),
|
|
609
|
+
...(unset ? [`A user without a value its role's rows are filtered by is refused at every statement: ${missingAttributesText({ name: unset.name, role: unset.role ?? "", missing: unset.missingAttributes })}.`] : []),
|
|
610
|
+
...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
|
|
611
|
+
...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
|
|
612
|
+
...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }, s), s);
|
|
613
|
+
}
|
|
614
|
+
/** A user in a headline: its name, and the replica it is a user of when the command acted on one (`on`), so the answer
|
|
615
|
+
* says its target. */
|
|
616
|
+
const userOn = (name, on) => on === undefined ? name : `${name} of the replica ${on}`;
|
|
617
|
+
/** `mason user disable` for a person. `up`: Mason runs, so the sessions the user holds end at their next statement. */
|
|
618
|
+
export const disabledReport = (r, up, next, s = styleOut(), on) => {
|
|
619
|
+
const d = r.disabled, who = userOn(d.name, on);
|
|
620
|
+
return reportText({ headline: r.already ? `The user ${who} is disabled already: nothing changed` : `The user ${who} disabled: it connects no more, until enabled`,
|
|
621
|
+
details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]])],
|
|
622
|
+
tips: [disableEffect(d.name)], next }, s);
|
|
623
|
+
};
|
|
624
|
+
/** `mason user enable` for a person. */
|
|
625
|
+
export const enabledReport = (r, next, s = styleOut(), on) => {
|
|
626
|
+
const e = r.enabled, who = userOn(e.name, on);
|
|
627
|
+
return reportText({ headline: r.already ? `The user ${who} is not disabled: nothing changed, it connects` : `The user ${who} enabled: it connects again, with the same password`,
|
|
628
|
+
details: [["user", e.name], ["added", e.addedAt]], next }, s);
|
|
629
|
+
};
|
|
630
|
+
/** `mason user from` for a person. `up`: Mason runs, so a session from outside the new ranges ends at its next statement. */
|
|
631
|
+
export const fromReport = (r, up, next, s = styleOut()) => {
|
|
632
|
+
const c = r.changed;
|
|
633
|
+
return reportText({ headline: r.already ? `The user ${c.name} connects from ${rangesText(c.from)} already: nothing changed`
|
|
634
|
+
: c.from === null ? `The user ${c.name} connects from any address now` : `The user ${c.name} connects from ${rangesText(c.from)} alone now`,
|
|
635
|
+
details: [["user", c.name], ["from", rangesText(c.from)], ...(r.already || c.from === null ? [] : [["sessions", up ? "those it holds from another address end at their next statement" : "none: Mason is not running"]])],
|
|
636
|
+
tips: c.from === null ? [] : ["A connection from any other address is refused before its password is checked (28000, no entry for host …)."], next }, s);
|
|
637
|
+
};
|
|
638
|
+
/** `mason user remove` for a person. */
|
|
639
|
+
export const removedReport = (r, up, next, s = styleOut(), on) => {
|
|
640
|
+
const gone = r.removed;
|
|
641
|
+
return reportText({ headline: `The user ${userOn(gone.name, on)} removed: it connects no more`,
|
|
642
|
+
details: [["user", gone.name], ["added", gone.addedAt], ["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]],
|
|
643
|
+
tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }, s);
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* `mason user add` for a person: the password, printed this once, in the url, psql and password rows (and nowhere else).
|
|
647
|
+
* The url, psql, host, port and ssl mode rows are there when the answer has them (Mason runs, so where it listens is
|
|
648
|
+
* known). `roles`: the roles as a command beside them sees them (the file's name in the role row; NO_ROLES where none
|
|
649
|
+
* can be read here); `listener`: how this run listens (its tips), null where it is not known here.
|
|
650
|
+
*/
|
|
651
|
+
export function addedReport(r, o, s = styleOut()) {
|
|
652
|
+
const { roles } = o;
|
|
653
|
+
return reportText({
|
|
654
|
+
headline: `The user ${userOn(r.user, o.on)} added${r.replaced === true ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
|
|
655
|
+
details: [
|
|
656
|
+
...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
|
|
657
|
+
["user", r.user], ["password", r.password], ["database", r.database],
|
|
658
|
+
...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
|
|
659
|
+
...(r.from !== undefined ? [["from", `${rangesText(r.from)} (it connects from these alone)`]] : []),
|
|
660
|
+
...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
|
|
661
|
+
: r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
|
|
662
|
+
: r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
|
|
663
|
+
...(r.host !== undefined && r.port !== undefined ? [["host", r.host], ["port", String(r.port)], ...(r.sslmode !== undefined && r.sslmode !== "disable" ? [["ssl mode", r.sslmode]] : [])] : []),
|
|
664
|
+
],
|
|
665
|
+
tips: [
|
|
666
|
+
"Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
|
|
667
|
+
...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
|
|
668
|
+
...(r.url === undefined ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
669
|
+
...listenerTips(o.listener),
|
|
670
|
+
"It never expires: mason user remove ends it, at once and for every session it holds.",
|
|
671
|
+
"Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
|
|
672
|
+
],
|
|
673
|
+
next: o.next,
|
|
674
|
+
}, s);
|
|
675
|
+
}
|
|
487
676
|
// ------------------------------------------------------------------ the command
|
|
488
677
|
export async function user(argv) {
|
|
489
678
|
const f = parseCommand(USER, argv);
|
|
@@ -492,13 +681,26 @@ export async function user(argv) {
|
|
|
492
681
|
const json = f.values.json === true;
|
|
493
682
|
const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
|
|
494
683
|
const [verb, name, ...rest] = f.positionals;
|
|
495
|
-
const VERBS = ["add", "list", "disable", "enable", "remove"];
|
|
684
|
+
const VERBS = ["add", "list", "from", "disable", "enable", "remove"];
|
|
496
685
|
if (verb === undefined)
|
|
497
|
-
return usage("add, list, disable, enable or remove: mason user add <name> makes a user");
|
|
686
|
+
return usage("add, list, from, disable, enable or remove: mason user add <name> makes a user");
|
|
498
687
|
if (!VERBS.includes(verb))
|
|
499
|
-
return usage(`there is no mason user ${verb}: add, list, disable, enable or remove${didYouMean(verb, VERBS)}`);
|
|
500
|
-
if (verb === "list" ? name !== undefined : rest.length)
|
|
688
|
+
return usage(`there is no mason user ${verb}: add, list, from, disable, enable or remove${didYouMean(verb, VERBS)}`);
|
|
689
|
+
if (verb === "list" ? name !== undefined : verb !== "from" && rest.length)
|
|
501
690
|
return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
|
|
691
|
+
// the address ranges: --from on add, the positionals of from (or its --any); each read by the one grammar, a wrong one a
|
|
692
|
+
// wrong line before anything is read, written or sent
|
|
693
|
+
// a comma list or the flag repeated, the same (src/cli/command.ts listFlag): a range holds no comma
|
|
694
|
+
const fromList = listFlag(f.values.from);
|
|
695
|
+
const fromFlags = fromList === undefined ? [] : fromList.split(",");
|
|
696
|
+
if (fromFlags.length > 0 && verb !== "add")
|
|
697
|
+
return usage(`--from goes with mason user add: mason user add <name> --from ${fromFlags[0]}${verb === "from" ? `, or mason user from <name> ${fromFlags[0]}` : ""}`);
|
|
698
|
+
const any = f.values.any === true;
|
|
699
|
+
if (any && verb !== "from")
|
|
700
|
+
return usage("--any goes with mason user from: mason user from <name> --any removes its address ranges");
|
|
701
|
+
if (verb === "from" && any === (rest.length > 0))
|
|
702
|
+
return usage(any ? `mason user from ${name ?? "<name>"}: --any or ranges, not both` : `mason user from ${name ?? "<name>"}: which addresses — one or more ranges (10.0.0.0/16, 2001:db8::/32, 192.0.2.10), or --any`);
|
|
703
|
+
const given = verb === "add" ? (fromFlags.length > 0 ? fromFlags : null) : verb === "from" && !any ? rest : null;
|
|
502
704
|
const role = typeof f.values.role === "string" ? f.values.role : undefined;
|
|
503
705
|
if (role !== undefined && verb !== "add")
|
|
504
706
|
return usage(`--role goes with mason user add: mason user add <name> --role ${role}`);
|
|
@@ -515,6 +717,20 @@ export async function user(argv) {
|
|
|
515
717
|
if (problem !== null)
|
|
516
718
|
return usage(problem);
|
|
517
719
|
}
|
|
720
|
+
const ranges = given === null ? null : rangesToSet(given, `mason user ${verb} ${name}`);
|
|
721
|
+
if (ranges !== null && "message" in ranges)
|
|
722
|
+
return failed("user", { ...ranges, usage: USER.usage }, { json });
|
|
723
|
+
// A REPLICA ON A HOSTED MASON SERVICE (--replica, else MASON_REPLICA): its users, asked of the service with the same
|
|
724
|
+
// request the run's /api/users takes, printed by the renderers above; nothing of a project here is read or written
|
|
725
|
+
const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, USER.usage);
|
|
726
|
+
if (replica !== null && "message" in replica)
|
|
727
|
+
return failed("user", replica, { json });
|
|
728
|
+
if (replica !== null) {
|
|
729
|
+
const named = role === undefined ? null : roleNameProblem(role);
|
|
730
|
+
if (named !== null)
|
|
731
|
+
return usage(named);
|
|
732
|
+
return hostedUser(replica, { verb: verb, name, role, sets, from: ranges?.from ?? null, json });
|
|
733
|
+
}
|
|
518
734
|
const w = await reach(USER, json);
|
|
519
735
|
if (w === null || w.project === null)
|
|
520
736
|
return 1;
|
|
@@ -551,42 +767,24 @@ export async function user(argv) {
|
|
|
551
767
|
}
|
|
552
768
|
}
|
|
553
769
|
const urlNote = up !== null ? null : NOT_RUNNING_URL;
|
|
554
|
-
const next =
|
|
555
|
-
...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
|
|
556
|
-
...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
|
|
770
|
+
const next = listNext(users);
|
|
557
771
|
if (json) {
|
|
558
772
|
out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
|
|
559
773
|
return 0;
|
|
560
774
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
775
|
+
out(listText(project.config.name, { users, lastUsedNote, urlNote, requestLogDays }, roles, next));
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
if (verb === "from") {
|
|
779
|
+
const r = setUserFrom(file, name, ranges?.from ?? null);
|
|
780
|
+
if ("message" in r)
|
|
781
|
+
return failed("user", r, { json });
|
|
782
|
+
const next = fromNext(r.changed.name, r.changed.from);
|
|
783
|
+
if (json) {
|
|
784
|
+
out(jsonText(USER, r, next));
|
|
564
785
|
return 0;
|
|
565
786
|
}
|
|
566
|
-
|
|
567
|
-
const gone = users.filter((u) => u.removedAt !== null);
|
|
568
|
-
const width = Math.max(...users.map((u) => u.name.length));
|
|
569
|
-
const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
|
|
570
|
-
// the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
|
|
571
|
-
const used = (u) => { const t = lastUsedText(u.lastUsedAt, lastUsedNote, requestLogDays, u.addedAt); return t === "never" ? "never used" : t.startsWith("not used") ? t : `last used ${t}`; };
|
|
572
|
-
const lines = active.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${u.role !== undefined ? `role ${u.role}${u.roleFrom === "default" ? " (default)" : ""}${u.attributes !== undefined ? ` (${attributesText(u.attributes)})` : ""} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
|
|
573
|
-
(u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
|
|
574
|
-
: u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
|
|
575
|
-
(u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
|
|
576
|
-
(u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
|
|
577
|
-
(u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
|
|
578
|
-
const disabled = active.filter((u) => u.disabledAt !== null);
|
|
579
|
-
const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
|
|
580
|
-
const rotated = active.filter((u) => !u.live && u.disabledAt === null);
|
|
581
|
-
const orphan = active.find((u) => u.roleKnown === false);
|
|
582
|
-
const roleless = active.find((u) => u.readsNothing === true);
|
|
583
|
-
const unset = active.find((u) => u.missingAttributes !== undefined);
|
|
584
|
-
out(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${project.config.name}${disabled.length ? `, ${disabled.length} disabled` : ""}${gone.length ? `, ${gone.length} removed` : ""}`)}\n${[...lines, ...removedLines].join("\n")}`, { tips: [...(rotated.length ? [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] : []),
|
|
585
|
-
...(orphan ? [`A role the roles file (mason/${ROLES_FILE}) does not name reads nothing: add ${orphan.role} to it, or mason user remove ${orphan.name}, then mason user add ${orphan.name} --role <role>.`] : []),
|
|
586
|
-
...(unset ? [`A user without a value its role's rows are filtered by is refused at every statement: ${missingAttributesText({ name: unset.name, role: unset.role ?? "", missing: unset.missingAttributes })}.`] : []),
|
|
587
|
-
...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
|
|
588
|
-
...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
|
|
589
|
-
...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }));
|
|
787
|
+
out(fromReport(r, w.state === "up", next));
|
|
590
788
|
return 0;
|
|
591
789
|
}
|
|
592
790
|
if (verb === "disable") {
|
|
@@ -598,10 +796,7 @@ export async function user(argv) {
|
|
|
598
796
|
out(jsonText(USER, r, next));
|
|
599
797
|
return 0;
|
|
600
798
|
}
|
|
601
|
-
|
|
602
|
-
out(reportText({ headline: r.already ? `The user ${d.name} is disabled already: nothing changed` : `The user ${d.name} disabled: it connects no more, until enabled`,
|
|
603
|
-
details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]])],
|
|
604
|
-
tips: [disableEffect(d.name)], next }));
|
|
799
|
+
out(disabledReport(r, w.state === "up", next));
|
|
605
800
|
return 0;
|
|
606
801
|
}
|
|
607
802
|
if (verb === "enable") {
|
|
@@ -613,24 +808,19 @@ export async function user(argv) {
|
|
|
613
808
|
out(jsonText(USER, r, next));
|
|
614
809
|
return 0;
|
|
615
810
|
}
|
|
616
|
-
|
|
617
|
-
out(reportText({ headline: r.already ? `The user ${e.name} is not disabled: nothing changed, it connects` : `The user ${e.name} enabled: it connects again, with the same password`,
|
|
618
|
-
details: [["user", e.name], ["added", e.addedAt]], next }));
|
|
811
|
+
out(enabledReport(r, next));
|
|
619
812
|
return 0;
|
|
620
813
|
}
|
|
621
814
|
if (verb === "remove") {
|
|
622
815
|
const r = removeUser(file, name);
|
|
623
816
|
if ("message" in r)
|
|
624
817
|
return failed("user", r, { json });
|
|
625
|
-
const gone = r.removed;
|
|
626
818
|
const next = REMOVED_NEXT;
|
|
627
819
|
if (json) {
|
|
628
|
-
out(jsonText(USER, r, next));
|
|
820
|
+
out(jsonText(USER, { removedUser: r.removed, ...r }, next));
|
|
629
821
|
return 0;
|
|
630
822
|
}
|
|
631
|
-
out(
|
|
632
|
-
details: [["user", gone.name], ["added", gone.addedAt], ["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]],
|
|
633
|
-
tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }));
|
|
823
|
+
out(removedReport(r, w.state === "up", next));
|
|
634
824
|
return 0;
|
|
635
825
|
}
|
|
636
826
|
// add
|
|
@@ -651,35 +841,15 @@ export async function user(argv) {
|
|
|
651
841
|
const attributes = attributesToAdd(name, role ?? userRole({}, roles).role, sets, roles);
|
|
652
842
|
if ("refused" in attributes)
|
|
653
843
|
return failed("user", attributes.refused, { json });
|
|
654
|
-
const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
|
|
844
|
+
const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values, ...(ranges !== null ? { from: ranges.from } : {}) });
|
|
655
845
|
if ("message" in r)
|
|
656
846
|
return failed("user", r, { json });
|
|
657
|
-
const { replaced, ...added } = r;
|
|
847
|
+
const { replaced: _, ...added } = r;
|
|
658
848
|
const next = addedNext(r.user, link !== null);
|
|
659
849
|
if (json) {
|
|
660
850
|
out(jsonText(USER, added, next));
|
|
661
851
|
return 0;
|
|
662
852
|
}
|
|
663
|
-
out(
|
|
664
|
-
headline: `The user ${r.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
|
|
665
|
-
details: [
|
|
666
|
-
...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
|
|
667
|
-
["user", r.user], ["password", r.password], ["database", r.database],
|
|
668
|
-
...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
|
|
669
|
-
...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
|
|
670
|
-
: r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
|
|
671
|
-
: r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
|
|
672
|
-
...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
|
|
673
|
-
],
|
|
674
|
-
tips: [
|
|
675
|
-
"Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
|
|
676
|
-
...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
|
|
677
|
-
...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
678
|
-
...listenerTips(run?.postgresListener ?? null),
|
|
679
|
-
"It never expires: mason user remove ends it, at once and for every session it holds.",
|
|
680
|
-
"Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
|
|
681
|
-
],
|
|
682
|
-
next,
|
|
683
|
-
}));
|
|
853
|
+
out(addedReport(r, { roles, listener: run?.postgresListener ?? null, next }));
|
|
684
854
|
return 0;
|
|
685
855
|
}
|
package/dist/src/demo/seed.js
CHANGED
|
@@ -48,6 +48,32 @@ export const DEMO_ADDED_TABLES = DEMO_TABLES.filter((t) => t !== DEMO_TABLE_TO_A
|
|
|
48
48
|
/** how the demo's tables are kept current (the sources file's top `sync:`, src/lib/table-sync.ts): polled every 5 s,
|
|
49
49
|
* so the demo shows a copy keeping up with its writer — where a project `mason init` makes is `off` */
|
|
50
50
|
export const DEMO_SYNC = { mode: "poll", every: "5s", seconds: 5 };
|
|
51
|
+
/**
|
|
52
|
+
* THE GENERATED TABLE `mason init demo` WRITES (`mason/generated_tables/<file>`, src/cli/generated-files.ts reads it), so a
|
|
53
|
+
* person trying Mason meets a generated table without reading the docs: orders and revenue per day and status, kept
|
|
54
|
+
* current from the synced `orders`.
|
|
55
|
+
* - It reads `orders` ALONE, a table init adds (`DEMO_ADDED_TABLES`), never `DEMO_TABLE_TO_ADD`: it is built at the
|
|
56
|
+
* first start, and the person's first lesson (`mason add order_items`) stays the only one.
|
|
57
|
+
* - It NAMES ITS ZONE (`AT TIME ZONE 'UTC'`): a generated table has no session, so a form reading the zone is refused.
|
|
58
|
+
* The day is a `date`, the same in every client's TimeZone.
|
|
59
|
+
* - `revenue` is the sum of a `numeric(12,2)`: its scale is fixed by the type, so it is stored exactly.
|
|
60
|
+
* - `target_lag: 30s`: the writer adds an order every few seconds and the poll is 5 s, so it is seen re-running.
|
|
61
|
+
* Its name is the README's example's (`daily_orders.yml`), so the two read as one.
|
|
62
|
+
*/
|
|
63
|
+
export const DEMO_GENERATED = {
|
|
64
|
+
file: "daily_orders.yml",
|
|
65
|
+
table: "daily_orders",
|
|
66
|
+
text: `# A generated table: a SELECT over the synced tables, kept current by Mason. Yours to commit; mason help generated.
|
|
67
|
+
kind: scheduled
|
|
68
|
+
target_lag: 30s # how far behind the synced orders it may be
|
|
69
|
+
sql: |
|
|
70
|
+
SELECT (created_at AT TIME ZONE 'UTC')::date AS day, status, count(*) AS orders, sum(total) AS revenue
|
|
71
|
+
FROM orders
|
|
72
|
+
GROUP BY 1, 2
|
|
73
|
+
order_by: day, status
|
|
74
|
+
comment: Orders and revenue per day (UTC) and status, kept current from the synced orders.
|
|
75
|
+
`,
|
|
76
|
+
};
|
|
51
77
|
/** an order's status moves along this path (`changeSql`); `cancelled` is a side exit */
|
|
52
78
|
export const ORDER_STATUSES = ["pending", "paid", "shipped", "delivered"];
|
|
53
79
|
const ROWS_PER_INSERT = 1_000;
|