@mason-data/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -12,7 +12,8 @@
12
12
  * is enough: the password is 192 random bits, nothing to guess). It is no row of `.mason/users.json`, because that file
13
13
  * outlives a process and a share must not: a run that dies takes it along.
14
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.
15
+ * run's own listener by nobody knowing it, and a `mason user` or a token here by the same; each is told so by its shape,
16
+ * which names what was given and where it works, and decides nothing.
16
17
  *
17
18
  * A LOGIN REFUSED HERE IS LOGGED WITH NO SOURCE: every connection through a tunnel comes from 127.0.0.1, so naming
18
19
  * 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
@@ -23,12 +24,15 @@ import { execFile } from "node:child_process";
23
24
  import { ApiError } from "../api/error.js";
24
25
  import { didYouMean } from "../lib/did-you-mean.js";
25
26
  import { missingAttributes } from "../duck/row-filter.js";
27
+ import { isToken } from "../duck/token.js";
28
+ import { isUserPassword, SHARE_PASSWORD_PREFIX } from "../duck/users.js";
26
29
  import { ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE } from "./roles.js";
27
30
  import { sameSecret } from "./security.js";
28
31
  /** What the password is stored and sent as: its SHA-256, hex. */
29
32
  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");
33
+ /** The new password `mason share` prints once: `mason_share_` and 192 random bits as 48 hex digits, in characters every
34
+ * form and URL takes. Recognisably Mason's and apart from a user's (`isUserPassword`, src/duck/users.ts) and a token's. */
35
+ export const newSharePassword = () => `${SHARE_PASSWORD_PREFIX}${crypto.randomBytes(24).toString("hex")}`;
32
36
  /** How often a refused login is written to the log, at most: the rest are counted. */
33
37
  const REFUSED_LOG_MS = 60_000;
34
38
  /** How many refused logins a minute are rows of the request log, at most: the rest are counted (`mason status`), so a
@@ -58,6 +62,11 @@ export function tunnelReport(body) {
58
62
  return { ...(via !== undefined ? { via } : {}), ...(host !== undefined ? { host } : {}), ...(port !== undefined ? { port } : {}),
59
63
  ...(endsAt !== undefined ? { endsAt } : {}), ...(state !== undefined ? { state } : {}), ...(pid !== undefined ? { pid } : {}) };
60
64
  }
65
+ /** How each holder ends its share, as a refusal names it. */
66
+ const HOW_TO_END = {
67
+ command: ["mason share", "Ctrl-C in the terminal running mason share ends it"],
68
+ page: ["the web page", "Stop sharing on the web page's Overview ends it (mason ui opens the page)"],
69
+ };
61
70
  /** What a role reads, in a line. */
62
71
  function readsText(g) {
63
72
  if (g.all)
@@ -155,15 +164,24 @@ export class RunShares {
155
164
  dropped = 0;
156
165
  lastSaid = -Infinity;
157
166
  unsaid = 0;
167
+ lastEnded = null;
158
168
  constructor(deps) { this.deps = deps; }
159
169
  /** 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
170
  async open(ask) {
171
+ const from = ask.from ?? "command";
172
+ const busy = this.busy(from);
173
+ if (busy !== null)
174
+ return { failure: busy };
161
175
  const role = shareRole(ask.role, await this.deps.listener.checkRoles());
162
176
  if ("failure" in role)
163
177
  return role;
178
+ // asked again after the wait: a share opened meanwhile is one too many
179
+ const raced = this.busy(from);
180
+ if (raced !== null)
181
+ return { failure: raced };
164
182
  const user = `share_${crypto.randomBytes(3).toString("hex")}`;
165
183
  // 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 });
184
+ this.held.set(user, { user, from, secret: ask.secret, role: role.role, reads: role.reads, openedAt: new Date().toISOString(), tunnel: { state: "opening" }, ended: null });
167
185
  let socket, fingerprint;
168
186
  try {
169
187
  const made = this.socket ?? this.openSocket();
@@ -184,6 +202,29 @@ export class RunShares {
184
202
  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
203
  return { user, port: socket.port, fingerprint, database: this.deps.database, role: role.role, reads: role.reads };
186
204
  }
205
+ /**
206
+ * ONE SHARE AT A TIME BETWEEN THE PAGE AND THE COMMAND: the page's share is refused while any share is open, and any
207
+ * share while the page's is (two `mason share`s still meet one socket, as before). The refusal names the share open
208
+ * and how its holder ends it. null: it may open.
209
+ */
210
+ busy(from) {
211
+ const open = [...this.held.values()].find((h) => from === "page" || h.from === "page");
212
+ if (open === undefined)
213
+ return null;
214
+ const [who, how] = HOW_TO_END[open.from];
215
+ return { message: `a share is open already: ${open.user}, opened by ${who}${open.tunnel.host !== undefined ? ` at ${open.tunnel.host}:${open.tunnel.port}` : ""}`, code: "refused",
216
+ why: "one share at a time between the web page and mason share: each is a public address someone must be watching", fix: `${how}, then share again`,
217
+ next: [["mason status", "the share open now"]] };
218
+ }
219
+ /** What a share with no role named reads (`shareRole`'s answer, the one authority): the web page asks it before it asks
220
+ * the person, to offer the roles as `mason share` would. */
221
+ async choices() {
222
+ return shareRole(undefined, await this.deps.listener.checkRoles());
223
+ }
224
+ /** Who holds a share: null when none is open by that name. */
225
+ fromOf(user) { return this.held.get(user)?.from ?? null; }
226
+ /** The last share that ended, and why (`/api/state` `shareEnded`). */
227
+ get ended() { return this.lastEnded; }
187
228
  openSocket() {
188
229
  const cert = this.deps.certificate();
189
230
  if ("message" in cert)
@@ -199,10 +240,11 @@ export class RunShares {
199
240
  return { opening, fingerprint: cert.fingerprint };
200
241
  }
201
242
  authenticate(password, user) {
202
- const given = shareSecret(password);
203
- const found = [...this.held.values()].find((h) => sameSecret(h.secret, given));
243
+ const secret = shareSecret(password);
244
+ const found = [...this.held.values()].find((h) => sameSecret(h.secret, secret));
204
245
  if (found === undefined || found.user !== user) {
205
- const refused = new ApiError(401, "not this share's password", "invalid_share");
246
+ const given = isUserPassword(password) ? "a mason user's password" : isToken(password) ? "an API token" : null;
247
+ const refused = new ApiError(401, given === null ? "not this share's password" : `that is ${given}: it logs in on the run's own port, never at a share's address`, "invalid_share");
206
248
  refused.userName = user;
207
249
  throw refused;
208
250
  }
@@ -253,6 +295,7 @@ export class RunShares {
253
295
  if (h === undefined)
254
296
  return;
255
297
  this.held.delete(user);
298
+ this.lastEnded = { user, from: h.from, why, at: new Date().toISOString() };
256
299
  const opening = this.socket?.opening;
257
300
  if (opening === undefined)
258
301
  return;
@@ -280,7 +323,7 @@ export class RunShares {
280
323
  if (this.socket === null || this.live === null)
281
324
  return null;
282
325
  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,
326
+ shares: [...this.held.values()].map((h) => ({ user: h.user, from: h.from, 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
327
  endsAt: h.tunnel.endsAt ?? null, state: h.tunnel.state ?? "opening" })) };
285
328
  }
286
329
  }
@@ -46,9 +46,9 @@ export const SHARE = {
46
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
47
  way to keep a dashboard running.
48
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
49
+ The password (mason_share_ and 48 hex digits) is made here, printed once and good for this run alone: it is kept
50
+ nowhere, works on this address only, and is gone when the share ends. A mason user's password and an API token are
51
+ refused here. What the share reads is 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
52
  asked, or with --json refused with the line to run. With no roles file it reads every synced table, and says so.
53
53
 
54
54
  --via pinggy (the default) needs only ssh; a free tunnel ends after about an hour, and the share with it: run mason
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { addedTables, chooseColumns, effectiveSync, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withEntrySync, withoutTable, withTable } from "./sources.js";
17
17
  import { BIG_TABLE_ROWS, COSTLY_POLL_ROWS, COSTLY_POLL_SECONDS, syncText } from "../lib/table-sync.js";
18
+ import { projectWriteRefusal } from "./project.js";
18
19
  /** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
19
20
  * default the message alone under the status's code. */
20
21
  export class Refusal extends Error {
@@ -50,11 +51,16 @@ export function pollCost(t, sync, m) {
50
51
  `busy ${Math.round((seconds / every) * 100)} % of the time — a longer interval (--sync "poll 1h"), or --sync replication (the change log), costs it less`;
51
52
  }
52
53
  /**
53
- * The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: no folder, not
54
- * exactly one file, a file with mistakes of its own, a URL that cannot be resolved (`read`, the file read as the run reads
55
- * it: src/cli/sources.ts readSource).
54
+ * The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: its files are
55
+ * read-only here (src/cli/project.ts `projectWriteRefusal`, the one authority, asked FIRST: nothing is read or checked
56
+ * against the database for an edit that cannot be written), no folder, not exactly one file, a file with mistakes of its
57
+ * own, a URL that cannot be resolved (`read`, the file read as the run reads it: src/cli/sources.ts readSource). An
58
+ * `EditTarget` is made here alone, so every edit `addEntry` and `removeEntries` write has asked.
56
59
  */
57
- export function sourceToEdit(folder, root, read) {
60
+ export function sourceToEdit(folder, root, read, env = process.env) {
61
+ const readOnly = projectWriteRefusal(root, env);
62
+ if (readOnly !== null)
63
+ throw new Refusal(409, readOnly.message, readOnly);
58
64
  if (!folder)
59
65
  throw new Refusal(409, "choose the project's folder first");
60
66
  const names = sourceFileNames(folder);
@@ -0,0 +1,344 @@
1
+ /**
2
+ * WHETHER THE ROLE MASON CONNECTS AS COULD WRITE TO THE SOURCE (issue 524), for `mason doctor`. Mason only reads the
3
+ * database it syncs (CLAUDE.md "The source database is production"), so the role it connects as need not be able to
4
+ * write either: one that can lets a Mason bug, or a stolen URL, change production. Said as WARNINGS, never a refusal:
5
+ * a start goes on, and the way out is the statements that make a role that only reads.
6
+ *
7
+ * ONE catalog read (`roleFactsSql`), no write ever attempted, and every privilege decided by Postgres itself:
8
+ * `has_table_privilege`, `has_any_column_privilege` (a column's INSERT, UPDATE or REFERENCES is a write too),
9
+ * `has_schema_privilege` and `has_function_privilege`, asked for the role, for every role whose privileges it holds or
10
+ * may SET ROLE to (`pg_has_role … 'USAGE'` or `'SET'` from Postgres 16; `'MEMBER'` before, when any membership could
11
+ * SET ROLE), and for PUBLIC, which every role holds; never an ACL read by hand.
12
+ *
13
+ * THE CLASS (PR 572's review, two rounds): every way Postgres lets a role change a synced table's rows WITHOUT a
14
+ * privilege on that table, each read from its catalog. The "only reads" line is true by construction because each is
15
+ * asked, and over-approximated where the catalog cannot say more (a warning too many, never one too few):
16
+ * - DECLARATIVE ROUTES, followed transitively (`reach`): a descendant by `pg_inherits` (a partition, or an INHERITS
17
+ * child, whose rows ARE the synced table's: every privilege); an INHERITS ancestor (an UPDATE, DELETE or TRUNCATE
18
+ * on it reaches its children's rows, Postgres asking only the parent's privilege); a relation whose rules depend on
19
+ * a route (`pg_rewrite` through `pg_depend`: a view at any depth, or another table's rule), for the INSERT, UPDATE
20
+ * and DELETE `pg_relation_is_updatable` says it takes; and a table a route's foreign key references with an ON
21
+ * DELETE or ON UPDATE action that writes (`pg_constraint` `confdeltype` / `confupdtype` CASCADE, SET NULL or SET
22
+ * DEFAULT), whose action runs as the referencing table's owner. TRUNCATE … CASCADE is no route: Postgres asks
23
+ * TRUNCATE on every table it cascades to (measured on 16).
24
+ * - CODE THAT RUNS AS ANOTHER ROLE: a SECURITY DEFINER function whose owner may write a route (a superuser, or one
25
+ * holding a route's privilege), not the role's own, that it reaches as Postgres decides (measured on 16, PR 572's
26
+ * third review): by EXECUTE, wherever it is called from (a view, a policy, a default, a CHECK, an index, a cast, an
27
+ * operator); by a trigger on a relation it may write; and with no EXECUTE asked, by an event trigger (any DDL, even
28
+ * one refused), an aggregate it may call (its support functions), a type (its I/O), an operator's estimators, an
29
+ * operator class's, a range's or a conversion's function. Volatility bounds nothing (a STABLE definer calling a
30
+ * VOLATILE writer writes). What such a function writes is in its body, which the catalog does not read: it is its own
31
+ * finding (`role_may_run_definer_code`), LISTED for a person to review, and the role is never said to "only read"
32
+ * while one is reached. Code of invoker's rights runs with the role's own privileges, which the rest asks.
33
+ * - ANOTHER IDENTITY BY CONNECTION: a foreign table it may write, or a user mapping on a server it may use (postgres_fdw,
34
+ * dblink): what it sends runs as the mapping's user, maybe on this very database.
35
+ * - BECOMING ANOTHER ROLE: ADMIN OPTION on a role (it may grant itself that role), CREATEROLE before Postgres 16 (any
36
+ * role but a superuser), and pg_execute_server_program, pg_write_server_files or pg_read_server_files (the server's
37
+ * programs and files as its operating-system user).
38
+ * Not routes: COPY … FROM needs INSERT, which is asked; a large object is not a table's rows; a publication only reads;
39
+ * a subscription writes as its owner, with that owner's privileges, and a table it fills is written from another
40
+ * database, which this database cannot see.
41
+ *
42
+ * The statement runs under `search_path = pg_catalog` (`SET LOCAL`, ending with it, as VIEWS_SQL does), so no function,
43
+ * operator or table of the role's own schemas can stand in for the catalog's.
44
+ */
45
+ import { pgLiteral } from "../cdc/pg-wire.js";
46
+ import { shownUrl } from "./preflight.js";
47
+ /** The privileges on a table that write or change it (SELECT is the one Mason needs), in their bit order (`mask`). */
48
+ export const WRITE_PRIVILEGES = ["INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"];
49
+ /** Each warning's code, stable for an agent: one per thing the role should not have (`role_unknown`: could not tell). */
50
+ export const ROLE_CODES = ["role_superuser", "role_bypassrls", "role_owns_table", "role_writes_table", "role_creates_in_schema",
51
+ "role_may_run_definer_code", "role_reaches_foreign_server", "role_may_become_another", "role_unknown"];
52
+ /** The name the way out gives the role that only reads, or the first of `mason_reader2`, … not taken. */
53
+ export const READER_ROLE = "mason_reader";
54
+ /** Whether `role` holds privilege `p` on relation `rel` (SQL expressions): a column's INSERT, UPDATE or REFERENCES counts */
55
+ const holds = (role, rel, p) => `CASE WHEN ${p} IN ('INSERT', 'UPDATE', 'REFERENCES') THEN has_any_column_privilege(${role}, ${rel}, ${p}) ELSE has_table_privilege(${role}, ${rel}, ${p}) END`;
56
+ /** The predefined roles that reach the server's programs or files (a superuser's, in effect) */
57
+ export const SERVER_ROLES = ["pg_execute_server_program", "pg_write_server_files", "pg_read_server_files"];
58
+ /**
59
+ * Everything the checks read, in ONE statement under the catalog's search_path, for the tables `tables` names (a JSON
60
+ * list of {schema, table}). A superuser holds every privilege: what else it holds is not asked.
61
+ */
62
+ export function roleFactsSql(tables) {
63
+ const want = pgLiteral(JSON.stringify(tables.map((t) => ({ schema: t.schema, table: t.table }))));
64
+ const writes = WRITE_PRIVILEGES.map((p) => `'${p}'`).join(", ");
65
+ const all = (1 << WRITE_PRIVILEGES.length) - 1, ins = 1, upd = 2, del = 4, trunc = 8;
66
+ return `SET LOCAL search_path = pg_catalog; WITH RECURSIVE me AS (SELECT oid, rolname, rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user),
67
+ -- the roles whose privileges it holds or that it may switch to (Postgres 16 split the two, before it any membership was both)
68
+ r AS (SELECT p.oid, p.rolname, p.rolsuper, p.rolbypassrls, p.oid = me.oid AS self FROM pg_roles p, me
69
+ WHERE p.oid = me.oid OR (NOT me.rolsuper AND CASE WHEN current_setting('server_version_num')::int >= 160000
70
+ THEN pg_has_role(me.oid, p.oid, 'USAGE') OR pg_has_role(me.oid, p.oid, 'SET') ELSE pg_has_role(me.oid, p.oid, 'MEMBER') END)),
71
+ su AS (SELECT EXISTS (SELECT FROM r WHERE rolsuper) AS any),
72
+ want AS (SELECT w.schema, w."table" FROM json_to_recordset(${want}::json) AS w(schema text, "table" text)),
73
+ rel AS (SELECT w.schema, w."table", c.oid FROM want w JOIN pg_namespace n ON n.nspname = w.schema
74
+ JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = w."table" AND c.relkind IN ('r', 'p')),
75
+ -- how a write on one relation reaches another's rows. need: the modes it leaves from (S a synced table, R its rows'
76
+ -- relation, A an ancestor, F a referenced table, V a view or rule), gives: the mode it reaches, mask: the privileges
77
+ -- (bits in WRITE_PRIVILEGES' order) that reach, a view's or rule's read from pg_relation_is_updatable below
78
+ edge AS (SELECT inhparent AS src, inhrelid AS dst, 'SR' AS need, 'R' AS gives, ${all} AS mask FROM pg_inherits
79
+ UNION ALL SELECT inhrelid, inhparent, 'SRAF', 'A', ${upd | del | trunc} FROM pg_inherits
80
+ UNION ALL SELECT d.refobjid, rw.ev_class, 'SRAFV', 'V', 0 FROM pg_depend d JOIN pg_rewrite rw ON rw.oid = d.objid
81
+ WHERE d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND rw.ev_class <> d.refobjid
82
+ UNION ALL SELECT conrelid, confrelid, 'SRF', 'F', CASE WHEN confdeltype IN ('c', 'n', 'd') THEN ${del} ELSE 0 END | CASE WHEN confupdtype IN ('c', 'n', 'd') THEN ${upd} ELSE 0 END
83
+ FROM pg_constraint WHERE contype = 'f' AND (confdeltype IN ('c', 'n', 'd') OR confupdtype IN ('c', 'n', 'd'))),
84
+ -- each synced table and every relation that reaches its rows, at any depth: UNION stops at what it has seen
85
+ reach AS (SELECT schema || '.' || "table" AS entry, oid, 'S' AS mode, ${all} AS mask FROM rel
86
+ UNION SELECT x.entry, e.dst, e.gives, e.mask FROM reach x JOIN edge e ON e.src = x.oid AND position(x.mode IN e.need) > 0),
87
+ route AS (SELECT x.entry, x.oid, min(position(x.mode IN 'SRAFV')) AS rank,
88
+ bit_or(CASE WHEN x.mode <> 'V' THEN x.mask ELSE CASE WHEN k.u & 8 <> 0 THEN ${ins} ELSE 0 END | CASE WHEN k.u & 4 <> 0 THEN ${upd} ELSE 0 END | CASE WHEN k.u & 16 <> 0 THEN ${del} ELSE 0 END END) AS mask
89
+ FROM reach x CROSS JOIN LATERAL (SELECT CASE WHEN x.mode = 'V' THEN pg_relation_is_updatable(x.oid, true) ELSE 0 END AS u) k GROUP BY x.entry, x.oid),
90
+ -- each privilege that reaches a synced table's rows through a route
91
+ asks AS (SELECT x.entry, x.oid, u.p, u.o FROM route x CROSS JOIN unnest(ARRAY[${writes}]) WITH ORDINALITY AS u(p, o) WHERE x.mask & (1 << (u.o::int - 1)) <> 0),
92
+ who AS (SELECT rolname AS role, CASE WHEN self THEN 0 ELSE 1 END AS ord FROM r UNION ALL SELECT 'public', 2),
93
+ -- SECURITY DEFINER code (not the role's own) whose owner may write a synced table's rows: each owner asked once
94
+ sd AS (SELECT f.oid, f.proowner, f.prorettype NOT IN ('trigger'::regtype, 'event_trigger'::regtype) AS direct FROM pg_proc f, su
95
+ WHERE f.prosecdef AND NOT su.any AND f.proowner NOT IN (SELECT oid FROM r)),
96
+ writer AS (SELECT o.oid, o.rolname, o.rolsuper FROM pg_roles o WHERE o.oid IN (SELECT proowner FROM sd)
97
+ AND (o.rolsuper OR EXISTS (SELECT FROM asks a WHERE ${holds("o.rolname", "a.oid", "a.p")}))),
98
+ definer AS (SELECT sd.oid, sd.direct, w.rolname AS owner, w.rolsuper AS owner_super FROM sd JOIN writer w ON w.oid = sd.proowner),
99
+ -- the relations it may write whose enabled trigger calls one (pg_trigger read once, joined: never once per function)
100
+ dtrig AS (SELECT DISTINCT t.tgfoid, tn.nspname || '.' || tc.relname AS rel FROM pg_trigger t JOIN definer d ON d.oid = t.tgfoid
101
+ JOIN pg_class tc ON tc.oid = t.tgrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace
102
+ WHERE NOT t.tgisinternal AND t.tgenabled <> 'D'
103
+ AND EXISTS (SELECT FROM who w, unnest(ARRAY['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS u(p) WHERE ${holds("w.role::name", "t.tgrelid", "u.p")})),
104
+ -- what Postgres calls without asking the caller's EXECUTE: an event trigger's function, an aggregate's support
105
+ -- functions (when it may call the aggregate), a type's input, output, receive, send, typmod and analyze functions, an
106
+ -- operator's estimators, an operator class's support functions, a range's, a conversion's
107
+ unchecked AS (SELECT evtfoid AS fn FROM pg_event_trigger WHERE evtenabled <> 'D'
108
+ UNION SELECT a.fn FROM (SELECT aggfnoid, unnest(ARRAY[aggtransfn::oid, aggfinalfn::oid, aggcombinefn::oid, aggserialfn::oid, aggdeserialfn::oid,
109
+ aggmtransfn::oid, aggminvtransfn::oid, aggmfinalfn::oid]) AS fn FROM pg_aggregate) a
110
+ WHERE a.fn IN (SELECT oid FROM definer) AND EXISTS (SELECT FROM who w WHERE has_function_privilege(w.role::name, a.aggfnoid, 'EXECUTE'))
111
+ UNION SELECT unnest(ARRAY[typinput::oid, typoutput::oid, typreceive::oid, typsend::oid, typmodin::oid, typmodout::oid, typanalyze::oid]) FROM pg_type
112
+ UNION SELECT unnest(ARRAY[oprrest::oid, oprjoin::oid]) FROM pg_operator
113
+ UNION SELECT amproc::oid FROM pg_amproc
114
+ UNION SELECT unnest(ARRAY[rngcanonical::oid, rngsubdiff::oid]) FROM pg_range
115
+ UNION SELECT conproc::oid FROM pg_conversion)
116
+ SELECT json_build_object(
117
+ 'user', me.rolname, 'user_sql', quote_ident(me.rolname), 'database_sql', quote_ident(current_database()),
118
+ 'roles', (SELECT json_agg(json_build_object('name', rolname, 'self', self, 'superuser', rolsuper, 'bypassrls', rolbypassrls) ORDER BY NOT self, rolname) FROM r),
119
+ 'relations', (SELECT coalesce(json_agg(json_build_object('name', n.nspname || '.' || c.relname, 'sql', quote_ident(n.nspname) || '.' || quote_ident(c.relname),
120
+ 'kind', CASE x.rank WHEN 1 THEN 'table' WHEN 2 THEN CASE WHEN c.relispartition THEN 'partition' ELSE 'child' END WHEN 3 THEN 'parent' WHEN 4 THEN 'referenced'
121
+ ELSE CASE WHEN c.relkind = 'v' THEN 'view' ELSE 'rule' END END,
122
+ 'entry', x.entry, 'schema_sql', quote_ident(n.nspname), 'owner', pg_get_userbyid(c.relowner),
123
+ 'owned', x.rank <= 2 AND c.relowner IN (SELECT oid FROM r), 'row_security', c.relrowsecurity,
124
+ 'privileges', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(json_build_object('role', CASE WHEN g.ord = 2 THEN 'PUBLIC' ELSE g.role END, 'privileges', g.privs) ORDER BY g.ord, g.role), '[]'::json)
125
+ FROM (SELECT w.role, w.ord, (SELECT coalesce(array_agg(a.p ORDER BY a.o), '{}') FROM asks a WHERE a.entry = x.entry AND a.oid = x.oid AND ${holds("w.role::name", "a.oid", "a.p")}) AS privs
126
+ FROM who w) g WHERE cardinality(g.privs) > 0) END) ORDER BY x.rank, x.entry, n.nspname, c.relname), '[]'::json)
127
+ FROM route x JOIN pg_class c ON c.oid = x.oid JOIN pg_namespace n ON n.oid = c.relnamespace),
128
+ 'schemas', (SELECT coalesce(json_agg(json_build_object('name', n.nspname, 'sql', quote_ident(n.nspname), 'owner', pg_get_userbyid(n.nspowner),
129
+ 'owned', n.nspowner IN (SELECT oid FROM r),
130
+ 'create', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(rr.rolname ORDER BY NOT rr.self, rr.rolname), '[]'::json) FROM r rr WHERE has_schema_privilege(rr.rolname, n.oid, 'CREATE')) END,
131
+ 'public_create', has_schema_privilege('public'::name, n.oid, 'CREATE')) ORDER BY n.nspname), '[]'::json)
132
+ FROM pg_namespace n WHERE n.oid IN (SELECT c.relnamespace FROM route x JOIN pg_class c ON c.oid = x.oid WHERE x.rank <= 2)),
133
+ 'definers', (SELECT coalesce(json_agg(d ORDER BY d.name), '[]'::json) FROM (
134
+ SELECT quote_ident(n.nspname) || '.' || quote_ident(f.proname) || '(' || pg_get_function_identity_arguments(f.oid) || ')' AS name, x.owner, x.owner_super,
135
+ -- reached through a view, a policy, a default, a CHECK, an index, a cast or an operator only by EXECUTE, a
136
+ -- trigger's function only by its trigger
137
+ x.direct AND EXISTS (SELECT FROM who w WHERE has_function_privilege(w.role::name, x.oid, 'EXECUTE')) AS callable,
138
+ x.direct AND has_function_privilege('public'::name, x.oid, 'EXECUTE') AS public_callable,
139
+ (SELECT coalesce(json_agg(t.rel ORDER BY t.rel), '[]'::json) FROM dtrig t WHERE t.tgfoid = x.oid) AS triggers,
140
+ EXISTS (SELECT FROM pg_event_trigger e WHERE e.evtfoid = x.oid AND e.evtenabled <> 'D') AS event_trigger,
141
+ x.oid IN (SELECT fn FROM unchecked) AS unchecked
142
+ FROM definer x JOIN pg_proc f ON f.oid = x.oid JOIN pg_namespace n ON n.oid = f.pronamespace) d
143
+ WHERE d.callable OR json_array_length(d.triggers) > 0 OR d.unchecked),
144
+ 'foreign_tables', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(n.nspname || '.' || c.relname ORDER BY n.nspname, c.relname), '[]'::json)
145
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'f'
146
+ AND EXISTS (SELECT FROM who w, unnest(ARRAY['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS u(p) WHERE ${holds("w.role::name", "c.oid", "u.p")})) END,
147
+ 'mapped_servers', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(DISTINCT m.srvname), '[]'::json) FROM pg_user_mappings m
148
+ WHERE (m.umuser = 0 OR m.umuser IN (SELECT oid FROM r)) AND EXISTS (SELECT FROM who w WHERE has_server_privilege(w.role::name, m.srvid, 'USAGE'))) END,
149
+ 'admin_of', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(p.rolname ORDER BY p.rolname), '[]'::json) FROM pg_roles p
150
+ WHERE p.oid <> me.oid AND pg_has_role(me.oid, p.oid, 'MEMBER WITH ADMIN OPTION')) END,
151
+ 'create_role', NOT su.any AND me.rolcreaterole AND current_setting('server_version_num')::int < 160000,
152
+ 'server_roles', (SELECT coalesce(json_agg(rolname ORDER BY rolname), '[]'::json) FROM r WHERE NOT su.any AND rolname IN (${SERVER_ROLES.map((x) => `'${x}'`).join(", ")})),
153
+ 'missing', (SELECT coalesce(json_agg(w.schema || '.' || w."table"), '[]'::json) FROM want w WHERE NOT EXISTS (SELECT FROM rel WHERE rel.schema = w.schema AND rel."table" = w."table")),
154
+ 'taken', (SELECT coalesce(json_agg(rolname ORDER BY rolname), '[]'::json) FROM pg_roles WHERE rolname LIKE '${READER_ROLE.replace(/_/g, "\\_")}%'),
155
+ 'rds_replication', EXISTS (SELECT FROM pg_roles WHERE rolname = 'rds_replication')
156
+ )::text FROM me, su`;
157
+ }
158
+ /** The role's facts, read in the one statement; a role with no row of pg_roles (it was dropped while connected) throws. */
159
+ export async function readRoleFacts(pg, tables) {
160
+ const row = JSON.parse((await pg.query(roleFactsSql(tables))).rows[0]?.[0] ?? "null");
161
+ if (row === null)
162
+ throw new Error("pg_roles has no row for the user connected");
163
+ return {
164
+ user: row.user, userSql: row.user_sql, databaseSql: row.database_sql,
165
+ roles: row.roles,
166
+ relations: row.relations.map((x) => ({ name: x.name, sql: x.sql, kind: x.kind, entry: x.entry, schemaSql: x.schema_sql, owner: x.owner, owned: x.owned,
167
+ rowSecurity: x.row_security, privileges: x.privileges })),
168
+ schemas: row.schemas.map((s) => ({ name: s.name, sql: s.sql, owner: s.owner, owned: s.owned, create: s.create, publicCreate: s.public_create })),
169
+ definers: row.definers.map((d) => ({ name: d.name, owner: d.owner, ownerSuperuser: d.owner_super, callable: d.callable, publicCallable: d.public_callable,
170
+ triggers: d.triggers, eventTrigger: d.event_trigger, unchecked: d.unchecked && !d.event_trigger })),
171
+ foreignTables: row.foreign_tables, mappedServers: row.mapped_servers, adminOf: row.admin_of, createRole: row.create_role, serverRoles: row.server_roles,
172
+ missing: row.missing, taken: row.taken, rdsReplication: row.rds_replication,
173
+ };
174
+ }
175
+ const list = (xs) => xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`;
176
+ /** "through writers", "granted to PUBLIC": how a role came to hold something it holds by another */
177
+ function route(others) {
178
+ const roles = others.filter((r) => r !== "PUBLIC");
179
+ const parts = [...(roles.length ? [`through ${list(roles)}, which it is a member of`] : []), ...(others.includes("PUBLIC") ? ["granted to PUBLIC, which every role is"] : [])];
180
+ return parts.length ? ` (${parts.join("; ")})` : "";
181
+ }
182
+ /** " (a partition of public.events)": how a relation that is not a synced table reaches one */
183
+ function via(x) {
184
+ const said = { table: "", partition: ` (a partition of ${x.entry})`, child: ` (a child of ${x.entry}, by INHERITS: its rows are ${x.entry}'s)`,
185
+ parent: ` (an INHERITS parent of ${x.entry}: an UPDATE, DELETE or TRUNCATE of it reaches ${x.entry}'s rows)`,
186
+ referenced: ` (a foreign key's ON DELETE or ON UPDATE action carries its changes to ${x.entry}, as its owner)`,
187
+ view: ` (a view that writes ${x.entry})`, rule: ` (its rules write ${x.entry})` };
188
+ return said[x.kind];
189
+ }
190
+ const WHY = "Mason only reads, so a role that may write lets a Mason bug or a stolen URL change your database";
191
+ /** The checks of the role, from its facts: one warning per code that applies, else one line saying it only reads. */
192
+ export function roleChecks(f, ask) {
193
+ const fix = readOnlyFix(f, ask);
194
+ const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}: sync as a role that only reads`, fix });
195
+ const out = [];
196
+ const supers = f.roles.filter((r) => r.superuser);
197
+ if (supers.length) {
198
+ // a superuser may do everything: what else it holds follows from it, and is not said again
199
+ const self = supers.some((r) => r.self);
200
+ out.push(warn("role_superuser", self ? `${f.user} is a superuser: it may write to, alter or drop anything on this database`
201
+ : `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser: it may SET ROLE to it and write to, alter or drop anything on this database`));
202
+ return out;
203
+ }
204
+ const bypass = f.roles.filter((r) => r.bypassrls);
205
+ if (bypass.length) {
206
+ out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}: row-level security does not apply to it, on any table`));
207
+ }
208
+ const owned = f.relations.filter((x) => x.owned);
209
+ if (owned.length) {
210
+ const others = [...new Set(owned.map((x) => x.owner).filter((o) => o !== f.user))];
211
+ out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}: ` +
212
+ "an owner may write to, alter or drop a table whatever is granted"));
213
+ }
214
+ // an owner holds every privilege on its table: the owner's line said it
215
+ const writable = f.relations.filter((x) => !x.owned && x.privileges.length > 0);
216
+ if (writable.length) {
217
+ const held = writable.map((x) => {
218
+ const privs = WRITE_PRIVILEGES.filter((p) => x.privileges.some((g) => g.privileges.includes(p)));
219
+ const others = x.privileges.filter((g) => g.role !== f.user).map((g) => g.role);
220
+ return `${privs.join(", ")} on ${x.name}${via(x)}${route(others)}`;
221
+ });
222
+ out.push(warn("role_writes_table", `${f.user} may ${held.join("; ")}`));
223
+ }
224
+ const creates = f.schemas.filter((s) => s.create.length > 0 || s.publicCreate);
225
+ if (creates.length) {
226
+ const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`}: it may drop what is in it too)` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
227
+ out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}: it may make tables and functions beside the synced ones`));
228
+ }
229
+ if (f.foreignTables.length || f.mappedServers.length) {
230
+ const held = [...(f.foreignTables.length ? [`may write the foreign table(s) ${list(f.foreignTables)}`] : []),
231
+ ...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}, which it may use`] : [])];
232
+ out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}: what it sends there runs as the mapping's user, and the server may be this very database`));
233
+ }
234
+ if (f.adminOf.length || f.createRole || f.serverRoles.length) {
235
+ const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}: it may grant itself that role and its privileges`] : []),
236
+ ...(f.createRole ? ["has CREATEROLE, which before Postgres 16 lets it grant itself any role but a superuser"] : []),
237
+ ...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}: the server's programs or files, as its operating-system user`] : [])];
238
+ out.push(warn("role_may_become_another", `${f.user} ${held.join("; ")}`));
239
+ }
240
+ // SECURITY DEFINER code: what it writes is in its body, which the catalog does not say, so it is LISTED for a person
241
+ // to review, never "fixed" by a statement that changes another role's privileges, and never taken for "only reads"
242
+ const definers = definerLine(f);
243
+ if (out.length)
244
+ return definers === null ? out : [...out, { name: "role", ok: true, warn: true, code: "role_may_run_definer_code", detail: `${f.user} ${definers}` }];
245
+ const unread = f.missing.length ? ` (not asked of ${list(f.missing)}, which the database does not have: mason start says so)` : "";
246
+ const synced = f.relations.filter((x) => x.kind === "table").length;
247
+ const itself = "not a superuser, no BYPASSRLS, no ADMIN OPTION, no server roles, no foreign server" + (synced ? `, and it neither owns nor may write to the ${synced} synced table(s) — ` +
248
+ "nor to their partitions, inheritance children or parents, the tables their foreign keys act on, or the views and rules that write them — " +
249
+ "nor CREATE in their schemas" : ", and no table is added yet (the tables are asked once one is)");
250
+ if (definers !== null)
251
+ return [{ name: "role", ok: true, warn: true, code: "role_may_run_definer_code", detail: `${f.user} may not write the synced tables itself: ${itself}. But it ${definers}${unread}` }];
252
+ return [{ name: "role", ok: true, detail: `${f.user} only reads: ${itself}, and it reaches no SECURITY DEFINER code whose owner may write${unread}` }];
253
+ }
254
+ /** "may run 2 SECURITY DEFINER function(s) of roles that may write the synced tables: …; what they write is not known", or null */
255
+ function definerLine(f) {
256
+ if (!f.definers.length)
257
+ return null;
258
+ const each = f.definers.map((d) => {
259
+ const how = [...(d.callable ? [d.publicCallable ? "every role may call it (PUBLIC's EXECUTE)" : "it may call it"] : []),
260
+ ...(d.triggers.length ? [`a trigger on ${list(d.triggers)}, which it may write, calls it`] : []),
261
+ ...(d.eventTrigger ? ["an event trigger calls it on any DDL, even one refused, never asking EXECUTE"] : []),
262
+ ...(d.unchecked ? ["Postgres calls it for an aggregate, a type, an operator or the like, never asking EXECUTE"] : [])];
263
+ return `${d.name} runs as ${d.owner}${d.ownerSuperuser ? ", a superuser" : ", which may write the synced tables"}: ${how.join("; ")}`;
264
+ });
265
+ return `may run ${f.definers.length} SECURITY DEFINER function(s) of roles that may write the synced tables — ${each.join("; ")}. ` +
266
+ "What they write is not known (it is in their bodies, which the catalog does not read): review each, whether it writes a synced table and who may reach it";
267
+ }
268
+ /** The line a check is when the role could not be asked: said, never taken for "only reads". */
269
+ export function roleUnknown(why) {
270
+ return { name: "role", ok: null, code: "role_unknown", detail: `could not tell whether the user Mason connects as may write to the database: ${why}` };
271
+ }
272
+ /**
273
+ * The URL `url` with `role` as its user and a placeholder for its password, nothing else of it changed: built from what
274
+ * `shownUrl` shows (src/cli/preflight.ts, issue 541), so no part the URL is not read with is ever printed; a value it
275
+ * does not show is said in words.
276
+ */
277
+ export function readerUrl(url, role) {
278
+ const shown = shownUrl(url);
279
+ if (shown === null)
280
+ return `the database's URL with ${role} and its password as the user and password`;
281
+ const u = new URL(shown);
282
+ return `${u.protocol}//${role}:<its password>@${u.host}${u.pathname}${u.search}`;
283
+ }
284
+ /**
285
+ * THE WAY OUT, as the statements the database's owner runs, then the line of the project to change: a role that may
286
+ * LOGIN and SELECT the synced tables and nothing else — REPLICATION where a table streams, and the publication made by
287
+ * the owner when Mason would make its own (CREATE PUBLICATION … FOR TABLE needs the tables' owner); a policy for it where
288
+ * a table has row-level security (the new role would read no row of it). It NEVER prints a statement that changes any
289
+ * role's privileges but the new one's (PR 572's review: a REVOKE … FROM PUBLIC of a policy's function, run as printed,
290
+ * broke the application's own reads): what PUBLIC holds, and the SECURITY DEFINER code every role reaches, the new role
291
+ * holds and reaches too, and is SAID in a comment, for the database's owner to decide.
292
+ */
293
+ export function readOnlyFix(f, ask) {
294
+ let role = READER_ROLE;
295
+ for (let n = 2; f.taken.includes(role); n++)
296
+ role = `${READER_ROLE}${n}`;
297
+ const tables = f.relations.filter((x) => x.kind === "table");
298
+ const schemas = [...new Set(tables.map((x) => x.schemaSql))];
299
+ const replication = ask.replicated.length > 0;
300
+ const replicating = replication && !f.rdsReplication;
301
+ const lines = [`-- as the database's owner (on the primary):`,
302
+ `CREATE ROLE ${role} LOGIN${replicating ? " REPLICATION" : ""} PASSWORD '<a new password>';`];
303
+ if (replication && f.rdsReplication)
304
+ lines.push(`GRANT rds_replication TO ${role};`);
305
+ lines.push(`GRANT CONNECT ON DATABASE ${f.databaseSql} TO ${role};`);
306
+ if (tables.length) {
307
+ lines.push(`GRANT USAGE ON SCHEMA ${schemas.join(", ")} TO ${role};`, `GRANT SELECT ON ${tables.map((x) => x.sql).join(", ")} TO ${role};`);
308
+ }
309
+ else {
310
+ lines.push(`-- and GRANT USAGE ON SCHEMA <schema>, SELECT ON <table> TO ${role}, for each table you add`);
311
+ }
312
+ for (const x of tables.filter((t) => t.rowSecurity))
313
+ lines.push(`CREATE POLICY ${role}_reads ON ${x.sql} FOR SELECT TO ${role} USING (true); -- row-level security: else it reads no row`);
314
+ // PUBLIC's privileges are every role's, the new one's too: said for the database's owner to decide, never revoked here
315
+ const publicCreate = f.schemas.filter((s) => s.publicCreate);
316
+ if (publicCreate.length)
317
+ lines.push(`-- PUBLIC may CREATE in schema ${list(publicCreate.map((s) => s.name))} (the default before Postgres 15), so the new role may too: whether PUBLIC should is the owner's to decide, for every role`);
318
+ for (const x of f.relations) {
319
+ const pub = x.privileges.find((g) => g.role === "PUBLIC");
320
+ if (pub)
321
+ lines.push(`-- PUBLIC holds ${pub.privileges.join(", ")} on ${x.name}, so the new role does too: whether PUBLIC should is the owner's to decide, for every role`);
322
+ }
323
+ // what Mason's own would carry (src/duck/sync.ts ensurePublication): the replicated tables, every change
324
+ const published = tables.filter((x) => ask.replicated.includes(x.entry));
325
+ const ownPublication = ask.publication === null && published.length > 0;
326
+ if (ownPublication) {
327
+ lines.push(`CREATE PUBLICATION ${role} FOR TABLE ${published.map((x) => x.sql).join(", ")} WITH (publish = 'insert, update, delete, truncate'); -- Mason's own needs the tables' owner`);
328
+ }
329
+ // what every role reaches, the new one too, is SAID, never changed: a statement that changed PUBLIC's would change
330
+ // every role's, the application's included (PR 572's review: a REVOKE of a policy's function broke the app's reads)
331
+ const everyone = f.definers.filter((d) => d.publicCallable || d.eventTrigger || d.unchecked);
332
+ if (everyone.length)
333
+ lines.push(`-- the new role may still run ${list(everyone.map((d) => d.name))}, as every role may (SECURITY DEFINER): mason doctor lists them to review`);
334
+ if (f.mappedServers.length)
335
+ lines.push(`-- a user mapping FOR PUBLIC on ${list(f.mappedServers)} would serve it too: map the roles that need it by name`);
336
+ lines.push("-- and GRANT SELECT ON each view Mason should serve: it serves the views the role it connects as may read");
337
+ // the app's own DATABASE_URL stays the app's: Mason's gets a variable of its own
338
+ const variable = ask.urlVariable === null || ask.urlVariable === "DATABASE_URL" ? "MASON_DATABASE_URL" : ask.urlVariable;
339
+ const own = variable !== ask.urlVariable;
340
+ lines.push(`then: set ${variable}=${readerUrl(ask.url, role)} ${own ? "in .env" : `where it is set now (${ask.urlFrom})`}` +
341
+ `${own ? ` and write url: \${${variable}} in ${ask.file}` : ""}${ownPublication ? `${own ? "," : " and"} publication: ${role} at its top` : ""}` +
342
+ "; mason doctor checks it again, and a running Mason reads it at its next start (mason stop, then mason start)");
343
+ return lines.join("\n");
344
+ }
@@ -181,7 +181,7 @@ export function parseSourceText(text, name, file) {
181
181
  else if (bare)
182
182
  problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
183
183
  else if (v.url.includes("${"))
184
- problems.push(`url: ${JSON.stringify(v.url)} — write the URL whole, or \${NAME} alone: the whole URL in one variable, like \${DATABASE_URL}`);
184
+ problems.push("url: holds a ${NAME} inside a longer value (not shown: it may hold a password) — write the URL whole, or ${NAME} alone: the whole URL in one variable, like ${DATABASE_URL}");
185
185
  else
186
186
  url = { literal: v.url.trim() };
187
187
  }
@@ -385,25 +385,10 @@ const tooManySources = (root, dir, names) => ({
385
385
  * Mason made (its tables listed in another folder, which nothing reads) that mason init writes it.
386
386
  */
387
387
  export function readSource(folder, root, env = process.env, o = {}) {
388
- const names = sourceFileNames(folder);
389
- const dir = sourcesDir(folder);
390
- if (names.length === 0) {
391
- const old = path.join(folder, OLD_TABLES_DIR);
392
- if (fs.existsSync(old)) {
393
- // no backwards compatibility before v1.0 (CLAUDE.md): nothing is migrated, the way out is said
394
- return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file, and ${shownPath(root, old)}/ is an older Mason's, which this one does not read`, code: "bad_source",
395
- fix: `mason init writes ${shownPath(root, dir)}/ (then mason add each table again); remove ${shownPath(root, old)}/ afterwards`, next: [["mason init", `write ${shownPath(root, dir)}/<database>.yml`]] };
396
- }
397
- const file = path.join(dir, "app.yml");
398
- return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
399
- fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
400
- }
401
- if (names.length > 1)
402
- return tooManySources(root, dir, names);
403
- const file = path.join(dir, names[0]);
404
- const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
405
- if ("problems" in parsed)
406
- return sourceFailure(file, root, parsed.problems);
388
+ const parsed = readSourceFile(folder, root);
389
+ if ("message" in parsed)
390
+ return parsed;
391
+ const file = parsed.file;
407
392
  const where = o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell";
408
393
  // the publication's name, read as url: is: ${NAME} from the same places, in the same order
409
394
  let publicationName = null;
@@ -428,6 +413,31 @@ export function readSource(folder, root, env = process.env, o = {}) {
428
413
  }
429
414
  return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}`, publicationName };
430
415
  }
416
+ /**
417
+ * THE PROJECT'S SOURCES FILE, ITS TEXT ALONE (the first step of `readSource`, which resolves its URL after): the one file
418
+ * in `sources/`, parsed, or what is wrong with it. What `mason deploy` checks before it sends a project (src/cli/deploy.ts):
419
+ * there the URL is the replica's environment's, never this machine's .env.
420
+ */
421
+ export function readSourceFile(folder, root) {
422
+ const names = sourceFileNames(folder);
423
+ const dir = sourcesDir(folder);
424
+ if (names.length === 0) {
425
+ const old = path.join(folder, OLD_TABLES_DIR);
426
+ if (fs.existsSync(old)) {
427
+ // no backwards compatibility before v1.0 (CLAUDE.md): nothing is migrated, the way out is said
428
+ return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file, and ${shownPath(root, old)}/ is an older Mason's, which this one does not read`, code: "bad_source",
429
+ fix: `mason init writes ${shownPath(root, dir)}/ (then mason add each table again); remove ${shownPath(root, old)}/ afterwards`, next: [["mason init", `write ${shownPath(root, dir)}/<database>.yml`]] };
430
+ }
431
+ const file = path.join(dir, "app.yml");
432
+ return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
433
+ fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
434
+ }
435
+ if (names.length > 1)
436
+ return tooManySources(root, dir, names);
437
+ const file = path.join(dir, names[0]);
438
+ const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
439
+ return "problems" in parsed ? sourceFailure(file, root, parsed.problems) : parsed;
440
+ }
431
441
  /** The columns a table's copy keeps, in the source's order: the entry's, its key always among them. */
432
442
  export function copiedColumns(t, e) {
433
443
  const keep = new Set(e.columns ?? t.columns.map((c) => c.name).filter((c) => !e.exclude.includes(c)));
@@ -34,7 +34,8 @@ export function colourOn(stream, env = process.env) {
34
34
  /** The style for a stream: colour when colourOn, and its width when it is a terminal that says one. */
35
35
  export function styleFor(stream, env = process.env) {
36
36
  const width = stream.isTTY === true && stream.columns !== undefined && stream.columns >= 40 ? stream.columns : null;
37
- return colourOn(stream, env) ? { ...COLOUR, width } : { ...PLAIN, width };
37
+ const columns = stream.isTTY === true && stream.columns !== undefined && stream.columns >= 1 ? { columns: stream.columns } : {};
38
+ return colourOn(stream, env) ? { ...COLOUR, width, ...columns } : { ...PLAIN, width, ...columns };
38
39
  }
39
40
  /** stdout's style (what a command prints) and stderr's (its progress), read when first asked. */
40
41
  export const out = () => styleFor(process.stdout);