0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-13 22:11:20 -05:00

add short-lived db tokens

This commit is contained in:
Fred K. Schott 2024-02-06 13:21:56 -08:00
parent 92d7d84e6b
commit eb7989dfda
11 changed files with 360 additions and 29 deletions

View file

@ -59,17 +59,19 @@
"drizzle-orm": "^0.28.6",
"kleur": "^4.1.5",
"nanoid": "^5.0.1",
"open": "^10.0.3",
"ora": "^7.0.1",
"prompts": "^2.4.2",
"yargs-parser": "^21.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/chai": "^4.3.6",
"@types/deep-diff": "^1.0.5",
"@types/diff": "^5.0.8",
"@types/yargs-parser": "^21.0.3",
"@types/chai": "^4.3.6",
"@types/mocha": "^10.0.2",
"@types/prompts": "^2.4.8",
"@types/yargs-parser": "^21.0.3",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"chai": "^4.3.10",

View file

@ -0,0 +1,61 @@
import type { AstroConfig } from 'astro';
import { mkdir, writeFile } from 'node:fs/promises';
import prompts from 'prompts';
import type { Arguments } from 'yargs-parser';
import { PROJECT_ID_FILE, getSessionIdFromFile } from '../../../tokens.js';
import { getAstroStudioUrl } from '../../../utils.js';
export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) {
const linkUrl = new URL(getAstroStudioUrl() + '/auth/cli/link');
const sessionToken = await getSessionIdFromFile();
if (!sessionToken) {
console.error('You must be logged in to link a project.');
process.exit(1);
}
const workspaceIdName = await promptWorkspaceName();
const projectIdName = await promptProjectName();
const response = await fetch(linkUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getSessionIdFromFile()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({projectIdName, workspaceIdName})
});
if (!response.ok) {
console.error(`Failed to link project: ${response.status} ${response.statusText}`);
process.exit(1);
}
const { data } = await response.json();
await mkdir(new URL('.', PROJECT_ID_FILE), { recursive: true });
await writeFile(PROJECT_ID_FILE, `${data.id}`);
console.info('Project linked.');
}
export async function promptProjectName(defaultName?: string): Promise<string> {
const { projectName } = await prompts({
type: 'text',
name: 'projectName',
message: 'Project ID',
initial: defaultName,
});
if (typeof projectName !== 'string') {
process.exit(0);
}
return projectName;
}
export async function promptWorkspaceName(defaultName?: string): Promise<string> {
const { workspaceName } = await prompts({
type: 'text',
name: 'workspaceName',
message: 'Workspace ID',
initial: defaultName,
});
if (typeof workspaceName !== 'string') {
process.exit(0);
}
return workspaceName;
}

View file

@ -0,0 +1,53 @@
import type { AstroConfig } from 'astro';
import { cyan } from 'kleur/colors';
import { mkdir, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import ora from 'ora';
import type { Arguments } from 'yargs-parser';
import { getAstroStudioUrl } from '../../../utils.js';
import open from 'open';
import { SESSION_LOGIN_FILE } from '../../../tokens.js';
function serveAndResolveSession(): Promise<string> {
let resolve: (value: string | PromiseLike<string>) => void,
reject: (value?: string | PromiseLike<string>) => void;
const sessionPromise = new Promise<string>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
const server = createServer((req, res) => {
res.writeHead(200);
res.end();
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
const session = url.searchParams.get('session');
if (!session) {
reject();
} else {
resolve(session);
}
}).listen(5710, 'localhost');
return sessionPromise.finally(() => {
server.closeAllConnections();
server.close();
});
}
export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) {
let session = flags.session;
const loginUrl = getAstroStudioUrl() + '/auth/cli';
if (!session) {
console.log(`Opening ${cyan(loginUrl)} in your browser...`);
console.log(`If something goes wrong, copy-and-paste the URL into your browser.`);
open(loginUrl);
const spinner = ora('Waiting for confirmation...');
session = await serveAndResolveSession();
spinner.succeed('Successfully logged in!');
}
await mkdir(new URL('.', SESSION_LOGIN_FILE), { recursive: true });
await writeFile(SESSION_LOGIN_FILE, `${session}`);
console.log('Logged in 🚀');
}

View file

@ -0,0 +1,9 @@
import type { AstroConfig } from 'astro';
import { unlink } from 'node:fs/promises';
import type { Arguments } from 'yargs-parser';
import { SESSION_LOGIN_FILE } from '../../../tokens.js';
export async function cmd({ }: { config: AstroConfig; flags: Arguments }) {
await unlink(SESSION_LOGIN_FILE);
console.log('Successfully logged out of Astro Studio.');
}

View file

@ -2,9 +2,15 @@ import { createClient, type InStatement } from '@libsql/client';
import type { AstroConfig } from 'astro';
import deepDiff from 'deep-diff';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import { red } from 'kleur/colors';
import prompts from 'prompts';
import type { Arguments } from 'yargs-parser';
import type { AstroConfigWithDB } from '../../../types.js';
import { APP_TOKEN_ERROR } from '../../../errors.js';
import { setupDbTables } from '../../../queries.js';
import { getManagedAppToken } from '../../../tokens.js';
import type { AstroConfigWithDB, DBSnapshot } from '../../../types.js';
import { getRemoteDatabaseUrl } from '../../../utils.js';
import { getMigrationQueries } from '../../migration-queries.js';
import {
createCurrentSnapshot,
createEmptySnapshot,
@ -13,19 +19,12 @@ import {
loadInitialSnapshot,
loadMigration,
} from '../../migrations.js';
import type { DBSnapshot } from '../../../types.js';
import { getAstroStudioEnv, getRemoteDatabaseUrl } from '../../../utils.js';
import { getMigrationQueries } from '../../migration-queries.js';
import { setupDbTables } from '../../../queries.js';
import prompts from 'prompts';
import { red } from 'kleur/colors';
const { diff } = deepDiff;
export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) {
const isSeedData = flags.seed;
const isDryRun = flags.dryRun;
const appToken = flags.token ?? getAstroStudioEnv().ASTRO_STUDIO_APP_TOKEN;
const currentSnapshot = createCurrentSnapshot(config);
const allMigrationFiles = await getMigrations();
if (allMigrationFiles.length === 0) {
@ -40,15 +39,18 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum
console.log(calculatedDiff);
process.exit(1);
}
const appToken = await getManagedAppToken(flags.token);
if (!appToken) {
console.error(APP_TOKEN_ERROR);
process.exit(1);
}
// get all migrations from the filesystem
const allLocalMigrations = await getMigrations();
const { data: missingMigrations } = await prepareMigrateQuery({
migrations: allLocalMigrations,
appToken,
appToken: appToken.token,
});
// exit early if there are no migrations to push
if (missingMigrations.length === 0) {
@ -58,13 +60,19 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum
// push the database schema
if (missingMigrations.length > 0) {
console.log(`Pushing ${missingMigrations.length} migrations...`);
await pushSchema({ migrations: missingMigrations, appToken, isDryRun, currentSnapshot });
await pushSchema({
migrations: missingMigrations,
appToken: appToken.token,
isDryRun,
currentSnapshot,
});
}
// push the database seed data
if (isSeedData) {
console.info('Pushing data...');
await pushData({ config, appToken, isDryRun });
await pushData({ config, appToken: appToken.token, isDryRun });
}
await appToken.destroy();
console.info('Push complete!');
}
@ -201,7 +209,7 @@ async function runMigrateQuery({
return new Response(null, { status: 200 });
}
const url = new URL('/db/migrate/run', getRemoteDatabaseUrl());
const url = new URL('/migrations/run', getRemoteDatabaseUrl());
return await fetch(url, {
method: 'POST',
@ -219,7 +227,7 @@ async function prepareMigrateQuery({
migrations: string[];
appToken: string;
}) {
const url = new URL('/db/migrate/prepare', getRemoteDatabaseUrl());
const url = new URL('/migrations/prepare', getRemoteDatabaseUrl());
const requestBody = {
migrations,
experimentalVersion: 1,

View file

@ -1,20 +1,21 @@
import type { AstroConfig } from 'astro';
import { sql } from 'drizzle-orm';
import type { Arguments } from 'yargs-parser';
import { APP_TOKEN_ERROR } from '../../../errors.js';
import { getAstroStudioEnv, getRemoteDatabaseUrl } from '../../../utils.js';
import { createRemoteDatabaseClient } from '../../../../runtime/db-client.js';
import { APP_TOKEN_ERROR } from '../../../errors.js';
import { getManagedAppToken } from '../../../tokens.js';
import { getRemoteDatabaseUrl } from '../../../utils.js';
export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) {
const query = flags.query;
const appToken = flags.token ?? getAstroStudioEnv().ASTRO_STUDIO_APP_TOKEN;
const appToken = await getManagedAppToken(flags.token);
if (!appToken) {
console.error(APP_TOKEN_ERROR);
process.exit(1);
}
const db = createRemoteDatabaseClient(appToken, getRemoteDatabaseUrl());
const db = createRemoteDatabaseClient(appToken.token, getRemoteDatabaseUrl());
// Temporary: create the migration table just in case it doesn't exist
const result = await db.run(sql.raw(query));
await appToken.destroy();
console.log(result);
}

View file

@ -12,20 +12,32 @@ export async function cli({ flags, config }: { flags: Arguments; config: AstroCo
switch (command) {
case 'shell': {
const { cmd: shellCommand } = await import('./commands/shell/index.js');
return await shellCommand({ config, flags });
const { cmd } = await import('./commands/shell/index.js');
return await cmd({ config, flags });
}
case 'sync': {
const { cmd: syncCommand } = await import('./commands/sync/index.js');
return await syncCommand({ config, flags });
const { cmd } = await import('./commands/sync/index.js');
return await cmd({ config, flags });
}
case 'push': {
const { cmd: pushCommand } = await import('./commands/push/index.js');
return await pushCommand({ config, flags });
const { cmd } = await import('./commands/push/index.js');
return await cmd({ config, flags });
}
case 'verify': {
const { cmd: verifyCommand } = await import('./commands/verify/index.js');
return await verifyCommand({ config, flags });
const { cmd } = await import('./commands/verify/index.js');
return await cmd({ config, flags });
}
case 'login': {
const { cmd } = await import('./commands/login/index.js');
return await cmd({ config, flags });
}
case 'logout': {
const { cmd } = await import('./commands/logout/index.js');
return await cmd({ config, flags });
}
case 'link': {
const { cmd } = await import('./commands/link/index.js');
return await cmd({ config, flags });
}
default: {
if (command == null) {

View file

@ -0,0 +1,134 @@
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { getAstroStudioEnv, getAstroStudioUrl } from './utils.js';
export const SESSION_LOGIN_FILE = pathToFileURL(join(homedir(), '.astro', 'session-token'));
export const PROJECT_ID_FILE = pathToFileURL(join(process.cwd(), '.astro', 'link'));
export interface ManagedAppToken {
token: string;
renew(): Promise<void>;
destroy(): Promise<void>;
}
class ManagedLocalAppToken implements ManagedAppToken {
token: string;
constructor(token: string) {
this.token = token;
}
async renew() {}
async destroy() {}
}
class ManagedRemoteAppToken implements ManagedAppToken {
token: string;
session: string;
projectId: string;
ttl: number;
renewTimer: NodeJS.Timeout | undefined;
static async create(sessionToken: string, projectId: string) {
const response = await fetch(new URL(`${getAstroStudioUrl()}/auth/cli/token-create`), {
method: 'POST',
headers: new Headers({
Authorization: `Bearer ${sessionToken}`,
}),
body: JSON.stringify({ projectId }),
});
const { token: shortLivedAppToken, ttl } = (await response.json());
return new ManagedRemoteAppToken({
token: shortLivedAppToken,
session: sessionToken,
projectId,
ttl,
});
}
constructor(options: { token: string; session: string; projectId: string; ttl: number }) {
this.token = options.token;
this.session = options.session;
this.projectId = options.projectId;
this.ttl = options.ttl;
this.renewTimer = setTimeout(() => this.renew(), (1000 * 60 * 5) / 2);
}
private async fetch(url: string, body: unknown) {
return fetch(`${getAstroStudioUrl()}${url}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.session}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
async renew() {
clearTimeout(this.renewTimer);
delete this.renewTimer;
try {
const response = await this.fetch('/auth/cli/token-renew', {
token: this.token,
projectId: this.projectId,
});
if (response.status === 200) {
this.renewTimer = setTimeout(() => this.renew(), (1000 * 60 * this.ttl) / 2);
} else {
throw new Error(`Unexpected response: ${response.status} ${response.statusText}`);
}
} catch (error: any) {
const retryIn = (60 * this.ttl) / 10;
console.error(`Failed to renew token. Retrying in ${retryIn} seconds.`, error?.message);
this.renewTimer = setTimeout(() => this.renew(), retryIn * 1000);
}
}
async destroy() {
try {
const response = await this.fetch('/auth/cli/token-delete', {
token: this.token,
projectId: this.projectId,
});
if (response.status !== 200) {
throw new Error(`Unexpected response: ${response.status} ${response.statusText}`);
}
} catch (error: any) {
console.error('Failed to delete token.', error?.message);
}
}
}
export async function getProjectIdFromFile() {
try {
return await readFile(PROJECT_ID_FILE, 'utf-8');
} catch (error) {
return undefined;
}
}
export async function getSessionIdFromFile() {
try {
return await readFile(SESSION_LOGIN_FILE, 'utf-8');
} catch (error) {
return undefined;
}
}
export async function getManagedAppToken(token?: string): Promise<ManagedAppToken | undefined> {
if (token) {
return new ManagedLocalAppToken(token);
}
const { ASTRO_STUDIO_APP_TOKEN } = getAstroStudioEnv();
if (ASTRO_STUDIO_APP_TOKEN) {
return new ManagedLocalAppToken(ASTRO_STUDIO_APP_TOKEN);
}
const sessionToken = await getSessionIdFromFile();
const projectId = await getProjectIdFromFile();
if (!sessionToken || !projectId) {
return undefined;
}
return ManagedRemoteAppToken.create(sessionToken, projectId);
}

View file

@ -12,3 +12,8 @@ export function getRemoteDatabaseUrl(): string {
const env = getAstroStudioEnv();
return env.ASTRO_STUDIO_REMOTE_DB_URL;
}
export function getAstroStudioUrl(): string {
const env = getAstroStudioEnv();
return env.ASTRO_STUDIO_URL;
}

View file

@ -50,7 +50,7 @@ function checkIfModificationIsAllowed(collections: DBCollections, Table: SQLiteT
}
export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string) {
const url = new URL('./db/query/', remoteDbURL);
const url = new URL('/db/query', remoteDbURL);
const db = drizzleProxy(async (sql, parameters, method) => {
const requestBody: InStatement = { sql, args: parameters };

46
pnpm-lock.yaml generated
View file

@ -3805,6 +3805,12 @@ importers:
nanoid:
specifier: ^5.0.1
version: 5.0.1
open:
specifier: ^10.0.3
version: 10.0.3
ora:
specifier: ^7.0.1
version: 7.0.1
prompts:
specifier: ^2.4.2
version: 2.4.2
@ -9108,6 +9114,13 @@ packages:
ieee754: 1.2.1
dev: false
/bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
dependencies:
run-applescript: 7.0.0
dev: false
/bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -9799,6 +9812,19 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/default-browser-id@5.0.0:
resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
engines: {node: '>=18'}
dev: false
/default-browser@5.2.1:
resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
engines: {node: '>=18'}
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.0
dev: false
/defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
dependencies:
@ -9814,6 +9840,11 @@ packages:
has-property-descriptors: 1.0.1
dev: true
/define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
dev: false
/define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@ -13401,6 +13432,16 @@ packages:
resolution: {integrity: sha512-6mp/gpLQz/ZwrGLz+i7tSJ3eWNLE1KxLXHO+b6xxRyZ1Alp4TgTcvHiQ89rC2IkvsU3/IRhpIJuxl7rRCwUzLA==}
dev: false
/open@10.0.3:
resolution: {integrity: sha512-dtbI5oW7987hwC9qjJTyABldTaa19SuyJse1QboWv3b0qCcrrLNVDqBx1XgELAjh9QTVQaP/C5b1nhQebd1H2A==}
engines: {node: '>=18'}
dependencies:
default-browser: 5.2.1
define-lazy-prop: 3.0.0
is-inside-container: 1.0.0
is-wsl: 3.1.0
dev: false
/optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@ -14815,6 +14856,11 @@ packages:
resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==}
dev: true
/run-applescript@7.0.0:
resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==}
engines: {node: '>=18'}
dev: false
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies: