@malloy-publisher/server 0.0.194 → 0.0.195

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.
package/dist/server.mjs CHANGED
@@ -220381,6 +220381,7 @@ import fs from "fs/promises";
220381
220381
  import path2 from "path";
220382
220382
 
220383
220383
  // src/service/connection_config.ts
220384
+ import { createPrivateKey } from "crypto";
220384
220385
  import path from "path";
220385
220386
  var PUBLISHER_DUCKDB_API_FIELDS = new Set(["attachedDatabases"]);
220386
220387
  function normalizeSnowflakePrivateKey(privateKey) {
@@ -220399,6 +220400,12 @@ function normalizeSnowflakePrivateKey(privateKey) {
220399
220400
  endRegex: /-----END\s+PRIVATE\s+KEY-----/i,
220400
220401
  beginMarker: "-----BEGIN PRIVATE KEY-----",
220401
220402
  endMarker: "-----END PRIVATE KEY-----"
220403
+ },
220404
+ {
220405
+ beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
220406
+ endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
220407
+ beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
220408
+ endMarker: "-----END RSA PRIVATE KEY-----"
220402
220409
  }
220403
220410
  ];
220404
220411
  for (const pattern of keyPatterns) {
@@ -220425,6 +220432,21 @@ ${pattern.endMarker}
220425
220432
  privateKeyContent += `
220426
220433
  `;
220427
220434
  }
220435
+ if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
220436
+ try {
220437
+ privateKeyContent = createPrivateKey({
220438
+ key: privateKeyContent,
220439
+ format: "pem"
220440
+ }).export({ type: "pkcs8", format: "pem" }).toString();
220441
+ } catch (err) {
220442
+ throw new Error(`Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${err instanceof Error ? err.message : String(err)}`);
220443
+ }
220444
+ if (!privateKeyContent.endsWith(`
220445
+ `)) {
220446
+ privateKeyContent += `
220447
+ `;
220448
+ }
220449
+ }
220428
220450
  return privateKeyContent;
220429
220451
  }
220430
220452
  function validateDuckdbApiSurface(connection) {
@@ -231556,6 +231578,9 @@ class Project {
231556
231578
  this.metadata.readme = payload.readme;
231557
231579
  await this.writeProjectReadme(payload.readme);
231558
231580
  }
231581
+ if (payload.materializationStorage !== undefined) {
231582
+ this.metadata.materializationStorage = payload.materializationStorage;
231583
+ }
231559
231584
  if (payload.connections) {
231560
231585
  const payloadConnections = payload.connections;
231561
231586
  await this.runConnectionUpdateExclusive(async () => {
@@ -232407,6 +232432,9 @@ class ProjectStore {
232407
232432
  if (!newProject.metadata)
232408
232433
  newProject.metadata = {};
232409
232434
  newProject.metadata.location = absoluteProjectPath;
232435
+ if (project.materializationStorage !== undefined) {
232436
+ newProject.metadata.materializationStorage = project.materializationStorage;
232437
+ }
232410
232438
  this.projects.set(projectName, newProject);
232411
232439
  project?.packages?.forEach((_package) => {
232412
232440
  if (_package.name) {
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.194",
4
+ "version": "0.0.195",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -1,5 +1,9 @@
1
+ import { generateKeyPairSync } from "crypto";
1
2
  import { describe, expect, it } from "bun:test";
2
- import { assembleProjectConnections } from "./connection_config";
3
+ import {
4
+ assembleProjectConnections,
5
+ normalizeSnowflakePrivateKey,
6
+ } from "./connection_config";
3
7
  import { components } from "../api";
4
8
 
5
9
  type ApiConnection = components["schemas"]["Connection"];
@@ -121,3 +125,44 @@ describe("assembleProjectConnections — databricks", () => {
121
125
  );
122
126
  });
123
127
  });
128
+
129
+ describe("normalizeSnowflakePrivateKey", () => {
130
+ const { privateKey: pkcs8Pem } = generateKeyPairSync("rsa", {
131
+ modulusLength: 2048,
132
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
133
+ publicKeyEncoding: { type: "spki", format: "pem" },
134
+ });
135
+ const { privateKey: pkcs1Pem } = generateKeyPairSync("rsa", {
136
+ modulusLength: 2048,
137
+ privateKeyEncoding: { type: "pkcs1", format: "pem" },
138
+ publicKeyEncoding: { type: "pkcs1", format: "pem" },
139
+ });
140
+
141
+ it("passes a multi-line PKCS#8 key through and adds a trailing newline", () => {
142
+ const trimmed = (pkcs8Pem as string).trimEnd();
143
+ const result = normalizeSnowflakePrivateKey(trimmed);
144
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
145
+ expect(result.endsWith("\n")).toBe(true);
146
+ });
147
+
148
+ it("converts a multi-line PKCS#1 RSA key to PKCS#8", () => {
149
+ const result = normalizeSnowflakePrivateKey(pkcs1Pem as string);
150
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
151
+ expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
152
+ expect(result.endsWith("\n")).toBe(true);
153
+ });
154
+
155
+ it("converts a single-line PKCS#1 RSA key to PKCS#8", () => {
156
+ const singleLine = (pkcs1Pem as string).replace(/\n/g, "");
157
+ const result = normalizeSnowflakePrivateKey(singleLine);
158
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
159
+ expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
160
+ });
161
+
162
+ it("reconstructs a single-line PKCS#8 key without conversion", () => {
163
+ const singleLine = (pkcs8Pem as string).replace(/\n/g, "");
164
+ const result = normalizeSnowflakePrivateKey(singleLine);
165
+ expect(result.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
166
+ expect(result.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
167
+ });
168
+ });
@@ -1,3 +1,4 @@
1
+ import { createPrivateKey } from "crypto";
1
2
  import path from "path";
2
3
  import { components } from "../api";
3
4
 
@@ -48,6 +49,12 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
48
49
  beginMarker: "-----BEGIN PRIVATE KEY-----",
49
50
  endMarker: "-----END PRIVATE KEY-----",
50
51
  },
52
+ {
53
+ beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
54
+ endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
55
+ beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
56
+ endMarker: "-----END RSA PRIVATE KEY-----",
57
+ },
51
58
  ];
52
59
 
53
60
  for (const pattern of keyPatterns) {
@@ -73,6 +80,28 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
73
80
  privateKeyContent += "\n";
74
81
  }
75
82
 
83
+ // Snowflake's Node SDK requires PKCS#8 ("BEGIN PRIVATE KEY"). Convert
84
+ // PKCS#1 ("BEGIN RSA PRIVATE KEY") so users can paste either format.
85
+ if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
86
+ try {
87
+ privateKeyContent = createPrivateKey({
88
+ key: privateKeyContent,
89
+ format: "pem",
90
+ })
91
+ .export({ type: "pkcs8", format: "pem" })
92
+ .toString();
93
+ } catch (err) {
94
+ throw new Error(
95
+ `Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${
96
+ err instanceof Error ? err.message : String(err)
97
+ }`,
98
+ );
99
+ }
100
+ if (!privateKeyContent.endsWith("\n")) {
101
+ privateKeyContent += "\n";
102
+ }
103
+ }
104
+
76
105
  return privateKeyContent;
77
106
  }
78
107
 
@@ -99,6 +99,10 @@ export class Project {
99
99
  await this.writeProjectReadme(payload.readme);
100
100
  }
101
101
 
102
+ if (payload.materializationStorage !== undefined) {
103
+ this.metadata.materializationStorage = payload.materializationStorage;
104
+ }
105
+
102
106
  // Handle connections update
103
107
  // TODO: Update project connections should have its own API endpoint
104
108
  if (payload.connections) {
@@ -10,6 +10,11 @@ import { ProjectStore } from "./project_store";
10
10
 
11
11
  type MockData = Record<string, unknown>;
12
12
 
13
+ const initializeDuckLakeCalls: Array<{
14
+ projectId: string;
15
+ config: { catalogUrl: string; dataPath: string };
16
+ }> = [];
17
+
13
18
  mock.module("../storage/StorageManager", () => {
14
19
  return {
15
20
  StorageManager: class MockStorageManager {
@@ -17,6 +22,13 @@ mock.module("../storage/StorageManager", () => {
17
22
  return;
18
23
  }
19
24
 
25
+ async initializeDuckLakeForProject(
26
+ projectId: string,
27
+ config: { catalogUrl: string; dataPath: string },
28
+ ): Promise<void> {
29
+ initializeDuckLakeCalls.push({ projectId, config });
30
+ }
31
+
20
32
  getRepository() {
21
33
  return {
22
34
  // ===== PROJECT METHODS =====
@@ -467,6 +479,69 @@ describe("ProjectStore Service", () => {
467
479
  expect(readmeContent).toBe("Updated README content");
468
480
  });
469
481
 
482
+ it("should propagate materializationStorage on addProject for new project", async () => {
483
+ writeFileSync(
484
+ path.join(serverRootPath, "publisher.config.json"),
485
+ JSON.stringify({ frozenConfig: false, projects: [] }),
486
+ );
487
+
488
+ await projectStore.finishedInitialization;
489
+
490
+ const materializationStorage = {
491
+ catalogUrl:
492
+ "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
493
+ dataPath: "gs://test-bucket",
494
+ };
495
+
496
+ initializeDuckLakeCalls.length = 0;
497
+ const project = await projectStore.addProject({
498
+ name: projectName,
499
+ materializationStorage,
500
+ });
501
+
502
+ expect(project.metadata.materializationStorage).toEqual(
503
+ materializationStorage,
504
+ );
505
+ expect(initializeDuckLakeCalls).toHaveLength(1);
506
+ expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
507
+ });
508
+
509
+ it("should propagate materializationStorage on update", async () => {
510
+ const projectPath = path.join(serverRootPath, projectName);
511
+ mkdirSync(projectPath, { recursive: true });
512
+ writeFileSync(
513
+ path.join(projectPath, "publisher.json"),
514
+ JSON.stringify({ name: projectName, description: "Test package" }),
515
+ );
516
+ writeFileSync(
517
+ path.join(serverRootPath, "publisher.config.json"),
518
+ JSON.stringify({
519
+ frozenConfig: false,
520
+ projects: [
521
+ {
522
+ name: projectName,
523
+ packages: [{ name: projectName, location: projectPath }],
524
+ },
525
+ ],
526
+ }),
527
+ );
528
+
529
+ await projectStore.finishedInitialization;
530
+ const project = await projectStore.getProject(projectName);
531
+
532
+ const materializationStorage = {
533
+ catalogUrl:
534
+ "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
535
+ dataPath: "gs://test-bucket",
536
+ };
537
+
538
+ await project.update({ name: projectName, materializationStorage });
539
+
540
+ expect(project.metadata.materializationStorage).toEqual(
541
+ materializationStorage,
542
+ );
543
+ });
544
+
470
545
  it(
471
546
  "should handle project reload",
472
547
  async () => {
@@ -807,6 +807,10 @@ export class ProjectStore {
807
807
 
808
808
  if (!newProject.metadata) newProject.metadata = {};
809
809
  newProject.metadata.location = absoluteProjectPath;
810
+ if (project.materializationStorage !== undefined) {
811
+ newProject.metadata.materializationStorage =
812
+ project.materializationStorage;
813
+ }
810
814
 
811
815
  this.projects.set(projectName, newProject);
812
816