@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,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), as an ISO time; a time with no zone is refused, as is anything else.
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 = AGE.exec(text.trim());
28
- if (age !== null)
29
- return new Date(now - Number(age[1]) * AGE_MS[age[2]]).toISOString();
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)");
@@ -371,7 +371,8 @@ export function roleProblems(roles, copied) {
371
371
  if (typeof th === "string")
372
372
  problems.push(th);
373
373
  }
374
- if (c.columns === null)
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`'s run serves no page.
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: c?.phase === "failed" ? c.error : null,
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
- // read once, here: nothing a request names is ever read from disk (mason serve's run serves no page)
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("/");