@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.
- package/dist/src/cli/commands.js +6 -3
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/project.js +24 -1
- package/dist/src/cli/run-server.js +46 -30
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +117 -7
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
package/dist/src/cli/commands.js
CHANGED
|
@@ -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),
|
|
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)`)
|
package/dist/src/cli/dev.js
CHANGED
|
@@ -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
|
-
|
|
975
|
-
process.on("
|
|
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
|
/**
|
package/dist/src/cli/project.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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(
|
|
581
|
+
.end(file.body);
|
|
566
582
|
}
|
|
567
|
-
else if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
606
|
+
else if (method === "GET" && url.pathname === "/api/refused") {
|
|
591
607
|
sendJson(res, 200, await ctx.duck.requests.refused());
|
|
592
608
|
}
|
|
593
|
-
else if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
632
|
+
else if (method === "GET" && url.pathname === "/api/running") {
|
|
617
633
|
sendJson(res, 200, { running: ctx.listener.running() });
|
|
618
634
|
}
|
|
619
|
-
else if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
716
|
+
else if (method === "POST" && url.pathname === "/api/ui/tunnel") {
|
|
701
717
|
await openTunnel(ctx, tunnels, req, res);
|
|
702
718
|
}
|
|
703
|
-
else if (
|
|
719
|
+
else if (method === "POST" && url.pathname === "/api/share") {
|
|
704
720
|
await openShare(ctx, req, res);
|
|
705
721
|
}
|
|
706
|
-
else if (
|
|
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 ((
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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, …] }");
|
package/dist/src/cli/security.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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)) {
|