@mason-data/cli 0.8.0 → 0.9.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -0,0 +1,76 @@
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
+ /** The replica the command means: `--replica`, else `MASON_REPLICA`; null when neither names one, a Failure when it is
8
+ * not `<org>/<name>` (never repeated beyond 200 characters, made printable). `usage`: the command's, said after a wrong
9
+ * `--replica`. */
10
+ export function replicaOf(flag, usage, env = process.env) {
11
+ const from = flag !== undefined ? "--replica" : env[REPLICA_ENV] !== undefined ? REPLICA_ENV : null;
12
+ if (from === null)
13
+ return null;
14
+ return replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
15
+ }
16
+ /** `raw` as a replica's name, `<org>/<name>`, each part `REPLICA_PART`: the one grammar of `--replica`, `MASON_REPLICA`
17
+ * and a command's own argument (`mason replica status <org>/<name>`); a Failure otherwise, a wrong line's (`usage`)
18
+ * unless the environment named it. */
19
+ export function replicaNamed(raw, from, usage) {
20
+ const parts = raw.split("/");
21
+ if (parts.length === 2 && REPLICA_PART.test(parts[0]) && REPLICA_PART.test(parts[1]))
22
+ return { org: parts[0], name: parts[1], from };
23
+ const named = from === "argument" ? "the replica" : from;
24
+ return { message: `${named} ${JSON.stringify(serviceText(raw, 200))} is not <org>/<name>`, code: from === REPLICA_ENV ? "refused" : "usage",
25
+ 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",
26
+ 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)"
27
+ : `set ${REPLICA_ENV}=<org>/<name> (acme/shop), or unset it and pass --replica`,
28
+ ...(from === REPLICA_ENV ? {} : { usage }) };
29
+ }
30
+ /** `raw` as an organisation's slug (`REPLICA_PART`: safe in a path as it is), or a wrong line's Failure. */
31
+ export function orgNamed(raw, usage) {
32
+ if (REPLICA_PART.test(raw))
33
+ return raw;
34
+ return { message: `the organisation ${JSON.stringify(serviceText(raw, 200))} is not an organisation's slug`, code: "usage",
35
+ 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 };
36
+ }
37
+ /** How many organisations a command reads when none is named (`loginOrgs`): past it, it asks for one. */
38
+ export const MAX_ORGS = 20;
39
+ /**
40
+ * THE ORGANISATIONS A COMMAND ACTS IN WHEN NONE IS NAMED: the login's own, as `GET /v1/me` says them (src/cli/hosted.ts
41
+ * `whoAmI`, its answer held there), those where its role is one of `roles` (every role when absent), each slug held to
42
+ * `REPLICA_PART` before it is in a path. A Failure when the service cannot say (a CI token is no person: 403), when a
43
+ * slug is outside the grammar, or past `MAX_ORGS`; `again` is the command that names one.
44
+ */
45
+ export async function loginOrgs(service, token, again, roles) {
46
+ const me = await whoAmI(service, token);
47
+ const name = [again, "name the organisation"];
48
+ if (isFailure(me))
49
+ return { ...me, next: [name, ...(me.next ?? [])] };
50
+ if (!me.orgs.every((o) => REPLICA_PART.test(o.slug)))
51
+ return { message: `${service.base} named an organisation whose slug is not one`, code: "failed",
52
+ 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] };
53
+ const orgs = me.orgs.filter((o) => roles === undefined || roles.includes(o.role)).map((o) => ({ slug: o.slug, role: o.role }));
54
+ if (orgs.length > MAX_ORGS)
55
+ return { message: `this login is in ${orgs.length} organisations, more than the ${MAX_ORGS} a command reads when none is named`, code: "refused",
56
+ fix: `name the organisation: ${again}`, next: [name] };
57
+ return orgs;
58
+ }
59
+ /**
60
+ * The token a request to a replica is sent with: MASON_TOKEN, else this machine's login to the service; a Failure naming
61
+ * mason login. `asked`: what the command sends, in its words ("a deploy is sent"); `again`: the command to run once
62
+ * signed in ("mason deploy").
63
+ */
64
+ export function tokenFor(service, o) {
65
+ const env = environmentToken();
66
+ if (env !== null)
67
+ return env;
68
+ const read = readLogins();
69
+ if (isFailure(read))
70
+ return read;
71
+ const saved = read.logins.logins[service.base];
72
+ if (saved !== undefined)
73
+ return saved.token;
74
+ 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)`,
75
+ 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}`]] };
76
+ }
@@ -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,7 +27,7 @@ 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";
@@ -34,6 +38,7 @@ import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabled
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)));
@@ -26,6 +26,16 @@
26
26
  * `mason_dev_<port>`): two runs on one machine each keep their own, and opening one's page never logs the other's out
27
27
  * (one name for every run did). A page of another run, or one the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
28
28
  * (tests/cli-security.test.ts).
29
+ *
30
+ * THROUGH A TUNNEL (`mason ui --ssh`, src/cli/ui-ssh.ts): the browser is on another machine, on a local port of its own,
31
+ * so its Host is 127.0.0.1:<that port>. The guard never takes any other Host: a tunnel is a `PageTunnel`, opened by a
32
+ * process holding the run's token (`POST /api/ui/tunnel`) and gone with that process's connection, and only the loopback
33
+ * names at ITS port are added, for as long as it lasts. A DNS-rebinding page still names itself, and is refused by that
34
+ * name. The run's token never crosses: the laptop gets a TICKET, one use and minutes old, because the URL a browser is
35
+ * opened at is on a command line; the first request trades it for a cookie holding the tunnel's own SESSION, named for
36
+ * the laptop's port, which dies with the tunnel. So nothing that reaches the laptop outlives it. The tunnel itself is raw
37
+ * TCP to this server, so a client chooses its path by the Host it sends: Bearer is refused by a tunnel's Host, and by the
38
+ * server's own Host it is the commands' path, which needs the token, which never crosses.
29
39
  */
30
40
  import { randomBytes, timingSafeEqual } from "node:crypto";
31
41
  /** The cookie of the run whose server is on `port`: one per run, as a browser shares a host's cookies across ports. */
@@ -44,12 +54,55 @@ export function cookieOf(header, name) {
44
54
  }
45
55
  return null;
46
56
  }
57
+ /** How long a tunnel's ticket can be traded: a person with `--no-open` pastes it; nobody needs it longer. */
58
+ export const TICKET_MS = 5 * 60_000;
59
+ export function newTunnel(port, now = Date.now()) {
60
+ return { port, ticket: newToken(), ticketUntil: now + TICKET_MS, session: newToken() };
61
+ }
47
62
  export function setCookieHeader(token, port) {
48
63
  return `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
49
64
  }
50
- /** Whether `req` may be served by the server on `port` whose run token is `token`. */
51
- export function guard(req, port, token) {
52
- const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
65
+ /** The Host a browser may send for a loopback port: its two names, that port, and nothing else. */
66
+ const loopbackHosts = (port) => [`127.0.0.1:${port}`, `localhost:${port}`];
67
+ /** A change must come from the page at `hosts`: its Origin is one of them. */
68
+ function foreignOrigin(req, hosts) {
69
+ if (req.method === "GET" || req.method === "HEAD")
70
+ return null;
71
+ if (req.origin && hosts.map((h) => `http://${h}`).includes(req.origin.toLowerCase()))
72
+ return null;
73
+ return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
74
+ }
75
+ /** A request by the Host of a tunnel's port (the header says why it is its own path): a ticket, or the session's cookie. */
76
+ function throughTunnel(req, tunnels, now) {
77
+ const port = tunnels[0].port;
78
+ if (req.authorization !== undefined)
79
+ return { ok: false, status: 403, reason: "a tunnel carries a browser, never a command: the run's token is not sent through one" };
80
+ const cookie = cookieOf(req.cookie, cookieName(port));
81
+ const held = cookie !== null && tunnels.some((t) => sameSecret(t.session, cookie));
82
+ const url = new URL(req.url, "http://tunnel");
83
+ const offered = url.searchParams.get("ticket");
84
+ if (offered !== null && (req.method === "GET" || req.method === "HEAD")) {
85
+ url.searchParams.delete("ticket");
86
+ const location = url.pathname + (url.search || "") + url.hash;
87
+ const t = tunnels.find((x) => x.ticket !== null && now < x.ticketUntil && sameSecret(x.ticket, offered));
88
+ if (t !== undefined) {
89
+ t.ticket = null;
90
+ return { ok: false, status: 303, location, setCookie: setCookieHeader(t.session, port) };
91
+ }
92
+ if (held)
93
+ return { ok: false, status: 303, location };
94
+ return { ok: false, status: 403, reason: "this ticket was used already, or is too old: mason ui --ssh gives a new one" };
95
+ }
96
+ if (!held)
97
+ return { ok: false, status: 403, reason: "this tunnel's page takes its ticket or its cookie: mason ui --ssh opens it" };
98
+ return foreignOrigin(req, loopbackHosts(port)) ?? { ok: true };
99
+ }
100
+ /** Whether `req` may be served by the server on `port` whose run token is `token`, `tunnels` the ones open now. */
101
+ export function guard(req, port, token, tunnels = [], now = Date.now()) {
102
+ const hosts = loopbackHosts(port);
103
+ const through = tunnels.filter((t) => loopbackHosts(t.port).includes((req.host ?? "").toLowerCase()));
104
+ if (through.length > 0 && !hosts.includes((req.host ?? "").toLowerCase()))
105
+ return throughTunnel(req, through, now);
53
106
  if (!req.host || !hosts.includes(req.host.toLowerCase())) {
54
107
  return { ok: false, status: 421, reason: `this server answers only as ${hosts[0]} (the request named ${JSON.stringify(req.host ?? "")})` };
55
108
  }
@@ -70,13 +123,7 @@ export function guard(req, port, token) {
70
123
  if (cookie === null || !sameSecret(cookie, token)) {
71
124
  return { ok: false, status: 403, reason: "this is mason start's command server for the mason command: a request carries the run's token (Authorization: Bearer, from .mason/config.json)" };
72
125
  }
73
- if (req.method !== "GET" && req.method !== "HEAD") {
74
- const own = hosts.map((h) => `http://${h}`);
75
- if (!req.origin || !own.includes(req.origin.toLowerCase())) {
76
- return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
77
- }
78
- }
79
- return { ok: true };
126
+ return foreignOrigin(req, hosts) ?? { ok: true };
80
127
  }
81
128
  /** The headers every response of a guarded server carries. */
82
129
  export const SECURITY_HEADERS = {
@@ -17,15 +17,20 @@
17
17
  * (counts, lag, ages, sizes) for the same reason: never a table's, a user's or a host's name, never a statement.
18
18
  * - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
19
19
  *
20
+ * - ITS WEB PAGE ON LOOPBACK ALONE (src/cli/run-server.ts `listenOnLoopback`): the run's server, MASON_UI_PORT, reached
21
+ * from a developer's machine with `mason ui --ssh`, never published. No setting moves it to another address.
22
+ *
20
23
  * The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
21
24
  */
22
25
  import * as fs from "node:fs";
23
26
  import * as http from "node:http";
24
27
  import * as path from "node:path";
25
28
  import { failed, parseCommand } from "./command.js";
29
+ import { DEFAULT_UI_PORT, heldText, portHolder } from "./ports.js";
30
+ import { pgListenerAddress } from "../api/postgres/listener.js";
26
31
  import { dev } from "./dev.js";
27
32
  import { parseSource } from "./preflight.js";
28
- import { NAME_RE, newConfig, readProject, STATE_DIR, writeConfig } from "./project.js";
33
+ import { NAME_RE, newConfig, projectReadOnlySetting, projectWriteRefusal, readProject, STATE_DIR, writeConfig } from "./project.js";
29
34
  import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir, sourcesTop, writtenDatabase } from "./sources.js";
30
35
  import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/database-name.js";
31
36
  import { appEnvValue } from "../lib/dotenv.js";
@@ -34,6 +39,8 @@ import { commandPinProblem } from "./pin.js";
34
39
  import { recordRefusal } from "./replication-mode.js";
35
40
  import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
36
41
  import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
42
+ import { DEFAULT_MEMORY_RULE, memoryProblems, memoryProblemText, memorySize, MEMORY_FLOOR_BYTES } from "../duck/store.js";
43
+ import { wrap } from "./style.js";
37
44
  import { runningVersion, version } from "./version.js";
38
45
  import { phaseCounts } from "./readiness.js";
39
46
  import { METRICS_CONTENT_TYPE, metricsText } from "./metrics.js";
@@ -47,7 +54,7 @@ export const SERVE = {
47
54
  GET /metrics for Prometheus (numbers only: no name, no statement), and a clean stop on SIGTERM. The tables it
48
55
  syncs are the ones the project folder's sources file names (sources/<name>.yml): make it with mason init and mason
49
56
  add on a laptop and commit it, or with mason add beside the running server. A project folder with no sources file
50
- gets one, url: \${DATABASE_URL}, with no table yet.
57
+ gets one, url: \${DATABASE_URL}, with no table yet — unless MASON_PROJECT_READONLY=1, which writes none.
51
58
 
52
59
  DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
53
60
  docker compose exec mason mason add public.orders beside it, in its container
@@ -60,23 +67,36 @@ export const SERVE = {
60
67
  this one), else this; default none: Mason makes its own
61
68
  MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
62
69
  MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
70
+ MASON_PROJECT_READONLY 1 when the project folder is deployed as committed (CI, GitOps, a platform that deploys
71
+ the folder): every edit of its files is refused — mason add and mason remove, beside it or
72
+ on the web page — and says to edit the files and deploy them (default 0)
63
73
  MASON_DATA the folder whose .mason/ holds the synced data and the state: the volume (default .)
64
74
  MASON_NAME the project's name, which the replication slot is named for (default mason); renaming it
65
75
  over the same synced data is refused
66
76
  MASON_PG_DATABASE the database name a tool connects with, for a sources file with no database: (default mason;
67
77
  the two must agree when both are set)
68
78
  MASON_HEALTH_PORT /healthz's and /metrics' port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
79
+ MASON_UI_PORT the web page's and the commands' port, on 127.0.0.1 alone: no setting moves the address, and
80
+ mason ui --ssh <user@server> opens the page from your machine (default ${DEFAULT_UI_PORT}, or the next
81
+ free one; a port named here is that port, or the start is refused)
69
82
  MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
70
83
  MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
71
84
  for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
72
- MASON_DUCKDB_MEMORY the engine's memory, 4GB or 512MiB (default half the container's memory limit, else half the
73
- machine's RAM; mason doctor says it); a statement past it fails alone with 53200
85
+ MASON_PG_PROXY_FROM behind a TCP proxy or load balancer that sends the PROXY protocol v2: its own addresses,
86
+ CIDRs comma-separated (10.0.0.0/16); a connection from one must begin with a header, whose
87
+ client the request log names; a header from any other address is refused (default unset:
88
+ none is read)
89
+ MASON_DUCKDB_MEMORY ${wrap(`the engine's memory, 4GB or 512MiB (default ${DEFAULT_MEMORY_RULE}; mason doctor says it); a statement past it fails alone with 53200. Unset, under ${memorySize(MEMORY_FLOOR_BYTES)} Mason starts and warns: the system may stop it`, 120, 28)}
90
+ MASON_SPILL_LIMIT what statements may spill to disk, 40GB or 512MiB (default half of what is free, followed as
91
+ the disk fills; never past 90% of it); a statement past it fails alone with 53400
74
92
  MASON_STATEMENT_TIMEOUT each statement's limit, as Postgres's statement_timeout (default 5min; 0 for none)
75
93
  MASON_HISTORY_DAYS how many days Mason keeps its history: the request log, the sync's events, the usage
76
94
  samples and the synced tables' sizes (default 7)
77
95
  MASON_STORE_JSON_PATHS on (the default) stores the JSON paths statements keep reading as columns; off stores none
78
96
  MASON_COPY_PARALLEL a first sync's chunks read at once, each a backend on the database (1 to 8, default 3)
79
97
  MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
98
+ UV_THREADPOOL_SIZE threads for running statements, one each, 1 to 1024 (default 32, lowered to fit a limit
99
+ on threads; one that does not fit, or is no such number, refuses the start)
80
100
 
81
101
  --json a refusal to start as data: { error, next }; once it serves, its output is its log
82
102
 
@@ -86,6 +106,11 @@ export const SERVE = {
86
106
  json: ["next"],
87
107
  };
88
108
  const MIN_API_KEY = 32;
109
+ /** The Postgres API's port as the settings say it; null while MASON_PG_PORT is wrong, which the run's own check says. */
110
+ function pgPortOf(env) {
111
+ const a = pgListenerAddress(env, "127.0.0.1");
112
+ return "problem" in a ? null : a.port;
113
+ }
89
114
  /** THE SETTINGS, OR EVERY ONE MISSING OR WRONG AT ONCE: a platform's deploy is slow, and one variable a deploy wastes it. */
90
115
  export function serveSettings(env, cwd) {
91
116
  const wrong = [];
@@ -116,6 +141,13 @@ export function serveSettings(env, cwd) {
116
141
  const days = historyDaysSetting(env);
117
142
  if (typeof days !== "number")
118
143
  wrong.push(historyDaysWrongText(days));
144
+ // MASON_DUCKDB_MEMORY not an amount (src/duck/store.ts memoryProblems), said here with the rest, before anything is
145
+ // written or connected; a container under the floor starts, and the run warns (memoryWarning)
146
+ wrong.push(...memoryProblems(env).map(memoryProblemText));
147
+ // the project's files deployed read-only (src/cli/project.ts projectWriteRefusal): 1 or 0, anything else refused here
148
+ const readOnly = projectReadOnlySetting(env);
149
+ if (typeof readOnly !== "boolean")
150
+ wrong.push(readOnly.problem);
119
151
  const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
120
152
  if (selfSigned !== "0" && selfSigned !== "1")
121
153
  wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
@@ -123,6 +155,17 @@ export function serveSettings(env, cwd) {
123
155
  const port = Number(portText);
124
156
  if (!/^\d+$/.test(portText) || port < 1 || port > 65_535)
125
157
  wrong.push(`MASON_HEALTH_PORT=${JSON.stringify(portText)}: a port number, 1-65535`);
158
+ const uiText = env.MASON_UI_PORT || String(DEFAULT_UI_PORT);
159
+ const uiPort = Number(uiText);
160
+ if (!/^\d+$/.test(uiText) || uiPort < 1 || uiPort > 65_535)
161
+ wrong.push(`MASON_UI_PORT=${JSON.stringify(uiText)}: a port number, 1-65535`);
162
+ else if (env.MASON_UI_PORT && uiPort === port)
163
+ wrong.push(`MASON_UI_PORT=${uiText} is MASON_HEALTH_PORT too: two ports`);
164
+ else if (env.MASON_UI_PORT && uiPort === pgPortOf(env))
165
+ wrong.push(`MASON_UI_PORT=${uiText} is the Postgres API's port too (MASON_PG_PORT): two ports`);
166
+ // a host would only be believed: the page has no address but loopback, and running on while one is asked for would hide that
167
+ if (env.MASON_UI_HOST)
168
+ wrong.push("MASON_UI_HOST is set, and the web page has no address but 127.0.0.1: remove it (mason ui --ssh <user@server> opens the page from your machine)");
126
169
  if (wrong.length) {
127
170
  return { message: `${wrong.length === 1 ? "a setting is" : `${wrong.length} settings are`} missing or wrong: ${wrong.join("; ")}`, code: "bad_config",
128
171
  why: "mason serve reads its settings from the environment, and asks nothing",
@@ -133,6 +176,7 @@ export function serveSettings(env, cwd) {
133
176
  name, apiKey, selfSignedTls: selfSigned === "1",
134
177
  project, data,
135
178
  health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
179
+ ui: { port: uiPort, named: !!env.MASON_UI_PORT },
136
180
  };
137
181
  }
138
182
  /**
@@ -186,8 +230,8 @@ function urlSettingProblems(project, data, env) {
186
230
  const found = appEnvValue(data, variable, env, { shellFirst: true });
187
231
  if (found === null)
188
232
  return [`${variable} is not set: the database to sync from, as the sources file's url: \${${variable}} names it`];
189
- const p = parseSource(found.value);
190
- return "problem" in p ? [`${variable}: ${p.problem}`] : [];
233
+ const p = parseSource(found.value, variable);
234
+ return "problem" in p ? [p.problem] : [];
191
235
  }
192
236
  /**
193
237
  * THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
@@ -247,6 +291,14 @@ export function serveSource(s, env) {
247
291
  fix: "set DATABASE_URL=postgres://user:password@host:5432/app (the sources file mason init writes names it), or mount the project folder with its sources/ (MASON_PROJECT)",
248
292
  next: [["mason serve --help", "every setting, with its default"]] };
249
293
  }
294
+ // a deployed project's files are its commit's (src/cli/project.ts projectWriteRefusal: this serve's own environment
295
+ // decides, the config not written yet): a folder deployed without its sources file is said, never filled in
296
+ const readOnly = projectWriteRefusal(null, env);
297
+ if (readOnly !== null) {
298
+ return { ...readOnly, message: `the project folder (${s.project}) has no sources file, and ${readOnly.message}`,
299
+ fix: "edit the files and deploy them: mason init and mason add where the project is committed write its sources file; commit it, and deploy the folder with it",
300
+ next: [["mason serve --help", "every setting, with its default"]] };
301
+ }
250
302
  const p = parseSource(url);
251
303
  const file = path.join(sourcesDir(s.project), `${sourceNameFor("source" in p ? p.source.database : "")}.yml`);
252
304
  try {
@@ -365,6 +417,10 @@ export async function serve(argv) {
365
417
  return failed("serve", { message: `the state cannot be written to ${path.join(s.data, STATE_DIR)}: ${e.message}`, code: "refused",
366
418
  fix: "MASON_DATA names a folder this user can write: the volume's mount point" }, { json });
367
419
  }
420
+ // a port named is that port: said here, before anything listens, as the next free one would hide the setting
421
+ const held = s.ui.named ? await portHolder(s.ui.port) : null;
422
+ if (held !== null)
423
+ return failed("serve", { message: `MASON_UI_PORT: ${heldText(s.ui.port, held)}`, code: "refused", fix: "name another port, or leave MASON_UI_PORT unset for the next free one" }, { json });
368
424
  const startedAt = Date.now();
369
425
  let state = { phase: "starting" };
370
426
  const health = healthServer(() => state, startedAt);
@@ -388,7 +444,7 @@ export async function serve(argv) {
388
444
  process.chdir(s.data);
389
445
  let code;
390
446
  try {
391
- code = await dev({ port: 4039, version: version(), selfSignedTls: s.selfSignedTls,
447
+ code = await dev({ port: s.ui.port, exactPort: s.ui.named, version: version(), selfSignedTls: s.selfSignedTls,
392
448
  serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
393
449
  }
394
450
  finally {