@mason-data/cli 0.8.0 → 0.9.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `mason user add | list | disable | enable | remove … --replica <org>/<name>` — THE USERS OF A REPLICA ON A HOSTED MASON
3
+ * SERVICE (docs/HOSTED-API.md "The users of a replica"). Each subcommand is ONE request, the one the run's own command
4
+ * server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
5
+ *
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)
8
+ * disable POST …/users/{user}/disable { disabled, already }
9
+ * enable POST …/users/{user}/enable { enabled, already }
10
+ * remove DELETE …/users/{user} { removed }
11
+ *
12
+ * `--role` and `--set` go as they were given: Mason on the replica checks them with the functions it checks them with
13
+ * here (src/cli/user.ts `roleToAdd`, `attributesToAdd`). What only this command line says (a verb, a user's name, a
14
+ * role's name, a `--set`'s form) is checked first, by the same functions, and refused as a wrong line before a byte is
15
+ * sent. Nothing of a project here is read or written: the users are the replica's.
16
+ *
17
+ * THE ANSWER IS HELD TO THE RUN'S OWN ANSWER SHAPE, then printed by the renderers the local command prints with
18
+ * (src/cli/user.ts `listText`, `addedReport`, …), in serviceStyle: the same answer prints the same lines. A SERVICE'S
19
+ * VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every value the
20
+ * service sends that this command prints, and how it is held:
21
+ *
22
+ * value where it is printed what holds it
23
+ * a failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`), masked
24
+ * a failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
25
+ * a failure's next nowhere: never read the step is `failureNext`'s, by the status
26
+ * a user's name every row, headline and step it names `USER_NAME_RE`; add, disable, enable, remove: the name asked
27
+ * an id list's rows, --json 16 hex digits
28
+ * addedAt, removedAt, disabledAt, rows, list's lines, --json `isServiceTime`
29
+ * lastUsedAt
30
+ * the password add's url, psql and password rows, --json `mason_<its id>_<32 hex>` (src/duck/users.ts), add's alone;
31
+ * anything shaped as one elsewhere refused (`carriesNoSecret`)
32
+ * or, in a failure's words, masked (`serviceText`)
33
+ * database, host, port, sslmode add's rows, --json the database name's rule, a host's letters, 1-65535, a closed set
34
+ * url, psql (add) add's rows, --json rebuilt here (`connectionUrl`, `psqlLine`): the service's must equal them
35
+ * url (list) --json `postgres://<its name>@<host>:<port>/<database>`, no password
36
+ * role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
37
+ * roleKnown, live
38
+ * attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
39
+ * lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
40
+ * requestLogDays list's lines, --json a whole number, 1 to 36500
41
+ * already the headline chosen, --json a boolean
42
+ * an HTTP status a failure's why a number
43
+ *
44
+ * BEFORE ANY OF THESE, every string of the answer, its keys included, is put to the exact test (src/cli/hosted.ts
45
+ * `carriesNoSecret`, `carriesSecret` on each: an echo of the held token, `ECHO_RUN` of its body in a row in one string; a
46
+ * token's or a password's shape — add's own password, url and psql for the token alone): an answer that carries one
47
+ * anywhere is refused whole, so no grammar is ever the only thing between a secret and the output, and an honest answer
48
+ * is refused with a probability under 1 in a million whatever its size. A failure's code, printed inside why, is put to
49
+ * the same test (`failureShape`). The free text (the notes, a refusal's words) is masked too, by #591's quarter rule.
50
+ *
51
+ * An answer any of which does not hold is outside the contract: said by the request and its status, never its body.
52
+ * What to run next is the CLI's own (`onReplica`: the local command's steps, each `mason user …` naming the replica).
53
+ */
54
+ import { failed, jsonText, serviceStyle } from "./command.js";
55
+ import { call, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
56
+ import { replicaText, tokenFor } from "./replica.js";
57
+ import { holdOutput } from "./output-gate.js";
58
+ import { err as styleErr, out as styleOut } from "./style.js";
59
+ import { connectionUrl, psqlLine } from "./pg-url.js";
60
+ import { USER_NAME_RE } from "../duck/users.js";
61
+ import { ROLE_NAME_RE } from "./roles.js";
62
+ import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
63
+ import { databaseNameProblem } from "../lib/database-name.js";
64
+ import { NO_ROLES } from "../duck/grants.js";
65
+ import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
66
+ /** 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
+ * is `call`'s own 64 KiB. */
68
+ export const MAX_LIST_BYTES = 1024 * 1024;
69
+ export const MAX_USERS = 2000;
70
+ const out = (m) => { process.stdout.write(`${m}\n`); };
71
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
72
+ const ID = /^[0-9a-f]{16}$/;
73
+ const HOST = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?|[0-9A-Fa-f:.]{2,45})$/;
74
+ const ROLE_FROM = ["file", "default", "none"];
75
+ /** The local command's next steps, as a replica's: each `mason user …` names the replica, so it runs against it. */
76
+ export const onReplica = (steps, r) => steps.map(([command, ...what]) => [command.startsWith("mason user ") ? `${command} --replica ${replicaText(r)}` : command, ...what]);
77
+ /** What to run after the service refused a request about a replica's users: the CLI's own, by the status (never the
78
+ * service's `next`): sign in again for a 401 or 403, else the replica's users. */
79
+ export const failureNext = (status, r) => status === 401 || status === 403 ? serviceNext(status) : [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]];
80
+ /** The request a subcommand is: the run's own `/api/users` request, under the replica's path. */
81
+ export function requestOf(r, o) {
82
+ const base = `/v1/replicas/${r.org}/${r.name}/users`;
83
+ // a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
84
+ switch (o.verb) {
85
+ 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] } : {}) } };
87
+ case "disable":
88
+ case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
89
+ case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
90
+ }
91
+ }
92
+ // ------------------------------------------------------------------ the answers, held to the run's own shapes
93
+ /** A string the service sends, as one line: null when it is not a string. */
94
+ const line = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
95
+ /** A user's role fields, each consistent with the others (src/cli/roles.ts userRole): a role for `file` and `default`,
96
+ * none for `none`, `readsNothing` only where there is none. */
97
+ function roleOf(u) {
98
+ if (typeof u.roleFrom !== "string" || !ROLE_FROM.includes(u.roleFrom))
99
+ return null;
100
+ const roleFrom = u.roleFrom;
101
+ if (u.role !== undefined && (typeof u.role !== "string" || !ROLE_NAME_RE.test(u.role)))
102
+ return null;
103
+ if ((roleFrom === "none") !== (u.role === undefined))
104
+ return null;
105
+ if (u.readsNothing !== undefined && (u.readsNothing !== true || roleFrom !== "none"))
106
+ return null;
107
+ return { ...(typeof u.role === "string" ? { role: u.role } : {}), roleFrom, ...(u.readsNothing === true ? { readsNothing: true } : {}) };
108
+ }
109
+ /** A user's attributes: at most 64 names, each a value (one line, bounded) or a list of 1 to 100 of them. A value is a
110
+ * value (a tenant's id), put to the exact test with every string (`carriesNoSecret`), so it is made one line here
111
+ * without the quarter rule of free text, which a thousand random ids would trip by chance (PR 609's review). */
112
+ function attributesOf(v) {
113
+ if (!obj(v) || Object.keys(v).length > 64)
114
+ return null;
115
+ const outv = {};
116
+ for (const [k, x] of Object.entries(v)) {
117
+ if (!ATTRIBUTE_NAME_RE.test(k))
118
+ return null;
119
+ if (typeof x === "string")
120
+ outv[k] = serviceText(x, 1000);
121
+ else if (Array.isArray(x) && x.length >= 1 && x.length <= 100 && x.every((y) => typeof y === "string"))
122
+ outv[k] = x.map((y) => serviceText(y, 1000));
123
+ else
124
+ return null;
125
+ }
126
+ return outv;
127
+ }
128
+ const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
129
+ const timeOrNull = (v) => v === null || isServiceTime(v);
130
+ /** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
131
+ function rowOf(v) {
132
+ if (!obj(v))
133
+ return null;
134
+ const u = v;
135
+ if (typeof u.name !== "string" || !USER_NAME_RE.test(u.name) || typeof u.id !== "string" || !ID.test(u.id) || !isServiceTime(u.addedAt)
136
+ || !timeOrNull(u.removedAt) || !timeOrNull(u.disabledAt) || !timeOrNull(u.lastUsedAt) || typeof u.live !== "boolean")
137
+ return null;
138
+ // the URL a tool pastes, WITHOUT the password: its own name, then where the replica listens
139
+ if (u.url !== null && !(typeof u.url === "string" && u.url.length <= 600 && u.url.startsWith(`postgres://${u.name}@`)
140
+ && /^postgres:\/\/[a-z0-9_.-]+@(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/[A-Za-z0-9%._~!*'()-]+(?:\?sslmode=require)?$/.test(u.url)))
141
+ return null;
142
+ const role = roleOf(u);
143
+ if (role === null)
144
+ return null;
145
+ const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
146
+ if (attributes === null)
147
+ return null;
148
+ if (u.missingAttributes !== undefined && !names(u.missingAttributes))
149
+ return null;
150
+ if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
151
+ return null;
152
+ 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 } : {}),
154
+ ...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
155
+ ...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
156
+ }
157
+ /** `GET …/users?lastUsed=1`'s answer: mason user list --json's shape (its `next` never read); null when it is not it. */
158
+ export function heldList(json, secrets) {
159
+ if (!obj(json) || !Array.isArray(json.users) || json.users.length > MAX_USERS || !carriesNoSecret(json, secrets))
160
+ return null;
161
+ const users = [];
162
+ for (const v of json.users) {
163
+ const u = rowOf(v);
164
+ if (u === null)
165
+ return null;
166
+ users.push(u);
167
+ }
168
+ const note = (v) => v === null ? null : line(v, 500, secrets) ?? undefined;
169
+ const lastUsedNote = note(json.lastUsedNote), urlNote = note(json.urlNote);
170
+ const days = json.requestLogDays;
171
+ if (lastUsedNote === undefined || urlNote === undefined || !(days === null || (Number.isInteger(days) && days >= 1 && days <= 36500)))
172
+ return null;
173
+ return { users, lastUsedNote, urlNote, requestLogDays: days };
174
+ }
175
+ /** `POST …/users`'s answer: mason user add --json's shape with Mason up — where it listens, the URL rebuilt here by the
176
+ * one builder and equal to the service's —, for the name asked; null when it is not it. */
177
+ export function heldAdded(json, asked, secrets) {
178
+ if (!obj(json) || !carriesNoSecret(json, secrets, ["password", "url", "psql"]))
179
+ return null;
180
+ const a = json;
181
+ if (a.user !== asked || typeof a.id !== "string" || !ID.test(a.id) || !isServiceTime(a.addedAt))
182
+ return null;
183
+ // the password is the user's, by its one grammar: its id, then its MAC
184
+ if (typeof a.password !== "string" || !new RegExp(`^mason_${a.id}_[0-9a-f]{32}$`).test(a.password))
185
+ return null;
186
+ if (typeof a.database !== "string" || databaseNameProblem(a.database) !== null || serviceText(a.database, 63) !== a.database)
187
+ return null;
188
+ if (typeof a.host !== "string" || !HOST.test(a.host) || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535)
189
+ return null;
190
+ if (a.sslmode !== "require" && a.sslmode !== "disable")
191
+ return null;
192
+ const url = connectionUrl({ url: "", host: a.host, port: a.port, database: a.database, ...(a.sslmode === "require" ? { sslmode: "require" } : {}) }, asked, a.password);
193
+ if (a.url !== url || a.psql !== psqlLine(url))
194
+ return null;
195
+ const role = roleOf(a);
196
+ if (role === null)
197
+ return null;
198
+ const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
199
+ if (attributes === null)
200
+ return null;
201
+ 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 } : {}) };
203
+ }
204
+ /** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
205
+ function userOf(v, asked, more) {
206
+ if (!obj(v) || v.name !== asked || typeof v.id !== "string" || !ID.test(v.id) || !isServiceTime(v.addedAt) || !more.every((k) => isServiceTime(v[k])))
207
+ return null;
208
+ return { name: asked, id: v.id, addedAt: v.addedAt, ...Object.fromEntries(more.map((k) => [k, v[k]])) };
209
+ }
210
+ export function heldDisabled(json, asked, secrets) {
211
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
212
+ return null;
213
+ const d = userOf(json.disabled, asked, ["disabledAt"]);
214
+ return d === null ? null : { disabled: d, already: json.already };
215
+ }
216
+ export function heldEnabled(json, asked, secrets) {
217
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
218
+ return null;
219
+ const e = userOf(json.enabled, asked, []);
220
+ return e === null ? null : { enabled: e, already: json.already };
221
+ }
222
+ export function heldRemoved(json, asked, secrets) {
223
+ if (!obj(json) || !carriesNoSecret(json, secrets))
224
+ return null;
225
+ const r = userOf(json.removed, asked, ["removedAt"]);
226
+ return r === null ? null : { removed: r };
227
+ }
228
+ // ------------------------------------------------------------------ the command
229
+ /** 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" };
231
+ /** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
232
+ export async function hostedUser(r, o) {
233
+ const outStyle = serviceStyle(styleOut());
234
+ const fail = (f) => failed("user", f, { json: o.json, style: serviceStyle(styleErr()) });
235
+ const service = serviceOf();
236
+ if (isFailure(service))
237
+ return fail(service);
238
+ const token = tokenFor(service, { asked: "a replica's users are asked for", again: `mason user ${o.verb}` });
239
+ if (isFailure(token))
240
+ return fail(token);
241
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts); add prints the
242
+ // password it made, so a password's shape is its answer's there
243
+ holdOutput({ command: "user", json: o.json, secrets: [token], passwords: o.verb !== "add" });
244
+ const secrets = [token];
245
+ const req = requestOf(r, o);
246
+ const a = await call(service, req.method, req.path, { token, ...(req.json !== undefined ? { json: req.json } : {}), ...(o.verb === "list" ? { maxBytes: MAX_LIST_BYTES } : {}) });
247
+ if (isFailure(a))
248
+ return fail(a);
249
+ const refused = (x) => serviceFailure(x, secrets, (status) => failureNext(status, r)) ?? unexpected(service, x, STEP[o.verb]);
250
+ if (a.status !== 200)
251
+ return fail(refused(a));
252
+ const outside = () => fail(unexpected(service, a, STEP[o.verb]));
253
+ const print = (value, next, text) => {
254
+ out(o.json ? jsonText(USER, value, next) : text());
255
+ return 0;
256
+ };
257
+ switch (o.verb) {
258
+ case "list": {
259
+ const l = heldList(a.json, secrets);
260
+ if (l === null)
261
+ return outside();
262
+ const next = onReplica(listNext(l.users), r);
263
+ return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
264
+ }
265
+ case "add": {
266
+ const added = heldAdded(a.json, o.name, secrets);
267
+ // the service answered 200: the replica may have made the user, whose password nobody has now been shown
268
+ if (added === null) {
269
+ const f = unexpected(service, a, STEP[o.verb]);
270
+ return fail({ ...f, why: `${f.why}; the replica may have added ${o.name} all the same, and its password was not shown`,
271
+ 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
+ next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
273
+ }
274
+ const next = onReplica(addedNext(added.user, true), r);
275
+ return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
276
+ }
277
+ case "disable": {
278
+ const d = heldDisabled(a.json, o.name, secrets);
279
+ if (d === null)
280
+ return outside();
281
+ const next = onReplica(disabledNext(d.disabled.name), r);
282
+ return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
283
+ }
284
+ case "enable": {
285
+ const e = heldEnabled(a.json, o.name, secrets);
286
+ if (e === null)
287
+ return outside();
288
+ const next = onReplica(enabledNext(e.enabled.name), r);
289
+ return print({ ...e }, next, () => enabledReport(e, next, outStyle));
290
+ }
291
+ case "remove": {
292
+ const x = heldRemoved(a.json, o.name, secrets);
293
+ if (x === null)
294
+ return outside();
295
+ const next = onReplica(REMOVED_NEXT, r);
296
+ return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
297
+ }
298
+ }
299
+ }
@@ -11,7 +11,7 @@
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, 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";
@@ -24,16 +24,18 @@ 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 list [--json]
34
- mason user disable <name> [--json]
35
- mason user enable <name> [--json]
36
- mason user remove <name> [--json]
34
+ usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--replica <org>/<name>] [--json]
35
+ mason user list [--replica <org>/<name>] [--json]
36
+ mason user disable <name> [--replica <org>/<name>] [--json]
37
+ mason user enable <name> [--replica <org>/<name>] [--json]
38
+ mason user remove <name> [--replica <org>/<name>] [--json]
37
39
 
38
40
  A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
39
41
  (Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
@@ -91,6 +93,14 @@ export const USER = {
91
93
  mason user enable metabase metabase connects again, with the same password
92
94
  mason user remove metabase metabase connects no more
93
95
  docker compose exec mason mason user add grafana beside mason serve, in its data folder
96
+ mason user add metabase --replica acme/shop a user of a replica on a hosted Mason service
97
+
98
+ --replica <org>/<name> manages the users of a replica on a hosted Mason service instead of this project's (else
99
+ MASON_REPLICA; each part lower case letters, digits and -): the same subcommands, --role and --set, the same
100
+ output and --json, the replica's Mason checking each as it does here. It needs no project here, and reads and writes
101
+ nothing of one. The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else
102
+ the default. A refusal is the service's words, each one line, its code the CLI's own by the HTTP status (not_found,
103
+ refused, failed: never usage), exit 1.
94
104
 
95
105
  <name> ${USER_NAME_RULE}
96
106
  --json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
@@ -106,7 +116,7 @@ export const USER = {
106
116
  before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
107
117
  id, addedAt, removedAt } }; next
108
118
  `,
109
- options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true } },
119
+ options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, replica: { type: "string" } },
110
120
  json: ["user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
111
121
  };
112
122
  /** The users of a project and why one is not live: added under an API key since rotated. */
@@ -194,6 +204,9 @@ export function attributesToAdd(name, role, sets, live) {
194
204
  return { values: out };
195
205
  }
196
206
  // ------------------------------------------------------------------ the role a user is added with
207
+ /** Why `role` is not a role's name (a usage failure's message), or null: asked by `roleToAdd`, and by `mason user add
208
+ * --role --replica` before a byte is sent. */
209
+ export const roleNameProblem = (role) => ROLE_NAME_RE.test(role) ? null : `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`;
197
210
  /**
198
211
  * 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
212
  * 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 +215,9 @@ export function attributesToAdd(name, role, sets, live) {
202
215
  * that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
203
216
  */
204
217
  export function roleToAdd(role, live) {
205
- if (!ROLE_NAME_RE.test(role))
206
- return { message: `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`, code: "usage" };
218
+ const named = roleNameProblem(role);
219
+ if (named !== null)
220
+ return { message: named, code: "usage" };
207
221
  const file = live.file ?? `mason/${ROLES_FILE}`;
208
222
  if (live.problem !== null) {
209
223
  return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
@@ -484,6 +498,91 @@ export function enableUser(file, name) {
484
498
  const u = found;
485
499
  return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
486
500
  }
501
+ /** What to run after a list. */
502
+ export const listNext = (users) => [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
503
+ ...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
504
+ ...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
505
+ /** `mason user list` for a person: the users of `of` (the project's name, or the replica's), a line each, then its tips
506
+ * and `next`. `roles`: the roles as a command beside them sees them (NO_ROLES where none can be read here). */
507
+ export function listText(of, l, roles, next, s = styleOut()) {
508
+ const { users, lastUsedNote, requestLogDays } = l;
509
+ if (!users.length)
510
+ return strict(withEnd(`${s.bold(`No users yet for ${of}.`)}`, { next }, s), s);
511
+ const active = users.filter((u) => u.removedAt === null);
512
+ const gone = users.filter((u) => u.removedAt !== null);
513
+ const width = Math.max(...users.map((u) => u.name.length));
514
+ const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
515
+ // the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
516
+ 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}`; };
517
+ 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)}`)}` +
518
+ (u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
519
+ : u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
520
+ (u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
521
+ (u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
522
+ (u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
523
+ const disabled = active.filter((u) => u.disabledAt !== null);
524
+ const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
525
+ const rotated = active.filter((u) => !u.live && u.disabledAt === null);
526
+ const orphan = active.find((u) => u.roleKnown === false);
527
+ const roleless = active.find((u) => u.readsNothing === true);
528
+ const unset = active.find((u) => u.missingAttributes !== undefined);
529
+ 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}.`] : []),
530
+ ...(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>.`] : []),
531
+ ...(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 })}.`] : []),
532
+ ...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
533
+ ...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
534
+ ...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }, s), s);
535
+ }
536
+ /** `mason user disable` for a person. `up`: Mason runs, so the sessions the user holds end at their next statement. */
537
+ export const disabledReport = (r, up, next, s = styleOut()) => {
538
+ const d = r.disabled;
539
+ return reportText({ headline: r.already ? `The user ${d.name} is disabled already: nothing changed` : `The user ${d.name} disabled: it connects no more, until enabled`,
540
+ details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]])],
541
+ tips: [disableEffect(d.name)], next }, s);
542
+ };
543
+ /** `mason user enable` for a person. */
544
+ export const enabledReport = (r, next, s = styleOut()) => {
545
+ const e = r.enabled;
546
+ return 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`,
547
+ details: [["user", e.name], ["added", e.addedAt]], next }, s);
548
+ };
549
+ /** `mason user remove` for a person. */
550
+ export const removedReport = (r, up, next, s = styleOut()) => {
551
+ const gone = r.removed;
552
+ return reportText({ headline: `The user ${gone.name} removed: it connects no more`,
553
+ details: [["user", gone.name], ["added", gone.addedAt], ["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]],
554
+ tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }, s);
555
+ };
556
+ /**
557
+ * `mason user add` for a person: the password, printed this once, in the url, psql and password rows (and nowhere else).
558
+ * The url, psql, host, port and ssl mode rows are there when the answer has them (Mason runs, so where it listens is
559
+ * known). `roles`: the roles as a command beside them sees them (the file's name in the role row; NO_ROLES where none
560
+ * can be read here); `listener`: how this run listens (its tips), null where it is not known here.
561
+ */
562
+ export function addedReport(r, o, s = styleOut()) {
563
+ const { roles } = o;
564
+ return reportText({
565
+ headline: `The user ${r.user} added${r.replaced === true ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
566
+ details: [
567
+ ...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
568
+ ["user", r.user], ["password", r.password], ["database", r.database],
569
+ ...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
570
+ ...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
571
+ : r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
572
+ : r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
573
+ ...(r.host !== undefined && r.port !== undefined ? [["host", r.host], ["port", String(r.port)], ...(r.sslmode !== undefined && r.sslmode !== "disable" ? [["ssl mode", r.sslmode]] : [])] : []),
574
+ ],
575
+ tips: [
576
+ "Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
577
+ ...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
578
+ ...(r.url === undefined ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
579
+ ...listenerTips(o.listener),
580
+ "It never expires: mason user remove ends it, at once and for every session it holds.",
581
+ "Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
582
+ ],
583
+ next: o.next,
584
+ }, s);
585
+ }
487
586
  // ------------------------------------------------------------------ the command
488
587
  export async function user(argv) {
489
588
  const f = parseCommand(USER, argv);
@@ -515,6 +614,17 @@ export async function user(argv) {
515
614
  if (problem !== null)
516
615
  return usage(problem);
517
616
  }
617
+ // A REPLICA ON A HOSTED MASON SERVICE (--replica, else MASON_REPLICA): its users, asked of the service with the same
618
+ // request the run's /api/users takes, printed by the renderers above; nothing of a project here is read or written
619
+ const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, USER.usage);
620
+ if (replica !== null && "message" in replica)
621
+ return failed("user", replica, { json });
622
+ if (replica !== null) {
623
+ const named = role === undefined ? null : roleNameProblem(role);
624
+ if (named !== null)
625
+ return usage(named);
626
+ return hostedUser(replica, { verb: verb, name, role, sets, json });
627
+ }
518
628
  const w = await reach(USER, json);
519
629
  if (w === null || w.project === null)
520
630
  return 1;
@@ -551,42 +661,12 @@ export async function user(argv) {
551
661
  }
552
662
  }
553
663
  const urlNote = up !== null ? null : NOT_RUNNING_URL;
554
- const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
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"]] : [])];
664
+ const next = listNext(users);
557
665
  if (json) {
558
666
  out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
559
667
  return 0;
560
668
  }
561
- const s = styleOut();
562
- if (!users.length) {
563
- out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
564
- return 0;
565
- }
566
- const active = users.filter((u) => u.removedAt === null);
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 }));
669
+ out(listText(project.config.name, { users, lastUsedNote, urlNote, requestLogDays }, roles, next));
590
670
  return 0;
591
671
  }
592
672
  if (verb === "disable") {
@@ -598,10 +678,7 @@ export async function user(argv) {
598
678
  out(jsonText(USER, r, next));
599
679
  return 0;
600
680
  }
601
- const d = r.disabled;
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 }));
681
+ out(disabledReport(r, w.state === "up", next));
605
682
  return 0;
606
683
  }
607
684
  if (verb === "enable") {
@@ -613,24 +690,19 @@ export async function user(argv) {
613
690
  out(jsonText(USER, r, next));
614
691
  return 0;
615
692
  }
616
- const e = r.enabled;
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 }));
693
+ out(enabledReport(r, next));
619
694
  return 0;
620
695
  }
621
696
  if (verb === "remove") {
622
697
  const r = removeUser(file, name);
623
698
  if ("message" in r)
624
699
  return failed("user", r, { json });
625
- const gone = r.removed;
626
700
  const next = REMOVED_NEXT;
627
701
  if (json) {
628
702
  out(jsonText(USER, r, next));
629
703
  return 0;
630
704
  }
631
- out(reportText({ headline: `The user ${gone.name} removed: it connects no more`,
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 }));
705
+ out(removedReport(r, w.state === "up", next));
634
706
  return 0;
635
707
  }
636
708
  // add
@@ -654,32 +726,12 @@ export async function user(argv) {
654
726
  const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
655
727
  if ("message" in r)
656
728
  return failed("user", r, { json });
657
- const { replaced, ...added } = r;
729
+ const { replaced: _, ...added } = r;
658
730
  const next = addedNext(r.user, link !== null);
659
731
  if (json) {
660
732
  out(jsonText(USER, added, next));
661
733
  return 0;
662
734
  }
663
- out(reportText({
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
- }));
735
+ out(addedReport(r, { roles, listener: run?.postgresListener ?? null, next }));
684
736
  return 0;
685
737
  }