@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
@@ -14,11 +14,14 @@ export function mintToken(apiKey, seconds, now = Date.now()) {
14
14
  const expiry = Math.floor(now / 1000) + seconds;
15
15
  return { token: `${PREFIX}_${expiry}_${mac(apiKey, expiry)}`, expiresAt: new Date(expiry * 1000).toISOString() };
16
16
  }
17
+ const TOKEN_RE = /^mason_(\d{1,12})_([A-Za-z0-9_-]{43})$/;
18
+ /** Whether a password is a token by its shape, good or not. */
19
+ export const isToken = (password) => TOKEN_RE.test(password);
17
20
  /** null when the token is good; else why not, in a sentence a person reads after "password authentication failed" */
18
21
  export function tokenProblem(apiKey, token, now = Date.now()) {
19
22
  if (token === apiKey)
20
23
  return "the API key is not a password here: mason user add <name> makes a user whose password does not expire";
21
- const m = /^mason_(\d{1,12})_([A-Za-z0-9_-]{43})$/.exec(token);
24
+ const m = TOKEN_RE.exec(token);
22
25
  if (m === null)
23
26
  return "not a Mason user's password or token: mason user add <name> makes a user whose password does not expire";
24
27
  const expiry = Number(m[1]);
@@ -11,9 +11,11 @@
11
11
  * UNION as its text. Each column that diverges carries the reason, and the runner logs it once per statement.
12
12
  */
13
13
  import { Buffer } from "node:buffer";
14
- import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
14
+ import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimeTZValue, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
15
15
  import { jsonbText } from "./jsonb.js";
16
16
  import { gapMark } from "./gaps.js";
17
+ import { heldType, ZONED_HELD } from "./held-types.js";
18
+ import { pgEvalSync } from "./pg-eval.js";
17
19
  import { EncodeError, encodeValue, OID, TIMESTAMP } from "../api/postgres/encode.js";
18
20
  const MICROS_PER_DAY = 86400000000n;
19
21
  const DATE_INFINITY = 2_147_483_647, DATE_NEG_INFINITY = -2_147_483_647;
@@ -137,6 +139,12 @@ function timeText(micros) {
137
139
  const secs = micros / 1000000n, frac = micros % 1000000n;
138
140
  return `${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6).replace(/0+$/, "")}`}`;
139
141
  }
142
+ /** A timetz as timetz_out writes it: the time, then its offset as `+HH`, `+HH:MM` or `+HH:MM:SS` (DuckDB's offset, as
143
+ * Postgres writes it, is east of UTC; its stored zone is west) */
144
+ export function timetzText(v) {
145
+ const abs = Math.abs(v.offset), s = abs % 60, m = Math.trunc(abs / 60) % 60;
146
+ return `${timeText(v.micros)}${v.offset < 0 ? "-" : "+"}${pad(Math.trunc(abs / 3600))}${m !== 0 || s !== 0 ? `:${pad(m)}` : ""}${s !== 0 ? `:${pad(s)}` : ""}`;
147
+ }
140
148
  /** An interval as Postgres's `interval_out` writes it under IntervalStyle postgres: `1 year 2 mons 3 days 04:05:06.7` */
141
149
  export function intervalText(months, days, micros) {
142
150
  const parts = [];
@@ -213,7 +221,7 @@ function arrayElement(text) {
213
221
  /** Postgres's array OIDs of the element OIDs this file sends */
214
222
  const ARRAY_OID = {
215
223
  16: 1000, 17: 1001, 20: 1016, 21: 1005, 23: 1007, 25: 1009, 700: 1021, 701: 1022, 1082: 1182, 1083: 1183,
216
- 1114: 1115, 1184: 1185, 1186: 1187, 1700: 1231, 2950: 2951, 3802: 3807,
224
+ 1114: 1115, 1184: 1185, 1186: 1187, 1266: 1270, 1700: 1231, 2950: 2951, 3802: 3807,
217
225
  };
218
226
  function custom(name, oid, size, text, binary, divergence = null) {
219
227
  return {
@@ -285,6 +293,8 @@ export function wireColumn(name, t) {
285
293
  case DuckDBTypeId.TIMESTAMP_TZ: return timestampColumn(name, true);
286
294
  case DuckDBTypeId.TIME:
287
295
  return custom(name, 1083, 8, (v) => timeText(v.micros), (v) => { const b = Buffer.alloc(8); b.writeBigInt64BE(v.micros); return b; });
296
+ case DuckDBTypeId.TIME_TZ:
297
+ return custom(name, 1266, 12, (v) => timetzText(v), (v) => { const t = v; const b = Buffer.alloc(12); b.writeBigInt64BE(t.micros, 0); b.writeInt32BE(-t.offset, 8); return b; });
288
298
  case DuckDBTypeId.INTERVAL:
289
299
  return custom(name, 1186, 16, (v) => { const i = v; return intervalText(i.months, i.days, i.micros); }, (v) => { const i = v; const b = Buffer.alloc(16); b.writeBigInt64BE(i.micros, 0); b.writeInt32BE(i.days, 8); b.writeInt32BE(i.months, 12); return b; });
290
300
  case DuckDBTypeId.BLOB:
@@ -313,7 +323,7 @@ export function wireColumn(name, t) {
313
323
  // int4, its DECIMAL as a numeric, its JSON as jsonb's canonical text. Where DuckDB's value cannot be written as
314
324
  // Postgres's type (a timestamp where Postgres says date), DuckDB's own encoding stands and the column says why.
315
325
  /** pg_type.typlen of the OIDs a result column may have */
316
- const TYPLEN = { 16: 1, 18: 1, 19: 64, 20: 8, 21: 2, 23: 4, 26: 4, 700: 4, 701: 8, 1082: 4, 1083: 8, 1114: 8, 1184: 8, 1186: 16, 2950: 16 };
326
+ const TYPLEN = { 16: 1, 18: 1, 19: 64, 20: 8, 21: 2, 23: 4, 26: 4, 700: 4, 701: 8, 1082: 4, 1083: 8, 1114: 8, 1184: 8, 1186: 16, 1266: 12, 2950: 16 };
317
327
  const PG_TEXT = new Set([25, 1043, 1042, 19, 18]);
318
328
  const PG_INT = { 21: "int2", 23: "int4", 20: "int8" };
319
329
  const DUCK_EXACT = new Set([DuckDBTypeId.TINYINT, DuckDBTypeId.SMALLINT, DuckDBTypeId.INTEGER, DuckDBTypeId.BIGINT, DuckDBTypeId.HUGEINT,
@@ -349,6 +359,45 @@ export function floatAsNumeric(x, digits = 15) {
349
359
  }
350
360
  return s === "-0" ? "0" : s;
351
361
  }
362
+ /** the binary forms of the text-held types a driver asks for by default (pgjdbc: point, box): each a float8's bytes */
363
+ const HELD_BINARY = {
364
+ 600: floats, 603: floats,
365
+ };
366
+ function floats(text) {
367
+ const parts = text.match(/-?(?:Infinity|NaN|[\d.]+(?:e[-+]?\d+)?)/g) ?? [];
368
+ const b = Buffer.alloc(parts.length * 8);
369
+ parts.forEach((p, i) => b.writeDoubleBE(Number(p), i * 8));
370
+ return b;
371
+ }
372
+ /** a time range's text in the session's zone: Postgres's own output, a value at a time, the last few remembered */
373
+ const zonedTexts = new Map();
374
+ function inZone(type, text, zone) {
375
+ const key = `${zone}\u0000${type}\u0000${text}`;
376
+ let written = zonedTexts.get(key);
377
+ if (written === undefined) {
378
+ written = pgEvalSync(`SELECT $1::${type}::text`, [text], zone)[0]?.[0] ?? text;
379
+ if (zonedTexts.size >= 1024)
380
+ zonedTexts.clear();
381
+ zonedTexts.set(key, written);
382
+ }
383
+ return written;
384
+ }
385
+ /** a column of a type held as its text. A value's text only: Mason writes the binary form of few of them */
386
+ function heldColumn(name, oid, own, size) {
387
+ const binary = HELD_BINARY[oid], zoned = ZONED_HELD.get(oid);
388
+ return {
389
+ name, oid, size, divergence: null,
390
+ encode: (v, format, s) => {
391
+ if (v === null)
392
+ return null;
393
+ if (format === 0)
394
+ return zoned !== undefined ? Buffer.from(inZone(zoned, str(v), s.timeZone), "utf8") : own.encode(v, 0, s);
395
+ if (binary !== undefined)
396
+ return binary(str(v));
397
+ throw new EncodeError(`column ${JSON.stringify(name)} (type OID ${oid}) is held as its text and cannot be sent in binary by Mason: ask for text (${gapMark("binary-held-type")})`, "0A000", "binary-held-type");
398
+ },
399
+ };
400
+ }
352
401
  /** The wire column of a result column whose Postgres type the analysis gave, holding DuckDB values of type `t`. */
353
402
  export function pgTypedColumn(name, oid, t, typmod = -1) {
354
403
  const own = wireColumn(name, t);
@@ -360,6 +409,16 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
360
409
  return retyped(viaApi(name, "numeric", (v) => padDecimals(exactText(v), (typmod - 4) & 0xffff)));
361
410
  if (own.oid === oid)
362
411
  return retyped(own);
412
+ // a type the copy holds as its text, or an array of one (src/duck/held-types.ts): the text, under Postgres's OID
413
+ const held = heldType(oid);
414
+ if (held !== undefined && (own.oid === 25 || own.oid === 1009))
415
+ return heldColumn(name, oid, own, held.oid === oid ? held.size : -1);
416
+ // an oid element of an oid[], held as its digits
417
+ if (oid === 26 && own.oid === 25)
418
+ return custom(name, 26, 4, str, (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(str(v))); return b; });
419
+ // an oid: DuckDB's UINTEGER
420
+ if (oid === 26 && DUCK_EXACT.has(t.typeId))
421
+ return custom(name, 26, 4, (v) => exactText(v), (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(exactText(v))); return b; });
363
422
  // an inet: stored as Postgres's text with its mask (`10.3.6.45/32`), sent as inet_out writes it (a full mask dropped)
364
423
  if (oid === 869 && own.oid === 25)
365
424
  return custom(name, 869, -1, (v) => inetOut(str(v)), (v) => inetBytes(str(v)));
@@ -404,12 +463,9 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
404
463
  }
405
464
  return { ...own, divergence: `Postgres says type OID ${oid}; the engine computed ${t.toString()}, sent as its own type` };
406
465
  }
407
- /** How a cell of an exact numeric (src/duck/rewrite.ts ExactColumn) reads as a decimal text, by DuckDB's type: an exact
408
- * number's digits, a float as Postgres's cast to numeric writes it — or null where the cell needs digits DuckDB did not
409
- * keep (a double where the program wants exact ones). */
410
466
  export function cellText(t, conv) {
411
467
  // a numeric's text as Postgres's numeric_out wrote it (a text read by numeric_in, src/duck/pg-eval.ts)
412
- if (conv === "text")
468
+ if (conv === "text" || conv === "parsed")
413
469
  return t.typeId === DuckDBTypeId.VARCHAR ? (v) => String(v) : null;
414
470
  if (DUCK_EXACT.has(t.typeId))
415
471
  return exactText;
@@ -1,5 +1,5 @@
1
1
  import { MASON_SCHEMA } from "./store.js";
2
- import { History, Streak } from "./history.js";
2
+ import { History, Streak, cutoff } from "./history.js";
3
3
  import { outcomeOf } from "../api/request-log.js";
4
4
  export const USAGE_TABLE = `${MASON_SCHEMA}.usage`;
5
5
  export const SIZES_TABLE = `${MASON_SCHEMA}.copy_sizes`;
@@ -265,10 +265,12 @@ export class UsageSampler {
265
265
  this.log(`usage: measured ${rows.length} synced tables' own size in ${Math.round(performance.now() - t0)} ms (every ${Math.round((this.options.sizeEveryMs ?? SIZE_EVERY_MS) / 60_000)} min)`);
266
266
  }
267
267
  }
268
- /** Deletes the samples and sizes older than the retention. */
268
+ /** Deletes the samples and sizes older than the retention, counted back from the module's ONE clock (`now`, which
269
+ * stamps each sample), never the engine's `now()` (src/duck/events.ts `trim` says why). */
269
270
  async trim() {
270
- await this.history.writer.run(`DELETE FROM ${USAGE_TABLE} WHERE sampled_at < now() - INTERVAL (${this.days}) DAY`);
271
- await this.history.writer.run(`DELETE FROM ${SIZES_TABLE} WHERE sampled_at < now() - INTERVAL (${this.days}) DAY`);
271
+ const before = cutoff(this.now(), this.days);
272
+ await this.history.writer.run(`DELETE FROM ${USAGE_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
273
+ await this.history.writer.run(`DELETE FROM ${SIZES_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
272
274
  }
273
275
  /**
274
276
  * The samples from `since` until `until`, oldest first; with `stepSeconds`, re-bucketed in ONE query: buckets of that
@@ -24,11 +24,19 @@
24
24
  * user's session, the file read again when its inode, size or mtime moved), so a removal refuses the next connection
25
25
  * at once and ends the sessions that user holds at their next statement, without a restart. FAIL CLOSED: a file that
26
26
  * cannot be read refuses every user login with the reason (57P03, "could not tell"), never "live"; no file is no users.
27
+ *
28
+ * A USER'S ADDRESS RANGES (`from`, `mason user add --from`, `mason user from`): where it may connect from, read by the one
29
+ * grammar `MASON_PG_PROXY_FROM` is read by (src/lib/cidr.ts). Judged at login BEFORE the password, on the session's own
30
+ * address (`Session.clientAddr`, the PROXY header's client behind a named proxy), and again before each statement; an
31
+ * address that could not be read is outside every list (fail closed). While any user has ranges, a name that is no user
32
+ * is refused in the same words, and with the same work, as an address outside them: a limited user cannot be told from
33
+ * a missing one by the answer or its time (a user with no ranges still answers a wrong password 28P01).
27
34
  */
28
35
  import * as crypto from "node:crypto";
29
36
  import * as fs from "node:fs";
30
37
  import * as path from "node:path";
31
- import { ApiError, USER_DISABLED } from "../api/error.js";
38
+ import { ADDRESS_NOT_ALLOWED, ApiError, USER_DISABLED } from "../api/error.js";
39
+ import { cidrOf, cidrText, inRanges, rangeOf } from "../lib/cidr.js";
32
40
  import { tokenProblem } from "./token.js";
33
41
  import { ATTRIBUTE_NAME_RE } from "./row-filter.js";
34
42
  export const USERS_FILE = "users.json";
@@ -36,6 +44,46 @@ export const USERS_FILE = "users.json";
36
44
  * capitalises it is told so, never another user. */
37
45
  export const USER_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
38
46
  export const USER_NAME_RULE = "1 to 63 lower case letters, digits, \"_\", \".\" or \"-\", starting with a letter or digit (e.g. metabase)";
47
+ /**
48
+ * A user's address ranges as given (`--from`, the file, `/api/users`): each read by the one grammar (src/lib/cidr.ts
49
+ * `cidrOf`), written back in its one form (`cidrText`: `10.0.0.0/16`, `2001:db8::/32`, `192.0.2.10`), a range given
50
+ * twice once; or every entry that is not a range, each with what is wrong. An empty list is no list of ranges: the way
51
+ * to any address is to have none (`mason user from <name> --any`).
52
+ */
53
+ export function userRanges(list) {
54
+ if (list.length === 0)
55
+ return { problem: "no address range: name one or more (10.0.0.0/16, 2001:db8::/32, 192.0.2.10)" };
56
+ if (list.length > MAX_RANGES)
57
+ return { problem: `${list.length} address ranges: a user takes at most ${MAX_RANGES}` };
58
+ const from = [], ranges = [], wrong = [];
59
+ for (const entry of list) {
60
+ const c = cidrOf(entry);
61
+ if ("problem" in c) {
62
+ wrong.push(c.problem);
63
+ continue;
64
+ }
65
+ const t = cidrText(c);
66
+ if (!from.includes(t)) {
67
+ from.push(t);
68
+ ranges.push(rangeOf(c));
69
+ }
70
+ }
71
+ return wrong.length ? { problem: wrong.join("; ") } : { from, ranges };
72
+ }
73
+ /** The most address ranges one user takes (all input is untrusted: a bound). */
74
+ export const MAX_RANGES = 100;
75
+ /** Whether a user may connect from `address` (`Session.clientAddr`): any address when it has no ranges; with ranges, one
76
+ * in them, and never an address that could not be read (null), nor any when its ranges cannot be read (fail closed). */
77
+ export function addressAllowed(u, address) {
78
+ if (u.from === undefined)
79
+ return true;
80
+ if (address === null)
81
+ return false;
82
+ const r = userRanges(u.from);
83
+ return !("problem" in r) && inRanges(r.ranges, address);
84
+ }
85
+ /** an address as a refusal says it */
86
+ export const addressShown = (address) => address === null ? "an address that could not be read" : address;
39
87
  /** a user's attributes as the file keeps them: a name each, a string or a list of strings */
40
88
  function attributesValid(v) {
41
89
  // a NUL ends the statement it is bound into, wherever it is: every statement of the user would fail as a syntax error
@@ -59,6 +107,8 @@ export const newUserId = () => crypto.randomBytes(8).toString("hex");
59
107
  export const userPassword = (apiKey, name, id) => `mason_${id}_${mac(apiKey, name, id)}`;
60
108
  /** Whether a password is a user's by its shape (else it is read as a token, src/duck/token.ts). */
61
109
  export const isUserPassword = (password) => PASSWORD_RE.test(password);
110
+ /** What every `mason share` password begins with (src/cli/share-run.ts `newSharePassword`): no user's has it, `share` being no hex. */
111
+ export const SHARE_PASSWORD_PREFIX = "mason_share_";
62
112
  /** Whether a user was added under this API key (one rotated since is no longer live). */
63
113
  export const underKey = (u, apiKey) => u.key === keyFingerprint(apiKey);
64
114
  /** The users the file lists; none when there is no file; why not, when it cannot be read — never an empty list then. */
@@ -91,7 +141,12 @@ function parseUsers(file, text) {
91
141
  if (typeof o !== "object" || o === null || typeof o.name !== "string" || !USER_NAME_RE.test(o.name) || typeof o.id !== "string" || !/^[0-9a-f]{16}$/.test(o.id) ||
92
142
  typeof o.addedAt !== "string" || typeof o.key !== "string" || (o.role !== undefined && (typeof o.role !== "string" || !ROLE_RE.test(o.role))) ||
93
143
  (o.attributes !== undefined && !attributesValid(o.attributes))) {
94
- return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?[, removedAt][, disabledAt] } as mason user add writes it` };
144
+ return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?, from?[, removedAt][, disabledAt] } as mason user add writes it` };
145
+ }
146
+ if (o.from !== undefined) {
147
+ const r = Array.isArray(o.from) && o.from.every((x) => typeof x === "string") ? userRanges(o.from) : { problem: "not a list of strings" };
148
+ if ("problem" in r)
149
+ return { problem: `${file}: users[${i}].from is not a list of address ranges as mason user from writes it: ${r.problem}` };
95
150
  }
96
151
  if (o.removedAt !== undefined && typeof o.removedAt !== "string") {
97
152
  return { problem: `${file}: users[${i}].removedAt is not a timestamp as mason user remove writes it` };
@@ -100,7 +155,7 @@ function parseUsers(file, text) {
100
155
  return { problem: `${file}: users[${i}].disabledAt is not a timestamp as mason user disable writes it` };
101
156
  }
102
157
  users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key, ...(typeof o.role === "string" ? { role: o.role } : {}),
103
- ...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}),
158
+ ...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}), ...(Array.isArray(o.from) ? { from: o.from } : {}),
104
159
  ...(o.removedAt !== undefined ? { removedAt: o.removedAt } : {}), ...(o.disabledAt !== undefined ? { disabledAt: o.disabledAt } : {}) });
105
160
  }
106
161
  return { users };
@@ -182,6 +237,11 @@ export class LiveUsers {
182
237
  apiKey;
183
238
  seen = null;
184
239
  read = { users: [] };
240
+ /** each live user's ranges, parsed once per read of the file (so a login or a statement costs a lookup, never a parse) */
241
+ ranges = new Map();
242
+ /** the longest of those lists: what a name that is no user is judged against, so its refusal costs what a limited
243
+ * user's does and the time of the answer does not tell them apart */
244
+ decoy = [];
185
245
  constructor(file, apiKey) { this.file = file; this.apiKey = apiKey; }
186
246
  /** the users not removed now (disabled ones included: their login is refused by name); why not, when the file cannot be read */
187
247
  now() {
@@ -200,11 +260,38 @@ export class LiveUsers {
200
260
  // a removed user is kept in the file for the list: it is no user to the listener
201
261
  this.read = "problem" in r ? r : { users: r.users.filter(isActive) };
202
262
  this.seen = stamp;
263
+ this.ranges = new Map();
264
+ this.decoy = [];
265
+ for (const u of "problem" in this.read ? [] : this.read.users) {
266
+ if (u.from === undefined)
267
+ continue;
268
+ const parsed = userRanges(u.from);
269
+ // the file was read whole, so every list parses; one that would not stays out of the map, which refuses it
270
+ if ("problem" in parsed)
271
+ continue;
272
+ this.ranges.set(u.id, parsed.ranges);
273
+ if (parsed.ranges.length > this.decoy.length)
274
+ this.decoy = parsed.ranges;
275
+ }
203
276
  }
204
277
  return this.read;
205
278
  }
206
- /** A connection's password, read as a user's: the session's token, or why it is refused. */
207
- login(user, password) {
279
+ /** Whether `address` is inside `ranges` (null: no list, any address); an address not read is inside none. The ranges
280
+ * are the parsed ones this read of the file holds, so this is a comparison, never a parse. */
281
+ inside(ranges, address) {
282
+ if (ranges === null)
283
+ return true;
284
+ return address !== null && inRanges(ranges, address);
285
+ }
286
+ /**
287
+ * A connection's password, read as a user's: the session's token, or why it is refused. `from` is the client's address
288
+ * (`Session.clientAddr`; null when it could not be read). THE ADDRESS IS JUDGED FIRST, before the password: the user
289
+ * the startup packet names, held to its ranges (`inside`); and while any user has ranges, a name that is no
290
+ * user is refused the same way (`ADDRESS_NOT_ALLOWED`, whose words the listener writes from the name and the address
291
+ * alone), so a connection from outside cannot tell a limited user from a missing one by its answer. Only the log line
292
+ * says which (the ApiError's message).
293
+ */
294
+ login(user, password, from = null) {
208
295
  const m = PASSWORD_RE.exec(password);
209
296
  if (m === null)
210
297
  throw refusal(401, "not a Mason user's password: mason user add <name> makes a user and prints its password", user);
@@ -212,6 +299,13 @@ export class LiveUsers {
212
299
  const now = this.now();
213
300
  if ("problem" in now)
214
301
  throw refusal(503, `the list of users cannot be read, so no user can connect: ${now.problem}`, user);
302
+ const named = now.users.find((u) => u.name === user);
303
+ // the same work on both sides: a limited user's own list, or for a name that is no user the longest list there is
304
+ const inside = this.inside(named === undefined ? this.decoy : named.from === undefined ? null : this.ranges.get(named.id) ?? [], from);
305
+ if (named !== undefined ? !inside : this.decoy.length > 0) {
306
+ throw refusal(401, named !== undefined ? `the user "${user}" may not connect from ${addressShown(from)}, outside its address ranges (mason user list shows them)`
307
+ : `no user "${user}" (from ${addressShown(from)}): refused as an address outside a user's ranges is, since some users have ranges`, user, ADDRESS_NOT_ALLOWED);
308
+ }
215
309
  const found = now.users.find((u) => u.id === id);
216
310
  const verifies = (name) => crypto.timingSafeEqual(Buffer.from(mac(this.apiKey, name, id)), Buffer.from(given));
217
311
  if (found === undefined) {
@@ -237,7 +331,7 @@ export class LiveUsers {
237
331
  * token as the file has the user NOW — its role as `mason user add --role` wrote it, so a role given or taken
238
332
  * applies at the user's next statement, without a restart.
239
333
  */
240
- recheck(token) {
334
+ recheck(token, from = null) {
241
335
  if (token.user === undefined)
242
336
  return token;
243
337
  const now = this.now();
@@ -248,6 +342,10 @@ export class LiveUsers {
248
342
  throw refusal(401, `the user "${token.user}" was removed`, token.user);
249
343
  if (isDisabled(found))
250
344
  throw refusal(401, `the user "${token.user}" was disabled`, token.user, USER_DISABLED);
345
+ // its ranges changed since it logged in: a session from outside them ends, as a new connection would be refused
346
+ if (!this.inside(found.from === undefined ? null : this.ranges.get(found.id) ?? [], from)) {
347
+ throw refusal(401, `the user "${token.user}" may not connect from ${addressShown(from)}: its address ranges are ${found.from.join(", ")}`, token.user, ADDRESS_NOT_ALLOWED);
348
+ }
251
349
  if (found.role === token.role && JSON.stringify(found.attributes ?? null) === JSON.stringify(token.attributes ?? null))
252
350
  return token;
253
351
  const { role: _was, attributes: _had, ...rest } = token;
@@ -263,18 +361,21 @@ export const disabledMessage = (name) => `role "${name}" is not permitted to log
263
361
  * HOW THE DUCKDB RUN'S LISTENER TAKES A PASSWORD (src/cli/dev.ts; the tests wire the same): a user's password (its shape)
264
362
  * is the users file's to judge, and its session is asked again before every statement (a stat); anything else is read as a
265
363
  * token, the kind `mason connect` minted before `mason user` (src/duck/token.ts: the API key used as a password is refused by name).
364
+ * A `mason share` password is refused by name too: it was never this port's, and making a user is not the way out.
266
365
  */
267
366
  export function listenerLogins(users, apiKey) {
268
367
  return {
269
- authenticate: (password, user) => {
368
+ authenticate: (password, user, from) => {
270
369
  if (isUserPassword(password))
271
- return users.login(user, password);
370
+ return users.login(user, password, from ?? null);
371
+ if (password.startsWith(SHARE_PASSWORD_PREFIX))
372
+ throw new Error("a mason share password logs in only at the address mason share printed, never on this port");
272
373
  const problem = tokenProblem(apiKey, password);
273
374
  if (problem !== null)
274
375
  throw new Error(problem);
275
376
  return { filters: [], subject: undefined };
276
377
  },
277
- recheck: async (t) => users.recheck(t),
378
+ recheck: async (t, from) => users.recheck(t, from ?? null),
278
379
  recheckMs: 0,
279
380
  };
280
381
  }