@malloy-publisher/server 0.0.207 → 0.0.209

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 (59) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/index-CCcHdeew.js +1760 -0
  12. package/dist/app/assets/index-CNFX-CGL.js +40 -0
  13. package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +26182 -16839
  17. package/package.json +14 -11
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/health.ts +6 -0
  21. package/src/materialization_metrics.ts +117 -34
  22. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  23. package/src/mcp/agent_server.spec.ts +18 -0
  24. package/src/mcp/agent_server.ts +144 -0
  25. package/src/mcp/server.ts +3 -0
  26. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  27. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -0
  29. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  30. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  31. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  32. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  33. package/src/mcp/tools/docs_search_tool.ts +138 -0
  34. package/src/mcp/tools/get_context_eval.ts +126 -0
  35. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  36. package/src/mcp/tools/get_context_tool.ts +341 -0
  37. package/src/server-old.ts +2 -11
  38. package/src/server.ts +16 -10
  39. package/src/service/build_plan.spec.ts +116 -0
  40. package/src/service/build_plan.ts +238 -0
  41. package/src/service/connection.ts +4 -0
  42. package/src/service/connection_config.spec.ts +182 -1
  43. package/src/service/connection_config.ts +70 -0
  44. package/src/service/db_utils.spec.ts +159 -1
  45. package/src/service/db_utils.ts +131 -0
  46. package/src/service/materialization_service.spec.ts +388 -184
  47. package/src/service/materialization_service.ts +156 -442
  48. package/src/service/materialization_test_fixtures.ts +119 -0
  49. package/src/service/package.ts +41 -1
  50. package/src/storage/DatabaseInterface.ts +5 -13
  51. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  52. package/src/storage/duckdb/schema.ts +4 -5
  53. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  54. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  55. package/dist/app/assets/HomePage-CGedji_w.js +0 -1
  56. package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
  57. package/dist/app/assets/index-DGGe8UpP.js +0 -40
  58. package/dist/app/assets/index-uu6UpHd2.js +0 -1812
  59. package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
package/src/server-old.ts CHANGED
@@ -944,16 +944,7 @@ export function registerLegacyRoutes(
944
944
  async (req, res) => {
945
945
  try {
946
946
  const action = req.query.action;
947
- if (action === "build") {
948
- const build =
949
- await materializationController.buildMaterialization(
950
- req.params.projectName,
951
- req.params.packageName,
952
- req.params.materializationId,
953
- req.body || {},
954
- );
955
- res.status(202).json(remapMaterializationResponse(build));
956
- } else if (action === "stop") {
947
+ if (action === "stop") {
957
948
  const build =
958
949
  await materializationController.stopMaterialization(
959
950
  req.params.projectName,
@@ -963,7 +954,7 @@ export function registerLegacyRoutes(
963
954
  res.status(200).json(remapMaterializationResponse(build));
964
955
  } else {
965
956
  throw new BadRequestError(
966
- `Unsupported action '${String(action ?? "")}'. Expected 'build' or 'stop'.`,
957
+ `Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
967
958
  );
968
959
  }
969
960
  } catch (error) {
package/src/server.ts CHANGED
@@ -39,6 +39,7 @@ import { checkHeapConfiguration } from "./heap_check";
39
39
  import { queryConcurrency } from "./query_concurrency";
40
40
  import { MaterializationController } from "./controller/materialization.controller";
41
41
  import { initializeMcpServer } from "./mcp/server";
42
+ import { startAgentMcpServer } from "./mcp/agent_server";
42
43
  import { registerLegacyRoutes } from "./server-old";
43
44
  import { EnvironmentStore } from "./service/environment_store";
44
45
  import { MaterializationService } from "./service/materialization_service";
@@ -160,6 +161,7 @@ parseArgs();
160
161
  const PUBLISHER_PORT = Number(process.env.PUBLISHER_PORT || 4000);
161
162
  const PUBLISHER_HOST = process.env.PUBLISHER_HOST || "0.0.0.0";
162
163
  const MCP_PORT = Number(process.env.MCP_PORT || 4040);
164
+ const AGENT_MCP_PORT = Number(process.env.AGENT_MCP_PORT || 4041);
163
165
  const MCP_ENDPOINT = "/mcp";
164
166
  const SHUTDOWN_DRAIN_DURATION_SECONDS = Number(
165
167
  process.env.SHUTDOWN_DRAIN_DURATION_SECONDS || 0,
@@ -1590,15 +1592,7 @@ app.post(
1590
1592
  async (req, res) => {
1591
1593
  try {
1592
1594
  const action = req.query.action;
1593
- if (action === "build") {
1594
- const build = await materializationController.buildMaterialization(
1595
- req.params.environmentName,
1596
- req.params.packageName,
1597
- req.params.materializationId,
1598
- req.body || {},
1599
- );
1600
- res.status(202).json(build);
1601
- } else if (action === "stop") {
1595
+ if (action === "stop") {
1602
1596
  const build = await materializationController.stopMaterialization(
1603
1597
  req.params.environmentName,
1604
1598
  req.params.packageName,
@@ -1607,7 +1601,7 @@ app.post(
1607
1601
  res.status(200).json(build);
1608
1602
  } else {
1609
1603
  throw new BadRequestError(
1610
- `Unsupported action '${String(action ?? "")}'. Expected 'build' or 'stop'.`,
1604
+ `Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
1611
1605
  );
1612
1606
  }
1613
1607
  } catch (error) {
@@ -1765,9 +1759,21 @@ mcpServer.timeout = 600000;
1765
1759
  mcpServer.keepAliveTimeout = 600000;
1766
1760
  mcpServer.headersTimeout = 600000;
1767
1761
 
1762
+ // Separate, isolated MCP server for the agent retrieval tools (get_context,
1763
+ // search_docs) on its own listener. Kept apart from the core MCP server above.
1764
+ const agentMcpServer = startAgentMcpServer(
1765
+ environmentStore,
1766
+ PUBLISHER_HOST,
1767
+ AGENT_MCP_PORT,
1768
+ );
1769
+ agentMcpServer.timeout = 600000;
1770
+ agentMcpServer.keepAliveTimeout = 600000;
1771
+ agentMcpServer.headersTimeout = 600000;
1772
+
1768
1773
  registerSignalHandlers(
1769
1774
  mainServer,
1770
1775
  mcpServer,
1771
1776
  SHUTDOWN_DRAIN_DURATION_SECONDS,
1772
1777
  SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS,
1778
+ agentMcpServer,
1773
1779
  );
@@ -0,0 +1,116 @@
1
+ import type { PersistSource } from "@malloydata/malloy";
2
+ import { describe, expect, it } from "bun:test";
3
+ import * as sinon from "sinon";
4
+ import {
5
+ computeBuildId,
6
+ computePackageBuildPlan,
7
+ deriveBuildPlan,
8
+ flattenDependsOn,
9
+ resolvePackageConnections,
10
+ } from "./build_plan";
11
+ import { fakeSource } from "./materialization_test_fixtures";
12
+
13
+ describe("flattenDependsOn", () => {
14
+ it("maps nested dependsOn entries to a flat sourceID list", () => {
15
+ expect(
16
+ flattenDependsOn({
17
+ dependsOn: [{ sourceID: "a" }, { sourceID: "b" }],
18
+ }),
19
+ ).toEqual(["a", "b"]);
20
+ });
21
+ });
22
+
23
+ describe("computeBuildId", () => {
24
+ it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
25
+ const makeBuildId = sinon.stub().returns("computed-id");
26
+ const source = {
27
+ connectionName: "duckdb",
28
+ makeBuildId,
29
+ getSQL: () => "SELECT 7",
30
+ } as unknown as PersistSource;
31
+
32
+ const id = computeBuildId(source, { duckdb: "dig-1" });
33
+
34
+ expect(id).toBe("computed-id");
35
+ expect(makeBuildId.calledOnceWithExactly("dig-1", "SELECT 7")).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("resolvePackageConnections", () => {
40
+ it("resolves each unique name once and omits failures", async () => {
41
+ const getMalloyConnection = sinon.stub();
42
+ getMalloyConnection.withArgs("ok").resolves({ id: "ok-conn" });
43
+ getMalloyConnection.withArgs("bad").rejects(new Error("nope"));
44
+
45
+ const map = await resolvePackageConnections({ getMalloyConnection }, [
46
+ "ok",
47
+ "ok",
48
+ "bad",
49
+ ]);
50
+
51
+ expect(map.has("ok")).toBe(true);
52
+ expect(map.has("bad")).toBe(false);
53
+ // "ok" requested twice but resolved once (dedupe).
54
+ expect(getMalloyConnection.withArgs("ok").callCount).toBe(1);
55
+ });
56
+ });
57
+
58
+ describe("deriveBuildPlan", () => {
59
+ it("projects graphs and sources into the wire build plan", () => {
60
+ const orders = fakeSource({
61
+ name: "orders",
62
+ buildId: "bid-orders",
63
+ sql: "SELECT 1",
64
+ });
65
+ const plan = deriveBuildPlan(
66
+ [
67
+ {
68
+ connectionName: "duckdb",
69
+ nodes: [[{ sourceID: "orders@m", dependsOn: [] }]],
70
+ },
71
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
72
+ { "orders@m": orders },
73
+ { duckdb: "dig" },
74
+ );
75
+
76
+ expect(plan.graphs[0].connectionName).toBe("duckdb");
77
+ expect(plan.sources["orders@m"]).toMatchObject({
78
+ name: "orders",
79
+ connectionName: "duckdb",
80
+ buildId: "bid-orders",
81
+ sql: "SELECT 1",
82
+ columns: [],
83
+ });
84
+ });
85
+
86
+ it("honors the sourceNames filter", () => {
87
+ const a = fakeSource({ name: "a", buildId: "bid-a" });
88
+ const b = fakeSource({ name: "b", buildId: "bid-b" });
89
+ const plan = deriveBuildPlan(
90
+ [
91
+ {
92
+ connectionName: "duckdb",
93
+ nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
94
+ },
95
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
96
+ { "a@m": a, "b@m": b },
97
+ { duckdb: "dig" },
98
+ ["a"],
99
+ );
100
+
101
+ expect(Object.keys(plan.sources)).toEqual(["a@m"]);
102
+ });
103
+ });
104
+
105
+ describe("computePackageBuildPlan", () => {
106
+ it("returns null when the package declares no persist sources", async () => {
107
+ const pkg = {
108
+ getModelPaths: () => [],
109
+ getPackagePath: () => "/test",
110
+ getMalloyConfig: () => ({}),
111
+ getMalloyConnection: async () => ({}),
112
+ } as unknown as Parameters<typeof computePackageBuildPlan>[0];
113
+
114
+ expect(await computePackageBuildPlan(pkg)).toBeNull();
115
+ });
116
+ });
@@ -0,0 +1,238 @@
1
+ import type {
2
+ AtomicField,
3
+ BuildGraph as MalloyBuildGraph,
4
+ MalloyConfig,
5
+ Connection as MalloyConnection,
6
+ PersistSource,
7
+ } from "@malloydata/malloy";
8
+ import { components } from "../api";
9
+ import { logger } from "../logger";
10
+ import { recordConnectionDigestSkipped } from "../materialization_metrics";
11
+ import { Model } from "./model";
12
+
13
+ type WireBuildGraph = components["schemas"]["BuildGraph"];
14
+ type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
15
+ type WireColumn = components["schemas"]["Column"];
16
+ type BuildPlan = components["schemas"]["BuildPlan"];
17
+
18
+ /**
19
+ * Minimal surface a package must expose to compile its build plan. Both
20
+ * {@link Package} (for the read-only `Package.buildPlan`) and the
21
+ * materialization service (for auto-run and orchestrated builds) satisfy it.
22
+ */
23
+ export interface BuildPlanPackage {
24
+ getModelPaths(): string[];
25
+ getPackagePath(): string;
26
+ getMalloyConfig(): MalloyConfig;
27
+ getMalloyConnection(name: string): Promise<MalloyConnection>;
28
+ }
29
+
30
+ /**
31
+ * Result of compiling a package's persist sources: the dependency-ordered
32
+ * build graphs, the persist sources keyed by sourceID, the per-connection
33
+ * digests, and the resolved live connections. The wire {@link BuildPlan} is a
34
+ * projection of this (see {@link deriveBuildPlan}); the build engine consumes
35
+ * the full structure.
36
+ */
37
+ export interface CompiledBuildPlan {
38
+ graphs: MalloyBuildGraph[];
39
+ sources: Record<string, PersistSource>;
40
+ connectionDigests: Record<string, string>;
41
+ connections: Map<string, MalloyConnection>;
42
+ }
43
+
44
+ /** Output columns of a persist source, degrading to [] if unavailable. */
45
+ export function deriveColumns(persistSource: PersistSource): WireColumn[] {
46
+ try {
47
+ return persistSource._explore.intrinsicFields
48
+ .filter((f) => f.isAtomicField())
49
+ .map((f) => ({
50
+ name: f.name,
51
+ type: String((f as AtomicField).type),
52
+ }));
53
+ } catch (err) {
54
+ logger.warn("Failed to derive columns for persist source", {
55
+ sourceID: persistSource.sourceID,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
63
+ export function flattenDependsOn(node: {
64
+ dependsOn: { sourceID: string }[];
65
+ }): string[] {
66
+ return node.dependsOn.map((d) => d.sourceID);
67
+ }
68
+
69
+ /**
70
+ * The buildId for a persist source: a stable digest of its connection identity
71
+ * and canonical SQL. Centralizes the (source, connectionDigests) call shape so
72
+ * planning, self-instruction, and build all agree on the same id.
73
+ */
74
+ export function computeBuildId(
75
+ source: PersistSource,
76
+ connectionDigests: Record<string, string>,
77
+ ): string {
78
+ return source.makeBuildId(
79
+ connectionDigests[source.connectionName],
80
+ source.getSQL(),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Resolve a Map<name, Connection> for the names a step is about to touch.
86
+ * The package's MalloyConfig caches each lookup, so repeated calls are cheap.
87
+ * A failed lookup is logged and omitted; downstream code reports the missing
88
+ * connection explicitly.
89
+ */
90
+ export async function resolvePackageConnections(
91
+ pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
92
+ names: Iterable<string>,
93
+ ): Promise<Map<string, MalloyConnection>> {
94
+ const map = new Map<string, MalloyConnection>();
95
+ const seen = new Set<string>();
96
+ for (const name of names) {
97
+ if (!name || seen.has(name)) continue;
98
+ seen.add(name);
99
+ try {
100
+ map.set(name, await pkg.getMalloyConnection(name));
101
+ } catch (err) {
102
+ logger.warn(`Failed to resolve connection ${name}`, {
103
+ error: err instanceof Error ? err.message : String(err),
104
+ });
105
+ }
106
+ }
107
+ return map;
108
+ }
109
+
110
+ /**
111
+ * Compile every model in the package and collect the dependency-ordered
112
+ * build graphs, persist sources, connection digests, and resolved
113
+ * connections. The build plan is a pure function of the compiled model plus
114
+ * connection config (no warehouse access).
115
+ */
116
+ export async function compilePackageBuildPlan(
117
+ pkg: BuildPlanPackage,
118
+ signal?: AbortSignal,
119
+ ): Promise<CompiledBuildPlan> {
120
+ const allGraphs: MalloyBuildGraph[] = [];
121
+ const allSources: Record<string, PersistSource> = {};
122
+
123
+ for (const modelPath of pkg.getModelPaths()) {
124
+ if (signal?.aborted) throw new Error("Build cancelled");
125
+
126
+ const { runtime, modelURL, importBaseURL } = await Model.getModelRuntime(
127
+ pkg.getPackagePath(),
128
+ modelPath,
129
+ pkg.getMalloyConfig(),
130
+ );
131
+ const malloyModel = await runtime
132
+ .loadModel(modelURL, { importBaseURL })
133
+ .getModel();
134
+
135
+ // getBuildPlan() returns empty graphs for models with no #@ persist
136
+ // sources, so non-persist models are simply skipped below.
137
+ const buildPlan = malloyModel.getBuildPlan();
138
+ for (const msg of buildPlan.tagParseLog) {
139
+ logger.warn("Persist annotation issue", {
140
+ modelPath,
141
+ message: msg.message,
142
+ severity: msg.severity,
143
+ });
144
+ }
145
+ if (buildPlan.graphs.length === 0) continue;
146
+
147
+ allGraphs.push(...buildPlan.graphs);
148
+ for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
149
+ allSources[sourceID] = source;
150
+ }
151
+ }
152
+
153
+ const connections = await resolvePackageConnections(
154
+ pkg,
155
+ allGraphs.map((g) => g.connectionName),
156
+ );
157
+ const connectionDigests: Record<string, string> = {};
158
+ for (const graph of allGraphs) {
159
+ const conn = connections.get(graph.connectionName);
160
+ if (!conn) {
161
+ // The connection failed to resolve (already warned in
162
+ // resolvePackageConnections). Its buildIds will be computed without a
163
+ // digest, so surface it as a discrete correctness signal rather than
164
+ // skipping silently.
165
+ recordConnectionDigestSkipped();
166
+ logger.warn("Skipping connection digest; connection did not resolve", {
167
+ connectionName: graph.connectionName,
168
+ });
169
+ continue;
170
+ }
171
+ if (!connectionDigests[graph.connectionName]) {
172
+ connectionDigests[graph.connectionName] = await conn.getDigest();
173
+ }
174
+ }
175
+
176
+ return {
177
+ graphs: allGraphs,
178
+ sources: allSources,
179
+ connectionDigests,
180
+ connections,
181
+ };
182
+ }
183
+
184
+ /** Project the Malloy build plan into the trimmed wire BuildPlan. */
185
+ export function deriveBuildPlan(
186
+ graphs: MalloyBuildGraph[],
187
+ sources: Record<string, PersistSource>,
188
+ connectionDigests: Record<string, string>,
189
+ sourceNames?: string[],
190
+ ): BuildPlan {
191
+ const include = sourceNames ? new Set(sourceNames) : null;
192
+
193
+ const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
194
+ connectionName: graph.connectionName,
195
+ nodes: graph.nodes.map((level) =>
196
+ level.map((node) => ({
197
+ sourceID: node.sourceID,
198
+ dependsOn: flattenDependsOn(node),
199
+ })),
200
+ ),
201
+ }));
202
+
203
+ const wireSources: Record<string, WirePersistSourcePlan> = {};
204
+ for (const [sourceID, source] of Object.entries(sources)) {
205
+ if (include && !include.has(source.name)) continue;
206
+ wireSources[sourceID] = {
207
+ name: source.name,
208
+ sourceID: source.sourceID,
209
+ connectionName: source.connectionName,
210
+ dialect: source.dialectName,
211
+ buildId: computeBuildId(source, connectionDigests),
212
+ sql: source.getSQL(),
213
+ columns: deriveColumns(source),
214
+ };
215
+ }
216
+
217
+ return { graphs: wireGraphs, sources: wireSources };
218
+ }
219
+
220
+ /**
221
+ * Compile and project a package's build plan, or null when the package
222
+ * declares no persist source. Convenience for the read-only `Package.buildPlan`
223
+ * field, which is a deterministic property of the compiled package.
224
+ */
225
+ export async function computePackageBuildPlan(
226
+ pkg: BuildPlanPackage,
227
+ signal?: AbortSignal,
228
+ ): Promise<BuildPlan | null> {
229
+ const compiled = await compilePackageBuildPlan(pkg, signal);
230
+ if (compiled.graphs.length === 0) {
231
+ return null;
232
+ }
233
+ return deriveBuildPlan(
234
+ compiled.graphs,
235
+ compiled.sources,
236
+ compiled.connectionDigests,
237
+ );
238
+ }
@@ -7,6 +7,10 @@ import "@malloydata/db-mysql";
7
7
  import type { MySQLConnection } from "@malloydata/db-mysql";
8
8
  import "@malloydata/db-postgres";
9
9
  import type { PostgresConnection } from "@malloydata/db-postgres";
10
+ // Registers the "publisher" connection type (proxies SQL to a remote Publisher
11
+ // dataplane). No live-class branch is needed in lookupConnection — the default
12
+ // path resolves it through the registry like any other registered type.
13
+ import "@malloydata/db-publisher";
10
14
  import {
11
15
  buildPoolOptions,
12
16
  SnowflakeConnection,
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { generateKeyPairSync } from "crypto";
3
3
  import { components } from "../api";
4
4
  import {
@@ -168,3 +168,184 @@ describe("normalizeSnowflakePrivateKey", () => {
168
168
  expect(result.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
169
169
  });
170
170
  });
171
+
172
+ describe("assembleEnvironmentConnections — publisher", () => {
173
+ const validBase: ApiConnection = {
174
+ name: "analytics",
175
+ type: "publisher",
176
+ publisherConnection: {
177
+ connectionUri:
178
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics",
179
+ accessToken: "jwt-token",
180
+ },
181
+ };
182
+
183
+ // publisher connections are default-deny (SSRF gate); these assembly tests
184
+ // exercise the enabled path, so opt in for the block and restore after.
185
+ const priorFlag = process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
186
+ beforeEach(() => {
187
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "true";
188
+ });
189
+ afterEach(() => {
190
+ if (priorFlag === undefined) {
191
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
192
+ } else {
193
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = priorFlag;
194
+ }
195
+ });
196
+
197
+ it("emits a publisher core entry proxying to the remote dataplane", () => {
198
+ const { pojo } = assembleEnvironmentConnections([validBase]);
199
+
200
+ const entry = pojo.connections["analytics"];
201
+ expect(entry.is).toBe("publisher");
202
+ expect(entry.connectionUri).toBe(
203
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics",
204
+ );
205
+ expect(entry.accessToken).toBe("jwt-token");
206
+ });
207
+
208
+ it("does not populate static connection attributes (dialect is resolved at runtime)", () => {
209
+ const { apiConnections } = assembleEnvironmentConnections([validBase]);
210
+ expect(apiConnections).toHaveLength(1);
211
+ expect(apiConnections[0].attributes).toBeUndefined();
212
+ });
213
+
214
+ it("assembles without an accessToken (optional)", () => {
215
+ const conn: ApiConnection = {
216
+ name: "analytics",
217
+ type: "publisher",
218
+ publisherConnection: {
219
+ connectionUri:
220
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics",
221
+ },
222
+ };
223
+ const { pojo } = assembleEnvironmentConnections([conn]);
224
+ const entry = pojo.connections["analytics"];
225
+ expect(entry.is).toBe("publisher");
226
+ expect(entry.accessToken).toBeUndefined();
227
+ });
228
+
229
+ it("rejects a publisher connection missing connectionUri with an actionable error", () => {
230
+ const conn: ApiConnection = {
231
+ name: "analytics",
232
+ type: "publisher",
233
+ publisherConnection:
234
+ {} as components["schemas"]["PublisherConnection"],
235
+ };
236
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
237
+ "Invalid publisher connection 'analytics': missing connectionUri.",
238
+ );
239
+ });
240
+
241
+ it("rejects a publisher connection missing the publisherConnection block", () => {
242
+ const conn: ApiConnection = {
243
+ name: "analytics",
244
+ type: "publisher",
245
+ };
246
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
247
+ "Invalid publisher connection 'analytics': missing connectionUri.",
248
+ );
249
+ });
250
+
251
+ it("rejects a publisher connection whose connectionUri is not a valid URL", () => {
252
+ const conn: ApiConnection = {
253
+ name: "analytics",
254
+ type: "publisher",
255
+ publisherConnection: { connectionUri: "not a url" },
256
+ };
257
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
258
+ "Invalid publisher connection 'analytics': connectionUri is not a valid URL.",
259
+ );
260
+ });
261
+
262
+ it("rejects a publisher connection whose connectionUri uses a non-http(s) scheme", () => {
263
+ const conn: ApiConnection = {
264
+ name: "analytics",
265
+ type: "publisher",
266
+ publisherConnection: { connectionUri: "file:///etc/passwd" },
267
+ };
268
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
269
+ "connectionUri must use http or https (got 'file:')",
270
+ );
271
+ });
272
+
273
+ it("does not echo a credential-bearing connectionUri in the validation error", () => {
274
+ const conn: ApiConnection = {
275
+ name: "analytics",
276
+ type: "publisher",
277
+ // Userinfo present but the URI is otherwise malformed (space in host)
278
+ // so it fails to parse and must not be reflected back verbatim.
279
+ publisherConnection: {
280
+ connectionUri: "https://user:s3cret@bad host/connections/x",
281
+ },
282
+ };
283
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
284
+ "connectionUri is not a valid URL",
285
+ );
286
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow(
287
+ /s3cret/,
288
+ );
289
+ });
290
+
291
+ it("still rejects the reserved 'duckdb' name for a publisher connection", () => {
292
+ const conn: ApiConnection = {
293
+ name: "duckdb",
294
+ type: "publisher",
295
+ publisherConnection: { connectionUri: "https://x/connections/duckdb" },
296
+ };
297
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
298
+ "Connection name 'duckdb' is reserved",
299
+ );
300
+ });
301
+
302
+ it("still rejects a publisher connection with no name", () => {
303
+ const conn = {
304
+ type: "publisher",
305
+ publisherConnection: { connectionUri: "https://x/connections/y" },
306
+ } as ApiConnection;
307
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
308
+ "Invalid connection configuration. No name.",
309
+ );
310
+ });
311
+
312
+ describe("SSRF gate (PUBLISHER_ALLOW_PROXY_CONNECTIONS)", () => {
313
+ it("denies a valid publisher connection when the flag is unset (default-deny)", () => {
314
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
315
+ expect(() => assembleEnvironmentConnections([validBase])).toThrow(
316
+ "Publisher proxy connection 'analytics' is disabled in this deployment",
317
+ );
318
+ });
319
+
320
+ it("error names the env var to flip", () => {
321
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
322
+ expect(() => assembleEnvironmentConnections([validBase])).toThrow(
323
+ "Fix: set the environment variable PUBLISHER_ALLOW_PROXY_CONNECTIONS=true",
324
+ );
325
+ });
326
+
327
+ it("denies for any non-'true' value (fail-closed)", () => {
328
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "1";
329
+ expect(() => assembleEnvironmentConnections([validBase])).toThrow(
330
+ "is disabled in this deployment",
331
+ );
332
+ });
333
+
334
+ it("gate fires before the connectionUri shape check", () => {
335
+ // A publisher connection missing connectionUri still surfaces the
336
+ // disabled error first when the gate is closed — the type is refused
337
+ // outright, not validated.
338
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
339
+ const conn: ApiConnection = { name: "analytics", type: "publisher" };
340
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
341
+ "is disabled in this deployment",
342
+ );
343
+ });
344
+
345
+ it("allows a valid publisher connection when the flag is 'true'", () => {
346
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "true";
347
+ const { pojo } = assembleEnvironmentConnections([validBase]);
348
+ expect(pojo.connections["analytics"].is).toBe("publisher");
349
+ });
350
+ });
351
+ });