From fee3eca17ec803f8de5067723c81c9e95ebc1872 Mon Sep 17 00:00:00 2001 From: lloyd tabb Date: Wed, 12 Aug 2026 16:40:34 -0700 Subject: [PATCH 1/3] feat(db-bigquery): accept the service account key as a JSON or base64 string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serviceAccountKey` is a `json`-typed property, and a json-typed slot takes its value literally — the config compiler refuses reference indirection into structured config, so an `{env: "..."}` reference is never resolved in one. A deployment whose credentials live in an environment variable rather than on disk therefore cannot reach the slot at all: the literal `{env: "..."}` object is what lands in the credentials, and the SDK reports it as "the incoming JSON object does not contain a client_email field" — an error that names neither the property nor the reason. Add `serviceAccountKeyJson` and `serviceAccountKeyJsonBase64`, both `secret` strings, so a reference resolves and the connection parses what arrives. The base64 form is for transports that mangle the quoting and embedded newlines of raw JSON. Going through JSON also restores the private key's newlines from its `\n` escapes, which supplying client_email/private_key as separate strings does not. Precedence is serviceAccountKey, then Json, then JsonBase64, then client_email/private_key; an empty value is skipped, as an unset environment variable produces. Parse failures name the property and what it expects without quoting the value, since JSON.parse's own SyntaxError would put a private key in the message. All three key-bearing properties are destructured out of the retained config, so raw key material never comes to rest there. Signed-off-by: lloyd tabb --- .../src/bigquery_connection.ts | 67 ++++- .../src/bigquery_credentials.unit.spec.ts | 277 ++++++++++++++++++ packages/malloy-db-bigquery/src/index.ts | 25 ++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts diff --git a/packages/malloy-db-bigquery/src/bigquery_connection.ts b/packages/malloy-db-bigquery/src/bigquery_connection.ts index c03f15e21f..60bac082b4 100644 --- a/packages/malloy-db-bigquery/src/bigquery_connection.ts +++ b/packages/malloy-db-bigquery/src/bigquery_connection.ts @@ -78,6 +78,10 @@ interface BigQueryConnectionOptions extends ConnectionConfig { projectId?: string; serviceAccountKeyPath?: string; serviceAccountKey?: {[key: string]: ConnectionParameterValue}; + /** The service account key file's contents, as a JSON string. */ + serviceAccountKeyJson?: string; + /** The service account key file's contents, base64-encoded. */ + serviceAccountKeyJsonBase64?: string; location?: string; maximumBytesBilled?: string; timeoutMs?: string; @@ -87,6 +91,46 @@ interface BigQueryConnectionOptions extends ConnectionConfig { setupSQL?: string; } +// A service account key that arrives as a *string* rather than as structured +// config. +// +// `serviceAccountKey` is a `json`-typed property, and a json-typed slot takes +// its value literally: an `{env: "..."}` reference is never resolved in one, +// because reference indirection into structured config is exactly what the +// config compiler refuses. A deployment whose credentials live in an +// environment variable — the normal shape for a server — therefore cannot +// reach that slot at all, and the literal `{env: "..."}` object it does reach +// the SDK with fails as "the incoming JSON object does not contain a +// client_email field". `serviceAccountKeyJson` and its base64 twin are string +// slots, so a reference resolves and the value arrives here to be parsed. +// +// Nothing from `text` reaches the error messages. It is a private key, and +// JSON.parse's own SyntaxError quotes the input it choked on. +function credentialsFromJson(text: string, malformed: string): CredentialBody { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(malformed); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(malformed); + } + return parsed as CredentialBody; +} + +const MALFORMED_JSON = + 'serviceAccountKeyJson is not a JSON object. It must hold the entire ' + + 'service account key file, verbatim.'; + +// Buffer's base64 decoder ignores characters outside the alphabet rather than +// rejecting them, so an unencoded key pasted into this slot decodes to garbage +// instead of throwing. The JSON parse is what catches that, which is why this +// message names the encoding as the thing to check. +const MALFORMED_BASE64 = + 'serviceAccountKeyJsonBase64 did not base64-decode to a JSON object. It ' + + 'must hold the entire service account key file, base64-encoded.'; + // BigQuery label grammar: keys and values are lowercase, <=63 chars, and // [a-z0-9_-]; keys must start with a lowercase letter. Values are transformed // to fit (lowercase, disallowed chars -> '_', truncate); a key that can't be @@ -439,11 +483,32 @@ export class BigQueryConnection if (typeof arg === 'string') { this.name = arg; } else { - const {name, client_email, private_key, serviceAccountKey, ...args} = arg; + // The three key-bearing properties are destructured out of `args`, so a + // key never lands in `this.config` — only the credentials object the SDK + // needs does. + const { + name, + client_email, + private_key, + serviceAccountKey, + serviceAccountKeyJson, + serviceAccountKeyJsonBase64, + ...args + } = arg; this.name = name; config = args; if (serviceAccountKey) { config.credentials = serviceAccountKey; + } else if (serviceAccountKeyJson) { + config.credentials = credentialsFromJson( + serviceAccountKeyJson, + MALFORMED_JSON + ); + } else if (serviceAccountKeyJsonBase64) { + config.credentials = credentialsFromJson( + Buffer.from(serviceAccountKeyJsonBase64, 'base64').toString('utf8'), + MALFORMED_BASE64 + ); } else if (client_email || private_key) { config.credentials = { client_email, diff --git a/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts b/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts new file mode 100644 index 0000000000..58b1254e13 --- /dev/null +++ b/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts @@ -0,0 +1,277 @@ +/* + * Copyright Contributors to the Malloy project + * SPDX-License-Identifier: MIT + */ + +import {MalloyConfig, getConnectionProperties} from '@malloydata/malloy'; +import {BigQueryConnection} from './bigquery_connection'; +// Registers the 'bigquery' connection type, which the property tests read back. +import './index'; + +// A realistic key shape: what matters is that `private_key` carries its +// newlines as JSON `\n` escapes, the way every downloaded key file does. A +// server that puts those two lines in an environment variable by hand loses +// them; going through JSON is what turns them back into real newlines, and a +// PEM without real newlines fails to sign. +const KEY = { + type: 'service_account', + project_id: 'test-project', + private_key_id: 'abc123', + private_key: + '-----BEGIN PRIVATE KEY-----\nMIIBVQ==\n-----END PRIVATE KEY-----\n', + client_email: 'malloy@test-project.iam.gserviceaccount.com', + client_id: '1234567890', + token_uri: 'https://oauth2.googleapis.com/token', +}; +const KEY_JSON = JSON.stringify(KEY); +const KEY_BASE64 = Buffer.from(KEY_JSON, 'utf8').toString('base64'); + +/** The connection's private config, which is where credentials come to rest. */ +function configOf(conn: BigQueryConnection): { + credentials?: {client_email?: string; private_key?: string}; + [key: string]: unknown; +} { + return (conn as unknown as {config: Record}) + .config as ReturnType; +} + +describe('BigQueryConnection service account key, supplied as a string', () => { + it('parses serviceAccountKeyJson into credentials', () => { + const conn = new BigQueryConnection({ + name: 'bq', + projectId: 'test-project', + serviceAccountKeyJson: KEY_JSON, + }); + expect(configOf(conn).credentials).toEqual(KEY); + }); + + it('decodes and parses serviceAccountKeyJsonBase64 into credentials', () => { + const conn = new BigQueryConnection({ + name: 'bq', + projectId: 'test-project', + serviceAccountKeyJsonBase64: KEY_BASE64, + }); + expect(configOf(conn).credentials).toEqual(KEY); + }); + + it("restores the private key's newlines from its JSON escapes", () => { + // The whole point of routing the key through JSON rather than through two + // separate client_email/private_key strings: a PEM whose "\n" stayed + // two literal characters is not a PEM, and the failure surfaces much + // later, as an opaque signing error. + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: KEY_JSON, + }); + const key = configOf(conn).credentials?.private_key ?? ''; + expect(key).toContain('\n'); + expect(key).not.toContain('\\n'); + expect(key.split('\n')).toHaveLength(4); + }); + + it('keeps the raw key material out of the retained config', () => { + // `config` is what the connection holds for the rest of its life and what + // any config dump would reach. Only the parsed credentials belong there — + // not the string the key arrived in. + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: KEY_JSON, + serviceAccountKeyJsonBase64: KEY_BASE64, + }); + const config = configOf(conn); + expect(config['serviceAccountKeyJson']).toBeUndefined(); + expect(config['serviceAccountKeyJsonBase64']).toBeUndefined(); + expect(JSON.stringify(config)).not.toContain(KEY_BASE64); + }); + + it('ignores an empty value, as an unset environment variable produces', () => { + // `{env: "X"}` with X set-but-empty resolves to "", which must not be + // taken as "the key is here" and blow up as malformed JSON. + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: '', + serviceAccountKeyJsonBase64: '', + client_email: 'fallback@test-project.iam.gserviceaccount.com', + private_key: 'fallback-key', + }); + expect(configOf(conn).credentials).toEqual({ + client_email: 'fallback@test-project.iam.gserviceaccount.com', + private_key: 'fallback-key', + }); + }); +}); + +describe('BigQueryConnection service account key precedence', () => { + it('prefers the structured serviceAccountKey over the string forms', () => { + const structured = { + client_email: 'structured@test.iam.gserviceaccount.com', + }; + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKey: structured, + serviceAccountKeyJson: KEY_JSON, + serviceAccountKeyJsonBase64: KEY_BASE64, + }); + expect(configOf(conn).credentials).toEqual(structured); + }); + + it('prefers serviceAccountKeyJson over the base64 form', () => { + const other = {...KEY, client_email: 'base64@test.iam.gserviceaccount.com'}; + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: KEY_JSON, + serviceAccountKeyJsonBase64: Buffer.from( + JSON.stringify(other), + 'utf8' + ).toString('base64'), + }); + expect(configOf(conn).credentials).toEqual(KEY); + }); + + it('prefers either string form over client_email/private_key', () => { + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJsonBase64: KEY_BASE64, + client_email: 'loser@test-project.iam.gserviceaccount.com', + private_key: 'loser-key', + }); + expect(configOf(conn).credentials).toEqual(KEY); + }); +}); + +describe('BigQueryConnection service account key errors', () => { + it('rejects malformed JSON by naming the property, not quoting the value', () => { + // JSON.parse's own SyntaxError quotes the text it choked on. That text is + // a private key, so it must not reach the message — the property name and + // what the slot expects are the whole of what a caller needs. + const secret = '{"private_key": "-----BEGIN PRIVATE KEY-----\nsecret'; + expect( + () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: secret}) + ).toThrow(/serviceAccountKeyJson is not a JSON object/); + try { + new BigQueryConnection({name: 'bq', serviceAccountKeyJson: secret}); + } catch (error) { + expect((error as Error).message).not.toContain('secret'); + expect((error as Error).message).not.toContain('BEGIN PRIVATE KEY'); + } + }); + + it('rejects JSON that parses to something other than an object', () => { + expect( + () => + new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: '"a string"', + }) + ).toThrow(/serviceAccountKeyJson is not a JSON object/); + expect( + () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: '[]'}) + ).toThrow(/serviceAccountKeyJson is not a JSON object/); + expect( + () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: 'null'}) + ).toThrow(/serviceAccountKeyJson is not a JSON object/); + }); + + it('names base64 when the base64 slot holds unencoded JSON', () => { + // Buffer's decoder drops characters outside the base64 alphabet instead of + // failing, so raw JSON here decodes to garbage rather than throwing. The + // error has to point at the encoding, or the mistake is invisible. + expect( + () => + new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJsonBase64: KEY_JSON, + }) + ).toThrow(/serviceAccountKeyJsonBase64 did not base64-decode/); + }); +}); + +describe('a key held in an environment variable', () => { + // The end of the road this change exists to open: a config that names an + // environment variable, resolved through the real overlay stack, arriving at + // a connection with usable credentials. Everything above tests a piece of + // this; this tests that the pieces meet. + const ENV = 'TEST_BIGQUERY_SERVICE_ACCOUNT_KEY'; + + afterEach(() => { + delete process.env[ENV]; + }); + + it('resolves {env: ...} into serviceAccountKeyJson', async () => { + process.env[ENV] = KEY_JSON; + const config = new MalloyConfig({ + connections: { + bq: { + is: 'bigquery', + projectId: 'test-project', + serviceAccountKeyJson: {env: ENV}, + }, + }, + }); + expect(config.log).toEqual([]); + const conn = await config.connections.lookupConnection('bq'); + expect(configOf(conn as BigQueryConnection).credentials).toEqual(KEY); + }); + + it('resolves {env: ...} into serviceAccountKeyJsonBase64', async () => { + process.env[ENV] = KEY_BASE64; + const config = new MalloyConfig({ + connections: { + bq: { + is: 'bigquery', + projectId: 'test-project', + serviceAccountKeyJsonBase64: {env: ENV}, + }, + }, + }); + expect(config.log).toEqual([]); + const conn = await config.connections.lookupConnection('bq'); + expect(configOf(conn as BigQueryConnection).credentials).toEqual(KEY); + }); + + it('cannot reach the structured serviceAccountKey slot, as before', async () => { + // The behavior that makes the two slots above necessary, pinned so it + // stays a deliberate design and not an accident: a `json` property takes + // its value literally, so the reference object itself lands in the + // credentials and the SDK later complains about a missing client_email. + // If malloy ever resolves references into json slots, this test should + // fail and be deleted along with half of this file's reason to exist. + process.env[ENV] = KEY_JSON; + const config = new MalloyConfig({ + connections: { + bq: { + is: 'bigquery', + projectId: 'test-project', + serviceAccountKey: {env: ENV}, + }, + }, + }); + const conn = await config.connections.lookupConnection('bq'); + expect(configOf(conn as BigQueryConnection).credentials).toEqual({ + env: ENV, + }); + }); +}); + +describe('bigquery connection property registration', () => { + const properties = getConnectionProperties('bigquery') ?? []; + const byName = (name: string) => properties.find(p => p.name === name); + + it.each(['serviceAccountKeyJson', 'serviceAccountKeyJsonBase64'])( + '%s is a secret string, so an {env: ...} reference resolves into it', + name => { + // Not a cosmetic assertion: the config compiler resolves overlay + // references only in non-`json` slots. Retyping either of these as + // 'json' would silently restore the very gap they exist to close, and + // the failure would show up as an authentication error far from here. + const property = byName(name); + expect(property).toBeDefined(); + expect(property?.type).toBe('secret'); + expect(property?.optional).toBe(true); + } + ); + + it('leaves serviceAccountKey structured', () => { + expect(byName('serviceAccountKey')?.type).toBe('json'); + }); +}); diff --git a/packages/malloy-db-bigquery/src/index.ts b/packages/malloy-db-bigquery/src/index.ts index a659e6ba90..1a35fc3243 100644 --- a/packages/malloy-db-bigquery/src/index.ts +++ b/packages/malloy-db-bigquery/src/index.ts @@ -34,6 +34,31 @@ registerConnectionType('bigquery', { type: 'json', optional: true, }, + // The two string-typed twins of `serviceAccountKey`. A `json` slot holds + // its value literally — an `{env: "..."}` reference is never resolved in + // one — so a deployment holding its key in an environment variable has no + // way to use the slot above. These are `secret` strings, where references + // do resolve, and the connection parses what arrives. + { + name: 'serviceAccountKeyJson', + displayName: 'Service Account Key (JSON)', + type: 'secret', + optional: true, + description: + 'The entire service account key file as a JSON string, for supplying ' + + 'it from an environment variable or secret manager rather than from ' + + 'disk. Takes precedence over serviceAccountKeyJsonBase64.', + }, + { + name: 'serviceAccountKeyJsonBase64', + displayName: 'Service Account Key (base64 JSON)', + type: 'secret', + optional: true, + description: + 'The entire service account key file, base64-encoded — the same as ' + + 'serviceAccountKeyJson, for transports that mangle the quoting and ' + + 'newlines of raw JSON.', + }, { name: 'location', displayName: 'Location', From e1829dae3baa2a93adefcb9099227224cc8aea29 Mon Sep 17 00:00:00 2001 From: lloyd tabb Date: Fri, 14 Aug 2026 07:33:56 -0700 Subject: [PATCH 2/3] refactor(db-bigquery): fold base64 into serviceAccountKeyJson, validate the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the property added in the previous commit. One property, not two. The encoding is detected rather than declared: JSON always begins with `{`, and `{` is not in the base64 alphabet, so the two cannot be confused. A deployment that switches encodings no longer has to edit config as well. Values are trimmed first, since a key that arrived through a here-doc or `$(cat key.json)` carries a trailing newline that would otherwise be read as content by both the sniff and the emptiness check. Validate what was parsed. Checking only for "an object" let `{}` and any unrelated JSON reach the SDK and come back as "the incoming JSON object does not contain a client_email field" — the exact error this property exists to eliminate. A key must now carry client_email and private_key, or be an `external_account` config, which is the other shape the SDK's `credentials` option accepts and which carries no key of its own. The check is a type predicate, so the cast to CredentialBody is gone. Correct the base64 rationale. It said raw JSON's newlines get mangled in transit; a downloaded key file carries its newlines as `\n` escapes, so the JSON is a single line and has none to mangle. The real reason is quoting — braces and quotes survive a shell, a CI secret editor, and a `.env` file poorly, and base64 is one token that survives all three. Also: document the property in connection/CONTEXT.md, whose BigQuery list was missing serviceAccountKey as well; and fix a test name that claimed to cover an unset environment variable when an unset reference is dropped before the connection sees it. It covers set-but-empty, now also set-but-whitespace. Signed-off-by: lloyd tabb --- .../src/bigquery_connection.ts | 131 ++++++---- .../src/bigquery_credentials.unit.spec.ts | 231 +++++++++--------- packages/malloy-db-bigquery/src/index.ts | 26 +- packages/malloy/src/connection/CONTEXT.md | 4 +- 4 files changed, 214 insertions(+), 178 deletions(-) diff --git a/packages/malloy-db-bigquery/src/bigquery_connection.ts b/packages/malloy-db-bigquery/src/bigquery_connection.ts index 60bac082b4..da570c01dd 100644 --- a/packages/malloy-db-bigquery/src/bigquery_connection.ts +++ b/packages/malloy-db-bigquery/src/bigquery_connection.ts @@ -78,10 +78,8 @@ interface BigQueryConnectionOptions extends ConnectionConfig { projectId?: string; serviceAccountKeyPath?: string; serviceAccountKey?: {[key: string]: ConnectionParameterValue}; - /** The service account key file's contents, as a JSON string. */ + /** The key file's contents, as a JSON string or base64-encoded JSON. */ serviceAccountKeyJson?: string; - /** The service account key file's contents, base64-encoded. */ - serviceAccountKeyJsonBase64?: string; location?: string; maximumBytesBilled?: string; timeoutMs?: string; @@ -91,46 +89,85 @@ interface BigQueryConnectionOptions extends ConnectionConfig { setupSQL?: string; } -// A service account key that arrives as a *string* rather than as structured -// config. -// -// `serviceAccountKey` is a `json`-typed property, and a json-typed slot takes -// its value literally: an `{env: "..."}` reference is never resolved in one, -// because reference indirection into structured config is exactly what the -// config compiler refuses. A deployment whose credentials live in an -// environment variable — the normal shape for a server — therefore cannot -// reach that slot at all, and the literal `{env: "..."}` object it does reach -// the SDK with fails as "the incoming JSON object does not contain a -// client_email field". `serviceAccountKeyJson` and its base64 twin are string -// slots, so a reference resolves and the value arrives here to be parsed. -// -// Nothing from `text` reaches the error messages. It is a private key, and -// JSON.parse's own SyntaxError quotes the input it choked on. -function credentialsFromJson(text: string, malformed: string): CredentialBody { +type JsonObject = {[key: string]: ConnectionParameterValue}; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The two credential shapes the SDK's `credentials` option accepts: a service + * account key, and an external account (workload identity federation) config, + * which carries no key of its own. Anything else — `{}`, a config file, half a + * key — is rejected here rather than at the first query, where it arrives as + * "the incoming JSON object does not contain a client_email field" and names + * nothing that would lead back to this property. + */ +function isCredentialObject(value: JsonObject): boolean { + return ( + (typeof value['client_email'] === 'string' && + typeof value['private_key'] === 'string') || + value['type'] === 'external_account' + ); +} + +function isProbablyBase64(text: string): boolean { + // `{` is not in the base64 alphabet, so JSON can never be mistaken for an + // encoding of itself. Everything else is treated as base64 and allowed to + // fail the parse below, which keeps the check to the one thing it can be + // certain about. + return !text.startsWith('{'); +} + +/** + * A service account key that arrives as a *string* rather than as structured + * config, in either of the two encodings a deployment can produce. + * + * `serviceAccountKey` is a `json`-typed property, and a json-typed slot takes + * its value literally: an `{env: "..."}` reference is never resolved in one, + * because reference indirection into structured config is exactly what the + * config compiler refuses. A deployment whose credentials live in an + * environment variable — the normal shape for a server — therefore cannot + * reach that slot at all, and the literal `{env: "..."}` object it does reach + * the SDK with fails as "the incoming JSON object does not contain a + * client_email field". This is a string slot, so a reference resolves and the + * value arrives here to be parsed. + * + * Base64 is accepted because a JSON object full of quotes and braces survives + * a shell, a CI secret editor, and a `.env` file poorly; base64 is one token + * that survives all three. Which one arrived is detected rather than declared, + * so a deployment that switches encodings doesn't also have to edit config. + * + * Nothing from `text` reaches the error message. It is a private key, and + * JSON.parse's own SyntaxError quotes the input it choked on. + */ +function credentialsFromJson(text: string): JsonObject { + // Buffer's base64 decoder drops characters outside the alphabet rather than + // rejecting them, so a mangled value decodes to garbage instead of throwing. + // The parse below is what catches that, which is why one message has to + // cover both encodings: at this point either could have been intended. + const json = isProbablyBase64(text) + ? Buffer.from(text, 'base64').toString('utf8') + : text; let parsed: unknown; try { - parsed = JSON.parse(text); + parsed = JSON.parse(json); } catch { - throw new Error(malformed); + throw new Error( + 'serviceAccountKeyJson is neither JSON nor base64-encoded JSON. It ' + + 'must hold the entire service account key file.' + ); } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(malformed); + if (!isJsonObject(parsed) || !isCredentialObject(parsed)) { + throw new Error( + 'serviceAccountKeyJson parsed but is not a service account key: it ' + + 'has no client_email and private_key. It must hold the entire key ' + + 'file, not a fragment of one.' + ); } - return parsed as CredentialBody; + return parsed; } -const MALFORMED_JSON = - 'serviceAccountKeyJson is not a JSON object. It must hold the entire ' + - 'service account key file, verbatim.'; - -// Buffer's base64 decoder ignores characters outside the alphabet rather than -// rejecting them, so an unencoded key pasted into this slot decodes to garbage -// instead of throwing. The JSON parse is what catches that, which is why this -// message names the encoding as the thing to check. -const MALFORMED_BASE64 = - 'serviceAccountKeyJsonBase64 did not base64-decode to a JSON object. It ' + - 'must hold the entire service account key file, base64-encoded.'; - // BigQuery label grammar: keys and values are lowercase, <=63 chars, and // [a-z0-9_-]; keys must start with a lowercase letter. Values are transformed // to fit (lowercase, disallowed chars -> '_', truncate); a key that can't be @@ -483,8 +520,8 @@ export class BigQueryConnection if (typeof arg === 'string') { this.name = arg; } else { - // The three key-bearing properties are destructured out of `args`, so a - // key never lands in `this.config` — only the credentials object the SDK + // Every key-bearing property is destructured out of `args`, so a key + // never lands in `this.config` — only the credentials object the SDK // needs does. const { name, @@ -492,23 +529,19 @@ export class BigQueryConnection private_key, serviceAccountKey, serviceAccountKeyJson, - serviceAccountKeyJsonBase64, ...args } = arg; this.name = name; config = args; + // Trimmed before it is looked at: a value that came through a here-doc, + // a `$(cat key.json)`, or a secret-store copy tends to carry a trailing + // newline, and both the encoding sniff and the emptiness check below + // would otherwise read it as content. + const keyJson = serviceAccountKeyJson?.trim(); if (serviceAccountKey) { config.credentials = serviceAccountKey; - } else if (serviceAccountKeyJson) { - config.credentials = credentialsFromJson( - serviceAccountKeyJson, - MALFORMED_JSON - ); - } else if (serviceAccountKeyJsonBase64) { - config.credentials = credentialsFromJson( - Buffer.from(serviceAccountKeyJsonBase64, 'base64').toString('utf8'), - MALFORMED_BASE64 - ); + } else if (keyJson) { + config.credentials = credentialsFromJson(keyJson); } else if (client_email || private_key) { config.credentials = { client_email, diff --git a/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts b/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts index 58b1254e13..ce1212a814 100644 --- a/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts +++ b/packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts @@ -8,11 +8,9 @@ import {BigQueryConnection} from './bigquery_connection'; // Registers the 'bigquery' connection type, which the property tests read back. import './index'; -// A realistic key shape: what matters is that `private_key` carries its -// newlines as JSON `\n` escapes, the way every downloaded key file does. A -// server that puts those two lines in an environment variable by hand loses -// them; going through JSON is what turns them back into real newlines, and a -// PEM without real newlines fails to sign. +// A downloaded key file carries the private key's newlines as JSON `\n` +// escapes, so the JSON text itself is a single line. Parsing is what turns +// those escapes back into the real newlines a PEM needs. const KEY = { type: 'service_account', project_id: 'test-project', @@ -29,14 +27,13 @@ const KEY_BASE64 = Buffer.from(KEY_JSON, 'utf8').toString('base64'); /** The connection's private config, which is where credentials come to rest. */ function configOf(conn: BigQueryConnection): { credentials?: {client_email?: string; private_key?: string}; - [key: string]: unknown; + serviceAccountKeyJson?: unknown; } { - return (conn as unknown as {config: Record}) - .config as ReturnType; + return (conn as unknown as {config: ReturnType}).config; } describe('BigQueryConnection service account key, supplied as a string', () => { - it('parses serviceAccountKeyJson into credentials', () => { + it('parses a JSON key into credentials', () => { const conn = new BigQueryConnection({ name: 'bq', projectId: 'test-project', @@ -45,20 +42,34 @@ describe('BigQueryConnection service account key, supplied as a string', () => { expect(configOf(conn).credentials).toEqual(KEY); }); - it('decodes and parses serviceAccountKeyJsonBase64 into credentials', () => { + it('detects and decodes a base64-encoded key', () => { + // Same property, no flag: `{` is not in the base64 alphabet, so the two + // encodings can be told apart with certainty. const conn = new BigQueryConnection({ name: 'bq', projectId: 'test-project', - serviceAccountKeyJsonBase64: KEY_BASE64, + serviceAccountKeyJson: KEY_BASE64, }); expect(configOf(conn).credentials).toEqual(KEY); }); + it('tolerates whitespace around either encoding', () => { + // A here-doc, a copied secret, and `$(cat key.json)` all tend to arrive + // with a trailing newline. + for (const value of [`\n${KEY_JSON}\n`, ` ${KEY_BASE64}\n`]) { + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: value, + }); + expect(configOf(conn).credentials).toEqual(KEY); + } + }); + it("restores the private key's newlines from its JSON escapes", () => { - // The whole point of routing the key through JSON rather than through two - // separate client_email/private_key strings: a PEM whose "\n" stayed - // two literal characters is not a PEM, and the failure surfaces much - // later, as an opaque signing error. + // A PEM whose "\n" stayed two literal characters is not a PEM, and the + // failure surfaces much later as an opaque signing error. This is what + // separates a whole key file from a bare `private_key` string, which an + // environment variable cannot carry the newlines of. const conn = new BigQueryConnection({ name: 'bq', serviceAccountKeyJson: KEY_JSON, @@ -69,28 +80,48 @@ describe('BigQueryConnection service account key, supplied as a string', () => { expect(key.split('\n')).toHaveLength(4); }); + it('accepts an external account credential, which carries no key', () => { + // The other shape the SDK's `credentials` option takes (workload identity + // federation). It has no client_email, so a check written only around + // service account keys would reject a valid credential. + const externalAccount = { + type: 'external_account', + audience: '//iam.googleapis.com/projects/1/locations/global/x/y', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: {file: '/var/run/secrets/token'}, + }; + const conn = new BigQueryConnection({ + name: 'bq', + serviceAccountKeyJson: JSON.stringify(externalAccount), + }); + expect(configOf(conn).credentials).toEqual(externalAccount); + }); + it('keeps the raw key material out of the retained config', () => { // `config` is what the connection holds for the rest of its life and what // any config dump would reach. Only the parsed credentials belong there — // not the string the key arrived in. const conn = new BigQueryConnection({ name: 'bq', - serviceAccountKeyJson: KEY_JSON, - serviceAccountKeyJsonBase64: KEY_BASE64, + serviceAccountKeyJson: KEY_BASE64, }); const config = configOf(conn); - expect(config['serviceAccountKeyJson']).toBeUndefined(); - expect(config['serviceAccountKeyJsonBase64']).toBeUndefined(); + expect(config.serviceAccountKeyJson).toBeUndefined(); expect(JSON.stringify(config)).not.toContain(KEY_BASE64); }); - it('ignores an empty value, as an unset environment variable produces', () => { - // `{env: "X"}` with X set-but-empty resolves to "", which must not be - // taken as "the key is here" and blow up as malformed JSON. + it.each([ + ['empty', ''], + ['whitespace only', ' \n '], + ])('ignores a set-but-%s value', (_label, serviceAccountKeyJson) => { + // `{env: "X"}` with X set to "" resolves to "", which must not be taken as + // "the key is here" and fail as malformed. (X *unset* is a different path + // entirely: the reference resolves to undefined and the property is + // dropped before the connection sees it.) const conn = new BigQueryConnection({ name: 'bq', - serviceAccountKeyJson: '', - serviceAccountKeyJsonBase64: '', + serviceAccountKeyJson, client_email: 'fallback@test-project.iam.gserviceaccount.com', private_key: 'fallback-key', }); @@ -102,7 +133,7 @@ describe('BigQueryConnection service account key, supplied as a string', () => { }); describe('BigQueryConnection service account key precedence', () => { - it('prefers the structured serviceAccountKey over the string forms', () => { + it('prefers the structured serviceAccountKey over the string form', () => { const structured = { client_email: 'structured@test.iam.gserviceaccount.com', }; @@ -110,28 +141,14 @@ describe('BigQueryConnection service account key precedence', () => { name: 'bq', serviceAccountKey: structured, serviceAccountKeyJson: KEY_JSON, - serviceAccountKeyJsonBase64: KEY_BASE64, }); expect(configOf(conn).credentials).toEqual(structured); }); - it('prefers serviceAccountKeyJson over the base64 form', () => { - const other = {...KEY, client_email: 'base64@test.iam.gserviceaccount.com'}; + it('prefers the string form over client_email/private_key', () => { const conn = new BigQueryConnection({ name: 'bq', serviceAccountKeyJson: KEY_JSON, - serviceAccountKeyJsonBase64: Buffer.from( - JSON.stringify(other), - 'utf8' - ).toString('base64'), - }); - expect(configOf(conn).credentials).toEqual(KEY); - }); - - it('prefers either string form over client_email/private_key', () => { - const conn = new BigQueryConnection({ - name: 'bq', - serviceAccountKeyJsonBase64: KEY_BASE64, client_email: 'loser@test-project.iam.gserviceaccount.com', private_key: 'loser-key', }); @@ -140,49 +157,49 @@ describe('BigQueryConnection service account key precedence', () => { }); describe('BigQueryConnection service account key errors', () => { - it('rejects malformed JSON by naming the property, not quoting the value', () => { + const construct = (serviceAccountKeyJson: string) => () => + new BigQueryConnection({name: 'bq', serviceAccountKeyJson}); + + it('rejects a malformed value without quoting it', () => { // JSON.parse's own SyntaxError quotes the text it choked on. That text is // a private key, so it must not reach the message — the property name and // what the slot expects are the whole of what a caller needs. const secret = '{"private_key": "-----BEGIN PRIVATE KEY-----\nsecret'; - expect( - () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: secret}) - ).toThrow(/serviceAccountKeyJson is not a JSON object/); + expect(construct(secret)).toThrow( + /serviceAccountKeyJson is neither JSON nor base64-encoded JSON/ + ); try { - new BigQueryConnection({name: 'bq', serviceAccountKeyJson: secret}); + construct(secret)(); } catch (error) { expect((error as Error).message).not.toContain('secret'); expect((error as Error).message).not.toContain('BEGIN PRIVATE KEY'); } }); - it('rejects JSON that parses to something other than an object', () => { - expect( - () => - new BigQueryConnection({ - name: 'bq', - serviceAccountKeyJson: '"a string"', - }) - ).toThrow(/serviceAccountKeyJson is not a JSON object/); - expect( - () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: '[]'}) - ).toThrow(/serviceAccountKeyJson is not a JSON object/); - expect( - () => new BigQueryConnection({name: 'bq', serviceAccountKeyJson: 'null'}) - ).toThrow(/serviceAccountKeyJson is not a JSON object/); + it('names both encodings, since either could have been intended', () => { + // Base64 decoding cannot fail — Buffer drops what it doesn't recognize — + // so by the time the parse fails there is no way to know which encoding + // was meant. The message has to cover both rather than guess. + expect(construct('not a key at all')).toThrow( + /neither JSON nor base64-encoded JSON/ + ); }); - it('names base64 when the base64 slot holds unencoded JSON', () => { - // Buffer's decoder drops characters outside the base64 alphabet instead of - // failing, so raw JSON here decodes to garbage rather than throwing. The - // error has to point at the encoding, or the mistake is invisible. - expect( - () => - new BigQueryConnection({ - name: 'bq', - serviceAccountKeyJsonBase64: KEY_JSON, - }) - ).toThrow(/serviceAccountKeyJsonBase64 did not base64-decode/); + it('rejects JSON that is not a credential object', () => { + // The failure this whole property exists to prevent: valid JSON that the + // SDK would take and then reject at the first query with "does not + // contain a client_email field". + for (const value of ['{}', '{"project_id": "p"}', '"a string"', '[]']) { + expect(construct(value)).toThrow( + /serviceAccountKeyJson (parsed but is not a service account key|is neither)/ + ); + } + }); + + it('rejects a key missing half its credential pair', () => { + const half = JSON.stringify({client_email: KEY.client_email}); + expect(construct(half)).toThrow(/is not a service account key/); + expect(construct(half)).toThrow(/not a fragment of one/); }); }); @@ -197,8 +214,7 @@ describe('a key held in an environment variable', () => { delete process.env[ENV]; }); - it('resolves {env: ...} into serviceAccountKeyJson', async () => { - process.env[ENV] = KEY_JSON; + async function connect(): Promise { const config = new MalloyConfig({ connections: { bq: { @@ -210,43 +226,35 @@ describe('a key held in an environment variable', () => { }); expect(config.log).toEqual([]); const conn = await config.connections.lookupConnection('bq'); - expect(configOf(conn as BigQueryConnection).credentials).toEqual(KEY); + expect(conn).toBeInstanceOf(BigQueryConnection); + return conn as BigQueryConnection; + } + + it('resolves {env: ...} holding JSON', async () => { + process.env[ENV] = KEY_JSON; + expect(configOf(await connect()).credentials).toEqual(KEY); }); - it('resolves {env: ...} into serviceAccountKeyJsonBase64', async () => { + it('resolves {env: ...} holding base64', async () => { process.env[ENV] = KEY_BASE64; - const config = new MalloyConfig({ - connections: { - bq: { - is: 'bigquery', - projectId: 'test-project', - serviceAccountKeyJsonBase64: {env: ENV}, - }, - }, - }); - expect(config.log).toEqual([]); - const conn = await config.connections.lookupConnection('bq'); - expect(configOf(conn as BigQueryConnection).credentials).toEqual(KEY); + expect(configOf(await connect()).credentials).toEqual(KEY); }); it('cannot reach the structured serviceAccountKey slot, as before', async () => { - // The behavior that makes the two slots above necessary, pinned so it - // stays a deliberate design and not an accident: a `json` property takes - // its value literally, so the reference object itself lands in the - // credentials and the SDK later complains about a missing client_email. - // If malloy ever resolves references into json slots, this test should - // fail and be deleted along with half of this file's reason to exist. + // The behavior that makes the property above necessary, pinned so it stays + // a deliberate design and not an accident: a `json` property takes its + // value literally, so the reference object itself lands in the credentials + // and the SDK later complains about a missing client_email. If malloy ever + // resolves references into json slots, this test should fail and be + // deleted along with half of this file's reason to exist. process.env[ENV] = KEY_JSON; const config = new MalloyConfig({ connections: { - bq: { - is: 'bigquery', - projectId: 'test-project', - serviceAccountKey: {env: ENV}, - }, + bq: {is: 'bigquery', serviceAccountKey: {env: ENV}}, }, }); const conn = await config.connections.lookupConnection('bq'); + expect(conn).toBeInstanceOf(BigQueryConnection); expect(configOf(conn as BigQueryConnection).credentials).toEqual({ env: ENV, }); @@ -257,21 +265,24 @@ describe('bigquery connection property registration', () => { const properties = getConnectionProperties('bigquery') ?? []; const byName = (name: string) => properties.find(p => p.name === name); - it.each(['serviceAccountKeyJson', 'serviceAccountKeyJsonBase64'])( - '%s is a secret string, so an {env: ...} reference resolves into it', - name => { - // Not a cosmetic assertion: the config compiler resolves overlay - // references only in non-`json` slots. Retyping either of these as - // 'json' would silently restore the very gap they exist to close, and - // the failure would show up as an authentication error far from here. - const property = byName(name); - expect(property).toBeDefined(); - expect(property?.type).toBe('secret'); - expect(property?.optional).toBe(true); - } - ); + it('registers serviceAccountKeyJson as a secret string', () => { + // Not a cosmetic assertion: the config compiler resolves overlay + // references only in non-`json` slots. Retyping this as 'json' would + // silently restore the very gap it exists to close, and the failure would + // show up as an authentication error far from here. + const property = byName('serviceAccountKeyJson'); + expect(property).toBeDefined(); + expect(property?.type).toBe('secret'); + expect(property?.optional).toBe(true); + }); it('leaves serviceAccountKey structured', () => { expect(byName('serviceAccountKey')?.type).toBe('json'); }); + + it('registers no separate base64 property', () => { + // One property, two encodings, detected. A second property would be a + // second thing to get wrong in config for no gain. + expect(byName('serviceAccountKeyJsonBase64')).toBeUndefined(); + }); }); diff --git a/packages/malloy-db-bigquery/src/index.ts b/packages/malloy-db-bigquery/src/index.ts index 1a35fc3243..15a6068941 100644 --- a/packages/malloy-db-bigquery/src/index.ts +++ b/packages/malloy-db-bigquery/src/index.ts @@ -34,30 +34,20 @@ registerConnectionType('bigquery', { type: 'json', optional: true, }, - // The two string-typed twins of `serviceAccountKey`. A `json` slot holds - // its value literally — an `{env: "..."}` reference is never resolved in - // one — so a deployment holding its key in an environment variable has no - // way to use the slot above. These are `secret` strings, where references - // do resolve, and the connection parses what arrives. + // The string-typed twin of `serviceAccountKey`. A `json` slot holds its + // value literally — an `{env: "..."}` reference is never resolved in one — + // so a deployment holding its key in an environment variable has no way to + // use the slot above. This is a `secret` string, where references do + // resolve, and the connection parses what arrives. { name: 'serviceAccountKeyJson', displayName: 'Service Account Key (JSON)', type: 'secret', optional: true, description: - 'The entire service account key file as a JSON string, for supplying ' + - 'it from an environment variable or secret manager rather than from ' + - 'disk. Takes precedence over serviceAccountKeyJsonBase64.', - }, - { - name: 'serviceAccountKeyJsonBase64', - displayName: 'Service Account Key (base64 JSON)', - type: 'secret', - optional: true, - description: - 'The entire service account key file, base64-encoded — the same as ' + - 'serviceAccountKeyJson, for transports that mangle the quoting and ' + - 'newlines of raw JSON.', + 'The entire service account key file, as JSON or base64-encoded ' + + 'JSON (detected automatically), for supplying the key from an ' + + 'environment variable or secret manager rather than from disk.', }, { name: 'location', diff --git a/packages/malloy/src/connection/CONTEXT.md b/packages/malloy/src/connection/CONTEXT.md index 563d5383f9..0394688f6b 100644 --- a/packages/malloy/src/connection/CONTEXT.md +++ b/packages/malloy/src/connection/CONTEXT.md @@ -147,7 +147,9 @@ so the docs site stays in sync. Add to the PR checklist: When `shareable: true` (and `databasePath` is a local file), the DuckDB connection binds its primary database to `:memory:` and brackets file access with `ATTACH 'path' AS malloy_db; USE malloy_db.main;` in `setupOnce()` and `DETACH malloy_db` in `idle()`. This releases the OS file lock between operations so other tools (`malloy-cli`, the `duckdb` CLI, another malloy host) can open the same file. The `:memory:` primary stays alive across `idle()`, so the `BaseConnection.schemaCache` and any `CREATE TEMPORARY TABLE` state survive a cycle. Shareable connections do not participate in `DuckDBConnection.activeDBs` sharing — each owns its own in-memory instance. `readOnly: true` is honored via `(READ_ONLY)` on the ATTACH so it scopes the real file, not the writable in-memory primary. **BigQuery** (`displayName: "BigQuery"`): -`projectId` (string), `serviceAccountKeyPath` (file), `location` (string), `maximumBytesBilled` (string, advanced), `timeoutMs` (string, advanced), `billingProjectId` (string, advanced), `setupSQL` (text, advanced) +`projectId` (string), `serviceAccountKeyPath` (file), `serviceAccountKey` (json), `serviceAccountKeyJson` (secret), `location` (string), `maximumBytesBilled` (string, advanced), `timeoutMs` (string, advanced), `billingProjectId` (string, advanced), `setupSQL` (text, advanced) + +The key can arrive three ways, in this order of precedence: `serviceAccountKey` (the parsed object), `serviceAccountKeyJson` (the key file as a string), then the unregistered `client_email`/`private_key` pair the constructor still honors for programmatic use. `serviceAccountKeyJson` exists because `serviceAccountKey` is `json`-typed and so can never hold a reference — a deployment whose key lives in an environment variable has no way to reach it, and the literal `{env: "..."}` object that does reach the SDK fails as "the incoming JSON object does not contain a client_email field". The string slot takes either raw JSON or base64, detected by whether the trimmed value starts with `{`, and rejects anything that is not a service account key or an `external_account` config rather than passing it to the SDK. **PostgreSQL** (`displayName: "PostgreSQL"`): `host` (string), `port` (number), `username` (string), `password` (password), `databaseName` (string), `connectionString` (string, advanced), `setupSQL` (text, advanced), `ssl` (json, advanced) From 2ff0c34e27d6ddbdc9bd8ecf8e137163b8e4231e Mon Sep 17 00:00:00 2001 From: lloyd tabb Date: Fri, 14 Aug 2026 07:33:56 -0700 Subject: [PATCH 3/3] test: run connector unit tests in ci-core, not only in credentialed jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/malloy-db-*/**/*.unit.spec.ts` are hermetic — they stub the SDK or test config parsing — but they sit in the same directories as the tests that need a live warehouse, so they ran only in that backend's credentialed CI job. A hermetic test of BigQuery config parsing sat out every PR that didn't touch BigQuery, which is exactly the PR that breaks it. Four specs across three connector packages were in that position. Add a `connector-unit` project that claims them and run it in `ci-core`. The backend projects exclude the same pattern, so each file still runs in exactly one project: a file matched by two would appear twice in `jest --listTests` and fail scripts/ci-test-sanity-check.sh, which diffs that list against the source tree. Signed-off-by: lloyd tabb --- jest.config.ts | 25 +++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index 10b16757ec..10cce62002 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -29,6 +29,13 @@ const defaultConfig: Config = { }, }; +// What the `connector-unit` project claims, excluded from the backend +// projects that would otherwise also match it. +const connectorUnitIgnored = [ + ...(defaultConfig.testPathIgnorePatterns ?? []), + '\\.unit\\.spec\\.ts$', +]; + const config: Config = { moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], setupFilesAfterEnv: ['/test/jest.setup.ts', 'jest-expect-message'], @@ -102,6 +109,21 @@ const config: Config = { ], }, }, + { + // Connector tests that need no warehouse — `*.unit.spec.ts` under the + // db-* packages. They live in the same directories as the tests that do + // need one, so without this project they would run only in that + // backend's credentialed CI job: a hermetic test of BigQuery config + // parsing would sit out every PR that didn't touch BigQuery, which is + // exactly the PR that breaks it. The db-* projects below exclude the + // same pattern, so each file still runs in exactly one project — a file + // matched by two would appear twice in `jest --listTests` and fail + // scripts/ci-test-sanity-check.sh. + ...defaultConfig, + displayName: 'connector-unit', + roots: ['/packages/'], + testMatch: ['/packages/malloy-db-*/**/*.unit.spec.ts'], + }, { ...defaultConfig, displayName: 'db-all', @@ -110,6 +132,7 @@ const config: Config = { { ...defaultConfig, displayName: 'db-bigquery', + testPathIgnorePatterns: connectorUnitIgnored, roots: [ '/packages/malloy-db-bigquery/', '/test/src/databases/bigquery/', @@ -155,6 +178,7 @@ const config: Config = { { ...defaultConfig, displayName: 'db-publisher', + testPathIgnorePatterns: connectorUnitIgnored, roots: ['/packages/malloy-db-publisher/'], }, { @@ -170,6 +194,7 @@ const config: Config = { { ...defaultConfig, displayName: 'db-databricks', + testPathIgnorePatterns: connectorUnitIgnored, roots: ['/packages/malloy-db-databricks/'], }, ], diff --git a/package.json b/package.json index 21c0170ed0..cb9a9b4d3b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typecheck": "tsc --build test/tsconfig.json", "precheck": "npm run typecheck && npm run test-duckdb", "test-mssql-via-duckdb": "MALLOY_DATABASE=mssql_via_duckdb JEST_SILENT_REPORTER_SHOW_PATHS=true jest --selectProjects db-all --reporters jest-silent-reporter summary", - "ci-core": "MALLOY_DATABASES=duckdb,postgres JEST_SILENT_REPORTER_SHOW_PATHS=true jest --reporters --selectProjects malloy-core malloy-render malloy-render-test --reporters jest-silent-reporter summary --runInBand", + "ci-core": "MALLOY_DATABASES=duckdb,postgres JEST_SILENT_REPORTER_SHOW_PATHS=true jest --reporters --selectProjects malloy-core malloy-render malloy-render-test connector-unit --reporters jest-silent-reporter summary --runInBand", "ci-bigquery": "MALLOY_DATABASE=bigquery JEST_SILENT_REPORTER_SHOW_PATHS=true jest --selectProjects db-all db-bigquery --reporters jest-silent-reporter summary", "ci-duckdb-wasm": "MALLOY_DATABASE=duckdb_wasm JEST_SILENT_REPORTER_SHOW_PATHS=true jest --selectProjects db-all db-duckdb --reporters jest-silent-reporter summary --runInBand", "ci-duckdb": "MALLOY_DATABASE=duckdb JEST_SILENT_REPORTER_SHOW_PATHS=true jest --selectProjects db-all db-duckdb db-duckdb-core simple-builder --reporters jest-silent-reporter summary",