@malloy-publisher/server 0.0.230 → 0.0.232

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 (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -3,8 +3,14 @@ import { z } from "zod";
3
3
  import lunr from "lunr";
4
4
  import { EnvironmentStore } from "../../service/environment_store";
5
5
  import { Package } from "../../service/package";
6
+ import {
7
+ EmbeddingProvider,
8
+ embeddingConfigured,
9
+ getEmbeddingProvider,
10
+ } from "../../service/embedding_provider";
6
11
  import { buildMalloyUri } from "../handler_utils";
7
12
  import { logger } from "../../logger";
13
+ import { entityRowKey, trySemanticSearch } from "./embedding_index";
8
14
 
9
15
  /**
10
16
  * A retrievable model entity: a source, one of its views, a field (dimension or
@@ -18,7 +24,23 @@ interface Entity {
18
24
  name: string;
19
25
  source: string | undefined;
20
26
  modelPath: string;
27
+ // Human-facing doc for the response (may fall back to raw annotations).
21
28
  doc: string;
29
+ // #(doc)-only text used as embedding input; never carries predicate
30
+ // annotations (#(authorize) etc.) that must not leave the machine.
31
+ embedDoc: string;
32
+ }
33
+
34
+ /** One tier-4 result. `score` (cosine) rides only on semantic results. */
35
+ interface ResultEntity {
36
+ kind: string;
37
+ name: string;
38
+ source: string | undefined;
39
+ environmentName: string;
40
+ packageName: string;
41
+ modelPath: string;
42
+ doc: string;
43
+ score?: number;
22
44
  }
23
45
 
24
46
  const getContextShape = {
@@ -62,16 +84,38 @@ type GetContextParams = z.infer<z.ZodObject<typeof getContextShape>>;
62
84
  * SourceInfo sources/fields carry Annotation objects ({ value }); named queries
63
85
  * carry raw strings, so accept both.
64
86
  */
65
- export function docText(
87
+ /**
88
+ * Extract ONLY `#(doc)` annotation text, empty when there is none. This is
89
+ * the safe input for embedding: unlike docText it never falls back to the
90
+ * raw annotation lines, so predicate-bearing annotations (`#(authorize)`
91
+ * row-level-security rules, tenant lists, `#(malloy)` internals) are never
92
+ * sent to an external embedding provider.
93
+ */
94
+ export function docOnlyText(
66
95
  annotations?: Array<string | { value: string }>,
67
96
  ): string {
68
97
  if (!annotations || annotations.length === 0) return "";
69
- const lines = annotations.map((a) => (typeof a === "string" ? a : a.value));
70
- const docs = lines
98
+ const docs = annotations
99
+ .map((a) => (typeof a === "string" ? a : a.value))
71
100
  .map((a) => a.match(/#\(doc\)\s*(.*)/)?.[1]?.trim() ?? "")
72
101
  .filter(Boolean);
73
- const chosen = docs.length > 0 ? docs : lines;
74
- return chosen.join(" ").replace(/\s+/g, " ").trim();
102
+ return docs.join(" ").replace(/\s+/g, " ").trim();
103
+ }
104
+
105
+ /**
106
+ * Human-facing doc text for the response `doc` field. Prefers `#(doc)`
107
+ * text and falls back to the raw annotation lines when there is none.
108
+ * Pre-existing lexical behaviour; NOT used as embedding input (see
109
+ * docOnlyText and Entity.embedDoc).
110
+ */
111
+ export function docText(
112
+ annotations?: Array<string | { value: string }>,
113
+ ): string {
114
+ const doc = docOnlyText(annotations);
115
+ if (doc) return doc;
116
+ if (!annotations || annotations.length === 0) return "";
117
+ const lines = annotations.map((a) => (typeof a === "string" ? a : a.value));
118
+ return lines.join(" ").replace(/\s+/g, " ").trim();
75
119
  }
76
120
 
77
121
  export function sanitize(query: string): string {
@@ -109,6 +153,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
109
153
  source: sourceName,
110
154
  modelPath,
111
155
  doc: docText(sourceInfo.annotations),
156
+ embedDoc: docOnlyText(sourceInfo.annotations),
112
157
  });
113
158
  for (const field of sourceInfo.schema.fields ?? []) {
114
159
  // v1 indexes the queryable surface: views and dimension/measure
@@ -127,6 +172,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
127
172
  source: sourceName,
128
173
  modelPath,
129
174
  doc: docText(field.annotations),
175
+ embedDoc: docOnlyText(field.annotations),
130
176
  });
131
177
  }
132
178
  }
@@ -140,6 +186,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
140
186
  source: query.sourceName,
141
187
  modelPath,
142
188
  doc: docText(query.annotations),
189
+ embedDoc: docOnlyText(query.annotations),
143
190
  });
144
191
  }
145
192
  }
@@ -149,7 +196,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
149
196
  // first occurrence per (kind, source, name).
150
197
  const seen = new Set<string>();
151
198
  return entities.filter((e) => {
152
- const key = `${e.kind}|${e.source ?? ""}|${e.name}`;
199
+ const key = entityRowKey(e.kind, e.source ?? "", e.name);
153
200
  if (seen.has(key)) return false;
154
201
  seen.add(key);
155
202
  return true;
@@ -157,6 +204,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
157
204
  }
158
205
 
159
206
  interface PackageIndex {
207
+ pkg: Package;
160
208
  byId: Map<string, Entity>;
161
209
  index: lunr.Index;
162
210
  entityCount: number;
@@ -197,7 +245,12 @@ async function getPackageIndex(
197
245
  });
198
246
  }
199
247
  });
200
- const built: PackageIndex = { byId, index, entityCount: entities.length };
248
+ const built: PackageIndex = {
249
+ pkg,
250
+ byId,
251
+ index,
252
+ entityCount: entities.length,
253
+ };
201
254
  indexCache.set(pkg, built);
202
255
  logger.debug("[MCP Tool getContext] Built and cached entity index", {
203
256
  packageName,
@@ -223,7 +276,7 @@ Call it with as much as you know and omit the rest; it answers at the appropriat
223
276
  - limit (optional): cap the number of results (max 50). Retrieval defaults to 10; the listing tiers return all unless set.
224
277
 
225
278
  ## Response
226
- A JSON object with a results array whose items carry a kind field. For retrieval, each entity has kind (source / view / query / dimension / measure), name, source, modelPath, and doc; environmentName, packageName, modelPath, and source map directly onto malloy_executeQuery parameters, and for a view or named query you pass its name as queryName with sourceName.
279
+ A JSON object with a results array whose items carry a kind field. For retrieval, each entity has kind (source / view / query / dimension / measure), name, source, modelPath, and doc; environmentName, packageName, modelPath, and source map directly onto malloy_executeQuery parameters, and for a view or named query you pass its name as queryName with sourceName. When the server is configured with an embedding provider, retrieval is ranked by semantic similarity: the payload then carries a retrieval field ("semantic", or "lexical" when the provider is unavailable) and each semantic entity a score.
227
280
 
228
281
  ## Contract rules
229
282
  - Use the names verbatim; do not invent environments, packages, or entities not in the results.
@@ -404,7 +457,100 @@ export function registerGetContextTool(
404
457
  return jsonResource(uri, { results });
405
458
  }
406
459
 
407
- // Tier 4: retrieval over the package's entities.
460
+ // Tier 4: retrieval over the package's entities. With an
461
+ // embedding provider configured, ranking is semantic (DuckDB
462
+ // cosine over cached entity embeddings); otherwise, or whenever
463
+ // the semantic path is unavailable (index still building,
464
+ // provider down, oversized package), it is lexical lunr. The
465
+ // `retrieval` marker and per-entity `score` appear ONLY when a
466
+ // provider is configured, so the unconfigured payload stays
467
+ // byte-identical to the lexical-only releases.
468
+ const configured = embeddingConfigured();
469
+ let semanticResults: ResultEntity[] | undefined;
470
+ if (configured) {
471
+ let provider: EmbeddingProvider | null = null;
472
+ try {
473
+ provider = getEmbeddingProvider();
474
+ } catch (error) {
475
+ logger.warn(
476
+ "[MCP Tool getContext] Embedding configuration invalid; using lexical ranking",
477
+ {
478
+ error:
479
+ error instanceof Error ? error.message : String(error),
480
+ },
481
+ );
482
+ }
483
+ if (provider) {
484
+ try {
485
+ // The raw query embeds better than the lunr-sanitized
486
+ // one; sanitize() only exists to strip lunr operators.
487
+ const semantic = await trySemanticSearch({
488
+ db: environmentStore.storageManager.getDuckDbConnection(),
489
+ provider,
490
+ pkg: pkgIndex.pkg,
491
+ environmentName,
492
+ packageName,
493
+ entities: Array.from(byId.values()),
494
+ query: query ?? sanitized,
495
+ limit: max,
496
+ // "" means no drill-down, matching the lexical
497
+ // path's truthiness filter.
498
+ sourceName: sourceName || undefined,
499
+ });
500
+ if ("hits" in semantic) {
501
+ const byKey = new Map(
502
+ Array.from(byId.values()).map((e) => [
503
+ entityRowKey(e.kind, e.source ?? "", e.name),
504
+ e,
505
+ ]),
506
+ );
507
+ // Rows are only a vector cache: modelPath and doc
508
+ // come from the live entity, and a hit with no live
509
+ // entity (deleted since the last sync) is dropped.
510
+ semanticResults = semantic.hits.flatMap((hit) => {
511
+ const e = byKey.get(
512
+ entityRowKey(hit.kind, hit.source ?? "", hit.name),
513
+ );
514
+ if (!e) return [];
515
+ return [
516
+ {
517
+ kind: e.kind,
518
+ name: e.name,
519
+ source: e.source,
520
+ environmentName,
521
+ packageName,
522
+ modelPath: e.modelPath,
523
+ doc: e.doc,
524
+ score: Math.round(hit.score * 10_000) / 10_000,
525
+ },
526
+ ];
527
+ });
528
+ }
529
+ } catch (error) {
530
+ // Defensive: trySemanticSearch does not throw, but the
531
+ // storage handle lookup can (e.g. before initialization
532
+ // or under a partial test double). Semantic retrieval
533
+ // must never take tier 4 down with it.
534
+ logger.warn(
535
+ "[MCP Tool getContext] Semantic retrieval unavailable; using lexical ranking",
536
+ {
537
+ error:
538
+ error instanceof Error
539
+ ? error.message
540
+ : String(error),
541
+ },
542
+ );
543
+ }
544
+ }
545
+ }
546
+
547
+ if (semanticResults !== undefined) {
548
+ return jsonResource(uri, {
549
+ retrieval: "semantic",
550
+ results: semanticResults,
551
+ });
552
+ }
553
+
408
554
  let hits: lunr.Index.Result[] = [];
409
555
  try {
410
556
  hits = index.search(sanitized);
@@ -433,7 +579,10 @@ export function registerGetContextTool(
433
579
  doc: e.doc,
434
580
  }));
435
581
 
436
- return jsonResource(uri, { results });
582
+ return jsonResource(
583
+ uri,
584
+ configured ? { retrieval: "lexical", results } : { results },
585
+ );
437
586
  },
438
587
  );
439
588
  }
@@ -19,4 +19,205 @@ describe("redactPgSecrets", () => {
19
19
  "user=alice dbname=billing",
20
20
  );
21
21
  });
22
+
23
+ it("redacts the password in a URL-form connection string", () => {
24
+ expect(redactPgSecrets("postgres://user:pass@host/db")).toBe(
25
+ "postgres://user:***@host/db",
26
+ );
27
+ });
28
+
29
+ it("preserves port and query string around a URI password", () => {
30
+ expect(redactPgSecrets("postgresql://u:p@h:5432/d?sslmode=require")).toBe(
31
+ "postgresql://u:***@h:5432/d?sslmode=require",
32
+ );
33
+ });
34
+
35
+ it("redacts a percent-encoded password whole", () => {
36
+ expect(redactPgSecrets("postgres://u:p%40ss@h/d")).toBe(
37
+ "postgres://u:***@h/d",
38
+ );
39
+ });
40
+
41
+ it("redacts a literal @ in the password up to the last @", () => {
42
+ expect(redactPgSecrets("postgres://u:p@ss@h/d")).toBe(
43
+ "postgres://u:***@h/d",
44
+ );
45
+ });
46
+
47
+ it("redacts a password containing colons whole", () => {
48
+ expect(redactPgSecrets("postgres://u:pa:ss@h/d")).toBe(
49
+ "postgres://u:***@h/d",
50
+ );
51
+ });
52
+
53
+ it("redacts a raw slash in a pg password via the mop-up pass", () => {
54
+ expect(redactPgSecrets("postgres://u:pa/ss@h/d")).toBe(
55
+ "postgres://u:***@h/d",
56
+ );
57
+ });
58
+
59
+ it("redacts the password when the username is empty", () => {
60
+ expect(redactPgSecrets("postgres://:secret@h/d")).toBe(
61
+ "postgres://:***@h/d",
62
+ );
63
+ });
64
+
65
+ it("leaves IPv6 hosts intact", () => {
66
+ expect(redactPgSecrets("postgres://u:p@[::1]:5432/d")).toBe(
67
+ "postgres://u:***@[::1]:5432/d",
68
+ );
69
+ });
70
+
71
+ it("does not redact a username-only userinfo", () => {
72
+ expect(redactPgSecrets("postgres://user@host/db")).toBe(
73
+ "postgres://user@host/db",
74
+ );
75
+ });
76
+
77
+ it("leaves URIs without userinfo alone", () => {
78
+ expect(redactPgSecrets("postgres://host/db")).toBe("postgres://host/db");
79
+ expect(redactPgSecrets("postgres://host:5432/db")).toBe(
80
+ "postgres://host:5432/db",
81
+ );
82
+ });
83
+
84
+ it("does not mangle a passwordless URI with :...@ in its query", () => {
85
+ expect(redactPgSecrets("postgres://h?options=endpoint:foo@bar")).toBe(
86
+ "postgres://h?options=endpoint:foo@bar",
87
+ );
88
+ expect(redactPgSecrets("postgres://h/d?x=a@b")).toBe(
89
+ "postgres://h/d?x=a@b",
90
+ );
91
+ });
92
+
93
+ it("leaves email addresses in prose alone", () => {
94
+ expect(redactPgSecrets("contact admin@example.com for help")).toBe(
95
+ "contact admin@example.com for help",
96
+ );
97
+ });
98
+
99
+ it("does not mangle https URLs with @ in the path", () => {
100
+ expect(
101
+ redactPgSecrets("fetched https://github.com:443/@scope/pkg ok"),
102
+ ).toBe("fetched https://github.com:443/@scope/pkg ok");
103
+ });
104
+
105
+ it("redacts every URI in a message independently", () => {
106
+ expect(
107
+ redactPgSecrets(
108
+ "tried postgres://u1:p1@h1/d1 then postgres://u2:p2@h2/d2",
109
+ ),
110
+ ).toBe("tried postgres://u1:***@h1/d1 then postgres://u2:***@h2/d2");
111
+ });
112
+
113
+ it("keeps comma-joined URIs in one token separate", () => {
114
+ expect(
115
+ redactPgSecrets(
116
+ "candidates: postgres://u1:p1@h1/d1,postgres://u2:p2@h2/d2",
117
+ ),
118
+ ).toBe("candidates: postgres://u1:***@h1/d1,postgres://u2:***@h2/d2");
119
+ });
120
+
121
+ it("leaves trailing sentence punctuation alone", () => {
122
+ expect(redactPgSecrets("could not connect to postgres://u:p@h/d.")).toBe(
123
+ "could not connect to postgres://u:***@h/d.",
124
+ );
125
+ });
126
+
127
+ it("handles keyword and URI forms in one message", () => {
128
+ expect(
129
+ redactPgSecrets(
130
+ "attach failed: host=h password=x and fallback postgres://u:p@h2/d",
131
+ ),
132
+ ).toBe(
133
+ "attach failed: host=h password=*** and fallback postgres://u:***@h2/d",
134
+ );
135
+ });
136
+
137
+ it("keeps the URI intact when a password contains 'password='", () => {
138
+ // URI passes run before the keyword pass; keyword-first would eat
139
+ // the @host/db tail via \S+.
140
+ expect(redactPgSecrets("postgres://u:password=abc@h/d")).toBe(
141
+ "postgres://u:***@h/d",
142
+ );
143
+ });
144
+
145
+ it("over-redacts rather than leaks a ?password= query parameter", () => {
146
+ // Pre-existing keyword-pass behavior: \S+ eats through &sslmode=...
147
+ // Over-redaction, never a leak.
148
+ expect(
149
+ redactPgSecrets("postgres://h/d?password=abc&sslmode=require"),
150
+ ).toBe("postgres://h/d?password=***");
151
+ });
152
+
153
+ it("matches uppercase schemes", () => {
154
+ expect(redactPgSecrets("POSTGRES://U:Secret@H/D")).toBe(
155
+ "POSTGRES://U:***@H/D",
156
+ );
157
+ });
158
+
159
+ it("redacts non-pg scheme userinfo passwords too", () => {
160
+ expect(redactPgSecrets("amqp://guest:guest@rabbit:5672/vhost")).toBe(
161
+ "amqp://guest:***@rabbit:5672/vhost",
162
+ );
163
+ });
164
+
165
+ it("redacts an Azure user@servername username form", () => {
166
+ // Azure single-server usernames carry a literal @ (user@servername);
167
+ // userinfo splits at the LAST @, so the password after the first : is
168
+ // redacted while the @-bearing username stays intact.
169
+ expect(
170
+ redactPgSecrets(
171
+ "postgres://myadmin@myserver:Zx7SeCReT9q@myserver.postgres.database.azure.com:5432/db",
172
+ ),
173
+ ).toBe(
174
+ "postgres://myadmin@myserver:***@myserver.postgres.database.azure.com:5432/db",
175
+ );
176
+ });
177
+
178
+ it("redacts an @-in-username form whose password also contains @", () => {
179
+ expect(redactPgSecrets("postgres://myadmin@srv:p@ss@host/db")).toBe(
180
+ "postgres://myadmin@srv:***@host/db",
181
+ );
182
+ });
183
+
184
+ it("redacts an @-in-username form with a raw / in the password", () => {
185
+ expect(redactPgSecrets("postgresql://u@srv:sec/ret@h/d")).toBe(
186
+ "postgresql://u@srv:***@h/d",
187
+ );
188
+ });
189
+
190
+ it("redacts a scheme abutting a word char", () => {
191
+ // No leading anchor on the scheme, so a scheme glued to a preceding
192
+ // word char (an invalid-scheme prefix, or a stray digit) still redacts.
193
+ expect(redactPgSecrets("x_postgres://u:secretpw@h/d")).toBe(
194
+ "x_postgres://u:***@h/d",
195
+ );
196
+ expect(redactPgSecrets("3postgres://u:secretpw@h/d")).toBe(
197
+ "3postgres://u:***@h/d",
198
+ );
199
+ });
200
+
201
+ it("never reveals a secret when re-applied (monotonic)", () => {
202
+ // redactPgSecrets is not strictly idempotent in general (re-applying
203
+ // can redact further, e.g. an unquoted *** left by the keyword pass),
204
+ // but re-application must never turn a redacted secret back into
205
+ // cleartext.
206
+ const input = "postgres://u:p@ss@h/d and host=h password=hunter2";
207
+ const once = redactPgSecrets(input);
208
+ const twice = redactPgSecrets(once);
209
+ expect(once).not.toContain("hunter2");
210
+ expect(twice).not.toContain("hunter2");
211
+ expect(twice).not.toContain("p@ss");
212
+ });
213
+
214
+ it("redacts the DSN inside a real DuckDB attach error message", () => {
215
+ // Shape observed from DuckDB's postgres extension: the full DSN is
216
+ // echoed verbatim inside the IO Error text.
217
+ const msg =
218
+ 'IO Error: Unable to connect to Postgres at "postgres://alice:supersecretpw@127.0.0.1:5432/mydb": connection to server at "127.0.0.1", port 5432 failed: Connection refused';
219
+ const redacted = redactPgSecrets(msg);
220
+ expect(redacted).toContain("postgres://alice:***@127.0.0.1:5432/mydb");
221
+ expect(redacted).not.toContain("supersecretpw");
222
+ });
22
223
  });
package/src/pg_helpers.ts CHANGED
@@ -3,11 +3,50 @@
3
3
  // neither layer takes a dependency on the other — see CLAUDE.md's "Two
4
4
  // parallel DuckLake/PG attach paths" note for why this matters.
5
5
 
6
- // Redact libpq `password=...` values from a string before it goes into a
7
- // log line or HTTP response body. Handles bare and quoted values.
6
+ // Redact Postgres credentials from a string before it goes into a log line
7
+ // or HTTP response body. Covers both libpq forms: keyword `password=...`
8
+ // (bare and quoted values) and URI userinfo (`scheme://user:pass@host`).
9
+ // The input is arbitrary error prose that may carry zero or more connection
10
+ // strings: attach failures echo user-supplied connection strings verbatim
11
+ // (DuckDB's postgres extension embeds the full DSN), so URL-parsing is not
12
+ // an option and substitution has to be shape-based.
8
13
  //
9
- // Scope: keyword-form `password=` only. Does not touch URL-style
10
- // `user:pw@host` credentials, AWS keys, GCS secrets, etc.
14
+ // Three passes, and the order is load-bearing (URI passes first: the
15
+ // keyword pass's \S+ would otherwise eat a URI's `@host/db` tail whenever
16
+ // a password contains the text `password=`):
17
+ // 1. Any-scheme URI userinfo. The password class ([^/\s]+) is greedy to
18
+ // the LAST `@` before a `/`, matching how WHATWG URL and libpq split
19
+ // userinfo, so a literal `@` in a password redacts fully; bounding at
20
+ // `/` keeps path-@ URLs (https://h:443/@scope/pkg) and comma-joined
21
+ // URI lists from collapsing into one match. The username class allows
22
+ // empty (`postgres://:pw@h`) and a literal `@` (Azure's `user@servername`
23
+ // form, `postgres://myadmin@srv:pw@host`), stopping at the first `:`;
24
+ // it excludes `?#` so a passwordless URI with `:...@` in its query is
25
+ // not mangled by this pass.
26
+ // 2. A postgres-scheme-only mop-up with first-@ semantics ([^@\s]+),
27
+ // for the one shape pass 1 fails open on: a raw `/` in the password
28
+ // (invalid per RFC 3986, but connectionStrings flow through verbatim).
29
+ // Scheme-restricted so it can never touch an https URL.
30
+ // 3. The original keyword-form pass, unchanged.
31
+ //
32
+ // The scheme has no leading `\b`/anchor on purpose: a scheme abutting a
33
+ // word char (`x_postgres://u:pw@h`) should still redact. Over-matching a
34
+ // credential-shaped `scheme://user:pass@` token is the safe direction, and
35
+ // usernames stay visible (they aid debugging, and the keyword pass keeps
36
+ // `user=` too). Some non-secret shapes are over-redacted as a result, which
37
+ // is always preferred to a leak: `?password=a&sslmode=b` loses the
38
+ // `&sslmode=b` tail, and a passwordless `@`-username URI with a raw `@`
39
+ // later in its query/fragment (`postgres://u@srv:5432/db?x=a@b`) has its
40
+ // tail replaced by `***`. Residual gap (accepted): a raw (unencoded)
41
+ // whitespace inside a password ends the match early; a valid URI/libpq
42
+ // string encodes it, and dropping the `\s` bound would let a match run
43
+ // across surrounding message text.
11
44
  export function redactPgSecrets(s: string): string {
12
- return s.replace(/password=('[^']*'|"[^"]*"|\S+)/gi, "password=***");
45
+ return s
46
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^:/?#\s]*):([^/\s]+)@/gi, "$1:***@")
47
+ .replace(
48
+ /((?:postgres|postgresql):\/\/[^:/?#\s]*):([^@\s]+)@/gi,
49
+ "$1:***@",
50
+ )
51
+ .replace(/password=('[^']*'|"[^"]*"|\S+)/gi, "password=***");
13
52
  }
package/src/server.ts CHANGED
@@ -35,9 +35,12 @@ import {
35
35
  import { logger, loggerMiddleware } from "./logger";
36
36
 
37
37
  import {
38
+ getEmbeddingConfig,
38
39
  getExtensionFetchPolicy,
39
40
  getMaterializationSchedulerConfig,
40
41
  getMemoryGovernorConfig,
42
+ getPersistCollisionEnforce,
43
+ getPersistStorageMode,
41
44
  } from "./config";
42
45
  import { setFilterDeprecationHeaders } from "./filter_deprecation";
43
46
  import { checkHeapConfiguration } from "./heap_check";
@@ -164,6 +167,16 @@ function parseArgs() {
164
167
  // Parse CLI arguments before setting up constants
165
168
  parseArgs();
166
169
 
170
+ // Fail fast at boot on a malformed PERSIST_STORAGE_MODE. The getter throws on an
171
+ // invalid value; without this it would only throw lazily inside a per-request
172
+ // package-metadata call (storageWarnings), on every deployment regardless of
173
+ // whether the tier is used. Reading it once here surfaces a typo at startup.
174
+ getPersistStorageMode();
175
+ // Same for PERSIST_COLLISION_ENFORCE, whose only other caller is the publish
176
+ // path — so a typo would otherwise surface as a failed publish request rather
177
+ // than a failed boot.
178
+ getPersistCollisionEnforce();
179
+
167
180
  const PUBLISHER_PORT = Number(process.env.PUBLISHER_PORT || 4000);
168
181
  const PUBLISHER_HOST = process.env.PUBLISHER_HOST || "0.0.0.0";
169
182
  const MCP_PORT = Number(process.env.MCP_PORT || 4040);
@@ -206,6 +219,17 @@ const modelController = new ModelController(environmentStore);
206
219
  // an operator relying on `local-only` for a no-network guarantee. Logging the
207
220
  // resolved policy also records the posture the server booted with.
208
221
  logger.info(`DuckDB extension-fetch policy: ${getExtensionFetchPolicy()}`);
222
+ // Resolve the embedding config at boot so a malformed EMBEDDING_API_BASE /
223
+ // EMBEDDING_DIMENSIONS fails loudly at startup (getEmbeddingConfig throws),
224
+ // matching the sibling getters above, rather than surfacing as a warn on the
225
+ // first getContext call that reaches tier 4 — or never. Logs the posture the
226
+ // server booted with; the host only, never the key.
227
+ const embeddingConfig = getEmbeddingConfig();
228
+ if (embeddingConfig) {
229
+ logger.info(
230
+ `Semantic get_context enabled: model ${embeddingConfig.model} at ${new URL(embeddingConfig.baseUrl).host}`,
231
+ );
232
+ }
209
233
  const memoryGovernorConfig = getMemoryGovernorConfig();
210
234
  const memoryGovernor = memoryGovernorConfig
211
235
  ? new PackageMemoryGovernor(memoryGovernorConfig)