@mason-data/cli 0.7.0 → 0.8.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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -0,0 +1,286 @@
1
+ /**
2
+ * THE RUN'S SIDE OF `mason share` (src/cli/share.ts is the command's; docs/POSTGRES-API.md "From a cloud BI tool"): the
3
+ * second socket of the Postgres listener (src/api/postgres/server.ts `openShare`), and who may log in on it.
4
+ *
5
+ * A SHARE LASTS AS LONG AS THE COMMAND'S CONNECTION TO THE RUN (`POST /api/share`, src/cli/run-server.ts): the answer
6
+ * stays open, and when it goes, however it goes — Ctrl-C, the tunnel's end, a `kill -9` —, the login goes, its sessions
7
+ * end, its tunnel client is stopped, and with the last share the socket closes. A second `mason share` meets the same
8
+ * socket with a login of its own.
9
+ *
10
+ * THE LOGIN IS IN THIS PROCESS'S MEMORY ALONE, AND ONLY ITS HASH: the command makes the password and sends its SHA-256
11
+ * (`shareSecret`), so no state, log line or answer of the run can carry a password the run was never given (a fast hash
12
+ * is enough: the password is 192 random bits, nothing to guess). It is no row of `.mason/users.json`, because that file
13
+ * outlives a process and a share must not: a run that dies takes it along.
14
+ * Only `authenticate` below reads this list, and only the share's socket asks it: the password is refused on the
15
+ * run's own listener by nobody knowing it, and a `mason user` or a token here by the same.
16
+ *
17
+ * A LOGIN REFUSED HERE IS LOGGED WITH NO SOURCE: every connection through a tunnel comes from 127.0.0.1, so naming
18
+ * one would be naming the tunnel's client. The log is written once a minute at most (a scanner's rate is anyone's), with
19
+ * how many it stands for; the counts are `mason status`'s.
20
+ */
21
+ import * as crypto from "node:crypto";
22
+ import { execFile } from "node:child_process";
23
+ import { ApiError } from "../api/error.js";
24
+ import { didYouMean } from "../lib/did-you-mean.js";
25
+ import { missingAttributes } from "../duck/row-filter.js";
26
+ import { ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE } from "./roles.js";
27
+ import { sameSecret } from "./security.js";
28
+ /** What the password is stored and sent as: its SHA-256, hex. */
29
+ export const shareSecret = (password) => crypto.createHash("sha256").update(password).digest("hex");
30
+ /** The new password `mason share` prints once: 192 random bits, in characters every form and URL takes. */
31
+ export const newSharePassword = () => crypto.randomBytes(24).toString("hex");
32
+ /** How often a refused login is written to the log, at most: the rest are counted. */
33
+ const REFUSED_LOG_MS = 60_000;
34
+ /** How many refused logins a minute are rows of the request log, at most: the rest are counted (`mason status`), so a
35
+ * stranger on a public address cannot set the log's growth. */
36
+ const REFUSED_ROWS_A_MINUTE = 10;
37
+ /** The tunnel of a share, as its command tells the run (`POST /api/share/<user>`). */
38
+ /** What `POST /api/share/<user>` was sent, as a ShareTunnel, or null: its `pid` is a process the run may stop, so
39
+ * what the run keeps is these fields, each its own type, never the body. */
40
+ export function tunnelReport(body) {
41
+ if (typeof body !== "object" || body === null || Array.isArray(body))
42
+ return null;
43
+ const { via, host, port, endsAt, state, pid } = body;
44
+ const text = (x) => typeof x === "string" && x !== "" && x.length <= 255;
45
+ const whole = (x, max) => typeof x === "number" && Number.isInteger(x) && x >= 1 && x <= max;
46
+ if (via !== undefined && !text(via))
47
+ return null;
48
+ if (host !== undefined && !text(host))
49
+ return null;
50
+ if (port !== undefined && !whole(port, 65_535))
51
+ return null;
52
+ if (endsAt !== undefined && endsAt !== null && !(text(endsAt) && !Number.isNaN(Date.parse(endsAt))))
53
+ return null;
54
+ if (state !== undefined && state !== "opening" && state !== "up" && state !== "reconnecting")
55
+ return null;
56
+ if (pid !== undefined && !whole(pid, 2 ** 31 - 1))
57
+ return null;
58
+ return { ...(via !== undefined ? { via } : {}), ...(host !== undefined ? { host } : {}), ...(port !== undefined ? { port } : {}),
59
+ ...(endsAt !== undefined ? { endsAt } : {}), ...(state !== undefined ? { state } : {}), ...(pid !== undefined ? { pid } : {}) };
60
+ }
61
+ /** What a role reads, in a line. */
62
+ function readsText(g) {
63
+ if (g.all)
64
+ return "every synced table";
65
+ const names = [...g.tables.keys()];
66
+ return `${names.length} synced table${names.length === 1 ? "" : "s"}: ${names.slice(0, 6).join(", ")}${names.length > 6 ? ` and ${names.length - 6} more` : ""}`;
67
+ }
68
+ /** The attributes a role's filters read, by the rule a session is refused by: a share's login is a password and no
69
+ * values, so under such a role its every statement would be refused. */
70
+ const attributesOf = (g) => g.filters === undefined ? [] : missingAttributes(g.filters.values(), {});
71
+ /** The roles a share may read as, the file's default first. */
72
+ function choicesOf(live) {
73
+ return [...live.grants.values()].filter((g) => attributesOf(g).length === 0).map((g) => ({ role: g.name, reads: readsText(g), default: g.name === live.default }))
74
+ .sort((a, b) => Number(b.default) - Number(a.default));
75
+ }
76
+ /** Why roles that read attributes cannot be shared: what each reads, the roles that can be, and the login that carries values. */
77
+ function needsAttributes(message, roles, choices, file) {
78
+ const first = roles[0];
79
+ const sets = attributesOf(first).map((m) => `--set ${m.attr}=<value>`).join(" ");
80
+ return { message, code: "refused", why: roles.flatMap(attributesOf).map((m) => `${m.at} reads :${m.attr} (${m.where})`).join("; "),
81
+ fix: choices.length > 0 ? choices.map((c) => `--role ${c.role}: reads ${c.reads}`).join("\n") : `add a role to ${file} whose rows need no attribute, then mason share --role <role>`,
82
+ next: [...choices.map((c) => [`mason share --role ${c.role}`, `reads ${c.reads}`]),
83
+ [`mason user add <name> --role ${first.name} ${sets}`, "a login that carries the values, for a tool that can reach this machine"]] };
84
+ }
85
+ /**
86
+ * WHAT A SHARE READS: `--role`'s role; with none named, the one answer the project gives — no roles file, so every synced
87
+ * table, or a file of one role — and otherwise a refusal naming the choices. A file with a `default:` and other roles
88
+ * is refused too: a default says what a local user gets, and a public address is no moment to widen it silently.
89
+ * The refusal is a Failure with `choices` beside it: a person is asked (src/cli/share.ts), and an agent reads the lines.
90
+ */
91
+ export function shareRole(asked, live) {
92
+ const file = live.file ?? `mason/${ROLES_FILE}`;
93
+ if (live.checkFailed === true) {
94
+ return { failure: { message: `nothing is shared: ${live.problem}`, code: "failed", why: `the file may be right: a share opens only on filters Postgres has read`, fix: "mason logs says why, then mason share again", next: [["mason logs", "the run's log"]] } };
95
+ }
96
+ if (live.problem !== null) {
97
+ return { failure: { message: `${file} cannot be read as it is now, so nothing is shared: ${live.problem}`, code: "bad_roles", why: "what a share reads is that file's", fix: `repair ${file}, then mason share` } };
98
+ }
99
+ const choices = choicesOf(live);
100
+ const left = [...live.grants.keys()].filter((r) => !choices.some((c) => c.role === r));
101
+ const lines = choices.map((c) => [`mason share --role ${c.role}`, `reads ${c.reads}`]);
102
+ if (asked !== undefined) {
103
+ if (!ROLE_NAME_RE.test(asked))
104
+ return { failure: { message: `${JSON.stringify(asked)} is not a role name: ${ROLE_NAME_RULE}`, code: "usage" } };
105
+ const g = live.grants.get(asked);
106
+ if (g !== undefined) {
107
+ if (attributesOf(g).length === 0)
108
+ return { role: asked, reads: readsText(g) };
109
+ return { failure: needsAttributes(`a share cannot read as the role ${asked}: its rows depend on ${attributesOf(g).map((m) => `:${m.attr}`).join(", ")}, and a share's login has no attributes`, [g], choices, file) };
110
+ }
111
+ if (live.file === null) {
112
+ return { failure: { message: `there is no role ${asked}: this project has no roles file`, code: "not_found", why: `a role is a key of ${file}, beside the sources file, naming the tables (and the columns of each) it may read`,
113
+ fix: `write ${file}:\n roles:\n ${asked}:\n tables:\n <a synced table>: {}\nthen mason share --role ${asked}`, next: [["mason share", "or share every synced table: this project has no roles file"]] } };
114
+ }
115
+ const roles = [...choices.map((c) => c.role), ...left.map((r) => `${r} (its rows depend on an attribute, so a share cannot read as it)`)];
116
+ return { failure: { message: `there is no role ${asked} in ${file}${didYouMean(asked, [...live.grants.keys()])}`, code: "not_found", why: `the roles: ${roles.join(", ")}`, next: lines }, choices };
117
+ }
118
+ if (live.file === null)
119
+ return { role: null, reads: "every synced table" };
120
+ if (live.grants.size === 1 && choices.length === 1)
121
+ return { role: choices[0].role, reads: choices[0].reads };
122
+ if (choices.length === 0 && live.grants.size > 0) {
123
+ return { failure: needsAttributes(`no role of ${file} can be shared: the rows of each depend on an attribute, and a share's login has no attributes`, [...live.grants.values()], choices, file) };
124
+ }
125
+ if (choices.length === 0) {
126
+ return { failure: { message: `${file} names no role, so a share would read nothing`, code: "not_found", why: "what a share reads is one role of that file", fix: `add a role to ${file}, then mason share --role <role>` } };
127
+ }
128
+ return { failure: { message: `${file} names ${live.grants.size} roles: mason share needs to know which one this address reads as`, code: "usage",
129
+ why: "anyone with the address and password reads what that role reads", fix: [...choices.map((c) => `--role ${c.role}: reads ${c.reads}${c.default ? " (the file's default)" : ""}`), ...(left.length > 0 ? [`left out, because a share's login has no attributes: ${left.join(", ")}`] : [])].join("\n"), next: lines }, choices };
130
+ }
131
+ /** Stops a share's tunnel client once its command is gone: by its pid, and only while that pid is still a process
132
+ * naming the share's port (a pid is reused; never kill by anything broader). */
133
+ export function stopTunnelClient(pid, port, log) {
134
+ execFile("ps", ["-o", "command=", "-p", String(pid)], (err, stdout) => {
135
+ // ps exits 1 for a pid that is gone: its command ended it, the usual case
136
+ if (err !== null || !stdout.includes(String(port)))
137
+ return;
138
+ try {
139
+ process.kill(pid, "SIGTERM");
140
+ }
141
+ catch (e) {
142
+ log(`share: stopping its tunnel client (pid ${pid}) failed: ${e.message}`);
143
+ }
144
+ });
145
+ }
146
+ export class RunShares {
147
+ deps;
148
+ held = new Map();
149
+ socket = null;
150
+ /** the socket once it listens */
151
+ live = null;
152
+ refused = 0;
153
+ rowsFrom = -Infinity;
154
+ rows = 0;
155
+ dropped = 0;
156
+ lastSaid = -Infinity;
157
+ unsaid = 0;
158
+ constructor(deps) { this.deps = deps; }
159
+ /** Opens a share: the socket when none is open, and a login of its own. Refused: the failure, with the choices when a role is what is missing. */
160
+ async open(ask) {
161
+ const role = shareRole(ask.role, await this.deps.listener.checkRoles());
162
+ if ("failure" in role)
163
+ return role;
164
+ const user = `share_${crypto.randomBytes(3).toString("hex")}`;
165
+ // in the list before the first wait, so a share ending meanwhile does not close the socket under this one
166
+ this.held.set(user, { user, secret: ask.secret, role: role.role, reads: role.reads, openedAt: new Date().toISOString(), tunnel: { state: "opening" }, ended: null });
167
+ let socket, fingerprint;
168
+ try {
169
+ const made = this.socket ?? this.openSocket();
170
+ if ("message" in made) {
171
+ this.held.delete(user);
172
+ return { failure: made };
173
+ }
174
+ this.socket = made;
175
+ fingerprint = made.fingerprint;
176
+ socket = await made.opening;
177
+ }
178
+ catch (e) {
179
+ this.held.delete(user);
180
+ this.socket = null;
181
+ return { failure: { message: `the share's socket could not be opened: ${e.message}`, code: "failed", next: [["mason logs", "the run's log"]] } };
182
+ }
183
+ this.live = socket;
184
+ this.deps.log(`share: ${user} opened, reading ${role.role === null ? "every synced table (no roles file)" : `as the role ${role.role}`}, TLS required on 127.0.0.1:${socket.port}`);
185
+ return { user, port: socket.port, fingerprint, database: this.deps.database, role: role.role, reads: role.reads };
186
+ }
187
+ openSocket() {
188
+ const cert = this.deps.certificate();
189
+ if ("message" in cert)
190
+ return cert;
191
+ const opening = this.deps.listener.openShare({
192
+ tls: { cert: cert.cert, key: cert.key },
193
+ authenticate: (password, user) => this.authenticate(password, user),
194
+ recheck: (token) => this.recheck(token),
195
+ loginHint: "the password mason share printed: every run of it makes a new one",
196
+ refused: (user) => { this.refused++; this.note(`a login refused for the user ${JSON.stringify(user)}`); return this.rowFree(Date.now()); },
197
+ dropped: (why) => { this.dropped++; this.note(`a connection dropped before its login: it ${why}`); },
198
+ });
199
+ return { opening, fingerprint: cert.fingerprint };
200
+ }
201
+ authenticate(password, user) {
202
+ const given = shareSecret(password);
203
+ const found = [...this.held.values()].find((h) => sameSecret(h.secret, given));
204
+ if (found === undefined || found.user !== user) {
205
+ const refused = new ApiError(401, "not this share's password", "invalid_share");
206
+ refused.userName = user;
207
+ throw refused;
208
+ }
209
+ return { filters: [], subject: undefined, user: found.user, jti: found.user, ...(found.role !== null ? { role: found.role } : {}) };
210
+ }
211
+ /** a session's login, asked again before a statement: a share closed ends its sessions at once, and this is why none outlives it */
212
+ async recheck(token) {
213
+ if (token.jti === undefined || !this.held.has(token.jti))
214
+ throw new ApiError(401, "the share ended", "invalid_share");
215
+ }
216
+ /** whether a refused login at `now` is a row of the request log */
217
+ rowFree(now) {
218
+ if (now - this.rowsFrom >= 60_000) {
219
+ this.rowsFrom = now;
220
+ this.rows = 0;
221
+ }
222
+ return ++this.rows <= REFUSED_ROWS_A_MINUTE;
223
+ }
224
+ /** the log's line: at most one a minute, saying how many it stands for, with no source (the header says why) */
225
+ note(what) {
226
+ const now = Date.now();
227
+ if (now - this.lastSaid < REFUSED_LOG_MS) {
228
+ this.unsaid++;
229
+ return;
230
+ }
231
+ const more = this.unsaid > 0 ? ` (and ${this.unsaid} more before it)` : "";
232
+ this.lastSaid = now;
233
+ this.unsaid = 0;
234
+ this.deps.log(`share: ${what}${more}; who cannot be known: every connection through a tunnel comes from 127.0.0.1`);
235
+ }
236
+ /** What a share's command says of its tunnel; false: no such share open. */
237
+ update(user, tunnel) {
238
+ const h = this.held.get(user);
239
+ if (h === undefined)
240
+ return false;
241
+ h.tunnel = { ...h.tunnel, ...tunnel };
242
+ return true;
243
+ }
244
+ /** Called once when a share ends by anything but its command's connection (the run stopping). */
245
+ onEnd(user, ended) {
246
+ const h = this.held.get(user);
247
+ if (h !== undefined)
248
+ h.ended = ended;
249
+ }
250
+ /** Ends a share: its login gone first, then its sessions, its tunnel client, and with the last one the socket. */
251
+ async close(user, why) {
252
+ const h = this.held.get(user);
253
+ if (h === undefined)
254
+ return;
255
+ this.held.delete(user);
256
+ const opening = this.socket?.opening;
257
+ if (opening === undefined)
258
+ return;
259
+ const socket = await opening;
260
+ const ended = socket.endSessions(user, "the share ended");
261
+ if (h.tunnel.pid !== undefined)
262
+ (this.deps.stopTunnel ?? ((pid, port) => stopTunnelClient(pid, port, this.deps.log)))(h.tunnel.pid, socket.port);
263
+ this.deps.log(`share: ${user} closed: ${why}; ${ended} session${ended === 1 ? "" : "s"} ended`);
264
+ h.ended?.(why);
265
+ if (this.held.size > 0)
266
+ return;
267
+ this.socket = null;
268
+ this.live = null;
269
+ const counts = `${this.refused} login${this.refused === 1 ? "" : "s"} refused, ${this.dropped} connection${this.dropped === 1 ? "" : "s"} dropped before a login`;
270
+ this.refused = 0;
271
+ this.dropped = 0;
272
+ this.unsaid = 0;
273
+ this.lastSaid = -Infinity;
274
+ await socket.close();
275
+ this.deps.log(`share: the share's socket closed (${counts})`);
276
+ }
277
+ /** Every share ended (the run stopping). */
278
+ async closeAll(why) { await Promise.all([...this.held.keys()].map((u) => this.close(u, why))); }
279
+ state() {
280
+ if (this.socket === null || this.live === null)
281
+ return null;
282
+ return { port: this.live.port, fingerprint: this.socket.fingerprint, sessions: this.live.sessions, refusedLogins: this.refused, droppedConnections: this.dropped,
283
+ shares: [...this.held.values()].map((h) => ({ user: h.user, role: h.role, reads: h.reads, openedAt: h.openedAt, via: h.tunnel.via ?? null, host: h.tunnel.host ?? null, port: h.tunnel.port ?? null,
284
+ endsAt: h.tunnel.endsAt ?? null, state: h.tunnel.state ?? "opening" })) };
285
+ }
286
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `mason share`: a run on a laptop reached by a tool in the cloud, through a free tunnel service, with TLS end to end
3
+ * between that tool and Mason (docs/POSTGRES-API.md "From a cloud BI tool"). This is the command's side: it makes the
4
+ * password, asks the run for a socket that requires TLS (`POST /api/share`, src/cli/share-run.ts), starts the tunnel's
5
+ * client (src/cli/tunnel.ts) pointed at that socket, prints what to type into the tool, and holds all of it in the
6
+ * foreground: the address is temporary by nature, and a share nobody is watching is a public login nobody is watching.
7
+ *
8
+ * ITS CONNECTION TO THE RUN IS THE SHARE: however this process ends the run closes the share, a `kill -9` included.
9
+ *
10
+ * A FREE TUNNEL ENDS THE SHARE WHEN IT ENDS: there is no reconnect, because a new tunnel is a new address the person would
11
+ * have to copy anyway; this says so and exits 1 with what to run next, and the next run gives a new address and a new
12
+ * password. WITH A PAID TOKEN the address survives, so the client is opened again, the password unchanged, and it is said.
13
+ *
14
+ * WHAT `--json` PRINTS IS ONE OBJECT, ONCE, when the address is known; everything after it (a reconnect, the end and
15
+ * why) is on stderr with the exit code, so stdout stays one document.
16
+ */
17
+ import { once } from "node:events";
18
+ import { createInterface } from "node:readline/promises";
19
+ import { failed, jsonText, parseCommand, reportText } from "./command.js";
20
+ import { guarded, runFailure } from "./commands.js";
21
+ import { callRun, holdRun } from "./control.js";
22
+ import { reach } from "./state.js";
23
+ import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
24
+ import { alive } from "./runs.js";
25
+ import { appEnvValue } from "../lib/dotenv.js";
26
+ import { newSharePassword, shareSecret } from "./share-run.js";
27
+ import { probeShare, startTunnel, sweepTunnelFiles, TOKEN_RE, TUNNELS, tunnelPlan } from "./tunnel.js";
28
+ const out = (m) => { process.stdout.write(`${m}\n`); };
29
+ const say = (m) => { process.stderr.write(`${m}\n`); };
30
+ const DEFAULT_TIMEOUT_S = 30;
31
+ /** The first wait before a dropped tunnel is opened again, and the longest. */
32
+ const RECONNECT_FIRST_MS = 1_000;
33
+ const RECONNECT_MAX_MS = 30_000;
34
+ export const SHARE = {
35
+ needs: "run",
36
+ name: "share",
37
+ summary: "share the synced data with a tool outside this machine: a temporary address, TLS end to end, a password for this run",
38
+ usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <seconds>] [--json]
39
+
40
+ For a BI tool in the cloud, which cannot reach 127.0.0.1: opens a temporary public address for this project's Postgres
41
+ API, through a free tunnel service, and prints what to type into the tool. It stays in the foreground: Ctrl-C ends
42
+ the share. Your local tools notice nothing: the share has a socket of its own, and no session is dropped.
43
+
44
+ The socket requires TLS of every connection, so a service that forwards bytes cannot read them. The certificate is
45
+ self-signed (the tool connects with sslmode=require, and checks no certificate), so a malicious service could sit in
46
+ the middle of the handshake: this is for trying Mason, on data you may send that way. mason serve on a host is the
47
+ way to keep a dashboard running.
48
+
49
+ The password is made here, printed once and good for this run alone: it is kept nowhere, works on this address only,
50
+ and is gone when the share ends. A mason user's password and an API token are refused here. What the share reads is
51
+ one role of mason/roles.yml: --role names it; with one role in the file that one is used, and with several you are
52
+ asked, or with --json refused with the line to run. With no roles file it reads every synced table, and says so.
53
+
54
+ --via pinggy (the default) needs only ssh; a free tunnel ends after about an hour, and the share with it: run mason
55
+ share again for a new address and a new password. MASON_PINGGY_TOKEN (the shell or .env, never a flag: a command
56
+ line is visible to every user of the machine), a paid Pinggy token, keeps the address, so a tunnel that drops is opened
57
+ again by itself, the password unchanged. --via bore needs bore (cargo install bore-cli).
58
+
59
+ mason share
60
+ mason share --role sales
61
+ mason share --via bore
62
+
63
+ --role <role> what it reads: a role of mason/roles.yml
64
+ --via <tunnel> pinggy (default) or bore
65
+ --timeout <s> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S})
66
+ --json as data, once the address is known: { share: { host, port, database, user, password, sslmode,
67
+ fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
68
+ share's certificate; false: it could not be reached from this machine) }, next }; the command
69
+ then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
70
+ `,
71
+ options: { role: { type: "string" }, via: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
72
+ json: ["share", "next"],
73
+ };
74
+ /** A role from what a person typed: a choice's number or its name; null for anything else. */
75
+ export function chosenRole(answer, choices) {
76
+ const a = answer.trim();
77
+ const byNumber = /^\d+$/.test(a) ? choices[Number(a) - 1] : undefined;
78
+ return byNumber?.role ?? choices.find((c) => c.role === a)?.role ?? null;
79
+ }
80
+ /** Whether a person is there to ask: a terminal both ways, and no --json (never an environment variable). */
81
+ const canAsk = (json) => !json && process.stdin.isTTY === true && process.stderr.isTTY === true;
82
+ /** Asks a person which role: the answer, or null for an empty line, an end of input or something that is no choice. */
83
+ async function askRole(choices) {
84
+ say("Which role does this share read as? Anyone with its address and password reads what it reads.");
85
+ choices.forEach((c, i) => say(` ${i + 1}. ${c.role}: ${c.reads}${c.default ? " (the file's default)" : ""}`));
86
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
87
+ const closed = once(rl, "close").then(() => null);
88
+ const answer = await Promise.race([rl.question("Role (a number or a name): "), closed]);
89
+ rl.close();
90
+ return answer === null ? null : chosenRole(answer, choices);
91
+ }
92
+ /** The run's refusal with its own next steps (`failure.next`, src/cli/run-server.ts sendFailure). */
93
+ function refusalOf(reply) {
94
+ const next = reply.body.failure?.next ?? [];
95
+ return runFailure(reply, next.map((n) => n.what === undefined ? [n.command] : [n.command, n.what]));
96
+ }
97
+ /** The share opened by the run: asked once, and again with what a person chose. */
98
+ async function openOnRun(run, secret, role, json) {
99
+ const asked = await holdRun(run, "/api/share", { secret, ...(role !== undefined ? { role } : {}) });
100
+ if ("first" in asked)
101
+ return asked;
102
+ const choices = asked.body.choices;
103
+ if (choices === undefined || role !== undefined || !canAsk(json))
104
+ return refusalOf(asked);
105
+ const chosen = await askRole(choices);
106
+ if (chosen === null)
107
+ return refusalOf(asked);
108
+ const again = await holdRun(run, "/api/share", { secret, role: chosen });
109
+ return "first" in again ? again : refusalOf(again);
110
+ }
111
+ /** `mason status`'s and the report's words for when it ends. */
112
+ function endsText(endsAt, now) {
113
+ if (endsAt === null)
114
+ return "when this command ends (Ctrl-C)";
115
+ const minutes = Math.max(0, Math.round((Date.parse(endsAt) - now) / 60_000));
116
+ return `in about ${minutes} minutes, at ${new Date(endsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: the free tunnel's limit (mason share again gives a new address and a new password)`;
117
+ }
118
+ /** What a tunnel's address is checked to be, once the client gave it: the Failure when something else answers there. */
119
+ async function verified(t, fingerprint) {
120
+ const probe = await probeShare(t, fingerprint);
121
+ if (probe === "other") {
122
+ return { message: `${t.host}:${t.port} is not this share: something else answers there`, code: "failed", why: "the tunnel printed that address, and the certificate there is not this share's: its output may have changed, or something stands in the way",
123
+ fix: "nothing was left open. Do not connect a tool to that address", next: [["mason share --via bore", "another tunnel"]] };
124
+ }
125
+ return probe === "same";
126
+ }
127
+ export async function share(argv) {
128
+ const f = parseCommand(SHARE, argv);
129
+ if (typeof f === "number")
130
+ return f;
131
+ const v = f.values;
132
+ const json = v.json;
133
+ if (f.positionals.length)
134
+ return failed("share", { message: "it takes no argument: --role names what it reads", code: "usage", usage: SHARE.usage }, { json });
135
+ const via = (v.via ?? "pinggy");
136
+ if (!TUNNELS.includes(via))
137
+ return failed("share", { message: `--via must be ${TUNNELS.join(" or ")}, not ${JSON.stringify(v.via)}`, code: "usage", usage: SHARE.usage }, { json });
138
+ const timeoutS = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
139
+ if (!Number.isFinite(timeoutS) || timeoutS <= 0)
140
+ return failed("share", { message: `--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`, code: "usage" }, { json });
141
+ const w = await reach(SHARE, json);
142
+ if (w === null || !("run" in w))
143
+ return 1;
144
+ const { run, project } = w;
145
+ const token = via === "pinggy" ? appEnvValue(project.root, "MASON_PINGGY_TOKEN")?.value ?? null : null;
146
+ if (token !== null && !TOKEN_RE.test(token)) {
147
+ return failed("share", { message: "MASON_PINGGY_TOKEN is not a token: it holds a character no Pinggy token has", code: "usage", fix: "set it to the token as the Pinggy dashboard shows it" }, { json });
148
+ }
149
+ return guarded("share", json, async () => {
150
+ sweepTunnelFiles(project.dir, alive);
151
+ const password = newSharePassword();
152
+ const held = await openOnRun(run, shareSecret(password), v.role, json);
153
+ if ("message" in held)
154
+ return failed("share", held, { json });
155
+ const opened = held.first;
156
+ return holding({ run, held, opened, password, json, plan: () => tunnelPlan(via, { port: opened.port, home: project.dir, token }), timeoutMs: timeoutS * 1000 });
157
+ });
158
+ }
159
+ /** The share from its socket on: the tunnel, the report, and the wait for its end. Everything is left closed on every way out. */
160
+ /** `send`, each call begun once the one before ended: an "up" must never be overtaken and set back by its "opening". */
161
+ export function inOrder(send) {
162
+ let last = Promise.resolve();
163
+ return (t) => (last = last.then(() => send(t)));
164
+ }
165
+ async function holding(h) {
166
+ const { run, held, opened, password, json } = h;
167
+ const tell = inOrder(async (t) => {
168
+ // what mason status shows, and the run's way to stop the client: the run gone is said by `held.ended`, the share's end
169
+ await callRun(run, "POST", `/api/share/${encodeURIComponent(opened.user)}`, t).catch((e) => say(` the run was not told of the tunnel: ${e.message}`));
170
+ });
171
+ const stop = new Promise((resolve) => { process.once("SIGINT", () => resolve("SIGINT")); process.once("SIGTERM", () => resolve("SIGTERM")); });
172
+ const runEnded = held.ended.then((why) => ({ runEnded: why ?? "the connection to Mason was lost" }));
173
+ const steps = stepRenderer({ line: (t) => { if (!json)
174
+ say(t); }, live: !json && process.stderr.isTTY ? { write: (t) => { process.stderr.write(t); } } : null, style: styleErr() });
175
+ steps.done(`A socket of its own for the share, TLS required (127.0.0.1:${opened.port})`);
176
+ const open = async () => {
177
+ const plan = h.plan();
178
+ const t = await startTunnel(plan, { timeoutMs: h.timeoutMs, onPid: (pid) => { void tell({ pid, via: plan.via, state: "opening" }); } });
179
+ if ("message" in t)
180
+ return t;
181
+ const checked = await verified(t, opened.fingerprint);
182
+ if (typeof checked !== "boolean") {
183
+ t.stop();
184
+ return checked;
185
+ }
186
+ return { tunnel: t, verified: checked };
187
+ };
188
+ const plan = h.plan();
189
+ steps.begin(`Opening a tunnel (${plan.via})`);
190
+ const opening = open();
191
+ const first = await Promise.race([opening, stop, runEnded]);
192
+ if (typeof first === "string" || "runEnded" in first) {
193
+ // ended while it opened: the tunnel, if it comes, goes at once
194
+ void opening.then((o) => { if ("tunnel" in o)
195
+ o.tunnel.stop(); }).catch((e) => say(` the tunnel: ${e.message}`));
196
+ steps.fail();
197
+ held.close();
198
+ return typeof first === "string" ? 0 : failed("share", ended(first.runEnded), { json });
199
+ }
200
+ if ("message" in first) {
201
+ steps.fail();
202
+ held.close();
203
+ return failed("share", first, { json });
204
+ }
205
+ let tunnel = first.tunnel;
206
+ steps.done(`Tunnel open: ${tunnel.host}:${tunnel.port}`);
207
+ await tell({ host: tunnel.host, port: tunnel.port, endsAt: tunnel.endsAt, state: "up", pid: tunnel.pid });
208
+ const next = [["mason status", "the share, its sessions and the logins refused"], ["mason logs --requests", "what the tool asks"]];
209
+ if (json) {
210
+ out(jsonText(SHARE, { share: { host: tunnel.host, port: tunnel.port, database: opened.database, user: opened.user, password, sslmode: "require", fingerprint: opened.fingerprint,
211
+ role: opened.role, reads: opened.reads, via: plan.via, endsAt: tunnel.endsAt, verified: first.verified } }, next));
212
+ }
213
+ else {
214
+ const st = styleOut();
215
+ out(reportText({
216
+ headline: "Sharing the synced data with a tool outside this machine",
217
+ details: [["host", tunnel.host], ["port", String(tunnel.port)], ["database", opened.database], ["user", opened.user], ["password", password], ["sslmode", "require"],
218
+ ["certificate", `SHA-256 ${opened.fingerprint}`], ["reads", opened.role === null ? `${opened.reads} (this project has no roles file)` : `the role ${opened.role}: ${opened.reads}`],
219
+ ["via", plan.via], ["ends", endsText(tunnel.endsAt, Date.now())]],
220
+ tips: [
221
+ "Paste it into the tool now: the password is printed this once, is kept nowhere, and ends with this command.",
222
+ ...(opened.role === null ? ["With no roles file, anyone with this address and password reads every synced table: mason/roles.yml narrows it."] : []),
223
+ ...(first.verified ? [] : [`${tunnel.host}:${tunnel.port} could not be reached from this machine, so it is not verified: a firewall here may be why, and a tool elsewhere may reach it.`]),
224
+ "A self-signed certificate: a service that forwards bytes cannot read them, a malicious one could sit in the middle. For anything lasting: mason help production.",
225
+ ],
226
+ next,
227
+ }, st));
228
+ say("Holding the share: Ctrl-C ends it.");
229
+ }
230
+ // THE WAIT: the signal, the run, or the tunnel; a tunnel that keeps its address is opened again
231
+ for (;;) {
232
+ const end = await Promise.race([stop, runEnded, tunnel.ended.then((why) => ({ tunnelEnded: why }))]);
233
+ if (typeof end === "string") {
234
+ tunnel.stop();
235
+ await Promise.race([tunnel.ended, new Promise((r) => setTimeout(r, 2_500).unref())]);
236
+ held.close();
237
+ if (json)
238
+ say("The share ended.");
239
+ else
240
+ out(reportText({ headline: "The share ended: its address no longer answers, and its password no longer works", next: [["mason share", "a new address and a new password"]] }));
241
+ return 0;
242
+ }
243
+ if ("runEnded" in end) {
244
+ tunnel.stop();
245
+ held.close();
246
+ return failed("share", ended(end.runEnded), { json: false });
247
+ }
248
+ if (!plan.reconnects) {
249
+ held.close();
250
+ return failed("share", { ...ended(`its tunnel closed (${end.tunnelEnded})`), fix: "a free tunnel lasts about an hour: the address is gone, and the password no longer works" }, { json: false });
251
+ }
252
+ const again = await reconnect({ open, tell, why: end.tunnelEnded, stop, runEnded, was: tunnel });
253
+ if (again === null)
254
+ continue; // a signal or the run's end: the next turn of the loop reads it
255
+ tunnel = again;
256
+ }
257
+ }
258
+ /** The share's end as a failure: what ended it, and the one way on. */
259
+ const ended = (why) => ({ message: `the share ended: ${why}`, code: "failed", next: [["mason share", "a new address and a new password"]] });
260
+ /** Opens a dropped tunnel again until it is up, saying so each time; null when a signal or the run's end came first. */
261
+ async function reconnect(o) {
262
+ say(`The tunnel dropped (${o.why}): opening it again; the password stays the same.`);
263
+ await o.tell({ state: "reconnecting" });
264
+ let waitMs = RECONNECT_FIRST_MS;
265
+ for (;;) {
266
+ const waited = await Promise.race([new Promise((r) => setTimeout(() => r("waited"), waitMs).unref()), o.stop.then(() => "ended"), o.runEnded.then(() => "ended")]);
267
+ if (waited === "ended")
268
+ return null;
269
+ const opening = o.open();
270
+ const got = await Promise.race([opening, o.stop.then(() => null), o.runEnded.then(() => null)]);
271
+ if (got === null) {
272
+ void opening.then((a) => { if ("tunnel" in a)
273
+ a.tunnel.stop(); }).catch((e) => say(` the tunnel: ${e.message}`));
274
+ return null;
275
+ }
276
+ if ("tunnel" in got) {
277
+ const t = got.tunnel;
278
+ const moved = t.host !== o.was.host || t.port !== o.was.port;
279
+ say(moved ? `Reconnected, at a NEW address: host ${t.host}, port ${t.port}. The password is the same; the tool needs the new host and port.` : `Reconnected: ${t.host}:${t.port}, the same address and password.`);
280
+ await o.tell({ host: t.host, port: t.port, endsAt: t.endsAt, state: "up", pid: t.pid });
281
+ return t;
282
+ }
283
+ say(` not yet: ${got.message}${got.why ? ` (${got.why})` : ""}; trying again in ${waitMs / 1000} s`);
284
+ waitMs = Math.min(waitMs * 2, RECONNECT_MAX_MS);
285
+ }
286
+ }