@malloy-publisher/server 0.0.203 → 0.0.204

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 (50) hide show
  1. package/dist/app/api-doc.yaml +17 -0
  2. package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CX06cjOF.js} +1 -1
  3. package/dist/app/assets/HomePage-CNFt_eUU.js +1 -0
  4. package/dist/app/assets/{MainPage-bYOWcgDP.js → MainPage-nUJ9YatG.js} +1 -1
  5. package/dist/app/assets/{PackagePage-N1ZBNJul.js → MaterializationsPage-B5goxVXW.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DT0gjNy1.js → ModelPage-Ba7Xh4lL.js} +1 -1
  7. package/dist/app/assets/PackagePage-BaEVdEAG.js +1 -0
  8. package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-BShQjZio.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-CBn6ZjJW.js} +1 -1
  10. package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-DECXYL4E.es-OaRfXwuQ.js} +1 -1
  11. package/dist/app/assets/{index-CqUWJELr.js → index-BLfPC1gy.js} +2 -2
  12. package/dist/app/assets/index-DqiJ0bWp.js +455 -0
  13. package/dist/app/assets/index-Dy3YhAZQ.js +1812 -0
  14. package/dist/app/assets/index.umd-DAN9K8yC.js +2469 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +392 -67
  17. package/dist/server.mjs +415 -152
  18. package/package.json +11 -11
  19. package/src/ducklake_version.spec.ts +43 -0
  20. package/src/ducklake_version.ts +26 -0
  21. package/src/errors.ts +18 -1
  22. package/src/package_load/package_load_pool.ts +0 -5
  23. package/src/package_load/package_load_worker.ts +41 -99
  24. package/src/package_load/protocol.ts +1 -7
  25. package/src/service/annotations.spec.ts +118 -0
  26. package/src/service/annotations.ts +91 -0
  27. package/src/service/authorize.spec.ts +132 -0
  28. package/src/service/authorize.ts +241 -0
  29. package/src/service/authorize_integration.spec.ts +838 -0
  30. package/src/service/connection.ts +1 -1
  31. package/src/service/environment.ts +4 -4
  32. package/src/service/filter.spec.ts +14 -3
  33. package/src/service/filter.ts +5 -1
  34. package/src/service/filter_bypass.spec.ts +418 -0
  35. package/src/service/given.ts +37 -12
  36. package/src/service/givens_integration.spec.ts +34 -7
  37. package/src/service/materialization_service.ts +25 -20
  38. package/src/service/materialized_table_gc.spec.ts +6 -5
  39. package/src/service/materialized_table_gc.ts +2 -50
  40. package/src/service/model.spec.ts +203 -8
  41. package/src/service/model.ts +305 -155
  42. package/src/service/package_worker_path.spec.ts +113 -0
  43. package/src/service/quoting.ts +0 -20
  44. package/src/service/restricted_mode.spec.ts +299 -0
  45. package/src/service/source_extraction.ts +226 -0
  46. package/src/storage/StorageManager.ts +73 -0
  47. package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
  48. package/dist/app/assets/index-BeNwIeYQ.js +0 -454
  49. package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
  50. 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,152 @@ 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
+ * Effective authorize expressions for whatever a query runs against:
237
+ * - a declared model source → its own list (file-level ++ source-level);
238
+ * - anything else (an ad-hoc inline `duckdb.sql(...)` source, or a source
239
+ * we couldn't name) → the model-wide file-level `##(authorize)` gates.
240
+ * The second case is what stops a file-level gate from being bypassed by
241
+ * querying the warehouse through raw inline SQL.
242
+ */
243
+ private effectiveAuthorizeFor(sourceName: string | undefined): string[] {
244
+ if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
245
+ return this.getAuthorize(sourceName);
246
+ }
247
+ return this.fileLevelAuthorize;
248
+ }
249
+
250
+ /**
251
+ * Runtime authorize gate. Throws `AccessDeniedError` (403) unless at least
252
+ * one in-scope authorize expression evaluates true for the supplied givens.
253
+ * No in-scope expressions = unrestricted.
254
+ *
255
+ * Fail closed: any failure to evaluate the probe — a missing given value, a
256
+ * transient probe error, a missing/non-true result — denies. (Expression
257
+ * well-formedness was already validated at model load; see authorize.ts.)
258
+ * The 403 message names only the source, never the expression, so gate logic
259
+ * is not leaked to the caller.
260
+ */
261
+ public async assertAuthorized(
262
+ sourceName: string | undefined,
263
+ givens: Record<string, GivenValue>,
264
+ ): Promise<void> {
265
+ const exprs = this.effectiveAuthorizeFor(sourceName);
266
+ if (exprs.length === 0) return; // unrestricted
267
+ const label = sourceName ?? "(query)";
268
+ const deny = () => {
269
+ throw new AccessDeniedError(`Access denied for source "${label}".`);
270
+ };
271
+ if (!this.modelMaterializer) deny();
272
+ let passed = false;
273
+ try {
274
+ passed = await evaluateAuthorize(
275
+ this.modelMaterializer!,
276
+ exprs,
277
+ givens,
278
+ );
279
+ } catch (err) {
280
+ // Fail closed — e.g. a referenced given had no supplied value.
281
+ logger.debug("Authorize probe failed; denying", {
282
+ sourceName: label,
283
+ modelPath: this.modelPath,
284
+ error: err instanceof Error ? err.message : String(err),
285
+ });
286
+ deny();
287
+ }
288
+ if (!passed) deny();
289
+ }
290
+
291
+ /**
292
+ * Resolve the source a compiled query reads, from its prepared query's
293
+ * `structRef`. This is authoritative — it survives named-query indirection
294
+ * and bare `run: <query>` forms that surface-syntax extraction misses — so
295
+ * the authorize gate can't be dodged by how a request names the query.
296
+ * Returns undefined if the source can't be determined.
297
+ */
298
+ private async resolveAuthorizeSourceFromRunnable(runnable: {
299
+ getPreparedQuery(): Promise<unknown>;
300
+ }): Promise<string | undefined> {
301
+ try {
302
+ const prepared = (await runnable.getPreparedQuery()) as {
303
+ _query?: { structRef?: unknown };
304
+ };
305
+ const structRef = prepared._query?.structRef;
306
+ if (typeof structRef === "string") return structRef;
307
+ if (structRef && typeof structRef === "object") {
308
+ const s = structRef as { as?: string; name?: string };
309
+ return s.as || s.name;
310
+ }
311
+ } catch {
312
+ // Can't resolve — caller simply has no name to gate on here.
313
+ }
314
+ return undefined;
315
+ }
316
+
194
317
  /**
195
318
  * Best-effort extraction of a source name from an ad-hoc Malloy query string.
196
319
  * Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
197
320
  */
198
321
  private extractSourceName(query?: string): string | undefined {
199
322
  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];
323
+ // Match a bare `\w+` identifier or a backtick-quoted Malloy identifier
324
+ // (e.g. `gated-source`, which needs quoting for the hyphen). Quoted names
325
+ // must be recognized here too, or the early schema-oracle gate would miss
326
+ // a gated source with a quoted name and let a denied caller probe its
327
+ // columns via a pre-compilation field error. The quoted capture returns
328
+ // the inner name (no backticks), matching how sources are keyed.
329
+ const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))\s*->/);
330
+ const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
331
+ return (
332
+ runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2]
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Resolve the run target of an ad-hoc query to the model-defined source
338
+ * whose filters apply, following source-derivation declarations so that a
339
+ * filter-protected source carries its filter requirements when read under a
340
+ * derived name. The declared filter belongs to the source, not to the name
341
+ * it is read under. Returns undefined when the run target does not derive
342
+ * from a protected source.
343
+ */
344
+ private resolveFilterSource(query?: string): string | undefined {
345
+ const target = this.extractSourceName(query);
346
+ if (!target || !query) return undefined;
347
+
348
+ // Map each ad-hoc source name to the base it derives from
349
+ // (`source: NAME is BASE ...` → NAME → BASE).
350
+ const aliasOf = new Map<string, string>();
351
+ const declRe = /source\s*:\s*(\w+)\s+is\s+(\w+)/g;
352
+ let match: RegExpExecArray | null;
353
+ while ((match = declRe.exec(query)) !== null) {
354
+ aliasOf.set(match[1], match[2]);
355
+ }
356
+
357
+ // Walk the derivation chain until we hit a protected source or run out.
358
+ let current: string | undefined = target;
359
+ const seen = new Set<string>();
360
+ while (current && !seen.has(current)) {
361
+ if (this.filterMap.has(current)) return current;
362
+ seen.add(current);
363
+ current = aliasOf.get(current);
364
+ }
365
+ return undefined;
203
366
  }
204
367
 
205
368
  /**
@@ -255,10 +418,16 @@ export class Model {
255
418
  malloyGivens.length > 0
256
419
  ? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
257
420
  : undefined;
258
- const sourceResult = Model.getSources(modelPath, modelDef, givens);
421
+ const sourceResult = Model.getSources(modelDef, givens);
259
422
  sources = sourceResult.sources;
260
423
  filterMap = sourceResult.filterMap;
261
- queries = Model.getQueries(modelPath, modelDef);
424
+ queries = Model.getQueries(modelDef);
425
+
426
+ // Translation-time validation of #(authorize) annotations (shared
427
+ // with the package-load worker so both compile paths validate
428
+ // identically). Compiling the probe surfaces unknown givens and
429
+ // source-field references at model-load instead of first request.
430
+ await validateAuthorizeProbes(modelMaterializer, sources ?? []);
262
431
 
263
432
  // Collect sourceInfos from imported models first
264
433
  // This follows the same pattern as notebook imports handling
@@ -557,6 +726,24 @@ export class Model {
557
726
  if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
558
727
  throw new BadRequestError("Model has no queryable entities.");
559
728
 
729
+ // Early fast-path authorize gate (before loadQuery). Resolve the source
730
+ // from surface syntax; gate if it names one. This runs BEFORE compilation
731
+ // so the gate can't be used as a schema oracle — without it, a denied
732
+ // caller probing `run: gated -> { group_by: maybe_field }` would get a
733
+ // Malloy "field not found" vs a 403 and learn the gated source's columns.
734
+ // It does NOT replace the authoritative compiled-source gate below (which
735
+ // always runs and catches named-query / multi-statement forms surface
736
+ // syntax can't resolve); it only fails fast for the common case.
737
+ const earlySource =
738
+ sourceName ||
739
+ (queryName
740
+ ? this.queries?.find((q) => q.name === queryName)?.sourceName
741
+ : undefined) ||
742
+ this.extractSourceName(query);
743
+ if (earlySource) {
744
+ await this.assertAuthorized(earlySource, givens ?? {});
745
+ }
746
+
560
747
  // Wrap loadQuery calls in try-catch to handle query parsing errors
561
748
  try {
562
749
  let queryString: string;
@@ -579,9 +766,19 @@ export class Model {
579
766
  );
580
767
  }
581
768
 
582
- // Inject source filter predicates unless bypassed
769
+ // Distinguishes free-form query text from the named `source->view`
770
+ // form. Both are driven by untrusted caller input and compiled in
771
+ // restricted mode below; this flag only controls how the protected
772
+ // source is resolved for filter injection.
773
+ const isAdHocQuery = !sourceName && !queryName && !!query;
774
+
775
+ // Inject source filter predicates unless bypassed. For ad-hoc queries
776
+ // resolve the run target through any alias/extend/chain so a protected
777
+ // source can't be read unfiltered under a different name.
583
778
  if (!bypassFilters) {
584
- const effectiveSource = sourceName ?? this.extractSourceName(query);
779
+ const effectiveSource = isAdHocQuery
780
+ ? this.resolveFilterSource(query)
781
+ : sourceName;
585
782
  if (effectiveSource) {
586
783
  const filters = this.getFilters(effectiveSource);
587
784
  if (filters.length > 0) {
@@ -597,7 +794,14 @@ export class Model {
597
794
  }
598
795
  }
599
796
 
600
- runnable = this.modelMaterializer.loadQuery(queryString);
797
+ // Restricted mode keeps untrusted query text inside the model's curated
798
+ // surface — it rejects `import`, raw `connection.table(...)` /
799
+ // `connection.sql(...)`, raw-SQL functions, and `##!` flags. The
800
+ // model's own definitions are unaffected. Both the ad-hoc `query` text
801
+ // and the `run: source->view` string built from the caller-supplied
802
+ // `sourceName`/`queryName` pair are untrusted, so both compile here;
803
+ // only author-curated notebook cells use the unrestricted `loadQuery`.
804
+ runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
601
805
  } catch (error) {
602
806
  // Re-throw BadRequestError as-is
603
807
  if (error instanceof BadRequestError) {
@@ -626,6 +830,31 @@ export class Model {
626
830
  throw new BadRequestError(`Invalid query: ${errorMessage}`);
627
831
  }
628
832
 
833
+ // Authoritative authorize gate: resolve the gated source from the
834
+ // COMPILED query — the source Malloy actually runs (the LAST `run:`
835
+ // statement) — not from surface syntax. Surface-syntax resolution alone
836
+ // is both bypassable (first-statement regex vs. last-statement execution:
837
+ // `run: ungated\nrun: gated` would gate `ungated` while running `gated`)
838
+ // and over-restrictive, so this compiled check always runs and is the
839
+ // source of truth; it handles named-query / blank-source / multi-statement
840
+ // forms uniformly. Skip only the redundant re-probe when it's the same
841
+ // source the early gate already cleared. Outside the loadQuery try so
842
+ // AccessDeniedError stays a 403; independent of bypassFilters.
843
+ const compiledSource =
844
+ await this.resolveAuthorizeSourceFromRunnable(runnable);
845
+ // Run unless it's the redundant re-probe of the exact named source the
846
+ // early gate already cleared. When compiledSource is unknown/unresolved,
847
+ // this still runs and assertAuthorized applies the model-wide file-level
848
+ // gate via effectiveAuthorizeFor. Note: on this path an ad-hoc inline
849
+ // `duckdb.sql(...)` query is rejected by restricted mode (the raw-SQL
850
+ // ban from loadRestrictedQuery above) before it can run, so the
851
+ // raw-warehouse bypass is closed by restricted mode — not by this gate.
852
+ // This fallback's job is to apply the file-level gate to permitted ad-hoc
853
+ // forms (declared-source references) whose source can't be named.
854
+ if (!(compiledSource && compiledSource === earlySource)) {
855
+ await this.assertAuthorized(compiledSource, givens ?? {});
856
+ }
857
+
629
858
  const maxRows = getMaxQueryRows();
630
859
  const maxBytes = getMaxResponseBytes();
631
860
  const rowLimit = resolveModelQueryRowLimit(
@@ -727,6 +956,32 @@ export class Model {
727
956
  } as ApiCompiledModel;
728
957
  }
729
958
 
959
+ /**
960
+ * Serialize a notebook cell's `newSources` to the wire shape (an array
961
+ * of JSON strings), embedding the model-level `givens` on every
962
+ * SourceInfo so consumers iterating `newSources` can render `given:`
963
+ * inputs without a second getModel round-trip. Matches `Source.givens`
964
+ * in the API spec ("Identical to CompiledModel.givens") and how
965
+ * `getSources` already copies the full list onto each CompiledModel
966
+ * source. When the model declares no givens, the SourceInfo is emitted
967
+ * untouched (no empty `givens` key).
968
+ *
969
+ * Shared by `getNotebookModel` (the notebook GET endpoint) and
970
+ * `executeNotebookCell` (the cell-run endpoint) so both surface givens
971
+ * identically.
972
+ */
973
+ private serializeNewSources(
974
+ newSources: Malloy.SourceInfo[] | undefined,
975
+ ): string[] | undefined {
976
+ return newSources?.map((source) =>
977
+ JSON.stringify(
978
+ this.givens && this.givens.length > 0
979
+ ? { ...source, givens: this.givens }
980
+ : source,
981
+ ),
982
+ );
983
+ }
984
+
730
985
  private async getNotebookModel(): Promise<ApiRawNotebook> {
731
986
  // Return raw cell contents without executing them
732
987
  const notebookCells: ApiNotebookCell[] = (
@@ -735,33 +990,16 @@ export class Model {
735
990
  return {
736
991
  type: cell.type,
737
992
  text: cell.text,
738
- newSources: cell.newSources?.map((source) =>
739
- JSON.stringify(source),
740
- ),
993
+ newSources: this.serializeNewSources(cell.newSources),
741
994
  queryInfo: cell.queryInfo
742
995
  ? JSON.stringify(cell.queryInfo)
743
996
  : undefined,
744
997
  } as ApiNotebookCell;
745
998
  });
746
999
 
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
- }
1000
+ const allAnnotations = this.modelDef
1001
+ ? new Annotations(modelAnnotations(this.modelDef)).texts()
1002
+ : [];
765
1003
 
766
1004
  return {
767
1005
  type: "notebook",
@@ -814,6 +1052,20 @@ export class Model {
814
1052
  };
815
1053
  }
816
1054
 
1055
+ // Authorize gate — only cells that actually run a query touch data, so
1056
+ // gate exactly those (a source-def / import cell has no runnable and
1057
+ // accesses nothing). Resolve the source from the COMPILED cell query
1058
+ // (authoritative — survives `run: <named query>` cells the text regex
1059
+ // misses); assertAuthorized applies the source's gate, or the model-wide
1060
+ // file-level gate for an unknown/inline source. Before the execution try
1061
+ // below so AccessDeniedError stays a 403; independent of bypassFilters.
1062
+ if (cell.runnable) {
1063
+ const authorizeSource = await this.resolveAuthorizeSourceFromRunnable(
1064
+ cell.runnable,
1065
+ );
1066
+ await this.assertAuthorized(authorizeSource, givens ?? {});
1067
+ }
1068
+
817
1069
  // For code cells, execute the runnable if available
818
1070
  let queryName: string | undefined = undefined;
819
1071
  let queryResult: string | undefined = undefined;
@@ -910,7 +1162,7 @@ export class Model {
910
1162
  text: cell.text,
911
1163
  queryName: queryName,
912
1164
  result: queryResult,
913
- newSources: cell.newSources?.map((source) => JSON.stringify(source)),
1165
+ newSources: this.serializeNewSources(cell.newSources),
914
1166
  };
915
1167
  }
916
1168
 
@@ -976,132 +1228,30 @@ export class Model {
976
1228
  return malloyConfig;
977
1229
  }
978
1230
 
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
- }));
1231
+ private static getQueries(modelDef: ModelDef): ApiQuery[] {
1232
+ // Shared with the package-load worker — see service/source_extraction.ts.
1233
+ return extractQueriesFromModelDef(modelDef) as ApiQuery[];
1001
1234
  }
1002
1235
 
1003
1236
  private static getSources(
1004
- modelPath: string,
1005
1237
  modelDef: ModelDef,
1006
1238
  givens?: ApiGiven[],
1007
1239
  ): {
1008
1240
  sources: ApiSource[];
1009
1241
  filterMap: Map<string, FilterDefinition[]>;
1010
1242
  } {
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 };
1243
+ // Shared with the package-load worker — see service/source_extraction.ts.
1244
+ // The service path logs filter parse failures; the worker stays silent.
1245
+ const { sources, filterMap } = extractSourcesFromModelDef(
1246
+ modelDef,
1247
+ givens,
1248
+ (sourceName, err) =>
1249
+ logger.warn(
1250
+ `Failed to parse filter annotations on source "${sourceName}"`,
1251
+ { error: err },
1252
+ ),
1253
+ );
1254
+ return { sources: sources as unknown as ApiSource[], filterMap };
1105
1255
  }
1106
1256
 
1107
1257
  static async getModelMaterializer(