@mason-data/cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -0,0 +1,364 @@
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?, from? } mason user add --json's shape (the run is up: its url)
8
+ * from POST …/users/{user}/from { from } { changed, already }: its ranges replaced (null: any address)
9
+ * disable POST …/users/{user}/disable { disabled, already }
10
+ * enable POST …/users/{user}/enable { enabled, already }
11
+ * remove DELETE …/users/{user} { removed }
12
+ *
13
+ * `--role` and `--set` go as they were given: Mason on the replica checks them with the functions it checks them with
14
+ * here (src/cli/user.ts `roleToAdd`, `attributesToAdd`). What only this command line says (a verb, a user's name, a
15
+ * role's name, a `--set`'s form) is checked first, by the same functions, and refused as a wrong line before a byte is
16
+ * sent. Nothing of a project here is read or written: the users are the replica's.
17
+ *
18
+ * THE ANSWER IS HELD TO THE RUN'S OWN ANSWER SHAPE, then printed by the renderers the local command prints with
19
+ * (src/cli/user.ts `listText`, `addedReport`, …), in serviceStyle: the same answer prints the same lines. A SERVICE'S
20
+ * VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every value the
21
+ * service sends that this command prints, and how it is held:
22
+ *
23
+ * value where it is printed what holds it
24
+ * a failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`), masked
25
+ * a failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
26
+ * a failure's next nowhere: never read the step is `failureNext`'s, by the status
27
+ * a user's name every row, headline and step it names `USER_NAME_RE`; add, disable, enable, remove: the name asked
28
+ * an id list's rows, --json 16 hex digits
29
+ * addedAt, removedAt, disabledAt, rows, list's lines, --json `isServiceTime`
30
+ * lastUsedAt
31
+ * the password add's url, psql and password rows, --json `mason_<its id>_<32 hex>` (src/duck/users.ts), add's alone;
32
+ * anything shaped as one elsewhere refused (`carriesNoSecret`)
33
+ * or, in a failure's words, masked (`serviceText`)
34
+ * database, host, port, sslmode add's rows, --json the database name's rule, a host's letters, 1-65535, a closed set
35
+ * url, psql (add) add's rows, --json rebuilt here (`connectionUrl`, `psqlLine`): the service's must equal them
36
+ * url (list) --json `postgres://<its name>@<host>:<port>/<database>`, no password
37
+ * role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
38
+ * roleKnown, live
39
+ * attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
40
+ * from (a user's address ranges) rows, list's lines, add's row, --json null, or 1 to MAX_RANGES ranges each in the one form
41
+ * src/duck/users.ts `userRanges` writes back (`heldRanges`);
42
+ * on add and from, exactly the ranges sent, else the answer
43
+ * is refused whole (`sameRanges`, `unconfirmed`: fail closed)
44
+ * lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
45
+ * requestLogDays list's lines, --json a whole number, 1 to 36500
46
+ * already the headline chosen, --json a boolean
47
+ * an HTTP status a failure's why a number
48
+ *
49
+ * BEFORE ANY OF THESE, every string of the answer, its keys included, is put to the exact test (src/cli/hosted.ts
50
+ * `carriesNoSecret`, `carriesSecret` on each: an echo of the held token, `ECHO_RUN` of its body in a row in one string; a
51
+ * token's or a password's shape — add's own password, url and psql for the token alone): an answer that carries one
52
+ * anywhere is refused whole, so no grammar is ever the only thing between a secret and the output, and an honest answer
53
+ * is refused with a probability under 1 in a million whatever its size. A failure's code, printed inside why, is put to
54
+ * the same test (`failureShape`). The free text (the notes, a refusal's words) is masked too, by #591's quarter rule.
55
+ *
56
+ * An answer any of which does not hold is outside the contract: said by the request and its status, never its body.
57
+ * What to run next is the CLI's own (`onReplica`: the local command's steps, each `mason user …` naming the replica).
58
+ */
59
+ import { failed, jsonText, serviceStyle } from "./command.js";
60
+ import { call, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
61
+ import { replicaText, tokenFor } from "./replica.js";
62
+ import { holdOutput } from "./output-gate.js";
63
+ import { err as styleErr, out as styleOut } from "./style.js";
64
+ import { connectionUrl, psqlLine } from "./pg-url.js";
65
+ import { MAX_RANGES, USER_NAME_RE, userRanges } from "../duck/users.js";
66
+ import { ROLE_NAME_RE } from "./roles.js";
67
+ import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
68
+ import { databaseNameProblem } from "../lib/database-name.js";
69
+ import { NO_ROLES } from "../duck/grants.js";
70
+ import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, fromNext, fromReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
71
+ /** The bound of a list's answer (a replica's users, a row each), and of the number of users in it; every other answer
72
+ * is `call`'s own 64 KiB. */
73
+ export const MAX_LIST_BYTES = 1024 * 1024;
74
+ export const MAX_USERS = 2000;
75
+ const out = (m) => { process.stdout.write(`${m}\n`); };
76
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
77
+ const ID = /^[0-9a-f]{16}$/;
78
+ 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})$/;
79
+ const ROLE_FROM = ["file", "default", "none"];
80
+ /** The local command's next steps, as a replica's: each `mason user …` names the replica, so it runs against it. */
81
+ export const onReplica = (steps, r) => steps.map(([command, ...what]) => [command.startsWith("mason user ") ? `${command} --replica ${replicaText(r)}` : command, ...what]);
82
+ /** What to run after the service refused a request about a replica's users: the CLI's own, by the status (never the
83
+ * service's `next`): sign in again for a 401 or 403, else the replica's users. */
84
+ export const failureNext = (status, r) => status === 401 || status === 403 ? serviceNext(status) : [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]];
85
+ /** The request a subcommand is: the run's own `/api/users` request, under the replica's path. */
86
+ export function requestOf(r, o) {
87
+ const base = `/v1/replicas/${r.org}/${r.name}/users`;
88
+ // a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
89
+ switch (o.verb) {
90
+ case "list": return { method: "GET", path: `${base}?lastUsed=1` };
91
+ case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}),
92
+ ...(o.from !== undefined && o.from !== null ? { from: [...o.from] } : {}) } };
93
+ case "from": return { method: "POST", path: `${base}/${o.name}/from`, json: { from: o.from === undefined || o.from === null ? null : [...o.from] } };
94
+ case "disable":
95
+ case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
96
+ case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
97
+ }
98
+ }
99
+ // ------------------------------------------------------------------ the answers, held to the run's own shapes
100
+ /** A string the service sends, as one line: null when it is not a string. */
101
+ const line = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
102
+ /** A user's role fields, each consistent with the others (src/cli/roles.ts userRole): a role for `file` and `default`,
103
+ * none for `none`, `readsNothing` only where there is none. */
104
+ function roleOf(u) {
105
+ if (typeof u.roleFrom !== "string" || !ROLE_FROM.includes(u.roleFrom))
106
+ return null;
107
+ const roleFrom = u.roleFrom;
108
+ if (u.role !== undefined && (typeof u.role !== "string" || !ROLE_NAME_RE.test(u.role)))
109
+ return null;
110
+ if ((roleFrom === "none") !== (u.role === undefined))
111
+ return null;
112
+ if (u.readsNothing !== undefined && (u.readsNothing !== true || roleFrom !== "none"))
113
+ return null;
114
+ return { ...(typeof u.role === "string" ? { role: u.role } : {}), roleFrom, ...(u.readsNothing === true ? { readsNothing: true } : {}) };
115
+ }
116
+ /** 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
117
+ * value (a tenant's id), put to the exact test with every string (`carriesNoSecret`), so it is made one line here
118
+ * without the quarter rule of free text, which a thousand random ids would trip by chance (PR 609's review). */
119
+ function attributesOf(v) {
120
+ if (!obj(v) || Object.keys(v).length > 64)
121
+ return null;
122
+ const outv = {};
123
+ for (const [k, x] of Object.entries(v)) {
124
+ if (!ATTRIBUTE_NAME_RE.test(k))
125
+ return null;
126
+ if (typeof x === "string")
127
+ outv[k] = serviceText(x, 1000);
128
+ else if (Array.isArray(x) && x.length >= 1 && x.length <= 100 && x.every((y) => typeof y === "string"))
129
+ outv[k] = x.map((y) => serviceText(y, 1000));
130
+ else
131
+ return null;
132
+ }
133
+ return outv;
134
+ }
135
+ /** A user's address ranges as the service sends them: null (any address), or 1 to MAX_RANGES ranges, each in the one form
136
+ * the one grammar writes back (src/duck/users.ts `userRanges`); undefined when they are not that. */
137
+ export function heldRanges(v) {
138
+ if (v === null)
139
+ return null;
140
+ if (!Array.isArray(v) || v.length < 1 || v.length > MAX_RANGES || !v.every((x) => typeof x === "string" && x.length <= 64))
141
+ return undefined;
142
+ const r = userRanges(v);
143
+ return "problem" in r || r.from.length !== v.length || r.from.some((x, i) => x !== v[i]) ? undefined : r.from;
144
+ }
145
+ const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
146
+ const timeOrNull = (v) => v === null || isServiceTime(v);
147
+ /** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
148
+ function rowOf(v) {
149
+ if (!obj(v))
150
+ return null;
151
+ const u = v;
152
+ if (typeof u.name !== "string" || !USER_NAME_RE.test(u.name) || typeof u.id !== "string" || !ID.test(u.id) || !isServiceTime(u.addedAt)
153
+ || !timeOrNull(u.removedAt) || !timeOrNull(u.disabledAt) || !timeOrNull(u.lastUsedAt) || typeof u.live !== "boolean")
154
+ return null;
155
+ // the URL a tool pastes, WITHOUT the password: its own name, then where the replica listens
156
+ if (u.url !== null && !(typeof u.url === "string" && u.url.length <= 600 && u.url.startsWith(`postgres://${u.name}@`)
157
+ && /^postgres:\/\/[a-z0-9_.-]+@(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/[A-Za-z0-9%._~!*'()-]+(?:\?sslmode=require)?$/.test(u.url)))
158
+ return null;
159
+ const role = roleOf(u);
160
+ if (role === null)
161
+ return null;
162
+ // a replica's Mason before address ranges sends none: it has none, so any address
163
+ const from = u.from === undefined ? null : heldRanges(u.from);
164
+ if (from === undefined)
165
+ return null;
166
+ const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
167
+ if (attributes === null)
168
+ return null;
169
+ if (u.missingAttributes !== undefined && !names(u.missingAttributes))
170
+ return null;
171
+ if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
172
+ return null;
173
+ return { name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt, disabledAt: u.disabledAt, live: u.live, lastUsedAt: u.lastUsedAt,
174
+ url: u.url, from, ...role, ...(attributes !== undefined ? { attributes } : {}),
175
+ ...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
176
+ ...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
177
+ }
178
+ /** `GET …/users?lastUsed=1`'s answer: mason user list --json's shape (its `next` never read); null when it is not it. */
179
+ export function heldList(json, secrets) {
180
+ if (!obj(json) || !Array.isArray(json.users) || json.users.length > MAX_USERS || !carriesNoSecret(json, secrets))
181
+ return null;
182
+ const users = [];
183
+ for (const v of json.users) {
184
+ const u = rowOf(v);
185
+ if (u === null)
186
+ return null;
187
+ users.push(u);
188
+ }
189
+ const note = (v) => v === null ? null : line(v, 500, secrets) ?? undefined;
190
+ const lastUsedNote = note(json.lastUsedNote), urlNote = note(json.urlNote);
191
+ const days = json.requestLogDays;
192
+ if (lastUsedNote === undefined || urlNote === undefined || !(days === null || (Number.isInteger(days) && days >= 1 && days <= 36500)))
193
+ return null;
194
+ return { users, lastUsedNote, urlNote, requestLogDays: days };
195
+ }
196
+ /** `POST …/users`'s answer: mason user add --json's shape with Mason up — where it listens, the URL rebuilt here by the
197
+ * one builder and equal to the service's —, for the name asked; null when it is not it. */
198
+ export function heldAdded(json, asked, secrets) {
199
+ if (!obj(json) || !carriesNoSecret(json, secrets, ["password", "url", "psql"]))
200
+ return null;
201
+ const a = json;
202
+ if (a.user !== asked || typeof a.id !== "string" || !ID.test(a.id) || !isServiceTime(a.addedAt))
203
+ return null;
204
+ // the password is the user's, by its one grammar: its id, then its MAC
205
+ if (typeof a.password !== "string" || !new RegExp(`^mason_${a.id}_[0-9a-f]{32}$`).test(a.password))
206
+ return null;
207
+ if (typeof a.database !== "string" || databaseNameProblem(a.database) !== null || serviceText(a.database, 63) !== a.database)
208
+ return null;
209
+ if (typeof a.host !== "string" || !HOST.test(a.host) || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535)
210
+ return null;
211
+ if (a.sslmode !== "require" && a.sslmode !== "disable")
212
+ return null;
213
+ const url = connectionUrl({ url: "", host: a.host, port: a.port, database: a.database, ...(a.sslmode === "require" ? { sslmode: "require" } : {}) }, asked, a.password);
214
+ if (a.url !== url || a.psql !== psqlLine(url))
215
+ return null;
216
+ const role = roleOf(a);
217
+ if (role === null)
218
+ return null;
219
+ const from = a.from === undefined ? undefined : heldRanges(a.from);
220
+ // null (any address) is read as no ranges, as a list's row reads it: an add that sent ranges then refuses it unconfirmed
221
+ if (from === undefined && a.from !== undefined)
222
+ return null;
223
+ const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
224
+ if (attributes === null)
225
+ return null;
226
+ return { user: asked, id: a.id, addedAt: a.addedAt, password: a.password, database: a.database, url, psql: psqlLine(url), host: a.host, port: a.port, sslmode: a.sslmode,
227
+ ...role, ...(attributes !== undefined ? { attributes } : {}), ...(from !== undefined && from !== null ? { from } : {}) };
228
+ }
229
+ /** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
230
+ function userOf(v, asked, more) {
231
+ if (!obj(v) || v.name !== asked || typeof v.id !== "string" || !ID.test(v.id) || !isServiceTime(v.addedAt) || !more.every((k) => isServiceTime(v[k])))
232
+ return null;
233
+ return { name: asked, id: v.id, addedAt: v.addedAt, ...Object.fromEntries(more.map((k) => [k, v[k]])) };
234
+ }
235
+ export function heldDisabled(json, asked, secrets) {
236
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
237
+ return null;
238
+ const d = userOf(json.disabled, asked, ["disabledAt"]);
239
+ return d === null ? null : { disabled: d, already: json.already };
240
+ }
241
+ export function heldEnabled(json, asked, secrets) {
242
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
243
+ return null;
244
+ const e = userOf(json.enabled, asked, []);
245
+ return e === null ? null : { enabled: e, already: json.already };
246
+ }
247
+ /** `POST …/users/{user}/from`'s answer: the user asked, its ranges now (null: any address), and `already`. */
248
+ export function heldFrom(json, asked, secrets) {
249
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
250
+ return null;
251
+ const c = userOf(json.changed, asked, []);
252
+ const from = obj(json.changed) ? heldRanges(json.changed.from) : undefined;
253
+ return c === null || from === undefined ? null : { changed: { ...c, from }, already: json.already };
254
+ }
255
+ export function heldRemoved(json, asked, secrets) {
256
+ if (!obj(json) || !carriesNoSecret(json, secrets))
257
+ return null;
258
+ const r = userOf(json.removed, asked, ["removedAt"]);
259
+ return r === null ? null : { removed: r };
260
+ }
261
+ // ------------------------------------------------------------------ the command
262
+ /** Whether the ranges an answer carries are exactly those sent (each in its one form, in order; null: any address). */
263
+ export const sameRanges = (got, sent) => got !== undefined && JSON.stringify(got) === JSON.stringify(sent);
264
+ /**
265
+ * AN ADDRESS LIMIT THE SERVICE DID NOT CONFIRM: its 200 does not carry the ranges sent (none, null, or others). The
266
+ * answer is refused whole, never printed (an add's password included): the user may now connect from more addresses
267
+ * than asked, which only the replica's list can say, and the way out removes it or sets the ranges again.
268
+ */
269
+ export function unconfirmed(r, name, sent, verb) {
270
+ const on = `--replica ${replicaText(r)}`;
271
+ const asked = sent === null ? "any address" : sent.join(", ");
272
+ return { message: `the service did not confirm ${name}'s address limit: its answer does not carry the ranges sent (${asked})`, code: "failed",
273
+ why: verb === "add" ? `the replica may have added ${name} with no limit, so it would connect from any address, and its password was not shown`
274
+ : `the replica may have kept ${name}'s ranges as they were, or set others`,
275
+ fix: verb === "add" ? `mason user list ${on} says what ${name} has; if it was made without the limit, mason user remove ${name} ${on}`
276
+ : `mason user list ${on} says what ${name} has now; mason user from ${name} ${sent === null ? "--any" : sent.join(" ")} ${on} sets it again, or mason user remove ${name} ${on}`,
277
+ next: [[`mason user list ${on}`, "the replica's users and the addresses each connects from"], [`mason user remove ${name} ${on}`, "if it was made without the limit"]] };
278
+ }
279
+ /** What each subcommand asked, in the words of a failure outside the contract (`unexpected`). */
280
+ const STEP = { list: "the replica's users were asked for", add: "a user was added", from: "a user's address ranges were set", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
281
+ /** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
282
+ export async function hostedUser(r, o) {
283
+ const outStyle = serviceStyle(styleOut());
284
+ const fail = (f) => failed("user", f, { json: o.json, style: serviceStyle(styleErr()) });
285
+ const service = serviceOf();
286
+ if (isFailure(service))
287
+ return fail(service);
288
+ const token = tokenFor(service, { asked: "a replica's users are asked for", again: `mason user ${o.verb}` });
289
+ if (isFailure(token))
290
+ return fail(token);
291
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts); add prints the
292
+ // password it made, so a password's shape is its answer's there
293
+ holdOutput({ command: "user", json: o.json, secrets: [token], passwords: o.verb !== "add" });
294
+ const secrets = [token];
295
+ const req = requestOf(r, o);
296
+ const a = await call(service, req.method, req.path, { token, ...(req.json !== undefined ? { json: req.json } : {}), ...(o.verb === "list" ? { maxBytes: MAX_LIST_BYTES } : {}) });
297
+ if (isFailure(a))
298
+ return fail(a);
299
+ const refused = (x) => serviceFailure(x, secrets, (status) => failureNext(status, r)) ?? unexpected(service, x, STEP[o.verb]);
300
+ if (a.status !== 200)
301
+ return fail(refused(a));
302
+ const outside = () => fail(unexpected(service, a, STEP[o.verb]));
303
+ // the target in every answer: the replica's name in --json and in the headline
304
+ const on = replicaText(r);
305
+ const print = (value, next, text) => {
306
+ out(o.json ? jsonText(USER, { replica: on, ...value }, next) : text());
307
+ return 0;
308
+ };
309
+ switch (o.verb) {
310
+ case "list": {
311
+ const l = heldList(a.json, secrets);
312
+ if (l === null)
313
+ return outside();
314
+ const next = onReplica(listNext(l.users), r);
315
+ return print({ ...l }, next, () => listText(on, l, NO_ROLES, next, outStyle));
316
+ }
317
+ case "add": {
318
+ const added = heldAdded(a.json, o.name, secrets);
319
+ // the service answered 200: the replica may have made the user, whose password nobody has now been shown
320
+ if (added === null) {
321
+ const f = unexpected(service, a, STEP[o.verb]);
322
+ return fail({ ...f, why: `${f.why}; the replica may have added ${o.name} all the same, and its password was not shown`,
323
+ fix: `mason user list --replica ${replicaText(r)} says whether it did; if so, mason user remove ${o.name} --replica ${replicaText(r)}, then add it again`,
324
+ next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
325
+ }
326
+ // FAIL CLOSED: ranges sent must come back as sent, or the user may have been made with none (a service that does
327
+ // not know `from` would make it connect from any address), and its password is not shown
328
+ if (o.from !== undefined && o.from !== null && !sameRanges(added.from, o.from))
329
+ return fail(unconfirmed(r, o.name, o.from, "add"));
330
+ const next = onReplica(addedNext(added.user, true), r);
331
+ return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next, on }, outStyle));
332
+ }
333
+ case "from": {
334
+ const c = heldFrom(a.json, o.name, secrets);
335
+ if (c === null)
336
+ return outside();
337
+ if (!sameRanges(c.changed.from, o.from ?? null))
338
+ return fail(unconfirmed(r, o.name, o.from ?? null, "from"));
339
+ const next = onReplica(fromNext(c.changed.name, c.changed.from), r);
340
+ return print({ ...c }, next, () => fromReport(c, true, next, outStyle));
341
+ }
342
+ case "disable": {
343
+ const d = heldDisabled(a.json, o.name, secrets);
344
+ if (d === null)
345
+ return outside();
346
+ const next = onReplica(disabledNext(d.disabled.name), r);
347
+ return print({ ...d }, next, () => disabledReport(d, true, next, outStyle, on));
348
+ }
349
+ case "enable": {
350
+ const e = heldEnabled(a.json, o.name, secrets);
351
+ if (e === null)
352
+ return outside();
353
+ const next = onReplica(enabledNext(e.enabled.name), r);
354
+ return print({ ...e }, next, () => enabledReport(e, next, outStyle, on));
355
+ }
356
+ case "remove": {
357
+ const x = heldRemoved(a.json, o.name, secrets);
358
+ if (x === null)
359
+ return outside();
360
+ const next = onReplica(REMOVED_NEXT, r);
361
+ return print({ removedUser: x.removed, ...x }, next, () => removedReport(x, true, next, outStyle, on));
362
+ }
363
+ }
364
+ }