@malloy-publisher/server 0.0.204 → 0.0.205

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 (55) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +133 -4
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-CeTxxGex.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
  7. package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-LRqQWrFY.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BdOZDcce.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-DHHAcY5o.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-RX3QOTde.js} +121 -121
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-D2WH3D-f.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/runtime/publisher.js +318 -0
  18. package/dist/server.mjs +567 -194
  19. package/package.json +5 -4
  20. package/scripts/bake-duckdb-extensions.js +104 -0
  21. package/src/controller/watch-mode.controller.ts +176 -46
  22. package/src/errors.spec.ts +21 -0
  23. package/src/mcp/error_messages.spec.ts +35 -0
  24. package/src/mcp/error_messages.ts +14 -1
  25. package/src/mcp/handler_utils.ts +12 -0
  26. package/src/runtime/publisher.js +318 -0
  27. package/src/server.ts +479 -2
  28. package/src/service/authorize_integration.spec.ts +96 -2
  29. package/src/service/compile_authorize.spec.ts +85 -0
  30. package/src/service/environment.ts +63 -5
  31. package/src/service/environment_store.ts +142 -11
  32. package/src/service/model.ts +44 -0
  33. package/src/service/package.ts +17 -6
  34. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  35. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  36. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  37. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  38. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  39. package/tests/fixtures/html-pages-test/data.csv +3 -0
  40. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  41. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  42. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  43. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  44. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  45. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  46. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  47. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  48. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  49. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  50. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  51. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  52. package/tests/unit/duckdb/repositories.test.ts +208 -0
  53. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  54. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  55. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.204",
4
+ "version": "0.0.205",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -17,8 +17,9 @@
17
17
  "test": "bun run test:unit && bun run test:integration",
18
18
  "test:unit": "bun test --timeout 100000 src",
19
19
  "test:integration": "bun test --timeout 200000 tests --max-workers=1",
20
- "build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
21
- "build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
20
+ "build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
21
+ "build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
22
+ "bake-duckdb-extensions": "bun scripts/bake-duckdb-extensions.js",
22
23
  "start": "NODE_ENV=production bun run ./dist/server.mjs",
23
24
  "start:init": "NODE_ENV=production bun run ./dist/server.mjs --init",
24
25
  "start:dev": "NODE_ENV=development bun --watch src/server.ts",
@@ -33,6 +34,7 @@
33
34
  "@aws-sdk/client-s3": "^3.958.0",
34
35
  "@azure/identity": "^4.13.0",
35
36
  "@azure/storage-blob": "^12.26.0",
37
+ "@duckdb/node-api": "1.5.3-r.2",
36
38
  "@google-cloud/storage": "^7.16.0",
37
39
  "@malloydata/db-bigquery": "^0.0.405",
38
40
  "@malloydata/db-databricks": "^0.0.405",
@@ -58,7 +60,6 @@
58
60
  "class-transformer": "^0.5.1",
59
61
  "class-validator": "^0.14.1",
60
62
  "cors": "^2.8.5",
61
- "duckdb": "1.4.4",
62
63
  "express": "^4.21.0",
63
64
  "extract-zip": "^2.0.1",
64
65
  "globals": "^15.9.0",
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pre-download ("bake") the DuckDB extensions the server loads at runtime so
5
+ * they are on disk before any query runs.
6
+ *
7
+ * DuckDB fetches extensions on first use from extensions.duckdb.org and caches
8
+ * them under ~/.duckdb/extensions/v<version>/<platform>/. Some hosts cannot
9
+ * reach that CDN (notably the macOS GitHub Actions fleet), so an on-demand
10
+ * fetch at query time fails and takes the query (or a test) down. Baking
11
+ * populates the cache via the same @duckdb/node-api engine the runtime uses,
12
+ * so later INSTALL/LOAD calls are served from disk.
13
+ *
14
+ * Runs as the last step of this package's build (`build` / `build:server-only`),
15
+ * so local dev, CI, and the Docker builder all bake the same set. The Docker
16
+ * final stage copies the baked ~/.duckdb/extensions from the builder rather
17
+ * than re-baking, keeping a single bake mechanism.
18
+ *
19
+ * Each extension is baked independently: a failure (offline build, transient
20
+ * CDN error) is logged and skipped, never aborting the others or the build.
21
+ */
22
+
23
+ import { DuckDBInstance } from "@duckdb/node-api";
24
+
25
+ // Every extension the connection layer (packages/server/src/service/connection.ts)
26
+ // and storage manager INSTALL/LOAD at runtime, for cloud attach, the per-package
27
+ // sandbox, federated-database attach, and the materialization catalog. Keep this
28
+ // in sync with the install sites in those files.
29
+ //
30
+ // `community: true` mirrors the runtime's `FORCE INSTALL '<name>' FROM community`
31
+ // (bigquery, snowflake); the rest are core extensions installed by name.
32
+ // `registered` is the name the extension reports in duckdb_extensions() when it
33
+ // differs from the INSTALL name (only postgres -> postgres_scanner).
34
+ const EXTENSIONS = [
35
+ { name: "httpfs", community: false }, // cloud storage (gcs/s3/azure) + per-package sandbox
36
+ { name: "aws", community: false }, // s3 credential chain
37
+ { name: "azure", community: false }, // azure blob storage
38
+ { name: "postgres", community: false, registered: "postgres_scanner" }, // postgres attach + ducklake postgres catalog
39
+ { name: "ducklake", community: false }, // materialization catalog
40
+ { name: "bigquery", community: true },
41
+ { name: "snowflake", community: true },
42
+ ];
43
+
44
+ async function main() {
45
+ const instance = await DuckDBInstance.create(":memory:");
46
+ const connection = await instance.connect();
47
+
48
+ const results = [];
49
+ for (const { name, community, registered } of EXTENSIONS) {
50
+ try {
51
+ const install = community
52
+ ? `FORCE INSTALL '${name}' FROM community;`
53
+ : `INSTALL ${name};`;
54
+ await connection.run(`${install} LOAD ${name};`);
55
+
56
+ // Verify the extension actually reports as loaded, rather than trusting
57
+ // that INSTALL/LOAD returned without error.
58
+ const reader = await connection.runAndReadAll(
59
+ `SELECT loaded, installed FROM duckdb_extensions() WHERE extension_name = '${registered ?? name}';`,
60
+ );
61
+ const row = reader.getRowObjectsJS()[0];
62
+ const loaded = row?.loaded === true;
63
+ const installed = row?.installed === true;
64
+
65
+ results.push({ name, installed, loaded });
66
+ if (loaded) {
67
+ console.log(`baked DuckDB extension: ${name} (loaded)`);
68
+ } else {
69
+ console.warn(
70
+ `DuckDB extension "${name}" installed=${installed} but not loaded`,
71
+ );
72
+ }
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ results.push({
76
+ name,
77
+ installed: false,
78
+ loaded: false,
79
+ error: message,
80
+ });
81
+ console.warn(`skipped DuckDB extension "${name}": ${message}`);
82
+ }
83
+ }
84
+
85
+ const ok = results.filter((r) => r.loaded).map((r) => r.name);
86
+ const missing = results.filter((r) => !r.loaded).map((r) => r.name);
87
+ console.log(
88
+ `DuckDB extensions baked: ${ok.length}/${results.length} loaded` +
89
+ (ok.length ? ` [${ok.join(", ")}]` : "") +
90
+ (missing.length ? `; not loaded [${missing.join(", ")}]` : ""),
91
+ );
92
+
93
+ connection.closeSync();
94
+ instance.closeSync();
95
+ }
96
+
97
+ main().catch((err) => {
98
+ // Never fail the build on a bake error -- the extensions are an optimization,
99
+ // not a correctness requirement (the runtime can still fetch on demand where
100
+ // the network allows).
101
+ console.warn(
102
+ `DuckDB extension bake skipped: ${err instanceof Error ? err.message : String(err)}`,
103
+ );
104
+ });
@@ -1,23 +1,185 @@
1
1
  import chokidar, { FSWatcher } from "chokidar";
2
+ import { EventEmitter } from "events";
2
3
  import { RequestHandler } from "express";
4
+ import path from "path";
3
5
  import { components } from "../api";
4
6
  import { internalErrorToHttpError } from "../errors";
5
7
  import { logger } from "../logger";
6
- import { assertSafePackageName, safeJoinUnderRoot } from "../path_safety";
8
+ import { assertSafePackageName } from "../path_safety";
7
9
  import { EnvironmentStore } from "../service/environment_store";
8
10
 
9
11
  type StartWatchReq = components["schemas"]["StartWatchRequest"];
10
12
  type WatchStatusRes = components["schemas"]["WatchStatus"];
11
13
  type Handler<Req = object, Res = void> = RequestHandler<object, Res, Req>;
12
14
 
15
+ // File extensions that should trigger a live-reload event but NOT a Malloy
16
+ // environment reload (these are package assets, not semantic-model sources).
17
+ const ASSET_EXTS = new Set([
18
+ ".html",
19
+ ".htm",
20
+ ".css",
21
+ ".js",
22
+ ".mjs",
23
+ ".json",
24
+ ".png",
25
+ ".jpg",
26
+ ".jpeg",
27
+ ".gif",
28
+ ".svg",
29
+ ".webp",
30
+ ".ico",
31
+ ".woff",
32
+ ".woff2",
33
+ ]);
34
+ const MODEL_EXTS = new Set([".malloy", ".malloynb", ".md"]);
35
+
13
36
  export class WatchModeController {
14
37
  watchingPath: string | null;
15
38
  watchingEnvironmentName: string | null;
16
- watcher: FSWatcher;
39
+ watcher: FSWatcher | null = null;
40
+ // Serializes ensureWatching so concurrent callers can't both close and
41
+ // recreate the watcher and orphan one (each chokidar watcher holds OS
42
+ // handles). See ensureWatching.
43
+ private setupChain: Promise<void> | null = null;
44
+ /**
45
+ * Per-package change bus. Event name is `<environmentName>/<packageName>`.
46
+ * Used by the SSE live-reload endpoint to push refreshes to embedded HTML
47
+ * dashboards. Each emit carries `{ path, kind }` for diagnostics; the SSE
48
+ * handler currently ignores the payload and just sends "changed".
49
+ */
50
+ public events = new EventEmitter();
17
51
 
18
52
  constructor(private environmentStore: EnvironmentStore) {
19
53
  this.watchingPath = null;
20
54
  this.watchingEnvironmentName = null;
55
+ // Live-reload subscribers can be many (one per open browser tab);
56
+ // bump the default cap so Node doesn't warn under normal use.
57
+ this.events.setMaxListeners(100);
58
+ }
59
+
60
+ /**
61
+ * Idempotent: starts watching `environmentName` if not already.
62
+ *
63
+ * Throws if the environment can't be resolved — never falls back to a
64
+ * `<serverRoot>/<envName>` path that an attacker could control via
65
+ * URL-encoded path traversal. Callers (the SSE handler in particular)
66
+ * must validate the env name before calling this.
67
+ */
68
+ public async ensureWatching(environmentName: string): Promise<void> {
69
+ if (this.watchingEnvironmentName === environmentName && this.watcher) {
70
+ return;
71
+ }
72
+ // Serialize setup behind any in-flight one, then re-check the guard, so
73
+ // concurrent callers don't both close + recreate the watcher (which would
74
+ // orphan a chokidar instance and leak its OS file handles).
75
+ const run = (this.setupChain ?? Promise.resolve())
76
+ .catch(() => {})
77
+ .then(async () => {
78
+ if (
79
+ this.watchingEnvironmentName === environmentName &&
80
+ this.watcher
81
+ ) {
82
+ return;
83
+ }
84
+ const env = await this.environmentStore.getEnvironment(
85
+ environmentName,
86
+ false,
87
+ );
88
+ const watchPath = env.getEnvironmentPath();
89
+ if (this.watcher) {
90
+ await this.watcher.close();
91
+ this.watcher = null;
92
+ }
93
+ this.startWatcher(environmentName, watchPath);
94
+ });
95
+ this.setupChain = run;
96
+ await run;
97
+ }
98
+
99
+ /** Whether watch mode is currently running for the given env. */
100
+ public isWatching(environmentName: string): boolean {
101
+ return !!this.watcher && this.watchingEnvironmentName === environmentName;
102
+ }
103
+
104
+ private startWatcher(watchName: string, watchPath: string) {
105
+ this.watchingEnvironmentName = watchName;
106
+ this.watchingPath = watchPath;
107
+ this.watcher = chokidar.watch(this.watchingPath, {
108
+ ignored: (filePath, stats) => {
109
+ if (!stats?.isFile()) return false;
110
+ const ext = path.extname(filePath).toLowerCase();
111
+ return !MODEL_EXTS.has(ext) && !ASSET_EXTS.has(ext);
112
+ },
113
+ ignoreInitial: true,
114
+ });
115
+ // Reload just the package a changed file belongs to: this recompiles
116
+ // that package's models from disk (Package.create) and replaces the
117
+ // cached entry, so the next query sees the edit. Scoped to watch mode by
118
+ // construction — it only runs from this watcher, which is started only
119
+ // when watch mode is active.
120
+ //
121
+ // The previous env-level reload (getEnvironment(reload) + addEnvironment)
122
+ // was a no-op for compilation: addEnvironment on an existing env calls
123
+ // Environment.update(), which refreshes metadata/connections only and
124
+ // never touches this.packages, so edits never took effect.
125
+ // Returns true if the package recompiled cleanly. A transient compile
126
+ // error (e.g. a half-typed model saved mid-edit) returns false so we can
127
+ // avoid bouncing open browser tabs into a compile-error/404 — see onEvent.
128
+ const reloadPackage = async (pkgName: string): Promise<boolean> => {
129
+ try {
130
+ const environment = await this.environmentStore.getEnvironment(
131
+ watchName,
132
+ false,
133
+ );
134
+ await environment.getPackage(pkgName, true);
135
+ logger.info(
136
+ `Watch: recompiled package "${pkgName}" in environment "${watchName}"`,
137
+ );
138
+ return true;
139
+ } catch (error) {
140
+ logger.error(
141
+ `Watch: failed to recompile package "${pkgName}" in environment "${watchName}"`,
142
+ { error },
143
+ );
144
+ return false;
145
+ }
146
+ };
147
+ const onEvent =
148
+ (kind: "add" | "change" | "unlink") => async (filePath: string) => {
149
+ logger.info(`Watch ${kind}: ${filePath}; environment=${watchName}`);
150
+ // Resolve which package the file belongs to. Packages are
151
+ // subdirectories of the environment (env/<pkg>/...), so a file must
152
+ // be nested at least one level to belong to a package; files at the
153
+ // environment root belong to no package and are ignored.
154
+ const rel = path.relative(this.watchingPath ?? "", filePath);
155
+ const segments = rel.split(path.sep);
156
+ const pkgName =
157
+ segments.length > 1 &&
158
+ segments[0] &&
159
+ !segments[0].startsWith("..")
160
+ ? segments[0]
161
+ : null;
162
+ if (!pkgName) return;
163
+
164
+ // Recompile Malloy state only for model files. Asset edits
165
+ // (HTML/CSS/JS) skip recompile — they just need the live-reload
166
+ // fanout below. For model edits, only signal a reload once the
167
+ // recompile succeeds: a transient syntax error shouldn't bounce
168
+ // open pages into a compile error or 404.
169
+ const ext = path.extname(filePath).toLowerCase();
170
+ if (MODEL_EXTS.has(ext)) {
171
+ const recompiled = await reloadPackage(pkgName);
172
+ if (!recompiled) return;
173
+ }
174
+ // Fan out to SSE clients embedded in the affected package.
175
+ this.events.emit(`${watchName}/${pkgName}`, {
176
+ path: filePath,
177
+ kind,
178
+ });
179
+ };
180
+ this.watcher.on("add", onEvent("add"));
181
+ this.watcher.on("change", onEvent("change"));
182
+ this.watcher.on("unlink", onEvent("unlink"));
21
183
  }
22
184
 
23
185
  public getWatchStatus: Handler<void, WatchStatusRes> = async (_req, res) => {
@@ -45,9 +207,6 @@ export class WatchModeController {
45
207
  await EnvironmentStore.reloadEnvironmentManifest(
46
208
  this.environmentStore.serverRootPath,
47
209
  );
48
- this.watchingEnvironmentName = watchName || null;
49
-
50
- // Find the environment in the manifest
51
210
  const environment = environmentManifest.environments.find(
52
211
  (e) => e.name === watchName,
53
212
  );
@@ -61,51 +220,22 @@ export class WatchModeController {
61
220
  });
62
221
  return;
63
222
  }
64
-
65
- this.watchingPath = safeJoinUnderRoot(
66
- this.environmentStore.serverRootPath,
67
- watchName,
68
- );
69
- this.watcher = chokidar.watch(this.watchingPath, {
70
- ignored: (path, stats) =>
71
- !!stats?.isFile() &&
72
- !path.endsWith(".malloy") &&
73
- !path.endsWith(".md"),
74
- ignoreInitial: true,
75
- });
76
- const reloadEnvironment = async () => {
77
- // Overwrite the environment with its existing metadata to trigger a re-read
78
- const environment = await this.environmentStore.getEnvironment(
79
- watchName,
80
- true,
81
- );
82
- await this.environmentStore.addEnvironment(environment.metadata);
83
- logger.info(`Reloaded environment ${watchName}`);
84
- };
85
-
86
- this.watcher.on("add", async (path) => {
87
- logger.info(
88
- `Detected new file ${path}, reloading environment ${watchName}`,
89
- );
90
- await reloadEnvironment();
91
- });
92
- this.watcher.on("unlink", async (path) => {
93
- logger.info(
94
- `Detected deletion of ${path}, reloading environment ${watchName}`,
95
- );
96
- await reloadEnvironment();
97
- });
98
- this.watcher.on("change", async (path) => {
99
- logger.info(
100
- `Detected change on ${path}, reloading environment ${watchName}`,
101
- );
102
- await reloadEnvironment();
103
- });
223
+ try {
224
+ await this.ensureWatching(watchName);
225
+ } catch (error) {
226
+ logger.error(error);
227
+ const { status } = internalErrorToHttpError(error as Error);
228
+ res.status(status).json({ error: (error as Error).message });
229
+ return;
230
+ }
104
231
  res.json();
105
232
  };
106
233
 
107
234
  public stopWatchMode: Handler = async (_req, res) => {
108
- this.watcher.close();
235
+ if (this.watcher) {
236
+ await this.watcher.close();
237
+ this.watcher = null;
238
+ }
109
239
  this.watchingPath = null;
110
240
  this.watchingEnvironmentName = null;
111
241
  res.json();
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ AccessDeniedError,
3
4
  BadRequestError,
4
5
  ConnectionAuthError,
5
6
  ConnectionError,
6
7
  internalErrorToHttpError,
8
+ ModelCompilationError,
7
9
  PayloadTooLargeError,
8
10
  QueryTimeoutError,
9
11
  ServiceUnavailableError,
@@ -29,6 +31,25 @@ describe("internalErrorToHttpError", () => {
29
31
  expect(json).toEqual({ code: 400, message: "bad input" });
30
32
  });
31
33
 
34
+ it("maps AccessDeniedError to 403 (authorize gate)", () => {
35
+ const { status, json } = internalErrorToHttpError(
36
+ new AccessDeniedError('Access denied for source "gated".'),
37
+ );
38
+ expect(status).toBe(403);
39
+ expect(json).toEqual({
40
+ code: 403,
41
+ message: 'Access denied for source "gated".',
42
+ });
43
+ });
44
+
45
+ it("maps ModelCompilationError to 424", () => {
46
+ const { status, json } = internalErrorToHttpError(
47
+ new ModelCompilationError({ message: "compile failed" }),
48
+ );
49
+ expect(status).toBe(424);
50
+ expect(json).toEqual({ code: 424, message: "compile failed" });
51
+ });
52
+
32
53
  it("maps ConnectionError to 502 (distinct from auth, still retryable)", () => {
33
54
  const { status, json } = internalErrorToHttpError(
34
55
  new ConnectionError("upstream broken"),
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { AccessDeniedError } from "../errors";
3
+ import { getMalloyErrorDetails } from "./error_messages";
4
+
5
+ describe("getMalloyErrorDetails — access-denied branch", () => {
6
+ it("recognizes an authorize denial and gives access-relevant (not syntax) advice", () => {
7
+ const details = getMalloyErrorDetails(
8
+ "executeQuery",
9
+ "env/pkg/model.malloy",
10
+ new AccessDeniedError('Access denied for source "gated".'),
11
+ );
12
+
13
+ // Message carries the source name, never the gate expression.
14
+ expect(details.message).toContain('Access denied for source "gated".');
15
+
16
+ // The suggestion is about satisfying access (givens/role), and the
17
+ // generic Malloy-syntax suggestions are replaced, not appended.
18
+ expect(details.suggestions).toHaveLength(1);
19
+ expect(details.suggestions[0]).toMatch(/given|authorize|restricted/i);
20
+ // Not the generic "check the database connection / consult the language
21
+ // docs" advice that an unrecognized error would yield.
22
+ expect(details.suggestions.join(" ")).not.toMatch(
23
+ /database connection configuration|language documentation/i,
24
+ );
25
+ });
26
+
27
+ it("still falls back to generic suggestions for an unrecognized error", () => {
28
+ const details = getMalloyErrorDetails(
29
+ "executeQuery",
30
+ "env/pkg/model.malloy",
31
+ new Error("something unexpected"),
32
+ );
33
+ expect(details.suggestions.length).toBeGreaterThan(1);
34
+ });
35
+ });
@@ -126,8 +126,21 @@ export function getMalloyErrorDetails(
126
126
  const invalidRequestMatch = error.message.match(
127
127
  /Invalid query request\\. Query OR queryName must be defined/i,
128
128
  );
129
+ // `#(authorize)` gate denial. The message names only the source (gate
130
+ // logic is never leaked), so the suggestion is about satisfying access,
131
+ // not fixing syntax — otherwise this falls through to generic Malloy
132
+ // syntax advice that misleads the caller.
133
+ const accessDeniedMatch = error.message.match(
134
+ /Access denied for source "([^"]+)"/i,
135
+ );
129
136
 
130
- if (viewNotFoundMatch) {
137
+ if (accessDeniedMatch) {
138
+ refined = true;
139
+ const [, sourceName] = accessDeniedMatch;
140
+ suggestions = [
141
+ `Suggestion: Access to source '${sourceName}' is restricted by an #(authorize) gate. Supply the givens its authorize expression requires (e.g. a role/region given) and retry. This is an authorization denial, not a syntax error.`,
142
+ ];
143
+ } else if (viewNotFoundMatch) {
131
144
  refined = true;
132
145
  const [, viewName, sourceName] = viewNotFoundMatch;
133
146
  suggestions.unshift(
@@ -3,6 +3,7 @@ import type { ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp";
3
3
  import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { EnvironmentStore } from "../service/environment_store";
5
5
  import {
6
+ AccessDeniedError,
6
7
  PackageNotFoundError,
7
8
  ModelNotFoundError,
8
9
  ModelCompilationError,
@@ -167,6 +168,17 @@ export async function getModelForQuery(
167
168
  `${environmentName}/${packageName}/${modelPath}`,
168
169
  error,
169
170
  );
171
+ } else if (error instanceof AccessDeniedError) {
172
+ // An #(authorize) denial during model setup. Funnel through
173
+ // getMalloyErrorDetails (which recognizes the access-denied message and
174
+ // gives supply-the-givens guidance) so a 403 never surfaces as an
175
+ // opaque internal error. Defensive: the gate fires during query
176
+ // execution today, not setup, but this keeps every error class homed.
177
+ errorDetails = getMalloyErrorDetails(
178
+ "executeQuery (load model)",
179
+ `${environmentName}/${packageName}/${modelPath}`,
180
+ error,
181
+ );
170
182
  } else if (error instanceof ServiceUnavailableError) {
171
183
  // Back-pressure: don't dress this up as a 404/500. Surface the
172
184
  // server's own message so the MCP caller knows to retry.