@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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- 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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 =
|
|
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 = {
|
|
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(
|
|
582
|
+
return jsonResource(
|
|
583
|
+
uri,
|
|
584
|
+
configured ? { retrieval: "lexical", results } : { results },
|
|
585
|
+
);
|
|
437
586
|
},
|
|
438
587
|
);
|
|
439
588
|
}
|
package/src/pg_helpers.spec.ts
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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)
|