@malloy-publisher/server 0.0.203 → 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 (84) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +146 -0
  3. package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-bYOWcgDP.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-LRqQWrFY.js +1 -0
  9. package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
  13. package/dist/app/assets/index-DHHAcY5o.js +1812 -0
  14. package/dist/app/assets/index-RX3QOTde.js +455 -0
  15. package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
  16. package/dist/app/index.html +1 -1
  17. package/dist/package_load_worker.mjs +392 -67
  18. package/dist/runtime/publisher.js +318 -0
  19. package/dist/server.mjs +982 -346
  20. package/package.json +15 -14
  21. package/scripts/bake-duckdb-extensions.js +104 -0
  22. package/src/controller/watch-mode.controller.ts +176 -46
  23. package/src/ducklake_version.spec.ts +43 -0
  24. package/src/ducklake_version.ts +26 -0
  25. package/src/errors.spec.ts +21 -0
  26. package/src/errors.ts +18 -1
  27. package/src/mcp/error_messages.spec.ts +35 -0
  28. package/src/mcp/error_messages.ts +14 -1
  29. package/src/mcp/handler_utils.ts +12 -0
  30. package/src/package_load/package_load_pool.ts +0 -5
  31. package/src/package_load/package_load_worker.ts +41 -99
  32. package/src/package_load/protocol.ts +1 -7
  33. package/src/runtime/publisher.js +318 -0
  34. package/src/server.ts +479 -2
  35. package/src/service/annotations.spec.ts +118 -0
  36. package/src/service/annotations.ts +91 -0
  37. package/src/service/authorize.spec.ts +132 -0
  38. package/src/service/authorize.ts +241 -0
  39. package/src/service/authorize_integration.spec.ts +932 -0
  40. package/src/service/compile_authorize.spec.ts +85 -0
  41. package/src/service/connection.ts +1 -1
  42. package/src/service/environment.ts +67 -9
  43. package/src/service/environment_store.ts +142 -11
  44. package/src/service/filter.spec.ts +14 -3
  45. package/src/service/filter.ts +5 -1
  46. package/src/service/filter_bypass.spec.ts +418 -0
  47. package/src/service/given.ts +37 -12
  48. package/src/service/givens_integration.spec.ts +34 -7
  49. package/src/service/materialization_service.ts +25 -20
  50. package/src/service/materialized_table_gc.spec.ts +6 -5
  51. package/src/service/materialized_table_gc.ts +2 -50
  52. package/src/service/model.spec.ts +203 -8
  53. package/src/service/model.ts +349 -155
  54. package/src/service/package.ts +17 -6
  55. package/src/service/package_worker_path.spec.ts +113 -0
  56. package/src/service/quoting.ts +0 -20
  57. package/src/service/restricted_mode.spec.ts +299 -0
  58. package/src/service/source_extraction.ts +226 -0
  59. package/src/storage/StorageManager.ts +73 -0
  60. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  61. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  62. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  63. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  64. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  65. package/tests/fixtures/html-pages-test/data.csv +3 -0
  66. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  67. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  68. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  69. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  70. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  72. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  73. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  74. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  75. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  76. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  77. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  78. package/tests/unit/duckdb/repositories.test.ts +208 -0
  79. package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
  80. package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
  81. package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
  82. package/dist/app/assets/index-BeNwIeYQ.js +0 -454
  83. package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
  84. package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
@@ -1,22 +1,18 @@
1
1
  import {
2
- Annotation,
2
+ Annotations,
3
3
  API,
4
4
  Connection,
5
5
  FixedConnectionMap,
6
6
  GivenValue,
7
- isSourceDef,
8
7
  MalloyConfig,
9
8
  MalloyError,
10
9
  ModelDef,
11
10
  modelDefToModelInfo,
12
11
  ModelMaterializer,
13
- NamedModelObject,
14
12
  NamedQueryDef,
15
13
  QueryData,
16
14
  QueryMaterializer,
17
15
  Runtime,
18
- StructDef,
19
- TurtleDef,
20
16
  } from "@malloydata/malloy";
21
17
  import * as Malloy from "@malloydata/malloy-interfaces";
22
18
  import {
@@ -42,6 +38,7 @@ import {
42
38
  import { MODEL_FILE_SUFFIX, NOTEBOOK_FILE_SUFFIX } from "../constants";
43
39
  import { HackyDataStylesAccumulator } from "../data_styles";
44
40
  import {
41
+ AccessDeniedError,
45
42
  BadRequestError,
46
43
  ModelCompilationError,
47
44
  ModelNotFoundError,
@@ -54,11 +51,20 @@ import {
54
51
  buildFilterClause,
55
52
  FilterValidationError,
56
53
  injectFilterRefinement,
57
- parseFilters,
58
54
  type FilterDefinition,
59
55
  type FilterParams,
60
56
  } from "./filter";
57
+ import {
58
+ collectAuthorizeExprs,
59
+ evaluateAuthorize,
60
+ validateAuthorizeProbes,
61
+ } from "./authorize";
62
+ import { modelAnnotations } from "./annotations";
61
63
  import { malloyGivenToApi, type MalloyGiven } from "./given";
64
+ import {
65
+ extractQueriesFromModelDef,
66
+ extractSourcesFromModelDef,
67
+ } from "./source_extraction";
62
68
  import {
63
69
  assertWithinModelResponseLimits,
64
70
  resolveModelQueryRowLimit,
@@ -68,9 +74,7 @@ type ApiCompiledModel = components["schemas"]["CompiledModel"];
68
74
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
69
75
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
70
76
  type ApiSource = components["schemas"]["Source"];
71
- type ApiFilter = components["schemas"]["Filter"];
72
77
  type ApiGiven = components["schemas"]["Given"];
73
- type ApiView = components["schemas"]["View"];
74
78
  type ApiQuery = components["schemas"]["Query"];
75
79
  export type ApiConnection = components["schemas"]["Connection"];
76
80
  export type SnowflakeConnection = components["schemas"]["SnowflakeConnection"];
@@ -116,6 +120,9 @@ export class Model {
116
120
  * `Model.givens` already collapses inheritance; we just stash the list
117
121
  * for surfacing on the compiled-model response. */
118
122
  private givens: ApiGiven[] | undefined;
123
+ /** Model-wide `##(authorize)` expressions; apply to every query in the
124
+ * model, including ad-hoc inline sources not declared in the model. */
125
+ private fileLevelAuthorize: string[] = [];
119
126
  private meter = metrics.getMeter("publisher");
120
127
  private queryExecutionHistogram = this.meter.createHistogram(
121
128
  "malloy_model_query_duration",
@@ -163,6 +170,25 @@ export class Model {
163
170
  this.compilationError = compilationError;
164
171
  this.filterMap = filterMap ?? new Map();
165
172
  this.givens = givens;
173
+ // Model-wide ##(authorize) gates, derived from the file-level annotations
174
+ // on the modelDef (which survives the worker boundary). These apply to
175
+ // any query that resolves to a model source (or to no nameable source),
176
+ // model-wide. (Raw-SQL access to the warehouse is closed separately by
177
+ // restricted mode, which rejects inline `duckdb.sql(...)` on the caller
178
+ // query path before any gate runs — see getQueryResults.) A
179
+ // successfully-loaded model has already had these validated; guard the
180
+ // parse defensively so the constructor never throws.
181
+ try {
182
+ this.fileLevelAuthorize = this.modelDef
183
+ ? collectAuthorizeExprs(
184
+ (modelAnnotations(this.modelDef).notes ?? []).map(
185
+ (note) => note.text,
186
+ ),
187
+ )
188
+ : [];
189
+ } catch {
190
+ this.fileLevelAuthorize = [];
191
+ }
166
192
  this.modelInfo =
167
193
  modelInfo ??
168
194
  (this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
@@ -191,15 +217,196 @@ export class Model {
191
217
  return this.filterMap.get(sourceName) ?? [];
192
218
  }
193
219
 
220
+ /**
221
+ * Effective authorize expressions gating a source: file-level
222
+ * `##(authorize)` followed by the source's own `#(authorize)`, evaluated as
223
+ * one OR disjunction at request time. Empty array means unrestricted. Reads
224
+ * the per-source list surfaced on `sources` (which rides the worker
225
+ * serialization boundary), so it works for both freshly-created and
226
+ * deserialized models.
227
+ */
228
+ public getAuthorize(sourceName: string): string[] {
229
+ return (
230
+ this.sources?.find((source) => source.name === sourceName)
231
+ ?.authorize ?? []
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Whether the model declares any `#(authorize)` / `##(authorize)` gate at all
237
+ * (file-level or on any source). Lets callers cheaply skip authorize work for
238
+ * ungated models without compiling a probe.
239
+ */
240
+ public hasAuthorize(): boolean {
241
+ return (
242
+ this.fileLevelAuthorize.length > 0 ||
243
+ (this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false)
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Effective authorize expressions for whatever a query runs against:
249
+ * - a declared model source → its own list (file-level ++ source-level);
250
+ * - anything else (an ad-hoc inline `duckdb.sql(...)` source, or a source
251
+ * we couldn't name) → the model-wide file-level `##(authorize)` gates.
252
+ * The second case is what stops a file-level gate from being bypassed by
253
+ * querying the warehouse through raw inline SQL.
254
+ */
255
+ private effectiveAuthorizeFor(sourceName: string | undefined): string[] {
256
+ if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
257
+ return this.getAuthorize(sourceName);
258
+ }
259
+ return this.fileLevelAuthorize;
260
+ }
261
+
262
+ /**
263
+ * Runtime authorize gate. Throws `AccessDeniedError` (403) unless at least
264
+ * one in-scope authorize expression evaluates true for the supplied givens.
265
+ * No in-scope expressions = unrestricted.
266
+ *
267
+ * Fail closed: any failure to evaluate the probe — a missing given value, a
268
+ * transient probe error, a missing/non-true result — denies. (Expression
269
+ * well-formedness was already validated at model load; see authorize.ts.)
270
+ * The 403 message names only the source, never the expression, so gate logic
271
+ * is not leaked to the caller.
272
+ */
273
+ public async assertAuthorized(
274
+ sourceName: string | undefined,
275
+ givens: Record<string, GivenValue>,
276
+ ): Promise<void> {
277
+ const exprs = this.effectiveAuthorizeFor(sourceName);
278
+ if (exprs.length === 0) return; // unrestricted
279
+ const label = sourceName ?? "(query)";
280
+ const deny = () => {
281
+ throw new AccessDeniedError(`Access denied for source "${label}".`);
282
+ };
283
+ if (!this.modelMaterializer) deny();
284
+ let passed = false;
285
+ try {
286
+ passed = await evaluateAuthorize(
287
+ this.modelMaterializer!,
288
+ exprs,
289
+ givens,
290
+ );
291
+ } catch (err) {
292
+ // Fail closed — e.g. a referenced given had no supplied value.
293
+ logger.debug("Authorize probe failed; denying", {
294
+ sourceName: label,
295
+ modelPath: this.modelPath,
296
+ error: err instanceof Error ? err.message : String(err),
297
+ });
298
+ deny();
299
+ }
300
+ if (!passed) deny();
301
+ }
302
+
303
+ /**
304
+ * Gate ad-hoc compile/query text by the named source it targets. Resolves the
305
+ * source from surface syntax (`extractSourceName`) and applies the gate. An
306
+ * unnamed/inline source resolves to `undefined`, so only the model-wide
307
+ * file-level gate applies — the same top-level-only boundary as the query
308
+ * path's early gate. Used by the `/compile` path, which has no runnable to
309
+ * resolve before it decides whether to compile at all.
310
+ */
311
+ public async assertAuthorizedForText(
312
+ text: string,
313
+ givens: Record<string, GivenValue>,
314
+ ): Promise<void> {
315
+ await this.assertAuthorized(this.extractSourceName(text), givens);
316
+ }
317
+
318
+ /**
319
+ * Gate a compiled query by the source it actually reads, resolved from the
320
+ * prepared query's `structRef` (authoritative — survives named-query and
321
+ * multi-statement indirection that surface syntax misses, e.g. the executed
322
+ * `run:` statement isn't the first one). Used as the `/compile` backstop once
323
+ * a runnable exists.
324
+ */
325
+ public async assertAuthorizedForRunnable(
326
+ runnable: { getPreparedQuery(): Promise<unknown> },
327
+ givens: Record<string, GivenValue>,
328
+ ): Promise<void> {
329
+ await this.assertAuthorized(
330
+ await this.resolveAuthorizeSourceFromRunnable(runnable),
331
+ givens,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Resolve the source a compiled query reads, from its prepared query's
337
+ * `structRef`. This is authoritative — it survives named-query indirection
338
+ * and bare `run: <query>` forms that surface-syntax extraction misses — so
339
+ * the authorize gate can't be dodged by how a request names the query.
340
+ * Returns undefined if the source can't be determined.
341
+ */
342
+ private async resolveAuthorizeSourceFromRunnable(runnable: {
343
+ getPreparedQuery(): Promise<unknown>;
344
+ }): Promise<string | undefined> {
345
+ try {
346
+ const prepared = (await runnable.getPreparedQuery()) as {
347
+ _query?: { structRef?: unknown };
348
+ };
349
+ const structRef = prepared._query?.structRef;
350
+ if (typeof structRef === "string") return structRef;
351
+ if (structRef && typeof structRef === "object") {
352
+ const s = structRef as { as?: string; name?: string };
353
+ return s.as || s.name;
354
+ }
355
+ } catch {
356
+ // Can't resolve — caller simply has no name to gate on here.
357
+ }
358
+ return undefined;
359
+ }
360
+
194
361
  /**
195
362
  * Best-effort extraction of a source name from an ad-hoc Malloy query string.
196
363
  * Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
197
364
  */
198
365
  private extractSourceName(query?: string): string | undefined {
199
366
  if (!query) return undefined;
200
- const runMatch = query.match(/run\s*:\s*(\w+)\s*->/);
201
- const arrowMatch = query.match(/^\s*(\w+)\s*->/m);
202
- return runMatch?.[1] ?? arrowMatch?.[1];
367
+ // Match a bare `\w+` identifier or a backtick-quoted Malloy identifier
368
+ // (e.g. `gated-source`, which needs quoting for the hyphen). Quoted names
369
+ // must be recognized here too, or the early schema-oracle gate would miss
370
+ // a gated source with a quoted name and let a denied caller probe its
371
+ // columns via a pre-compilation field error. The quoted capture returns
372
+ // the inner name (no backticks), matching how sources are keyed.
373
+ const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))\s*->/);
374
+ const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
375
+ return (
376
+ runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2]
377
+ );
378
+ }
379
+
380
+ /**
381
+ * Resolve the run target of an ad-hoc query to the model-defined source
382
+ * whose filters apply, following source-derivation declarations so that a
383
+ * filter-protected source carries its filter requirements when read under a
384
+ * derived name. The declared filter belongs to the source, not to the name
385
+ * it is read under. Returns undefined when the run target does not derive
386
+ * from a protected source.
387
+ */
388
+ private resolveFilterSource(query?: string): string | undefined {
389
+ const target = this.extractSourceName(query);
390
+ if (!target || !query) return undefined;
391
+
392
+ // Map each ad-hoc source name to the base it derives from
393
+ // (`source: NAME is BASE ...` → NAME → BASE).
394
+ const aliasOf = new Map<string, string>();
395
+ const declRe = /source\s*:\s*(\w+)\s+is\s+(\w+)/g;
396
+ let match: RegExpExecArray | null;
397
+ while ((match = declRe.exec(query)) !== null) {
398
+ aliasOf.set(match[1], match[2]);
399
+ }
400
+
401
+ // Walk the derivation chain until we hit a protected source or run out.
402
+ let current: string | undefined = target;
403
+ const seen = new Set<string>();
404
+ while (current && !seen.has(current)) {
405
+ if (this.filterMap.has(current)) return current;
406
+ seen.add(current);
407
+ current = aliasOf.get(current);
408
+ }
409
+ return undefined;
203
410
  }
204
411
 
205
412
  /**
@@ -255,10 +462,16 @@ export class Model {
255
462
  malloyGivens.length > 0
256
463
  ? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
257
464
  : undefined;
258
- const sourceResult = Model.getSources(modelPath, modelDef, givens);
465
+ const sourceResult = Model.getSources(modelDef, givens);
259
466
  sources = sourceResult.sources;
260
467
  filterMap = sourceResult.filterMap;
261
- queries = Model.getQueries(modelPath, modelDef);
468
+ queries = Model.getQueries(modelDef);
469
+
470
+ // Translation-time validation of #(authorize) annotations (shared
471
+ // with the package-load worker so both compile paths validate
472
+ // identically). Compiling the probe surfaces unknown givens and
473
+ // source-field references at model-load instead of first request.
474
+ await validateAuthorizeProbes(modelMaterializer, sources ?? []);
262
475
 
263
476
  // Collect sourceInfos from imported models first
264
477
  // This follows the same pattern as notebook imports handling
@@ -557,6 +770,24 @@ export class Model {
557
770
  if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
558
771
  throw new BadRequestError("Model has no queryable entities.");
559
772
 
773
+ // Early fast-path authorize gate (before loadQuery). Resolve the source
774
+ // from surface syntax; gate if it names one. This runs BEFORE compilation
775
+ // so the gate can't be used as a schema oracle — without it, a denied
776
+ // caller probing `run: gated -> { group_by: maybe_field }` would get a
777
+ // Malloy "field not found" vs a 403 and learn the gated source's columns.
778
+ // It does NOT replace the authoritative compiled-source gate below (which
779
+ // always runs and catches named-query / multi-statement forms surface
780
+ // syntax can't resolve); it only fails fast for the common case.
781
+ const earlySource =
782
+ sourceName ||
783
+ (queryName
784
+ ? this.queries?.find((q) => q.name === queryName)?.sourceName
785
+ : undefined) ||
786
+ this.extractSourceName(query);
787
+ if (earlySource) {
788
+ await this.assertAuthorized(earlySource, givens ?? {});
789
+ }
790
+
560
791
  // Wrap loadQuery calls in try-catch to handle query parsing errors
561
792
  try {
562
793
  let queryString: string;
@@ -579,9 +810,19 @@ export class Model {
579
810
  );
580
811
  }
581
812
 
582
- // Inject source filter predicates unless bypassed
813
+ // Distinguishes free-form query text from the named `source->view`
814
+ // form. Both are driven by untrusted caller input and compiled in
815
+ // restricted mode below; this flag only controls how the protected
816
+ // source is resolved for filter injection.
817
+ const isAdHocQuery = !sourceName && !queryName && !!query;
818
+
819
+ // Inject source filter predicates unless bypassed. For ad-hoc queries
820
+ // resolve the run target through any alias/extend/chain so a protected
821
+ // source can't be read unfiltered under a different name.
583
822
  if (!bypassFilters) {
584
- const effectiveSource = sourceName ?? this.extractSourceName(query);
823
+ const effectiveSource = isAdHocQuery
824
+ ? this.resolveFilterSource(query)
825
+ : sourceName;
585
826
  if (effectiveSource) {
586
827
  const filters = this.getFilters(effectiveSource);
587
828
  if (filters.length > 0) {
@@ -597,7 +838,14 @@ export class Model {
597
838
  }
598
839
  }
599
840
 
600
- runnable = this.modelMaterializer.loadQuery(queryString);
841
+ // Restricted mode keeps untrusted query text inside the model's curated
842
+ // surface — it rejects `import`, raw `connection.table(...)` /
843
+ // `connection.sql(...)`, raw-SQL functions, and `##!` flags. The
844
+ // model's own definitions are unaffected. Both the ad-hoc `query` text
845
+ // and the `run: source->view` string built from the caller-supplied
846
+ // `sourceName`/`queryName` pair are untrusted, so both compile here;
847
+ // only author-curated notebook cells use the unrestricted `loadQuery`.
848
+ runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
601
849
  } catch (error) {
602
850
  // Re-throw BadRequestError as-is
603
851
  if (error instanceof BadRequestError) {
@@ -626,6 +874,31 @@ export class Model {
626
874
  throw new BadRequestError(`Invalid query: ${errorMessage}`);
627
875
  }
628
876
 
877
+ // Authoritative authorize gate: resolve the gated source from the
878
+ // COMPILED query — the source Malloy actually runs (the LAST `run:`
879
+ // statement) — not from surface syntax. Surface-syntax resolution alone
880
+ // is both bypassable (first-statement regex vs. last-statement execution:
881
+ // `run: ungated\nrun: gated` would gate `ungated` while running `gated`)
882
+ // and over-restrictive, so this compiled check always runs and is the
883
+ // source of truth; it handles named-query / blank-source / multi-statement
884
+ // forms uniformly. Skip only the redundant re-probe when it's the same
885
+ // source the early gate already cleared. Outside the loadQuery try so
886
+ // AccessDeniedError stays a 403; independent of bypassFilters.
887
+ const compiledSource =
888
+ await this.resolveAuthorizeSourceFromRunnable(runnable);
889
+ // Run unless it's the redundant re-probe of the exact named source the
890
+ // early gate already cleared. When compiledSource is unknown/unresolved,
891
+ // this still runs and assertAuthorized applies the model-wide file-level
892
+ // gate via effectiveAuthorizeFor. Note: on this path an ad-hoc inline
893
+ // `duckdb.sql(...)` query is rejected by restricted mode (the raw-SQL
894
+ // ban from loadRestrictedQuery above) before it can run, so the
895
+ // raw-warehouse bypass is closed by restricted mode — not by this gate.
896
+ // This fallback's job is to apply the file-level gate to permitted ad-hoc
897
+ // forms (declared-source references) whose source can't be named.
898
+ if (!(compiledSource && compiledSource === earlySource)) {
899
+ await this.assertAuthorized(compiledSource, givens ?? {});
900
+ }
901
+
629
902
  const maxRows = getMaxQueryRows();
630
903
  const maxBytes = getMaxResponseBytes();
631
904
  const rowLimit = resolveModelQueryRowLimit(
@@ -727,6 +1000,32 @@ export class Model {
727
1000
  } as ApiCompiledModel;
728
1001
  }
729
1002
 
1003
+ /**
1004
+ * Serialize a notebook cell's `newSources` to the wire shape (an array
1005
+ * of JSON strings), embedding the model-level `givens` on every
1006
+ * SourceInfo so consumers iterating `newSources` can render `given:`
1007
+ * inputs without a second getModel round-trip. Matches `Source.givens`
1008
+ * in the API spec ("Identical to CompiledModel.givens") and how
1009
+ * `getSources` already copies the full list onto each CompiledModel
1010
+ * source. When the model declares no givens, the SourceInfo is emitted
1011
+ * untouched (no empty `givens` key).
1012
+ *
1013
+ * Shared by `getNotebookModel` (the notebook GET endpoint) and
1014
+ * `executeNotebookCell` (the cell-run endpoint) so both surface givens
1015
+ * identically.
1016
+ */
1017
+ private serializeNewSources(
1018
+ newSources: Malloy.SourceInfo[] | undefined,
1019
+ ): string[] | undefined {
1020
+ return newSources?.map((source) =>
1021
+ JSON.stringify(
1022
+ this.givens && this.givens.length > 0
1023
+ ? { ...source, givens: this.givens }
1024
+ : source,
1025
+ ),
1026
+ );
1027
+ }
1028
+
730
1029
  private async getNotebookModel(): Promise<ApiRawNotebook> {
731
1030
  // Return raw cell contents without executing them
732
1031
  const notebookCells: ApiNotebookCell[] = (
@@ -735,33 +1034,16 @@ export class Model {
735
1034
  return {
736
1035
  type: cell.type,
737
1036
  text: cell.text,
738
- newSources: cell.newSources?.map((source) =>
739
- JSON.stringify(source),
740
- ),
1037
+ newSources: this.serializeNewSources(cell.newSources),
741
1038
  queryInfo: cell.queryInfo
742
1039
  ? JSON.stringify(cell.queryInfo)
743
1040
  : undefined,
744
1041
  } as ApiNotebookCell;
745
1042
  });
746
1043
 
747
- // Collect all annotations from the inherits chain
748
- const allAnnotations: string[] = [];
749
- if (this.modelDef) {
750
- // Traverse the inherits chain to collect all annotations
751
- // Type as Annotation to handle the inherits chain properly
752
- let currentAnnotation: Annotation | undefined =
753
- this.modelDef.annotation;
754
-
755
- while (currentAnnotation) {
756
- if (currentAnnotation.notes) {
757
- allAnnotations.push(
758
- ...currentAnnotation.notes.map((note) => note.text),
759
- );
760
- }
761
- // Navigate to the inherited annotation if it exists
762
- currentAnnotation = currentAnnotation.inherits;
763
- }
764
- }
1044
+ const allAnnotations = this.modelDef
1045
+ ? new Annotations(modelAnnotations(this.modelDef)).texts()
1046
+ : [];
765
1047
 
766
1048
  return {
767
1049
  type: "notebook",
@@ -814,6 +1096,20 @@ export class Model {
814
1096
  };
815
1097
  }
816
1098
 
1099
+ // Authorize gate — only cells that actually run a query touch data, so
1100
+ // gate exactly those (a source-def / import cell has no runnable and
1101
+ // accesses nothing). Resolve the source from the COMPILED cell query
1102
+ // (authoritative — survives `run: <named query>` cells the text regex
1103
+ // misses); assertAuthorized applies the source's gate, or the model-wide
1104
+ // file-level gate for an unknown/inline source. Before the execution try
1105
+ // below so AccessDeniedError stays a 403; independent of bypassFilters.
1106
+ if (cell.runnable) {
1107
+ const authorizeSource = await this.resolveAuthorizeSourceFromRunnable(
1108
+ cell.runnable,
1109
+ );
1110
+ await this.assertAuthorized(authorizeSource, givens ?? {});
1111
+ }
1112
+
817
1113
  // For code cells, execute the runnable if available
818
1114
  let queryName: string | undefined = undefined;
819
1115
  let queryResult: string | undefined = undefined;
@@ -910,7 +1206,7 @@ export class Model {
910
1206
  text: cell.text,
911
1207
  queryName: queryName,
912
1208
  result: queryResult,
913
- newSources: cell.newSources?.map((source) => JSON.stringify(source)),
1209
+ newSources: this.serializeNewSources(cell.newSources),
914
1210
  };
915
1211
  }
916
1212
 
@@ -976,132 +1272,30 @@ export class Model {
976
1272
  return malloyConfig;
977
1273
  }
978
1274
 
979
- private static getQueries(
980
- modelPath: string,
981
- modelDef: ModelDef,
982
- ): ApiQuery[] {
983
- const isNamedQuery = (
984
- object: NamedModelObject,
985
- ): object is NamedQueryDef => object.type === "query";
986
- return Object.values(modelDef.contents)
987
- .filter(isNamedQuery)
988
- .map((queryObj: NamedQueryDef) => ({
989
- name: queryObj.as || queryObj.name,
990
- // What to do when the source is not a string?
991
- sourceName:
992
- typeof queryObj.structRef === "string"
993
- ? queryObj.structRef
994
- : undefined,
995
- annotations: queryObj?.annotation?.blockNotes
996
- ?.filter((note: { at: { url: string } }) =>
997
- note.at.url.includes(modelPath),
998
- )
999
- .map((note: { text: string }) => note.text),
1000
- }));
1275
+ private static getQueries(modelDef: ModelDef): ApiQuery[] {
1276
+ // Shared with the package-load worker — see service/source_extraction.ts.
1277
+ return extractQueriesFromModelDef(modelDef) as ApiQuery[];
1001
1278
  }
1002
1279
 
1003
1280
  private static getSources(
1004
- modelPath: string,
1005
1281
  modelDef: ModelDef,
1006
1282
  givens?: ApiGiven[],
1007
1283
  ): {
1008
1284
  sources: ApiSource[];
1009
1285
  filterMap: Map<string, FilterDefinition[]>;
1010
1286
  } {
1011
- const filterMap = new Map<string, FilterDefinition[]>();
1012
-
1013
- const sources = Object.values(modelDef.contents)
1014
- .filter((obj) => isSourceDef(obj))
1015
- .map((sourceObj) => {
1016
- const sourceName = sourceObj.as || sourceObj.name;
1017
- const annotations = (sourceObj as StructDef).annotation?.blockNotes
1018
- ?.filter((note) => note.at.url.includes(modelPath))
1019
- .map((note) => note.text);
1020
-
1021
- // Parse #(filter) from ALL annotations, traversing the inherits
1022
- // chain so that filters on a base source (e.g. `recalls`) are
1023
- // picked up by an extending source (`manufacturer_recalls is
1024
- // recalls extend {}`). The Malloy compiler stores the base
1025
- // source's annotations in `annotation.inherits`.
1026
- //
1027
- // The chain goes child → parent, so we collect child-first.
1028
- // parseFilters uses "last wins" dedup, so we reverse to put
1029
- // parent annotations first and child annotations last (winning).
1030
- const collectedAnnotations: string[][] = [];
1031
- let curAnnotation: Annotation | undefined = (sourceObj as StructDef)
1032
- .annotation;
1033
- while (curAnnotation) {
1034
- if (curAnnotation.blockNotes) {
1035
- collectedAnnotations.push(
1036
- curAnnotation.blockNotes.map((note) => note.text),
1037
- );
1038
- }
1039
- curAnnotation = curAnnotation.inherits;
1040
- }
1041
- const allAnnotations = collectedAnnotations.reverse().flat();
1042
- let filters: ApiFilter[] | undefined;
1043
- if (allAnnotations.length > 0) {
1044
- try {
1045
- const parsed = parseFilters(allAnnotations);
1046
- if (parsed.length > 0) {
1047
- filterMap.set(sourceName, parsed);
1048
- const structFields = (sourceObj as StructDef).fields;
1049
- filters = parsed.map((f) => {
1050
- const field = structFields.find(
1051
- (fd) => (fd.as || fd.name) === f.dimension,
1052
- );
1053
- return {
1054
- name: f.name,
1055
- dimension: f.dimension,
1056
- type: f.type,
1057
- implicit: f.implicit,
1058
- required: f.required,
1059
- dimensionType: field?.type as string | undefined,
1060
- };
1061
- });
1062
- }
1063
- } catch (err) {
1064
- logger.warn(
1065
- `Failed to parse filter annotations on source "${sourceName}"`,
1066
- { error: err },
1067
- );
1068
- }
1069
- }
1070
-
1071
- const views = (sourceObj as StructDef).fields
1072
- .filter((turtleObj) => turtleObj.type === "turtle")
1073
- .filter((turtleObj) =>
1074
- // TODO(kjnesbit): Fix non-reduce views. Filter out
1075
- // non-reduce views, i.e., indexes. Need to discuss with Will.
1076
- (turtleObj as TurtleDef).pipeline
1077
- .map((stage) => stage.type)
1078
- .every((type) => type == "reduce"),
1079
- )
1080
- .map(
1081
- (turtleObj) =>
1082
- ({
1083
- name: turtleObj.as || turtleObj.name,
1084
- annotations: turtleObj?.annotation?.blockNotes
1085
- ?.filter((note) => note.at.url.includes(modelPath))
1086
- .map((note) => note.text),
1087
- }) as ApiView,
1088
- );
1089
-
1090
- return {
1091
- name: sourceName,
1092
- annotations,
1093
- views,
1094
- filters,
1095
- // Malloy exposes givens at the model level, not per-source.
1096
- // First pass: surface the full model-level list on every source
1097
- // — matches how filter introspection already collapses
1098
- // inheritance into the per-source list. Refine to view-scoped
1099
- // filtering if a customer asks.
1100
- givens,
1101
- } as ApiSource;
1102
- });
1103
-
1104
- return { sources, filterMap };
1287
+ // Shared with the package-load worker — see service/source_extraction.ts.
1288
+ // The service path logs filter parse failures; the worker stays silent.
1289
+ const { sources, filterMap } = extractSourcesFromModelDef(
1290
+ modelDef,
1291
+ givens,
1292
+ (sourceName, err) =>
1293
+ logger.warn(
1294
+ `Failed to parse filter annotations on source "${sourceName}"`,
1295
+ { error: err },
1296
+ ),
1297
+ );
1298
+ return { sources: sources as unknown as ApiSource[], filterMap };
1105
1299
  }
1106
1300
 
1107
1301
  static async getModelMaterializer(