diff --git a/.changeset/happy-rooms-scream.md b/.changeset/happy-rooms-scream.md new file mode 100644 index 000000000000..e1a15e1934af --- /dev/null +++ b/.changeset/happy-rooms-scream.md @@ -0,0 +1,15 @@ +--- +'@astrojs/db': minor +--- + +Adds a `--db-app-token` CLI flag to `astro db` commands `execute`, `push`, `query`, and `verify` + +The new Astro DB CLI flags allow you to provide a remote database app token directly instead of `ASTRO_DB_APP_TOKEN`. This ensures that no untrusted code (e.g. CI / CD workflows) has access to the secret that is only needed by the `astro db` commands. + +The following command can be used to safely push database configuration changes to your project database: + +``` +astro db push --db-app-token +``` + +See the [Astro DB integration documentation](https://docs.astro.build/en/guides/integrations-guide/db/#astro-db-cli-reference) for more information. diff --git a/packages/astro/test/custom-404-implicit-rerouting.test.js b/packages/astro/test/custom-404-implicit-rerouting.test.js index 49f7e56689e7..b5139a78980d 100644 --- a/packages/astro/test/custom-404-implicit-rerouting.test.js +++ b/packages/astro/test/custom-404-implicit-rerouting.test.js @@ -27,13 +27,13 @@ for (const caseNumber of [1, 2, 3, 4, 5]) { }); // sanity check - it('dev server handles normal requests', { timeout: 1000 }, async () => { + it('dev server handles normal requests', { timeout: 3000 }, async () => { const response = await fixture.fetch('/'); assert.equal(response.status, 200); }); // IMPORTANT: never skip - it('dev server stays responsive', { timeout: 1000 }, async () => { + it('dev server stays responsive', { timeout: 3000 }, async () => { const response = await fixture.fetch('/alvsibdlvjks'); assert.equal(response.status, 404); }); @@ -52,7 +52,7 @@ for (const caseNumber of [1, 2, 3, 4, 5]) { }); // sanity check - it('prod server handles normal requests', { timeout: 1000 }, async () => { + it('prod server handles normal requests', { timeout: 3000 }, async () => { const response = await app.render(new Request('https://example.com/')); assert.equal(response.status, 200); }); @@ -60,7 +60,7 @@ for (const caseNumber of [1, 2, 3, 4, 5]) { // IMPORTANT: never skip it( 'prod server stays responsive for case number ' + caseNumber, - { timeout: 1000 }, + { timeout: 3000 }, async () => { const response = await app.render(new Request('https://example.com/alvsibdlvjks')); assert.equal(response.status, 404); diff --git a/packages/db/src/core/cli/commands/execute/index.ts b/packages/db/src/core/cli/commands/execute/index.ts index 0ae836425dca..41838904267f 100644 --- a/packages/db/src/core/cli/commands/execute/index.ts +++ b/packages/db/src/core/cli/commands/execute/index.ts @@ -15,7 +15,7 @@ import { } from '../../../integration/vite-plugin-db.js'; import { bundleFile, importBundledFile } from '../../../load-file.js'; import type { DBConfig } from '../../../types.js'; -import { getRemoteDatabaseInfo } from '../../../utils.js'; +import { getRemoteDatabaseInfo, resolveDbAppToken } from '../../../utils.js'; export async function cmd({ astroConfig, @@ -41,9 +41,10 @@ export async function cmd({ let virtualModContents: string; if (flags.remote) { const dbInfo = getRemoteDatabaseInfo(); + const appToken = resolveDbAppToken(flags, dbInfo.token); virtualModContents = getRemoteVirtualModContents({ tables: dbConfig.tables ?? {}, - appToken: flags.token ?? dbInfo.token, + appToken, isBuild: false, output: 'server', localExecution: true, diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index 663b648119cf..a9781af37ae7 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -5,7 +5,11 @@ import type { Arguments } from 'yargs-parser'; import { MIGRATION_VERSION } from '../../../consts.js'; import { createClient } from '../../../db-client/libsql-node.js'; import type { DBConfig, DBSnapshot } from '../../../types.js'; -import { getRemoteDatabaseInfo, type RemoteDatabaseInfo } from '../../../utils.js'; +import { + getRemoteDatabaseInfo, + type RemoteDatabaseInfo, + resolveDbAppToken, +} from '../../../utils.js'; import { createCurrentSnapshot, createEmptySnapshot, @@ -25,7 +29,8 @@ export async function cmd({ const isDryRun = flags.dryRun; const isForceReset = flags.forceReset; const dbInfo = getRemoteDatabaseInfo(); - const productionSnapshot = await getProductionCurrentSnapshot(dbInfo); + const appToken = resolveDbAppToken(flags, dbInfo.token); + const productionSnapshot = await getProductionCurrentSnapshot({ ...dbInfo, token: appToken }); const currentSnapshot = createCurrentSnapshot(dbConfig); const isFromScratch = !productionSnapshot; const { queries: migrationQueries, confirmations } = await getMigrationQueries({ @@ -67,7 +72,7 @@ export async function cmd({ await pushSchema({ statements: migrationQueries, dbInfo, - appToken: flags.token ?? dbInfo.token, + appToken, isDryRun, currentSnapshot: currentSnapshot, }); diff --git a/packages/db/src/core/cli/commands/shell/index.ts b/packages/db/src/core/cli/commands/shell/index.ts index a4b61863c92e..d652fa017437 100644 --- a/packages/db/src/core/cli/commands/shell/index.ts +++ b/packages/db/src/core/cli/commands/shell/index.ts @@ -7,7 +7,7 @@ import { createClient as createLocalDatabaseClient } from '../../../db-client/li import { createClient as createRemoteDatabaseClient } from '../../../db-client/libsql-node.js'; import { SHELL_QUERY_MISSING_ERROR } from '../../../errors.js'; import type { DBConfigInput } from '../../../types.js'; -import { getAstroEnv, getRemoteDatabaseInfo } from '../../../utils.js'; +import { getAstroEnv, getRemoteDatabaseInfo, resolveDbAppToken } from '../../../utils.js'; export async function cmd({ flags, @@ -24,7 +24,8 @@ export async function cmd({ } const dbInfo = getRemoteDatabaseInfo(); if (flags.remote) { - const db = createRemoteDatabaseClient(dbInfo); + const appToken = resolveDbAppToken(flags, dbInfo.token); + const db = createRemoteDatabaseClient({ ...dbInfo, token: appToken }); const result = await db.run(sql.raw(query)); console.log(result); } else { diff --git a/packages/db/src/core/cli/commands/verify/index.ts b/packages/db/src/core/cli/commands/verify/index.ts index ae9c776090ca..3f876c323259 100644 --- a/packages/db/src/core/cli/commands/verify/index.ts +++ b/packages/db/src/core/cli/commands/verify/index.ts @@ -1,7 +1,7 @@ import type { AstroConfig } from 'astro'; import type { Arguments } from 'yargs-parser'; import type { DBConfig } from '../../../types.js'; -import { getRemoteDatabaseInfo } from '../../../utils.js'; +import { getRemoteDatabaseInfo, resolveDbAppToken } from '../../../utils.js'; import { createCurrentSnapshot, createEmptySnapshot, @@ -20,7 +20,8 @@ export async function cmd({ }) { const isJson = flags.json; const dbInfo = getRemoteDatabaseInfo(); - const productionSnapshot = await getProductionCurrentSnapshot(dbInfo); + const appToken = resolveDbAppToken(flags, dbInfo.token); + const productionSnapshot = await getProductionCurrentSnapshot({ ...dbInfo, token: appToken }); const currentSnapshot = createCurrentSnapshot(dbConfig); const { queries: migrationQueries, confirmations } = await getMigrationQueries({ oldSnapshot: productionSnapshot || createEmptySnapshot(), diff --git a/packages/db/src/core/cli/index.ts b/packages/db/src/core/cli/index.ts index 915d2a20f548..435207d2c0f0 100644 --- a/packages/db/src/core/cli/index.ts +++ b/packages/db/src/core/cli/index.ts @@ -14,6 +14,7 @@ export async function cli({ // Most commands are `astro db foo`, but for now login/logout // are also handled by this package, so first check if this is a db command. const command = args[2] === 'db' ? args[3] : args[2]; + validateDbAppTokenFlag(command, flags); const { dbConfig } = await resolveDbConfig(astroConfig); switch (command) { @@ -68,3 +69,14 @@ export async function cli({ } } } + +function validateDbAppTokenFlag(command: string | undefined, flags: Arguments) { + if (command !== 'execute' && command !== 'push' && command !== 'verify' && command !== 'shell') return; + + const dbAppToken = (flags as Arguments & { dbAppToken?: unknown }).dbAppToken; + if (dbAppToken == null) return; + if (typeof dbAppToken !== 'string') { + console.error(`Invalid value for --db-app-token; expected a string.`); + process.exit(1); + } +} diff --git a/packages/db/src/core/utils.ts b/packages/db/src/core/utils.ts index 1a58ddcab646..5410b2522fbe 100644 --- a/packages/db/src/core/utils.ts +++ b/packages/db/src/core/utils.ts @@ -1,5 +1,6 @@ import type { AstroConfig, AstroIntegration } from 'astro'; import { loadEnv } from 'vite'; +import type { Arguments } from 'yargs-parser'; import './types.js'; export type VitePlugin = Required['plugins'][number]; @@ -23,6 +24,24 @@ export function getRemoteDatabaseInfo(): RemoteDatabaseInfo { }; } +export function resolveDbAppToken( + flags: Arguments, + envToken: string, +): string; +export function resolveDbAppToken( + flags: Arguments, + envToken: string | undefined, +): string | undefined; +export function resolveDbAppToken( + flags: Arguments, + envToken: string | undefined, +): string | undefined { + const dbAppToken = (flags as Arguments & { dbAppToken?: unknown }).dbAppToken; + if (typeof dbAppToken === 'string') return dbAppToken; + + return envToken; +} + export function getDbDirectoryUrl(root: URL | string) { return new URL('db/', root); } diff --git a/packages/db/test/basics.test.js b/packages/db/test/basics.test.js index 701f31d1c9d7..c2d6aaea0df9 100644 --- a/packages/db/test/basics.test.js +++ b/packages/db/test/basics.test.js @@ -3,6 +3,7 @@ import { after, before, describe, it } from 'node:test'; import { load as cheerioLoad } from 'cheerio'; import testAdapter from '../../astro/test/test-adapter.js'; import { loadFixture } from '../../astro/test/test-utils.js'; +import { resolveDbAppToken } from '../dist/core/utils.js'; import { clearEnvironment, setupRemoteDb } from './test-utils.js'; describe('astro:db', () => { @@ -200,4 +201,31 @@ describe('astro:db', () => { assert.equal(ul.children().length, 5); }); }); + + describe('cli --db-app-token', () => { + it('Seeds remote database with --db-app-token flag set and without ASTRO_DB_APP_TOKEN env being set', async () => { + clearEnvironment(); + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + + const remoteDbServer = await setupRemoteDb(fixture.config, { useDbAppTokenFlag: true }); + try { + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + } finally { + await remoteDbServer.stop(); + } + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + }); + }); + + describe('Precedence for --db-app-token and ASTRO_DB_APP_TOKEN handled correctly', () => { + it('prefers --db-app-token over `ASTRO_DB_APP_TOKEN`', () => { + const flags = /** @type {any} */ ({ _: [], dbAppToken: 'from-flag' }); + assert.equal(resolveDbAppToken(flags, 'from-env'), 'from-flag'); + }); + + it('falls back to ASTRO_DB_APP_TOKEN if no flags set', () => { + const flags = /** @type {any} */ ({ _: [] }); + assert.equal(resolveDbAppToken(flags, 'from-env'), 'from-env'); + }); + }); }); diff --git a/packages/db/test/error-handling.test.js b/packages/db/test/error-handling.test.js index 82ae715908ca..8dc6e1e89b13 100644 --- a/packages/db/test/error-handling.test.js +++ b/packages/db/test/error-handling.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { loadFixture } from '../../astro/test/test-utils.js'; +import { cli } from '../dist/core/cli/index.js'; import { setupRemoteDb } from './test-utils.js'; const foreignKeyConstraintError = 'LibsqlError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed'; @@ -13,6 +14,39 @@ describe('astro:db - error handling', () => { }); }); + it('Errors on invalid --db-app-token input', async () => { + const originalExit = process.exit; + const originalError = console.error; + /** @type {string[]} */ + const errorMessages = []; + console.error = (...args) => { + errorMessages.push(args.map(String).join(' ')); + }; + process.exit = (code) => { + throw new Error(`EXIT_${code}`); + }; + + try { + await cli({ + config: fixture.config, + flags: { + _: [undefined, 'astro', 'db', 'verify'], + dbAppToken: true, + }, + }); + assert.fail('Expected command to exit'); + } catch (err) { + assert.match(String(err), /EXIT_1/); + assert.ok( + errorMessages.some((m) => m.includes('Invalid value for --db-app-token')), + `Expected error output to mention invalid --db-app-token, got: ${errorMessages.join('\n')}`, + ); + } finally { + process.exit = originalExit; + console.error = originalError; + } + }); + describe('development', () => { let devServer; diff --git a/packages/db/test/test-utils.js b/packages/db/test/test-utils.js index fbc117a39f67..7e1a6623debd 100644 --- a/packages/db/test/test-utils.js +++ b/packages/db/test/test-utils.js @@ -9,13 +9,15 @@ const isWindows = process.platform === 'win32'; /** * @param {import('astro').AstroConfig} astroConfig */ -export async function setupRemoteDb(astroConfig) { +export async function setupRemoteDb(astroConfig, options = {}) { const url = isWindows ? new URL(`./.astro/${Date.now()}.db`, astroConfig.root) : new URL(`./${Date.now()}.db`, astroConfig.root); const token = 'foo'; process.env.ASTRO_DB_REMOTE_URL = url.toString(); - process.env.ASTRO_DB_APP_TOKEN = token; + if (!options.useDbAppTokenFlag) { + process.env.ASTRO_DB_APP_TOKEN = token; + } process.env.ASTRO_INTERNAL_TEST_REMOTE = true; if (isWindows) { @@ -47,6 +49,7 @@ export async function setupRemoteDb(astroConfig) { flags: { _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'], remote: true, + ...(options.useDbAppTokenFlag ? { dbAppToken: token } : {}), }, });