Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/happy-rooms-scream.md
Original file line number Diff line number Diff line change
@@ -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 <token>
```

See the [Astro DB integration documentation](https://docs.astro.build/en/guides/integrations-guide/db/#astro-db-cli-reference) for more information.
8 changes: 4 additions & 4 deletions packages/astro/test/custom-404-implicit-rerouting.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -52,15 +52,15 @@ 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);
});

// 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);
Expand Down
5 changes: 3 additions & 2 deletions packages/db/src/core/cli/commands/execute/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions packages/db/src/core/cli/commands/push/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -67,7 +72,7 @@ export async function cmd({
await pushSchema({
statements: migrationQueries,
dbInfo,
appToken: flags.token ?? dbInfo.token,
appToken,
isDryRun,
currentSnapshot: currentSnapshot,
});
Expand Down
5 changes: 3 additions & 2 deletions packages/db/src/core/cli/commands/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions packages/db/src/core/cli/commands/verify/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(),
Expand Down
12 changes: 12 additions & 0 deletions packages/db/src/core/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
19 changes: 19 additions & 0 deletions packages/db/src/core/utils.ts
Original file line number Diff line number Diff line change
@@ -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<AstroConfig['vite']>['plugins'][number];
Expand All @@ -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);
}
Expand Down
28 changes: 28 additions & 0 deletions packages/db/test/basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});
34 changes: 34 additions & 0 deletions packages/db/test/error-handling.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand Down
7 changes: 5 additions & 2 deletions packages/db/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -47,6 +49,7 @@ export async function setupRemoteDb(astroConfig) {
flags: {
_: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'],
remote: true,
...(options.useDbAppTokenFlag ? { dbAppToken: token } : {}),
},
});

Expand Down
Loading