@mason-data/cli 0.9.0 → 0.11.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 (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -4,14 +4,52 @@ import { isFailure, serviceText, whoAmI } from "./hosted.js";
4
4
  export const REPLICA_PART = /^[a-z0-9][a-z0-9-]{0,62}$/;
5
5
  export const REPLICA_ENV = "MASON_REPLICA";
6
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) {
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
+ }
11
29
  const from = flag !== undefined ? "--replica" : env[REPLICA_ENV] !== undefined ? REPLICA_ENV : null;
12
30
  if (from === null)
13
31
  return null;
14
- return replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
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;
15
53
  }
16
54
  /** `raw` as a replica's name, `<org>/<name>`, each part `REPLICA_PART`: the one grammar of `--replica`, `MASON_REPLICA`
17
55
  * and a command's own argument (`mason replica status <org>/<name>`); a Failure otherwise, a wrong line's (`usage`)
@@ -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)");
@@ -28,13 +28,14 @@ import { fileURLToPath } from "node:url";
28
28
  import { readBody, sendJson } from "../lib/http-json.js";
29
29
  import { projectFingerprint } from "../lib/project-files.js";
30
30
  import { guard, newTunnel, SECURITY_HEADERS } from "./security.js";
31
+ import { PAGE_READ_ONLY, PAGE_READ_ONLY_WHY, pageWrite } from "./ui-read-only.js";
31
32
  import { maskedUrl } from "./preflight.js";
32
33
  import { errorJson, nextJson } from "./command.js";
33
34
  import { phaseCounts, readiness, syncTotals, tableSync } from "./readiness.js";
34
35
  import { restartsFact } from "./supervise.js";
35
36
  import { withRestarts } from "../duck/events.js";
36
37
  import { NO_ROLES } from "../duck/grants.js";
37
- import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, listUsers, removeUser, REMOVED_NEXT, roleToAdd, withLastUsed } from "./user.js";
38
+ import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, fromNext, listUsers, rangesText, removeUser, REMOVED_NEXT, roleToAdd, setUserFrom, withLastUsed } from "./user.js";
38
39
  import { userRole } from "./roles.js";
39
40
  import { postgresFacts, postgresLink } from "./pg-url.js";
40
41
  import { catalogTable, parseSourceText, shownPath } from "./sources.js";
@@ -101,6 +102,11 @@ export function runState(ctx, copies, stream, now = Date.now(), { columns = fals
101
102
  // why the page offers no Add or Remove, or null: the project's files deployed read-only (MASON_PROJECT_READONLY), or
102
103
  // a server's project folder mounted read-only — a control that can only fail is not offered
103
104
  sourcesReadOnly: sourcesReadOnly(ctx, sources.file?.file ?? null),
105
+ // why the page offers no control that changes anything (MASON_UI_READ_ONLY=1, src/cli/ui-read-only.ts), or null:
106
+ // every change it would send is refused
107
+ pageReadOnly: ctx.pageReadOnly === true ? PAGE_READ_ONLY_WHY : null,
108
+ // the Unix socket the page is served on too (MASON_UI_SOCKET), or null: mason status says it
109
+ pageSocket: ctx.pageSocket ?? null,
104
110
  source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
105
111
  // `replication` while a table's sync is replication (the run streams), else `poll`: each table's own is its kept
106
112
  version: facts.versionText, standby: facts.standby, changes: repl !== null ? "replication" : "poll", name: sources.file?.name ?? null, file: sources.file?.file ?? null,
@@ -556,23 +562,33 @@ export function createRunServer(ctx) {
556
562
  res.writeHead(v.status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${v.reason}\n`);
557
563
  return;
558
564
  }
565
+ // A READ-ONLY PAGE (MASON_UI_READ_ONLY=1): every change the page asks for is refused by rule, before any route is
566
+ // read, so a route added later is refused too; the commands' requests (the run's token) are served as before
567
+ if (ctx.pageReadOnly === true && pageWrite(req.method, req.headers.authorization)) {
568
+ sendFailure(res, 403, PAGE_READ_ONLY);
569
+ return;
570
+ }
559
571
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${ctx.port}`);
572
+ // HEAD IS GET BY RULE, never route by route: routed as a GET, answered with its status and headers, and its body
573
+ // dropped by Node's own response to a HEAD. It passes nothing GET does not: the guard and the read-only rule above
574
+ // have judged it as the HEAD it is, and only GET routes are reached, none of which changes anything.
575
+ const method = req.method === "HEAD" ? "GET" : req.method;
560
576
  void (async () => {
561
577
  try {
562
- const file = req.method === "GET" || req.method === "HEAD" ? page.get(url.pathname) : undefined;
578
+ const file = method === "GET" ? page.get(url.pathname) : undefined;
563
579
  if (file !== undefined) {
564
580
  res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length, ...(file.immutable ? { "Cache-Control": IMMUTABLE } : {}) })
565
- .end(req.method === "HEAD" ? undefined : file.body);
581
+ .end(file.body);
566
582
  }
567
- else if (req.method === "GET" && url.pathname === "/api/ping") {
583
+ else if (method === "GET" && url.pathname === "/api/ping") {
568
584
  // what proves a run is up (src/cli/runs.ts answers): this token answered, by this process
569
585
  sendJson(res, 200, { pid: process.pid });
570
586
  }
571
- else if (req.method === "GET" && url.pathname === "/api/state") {
587
+ else if (method === "GET" && url.pathname === "/api/state") {
572
588
  // lean, as the page polls it; ?columns=1 with each table's columns (mason status --json)
573
589
  sendJson(res, 200, await state(ctx, url.searchParams.get("columns") === "1"));
574
590
  }
575
- else if (req.method === "GET" && /^\/api\/tables\/[^/]+\/columns$/.test(url.pathname)) {
591
+ else if (method === "GET" && /^\/api\/tables\/[^/]+\/columns$/.test(url.pathname)) {
576
592
  // one table's columns, read when the page's Columns… form opens (never with the state, polled every 2 s)
577
593
  let table;
578
594
  try {
@@ -583,18 +599,18 @@ export function createRunServer(ctx) {
583
599
  }
584
600
  sendJson(res, 200, tableColumns(ctx, table));
585
601
  }
586
- else if (req.method === "GET" && url.pathname === "/api/slow") {
602
+ else if (method === "GET" && url.pathname === "/api/slow") {
587
603
  // the last day's slow statements, from the request log (src/duck/requests.ts)
588
604
  sendJson(res, 200, { slow: await ctx.duck.requests.slow(SLOW_MS), slowMs: SLOW_MS });
589
605
  }
590
- else if (req.method === "GET" && url.pathname === "/api/refused") {
606
+ else if (method === "GET" && url.pathname === "/api/refused") {
591
607
  sendJson(res, 200, await ctx.duck.requests.refused());
592
608
  }
593
- else if (req.method === "GET" && url.pathname === "/api/unsupported") {
609
+ else if (method === "GET" && url.pathname === "/api/unsupported") {
594
610
  // the refused statements grouped by the unsupported SQL they used (src/duck/gaps.ts): mason status
595
611
  sendJson(res, 200, await unsupported(ctx, url.searchParams));
596
612
  }
597
- else if (req.method === "GET" && url.pathname === "/api/requests") {
613
+ else if (method === "GET" && url.pathname === "/api/requests") {
598
614
  // the filters, read as mason logs --requests reads its options (src/cli/request-filter.ts)
599
615
  const q = requestFilter(requestFilterText(url.searchParams));
600
616
  if ("message" in q) {
@@ -603,20 +619,20 @@ export function createRunServer(ctx) {
603
619
  }
604
620
  sendJson(res, 200, { ...await ctx.duck.requests.page(q), keptDays: ctx.duck.requests.days });
605
621
  }
606
- else if (req.method === "GET" && url.pathname === "/api/queries") {
622
+ else if (method === "GET" && url.pathname === "/api/queries") {
607
623
  // the last day's and week's statements in one scan of the request log (mason status); asked on its own, never
608
624
  // with /api/state, which the page asks every 2 s
609
625
  // (cached SUMMARY_TTL_MS: up to 30 s old)
610
626
  sendJson(res, 200, { queries: await summary() });
611
627
  }
612
- else if (req.method === "GET" && url.pathname === "/api/activity") {
628
+ else if (method === "GET" && url.pathname === "/api/activity") {
613
629
  // one time range's statements, clients and slowest (the Usage page's), from the request log
614
630
  sendJson(res, 200, await activity(ctx, url.searchParams));
615
631
  }
616
- else if (req.method === "GET" && url.pathname === "/api/running") {
632
+ else if (method === "GET" && url.pathname === "/api/running") {
617
633
  sendJson(res, 200, { running: ctx.listener.running() });
618
634
  }
619
- else if (req.method === "POST" && url.pathname === "/api/cancel") {
635
+ else if (method === "POST" && url.pathname === "/api/cancel") {
620
636
  const body = JSON.parse(await readBody(req, 64 * 1024));
621
637
  const whole = (x) => typeof x === "number" && Number.isInteger(x) && x >= 1;
622
638
  if (!whole(body.session))
@@ -640,7 +656,7 @@ export function createRunServer(ctx) {
640
656
  }
641
657
  sendFailure(res, 409, { message: `session ${r.idle} is running no statement: nothing to cancel`, code: "refused", why: "its statement ended, or it is between two", next: [running] });
642
658
  }
643
- else if (req.method === "GET" && url.pathname === "/api/logs") {
659
+ else if (method === "GET" && url.pathname === "/api/logs") {
644
660
  // the run's last log entries from memory (mason serve has no file to read): { at, text } each, oldest first
645
661
  const lines = Number(url.searchParams.get("lines") ?? Math.min(40, ctx.logs.capacity));
646
662
  if (!Number.isInteger(lines) || lines < 1 || lines > ctx.logs.capacity)
@@ -649,15 +665,15 @@ export function createRunServer(ctx) {
649
665
  const since = sinceText === null ? undefined : timeParam("since", sinceText, Date.now());
650
666
  sendJson(res, 200, { lines: ctx.logs.recent({ lines, ...(since !== undefined ? { since } : {}) }), kept: ctx.logs.capacity });
651
667
  }
652
- else if (req.method === "GET" && url.pathname === "/api/events") {
668
+ else if (method === "GET" && url.pathname === "/api/events") {
653
669
  // the sync's events and the run's restarts, newest last (src/duck/events.ts): mason logs --events, mason status
654
670
  sendJson(res, 200, await events(ctx, url.searchParams));
655
671
  }
656
- else if (req.method === "GET" && url.pathname === "/api/usage") {
672
+ else if (method === "GET" && url.pathname === "/api/usage") {
657
673
  // usage over time, a sample a minute (src/duck/usage.ts): mason usage
658
674
  sendJson(res, 200, await usage(ctx, url.searchParams));
659
675
  }
660
- else if (req.method === "POST" && url.pathname === "/api/pick") {
676
+ else if (method === "POST" && url.pathname === "/api/pick") {
661
677
  const body = JSON.parse(await readBody(req, 64 * 1024));
662
678
  if (typeof body.table !== "string" || typeof body.picked !== "boolean")
663
679
  throw new Refusal(400, "send { table, picked, columns?, key?, sync?, keep? }");
@@ -697,13 +713,13 @@ export function createRunServer(ctx) {
697
713
  sendJson(res, 200, { ...r, state: await state(ctx) });
698
714
  }
699
715
  }
700
- else if (req.method === "POST" && url.pathname === "/api/ui/tunnel") {
716
+ else if (method === "POST" && url.pathname === "/api/ui/tunnel") {
701
717
  await openTunnel(ctx, tunnels, req, res);
702
718
  }
703
- else if (req.method === "POST" && url.pathname === "/api/share") {
719
+ else if (method === "POST" && url.pathname === "/api/share") {
704
720
  await openShare(ctx, req, res);
705
721
  }
706
- else if (req.method === "GET" && url.pathname === "/api/share/choices") {
722
+ else if (method === "GET" && url.pathname === "/api/share/choices") {
707
723
  // what a share with no role named reads, or the roles to choose from: the page asks before it asks the person
708
724
  if (ctx.pageShares === undefined) {
709
725
  sendFailure(res, 409, SERVED_SHARE);
@@ -716,7 +732,7 @@ export function createRunServer(ctx) {
716
732
  }
717
733
  sendJson(res, 200, "failure" in r ? { choices: r.choices } : { choices: [], role: r.role, reads: r.reads });
718
734
  }
719
- else if ((req.method === "POST" && /^\/api\/share\/[^/]+\/beat$/.test(url.pathname)) || (req.method === "DELETE" && /^\/api\/share\/[^/]+$/.test(url.pathname))) {
735
+ else if ((method === "POST" && /^\/api\/share\/[^/]+\/beat$/.test(url.pathname)) || (method === "DELETE" && /^\/api\/share\/[^/]+$/.test(url.pathname))) {
720
736
  // 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
737
  if (ctx.pageShares === undefined || ctx.shares === undefined) {
722
738
  sendFailure(res, 409, SERVED_SHARE);
@@ -736,14 +752,14 @@ export function createRunServer(ctx) {
736
752
  }
737
753
  const ended = ctx.shares.ended;
738
754
  const why = ended?.user === user ? `: ${ended.why}` : "";
739
- const ok = req.method === "DELETE" ? await ctx.pageShares.stop(user) : ctx.pageShares.beat(user);
755
+ const ok = method === "DELETE" ? await ctx.pageShares.stop(user) : ctx.pageShares.beat(user);
740
756
  if (!ok) {
741
757
  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
758
  return;
743
759
  }
744
760
  sendJson(res, 200, { ok: true });
745
761
  }
746
- else if (req.method === "POST" && /^\/api\/share\/[^/]+$/.test(url.pathname)) {
762
+ else if (method === "POST" && /^\/api\/share\/[^/]+$/.test(url.pathname)) {
747
763
  // what the command knows of its tunnel, as it knows it: mason status says it, and the run stops the client
748
764
  const body = tunnelReport(JSON.parse(await readBody(req, 64 * 1024)));
749
765
  if (body === null)
@@ -755,7 +771,7 @@ export function createRunServer(ctx) {
755
771
  }
756
772
  sendJson(res, 200, { ok: true });
757
773
  }
758
- else if (req.method === "GET" && url.pathname === "/api/users") {
774
+ else if (method === "GET" && url.pathname === "/api/users") {
759
775
  // mason user list --json's shape: the users, and whether each connects (and with ?lastUsed=1 when each was last used)
760
776
  const r = listUsers(ctx.users.file, ctx.users.apiKey, postgresLink(ctx.users.postgresUrl), ctx.roles?.() ?? NO_ROLES);
761
777
  if ("message" in r) {
@@ -768,7 +784,7 @@ export function createRunServer(ctx) {
768
784
  : { users: r.users, lastUsedNote: LAST_USED_NOT_ASKED, requestLogDays: ctx.duck.requests.days };
769
785
  sendJson(res, 200, { ...used, urlNote: null, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
770
786
  }
771
- else if (req.method === "POST" && url.pathname === "/api/users") {
787
+ else if (method === "POST" && url.pathname === "/api/users") {
772
788
  const body = JSON.parse(await readBody(req, 64 * 1024));
773
789
  if (typeof body.name !== "string")
774
790
  throw new Refusal(400, "send { name }: the user's name");
@@ -776,6 +792,8 @@ export function createRunServer(ctx) {
776
792
  throw new Refusal(400, "send { name, role }: the role's name, a string");
777
793
  if (body.set !== undefined && !(Array.isArray(body.set) && body.set.every((x) => typeof x === "string")))
778
794
  throw new Refusal(400, "send { name, role, set }: set is a list of \"attribute=value\" strings, as mason user add --set takes them");
795
+ if (body.from !== undefined && !(Array.isArray(body.from) && body.from.every((x) => typeof x === "string")))
796
+ 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");
779
797
  // the role must be one the roles file names NOW (src/cli/user.ts roleToAdd): refused before anything is written
780
798
  const roleProblem = body.role === undefined ? null : roleToAdd(body.role, ctx.roles?.() ?? NO_ROLES);
781
799
  if (roleProblem !== null) {
@@ -791,7 +809,7 @@ export function createRunServer(ctx) {
791
809
  }
792
810
  // mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
793
811
  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,
794
- ...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values });
812
+ ...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values, ...(Array.isArray(body.from) ? { from: body.from } : {}) });
795
813
  if ("message" in r) {
796
814
  sendFailure(res, statusOf(r), r);
797
815
  return;
@@ -800,7 +818,28 @@ export function createRunServer(ctx) {
800
818
  ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
801
819
  sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
802
820
  }
803
- else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
821
+ else if (method === "POST" && /^\/api\/users\/[^/]+\/from$/.test(url.pathname)) {
822
+ // mason user from --json's shape, through its own function: the user's address ranges replaced (null: any address)
823
+ let name;
824
+ try {
825
+ name = decodeURIComponent(url.pathname.split("/")[3]);
826
+ }
827
+ catch {
828
+ throw new Refusal(400, `${url.pathname} does not name a user: POST /api/users/<name>/from`);
829
+ }
830
+ const body = JSON.parse(await readBody(req, 64 * 1024));
831
+ if (!(body.from === null || (Array.isArray(body.from) && body.from.every((x) => typeof x === "string"))))
832
+ throw new Refusal(400, "send { from }: a list of address ranges (\"10.0.0.0/16\"), or null for any address");
833
+ const r = setUserFrom(ctx.users.file, name, body.from);
834
+ if ("message" in r) {
835
+ sendFailure(res, statusOf(r), r);
836
+ return;
837
+ }
838
+ if (!r.already)
839
+ ctx.log(`user ${r.changed.name} connects from ${rangesText(r.changed.from)}`);
840
+ sendJson(res, 200, { ...r, next: nextJson(fromNext(r.changed.name, r.changed.from)) });
841
+ }
842
+ else if (method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
804
843
  // mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
805
844
  const [, , , encoded, verb] = url.pathname.split("/");
806
845
  let name;
@@ -831,7 +870,7 @@ export function createRunServer(ctx) {
831
870
  sendJson(res, 200, { ...r, next: nextJson(enabledNext(r.enabled.name)) });
832
871
  }
833
872
  }
834
- else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
873
+ else if (method === "DELETE" && url.pathname.startsWith("/api/users/")) {
835
874
  let name;
836
875
  try {
837
876
  name = decodeURIComponent(url.pathname.slice("/api/users/".length));
@@ -847,7 +886,7 @@ export function createRunServer(ctx) {
847
886
  ctx.log(`user ${r.removed.name} removed`);
848
887
  sendJson(res, 200, { ...r, next: nextJson(REMOVED_NEXT) });
849
888
  }
850
- else if (req.method === "POST" && url.pathname === "/api/sync") {
889
+ else if (method === "POST" && url.pathname === "/api/sync") {
851
890
  const body = JSON.parse(await readBody(req, 64 * 1024));
852
891
  if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((x) => typeof x === "string"))
853
892
  throw new Refusal(400, "send { tables: [schema.table, …] }");
@@ -862,7 +901,7 @@ export function createRunServer(ctx) {
862
901
  throw e;
863
902
  }
864
903
  }
865
- else if (req.method === "POST" && url.pathname === "/api/remove") {
904
+ else if (method === "POST" && url.pathname === "/api/remove") {
866
905
  const body = JSON.parse(await readBody(req, 64 * 1024));
867
906
  if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
868
907
  throw new Refusal(400, "send { tables: [name, …] }");
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A SECRET READ FROM THE PERSON OR THE PIPE, NEVER FROM A FLAG, AN ARGUMENT OR A FILE (`mason replica create`'s database
3
+ * URL, issue 720): on a terminal a hidden prompt, otherwise stdin read whole. Node's own TTY handling, no dependency.
4
+ *
5
+ * - THE PROMPT (stdin a terminal): the question on stderr, stdin in raw mode, so the terminal echoes nothing and keeps
6
+ * no line of it; each key is read here. Enter ends it; Ctrl-C, or Ctrl-D or Enter on an empty line, cancels; Backspace
7
+ * and Ctrl-U edit; a terminal's escape sequences (a bracketed paste's markers, an arrow) are dropped. The terminal is
8
+ * put back as it was on EVERY way out — the answer, a cancel, an error of the stream, its end, a signal (SIGINT,
9
+ * SIGTERM, SIGHUP while raw mode keeps Ctrl-C from being one), and the process's exit.
10
+ * - THE PIPE (stdin not a terminal): read whole, bounded at `max` bytes and by a time limit (a pipe left open and
11
+ * silent never holds the command), one trailing newline (`\n` or `\r\n`) trimmed; more than one line is refused.
12
+ *
13
+ * Nothing read is ever written anywhere by this module: what went wrong is said by kind, never by value.
14
+ */
15
+ import { StringDecoder } from "node:string_decoder";
16
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
17
+ /**
18
+ * THE HIDDEN PROMPT: `question` on `output` (stderr), a line read from `input` (a terminal) in raw mode, echoing nothing.
19
+ * At most `max` characters are kept; a longer line is read to its end (so nothing of it is left for the shell to echo)
20
+ * and refused.
21
+ */
22
+ export function readHidden(input, output, question, max, signals = process) {
23
+ return new Promise((resolve, reject) => {
24
+ const wasRaw = input.isRaw === true;
25
+ const decoder = new StringDecoder("utf8");
26
+ let line = [];
27
+ let tooLong = false;
28
+ let escape = "";
29
+ let ended = false;
30
+ const restore = () => { if (!wasRaw)
31
+ input.setRawMode?.(false); };
32
+ const finish = (r) => {
33
+ if (ended)
34
+ return;
35
+ ended = true;
36
+ input.removeListener("data", onData);
37
+ input.removeListener("end", onEnd);
38
+ input.removeListener("close", onEnd);
39
+ input.removeListener("error", onError);
40
+ for (const s of SIGNALS)
41
+ signals.removeListener(s, onSignal);
42
+ signals.removeListener("exit", restore);
43
+ try {
44
+ restore();
45
+ }
46
+ finally {
47
+ input.pause();
48
+ // the person's Enter was not echoed: the next line starts on a line of its own
49
+ output.write("\n");
50
+ line = [];
51
+ if (r instanceof Error)
52
+ reject(r);
53
+ else
54
+ resolve(r);
55
+ }
56
+ };
57
+ const onSignal = () => finish({ none: "cancelled" });
58
+ const onEnd = () => finish({ none: line.length ? "closed" : "cancelled" });
59
+ const onError = (e) => finish(e);
60
+ const onData = (chunk) => {
61
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
62
+ for (const c of text) {
63
+ if (ended)
64
+ return;
65
+ // a terminal's escape sequence (an arrow, a bracketed paste's markers, a title report): dropped whole
66
+ if (escape === "esc") {
67
+ escape = c === "[" ? "csi" : c === "]" ? "osc" : "";
68
+ continue;
69
+ }
70
+ if (escape === "csi") {
71
+ if (c >= "@" && c <= "~")
72
+ escape = "";
73
+ continue;
74
+ }
75
+ if (escape === "osc") {
76
+ if (c === "\x07" || c === "\x1b")
77
+ escape = "";
78
+ continue;
79
+ }
80
+ if (c === "\x1b") {
81
+ escape = "esc";
82
+ continue;
83
+ }
84
+ if (c === "\r" || c === "\n") {
85
+ finish(tooLong ? { none: "too_long" } : line.length === 0 ? { none: "empty" } : { value: line.join("") });
86
+ return;
87
+ }
88
+ if (c === "\x03") {
89
+ finish({ none: "cancelled" });
90
+ return;
91
+ }
92
+ if (c === "\x04") {
93
+ if (line.length === 0 && !tooLong) {
94
+ finish({ none: "cancelled" });
95
+ return;
96
+ }
97
+ continue;
98
+ }
99
+ if (c === "\x7f" || c === "\b") {
100
+ line.pop();
101
+ continue;
102
+ }
103
+ if (c === "\x15") {
104
+ line = [];
105
+ tooLong = false;
106
+ continue;
107
+ }
108
+ if (c < " ")
109
+ continue;
110
+ if (line.length >= max) {
111
+ tooLong = true;
112
+ continue;
113
+ }
114
+ line.push(c);
115
+ }
116
+ };
117
+ try {
118
+ if (input.setRawMode === undefined)
119
+ throw new Error("stdin is a terminal that cannot turn its echo off");
120
+ // the question first, then echo off: nothing typed before it is shown as asked
121
+ output.write(question);
122
+ input.setRawMode(true);
123
+ for (const s of SIGNALS)
124
+ signals.on(s, onSignal);
125
+ signals.on("exit", restore);
126
+ input.on("data", onData);
127
+ input.on("end", onEnd);
128
+ input.on("close", onEnd);
129
+ input.on("error", onError);
130
+ input.resume();
131
+ }
132
+ catch (e) {
133
+ finish(e instanceof Error ? e : new Error(String(e)));
134
+ }
135
+ });
136
+ }
137
+ /**
138
+ * THE PIPE: `input` read whole, at most `max` bytes after one trailing newline is trimmed, within `timeoutMs`. Empty,
139
+ * more than one line, longer than `max`, or nothing by the time limit, each said by kind.
140
+ */
141
+ export function readPiped(input, max, timeoutMs) {
142
+ return new Promise((resolve, reject) => {
143
+ const chunks = [];
144
+ let bytes = 0;
145
+ let ended = false;
146
+ const finish = (r) => {
147
+ if (ended)
148
+ return;
149
+ ended = true;
150
+ clearTimeout(timer);
151
+ input.removeListener("data", onData);
152
+ input.removeListener("end", onEnd);
153
+ input.removeListener("error", onError);
154
+ input.pause();
155
+ if (r instanceof Error)
156
+ reject(r);
157
+ else
158
+ resolve(r);
159
+ };
160
+ const timer = setTimeout(() => finish({ none: "timeout" }), timeoutMs);
161
+ // the newline a shell's echo or a file ends with is not the URL's: room for it, then the bound
162
+ const most = max + 2;
163
+ const onData = (chunk) => {
164
+ const b = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
165
+ bytes += b.length;
166
+ if (bytes > most) {
167
+ finish({ none: "too_long" });
168
+ return;
169
+ }
170
+ chunks.push(b);
171
+ };
172
+ const onEnd = () => {
173
+ let text = Buffer.concat(chunks).toString("utf8");
174
+ chunks.length = 0;
175
+ if (text.endsWith("\r\n"))
176
+ text = text.slice(0, -2);
177
+ else if (text.endsWith("\n"))
178
+ text = text.slice(0, -1);
179
+ if (text === "")
180
+ return finish({ none: "empty" });
181
+ if (/[\r\n]/.test(text))
182
+ return finish({ none: "lines" });
183
+ if (Buffer.byteLength(text, "utf8") > max)
184
+ return finish({ none: "too_long" });
185
+ finish({ value: text });
186
+ };
187
+ const onError = (e) => finish(e);
188
+ input.on("data", onData);
189
+ input.on("end", onEnd);
190
+ input.on("error", onError);
191
+ input.resume();
192
+ });
193
+ }
@@ -62,6 +62,11 @@ export function newTunnel(port, now = Date.now()) {
62
62
  export function setCookieHeader(token, port) {
63
63
  return `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
64
64
  }
65
+ /** Where a trade sends the browser: the same address without its secret, RELATIVE to the one asked (`./` for `/`), so a
66
+ * page a reverse proxy serves under a prefix of its own lands back there, never on the proxy's root. */
67
+ export function sameAddress(url) {
68
+ return `./${url.pathname.slice(url.pathname.lastIndexOf("/") + 1)}${url.search}${url.hash}`;
69
+ }
65
70
  /** The Host a browser may send for a loopback port: its two names, that port, and nothing else. */
66
71
  const loopbackHosts = (port) => [`127.0.0.1:${port}`, `localhost:${port}`];
67
72
  /** A change must come from the page at `hosts`: its Origin is one of them. */
@@ -83,7 +88,7 @@ function throughTunnel(req, tunnels, now) {
83
88
  const offered = url.searchParams.get("ticket");
84
89
  if (offered !== null && (req.method === "GET" || req.method === "HEAD")) {
85
90
  url.searchParams.delete("ticket");
86
- const location = url.pathname + (url.search || "") + url.hash;
91
+ const location = sameAddress(url);
87
92
  const t = tunnels.find((x) => x.ticket !== null && now < x.ticketUntil && sameSecret(x.ticket, offered));
88
93
  if (t !== undefined) {
89
94
  t.ticket = null;
@@ -117,7 +122,7 @@ export function guard(req, port, token, tunnels = [], now = Date.now()) {
117
122
  if (!sameSecret(offered, token))
118
123
  return { ok: false, status: 403, reason: "the token in the URL is not this run's" };
119
124
  url.searchParams.delete("token");
120
- return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token, port) };
125
+ return { ok: false, status: 303, location: sameAddress(url), setCookie: setCookieHeader(token, port) };
121
126
  }
122
127
  const cookie = cookieOf(req.cookie, cookieName(port));
123
128
  if (cookie === null || !sameSecret(cookie, token)) {