@mason-data/cli 0.7.0 → 0.8.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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `mason user add | list | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
2
+ * `mason user add | list | disable | enable | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
3
3
  * (docs/DESIGN.md "On a server", item 1 of the agreed plan; src/duck/users.ts holds the rules). A BI
4
4
  * tool's datasource stores ONE password: a user's lasts until it is removed.
5
5
  *
@@ -8,53 +8,301 @@
8
8
  * the file's lock, and the run's listener reads the change at its next connection or statement. The password is said
9
9
  * ONCE, by `add`, with the URL to paste (one URL builder, pg-url.ts `connectionUrl`) when the run is up and recorded
10
10
  * where it listens. The web page's users (`mason ui`) are the same functions (`addUser`, `listUsers`, `removeUser`),
11
- * called by the run's command server.
11
+ * called by the run's command server; so are `disableUser` and `enableUser`.
12
12
  */
13
13
  import * as path from "node:path";
14
14
  import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
15
15
  import { reach } from "./state.js";
16
+ import { callRun, RunUnreachable } from "./control.js";
16
17
  import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
17
- import { changeUsers, 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, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
18
19
  import { didYouMean } from "../lib/did-you-mean.js";
19
20
  import { out as styleOut } from "./style.js";
21
+ import { NO_ROLES } from "../duck/grants.js";
22
+ import { exampleRole, missingAttributesText, noRoleWayOut, problemText, rolesProblemText, readRoles, ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE, roleProblems, rolesFailure, rolesFile, userRole, usersMissingAttributes } from "./roles.js";
23
+ import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, whereAttributes, whereParts } from "../duck/row-filter.js";
24
+ import { readSource, shownPath } from "./sources.js";
25
+ import { readState } from "./commands.js";
26
+ import { projectDatabase } from "./project.js";
20
27
  const out = (m) => { process.stdout.write(`${m}\n`); };
21
28
  export const USER = {
22
29
  needs: "project",
23
30
  name: "user",
24
31
  summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
25
- usage: `usage: mason user add <name> [--json]
32
+ usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--json]
26
33
  mason user list [--json]
34
+ mason user disable <name> [--json]
35
+ mason user enable <name> [--json]
27
36
  mason user remove <name> [--json]
28
37
 
29
38
  A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
30
39
  (Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
31
40
  it connects until mason user remove, which refuses its next connection at once and
32
41
  ends the sessions it holds at their next statement, without a restart. The password is printed once, by add.
33
- The user name a tool sends must be the user's own.
42
+ The user name a tool sends must be the user's own. A user disabled is kept with its password: its logins are
43
+ refused (as Postgres refuses a role without LOGIN) and its sessions end at their next statement, until enable.
34
44
 
35
45
  The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
46
+ A removed user stays in it, marked removed, so the list shows who was removed and when; its name can be added again.
36
47
  Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
37
48
 
49
+ --role <role> limits what the user reads to a role of mason/roles.yml (beside the sources file): the tables, and
50
+ the columns of each, it may read. A statement over anything else is refused as Postgres refuses it (42501,
51
+ permission denied for table …), information_schema and mason mcp list the role only what it may read, and the
52
+ role is read with the user at every statement, so an edit of the file applies at the next one. A user added
53
+ without --role reads as the file's default: role; under a file with no default: it reads nothing; with no
54
+ roles file at all, every synced table. mason init writes the file:
55
+
56
+ default: everything the role of a user added without --role
57
+ roles:
58
+ everything:
59
+ tables: all every synced table and column, those added later included
60
+ agent: the role's name (${ROLE_NAME_RULE})
61
+ tables:
62
+ orders: {} every column of a synced table
63
+ customers:
64
+ columns: [id, country] these columns only
65
+ billing.invoices:
66
+ exclude: [notes] every column but these
67
+ tenant:
68
+ attributes: [tenant] what each of its users carries: --set tenant=42
69
+ tables:
70
+ orders: { where: "tenant_id = :tenant" } the rows it reads: a Postgres condition
71
+ order_items: { through: orders } the rows whose order it reads
72
+ region:
73
+ attributes: { regions: list } several values: --set regions=eu --set regions=us
74
+ tables:
75
+ customers: { where: "region = ANY(:regions)" }
76
+
77
+ --set <attribute>=<value> gives the user its value of an attribute its role declares (repeat --set for each
78
+ attribute; a list attribute takes one --set per value; an empty list cannot be written). A role's where:
79
+ reads it as :attribute, bound as a literal of the type Postgres infers for it there (tenant_id = :tenant: the
80
+ column's), so every read of the table — through a view, a join, a subquery, a prepared statement — reads only
81
+ its rows, as Postgres's row security reads them. A user without a value its role reads is refused at every
82
+ statement, never answered with no rows: add it again with the --set. Postgres refuses a where: it cannot read
83
+ when the running Mason reads the file, at its key.
84
+
38
85
  mason user add metabase a user for Metabase: its password and the URL to paste
86
+ mason user add agent --role agent a user held to the role agent
87
+ mason user add acme --role tenant --set tenant=42 the rows of tenant 42
88
+ mason user add emea --role region --set regions=eu --set regions=uk
39
89
  mason user list
90
+ mason user disable metabase metabase connects no more, until mason user enable metabase
91
+ mason user enable metabase metabase connects again, with the same password
40
92
  mason user remove metabase metabase connects no more
41
93
  docker compose exec mason mason user add grafana beside mason serve, in its data folder
42
94
 
43
95
  <name> ${USER_NAME_RULE}
44
- --json as data: add { user (the name), id, addedAt, password, database, and once Mason runs url, host, port,
45
- sslmode }; list { users: [{ name, id, addedAt, live }] }; remove { removed: { name, id, addedAt } }; next
96
+ --json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
97
+ 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;
98
+ none), readsNothing (true: no role under a file with no default), and once Mason runs url, psql (the
99
+ psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
100
+ not removed), disabledAt (null for a user not disabled), live (connects now), role?, attributes?,
101
+ missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
102
+ roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
103
+ requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
104
+ lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
105
+ (null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
106
+ before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
107
+ id, addedAt, removedAt } }; next
46
108
  `,
47
- options: { json: { type: "boolean", default: false } },
48
- json: ["user", "id", "addedAt", "password", "url", "host", "port", "database", "sslmode", "users", "removed", "next"],
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"],
49
111
  };
50
112
  /** The users of a project and why one is not live: added under an API key since rotated. */
51
113
  const liveOf = (u, apiKey) => underKey(u, apiKey);
114
+ export const NOT_RUNNING_LAST_USED = "unknown: Mason is not running, and the request log is only read by a running Mason (mason start)";
115
+ export const NOT_RUNNING_URL = "unknown: Mason is not running, so where its Postgres API listens is not known (mason start, then mason status)";
116
+ /** A time as `mason user list` stamps it: `2026-10-27 09:10`, UTC. */
117
+ const stamp = (iso) => iso.slice(0, 16).replace("T", " ");
118
+ /**
119
+ * WHEN A USER WAS LAST USED, as `mason user list` and the web page's Users page (src/web/pages/users.js, held equal by
120
+ * tests/web-page.test.ts) say it: its time, "unknown" when the note says why it cannot be known, "never" when it was
121
+ * added within the days the request log keeps (so the log holds its whole life: a user just added was "not used in the
122
+ * last 7 days", which reads as if it once was), else not used within those days. `now` is the clock, in ms.
123
+ */
124
+ export function lastUsedText(lastUsedAt, lastUsedNote, requestLogDays, addedAt = null, now = Date.now()) {
125
+ if (lastUsedAt !== null)
126
+ return `${stamp(lastUsedAt)} UTC`;
127
+ if (lastUsedNote !== null)
128
+ return "unknown";
129
+ if (requestLogDays !== null && addedAt !== null && now - Date.parse(addedAt) < requestLogDays * 86_400_000)
130
+ return "never";
131
+ return `not used in the last ${requestLogDays ?? "?"} days`;
132
+ }
133
+ // ------------------------------------------------------------------ the attributes a user is added with
134
+ /** `--set name=value` read: the name and the value (everything after the first `=`, a comma included); or the problem */
135
+ export function setOf(arg) {
136
+ const eq = arg.indexOf("=");
137
+ if (eq < 0)
138
+ return `--set ${arg}: is <attribute>=<value>, like --set tenant=42`;
139
+ const name = arg.slice(0, eq);
140
+ if (!ATTRIBUTE_NAME_RE.test(name))
141
+ return `--set ${arg}: ${JSON.stringify(name)} is not an attribute's name: ${ATTRIBUTE_NAME_RULE}`;
142
+ // through /api/users' JSON: a shell cannot pass one
143
+ if (arg.includes("\0"))
144
+ return `--set ${name}: a value cannot hold a NUL byte — it would end every statement the value is bound into`;
145
+ return { name, value: arg.slice(eq + 1) };
146
+ }
147
+ /**
148
+ * THE ATTRIBUTE VALUES A USER IS ADDED WITH, held to its role (`role`: its own, or the file's default) as the roles are
149
+ * now (`live`): every attribute the role declares set — a missing one refused with the where: that reads it and the exact
150
+ * `--set` to add —, none it does not declare (the closest it does), a scalar once, a list once per value and never
151
+ * empty. Checked BEFORE the user is written, by `mason user add` and the run's `/api/users` alike (one grammar: the
152
+ * `--set` strings). A scalar is a string, a list an array of strings, in the order given.
153
+ */
154
+ export function attributesToAdd(name, role, sets, live) {
155
+ const g = role === undefined ? undefined : live.grants.get(role);
156
+ const declared = g?.attributes ?? new Map();
157
+ const usage = (message, fix) => ({ refused: { message, code: "usage", ...(fix !== undefined ? { fix } : {}) } });
158
+ const out = {};
159
+ for (const arg of sets) {
160
+ const s = setOf(arg);
161
+ if (typeof s === "string")
162
+ return usage(s);
163
+ const shape = declared.get(s.name);
164
+ if (shape === undefined) {
165
+ if (role === undefined)
166
+ return usage(`--set ${s.name}: the user has no role, so it carries no attribute`, `mason user add ${name} --role <a role that declares ${s.name}> --set ${arg}`);
167
+ const names = [...declared.keys()];
168
+ return usage(`--set ${s.name}: the role ${role} has no attribute ${s.name}${didYouMean(s.name, names)}`, names.length ? `its attributes: ${names.join(", ")}` : `the role ${role} declares no attributes (attributes: [${s.name}] beside its tables: in ${live.file ?? `mason/${ROLES_FILE}`})`);
169
+ }
170
+ if (shape === "list") {
171
+ if (s.value === "")
172
+ return usage(`--set ${s.name}=: ${s.name} is a list, and a list's value is never empty — an empty list is not written (Mason refuses a user without its values rather than answer no rows)`);
173
+ out[s.name] = [...(out[s.name] ?? []), s.value];
174
+ }
175
+ else {
176
+ if (out[s.name] !== undefined) {
177
+ const lists = [...declared].filter(([, k]) => k === "list").map(([n]) => n);
178
+ return usage(`--set ${s.name} twice: ${s.name} is a scalar of the role ${role}, one value${didYouMean(s.name, lists)}`, `one --set ${s.name}=<value>; several values are a list attribute (attributes: { ${s.name}: list })`);
179
+ }
180
+ out[s.name] = s.value;
181
+ }
182
+ }
183
+ const missing = [...declared.keys()].filter((a) => out[a] === undefined);
184
+ if (missing.length > 0) {
185
+ const reads = (a) => {
186
+ const f = [...(g?.filters?.values() ?? [])].find((x) => x.where !== undefined && whereAttributes(x.where.parts).includes(a));
187
+ return f === undefined ? "" : ` (${f.at}.where: ${f.where.text})`;
188
+ };
189
+ const sets2 = missing.map((a) => declared.get(a) === "list" ? `--set ${a}=<value> --set ${a}=<another>` : `--set ${a}=<value>`).join(" ");
190
+ return { refused: { message: `the role ${role} needs ${missing.map((a) => `--set ${a}=…`).join(" ")}: its users carry ${missing.length === 1 ? "the attribute" : "the attributes"} ${missing.map((a) => `${a}${reads(a)}`).join(", ")}`,
191
+ code: "usage", why: "a user without a value its role's rows are filtered by would be refused at every statement (Mason never answers it with no rows)",
192
+ fix: `mason user add ${name}${role === undefined ? "" : ` --role ${role}`} ${[...sets.map((x) => `--set ${x}`), sets2].join(" ")}` } };
193
+ }
194
+ return { values: out };
195
+ }
196
+ // ------------------------------------------------------------------ the role a user is added with
197
+ /**
198
+ * 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
+ * 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
200
+ * all (the lines to add), a role the file does not name (the closest it does). Null: the role is in the file. Checked
201
+ * BEFORE the user is written, by `mason user add` and the run's `/api/users` alike, so a user never carries a role
202
+ * that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
203
+ */
204
+ 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" };
207
+ const file = live.file ?? `mason/${ROLES_FILE}`;
208
+ if (live.problem !== null) {
209
+ return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
210
+ why: "a user's role is checked against the file before the user is written, and every statement of a user with a role is refused while the file is wrong",
211
+ fix: `repair ${file}; mason user add ${"<name>"} --role ${role} then`, next: [["mason user list", "the users, and whose role the file lacks"]] };
212
+ }
213
+ const names = [...live.grants.keys()];
214
+ if (names.length === 0) {
215
+ return { message: `there is no role ${role}: this project has no roles file`, code: "not_found",
216
+ why: `a role is a key of ${file}, beside the sources file, naming the tables (and the columns of each) its users may read`,
217
+ fix: `write ${file}:
218
+ roles:
219
+ ${role}:
220
+ tables:
221
+ <a synced table>: {}
222
+ then mason user add <name> --role ${role}`,
223
+ next: [["mason tables", "the tables the sources file syncs: what a role may name"]] };
224
+ }
225
+ if (!live.grants.has(role)) {
226
+ return { message: `there is no role ${role} in ${file}${didYouMean(role, names)}`, code: "not_found",
227
+ why: `the roles: ${names.join(", ")}`,
228
+ fix: `add it to ${file}:
229
+ roles:
230
+ ${role}:
231
+ tables:
232
+ <a synced table>: {}
233
+ or mason user add <name> --role ${names[0]}`,
234
+ next: [["mason user list", "the users and their roles"]] };
235
+ }
236
+ return null;
237
+ }
238
+ /**
239
+ * THE ROLES AS A COMMAND BESIDE THE RUN SEES THEM: the listener's own while the run is up (`/api/state`'s roles: what a
240
+ * session is held to this moment), else the file read here and its tables held to the sources file's entries (their
241
+ * columns are the database's, which only the run knows: a column the copy lacks is refused by mason start). One
242
+ * object of src/duck/grants.ts's shape, whose grants here carry the names only.
243
+ */
244
+ export async function rolesBeside(project, run) {
245
+ if (run !== null) {
246
+ let r;
247
+ // a run that does not answer its state (hung, or an older run without /api/state's roles): the file is read here
248
+ // instead, as with Mason stopped — the user is still refused a role the file lacks, which is what this guards
249
+ try {
250
+ r = (await readState(run)).roles;
251
+ }
252
+ catch (e) {
253
+ if (!(e instanceof RunUnreachable))
254
+ throw e;
255
+ r = undefined;
256
+ }
257
+ if (r !== undefined)
258
+ return { file: r.file, grants: new Map(r.roles.map((n) => [n, { name: n, file: r.file ?? "", tables: new Map(), all: r.all?.includes(n) === true, ...rowsOfState(r, n) }])), problem: r.problem, default: r.default ?? null };
259
+ }
260
+ const read = readRoles(project.folder, project.root);
261
+ if (read === null)
262
+ return NO_ROLES;
263
+ const file = shownPath(project.root, rolesFile(project.folder));
264
+ if ("message" in read)
265
+ return { file, grants: new Map(), problem: problemText(read), default: null };
266
+ const src = readSource(project.folder, project.root);
267
+ // the sources file unreadable here: the role's tables are held to it by mason start; the names are checked
268
+ const copied = "message" in src ? null : src.tables.map((e) => ({ source: e.table, columns: e.columns === null ? null : e.columns.map((name) => ({ name })) }));
269
+ const problems = copied === null ? [] : roleProblems(read, copied);
270
+ if (problems.length)
271
+ return { file, grants: new Map(), problem: problemText(rolesFailure(read.file, project.root, problems)), default: read.default };
272
+ return { file, grants: new Map(read.roles.map((r) => {
273
+ const filters = new Map(r.tables === "all" ? [] : r.tables.flatMap((t) => t.where === undefined ? [] : [[t.table, { table: t.table, at: `roles.${r.name}.tables.${t.name}`, where: t.where }]]));
274
+ return [r.name, { name: r.name, file, tables: new Map(), all: r.tables === "all", ...(r.attributes !== undefined ? { attributes: r.attributes } : {}), ...(filters.size > 0 ? { filters } : {}) }];
275
+ })), problem: null, default: read.default };
276
+ }
277
+ /** a role's attributes and its where:s as `/api/state` says them, as the grants' fields `attributesToAdd` reads */
278
+ function rowsOfState(r, role) {
279
+ const attrs = r.attributes?.[role];
280
+ const filters = new Map();
281
+ for (const f of r.wheres?.[role] ?? []) {
282
+ const parts = whereParts(f.where);
283
+ if (!("problem" in parts))
284
+ filters.set(f.table, { table: f.table, at: f.at, where: { text: f.where, parts } });
285
+ }
286
+ return { ...(attrs !== undefined && Object.keys(attrs).length > 0 ? { attributes: new Map(Object.entries(attrs)) } : {}), ...(filters.size > 0 ? { filters } : {}) };
287
+ }
288
+ /** a user's attributes as `mason user add` and `mason user list` say them: `tenant=42, regions=[eu, uk]` */
289
+ export const attributesText = (a) => Object.entries(a).map(([k, v]) => `${k}=${typeof v === "string" ? v : `[${v.join(", ")}]`}`).join(", ");
52
290
  /** What to run after an add: with Mason up, the list and how to end it; with Mason stopped, mason start first. */
53
291
  export const addedNext = (name, up) => up
54
292
  ? [["mason user list", "the users"], [`mason user remove ${name}`, "it connects no more"]]
55
293
  : [["mason start", "start Mason: then mason status prints where the Postgres API listens"], ["mason user list", "the users"]];
56
294
  /** What to run after a remove. */
57
- export const REMOVED_NEXT = [["mason user list", "the users left"], ["mason user add <name>", "a new user, with a new password"]];
295
+ export const REMOVED_NEXT = [["mason user list", "the users, removed ones included"], ["mason user add <name>", "a new user, with a new password"]];
296
+ /** What to run after a disable, and after an enable. */
297
+ export const disabledNext = (name) => [[`mason user enable ${name}`, "it connects again, with the same password"], ["mason user list", "the users"]];
298
+ export const enabledNext = (name) => [["mason user list", "the users"], [`mason user disable ${name}`, "it connects no more, until enabled"]];
299
+ /**
300
+ * WHAT A DISABLE DOES, in the words `mason user disable` prints and the web page's Disable asks with (src/web/pages/users.js
301
+ * `disableEffect`, held equal by tests/web-page.test.ts).
302
+ */
303
+ export const disableEffect = (name) => `It connects no more until mason user enable ${name}, with the same password, and the sessions it holds end at their next statement.`;
304
+ /** A disabled user's state, as `mason user list` and the web page's Users page say it (held equal by the tests). */
305
+ export const disabledText = (disabledAt) => `Disabled ${stamp(disabledAt)} UTC`;
58
306
  /** Why `name` is not one `mason user <verb>` takes (a usage failure), or null. */
59
307
  export function userNameProblem(verb, name) {
60
308
  if (name === undefined)
@@ -65,13 +313,43 @@ export function userNameProblem(verb, name) {
65
313
  }
66
314
  const cantChange = (file, e) => ({ message: `the list of users could not be changed: ${e.message}`, code: "failed",
67
315
  fix: `${file} is the list (mason user writes it): repair it, or move it aside, which removes every user` });
68
- /** The users `file` lists, each with whether it connects under `apiKey`; or why the file cannot be read. */
69
- export function listUsers(file, apiKey) {
316
+ /**
317
+ * The users `file` lists, removed ones too, each with whether it connects under `apiKey` and — `link`, where the run's
318
+ * Postgres API listens, when it is known — the URL to paste without the password, and with a role whether `roles` names it now; or why the file cannot be read.
319
+ * `lastUsedAt` is null here: `withLastUsed` fills it from the request log, which only the run has.
320
+ */
321
+ export function listUsers(file, apiKey, link, roles = NO_ROLES) {
70
322
  const r = readUsers(file);
71
323
  if ("problem" in r)
72
324
  return { message: `the list of users cannot be read: ${r.problem}`, code: "bad_config", why: "no user connects while it cannot be read",
73
325
  fix: `repair ${file}, or move it aside, which removes every user` };
74
- return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, live: liveOf(u, apiKey) })) };
326
+ // roleKnown only where the file can be read: under a problem the listener refuses every user held to it for the FILE
327
+ 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),
329
+ lastUsedAt: null, url: link !== null && isActive(u) ? connectionUrl(link, u.name, null) : null,
330
+ ...roleFields(u, roles), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}),
331
+ ...((m) => m === undefined ? {} : { missingAttributes: m.missing })(usersMissingAttributes([u], roles)[0]),
332
+ ...(u.role !== undefined && roles.problem === null ? { roleKnown: roles.grants.has(u.role) } : {}) })) };
333
+ }
334
+ /** a user's role as the JSON says it: the role it reads as, where from, and whether it reads nothing (src/cli/roles.ts userRole) */
335
+ function roleFields(u, roles) {
336
+ const r = userRole(u, roles);
337
+ return { ...(r.role !== undefined ? { role: r.role } : {}), roleFrom: r.roleFrom, ...(r.readsNothing ? { readsNothing: true } : {}) };
338
+ }
339
+ /**
340
+ * The rows with their last use from the request log (`lastUsed`, src/duck/requests.ts: within the log's retention, in
341
+ * the window each user was a user in, so a name added again is not credited with the earlier user's statements).
342
+ * A log that cannot be read is SAID in the note, never a guess.
343
+ */
344
+ export async function withLastUsed(rows, requests, log) {
345
+ try {
346
+ const used = await requests.lastUsed(rows.map((u) => ({ name: u.name, since: u.addedAt, until: u.removedAt })));
347
+ return { users: rows.map((u, i) => ({ ...u, lastUsedAt: used[i] ?? null })), lastUsedNote: null, requestLogDays: requests.days };
348
+ }
349
+ catch (e) {
350
+ log(`mason user list: the request log could not be read for the users' last use: ${e.message}`);
351
+ return { users: [...rows], lastUsedNote: `unknown: the request log could not be read (${e.message})`, requestLogDays: requests.days };
352
+ }
75
353
  }
76
354
  /**
77
355
  * ADD A USER to `file` under its lock: a new id, its password (said here, once, and kept nowhere), and — `link`, where
@@ -87,13 +365,15 @@ export function addUser(file, apiKey, name, o) {
87
365
  let r;
88
366
  try {
89
367
  r = changeUsers(file, (users) => {
90
- const was = users.find((u) => u.name === name);
91
- // a user added under a key since rotated connects no more: its name is free, said
368
+ // a removed user's name is free; so is a live one's added under a key since rotated (it connects no more), said
369
+ const was = users.find((u) => u.name === name && isActive(u));
92
370
  if (was !== undefined && was.key === fingerprint)
93
371
  return { refused: "taken" };
94
372
  replaced = was !== undefined;
95
- added = { name, id: newUserId(), addedAt: new Date().toISOString(), key: fingerprint };
96
- return [...users.filter((u) => u !== was), added];
373
+ const now = new Date().toISOString();
374
+ 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 } : {}) };
376
+ return [...users.map((u) => u === was ? { ...u, removedAt: now } : u), added];
97
377
  });
98
378
  }
99
379
  catch (e) {
@@ -109,7 +389,8 @@ export function addUser(file, apiKey, name, o) {
109
389
  const password = userPassword(apiKey, u.name, u.id);
110
390
  const { link } = o;
111
391
  return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
112
- ...(link !== null ? { url: connectionUrl(link, u.name, password), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}), replaced };
392
+ ...(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 };
113
394
  }
114
395
  /** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
115
396
  export function removeUser(file, name) {
@@ -117,24 +398,91 @@ export function removeUser(file, name) {
117
398
  if (usage !== null)
118
399
  return { message: usage, code: "usage" };
119
400
  let removed;
401
+ let removedAt = "";
120
402
  let live = [];
121
403
  let r;
122
404
  try {
123
405
  r = changeUsers(file, (users) => {
124
- removed = users.find((u) => u.name === name);
125
- live = users.map((u) => u.name);
126
- return removed === undefined ? { refused: "unknown" } : users.filter((u) => u !== removed);
406
+ removed = users.find((u) => u.name === name && isActive(u));
407
+ live = users.filter(isActive).map((u) => u.name);
408
+ // the entry stays, marked: the list shows who was removed and when; the listener reads only the live ones
409
+ removedAt = new Date().toISOString();
410
+ return removed === undefined ? { refused: "unknown" } : users.map((u) => u === removed ? { ...u, removedAt } : u);
127
411
  });
128
412
  }
129
413
  catch (e) {
130
414
  return cantChange(file, e);
131
415
  }
132
- if (!Array.isArray(r)) {
133
- return { message: `there is no user ${JSON.stringify(name)}${didYouMean(name, live)}`, code: "not_found",
134
- why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] };
135
- }
416
+ if (!Array.isArray(r))
417
+ return noSuchUser(name, live);
136
418
  const gone = removed;
137
- return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt } };
419
+ return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt, removedAt } };
420
+ }
421
+ /** No user `name` among those not removed: the closest name they have, and who they are. */
422
+ const noSuchUser = (name, live) => ({ message: `there is no user ${JSON.stringify(name)}${didYouMean(name, live)}`, code: "not_found",
423
+ why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] });
424
+ /**
425
+ * DISABLE A USER in `file` under its lock: kept, its id (so its password) unchanged, marked `disabledAt`; the listener
426
+ * refuses its logins and ends its sessions at their next statement. One already disabled is said so (`already`), its
427
+ * first time kept, nothing written: a no-op is no failure (as `mason add` of a table already added).
428
+ */
429
+ export function disableUser(file, name) {
430
+ const usage = userNameProblem("disable", name);
431
+ if (usage !== null)
432
+ return { message: usage, code: "usage" };
433
+ let found;
434
+ let disabledAt = "";
435
+ let live = [];
436
+ let r;
437
+ try {
438
+ r = changeUsers(file, (users) => {
439
+ found = users.find((u) => u.name === name && isActive(u));
440
+ live = users.filter(isActive).map((u) => u.name);
441
+ if (found === undefined)
442
+ return { refused: "unknown" };
443
+ if (isDisabled(found)) {
444
+ disabledAt = found.disabledAt;
445
+ return { refused: "already" };
446
+ }
447
+ disabledAt = new Date().toISOString();
448
+ return users.map((u) => u === found ? { ...u, disabledAt } : u);
449
+ });
450
+ }
451
+ catch (e) {
452
+ return cantChange(file, e);
453
+ }
454
+ if (found === undefined)
455
+ return noSuchUser(name, live);
456
+ const u = found;
457
+ return { disabled: { name: u.name, id: u.id, addedAt: u.addedAt, disabledAt }, already: !Array.isArray(r) };
458
+ }
459
+ /** ENABLE A DISABLED USER in `file` under its lock: it connects again, with the same password. One not disabled is said so. */
460
+ export function enableUser(file, name) {
461
+ const usage = userNameProblem("enable", name);
462
+ if (usage !== null)
463
+ return { message: usage, code: "usage" };
464
+ let found;
465
+ let live = [];
466
+ let r;
467
+ try {
468
+ r = changeUsers(file, (users) => {
469
+ found = users.find((u) => u.name === name && isActive(u));
470
+ live = users.filter(isActive).map((u) => u.name);
471
+ if (found === undefined)
472
+ return { refused: "unknown" };
473
+ if (!isDisabled(found))
474
+ return { refused: "already" };
475
+ return users.map((u) => { if (u !== found)
476
+ return u; const { disabledAt: _, ...rest } = u; return rest; });
477
+ });
478
+ }
479
+ catch (e) {
480
+ return cantChange(file, e);
481
+ }
482
+ if (found === undefined)
483
+ return noSuchUser(name, live);
484
+ const u = found;
485
+ return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
138
486
  }
139
487
  // ------------------------------------------------------------------ the command
140
488
  export async function user(argv) {
@@ -144,12 +492,24 @@ export async function user(argv) {
144
492
  const json = f.values.json === true;
145
493
  const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
146
494
  const [verb, name, ...rest] = f.positionals;
495
+ const VERBS = ["add", "list", "disable", "enable", "remove"];
147
496
  if (verb === undefined)
148
- return usage("add, list or remove: mason user add <name> makes a user");
149
- if (!["add", "list", "remove"].includes(verb))
150
- return usage(`there is no mason user ${verb}: add, list or remove${didYouMean(verb, ["add", "list", "remove"])}`);
497
+ return usage("add, list, disable, enable or remove: mason user add <name> makes a user");
498
+ if (!VERBS.includes(verb))
499
+ return usage(`there is no mason user ${verb}: add, list, disable, enable or remove${didYouMean(verb, VERBS)}`);
151
500
  if (verb === "list" ? name !== undefined : rest.length)
152
501
  return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
502
+ const role = typeof f.values.role === "string" ? f.values.role : undefined;
503
+ if (role !== undefined && verb !== "add")
504
+ return usage(`--role goes with mason user add: mason user add <name> --role ${role}`);
505
+ const sets = Array.isArray(f.values.set) ? f.values.set.filter((x) => typeof x === "string") : [];
506
+ if (sets.length > 0 && verb !== "add")
507
+ return usage(`--set goes with mason user add: mason user add <name> --role <role> --set ${sets[0]}`);
508
+ for (const arg of sets) {
509
+ const s = setOf(arg);
510
+ if (typeof s === "string")
511
+ return usage(s);
512
+ }
153
513
  if (verb !== "list") {
154
514
  const problem = userNameProblem(verb, name);
155
515
  if (problem !== null)
@@ -162,24 +522,100 @@ export async function user(argv) {
162
522
  const file = path.join(project.dir, USERS_FILE);
163
523
  const apiKey = project.config.apiKey;
164
524
  if (verb === "list") {
165
- const r = listUsers(file, apiKey);
525
+ const up = w.state === "up" ? w.run : null;
526
+ const roles = await rolesBeside(project, up);
527
+ const r = listUsers(file, apiKey, up !== null ? postgresLink(up.postgresUrl) : null, roles);
166
528
  if ("message" in r)
167
529
  return failed("user", r, { json });
168
- const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"], ...(r.users.length ? [["mason user remove <name>", "that user connects no more"]] : [])];
530
+ // the last use is the run's (its request log is in the copy's file, which only the run opens): asked of its command
531
+ // server, which lists the same file. A run that cannot say is SAID, never guessed.
532
+ let users = r.users;
533
+ let lastUsedNote = NOT_RUNNING_LAST_USED;
534
+ let requestLogDays = null;
535
+ if (up !== null) {
536
+ try {
537
+ const asked = await callRun(up, "GET", "/api/users?lastUsed=1");
538
+ if (asked.status === 200 && Array.isArray(asked.body.users)) {
539
+ const used = new Map(asked.body.users.map((u) => [u.id, u.lastUsedAt]));
540
+ users = users.map((u) => ({ ...u, lastUsedAt: used.get(u.id) ?? null }));
541
+ lastUsedNote = typeof asked.body.lastUsedNote === "string" ? asked.body.lastUsedNote : null;
542
+ requestLogDays = typeof asked.body.requestLogDays === "number" ? asked.body.requestLogDays : null;
543
+ }
544
+ else
545
+ lastUsedNote = `unknown: Mason answered ${asked.status}${typeof asked.body.error === "string" ? `: ${asked.body.error}` : ""}`;
546
+ }
547
+ catch (e) {
548
+ if (!(e instanceof RunUnreachable))
549
+ throw e;
550
+ lastUsedNote = `unknown: ${e.message}`;
551
+ }
552
+ }
553
+ 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"]] : [])];
169
557
  if (json) {
170
- out(jsonText(USER, { users: r.users }, next));
558
+ out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
171
559
  return 0;
172
560
  }
173
561
  const s = styleOut();
174
- if (!r.users.length) {
562
+ if (!users.length) {
175
563
  out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
176
564
  return 0;
177
565
  }
178
- const width = Math.max(...r.users.map((u) => u.name.length));
179
- const lines = r.users.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${s.dim(`id ${u.id} · added ${u.addedAt.slice(0, 16).replace("T", " ")} UTC`)}` +
180
- (u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`));
181
- const rotated = r.users.filter((u) => !u.live);
182
- out(withEnd(`${s.bold(`${r.users.length} user${r.users.length === 1 ? "" : "s"} of ${project.config.name}`)}\n${lines.join("\n")}`, { ...(rotated.length ? { tips: [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] } : {}), next }));
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 }));
590
+ return 0;
591
+ }
592
+ if (verb === "disable") {
593
+ const r = disableUser(file, name);
594
+ if ("message" in r)
595
+ return failed("user", r, { json });
596
+ const next = disabledNext(r.disabled.name);
597
+ if (json) {
598
+ out(jsonText(USER, r, next));
599
+ return 0;
600
+ }
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 }));
605
+ return 0;
606
+ }
607
+ if (verb === "enable") {
608
+ const r = enableUser(file, name);
609
+ if ("message" in r)
610
+ return failed("user", r, { json });
611
+ const next = enabledNext(r.enabled.name);
612
+ if (json) {
613
+ out(jsonText(USER, r, next));
614
+ return 0;
615
+ }
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 }));
183
619
  return 0;
184
620
  }
185
621
  if (verb === "remove") {
@@ -200,7 +636,22 @@ export async function user(argv) {
200
636
  // add
201
637
  const run = w.state === "up" ? w.run : null;
202
638
  const link = run ? postgresLink(run.postgresUrl) : null;
203
- const r = addUser(file, apiKey, name, { link, database: project.config.name });
639
+ // the role must be one the roles file names NOW (the listener's reading while the run is up): refused before writing
640
+ const roles = await rolesBeside(project, run);
641
+ if (role !== undefined) {
642
+ const problem = roleToAdd(role, roles);
643
+ if (problem !== null)
644
+ return failed("user", problem, { json });
645
+ }
646
+ // the name it connects with: the run's, or with Mason stopped what the project says it will be
647
+ const database = link?.database ?? projectDatabase(project);
648
+ if (typeof database !== "string")
649
+ return failed("user", database, { json });
650
+ // the attributes the role declares, every one, and none other: refused before writing, as the role is
651
+ const attributes = attributesToAdd(name, role ?? userRole({}, roles).role, sets, roles);
652
+ if ("refused" in attributes)
653
+ return failed("user", attributes.refused, { json });
654
+ const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
204
655
  if ("message" in r)
205
656
  return failed("user", r, { json });
206
657
  const { replaced, ...added } = r;
@@ -212,12 +663,17 @@ export async function user(argv) {
212
663
  out(reportText({
213
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`,
214
665
  details: [
215
- ...(r.url !== undefined ? [["url", r.url], ["psql", psqlLine(r.url)]] : []),
666
+ ...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
216
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`]] : []),
217
672
  ...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
218
673
  ],
219
674
  tips: [
220
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))}.`] : []),
221
677
  ...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
222
678
  ...listenerTips(run?.postgresListener ?? null),
223
679
  "It never expires: mason user remove ends it, at once and for every session it holds.",