@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -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
|
+
}
|