@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
@@ -11,11 +11,11 @@
11
11
  * called by the run's command server; so are `disableUser` and `enableUser`.
12
12
  */
13
13
  import * as path from "node:path";
14
- import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
14
+ import { failed, jsonText, listFlag, parseCommand, reportText, strict, withEnd } from "./command.js";
15
15
  import { reach } from "./state.js";
16
16
  import { callRun, RunUnreachable } from "./control.js";
17
17
  import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
18
- import { changeUsers, isActive, isDisabled, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
18
+ import { changeUsers, isActive, isDisabled, keyFingerprint, newUserId, readUsers, underKey, userPassword, userRanges, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
19
19
  import { didYouMean } from "../lib/did-you-mean.js";
20
20
  import { out as styleOut } from "./style.js";
21
21
  import { NO_ROLES } from "../duck/grants.js";
@@ -24,16 +24,19 @@ import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, whereAttributes, whereParts } f
24
24
  import { readSource, shownPath } from "./sources.js";
25
25
  import { readState } from "./commands.js";
26
26
  import { projectDatabase } from "./project.js";
27
+ import { replicaOf } from "./replica.js";
28
+ import { hostedUser } from "./user-hosted.js";
27
29
  const out = (m) => { process.stdout.write(`${m}\n`); };
28
30
  export const USER = {
29
31
  needs: "project",
30
32
  name: "user",
31
33
  summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
32
- usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--json]
33
- mason user list [--json]
34
- mason user disable <name> [--json]
35
- mason user enable <name> [--json]
36
- mason user remove <name> [--json]
34
+ usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--from <range> …] [--replica <org>/<name>] [--json]
35
+ mason user from <name> <range> … | --any [--replica <org>/<name>] [--json]
36
+ mason user list [--replica <org>/<name>] [--json]
37
+ mason user disable <name> [--replica <org>/<name>] [--json]
38
+ mason user enable <name> [--replica <org>/<name>] [--json]
39
+ mason user remove <name> [--replica <org>/<name>] [--json]
37
40
 
38
41
  A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
39
42
  (Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
@@ -42,6 +45,28 @@ export const USER = {
42
45
  The user name a tool sends must be the user's own. A user disabled is kept with its password: its logins are
43
46
  refused (as Postgres refuses a role without LOGIN) and its sessions end at their next statement, until enable.
44
47
 
48
+ mason user add metabase a user for Metabase: its password and the URL to paste
49
+ mason user add looker --from 203.0.113.0/24 --from 2001:db8:7::/48 connects from these alone
50
+ mason user from looker 198.51.100.0/24 its ranges replaced
51
+ mason user from looker --any it connects from any address again
52
+ mason user add agent --role agent a user held to the role agent
53
+ mason user add acme --role tenant --set tenant=42 the rows of tenant 42
54
+ mason user add emea --role region --set regions=eu --set regions=uk
55
+ mason user list
56
+ mason user disable metabase metabase connects no more, until mason user enable metabase
57
+ mason user enable metabase metabase connects again, with the same password
58
+ mason user remove metabase metabase connects no more
59
+ docker compose exec mason mason user add grafana beside mason serve, in its data folder
60
+ mason user add metabase --replica acme/shop a user of a replica on a hosted Mason service
61
+
62
+ <name> ${USER_NAME_RULE}
63
+ --role <role> add: what the user reads, a role of mason/roles.yml (below)
64
+ --set <attribute>=<value> add: its value of an attribute its role declares (below)
65
+ --from <range> add: the addresses it connects from (below)
66
+ --any from: its ranges removed, so it connects from any address
67
+ --replica <org>/<name> a replica's users on a hosted Mason service, not this project's (below)
68
+ --json as data: its shape is at the end
69
+
45
70
  The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
46
71
  A removed user stays in it, marked removed, so the list shows who was removed and when; its name can be added again.
47
72
  Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
@@ -82,32 +107,42 @@ export const USER = {
82
107
  statement, never answered with no rows: add it again with the --set. Postgres refuses a where: it cannot read
83
108
  when the running Mason reads the file, at its key.
84
109
 
85
- mason user add metabase a user for Metabase: its password and the URL to paste
86
- mason user add agent --role agent a user held to the role agent
87
- mason user add acme --role tenant --set tenant=42 the rows of tenant 42
88
- mason user add emea --role region --set regions=eu --set regions=uk
89
- mason user list
90
- mason user disable metabase metabase connects no more, until mason user enable metabase
91
- mason user enable metabase metabase connects again, with the same password
92
- mason user remove metabase metabase connects no more
93
- docker compose exec mason mason user add grafana beside mason serve, in its data folder
110
+ --from <range> limits the addresses the user connects from (repeat --from for each, or a comma list): an IPv4 or IPv6 range with its
111
+ prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10). A connection from any other address is
112
+ refused before its password is checked, as Postgres refuses one no pg_hba.conf line takes (28000, no entry for host
113
+ …), and in the same words for a name that is no user while any user has ranges, so a user with ranges cannot be
114
+ told from a missing one (a user without ranges still answers a wrong password 28P01). An address that cannot be read (a public share's socket) is refused too. Behind a TCP proxy the address is
115
+ the client its PROXY header carries (MASON_PG_PROXY_FROM). A user added without --from connects from any address.
116
+ mason user from <name> <range> … replaces a user's ranges, and mason user from <name> --any removes them; the sessions
117
+ it holds from an address outside its new ranges end at their next statement.
118
+
119
+ --replica <org>/<name> manages the users of a replica on a hosted Mason service instead of this project's (else
120
+ MASON_REPLICA, said on stderr when it chose; each part lower case letters, digits and -): the same subcommands,
121
+ --role, --set and --from, the same output and --json, which names the replica in its headline and its replica key,
122
+ the replica's Mason checking each as it does here. It needs no project here, and reads and writes
123
+ nothing of one. The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else
124
+ the default. A refusal is the service's words, each one line, its code the CLI's own by the HTTP status (not_found,
125
+ refused, failed: never usage), exit 1.
94
126
 
95
- <name> ${USER_NAME_RULE}
96
- --json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
97
- or the file's default; absent when none), attributes? ({ name: value, or [values] for a list }), roleFrom (file: its own --role; default: the file's default;
98
- none), readsNothing (true: no role under a file with no default), and once Mason runs url, psql (the
99
- psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
100
- not removed), disabledAt (null for a user not disabled), live (connects now), role?, attributes?,
101
- missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
102
- roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
103
- requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
104
- lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
105
- (null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
106
- before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
107
- id, addedAt, removedAt } }; next
127
+ The --json answer: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
128
+ or the file's default; absent when none), attributes? ({ name: value, or [values] for a list }), roleFrom (file: its own --role; default: the file's default;
129
+ none), readsNothing (true: no role under a file with no default), from? (its address ranges, as given
130
+ to --from in their one form; absent: any address), and once Mason runs url, psql (the
131
+ psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
132
+ not removed), disabledAt (null for a user not disabled), live (connects now), from (its address ranges;
133
+ null: any address), role?, attributes?,
134
+ missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
135
+ roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
136
+ requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
137
+ lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
138
+ (null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
139
+ before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; from { changed: { name, id,
140
+ addedAt, from (its ranges now; null: any address) }, already (they were these before) }; remove { removedUser: { name,
141
+ id, addedAt, removedAt }, removed (the same, kept until 1.0) }; next. With --replica (or MASON_REPLICA) every answer also has replica ("<org>/<name>":
142
+ the replica it acted on), and a project's never does
108
143
  `,
109
- options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true } },
110
- json: ["user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
144
+ options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, from: { type: "string", multiple: true }, any: { type: "boolean", default: false }, replica: { type: "string" } },
145
+ json: ["replica", "user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removedUser", "removed", "from", "changed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
111
146
  };
112
147
  /** The users of a project and why one is not live: added under an API key since rotated. */
113
148
  const liveOf = (u, apiKey) => underKey(u, apiKey);
@@ -194,6 +229,9 @@ export function attributesToAdd(name, role, sets, live) {
194
229
  return { values: out };
195
230
  }
196
231
  // ------------------------------------------------------------------ the role a user is added with
232
+ /** Why `role` is not a role's name (a usage failure's message), or null: asked by `roleToAdd`, and by `mason user add
233
+ * --role --replica` before a byte is sent. */
234
+ export const roleNameProblem = (role) => ROLE_NAME_RE.test(role) ? null : `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`;
197
235
  /**
198
236
  * WHY `role` IS NOT ONE A USER CAN BE ADDED WITH, against the roles as they are now (`live`: the listener's while the
199
237
  * run is up, the file's otherwise): a name of another shape, a file that cannot be read as it is, no roles file at
@@ -202,8 +240,9 @@ export function attributesToAdd(name, role, sets, live) {
202
240
  * that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
203
241
  */
204
242
  export function roleToAdd(role, live) {
205
- if (!ROLE_NAME_RE.test(role))
206
- return { message: `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`, code: "usage" };
243
+ const named = roleNameProblem(role);
244
+ if (named !== null)
245
+ return { message: named, code: "usage" };
207
246
  const file = live.file ?? `mason/${ROLES_FILE}`;
208
247
  if (live.problem !== null) {
209
248
  return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
@@ -325,7 +364,7 @@ export function listUsers(file, apiKey, link, roles = NO_ROLES) {
325
364
  fix: `repair ${file}, or move it aside, which removes every user` };
326
365
  // roleKnown only where the file can be read: under a problem the listener refuses every user held to it for the FILE
327
366
  return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt ?? null, disabledAt: u.disabledAt ?? null,
328
- live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey),
367
+ live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey), from: u.from ?? null,
329
368
  lastUsedAt: null, url: link !== null && isActive(u) ? connectionUrl(link, u.name, null) : null,
330
369
  ...roleFields(u, roles), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}),
331
370
  ...((m) => m === undefined ? {} : { missingAttributes: m.missing })(usersMissingAttributes([u], roles)[0]),
@@ -359,6 +398,9 @@ export function addUser(file, apiKey, name, o) {
359
398
  const usage = userNameProblem("add", name);
360
399
  if (usage !== null)
361
400
  return { message: usage, code: "usage" };
401
+ const ranges = o.from === undefined ? null : rangesToSet(o.from, `mason user add ${name}`);
402
+ if (ranges !== null && "message" in ranges)
403
+ return ranges;
362
404
  const fingerprint = keyFingerprint(apiKey);
363
405
  let added;
364
406
  let replaced = false;
@@ -372,7 +414,7 @@ export function addUser(file, apiKey, name, o) {
372
414
  replaced = was !== undefined;
373
415
  const now = new Date().toISOString();
374
416
  added = { name, id: newUserId(), addedAt: now, key: fingerprint, ...(o.role !== undefined ? { role: o.role } : {}),
375
- ...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}) };
417
+ ...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}), ...(ranges !== null ? { from: ranges.from } : {}) };
376
418
  return [...users.map((u) => u === was ? { ...u, removedAt: now } : u), added];
377
419
  });
378
420
  }
@@ -390,8 +432,58 @@ export function addUser(file, apiKey, name, o) {
390
432
  const { link } = o;
391
433
  return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
392
434
  ...(link !== null ? { url: connectionUrl(link, u.name, password), psql: psqlLine(connectionUrl(link, u.name, password)), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}),
393
- ...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), replaced };
435
+ ...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), ...(u.from !== undefined ? { from: u.from } : {}), replaced };
436
+ }
437
+ /** Address ranges as `--from` (or `/api/users`' `from`) gives them, each read by the one grammar (src/duck/users.ts
438
+ * `userRanges`) and in its one form; or a wrong line (`usage`), every entry that is not a range said, nothing written. */
439
+ export function rangesToSet(given, line) {
440
+ const r = userRanges(given);
441
+ if ("problem" in r)
442
+ return { message: `${line}: ${r.problem}`, code: "usage", fix: "an IPv4 or IPv6 range with its prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10), one --from each" };
443
+ return { from: r.from };
394
444
  }
445
+ /**
446
+ * SET A USER'S ADDRESS RANGES in `file` under its lock, replacing those it had (`from`: the ranges as given; null: any
447
+ * address, its ranges removed). The listener reads them at the user's next connection and at each statement of a session
448
+ * it holds, so a session from outside the new ranges ends there. The same ranges as now are said so (`already`), nothing
449
+ * written. A range that is not one is a wrong line, nothing written.
450
+ */
451
+ export function setUserFrom(file, name, from) {
452
+ const usage = userNameProblem("from", name);
453
+ if (usage !== null)
454
+ return { message: usage, code: "usage" };
455
+ const ranges = from === null ? null : rangesToSet(from, `mason user from ${name}`);
456
+ if (ranges !== null && "message" in ranges)
457
+ return ranges;
458
+ const next = ranges === null ? undefined : ranges.from;
459
+ let found;
460
+ let live = [];
461
+ let r;
462
+ try {
463
+ r = changeUsers(file, (users) => {
464
+ found = users.find((u) => u.name === name && isActive(u));
465
+ live = users.filter(isActive).map((u) => u.name);
466
+ if (found === undefined)
467
+ return { refused: "unknown" };
468
+ if (JSON.stringify(found.from ?? null) === JSON.stringify(next ?? null))
469
+ return { refused: "already" };
470
+ return users.map((u) => { if (u !== found)
471
+ return u; const { from: _, ...rest } = u; return next === undefined ? rest : { ...rest, from: next }; });
472
+ });
473
+ }
474
+ catch (e) {
475
+ return cantChange(file, e);
476
+ }
477
+ if (found === undefined)
478
+ return noSuchUser(name, live);
479
+ const u = found;
480
+ return { changed: { name: u.name, id: u.id, addedAt: u.addedAt, from: next ?? null }, already: !Array.isArray(r) };
481
+ }
482
+ /** a user's ranges as `mason user` says them: `10.0.0.0/16, 2001:db8::/32`, or any address */
483
+ export const rangesText = (from) => from === null || from === undefined ? "any address" : from.join(", ");
484
+ /** What to run after a `mason user from`. */
485
+ export const fromNext = (name, from) => [["mason user list", "the users and the addresses each connects from"],
486
+ ...(from !== null ? [[`mason user from ${name} --any`, "it connects from any address again"]] : [])];
395
487
  /** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
396
488
  export function removeUser(file, name) {
397
489
  const usage = userNameProblem("remove", name);
@@ -484,6 +576,103 @@ export function enableUser(file, name) {
484
576
  const u = found;
485
577
  return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
486
578
  }
579
+ /** What to run after a list. */
580
+ export const listNext = (users) => [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
581
+ ...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
582
+ ...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
583
+ /** `mason user list` for a person: the users of `of` (the project's name, or the replica's), a line each, then its tips
584
+ * and `next`. `roles`: the roles as a command beside them sees them (NO_ROLES where none can be read here). */
585
+ export function listText(of, l, roles, next, s = styleOut()) {
586
+ const { users, lastUsedNote, requestLogDays } = l;
587
+ if (!users.length)
588
+ return strict(withEnd(`${s.bold(`No users yet for ${of}.`)}`, { next }, s), s);
589
+ const active = users.filter((u) => u.removedAt === null);
590
+ const gone = users.filter((u) => u.removedAt !== null);
591
+ const width = Math.max(...users.map((u) => u.name.length));
592
+ const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
593
+ // the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
594
+ const used = (u) => { const t = lastUsedText(u.lastUsedAt, lastUsedNote, requestLogDays, u.addedAt); return t === "never" ? "never used" : t.startsWith("not used") ? t : `last used ${t}`; };
595
+ const lines = active.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${u.role !== undefined ? `role ${u.role}${u.roleFrom === "default" ? " (default)" : ""}${u.attributes !== undefined ? ` (${attributesText(u.attributes)})` : ""} ` : ""}${u.from !== null ? `from ${rangesText(u.from)} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
596
+ (u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
597
+ : u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
598
+ (u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
599
+ (u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
600
+ (u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
601
+ const disabled = active.filter((u) => u.disabledAt !== null);
602
+ const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
603
+ const rotated = active.filter((u) => !u.live && u.disabledAt === null);
604
+ const orphan = active.find((u) => u.roleKnown === false);
605
+ const roleless = active.find((u) => u.readsNothing === true);
606
+ const unset = active.find((u) => u.missingAttributes !== undefined);
607
+ return strict(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${of}${disabled.length ? `, ${disabled.length} disabled` : ""}${gone.length ? `, ${gone.length} removed` : ""}`)}\n${[...lines, ...removedLines].join("\n")}`, { tips: [...(rotated.length ? [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] : []),
608
+ ...(orphan ? [`A role the roles file (mason/${ROLES_FILE}) does not name reads nothing: add ${orphan.role} to it, or mason user remove ${orphan.name}, then mason user add ${orphan.name} --role <role>.`] : []),
609
+ ...(unset ? [`A user without a value its role's rows are filtered by is refused at every statement: ${missingAttributesText({ name: unset.name, role: unset.role ?? "", missing: unset.missingAttributes })}.`] : []),
610
+ ...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
611
+ ...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
612
+ ...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }, s), s);
613
+ }
614
+ /** A user in a headline: its name, and the replica it is a user of when the command acted on one (`on`), so the answer
615
+ * says its target. */
616
+ const userOn = (name, on) => on === undefined ? name : `${name} of the replica ${on}`;
617
+ /** `mason user disable` for a person. `up`: Mason runs, so the sessions the user holds end at their next statement. */
618
+ export const disabledReport = (r, up, next, s = styleOut(), on) => {
619
+ const d = r.disabled, who = userOn(d.name, on);
620
+ return reportText({ headline: r.already ? `The user ${who} is disabled already: nothing changed` : `The user ${who} disabled: it connects no more, until enabled`,
621
+ details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]])],
622
+ tips: [disableEffect(d.name)], next }, s);
623
+ };
624
+ /** `mason user enable` for a person. */
625
+ export const enabledReport = (r, next, s = styleOut(), on) => {
626
+ const e = r.enabled, who = userOn(e.name, on);
627
+ return reportText({ headline: r.already ? `The user ${who} is not disabled: nothing changed, it connects` : `The user ${who} enabled: it connects again, with the same password`,
628
+ details: [["user", e.name], ["added", e.addedAt]], next }, s);
629
+ };
630
+ /** `mason user from` for a person. `up`: Mason runs, so a session from outside the new ranges ends at its next statement. */
631
+ export const fromReport = (r, up, next, s = styleOut()) => {
632
+ const c = r.changed;
633
+ return reportText({ headline: r.already ? `The user ${c.name} connects from ${rangesText(c.from)} already: nothing changed`
634
+ : c.from === null ? `The user ${c.name} connects from any address now` : `The user ${c.name} connects from ${rangesText(c.from)} alone now`,
635
+ details: [["user", c.name], ["from", rangesText(c.from)], ...(r.already || c.from === null ? [] : [["sessions", up ? "those it holds from another address end at their next statement" : "none: Mason is not running"]])],
636
+ tips: c.from === null ? [] : ["A connection from any other address is refused before its password is checked (28000, no entry for host …)."], next }, s);
637
+ };
638
+ /** `mason user remove` for a person. */
639
+ export const removedReport = (r, up, next, s = styleOut(), on) => {
640
+ const gone = r.removed;
641
+ return reportText({ headline: `The user ${userOn(gone.name, on)} removed: it connects no more`,
642
+ details: [["user", gone.name], ["added", gone.addedAt], ["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]],
643
+ tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }, s);
644
+ };
645
+ /**
646
+ * `mason user add` for a person: the password, printed this once, in the url, psql and password rows (and nowhere else).
647
+ * The url, psql, host, port and ssl mode rows are there when the answer has them (Mason runs, so where it listens is
648
+ * known). `roles`: the roles as a command beside them sees them (the file's name in the role row; NO_ROLES where none
649
+ * can be read here); `listener`: how this run listens (its tips), null where it is not known here.
650
+ */
651
+ export function addedReport(r, o, s = styleOut()) {
652
+ const { roles } = o;
653
+ return reportText({
654
+ headline: `The user ${userOn(r.user, o.on)} added${r.replaced === true ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
655
+ details: [
656
+ ...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
657
+ ["user", r.user], ["password", r.password], ["database", r.database],
658
+ ...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
659
+ ...(r.from !== undefined ? [["from", `${rangesText(r.from)} (it connects from these alone)`]] : []),
660
+ ...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
661
+ : r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
662
+ : r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
663
+ ...(r.host !== undefined && r.port !== undefined ? [["host", r.host], ["port", String(r.port)], ...(r.sslmode !== undefined && r.sslmode !== "disable" ? [["ssl mode", r.sslmode]] : [])] : []),
664
+ ],
665
+ tips: [
666
+ "Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
667
+ ...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
668
+ ...(r.url === undefined ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
669
+ ...listenerTips(o.listener),
670
+ "It never expires: mason user remove ends it, at once and for every session it holds.",
671
+ "Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
672
+ ],
673
+ next: o.next,
674
+ }, s);
675
+ }
487
676
  // ------------------------------------------------------------------ the command
488
677
  export async function user(argv) {
489
678
  const f = parseCommand(USER, argv);
@@ -492,13 +681,26 @@ export async function user(argv) {
492
681
  const json = f.values.json === true;
493
682
  const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
494
683
  const [verb, name, ...rest] = f.positionals;
495
- const VERBS = ["add", "list", "disable", "enable", "remove"];
684
+ const VERBS = ["add", "list", "from", "disable", "enable", "remove"];
496
685
  if (verb === undefined)
497
- return usage("add, list, disable, enable or remove: mason user add <name> makes a user");
686
+ return usage("add, list, from, disable, enable or remove: mason user add <name> makes a user");
498
687
  if (!VERBS.includes(verb))
499
- return usage(`there is no mason user ${verb}: add, list, disable, enable or remove${didYouMean(verb, VERBS)}`);
500
- if (verb === "list" ? name !== undefined : rest.length)
688
+ return usage(`there is no mason user ${verb}: add, list, from, disable, enable or remove${didYouMean(verb, VERBS)}`);
689
+ if (verb === "list" ? name !== undefined : verb !== "from" && rest.length)
501
690
  return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
691
+ // the address ranges: --from on add, the positionals of from (or its --any); each read by the one grammar, a wrong one a
692
+ // wrong line before anything is read, written or sent
693
+ // a comma list or the flag repeated, the same (src/cli/command.ts listFlag): a range holds no comma
694
+ const fromList = listFlag(f.values.from);
695
+ const fromFlags = fromList === undefined ? [] : fromList.split(",");
696
+ if (fromFlags.length > 0 && verb !== "add")
697
+ return usage(`--from goes with mason user add: mason user add <name> --from ${fromFlags[0]}${verb === "from" ? `, or mason user from <name> ${fromFlags[0]}` : ""}`);
698
+ const any = f.values.any === true;
699
+ if (any && verb !== "from")
700
+ return usage("--any goes with mason user from: mason user from <name> --any removes its address ranges");
701
+ if (verb === "from" && any === (rest.length > 0))
702
+ return usage(any ? `mason user from ${name ?? "<name>"}: --any or ranges, not both` : `mason user from ${name ?? "<name>"}: which addresses — one or more ranges (10.0.0.0/16, 2001:db8::/32, 192.0.2.10), or --any`);
703
+ const given = verb === "add" ? (fromFlags.length > 0 ? fromFlags : null) : verb === "from" && !any ? rest : null;
502
704
  const role = typeof f.values.role === "string" ? f.values.role : undefined;
503
705
  if (role !== undefined && verb !== "add")
504
706
  return usage(`--role goes with mason user add: mason user add <name> --role ${role}`);
@@ -515,6 +717,20 @@ export async function user(argv) {
515
717
  if (problem !== null)
516
718
  return usage(problem);
517
719
  }
720
+ const ranges = given === null ? null : rangesToSet(given, `mason user ${verb} ${name}`);
721
+ if (ranges !== null && "message" in ranges)
722
+ return failed("user", { ...ranges, usage: USER.usage }, { json });
723
+ // A REPLICA ON A HOSTED MASON SERVICE (--replica, else MASON_REPLICA): its users, asked of the service with the same
724
+ // request the run's /api/users takes, printed by the renderers above; nothing of a project here is read or written
725
+ const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, USER.usage);
726
+ if (replica !== null && "message" in replica)
727
+ return failed("user", replica, { json });
728
+ if (replica !== null) {
729
+ const named = role === undefined ? null : roleNameProblem(role);
730
+ if (named !== null)
731
+ return usage(named);
732
+ return hostedUser(replica, { verb: verb, name, role, sets, from: ranges?.from ?? null, json });
733
+ }
518
734
  const w = await reach(USER, json);
519
735
  if (w === null || w.project === null)
520
736
  return 1;
@@ -551,42 +767,24 @@ export async function user(argv) {
551
767
  }
552
768
  }
553
769
  const urlNote = up !== null ? null : NOT_RUNNING_URL;
554
- const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
555
- ...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
556
- ...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
770
+ const next = listNext(users);
557
771
  if (json) {
558
772
  out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
559
773
  return 0;
560
774
  }
561
- const s = styleOut();
562
- if (!users.length) {
563
- out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
775
+ out(listText(project.config.name, { users, lastUsedNote, urlNote, requestLogDays }, roles, next));
776
+ return 0;
777
+ }
778
+ if (verb === "from") {
779
+ const r = setUserFrom(file, name, ranges?.from ?? null);
780
+ if ("message" in r)
781
+ return failed("user", r, { json });
782
+ const next = fromNext(r.changed.name, r.changed.from);
783
+ if (json) {
784
+ out(jsonText(USER, r, next));
564
785
  return 0;
565
786
  }
566
- const active = users.filter((u) => u.removedAt === null);
567
- const gone = users.filter((u) => u.removedAt !== null);
568
- const width = Math.max(...users.map((u) => u.name.length));
569
- const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
570
- // the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
571
- const used = (u) => { const t = lastUsedText(u.lastUsedAt, lastUsedNote, requestLogDays, u.addedAt); return t === "never" ? "never used" : t.startsWith("not used") ? t : `last used ${t}`; };
572
- const lines = active.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${u.role !== undefined ? `role ${u.role}${u.roleFrom === "default" ? " (default)" : ""}${u.attributes !== undefined ? ` (${attributesText(u.attributes)})` : ""} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
573
- (u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
574
- : u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
575
- (u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
576
- (u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
577
- (u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
578
- const disabled = active.filter((u) => u.disabledAt !== null);
579
- const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
580
- const rotated = active.filter((u) => !u.live && u.disabledAt === null);
581
- const orphan = active.find((u) => u.roleKnown === false);
582
- const roleless = active.find((u) => u.readsNothing === true);
583
- const unset = active.find((u) => u.missingAttributes !== undefined);
584
- out(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${project.config.name}${disabled.length ? `, ${disabled.length} disabled` : ""}${gone.length ? `, ${gone.length} removed` : ""}`)}\n${[...lines, ...removedLines].join("\n")}`, { tips: [...(rotated.length ? [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] : []),
585
- ...(orphan ? [`A role the roles file (mason/${ROLES_FILE}) does not name reads nothing: add ${orphan.role} to it, or mason user remove ${orphan.name}, then mason user add ${orphan.name} --role <role>.`] : []),
586
- ...(unset ? [`A user without a value its role's rows are filtered by is refused at every statement: ${missingAttributesText({ name: unset.name, role: unset.role ?? "", missing: unset.missingAttributes })}.`] : []),
587
- ...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
588
- ...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
589
- ...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }));
787
+ out(fromReport(r, w.state === "up", next));
590
788
  return 0;
591
789
  }
592
790
  if (verb === "disable") {
@@ -598,10 +796,7 @@ export async function user(argv) {
598
796
  out(jsonText(USER, r, next));
599
797
  return 0;
600
798
  }
601
- const d = r.disabled;
602
- out(reportText({ headline: r.already ? `The user ${d.name} is disabled already: nothing changed` : `The user ${d.name} disabled: it connects no more, until enabled`,
603
- details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]])],
604
- tips: [disableEffect(d.name)], next }));
799
+ out(disabledReport(r, w.state === "up", next));
605
800
  return 0;
606
801
  }
607
802
  if (verb === "enable") {
@@ -613,24 +808,19 @@ export async function user(argv) {
613
808
  out(jsonText(USER, r, next));
614
809
  return 0;
615
810
  }
616
- const e = r.enabled;
617
- out(reportText({ headline: r.already ? `The user ${e.name} is not disabled: nothing changed, it connects` : `The user ${e.name} enabled: it connects again, with the same password`,
618
- details: [["user", e.name], ["added", e.addedAt]], next }));
811
+ out(enabledReport(r, next));
619
812
  return 0;
620
813
  }
621
814
  if (verb === "remove") {
622
815
  const r = removeUser(file, name);
623
816
  if ("message" in r)
624
817
  return failed("user", r, { json });
625
- const gone = r.removed;
626
818
  const next = REMOVED_NEXT;
627
819
  if (json) {
628
- out(jsonText(USER, r, next));
820
+ out(jsonText(USER, { removedUser: r.removed, ...r }, next));
629
821
  return 0;
630
822
  }
631
- out(reportText({ headline: `The user ${gone.name} removed: it connects no more`,
632
- details: [["user", gone.name], ["added", gone.addedAt], ["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]],
633
- tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }));
823
+ out(removedReport(r, w.state === "up", next));
634
824
  return 0;
635
825
  }
636
826
  // add
@@ -651,35 +841,15 @@ export async function user(argv) {
651
841
  const attributes = attributesToAdd(name, role ?? userRole({}, roles).role, sets, roles);
652
842
  if ("refused" in attributes)
653
843
  return failed("user", attributes.refused, { json });
654
- const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
844
+ const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values, ...(ranges !== null ? { from: ranges.from } : {}) });
655
845
  if ("message" in r)
656
846
  return failed("user", r, { json });
657
- const { replaced, ...added } = r;
847
+ const { replaced: _, ...added } = r;
658
848
  const next = addedNext(r.user, link !== null);
659
849
  if (json) {
660
850
  out(jsonText(USER, added, next));
661
851
  return 0;
662
852
  }
663
- out(reportText({
664
- headline: `The user ${r.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
665
- details: [
666
- ...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
667
- ["user", r.user], ["password", r.password], ["database", r.database],
668
- ...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
669
- ...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
670
- : r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
671
- : r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
672
- ...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
673
- ],
674
- tips: [
675
- "Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
676
- ...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
677
- ...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
678
- ...listenerTips(run?.postgresListener ?? null),
679
- "It never expires: mason user remove ends it, at once and for every session it holds.",
680
- "Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
681
- ],
682
- next,
683
- }));
853
+ out(addedReport(r, { roles, listener: run?.postgresListener ?? null, next }));
684
854
  return 0;
685
855
  }
@@ -48,6 +48,32 @@ export const DEMO_ADDED_TABLES = DEMO_TABLES.filter((t) => t !== DEMO_TABLE_TO_A
48
48
  /** how the demo's tables are kept current (the sources file's top `sync:`, src/lib/table-sync.ts): polled every 5 s,
49
49
  * so the demo shows a copy keeping up with its writer — where a project `mason init` makes is `off` */
50
50
  export const DEMO_SYNC = { mode: "poll", every: "5s", seconds: 5 };
51
+ /**
52
+ * THE GENERATED TABLE `mason init demo` WRITES (`mason/generated_tables/<file>`, src/cli/generated-files.ts reads it), so a
53
+ * person trying Mason meets a generated table without reading the docs: orders and revenue per day and status, kept
54
+ * current from the synced `orders`.
55
+ * - It reads `orders` ALONE, a table init adds (`DEMO_ADDED_TABLES`), never `DEMO_TABLE_TO_ADD`: it is built at the
56
+ * first start, and the person's first lesson (`mason add order_items`) stays the only one.
57
+ * - It NAMES ITS ZONE (`AT TIME ZONE 'UTC'`): a generated table has no session, so a form reading the zone is refused.
58
+ * The day is a `date`, the same in every client's TimeZone.
59
+ * - `revenue` is the sum of a `numeric(12,2)`: its scale is fixed by the type, so it is stored exactly.
60
+ * - `target_lag: 30s`: the writer adds an order every few seconds and the poll is 5 s, so it is seen re-running.
61
+ * Its name is the README's example's (`daily_orders.yml`), so the two read as one.
62
+ */
63
+ export const DEMO_GENERATED = {
64
+ file: "daily_orders.yml",
65
+ table: "daily_orders",
66
+ text: `# A generated table: a SELECT over the synced tables, kept current by Mason. Yours to commit; mason help generated.
67
+ kind: scheduled
68
+ target_lag: 30s # how far behind the synced orders it may be
69
+ sql: |
70
+ SELECT (created_at AT TIME ZONE 'UTC')::date AS day, status, count(*) AS orders, sum(total) AS revenue
71
+ FROM orders
72
+ GROUP BY 1, 2
73
+ order_by: day, status
74
+ comment: Orders and revenue per day (UTC) and status, kept current from the synced orders.
75
+ `,
76
+ };
51
77
  /** an order's status moves along this path (`changeSql`); `cancelled` is a side exit */
52
78
  export const ORDER_STATUSES = ["pending", "paid", "shipped", "delivered"];
53
79
  const ROWS_PER_INSERT = 1_000;