@mason-data/cli 0.8.0 → 0.10.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -25,6 +25,7 @@ import { alive } from "./runs.js";
25
25
  import { appEnvValue } from "../lib/dotenv.js";
26
26
  import { newSharePassword, shareSecret } from "./share-run.js";
27
27
  import { probeShare, startTunnel, sweepTunnelFiles, TOKEN_RE, TUNNELS, tunnelPlan } from "./tunnel.js";
28
+ import { parseDuration } from "../lib/table-sync.js";
28
29
  const out = (m) => { process.stdout.write(`${m}\n`); };
29
30
  const say = (m) => { process.stderr.write(`${m}\n`); };
30
31
  const DEFAULT_TIMEOUT_S = 30;
@@ -35,20 +36,30 @@ export const SHARE = {
35
36
  needs: "run",
36
37
  name: "share",
37
38
  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
+ usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <duration>] [--json]
39
40
 
40
41
  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
42
  API, through a free tunnel service, and prints what to type into the tool. It stays in the foreground: Ctrl-C ends
42
43
  the share. Your local tools notice nothing: the share has a socket of its own, and no session is dropped.
43
44
 
45
+ mason share
46
+ mason share --role sales
47
+ mason share --via bore
48
+
49
+ --role <role> what it reads: a role of mason/roles.yml
50
+ --via <tunnel> pinggy (default) or bore
51
+ --timeout <duration> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S}s; a bare number is
52
+ seconds; 30s, 2m); past it, exit 124
53
+ --json as data: its shape is at the end
54
+
44
55
  The socket requires TLS of every connection, so a service that forwards bytes cannot read them. The certificate is
45
56
  self-signed (the tool connects with sslmode=require, and checks no certificate), so a malicious service could sit in
46
57
  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
58
  way to keep a dashboard running.
48
59
 
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
60
+ The password (mason_share_ and 48 hex digits) is made here, printed once and good for this run alone: it is kept
61
+ nowhere, works on this address only, and is gone when the share ends. A mason user's password and an API token are
62
+ 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
63
  asked, or with --json refused with the line to run. With no roles file it reads every synced table, and says so.
53
64
 
54
65
  --via pinggy (the default) needs only ssh; a free tunnel ends after about an hour, and the share with it: run mason
@@ -56,17 +67,10 @@ export const SHARE = {
56
67
  line is visible to every user of the machine), a paid Pinggy token, keeps the address, so a tunnel that drops is opened
57
68
  again by itself, the password unchanged. --via bore needs bore (cargo install bore-cli).
58
69
 
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
+ The --json answer, once the address is known: { share: { host, port, database, user, password, sslmode,
71
+ fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
72
+ share's certificate; false: it could not be reached from this machine) }, next }; the command
73
+ then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
70
74
  `,
71
75
  options: { role: { type: "string" }, via: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
72
76
  json: ["share", "next"],
@@ -135,9 +139,10 @@ export async function share(argv) {
135
139
  const via = (v.via ?? "pinggy");
136
140
  if (!TUNNELS.includes(via))
137
141
  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 });
142
+ const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
143
+ if ("problem" in read)
144
+ return failed("share", { message: `--timeout ${read.problem}`, code: "usage", usage: SHARE.usage }, { json });
145
+ const timeoutS = read.seconds;
141
146
  const w = await reach(SHARE, json);
142
147
  if (w === null || !("run" in w))
143
148
  return 1;
@@ -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,343 @@
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, but, for security reasons, we recommend using a role that only reads";
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}`, 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`
201
+ : `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser`));
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))}`));
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
+ }
213
+ // an owner holds every privilege on its table: the owner's line said it
214
+ const writable = f.relations.filter((x) => !x.owned && x.privileges.length > 0);
215
+ if (writable.length) {
216
+ const held = writable.map((x) => {
217
+ const privs = WRITE_PRIVILEGES.filter((p) => x.privileges.some((g) => g.privileges.includes(p)));
218
+ const others = x.privileges.filter((g) => g.role !== f.user).map((g) => g.role);
219
+ return `${privs.join(", ")} on ${x.name}${via(x)}${route(others)}`;
220
+ });
221
+ out.push(warn("role_writes_table", `${f.user} may ${held.join("; ")}`));
222
+ }
223
+ const creates = f.schemas.filter((s) => s.create.length > 0 || s.publicCreate);
224
+ if (creates.length) {
225
+ 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`})` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
226
+ out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}`));
227
+ }
228
+ if (f.foreignTables.length || f.mappedServers.length) {
229
+ const held = [...(f.foreignTables.length ? [`may write the foreign table(s) ${list(f.foreignTables)}`] : []),
230
+ ...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}`] : [])];
231
+ out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}`));
232
+ }
233
+ if (f.adminOf.length || f.createRole || f.serverRoles.length) {
234
+ const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}`] : []),
235
+ ...(f.createRole ? ["has CREATEROLE"] : []),
236
+ ...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}`] : [])];
237
+ out.push(warn("role_may_become_another", `${f.user} ${held.join("; ")}`));
238
+ }
239
+ // SECURITY DEFINER code: what it writes is in its body, which the catalog does not say, so it is LISTED for a person
240
+ // to review, never "fixed" by a statement that changes another role's privileges, and never taken for "only reads"
241
+ const definers = definerLine(f);
242
+ if (out.length)
243
+ return definers === null ? out : [...out, { name: "role", ok: true, warn: true, code: "role_may_run_definer_code", detail: `${f.user} ${definers}` }];
244
+ const unread = f.missing.length ? ` (not asked of ${list(f.missing)}, which the database does not have: mason start says so)` : "";
245
+ const synced = f.relations.filter((x) => x.kind === "table").length;
246
+ 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) — ` +
247
+ "nor to their partitions, inheritance children or parents, the tables their foreign keys act on, or the views and rules that write them — " +
248
+ "nor CREATE in their schemas" : ", and no table is added yet (the tables are asked once one is)");
249
+ if (definers !== null)
250
+ 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}` }];
251
+ return [{ name: "role", ok: true, detail: `${f.user} only reads: ${itself}, and it reaches no SECURITY DEFINER code whose owner may write${unread}` }];
252
+ }
253
+ /** "may run 2 SECURITY DEFINER function(s) of roles that may write the synced tables: …; what they write is not known", or null */
254
+ function definerLine(f) {
255
+ if (!f.definers.length)
256
+ return null;
257
+ const each = f.definers.map((d) => {
258
+ const how = [...(d.callable ? [d.publicCallable ? "every role may call it (PUBLIC's EXECUTE)" : "it may call it"] : []),
259
+ ...(d.triggers.length ? [`a trigger on ${list(d.triggers)}, which it may write, calls it`] : []),
260
+ ...(d.eventTrigger ? ["an event trigger calls it on any DDL, even one refused, never asking EXECUTE"] : []),
261
+ ...(d.unchecked ? ["Postgres calls it for an aggregate, a type, an operator or the like, never asking EXECUTE"] : [])];
262
+ return `${d.name} runs as ${d.owner}${d.ownerSuperuser ? ", a superuser" : ", which may write the synced tables"}: ${how.join("; ")}`;
263
+ });
264
+ return `may run ${f.definers.length} SECURITY DEFINER function(s) of roles that may write the synced tables — ${each.join("; ")}. ` +
265
+ "We recommend reviewing each: whether it writes a synced table, and who may call it";
266
+ }
267
+ /** The line a check is when the role could not be asked: said, never taken for "only reads". */
268
+ export function roleUnknown(why) {
269
+ return { name: "role", ok: null, code: "role_unknown", detail: `could not tell whether the user Mason connects as may write to the database: ${why}` };
270
+ }
271
+ /**
272
+ * The URL `url` with `role` as its user and a placeholder for its password, nothing else of it changed: built from what
273
+ * `shownUrl` shows (src/cli/preflight.ts, issue 541), so no part the URL is not read with is ever printed; a value it
274
+ * does not show is said in words.
275
+ */
276
+ export function readerUrl(url, role) {
277
+ const shown = shownUrl(url);
278
+ if (shown === null)
279
+ return `the database's URL with ${role} and its password as the user and password`;
280
+ const u = new URL(shown);
281
+ return `${u.protocol}//${role}:<its password>@${u.host}${u.pathname}${u.search}`;
282
+ }
283
+ /**
284
+ * THE WAY OUT, as the statements the database's owner runs, then the line of the project to change: a role that may
285
+ * LOGIN and SELECT the synced tables and nothing else — REPLICATION where a table streams, and the publication made by
286
+ * the owner when Mason would make its own (CREATE PUBLICATION … FOR TABLE needs the tables' owner); a policy for it where
287
+ * a table has row-level security (the new role would read no row of it). It NEVER prints a statement that changes any
288
+ * role's privileges but the new one's (PR 572's review: a REVOKE … FROM PUBLIC of a policy's function, run as printed,
289
+ * broke the application's own reads): what PUBLIC holds, and the SECURITY DEFINER code every role reaches, the new role
290
+ * holds and reaches too, and is SAID in a comment, for the database's owner to decide.
291
+ */
292
+ export function readOnlyFix(f, ask) {
293
+ let role = READER_ROLE;
294
+ for (let n = 2; f.taken.includes(role); n++)
295
+ role = `${READER_ROLE}${n}`;
296
+ const tables = f.relations.filter((x) => x.kind === "table");
297
+ const schemas = [...new Set(tables.map((x) => x.schemaSql))];
298
+ const replication = ask.replicated.length > 0;
299
+ const replicating = replication && !f.rdsReplication;
300
+ const lines = [`-- as the database's owner (on the primary):`,
301
+ `CREATE ROLE ${role} LOGIN${replicating ? " REPLICATION" : ""} PASSWORD '<a new password>';`];
302
+ if (replication && f.rdsReplication)
303
+ lines.push(`GRANT rds_replication TO ${role};`);
304
+ lines.push(`GRANT CONNECT ON DATABASE ${f.databaseSql} TO ${role};`);
305
+ if (tables.length) {
306
+ lines.push(`GRANT USAGE ON SCHEMA ${schemas.join(", ")} TO ${role};`, `GRANT SELECT ON ${tables.map((x) => x.sql).join(", ")} TO ${role};`);
307
+ }
308
+ else {
309
+ lines.push(`-- and GRANT USAGE ON SCHEMA <schema>, SELECT ON <table> TO ${role}, for each table you add`);
310
+ }
311
+ for (const x of tables.filter((t) => t.rowSecurity))
312
+ lines.push(`CREATE POLICY ${role}_reads ON ${x.sql} FOR SELECT TO ${role} USING (true); -- row-level security: else it reads no row`);
313
+ // PUBLIC's privileges are every role's, the new one's too: said for the database's owner to decide, never revoked here
314
+ const publicCreate = f.schemas.filter((s) => s.publicCreate);
315
+ if (publicCreate.length)
316
+ 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`);
317
+ for (const x of f.relations) {
318
+ const pub = x.privileges.find((g) => g.role === "PUBLIC");
319
+ if (pub)
320
+ 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`);
321
+ }
322
+ // what Mason's own would carry (src/duck/sync.ts ensurePublication): the replicated tables, every change
323
+ const published = tables.filter((x) => ask.replicated.includes(x.entry));
324
+ const ownPublication = ask.publication === null && published.length > 0;
325
+ if (ownPublication) {
326
+ 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`);
327
+ }
328
+ // what every role reaches, the new one too, is SAID, never changed: a statement that changed PUBLIC's would change
329
+ // every role's, the application's included (PR 572's review: a REVOKE of a policy's function broke the app's reads)
330
+ const everyone = f.definers.filter((d) => d.publicCallable || d.eventTrigger || d.unchecked);
331
+ if (everyone.length)
332
+ 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`);
333
+ if (f.mappedServers.length)
334
+ lines.push(`-- a user mapping FOR PUBLIC on ${list(f.mappedServers)} would serve it too: map the roles that need it by name`);
335
+ lines.push("-- and GRANT SELECT ON each view Mason should serve: it serves the views the role it connects as may read");
336
+ // the app's own DATABASE_URL stays the app's: Mason's gets a variable of its own
337
+ const variable = ask.urlVariable === null || ask.urlVariable === "DATABASE_URL" ? "MASON_DATABASE_URL" : ask.urlVariable;
338
+ const own = variable !== ask.urlVariable;
339
+ lines.push(`then: set ${variable}=${readerUrl(ask.url, role)} ${own ? "in .env" : `where it is set now (${ask.urlFrom})`}` +
340
+ `${own ? ` and write url: \${${variable}} in ${ask.file}` : ""}${ownPublication ? `${own ? "," : " and"} publication: ${role} at its top` : ""}` +
341
+ "; mason doctor checks it again, and a running Mason reads it at its next start (mason stop, then mason start)");
342
+ return lines.join("\n");
343
+ }
@@ -42,6 +42,15 @@ export const SOURCES_DIR = "sources";
42
42
  const KEYS = ["kind", "url", "database", "sync", "publication", "tables"];
43
43
  const ENTRY_KEYS = ["columns", "exclude", "key", "sync"];
44
44
  const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
45
+ /**
46
+ * A `${NAME}` naming one of Mason's own settings (`MASON_*`, such as `MASON_API_KEY`): refused where the file is read,
47
+ * before anything resolves it, because a project may be someone else's and the value would be printed or sent as the
48
+ * publication's name (issue 718). `url:` keeps `${MASON_DATABASE_URL}`, the source's URL `mason init` reads and writes.
49
+ */
50
+ const ownSetting = (name, key) => !/^MASON_/i.test(name) || (key === "url" && name === "MASON_DATABASE_URL") ? null :
51
+ `${key}: \${${name}} names one of Mason's own settings (MASON_*), which a sources file may not read — ` +
52
+ (key === "url" ? "write url: ${DATABASE_URL} (or ${MASON_DATABASE_URL}), or a postgres:// URL" :
53
+ "write publication: ${PG_PUBLICATION}, the publication's name, or publication: null and Mason makes its own");
45
54
  /** The tables a file keeps current by replication, by their effective sync (`effectiveSync`): what streams. */
46
55
  export function replicatedEntries(src) {
47
56
  return src.tables.filter((e) => effectiveSync(src, e).sync.mode === "replication");
@@ -176,12 +185,15 @@ export function parseSourceText(text, name, file) {
176
185
  else {
177
186
  const m = ENV_REF.exec(v.url.trim());
178
187
  const bare = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(v.url.trim());
179
- if (m)
188
+ const own = m ? ownSetting(m[1], "url") : null;
189
+ if (own !== null)
190
+ problems.push(own);
191
+ else if (m)
180
192
  url = { env: m[1] };
181
193
  else if (bare)
182
194
  problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
183
195
  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}`);
196
+ 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
197
  else
186
198
  url = { literal: v.url.trim() };
187
199
  }
@@ -201,8 +213,13 @@ export function parseSourceText(text, name, file) {
201
213
  const m = ENV_REF.exec(p);
202
214
  if (p === "")
203
215
  problems.push("publication: write publication: ${PG_PUBLICATION}, or a publication's name (publication: null and Mason makes its own)");
204
- else if (m)
205
- publication = { env: m[1] };
216
+ else if (m) {
217
+ const own = ownSetting(m[1], "publication");
218
+ if (own !== null)
219
+ problems.push(own);
220
+ else
221
+ publication = { env: m[1] };
222
+ }
206
223
  else if (p.includes("$"))
207
224
  problems.push(`publication: ${JSON.stringify(p)} — write \${NAME} alone, like \${PG_PUBLICATION}, or the publication's name`);
208
225
  else
@@ -385,25 +402,10 @@ const tooManySources = (root, dir, names) => ({
385
402
  * Mason made (its tables listed in another folder, which nothing reads) that mason init writes it.
386
403
  */
387
404
  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);
405
+ const parsed = readSourceFile(folder, root);
406
+ if ("message" in parsed)
407
+ return parsed;
408
+ const file = parsed.file;
407
409
  const where = o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell";
408
410
  // the publication's name, read as url: is: ${NAME} from the same places, in the same order
409
411
  let publicationName = null;
@@ -428,6 +430,31 @@ export function readSource(folder, root, env = process.env, o = {}) {
428
430
  }
429
431
  return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}`, publicationName };
430
432
  }
433
+ /**
434
+ * THE PROJECT'S SOURCES FILE, ITS TEXT ALONE (the first step of `readSource`, which resolves its URL after): the one file
435
+ * in `sources/`, parsed, or what is wrong with it. What `mason deploy` checks before it sends a project (src/cli/deploy.ts):
436
+ * there the URL is the replica's environment's, never this machine's .env.
437
+ */
438
+ export function readSourceFile(folder, root) {
439
+ const names = sourceFileNames(folder);
440
+ const dir = sourcesDir(folder);
441
+ if (names.length === 0) {
442
+ const old = path.join(folder, OLD_TABLES_DIR);
443
+ if (fs.existsSync(old)) {
444
+ // no backwards compatibility before v1.0 (CLAUDE.md): nothing is migrated, the way out is said
445
+ 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",
446
+ 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`]] };
447
+ }
448
+ const file = path.join(dir, "app.yml");
449
+ return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
450
+ fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
451
+ }
452
+ if (names.length > 1)
453
+ return tooManySources(root, dir, names);
454
+ const file = path.join(dir, names[0]);
455
+ const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
456
+ return "problems" in parsed ? sourceFailure(file, root, parsed.problems) : parsed;
457
+ }
431
458
  /** The columns a table's copy keeps, in the source's order: the entry's, its key always among them. */
432
459
  export function copiedColumns(t, e) {
433
460
  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);
@@ -157,6 +158,20 @@ export function stepRenderer(o) {
157
158
  done(text) { end(`${s.green(live ? "✔" : "✓")} ${text ?? label ?? ""}`); },
158
159
  fail(text) { end(`${s.red(live ? "✘" : "✗")} ${text ?? label ?? ""}`); },
159
160
  print(text) { clear(); o.line(text); draw(); },
161
+ /** the step still turning, said in new words (a count that moves: how long it has waited); a step begun if none is */
162
+ relabel(text) {
163
+ if (label === null) {
164
+ label = text;
165
+ if (live) {
166
+ draw();
167
+ timer = setInterval(draw, 80);
168
+ timer.unref();
169
+ }
170
+ return;
171
+ }
172
+ label = text;
173
+ draw();
174
+ },
160
175
  /** a step still turning: its label (null when none) */
161
176
  open: () => label,
162
177
  };