@mason-data/cli 0.10.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.
@@ -327,7 +327,9 @@ const STATUS_USAGE = `usage: mason status [--json]
327
327
  readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), served (this is
328
328
  mason serve's run: mason ui --ssh opens its page), sourcesReadOnly (why the page offers no Add or Remove — the
329
329
  project's files deployed read-only, MASON_PROJECT_READONLY, which mason add and mason remove refuse too, or a
330
- server's sources file it cannot write — or null), next }.
330
+ server's sources file it cannot write — or null), pageReadOnly (why the page changes nothing: mason serve with
331
+ MASON_UI_READ_ONLY=1, which refuses every change the page sends — or null), pageSocket (the Unix socket the
332
+ page is served on too: mason serve with MASON_UI_SOCKET — or null), next }.
331
333
  Each table's kept is how it is kept current now (absent for a table the sync does not hold):
332
334
  { by: poll, sync: { mode: off } or { mode: poll, every (30s), seconds } (its entry's sync:, else the file's,
333
335
  else off), syncedAt (its last whole read or poll), nextAt (when its next poll is due; null: off, or not polled
@@ -368,7 +370,7 @@ export const STATUS = {
368
370
  usage: STATUS_USAGE,
369
371
  options: { json: { type: "boolean" } },
370
372
  json: ["running", "pid", "postgres", "source", "warnings", "folder", "project", "tables", "totals", "engine", "disk", "sessions", "stream", "jsonPaths", "generatedTables", "queries", "slowQueries", "refusedQueries",
371
- "refusedTotal", "unsupported", "recentErrors", "readiness", "ui", "served", "sourcesReadOnly",
373
+ "refusedTotal", "unsupported", "recentErrors", "readiness", "ui", "served", "sourcesReadOnly", "pageReadOnly", "pageSocket",
372
374
  "roles", "share", "shareEnded", "lastRun", "lastStop", "hung", "next"],
373
375
  };
374
376
  export async function status(argv) {
@@ -448,7 +450,8 @@ export async function status(argv) {
448
450
  ["Listener", listenerText(run.postgresListener, postgresLink(run.postgresUrl).port)],
449
451
  ...shareRows(s.share),
450
452
  // the web page (mason ui), without its one-time token: mason ui prints the URL that carries it, and opens it
451
- ...(s.ui === true ? [["Web UI", s.served === true ? "from your machine: mason ui --ssh <user@server>" : `${run.control.url}/ (mason ui opens it)`]] : []),
453
+ ...(s.ui === true ? [["Web UI", (s.served === true ? "from your machine: mason ui --ssh <user@server>" : `${run.control.url}/ (mason ui opens it)`) +
454
+ (typeof s.pageSocket === "string" ? `; on this machine, the Unix socket ${s.pageSocket} too` : "")]] : []),
452
455
  // the roles file as the listener reads it now (mason user add --role): its roles, or what is wrong with it
453
456
  ...(s.roles && s.roles.file !== null
454
457
  ? [["Roles", s.roles.problem !== null ? st.red(`${s.roles.problem} (every statement of a user with a role is refused until it is fixed)`)
@@ -29,6 +29,7 @@ import * as path from "node:path";
29
29
  import { choosePgPort, freePort } from "./ports.js";
30
30
  import { failed } from "./command.js";
31
31
  import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
32
+ import { uiReadOnlySetting } from "./ui-read-only.js";
32
33
  import { createRunServer, listenOnLoopback, metricsFacts, usageFacts } from "./run-server.js";
33
34
  import { readGenerated } from "./generated-files.js";
34
35
  import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
@@ -789,6 +790,10 @@ async function run(o, hosted) {
789
790
  version: o.version, startedAt,
790
791
  // the web page (mason ui): on loopback alone, so mason serve's is reached through mason ui --ssh
791
792
  page: true, served: o.serve !== undefined,
793
+ // MASON_UI_READ_ONLY=1: a server's page refuses every change it asks for (mason start refuses the setting above)
794
+ pageReadOnly: o.serve !== undefined && uiReadOnlySetting(process.env) === true,
795
+ // MASON_UI_SOCKET: the page on a Unix socket too (src/cli/ui-socket.ts), or null
796
+ pageSocket: o.serve?.socket ?? null,
792
797
  demo: cfg.demo === true,
793
798
  users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
794
799
  // the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
@@ -824,6 +829,7 @@ async function run(o, hosted) {
824
829
  restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status(), generated: () => sync.generated.status() },
825
830
  };
826
831
  const server = createRunServer(runCtx);
832
+ o.serve?.page?.(server);
827
833
  // a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
828
834
  usage.start(() => usageFacts(runCtx));
829
835
  await listenOnLoopback(server, setupPort);
@@ -867,6 +873,8 @@ async function run(o, hosted) {
867
873
  // the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
868
874
  // no handler would end the run with the default action, never recording `running: null`
869
875
  let stopping = false;
876
+ // the run's own stop, for a step of a serve's ready that failed (`ServeHooks.ready`): its words are what lastStop says
877
+ let stopFor = () => undefined;
870
878
  const stopped = new Promise((resolve) => {
871
879
  // a crash of the engine's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
872
880
  // (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
@@ -889,7 +897,7 @@ async function run(o, hosted) {
889
897
  resolve(1);
890
898
  })();
891
899
  };
892
- const stop = (signal) => {
900
+ const stop = (signal, how) => {
893
901
  if (stopping)
894
902
  return;
895
903
  stopping = true;
@@ -965,14 +973,16 @@ async function run(o, hosted) {
965
973
  (repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
966
974
  cfg.running = null;
967
975
  // how it ended, for mason status once it is gone (a kill records nothing: mason stop writes that one)
968
- cfg.lastStop = { at: new Date().toISOString(), how: signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})` };
976
+ cfg.lastStop = { at: new Date().toISOString(), how: how ?? (signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})`) };
969
977
  writeConfig(home, cfg);
970
978
  log.end();
971
979
  resolve(0);
972
980
  })();
973
981
  };
974
- process.on("SIGINT", stop);
975
- process.on("SIGTERM", stop);
982
+ // wrapped: Node calls a signal's listener with (name, number), and the number must never be read as `how`
983
+ process.on("SIGINT", () => { stop("SIGINT"); });
984
+ process.on("SIGTERM", () => { stop("SIGTERM"); });
985
+ stopFor = (how) => { stop("SIGTERM", how); };
976
986
  });
977
987
  say("");
978
988
  say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
@@ -985,9 +995,15 @@ async function run(o, hosted) {
985
995
  const elsewhere = elsewhereTip(pgTls.listener);
986
996
  if (elsewhere)
987
997
  say(` ${elsewhere}`);
998
+ // before the ready line: what a serve writes when it answers (MASON_UI_RUN_FILE) is there when a supervisor reads it
999
+ // a step of it that failed stops the run, said in its words, and the ready line is never printed: it did not get ready
1000
+ const notReady = o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx), page: { port: setupPort, token } }) ?? null;
1001
+ if (notReady !== null) {
1002
+ stopFor(notReady);
1003
+ return await stopped;
1004
+ }
988
1005
  say(READY);
989
1006
  say("");
990
- o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx) });
991
1007
  return await stopped;
992
1008
  }
993
1009
  /**
@@ -21,6 +21,9 @@ import { randomBytes } from "node:crypto";
21
21
  import { connectDatabase, databaseSettingRefusal } from "../lib/database-name.js";
22
22
  import { changesRetired, pollMsRetired } from "../lib/table-sync.js";
23
23
  import { readSource, sourcesTop, writtenDatabase } from "./sources.js";
24
+ import { UI_SOCKET_SETTING } from "./ui-socket.js";
25
+ import { UI_RUN_FILE_SETTING } from "./ui-run-file.js";
26
+ import { UI_READ_ONLY_SETTING, uiReadOnlySetting } from "./ui-read-only.js";
24
27
  export const STATE_DIR = ".mason";
25
28
  const CONFIG = "config.json";
26
29
  /** A project's name: what its commands call it and (with the source's) what its replication slot is named for. Never
@@ -263,5 +266,25 @@ export function startSettingRefusal(p, env, o) {
263
266
  if (changes !== null)
264
267
  return { code: "refused", ...changes };
265
268
  const database = o.serve ? null : databaseSettingRefusal(env, top().shown);
266
- return database === null ? null : { code: "refused", ...database };
269
+ if (database !== null)
270
+ return { code: "refused", ...database };
271
+ // a read-only page is a server's (src/cli/ui-read-only.ts): a value not 0 or 1 refuses either start, and 1 mason start's
272
+ const pageReadOnly = uiReadOnlySetting(env);
273
+ if (typeof pageReadOnly !== "boolean")
274
+ return { message: pageReadOnly.problem, code: "refused", fix: `set ${UI_READ_ONLY_SETTING}=1 or ${UI_READ_ONLY_SETTING}=0, or unset it` };
275
+ if (!o.serve && pageReadOnly) {
276
+ return { message: `${UI_READ_ONLY_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
277
+ fix: `unset ${UI_READ_ONLY_SETTING}: mason start's page is yours alone, on this machine` };
278
+ }
279
+ // the page's Unix socket is a server's (src/cli/ui-socket.ts): a laptop's page is opened with mason ui
280
+ if (!o.serve && env[UI_SOCKET_SETTING]) {
281
+ return { message: `${UI_SOCKET_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
282
+ fix: `unset ${UI_SOCKET_SETTING}: mason ui opens this run's page` };
283
+ }
284
+ // the run's file is a server's supervisor's (src/cli/ui-run-file.ts): a laptop's commands read the project's config
285
+ if (!o.serve && env[UI_RUN_FILE_SETTING]) {
286
+ return { message: `${UI_RUN_FILE_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
287
+ fix: `unset ${UI_RUN_FILE_SETTING}: mason ui opens this run's page, and the mason commands reach the run by themselves` };
288
+ }
289
+ return null;
267
290
  }
@@ -28,6 +28,7 @@ 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";
@@ -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");
@@ -802,7 +818,7 @@ export function createRunServer(ctx) {
802
818
  ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
803
819
  sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
804
820
  }
805
- else if (req.method === "POST" && /^\/api\/users\/[^/]+\/from$/.test(url.pathname)) {
821
+ else if (method === "POST" && /^\/api\/users\/[^/]+\/from$/.test(url.pathname)) {
806
822
  // mason user from --json's shape, through its own function: the user's address ranges replaced (null: any address)
807
823
  let name;
808
824
  try {
@@ -823,7 +839,7 @@ export function createRunServer(ctx) {
823
839
  ctx.log(`user ${r.changed.name} connects from ${rangesText(r.changed.from)}`);
824
840
  sendJson(res, 200, { ...r, next: nextJson(fromNext(r.changed.name, r.changed.from)) });
825
841
  }
826
- else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
842
+ else if (method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
827
843
  // mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
828
844
  const [, , , encoded, verb] = url.pathname.split("/");
829
845
  let name;
@@ -854,7 +870,7 @@ export function createRunServer(ctx) {
854
870
  sendJson(res, 200, { ...r, next: nextJson(enabledNext(r.enabled.name)) });
855
871
  }
856
872
  }
857
- else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
873
+ else if (method === "DELETE" && url.pathname.startsWith("/api/users/")) {
858
874
  let name;
859
875
  try {
860
876
  name = decodeURIComponent(url.pathname.slice("/api/users/".length));
@@ -870,7 +886,7 @@ export function createRunServer(ctx) {
870
886
  ctx.log(`user ${r.removed.name} removed`);
871
887
  sendJson(res, 200, { ...r, next: nextJson(REMOVED_NEXT) });
872
888
  }
873
- else if (req.method === "POST" && url.pathname === "/api/sync") {
889
+ else if (method === "POST" && url.pathname === "/api/sync") {
874
890
  const body = JSON.parse(await readBody(req, 64 * 1024));
875
891
  if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((x) => typeof x === "string"))
876
892
  throw new Refusal(400, "send { tables: [schema.table, …] }");
@@ -885,7 +901,7 @@ export function createRunServer(ctx) {
885
901
  throw e;
886
902
  }
887
903
  }
888
- else if (req.method === "POST" && url.pathname === "/api/remove") {
904
+ else if (method === "POST" && url.pathname === "/api/remove") {
889
905
  const body = JSON.parse(await readBody(req, 64 * 1024));
890
906
  if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
891
907
  throw new Refusal(400, "send { tables: [name, …] }");
@@ -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)) {