@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,114 @@
|
|
|
1
|
+
import { environmentToken, readLogins, TOKEN_ENV } from "./credentials.js";
|
|
2
|
+
import { isFailure, serviceText, whoAmI } from "./hosted.js";
|
|
3
|
+
/** What one part of a replica's name may be: an organisation's slug, a replica's name. */
|
|
4
|
+
export const REPLICA_PART = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
5
|
+
export const REPLICA_ENV = "MASON_REPLICA";
|
|
6
|
+
export const replicaText = (r) => `${r.org}/${r.name}`;
|
|
7
|
+
/**
|
|
8
|
+
* THE REPLICA THE COMMAND MEANS, ONE RESOLVER FOR EVERY COMMAND THAT NAMES ONE (`mason deploy`, `mason user …
|
|
9
|
+
* --replica`, `mason token create`, `mason replica create | status`): the command's own argument where it takes one
|
|
10
|
+
* (`mason replica status acme/shop`), else `--replica`, else `MASON_REPLICA`; null when none names one, a Failure when
|
|
11
|
+
* it is not `<org>/<name>` (never repeated beyond 200 characters, made printable), or when the argument and `--replica`
|
|
12
|
+
* name two replicas. `usage`: the command's, said after a wrong line.
|
|
13
|
+
*
|
|
14
|
+
* WHEN THE ENVIRONMENT CHOSE THE TARGET IT IS SAID, on stderr (`say`): an agent inherits an environment it may never
|
|
15
|
+
* read, and `MASON_REPLICA` exported turns `mason user add` from this project's users to a replica's.
|
|
16
|
+
*/
|
|
17
|
+
export function replicaOf(flag, usage, env = process.env, o = {}) {
|
|
18
|
+
if (o.argument !== undefined) {
|
|
19
|
+
const r = replicaNamed(o.argument, "argument", usage);
|
|
20
|
+
if (flag === undefined || "message" in r)
|
|
21
|
+
return r;
|
|
22
|
+
const f = replicaNamed(flag, "--replica", usage);
|
|
23
|
+
if ("message" in f)
|
|
24
|
+
return f;
|
|
25
|
+
if (replicaText(f) === replicaText(r))
|
|
26
|
+
return r;
|
|
27
|
+
return { message: `the replica is named twice: ${replicaText(r)} and --replica ${replicaText(f)}`, code: "usage", fix: `name one: ${replicaText(r)}, or --replica ${replicaText(f)}`, usage };
|
|
28
|
+
}
|
|
29
|
+
const from = flag !== undefined ? "--replica" : env[REPLICA_ENV] !== undefined ? REPLICA_ENV : null;
|
|
30
|
+
if (from === null)
|
|
31
|
+
return null;
|
|
32
|
+
const r = replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
|
|
33
|
+
if (!("message" in r) && r.from === REPLICA_ENV)
|
|
34
|
+
(o.say ?? sayStderr)(envChoseText(r));
|
|
35
|
+
return r;
|
|
36
|
+
}
|
|
37
|
+
/** What is said on stderr when `MASON_REPLICA` chose the replica (a name `REPLICA_PART` holds: safe to print). */
|
|
38
|
+
export const envChoseText = (r) => `using ${REPLICA_ENV}=${replicaText(r)} (--replica names another; unset ${REPLICA_ENV} for none)`;
|
|
39
|
+
const sayStderr = (line) => { process.stderr.write(`${line}\n`); };
|
|
40
|
+
/** The organisation the command means: its own argument (`mason replica list acme`), else `--org`; null when neither
|
|
41
|
+
* names one, a wrong line's Failure when one is not a slug or the two differ. */
|
|
42
|
+
export function orgOf(flag, argument, usage) {
|
|
43
|
+
const named = [argument, flag].filter((x) => x !== undefined).map((x) => orgNamed(x, usage));
|
|
44
|
+
const bad = named.find((x) => typeof x !== "string");
|
|
45
|
+
if (bad !== undefined)
|
|
46
|
+
return bad;
|
|
47
|
+
const [a, b] = named;
|
|
48
|
+
if (a === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
if (b !== undefined && a !== b)
|
|
51
|
+
return { message: `the organisation is named twice: ${a} and --org ${b}`, code: "usage", fix: `name one: ${a}, or --org ${b}`, usage };
|
|
52
|
+
return a;
|
|
53
|
+
}
|
|
54
|
+
/** `raw` as a replica's name, `<org>/<name>`, each part `REPLICA_PART`: the one grammar of `--replica`, `MASON_REPLICA`
|
|
55
|
+
* and a command's own argument (`mason replica status <org>/<name>`); a Failure otherwise, a wrong line's (`usage`)
|
|
56
|
+
* unless the environment named it. */
|
|
57
|
+
export function replicaNamed(raw, from, usage) {
|
|
58
|
+
const parts = raw.split("/");
|
|
59
|
+
if (parts.length === 2 && REPLICA_PART.test(parts[0]) && REPLICA_PART.test(parts[1]))
|
|
60
|
+
return { org: parts[0], name: parts[1], from };
|
|
61
|
+
const named = from === "argument" ? "the replica" : from;
|
|
62
|
+
return { message: `${named} ${JSON.stringify(serviceText(raw, 200))} is not <org>/<name>`, code: from === REPLICA_ENV ? "refused" : "usage",
|
|
63
|
+
why: "an organisation's slug and a replica's name, each 1 to 63 lower case letters, digits and -, starting with a letter or digit",
|
|
64
|
+
fix: from === "--replica" ? "name it as the service lists it: --replica acme/shop" : from === "argument" ? "name it as the service lists it: acme/shop (mason replica list)"
|
|
65
|
+
: `set ${REPLICA_ENV}=<org>/<name> (acme/shop), or unset it and pass --replica`,
|
|
66
|
+
...(from === REPLICA_ENV ? {} : { usage }) };
|
|
67
|
+
}
|
|
68
|
+
/** `raw` as an organisation's slug (`REPLICA_PART`: safe in a path as it is), or a wrong line's Failure. */
|
|
69
|
+
export function orgNamed(raw, usage) {
|
|
70
|
+
if (REPLICA_PART.test(raw))
|
|
71
|
+
return raw;
|
|
72
|
+
return { message: `the organisation ${JSON.stringify(serviceText(raw, 200))} is not an organisation's slug`, code: "usage",
|
|
73
|
+
why: "an organisation's slug is 1 to 63 lower case letters, digits and -, starting with a letter or digit", fix: "name it as the service lists it: acme", usage };
|
|
74
|
+
}
|
|
75
|
+
/** How many organisations a command reads when none is named (`loginOrgs`): past it, it asks for one. */
|
|
76
|
+
export const MAX_ORGS = 20;
|
|
77
|
+
/**
|
|
78
|
+
* THE ORGANISATIONS A COMMAND ACTS IN WHEN NONE IS NAMED: the login's own, as `GET /v1/me` says them (src/cli/hosted.ts
|
|
79
|
+
* `whoAmI`, its answer held there), those where its role is one of `roles` (every role when absent), each slug held to
|
|
80
|
+
* `REPLICA_PART` before it is in a path. A Failure when the service cannot say (a CI token is no person: 403), when a
|
|
81
|
+
* slug is outside the grammar, or past `MAX_ORGS`; `again` is the command that names one.
|
|
82
|
+
*/
|
|
83
|
+
export async function loginOrgs(service, token, again, roles) {
|
|
84
|
+
const me = await whoAmI(service, token);
|
|
85
|
+
const name = [again, "name the organisation"];
|
|
86
|
+
if (isFailure(me))
|
|
87
|
+
return { ...me, next: [name, ...(me.next ?? [])] };
|
|
88
|
+
if (!me.orgs.every((o) => REPLICA_PART.test(o.slug)))
|
|
89
|
+
return { message: `${service.base} named an organisation whose slug is not one`, code: "failed",
|
|
90
|
+
why: "a slug is 1 to 63 lower case letters, digits and -: the answer is not the one docs/HOSTED-API.md documents", fix: `name the organisation: ${again}`, next: [name] };
|
|
91
|
+
const orgs = me.orgs.filter((o) => roles === undefined || roles.includes(o.role)).map((o) => ({ slug: o.slug, role: o.role }));
|
|
92
|
+
if (orgs.length > MAX_ORGS)
|
|
93
|
+
return { message: `this login is in ${orgs.length} organisations, more than the ${MAX_ORGS} a command reads when none is named`, code: "refused",
|
|
94
|
+
fix: `name the organisation: ${again}`, next: [name] };
|
|
95
|
+
return orgs;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The token a request to a replica is sent with: MASON_TOKEN, else this machine's login to the service; a Failure naming
|
|
99
|
+
* mason login. `asked`: what the command sends, in its words ("a deploy is sent"); `again`: the command to run once
|
|
100
|
+
* signed in ("mason deploy").
|
|
101
|
+
*/
|
|
102
|
+
export function tokenFor(service, o) {
|
|
103
|
+
const env = environmentToken();
|
|
104
|
+
if (env !== null)
|
|
105
|
+
return env;
|
|
106
|
+
const read = readLogins();
|
|
107
|
+
if (isFailure(read))
|
|
108
|
+
return read;
|
|
109
|
+
const saved = read.logins.logins[service.base];
|
|
110
|
+
if (saved !== undefined)
|
|
111
|
+
return saved.token;
|
|
112
|
+
return { message: `this machine is not signed in to ${service.base}`, code: "refused", why: `${o.asked} as a person signed in (mason login), or with a token in ${TOKEN_ENV} (CI)`,
|
|
113
|
+
fix: `run mason login, then ${o.again} again; in CI, set ${TOKEN_ENV} to a CI token`, next: [["mason login", `sign this machine in to ${service.base}`]] };
|
|
114
|
+
}
|
|
@@ -165,6 +165,11 @@ export function dropStatements(o) {
|
|
|
165
165
|
return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
|
|
166
166
|
(o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
|
|
167
167
|
}
|
|
168
|
+
/** The statement that lists Mason's replication slots on a database (each named `mason_…`, `replicationNames`), for a
|
|
169
|
+
* person to find the one to drop when its name is not known (a hosted replica's record of it could not be read). */
|
|
170
|
+
export function listSlotsStatement() {
|
|
171
|
+
return "SELECT slot_name, database, active FROM pg_replication_slots WHERE left(slot_name, 6) = 'mason_';";
|
|
172
|
+
}
|
|
168
173
|
/**
|
|
169
174
|
* WHAT THE RECORD NAMES THAT THIS START NO LONGER READS, to drop before the record names the new one (each holds what
|
|
170
175
|
* Mason made on a database nothing would drop otherwise): a slot under another name (the sources file renamed) goes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OUTCOMES } from "../api/request-log.js";
|
|
2
2
|
import { parseRequestCursor, requestCursor } from "../duck/requests.js";
|
|
3
3
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
4
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
4
5
|
/** How many requests a read returns unless it says. */
|
|
5
6
|
export const DEFAULT_REQUEST_LIMIT = 40;
|
|
6
7
|
export const MAX_REQUEST_LIMIT = 10_000;
|
|
@@ -12,21 +13,30 @@ export const MAX_SEARCH = 200;
|
|
|
12
13
|
/** How many sync events a read returns unless it says, and the most it may. */
|
|
13
14
|
export const DEFAULT_EVENT_LIMIT = 40;
|
|
14
15
|
export const MAX_EVENT_LIMIT = MAX_REQUEST_LIMIT;
|
|
15
|
-
const AGE = /^(\d+)(s|m|h|d)$/;
|
|
16
|
-
const AGE_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
17
16
|
/** a date, or a date and time with its zone (a time without one would be read in this machine's zone: refused) */
|
|
18
17
|
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}))?$/;
|
|
18
|
+
/** The furthest a JavaScript Date reaches from 1970, either way (ECMA-262 21.4.1.1). */
|
|
19
|
+
const MAX_TIME_MS = 8.64e15;
|
|
19
20
|
const usage = (message, fix) => ({ message, code: "usage", ...(fix !== undefined ? { fix } : {}) });
|
|
20
21
|
export const QUERY_NAMES = (k) => k;
|
|
21
22
|
/**
|
|
22
23
|
* THE TIME GRAMMAR, ONE FOR EVERY ENTRANCE (`--since` / `--until` of mason logs and mason usage, and the run's `since` and
|
|
23
24
|
* `until` of /api/requests, /api/events, /api/usage and /api/logs): an ISO 8601 date or time with its zone, or an age
|
|
24
|
-
* before `now` (30s, 30m, 2h, 7d
|
|
25
|
+
* before `now` (30s, 30m, 2h, 7d, 2 hours), read by the command line's one duration grammar (src/lib/table-sync.ts
|
|
26
|
+
* `parseDuration`) with its unit written, as an ISO time; a time with no zone is refused, as is anything else. A bare
|
|
27
|
+
* number is refused here, as it always was: as a TIME it reads as well as a Unix timestamp as an age in seconds.
|
|
25
28
|
*/
|
|
26
29
|
export function timeOf(name, text, now) {
|
|
27
|
-
const age =
|
|
28
|
-
if (age !== null)
|
|
29
|
-
|
|
30
|
+
const age = Number.isFinite(Number(text.trim())) ? null : parseDuration(text, { zero: true });
|
|
31
|
+
if (age !== null && !("problem" in age)) {
|
|
32
|
+
// an age past what a time can hold (Date's ±8.64e15 ms) is the command line's mistake, refused as one: never a throw,
|
|
33
|
+
// which the run's API would answer 500
|
|
34
|
+
const at = now - age.seconds * 1000;
|
|
35
|
+
if (!Number.isFinite(at) || Math.abs(at) > MAX_TIME_MS) {
|
|
36
|
+
return usage(`${name} ${JSON.stringify(text)} is further back than a time reaches`, "a shorter age (30m, 2h, 7d), or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
|
|
37
|
+
}
|
|
38
|
+
return new Date(at).toISOString();
|
|
39
|
+
}
|
|
30
40
|
const t = ISO.test(text.trim()) ? Date.parse(text.trim()) : NaN;
|
|
31
41
|
if (Number.isNaN(t)) {
|
|
32
42
|
return usage(`${name} ${JSON.stringify(text)} is not a time`, "an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
|
package/dist/src/cli/roles.js
CHANGED
|
@@ -371,7 +371,8 @@ export function roleProblems(roles, copied) {
|
|
|
371
371
|
if (typeof th === "string")
|
|
372
372
|
problems.push(th);
|
|
373
373
|
}
|
|
374
|
-
|
|
374
|
+
// a generated table not built yet: its columns are not known, and the role reads nothing of it until they are
|
|
375
|
+
if (c.columns === null || c.pending === true)
|
|
375
376
|
continue;
|
|
376
377
|
const have = c.columns.map((x) => x.name);
|
|
377
378
|
for (const [key, list] of [["columns", t.columns ?? []], ["exclude", t.exclude]]) {
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* THE WEB PAGE (src/web/, `mason ui`, docs/WEB-UI.md): under `mason start` it also serves one page and its files (the
|
|
16
16
|
* shell, its pages' modules, the stylesheet), from an allowlist read when it starts (`pageFiles`), and the users the
|
|
17
17
|
* page manages (`/api/users`, the functions `mason user` calls). The page reads the same JSON the commands read; a refusal carries `failure`, the CLI's Failure
|
|
18
|
-
* shape (message, why, fix, next), so the page says it as the command would. `mason serve`
|
|
18
|
+
* shape (message, why, fix, next), so the page says it as the command would. Under `mason serve` it serves the same page,
|
|
19
|
+
* on loopback alone, to a browser on another machine through `mason ui --ssh` (src/cli/ui-ssh.ts, `openTunnel` below).
|
|
20
|
+
*
|
|
21
|
+
* LOOPBACK BY CONSTRUCTION: `listenOnLoopback` is how this server listens, and it takes no address. No setting names
|
|
22
|
+
* one, and a container's page is reached from inside the container, never published (docs/WEB-UI.md).
|
|
19
23
|
*/
|
|
20
24
|
import * as fs from "node:fs";
|
|
21
25
|
import * as http from "node:http";
|
|
@@ -23,17 +27,18 @@ import * as path from "node:path";
|
|
|
23
27
|
import { fileURLToPath } from "node:url";
|
|
24
28
|
import { readBody, sendJson } from "../lib/http-json.js";
|
|
25
29
|
import { projectFingerprint } from "../lib/project-files.js";
|
|
26
|
-
import { guard, SECURITY_HEADERS } from "./security.js";
|
|
30
|
+
import { guard, newTunnel, SECURITY_HEADERS } from "./security.js";
|
|
27
31
|
import { maskedUrl } from "./preflight.js";
|
|
28
32
|
import { errorJson, nextJson } from "./command.js";
|
|
29
33
|
import { phaseCounts, readiness, syncTotals, tableSync } from "./readiness.js";
|
|
30
34
|
import { restartsFact } from "./supervise.js";
|
|
31
35
|
import { withRestarts } from "../duck/events.js";
|
|
32
36
|
import { NO_ROLES } from "../duck/grants.js";
|
|
33
|
-
import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, listUsers, removeUser, REMOVED_NEXT, roleToAdd, withLastUsed } from "./user.js";
|
|
37
|
+
import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, fromNext, listUsers, rangesText, removeUser, REMOVED_NEXT, roleToAdd, setUserFrom, withLastUsed } from "./user.js";
|
|
34
38
|
import { userRole } from "./roles.js";
|
|
35
39
|
import { postgresFacts, postgresLink } from "./pg-url.js";
|
|
36
40
|
import { catalogTable, parseSourceText, shownPath } from "./sources.js";
|
|
41
|
+
import { projectWriteRefusal } from "./project.js";
|
|
37
42
|
import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
|
|
38
43
|
import { PollRefused } from "../duck/sync.js";
|
|
39
44
|
import { DEFAULT_SYNC, parseSync, syncText } from "../lib/table-sync.js";
|
|
@@ -73,7 +78,9 @@ export function runState(ctx, copies, stream, now = Date.now(), { columns = fals
|
|
|
73
78
|
// copyProgressNow), so mason wait and status move between two commits
|
|
74
79
|
...(c.phase === "copying" && c.progress !== undefined ? { progress: c.progress } : {}),
|
|
75
80
|
...(c.phase === "copied" && c.retired !== undefined ? { retired: c.retired } : {}) } : null,
|
|
76
|
-
error
|
|
81
|
+
// its sync's error, whatever its phase: a copied table whose poll, catch-up, stream or publication check failed
|
|
82
|
+
// carries it on its own row, as its sync's state says failed (issue 100)
|
|
83
|
+
error: c?.error ?? null,
|
|
77
84
|
// how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`): its
|
|
78
85
|
// sync and when it was last synced, what mason status and the stream's tables say, and EXPLAIN says over the wire
|
|
79
86
|
...(c?.kept ? { kept: c.kept } : {}),
|
|
@@ -87,12 +94,18 @@ export function runState(ctx, copies, stream, now = Date.now(), { columns = fals
|
|
|
87
94
|
// what mason wait waits on and mason status prints last (src/cli/readiness.ts): said by the run, so the page and the
|
|
88
95
|
// commands say one thing
|
|
89
96
|
readiness: readiness({ tables }),
|
|
90
|
-
// the run serves the web page (mason ui): mason status prints its URL
|
|
97
|
+
// the run serves the web page (mason ui): mason status prints its URL, or on a server how it is opened
|
|
91
98
|
ui: ctx.page,
|
|
99
|
+
// mason serve's run: the page's words for a tunnel that closed, and for the Postgres URL's host, follow from it
|
|
100
|
+
served: ctx.served === true,
|
|
101
|
+
// why the page offers no Add or Remove, or null: the project's files deployed read-only (MASON_PROJECT_READONLY), or
|
|
102
|
+
// a server's project folder mounted read-only — a control that can only fail is not offered
|
|
103
|
+
sourcesReadOnly: sourcesReadOnly(ctx, sources.file?.file ?? null),
|
|
92
104
|
source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
|
|
93
105
|
// `replication` while a table's sync is replication (the run streams), else `poll`: each table's own is its kept
|
|
94
106
|
version: facts.versionText, standby: facts.standby, changes: repl !== null ? "replication" : "poll", name: sources.file?.name ?? null, file: sources.file?.file ?? null,
|
|
95
107
|
shownFile: sources.file ? shownPath(ctx.root, sources.file.file) : null,
|
|
108
|
+
...(ctx.textOrder ? { textOrder: ctx.textOrder } : {}),
|
|
96
109
|
// every table's sync unless its entry says its own: the file's top `sync:`, or the default when it writes none
|
|
97
110
|
sync: { default: syncText(sources.file?.sync ?? DEFAULT_SYNC), written: sources.file?.sync != null },
|
|
98
111
|
...(repl !== null ? { replication: { slot: repl.slot, publication: repl.publication, owned: repl.owned } } : {}) },
|
|
@@ -117,15 +130,38 @@ export function runState(ctx, copies, stream, now = Date.now(), { columns = fals
|
|
|
117
130
|
sessions: ctx.listener.openSessions(),
|
|
118
131
|
disk: { path: disk.path, freeBytes: disk.freeBytes, copyBytes: bytes, projectedBytes: disk.projectedBytes, ...(disk.error !== undefined ? { error: disk.error } : {}) },
|
|
119
132
|
jsonPaths: ctx.duck.jsonPaths?.() ?? [],
|
|
133
|
+
generatedTables: ctx.duck.generated?.() ?? [],
|
|
120
134
|
// the roles file as the listener reads it now: its roles by name, and what is wrong with it (every statement of a
|
|
121
135
|
// session with a role is refused while there is)
|
|
122
136
|
...(ctx.roles ? { roles: rolesState(ctx.roles()) } : {}),
|
|
123
137
|
// the share open now (mason share): where, who, and what was refused there; nothing in it logs anyone in
|
|
124
138
|
share: ctx.shares?.state() ?? null,
|
|
139
|
+
// the last share that ended and why: a page that held it says so (null before any, and on a server)
|
|
140
|
+
shareEnded: ctx.shares?.ended ?? null,
|
|
125
141
|
// replication mode: where the stream is, its lag and what it applied (mason status and wait render it, streamLines)
|
|
126
142
|
...(stream !== null ? { stream } : {}),
|
|
127
143
|
};
|
|
128
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Why the run could not write its sources file now, or null — ONE fact for the page and `mason status --json`, computed
|
|
147
|
+
* here: first the project's own word (src/cli/project.ts `projectWriteRefusal`, the authority every edit asks, so the page
|
|
148
|
+
* offers no Add or Remove exactly when `/api/pick` and `/api/remove` would refuse them); then, on a server, a file it
|
|
149
|
+
* cannot write (a folder mounted read-only). `mason start`'s file is the developer's, and never asked of the disk.
|
|
150
|
+
*/
|
|
151
|
+
function sourcesReadOnly(ctx, file) {
|
|
152
|
+
const readOnly = projectWriteRefusal(ctx.root, process.env);
|
|
153
|
+
if (readOnly !== null)
|
|
154
|
+
return `${readOnly.message}; ${readOnly.fix}`;
|
|
155
|
+
if (ctx.served !== true || file === null)
|
|
156
|
+
return null;
|
|
157
|
+
try {
|
|
158
|
+
fs.accessSync(file, fs.constants.W_OK);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return `the sources file cannot be written on this server (${e.code ?? e.message}): commit a change to it, and deploy`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
129
165
|
/** a file's size, or null where it cannot be read (the run's own DuckDB file: it exists while the run does) */
|
|
130
166
|
function fileBytes(file) {
|
|
131
167
|
try {
|
|
@@ -412,6 +448,10 @@ async function openShare(ctx, req, res) {
|
|
|
412
448
|
}
|
|
413
449
|
const shares = ctx.shares;
|
|
414
450
|
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
451
|
+
if (body.from === "page") {
|
|
452
|
+
await openPageShare(ctx, body, res);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
415
455
|
if (typeof body.secret !== "string" || !/^[0-9a-f]{64}$/.test(body.secret))
|
|
416
456
|
throw new Refusal(400, "send { secret, role? }: the password's SHA-256 in hex");
|
|
417
457
|
if (body.role !== undefined && typeof body.role !== "string")
|
|
@@ -434,19 +474,83 @@ async function openShare(ctx, req, res) {
|
|
|
434
474
|
res.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
|
435
475
|
res.write(`${JSON.stringify(opened)}\n`);
|
|
436
476
|
}
|
|
477
|
+
/** The refusal of a page's share where there is none to open (`mason serve`): the command's own words. */
|
|
478
|
+
const SERVED_SHARE = { message: "this run is on a server, and has an address of its own: mason share is for a run on a laptop", code: "refused", next: [["mason help production", "reaching a server's Postgres API"]] };
|
|
479
|
+
/**
|
|
480
|
+
* `POST /api/share` { from: "page", confirmed: true, role? }: the web page's Connect from anywhere (src/cli/page-share.ts),
|
|
481
|
+
* answered ONCE with the share and its password, which nothing here keeps; the page's beats hold it from then on.
|
|
482
|
+
* `confirmed` is the person's yes to a public address, which the page asks for: a body without it opens nothing.
|
|
483
|
+
*/
|
|
484
|
+
async function openPageShare(ctx, body, res) {
|
|
485
|
+
if (ctx.pageShares === undefined) {
|
|
486
|
+
sendFailure(res, 409, SERVED_SHARE);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (body.confirmed !== true) {
|
|
490
|
+
sendFailure(res, 400, { message: "nothing was shared: a temporary public address is opened only once the person said yes to it", code: "usage", fix: "send { from: \"page\", confirmed: true, role? }: the page's Connect from anywhere asks first" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (body.role !== undefined && typeof body.role !== "string")
|
|
494
|
+
throw new Refusal(400, "role is a role's name, or left out");
|
|
495
|
+
const r = await ctx.pageShares.open({ role: body.role });
|
|
496
|
+
if ("failure" in r) {
|
|
497
|
+
sendFailure(res, statusOf(r.failure), r.failure, r.choices ? { choices: r.choices } : {});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
sendJson(res, 200, r);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* `POST /api/ui/tunnel` { port }: a tunnel to the page (src/cli/security.ts, src/cli/ui-ssh.ts), its ticket in the first
|
|
504
|
+
* line of JSON and THE ANSWER LEFT OPEN, as a share's is: the connection is the tunnel, so its bridge gone by any means
|
|
505
|
+
* takes the Host, the ticket and the cookie with it. A process holding the run's token asks; a page's cookie cannot.
|
|
506
|
+
*/
|
|
507
|
+
async function openTunnel(ctx, tunnels, req, res) {
|
|
508
|
+
if (req.headers.authorization === undefined) {
|
|
509
|
+
sendFailure(res, 403, { message: "a tunnel is asked for with the run's token: mason ui --ssh does", code: "refused" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!ctx.page) {
|
|
513
|
+
sendFailure(res, 404, { message: "this Mason serves no web page", code: "not_found" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const body = JSON.parse(await readBody(req, 1024));
|
|
517
|
+
const port = body.port;
|
|
518
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65_535)
|
|
519
|
+
throw new Refusal(400, "send { port }: the local port the browser names");
|
|
520
|
+
// its Host would be this server's own, whose cookie is the run's token: rare, and a new local port is one run away
|
|
521
|
+
if (port === ctx.port) {
|
|
522
|
+
sendFailure(res, 409, { message: `local port ${port} is this server's own port on the server`, code: "refused", fix: "run it again: another local port is chosen" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const tunnel = newTunnel(port);
|
|
526
|
+
tunnels.push(tunnel);
|
|
527
|
+
res.once("close", () => {
|
|
528
|
+
tunnels.splice(tunnels.indexOf(tunnel), 1);
|
|
529
|
+
ctx.log(`web page: the tunnel from local port ${port} closed`);
|
|
530
|
+
});
|
|
531
|
+
ctx.log(`web page: a tunnel opened from local port ${port}`);
|
|
532
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
|
533
|
+
res.write(`${JSON.stringify({ ticket: tunnel.ticket })}\n`);
|
|
534
|
+
}
|
|
535
|
+
/** How the run's server listens: on 127.0.0.1, and there is no other way (the header says why). */
|
|
536
|
+
export function listenOnLoopback(server, port) {
|
|
537
|
+
return new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
|
538
|
+
}
|
|
437
539
|
export function createRunServer(ctx) {
|
|
438
540
|
const saidErrors = new Set();
|
|
439
541
|
// the last day's and week's statements, read at most once every SUMMARY_TTL_MS for every page and command that asks
|
|
440
542
|
const summary = timedCache(() => ctx.duck.requests.summary(), SUMMARY_TTL_MS);
|
|
441
|
-
//
|
|
543
|
+
// the tunnels open now (`openTunnel`): the only Hosts the guard takes beside this server's own
|
|
544
|
+
const tunnels = [];
|
|
545
|
+
// read once, here: nothing a request names is ever read from disk
|
|
442
546
|
const page = ctx.page ? pageFiles() : new Map();
|
|
443
547
|
return http.createServer((req, res) => {
|
|
444
548
|
for (const [k, v] of Object.entries(SECURITY_HEADERS))
|
|
445
549
|
res.setHeader(k, v);
|
|
446
|
-
const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token);
|
|
550
|
+
const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token, tunnels);
|
|
447
551
|
if (!v.ok) {
|
|
448
552
|
if (v.status === 303) {
|
|
449
|
-
res.writeHead(303, { Location: v.location, "Set-Cookie": v.setCookie }).end();
|
|
553
|
+
res.writeHead(303, { Location: v.location, ...(v.setCookie !== undefined ? { "Set-Cookie": v.setCookie } : {}) }).end();
|
|
450
554
|
return;
|
|
451
555
|
}
|
|
452
556
|
res.writeHead(v.status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${v.reason}\n`);
|
|
@@ -593,9 +697,52 @@ export function createRunServer(ctx) {
|
|
|
593
697
|
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
594
698
|
}
|
|
595
699
|
}
|
|
700
|
+
else if (req.method === "POST" && url.pathname === "/api/ui/tunnel") {
|
|
701
|
+
await openTunnel(ctx, tunnels, req, res);
|
|
702
|
+
}
|
|
596
703
|
else if (req.method === "POST" && url.pathname === "/api/share") {
|
|
597
704
|
await openShare(ctx, req, res);
|
|
598
705
|
}
|
|
706
|
+
else if (req.method === "GET" && url.pathname === "/api/share/choices") {
|
|
707
|
+
// what a share with no role named reads, or the roles to choose from: the page asks before it asks the person
|
|
708
|
+
if (ctx.pageShares === undefined) {
|
|
709
|
+
sendFailure(res, 409, SERVED_SHARE);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const r = await ctx.pageShares.choices();
|
|
713
|
+
if ("failure" in r && r.choices === undefined) {
|
|
714
|
+
sendFailure(res, statusOf(r.failure), r.failure);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
sendJson(res, 200, "failure" in r ? { choices: r.choices } : { choices: [], role: r.role, reads: r.reads });
|
|
718
|
+
}
|
|
719
|
+
else if ((req.method === "POST" && /^\/api\/share\/[^/]+\/beat$/.test(url.pathname)) || (req.method === "DELETE" && /^\/api\/share\/[^/]+$/.test(url.pathname))) {
|
|
720
|
+
// the page's share: a beat holds it, a DELETE is Stop sharing; a share of mason share's is its command's to end
|
|
721
|
+
if (ctx.pageShares === undefined || ctx.shares === undefined) {
|
|
722
|
+
sendFailure(res, 409, SERVED_SHARE);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
let user;
|
|
726
|
+
try {
|
|
727
|
+
user = decodeURIComponent(url.pathname.split("/")[3]);
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
throw new Refusal(400, `${url.pathname} does not name a share`);
|
|
731
|
+
}
|
|
732
|
+
const from = ctx.shares.fromOf(user);
|
|
733
|
+
if (from === "command") {
|
|
734
|
+
sendFailure(res, 409, { message: `the share ${user} is mason share's: Ctrl-C in its terminal ends it`, code: "refused" });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const ended = ctx.shares.ended;
|
|
738
|
+
const why = ended?.user === user ? `: ${ended.why}` : "";
|
|
739
|
+
const ok = req.method === "DELETE" ? await ctx.pageShares.stop(user) : ctx.pageShares.beat(user);
|
|
740
|
+
if (!ok) {
|
|
741
|
+
sendFailure(res, 404, { message: `the share ${user} is not open${why === "" ? "" : `; it ended${why}`}`, code: "not_found", fix: "Connect from anywhere opens a new one, with a new address and password" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
sendJson(res, 200, { ok: true });
|
|
745
|
+
}
|
|
599
746
|
else if (req.method === "POST" && /^\/api\/share\/[^/]+$/.test(url.pathname)) {
|
|
600
747
|
// what the command knows of its tunnel, as it knows it: mason status says it, and the run stops the client
|
|
601
748
|
const body = tunnelReport(JSON.parse(await readBody(req, 64 * 1024)));
|
|
@@ -629,6 +776,8 @@ export function createRunServer(ctx) {
|
|
|
629
776
|
throw new Refusal(400, "send { name, role }: the role's name, a string");
|
|
630
777
|
if (body.set !== undefined && !(Array.isArray(body.set) && body.set.every((x) => typeof x === "string")))
|
|
631
778
|
throw new Refusal(400, "send { name, role, set }: set is a list of \"attribute=value\" strings, as mason user add --set takes them");
|
|
779
|
+
if (body.from !== undefined && !(Array.isArray(body.from) && body.from.every((x) => typeof x === "string")))
|
|
780
|
+
throw new Refusal(400, "send { name, from }: from is a list of address ranges (\"10.0.0.0/16\"), as mason user add --from takes them");
|
|
632
781
|
// the role must be one the roles file names NOW (src/cli/user.ts roleToAdd): refused before anything is written
|
|
633
782
|
const roleProblem = body.role === undefined ? null : roleToAdd(body.role, ctx.roles?.() ?? NO_ROLES);
|
|
634
783
|
if (roleProblem !== null) {
|
|
@@ -644,7 +793,7 @@ export function createRunServer(ctx) {
|
|
|
644
793
|
}
|
|
645
794
|
// mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
|
|
646
795
|
const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database, roles: ctx.roles?.() ?? NO_ROLES,
|
|
647
|
-
...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values });
|
|
796
|
+
...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values, ...(Array.isArray(body.from) ? { from: body.from } : {}) });
|
|
648
797
|
if ("message" in r) {
|
|
649
798
|
sendFailure(res, statusOf(r), r);
|
|
650
799
|
return;
|
|
@@ -653,6 +802,27 @@ export function createRunServer(ctx) {
|
|
|
653
802
|
ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
|
|
654
803
|
sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
|
|
655
804
|
}
|
|
805
|
+
else if (req.method === "POST" && /^\/api\/users\/[^/]+\/from$/.test(url.pathname)) {
|
|
806
|
+
// mason user from --json's shape, through its own function: the user's address ranges replaced (null: any address)
|
|
807
|
+
let name;
|
|
808
|
+
try {
|
|
809
|
+
name = decodeURIComponent(url.pathname.split("/")[3]);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
throw new Refusal(400, `${url.pathname} does not name a user: POST /api/users/<name>/from`);
|
|
813
|
+
}
|
|
814
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
815
|
+
if (!(body.from === null || (Array.isArray(body.from) && body.from.every((x) => typeof x === "string"))))
|
|
816
|
+
throw new Refusal(400, "send { from }: a list of address ranges (\"10.0.0.0/16\"), or null for any address");
|
|
817
|
+
const r = setUserFrom(ctx.users.file, name, body.from);
|
|
818
|
+
if ("message" in r) {
|
|
819
|
+
sendFailure(res, statusOf(r), r);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (!r.already)
|
|
823
|
+
ctx.log(`user ${r.changed.name} connects from ${rangesText(r.changed.from)}`);
|
|
824
|
+
sendJson(res, 200, { ...r, next: nextJson(fromNext(r.changed.name, r.changed.from)) });
|
|
825
|
+
}
|
|
656
826
|
else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
|
|
657
827
|
// mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
|
|
658
828
|
const [, , , encoded, verb] = url.pathname.split("/");
|