@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
@@ -336,6 +336,60 @@ function validateConnectionShape(connection: ApiConnection): void {
336
336
  }
337
337
  break;
338
338
  }
339
+ case "publisher": {
340
+ // SSRF gate (default-deny / fail-closed). A `publisher` connection
341
+ // makes THIS server issue outbound HTTP to a tenant-controlled
342
+ // `connectionUri` (both the query path in db-publisher's
343
+ // PublisherConnection and the introspection path in db_utils). That is
344
+ // the intended behavior for local `--watch-env` authoring, but in a
345
+ // hosted multi-tenant deployment (e.g. Credible running this server) it
346
+ // is an SSRF surface. Require an explicit opt-in so the type is refused
347
+ // unless the operator deliberately enabled it. This is the single
348
+ // choke point — every connection passes validateConnectionShape before
349
+ // it can be assembled, queried, or introspected — so denying here shuts
350
+ // off all three at once.
351
+ if (process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS !== "true") {
352
+ throw new Error(
353
+ `Publisher proxy connection '${connection.name}' is disabled in this deployment. ` +
354
+ `'publisher' connections make the server issue outbound requests to a configured connectionUri, ` +
355
+ `which is only appropriate for local --watch-env authoring. ` +
356
+ `Fix: set the environment variable PUBLISHER_ALLOW_PROXY_CONNECTIONS=true to enable them.`,
357
+ );
358
+ }
359
+ const publisher = connection.publisherConnection;
360
+ if (!publisher?.connectionUri) {
361
+ throw new Error(
362
+ `Invalid publisher connection '${connection.name}': missing connectionUri. ` +
363
+ `Fix: { "name": "${connection.name}", "type": "publisher", ` +
364
+ `"publisherConnection": { "connectionUri": "https://…/connections/${connection.name}", "accessToken": "<jwt>" } }`,
365
+ );
366
+ }
367
+ // Reject a malformed connectionUri here, at config-load, rather than
368
+ // letting it fail deep in the request path — where the thrown error can
369
+ // echo the raw value back, leaking any credentials embedded in it
370
+ // (`redactUrlCredentials` returns the URI unchanged when it can't parse
371
+ // it). Never include the raw connectionUri in these messages; the
372
+ // scheme is safe to name.
373
+ let parsedUri: URL;
374
+ try {
375
+ parsedUri = new URL(publisher.connectionUri);
376
+ } catch {
377
+ throw new Error(
378
+ `Invalid publisher connection '${connection.name}': connectionUri is not a valid URL. ` +
379
+ `Fix: set connectionUri to an absolute https URL like "https://…/connections/${connection.name}".`,
380
+ );
381
+ }
382
+ if (
383
+ parsedUri.protocol !== "http:" &&
384
+ parsedUri.protocol !== "https:"
385
+ ) {
386
+ throw new Error(
387
+ `Invalid publisher connection '${connection.name}': connectionUri must use http or https (got '${parsedUri.protocol}'). ` +
388
+ `Fix: set connectionUri to an absolute https URL like "https://…/connections/${connection.name}".`,
389
+ );
390
+ }
391
+ break;
392
+ }
339
393
  }
340
394
  }
341
395
 
@@ -546,6 +600,22 @@ export function assembleEnvironmentConnections(
546
600
  break;
547
601
  }
548
602
 
603
+ case "publisher": {
604
+ // connectionUri presence is validated by validateConnectionShape
605
+ // above. The proxied dataplane owns auth and read-only enforcement;
606
+ // PublisherConnection itself does not reject writes. The real
607
+ // dialect is the remote connection's and is resolved at runtime by
608
+ // the live connection, so getStaticConnectionAttributes returns
609
+ // undefined for publisher (falls through to its default).
610
+ const publisher = connection.publisherConnection!;
611
+ pojo.connections[connection.name] = {
612
+ is: "publisher",
613
+ connectionUri: publisher.connectionUri,
614
+ accessToken: publisher.accessToken,
615
+ };
616
+ break;
617
+ }
618
+
549
619
  default: {
550
620
  throw new Error(`Unsupported connection type: ${connection.type}`);
551
621
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, mock } from "bun:test";
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
2
 
3
3
  // Stub the missing optional dependency so db_utils.ts can be imported
4
4
  mock.module("@azure/identity", () => ({
@@ -873,3 +873,161 @@ describe("extractErrorDataFromError", () => {
873
873
  expect(result.task).toEqual({ id: 1 });
874
874
  });
875
875
  });
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // publisher proxy introspection
879
+ //
880
+ // The remote dataplane is the oracle for introspection correctness and is not
881
+ // simulated here. These tests cover only this server's own logic: the request
882
+ // URL it builds, the auth header, the bare-name table filter, and how it
883
+ // surfaces a non-ok / unreachable dataplane (credentials redacted from errors).
884
+ // ---------------------------------------------------------------------------
885
+ describe("publisher proxy introspection", () => {
886
+ const realFetch = globalThis.fetch;
887
+ let lastRequest:
888
+ | { url: string; headers: Record<string, string> }
889
+ | undefined;
890
+
891
+ function stubFetch(
892
+ payload: unknown,
893
+ init: { ok?: boolean; status?: number; body?: string } = {},
894
+ ) {
895
+ lastRequest = undefined;
896
+ const ok = init.ok ?? true;
897
+ globalThis.fetch = (async (url: unknown, opts?: RequestInit) => {
898
+ lastRequest = {
899
+ url: String(url),
900
+ headers: (opts?.headers as Record<string, string>) ?? {},
901
+ };
902
+ return {
903
+ ok,
904
+ status: init.status ?? (ok ? 200 : 500),
905
+ json: async () => payload,
906
+ text: async () => init.body ?? "",
907
+ } as Response;
908
+ }) as typeof fetch;
909
+ }
910
+
911
+ afterEach(() => {
912
+ globalThis.fetch = realFetch;
913
+ });
914
+
915
+ const conn: ApiConnection = {
916
+ name: "analytics",
917
+ type: "publisher",
918
+ publisherConnection: {
919
+ connectionUri:
920
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics",
921
+ accessToken: "jwt-token",
922
+ },
923
+ };
924
+ // No malloyConnection is used for the publisher path.
925
+ const noConn = undefined as unknown as Connection;
926
+
927
+ it("forwards GET /schemas to the connectionUri with a bearer token", async () => {
928
+ const schemas = [{ name: "main", isHidden: false, isDefault: true }];
929
+ stubFetch(schemas);
930
+ const result = await getSchemasForConnection(conn, noConn);
931
+
932
+ expect(lastRequest?.url).toBe(
933
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics/schemas",
934
+ );
935
+ expect(lastRequest?.headers["Authorization"]).toBe("Bearer jwt-token");
936
+ expect(result).toEqual(schemas);
937
+ });
938
+
939
+ it("omits the Authorization header when no accessToken is configured", async () => {
940
+ const tokenless: ApiConnection = {
941
+ name: "analytics",
942
+ type: "publisher",
943
+ publisherConnection: {
944
+ connectionUri:
945
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics",
946
+ },
947
+ };
948
+ stubFetch([]);
949
+ await getSchemasForConnection(tokenless, noConn);
950
+ expect(lastRequest?.headers["Authorization"]).toBeUndefined();
951
+ });
952
+
953
+ it("forwards GET /schemas/<schema>/tables and returns all tables when unfiltered", async () => {
954
+ const tables = [
955
+ { resource: "main.orders" },
956
+ { resource: "main.customers" },
957
+ ];
958
+ stubFetch(tables);
959
+ const result = await listTablesForSchema(conn, "main", noConn);
960
+
961
+ expect(lastRequest?.url).toBe(
962
+ "https://org.data.example.com/api/v0/environments/proj/connections/analytics/schemas/main/tables",
963
+ );
964
+ expect(result).toEqual(tables);
965
+ });
966
+
967
+ it("URL-encodes the schema name in the tables path", async () => {
968
+ stubFetch([]);
969
+ await listTablesForSchema(conn, "weird schema/name", noConn);
970
+ expect(lastRequest?.url).toContain(
971
+ "/schemas/weird%20schema%2Fname/tables",
972
+ );
973
+ });
974
+
975
+ it("filters by bare table name across schema- and catalog-qualified resources", async () => {
976
+ const tables = [
977
+ { resource: "main.orders" }, // "<schema>.<table>"
978
+ { resource: "cat.main.customers" }, // "<catalog>.<schema>.<table>"
979
+ { resource: "main.events" },
980
+ ];
981
+ stubFetch(tables);
982
+ const result = await listTablesForSchema(conn, "main", noConn, [
983
+ "orders",
984
+ "customers",
985
+ ]);
986
+ expect(result.map((t) => t.resource)).toEqual([
987
+ "main.orders",
988
+ "cat.main.customers",
989
+ ]);
990
+ });
991
+
992
+ it("returns an empty list when the filter matches nothing (not all rows)", async () => {
993
+ stubFetch([{ resource: "main.orders" }]);
994
+ const result = await listTablesForSchema(conn, "main", noConn, [
995
+ "nonexistent",
996
+ ]);
997
+ expect(result).toEqual([]);
998
+ });
999
+
1000
+ it("throws an actionable error on a non-ok dataplane response", async () => {
1001
+ stubFetch(null, { ok: false, status: 403, body: "forbidden" });
1002
+ await expect(getSchemasForConnection(conn, noConn)).rejects.toThrow(
1003
+ "failed (403): forbidden",
1004
+ );
1005
+ });
1006
+
1007
+ it("redacts URL-embedded credentials from error messages", async () => {
1008
+ const withCreds: ApiConnection = {
1009
+ name: "analytics",
1010
+ type: "publisher",
1011
+ publisherConnection: {
1012
+ connectionUri:
1013
+ "https://user:secret@org.data.example.com/api/v0/environments/proj/connections/analytics",
1014
+ },
1015
+ };
1016
+ stubFetch(null, { ok: false, status: 500, body: "boom" });
1017
+ await expect(getSchemasForConnection(withCreds, noConn)).rejects.toThrow(
1018
+ /Publisher dataplane request to https:\/\/org\.data\.example\.com.*failed \(500\)/,
1019
+ );
1020
+ await expect(
1021
+ getSchemasForConnection(withCreds, noConn),
1022
+ ).rejects.not.toThrow(/secret/);
1023
+ });
1024
+
1025
+ it("surfaces a network-level failure with the connection name and no token", async () => {
1026
+ globalThis.fetch = (async () => {
1027
+ throw new Error("ECONNREFUSED");
1028
+ }) as unknown as typeof fetch;
1029
+ await expect(getSchemasForConnection(conn, noConn)).rejects.toThrow(
1030
+ 'failed for connection "analytics": ECONNREFUSED',
1031
+ );
1032
+ });
1033
+ });
@@ -594,6 +594,133 @@ async function getSchemasForDuckLake(
594
594
  }
595
595
  }
596
596
 
597
+ /** Abort introspection requests that hang instead of holding the slot open. */
598
+ const PUBLISHER_INTROSPECTION_TIMEOUT_MS = 60_000;
599
+
600
+ /**
601
+ * Strip any userinfo (`user:pass@`) from a URL before it reaches a log or error
602
+ * message. `connectionUri` comes from a config file and may legitimately carry
603
+ * embedded credentials; the bearer token always travels in the header, never
604
+ * the URL, but this keeps any URL-embedded secret out of thrown messages too.
605
+ */
606
+ function redactUrlCredentials(url: string): string {
607
+ try {
608
+ const parsed = new URL(url);
609
+ parsed.username = "";
610
+ parsed.password = "";
611
+ return parsed.toString();
612
+ } catch {
613
+ // Non-absolute / unparseable: nothing to redact, return as-is.
614
+ return url;
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Publisher proxy connections introspect against the remote dataplane, which
620
+ * exposes the same connection API as this server. Schema/table discovery is a
621
+ * GET passthrough — the remote owns the dialect-specific introspection and
622
+ * returns the same Schema/Table shapes, so we forward the response verbatim
623
+ * (no Malloy connection involved).
624
+ *
625
+ * URL contract: the request is the configured `connectionUri` with the
626
+ * introspection `pathSuffix` appended verbatim. `connectionUri` must point at
627
+ * the connection resource itself (`.../connections/<name>`), so this path and
628
+ * the data path (db-publisher's `PublisherConnection`) resolve against the same
629
+ * remote connection. Note the two halves derive their URL differently: the
630
+ * db-publisher client re-parses `connectionUri` and rebuilds it through its
631
+ * generated client, while this introspection path appends to it verbatim — the
632
+ * remote must serve both forms for a given `connectionUri`. Keep them pointed at
633
+ * the same connection resource so they stay in agreement.
634
+ */
635
+ async function fetchFromPublisherDataplane<T>(
636
+ connection: ApiConnection,
637
+ pathSuffix: string,
638
+ ): Promise<T> {
639
+ const publisher = connection.publisherConnection;
640
+ // Type-narrowing guard only — the user-facing validation (with the
641
+ // actionable `Fix:` message) lives in validateConnectionShape, which every
642
+ // connection passes before it can be assembled and introspected here.
643
+ if (!publisher?.connectionUri) {
644
+ throw new Error(
645
+ `Publisher connection "${connection.name}" is missing connectionUri`,
646
+ );
647
+ }
648
+ const url = `${publisher.connectionUri.replace(/\/+$/, "")}${pathSuffix}`;
649
+ const headers: Record<string, string> = {};
650
+ if (publisher.accessToken) {
651
+ headers["Authorization"] = `Bearer ${publisher.accessToken}`;
652
+ }
653
+
654
+ let response: Response;
655
+ try {
656
+ response = await fetch(url, {
657
+ headers,
658
+ signal: AbortSignal.timeout(PUBLISHER_INTROSPECTION_TIMEOUT_MS),
659
+ });
660
+ } catch (error) {
661
+ // Network-level failure (DNS, connection refused, timeout) — no HTTP
662
+ // status. Surface it with the connection name, like the other
663
+ // introspectors, and never echo the token-bearing header.
664
+ const reason =
665
+ (error as Error)?.name === "TimeoutError"
666
+ ? `timed out after ${PUBLISHER_INTROSPECTION_TIMEOUT_MS}ms`
667
+ : (error as Error).message;
668
+ throw new Error(
669
+ `Publisher dataplane request to ${redactUrlCredentials(url)} failed ` +
670
+ `for connection "${connection.name}": ${reason}`,
671
+ );
672
+ }
673
+
674
+ if (!response.ok) {
675
+ const body = await response.text().catch(() => "");
676
+ throw new Error(
677
+ `Publisher dataplane request to ${redactUrlCredentials(url)} failed ` +
678
+ `(${response.status}): ${body.slice(0, 200)}`,
679
+ );
680
+ }
681
+ return (await response.json()) as T;
682
+ }
683
+
684
+ async function getSchemasForPublisher(
685
+ connection: ApiConnection,
686
+ ): Promise<ApiSchema[]> {
687
+ return fetchFromPublisherDataplane<ApiSchema[]>(connection, "/schemas");
688
+ }
689
+
690
+ async function listTablesForPublisher(
691
+ connection: ApiConnection,
692
+ schemaName: string,
693
+ tableNames?: string[],
694
+ ): Promise<ApiTable[]> {
695
+ const tables = await fetchFromPublisherDataplane<ApiTable[]>(
696
+ connection,
697
+ `/schemas/${encodeURIComponent(schemaName)}/tables`,
698
+ );
699
+ if (!tableNames) {
700
+ return tables;
701
+ }
702
+ // `tableNames` is a bare-table-name filter (see listTablesForBigQuery). The
703
+ // remote's `resource` is dotted and its qualification depends on the remote
704
+ // dialect ("<schema>.<table>" or "<catalog>.<schema>.<table>"), so match the
705
+ // bare name (last segment) as well as the schema-stripped and full forms. An
706
+ // unrecognized shape still matches on the full resource rather than being
707
+ // silently dropped.
708
+ const allowed = new Set(tableNames);
709
+ const prefix = `${schemaName}.`;
710
+ return tables.filter((table) => {
711
+ const resource = table.resource ?? "";
712
+ const schemaStripped = resource.startsWith(prefix)
713
+ ? resource.slice(prefix.length)
714
+ : resource;
715
+ const bareName = resource.slice(resource.lastIndexOf(".") + 1);
716
+ return (
717
+ allowed.has(bareName) ||
718
+ allowed.has(schemaStripped) ||
719
+ allowed.has(resource)
720
+ );
721
+ });
722
+ }
723
+
597
724
  export async function getSchemasForConnection(
598
725
  connection: ApiConnection,
599
726
  malloyConnection: Connection,
@@ -617,6 +744,8 @@ export async function getSchemasForConnection(
617
744
  return getSchemasForMotherDuck(connection, malloyConnection);
618
745
  case "ducklake":
619
746
  return getSchemasForDuckLake(connection, malloyConnection);
747
+ case "publisher":
748
+ return getSchemasForPublisher(connection);
620
749
  default:
621
750
  throw new Error(`Unsupported connection type: ${connection.type}`);
622
751
  }
@@ -929,6 +1058,8 @@ export async function listTablesForSchema(
929
1058
  malloyConnection,
930
1059
  tableNames,
931
1060
  );
1061
+ case "publisher":
1062
+ return listTablesForPublisher(connection, schemaName, tableNames);
932
1063
  default:
933
1064
  throw new Error(`Unsupported connection type: ${connection.type}`);
934
1065
  }