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
104 changes: 104 additions & 0 deletions scripts/start-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { spawn } from 'node:child_process';

const DEFAULT_DB = 'mysql://root@localhost:3306/thoth';
const SECRET_FILENAME = 'secret';

/**
* Resolves the Thoth home directory path.
* Uses THOTH_HOME_DIR env var if set, otherwise defaults to ~/.thoth
*/
function resolveHomeDirectory() {
const customHomeDirectory = process.env.THOTH_HOME_DIR;
if (customHomeDirectory) {
return customHomeDirectory;
}
return path.join(homedir(), '.thoth');
}

/**
* Ensures the home directory exists, creating it if necessary.
*/
function ensureHomeDirectory(homeDirectory) {
if (!existsSync(homeDirectory)) {
console.log(`Creating Thoth home directory: ${homeDirectory}`);
mkdirSync(homeDirectory, { recursive: true });
}
}

/**
* Resolves the database connection string.
* Uses DB env var if set, otherwise returns default MySQL connection.
*/
function resolveDatabase() {
const database = process.env.DB;
if (database) {
return database;
}
console.log(`DB not set, using default: ${DEFAULT_DB}`);
return DEFAULT_DB;
}

/**
* Resolves the Better Auth secret.
* Priority:
* 1. BETTER_AUTH_SECRET env var if set
* 2. Read from {homeDirectory}/secret if file exists
* 3. Generate new secret and save to {homeDirectory}/secret
*/
function resolveSecret(homeDirectory) {
const environmentSecret = process.env.BETTER_AUTH_SECRET;
if (environmentSecret) {
return environmentSecret;
}

const secretPath = path.join(homeDirectory, SECRET_FILENAME);

if (existsSync(secretPath)) {
console.log(`Reading auth secret from: ${secretPath}`);
return readFileSync(secretPath, 'utf8').trim();
}

console.log(`Generating new auth secret and saving to: ${secretPath}`);
const newSecret = randomBytes(32).toString('hex');
writeFileSync(secretPath, newSecret, { mode: 0o600 });
return newSecret;
}

/**
* Starts the Next.js server with the resolved environment variables.
*/
function startServer(environment) {
console.log('Starting server with pnpm start...');

const child = spawn('pnpm', ['start'], {
stdio: 'inherit',
env: { ...process.env, ...environment },
shell: true,
});

child.on('error', (error) => {
console.error('Failed to start server:', error);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

child.on('exit', (code) => {
// eslint-disable-next-line unicorn/no-process-exit
process.exit(code ?? 0);
});
}

// Main execution
const homeDirectory = resolveHomeDirectory();
ensureHomeDirectory(homeDirectory);

const environment = {
DB: resolveDatabase(),
BETTER_AUTH_SECRET: resolveSecret(homeDirectory),
};

startServer(environment);
121 changes: 77 additions & 44 deletions src/lib/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable unicorn/prefer-ternary */
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';
import { createPool } from 'mysql2/promise';
Expand All @@ -8,61 +9,93 @@ import { connection } from 'next/server';

let authInstance: ReturnType<typeof betterAuth> | null = null;

/**
* Checks if all OIDC environment variables are configured.
* If all are present, OIDC authentication will be used.
* If any are missing, credentials (email/password) authentication will be used.
*/
function hasOidcConfig(environment: Awaited<ReturnType<typeof getEnvironment>>): boolean {
return Boolean(
environment.OIDC_CLIENT_ID &&
environment.OIDC_CLIENT_SECRET &&
environment.OIDC_DISCOVERY_URL &&
environment.OIDC_AUTHORIZATION_URL
);
}

async function initializeAuth() {
if (authInstance === null) {
await connection().then(getDatabase);

const environment = await getEnvironment();
const useOidc = hasOidcConfig(environment);

authInstance = betterAuth({
database: createPool(environment.DB),
plugins: [
genericOAuth({
config: [
{
providerId: 'oidc',
clientId: environment.OIDC_CLIENT_ID,
clientSecret: environment.OIDC_CLIENT_SECRET,
authorizationUrl: environment.OIDC_AUTHORIZATION_URL,
discoveryUrl: environment.OIDC_DISCOVERY_URL,
scopes: ['openid', 'profile', 'email'],
},
],
}),
],
trustedOrigins: environment.NODE_ENV === 'development' ? ['http://localhost:3000'] : [],
secret: environment.BETTER_AUTH_SECRET,
hooks: {},
databaseHooks: {
user: {
create: {
after: async (user) => {
const workspaceRepository = await getWorkspaceRepository();
const workspace = await workspaceRepository.create({
name: 'Default Workspace',
userId: user.id,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
} satisfies WorkspaceCreate);
const databaseHooks = {
user: {
create: {
after: async (user: { id: string }) => {
const workspaceRepository = await getWorkspaceRepository();
const workspace = await workspaceRepository.create({
name: 'Default Workspace',
userId: user.id,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
} satisfies WorkspaceCreate);

const containerRepository = await getContainerRepository();
const pageData: PageContainerCreate = {
name: 'Welcome',
type: 'page',
userId: user.id,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
workspaceId: workspace.id,
emoji: '👋',
parentId: null,
};
const containerRepository = await getContainerRepository();
const pageData: PageContainerCreate = {
name: 'Welcome',
type: 'page',
userId: user.id,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
workspaceId: workspace.id,
emoji: '👋',
parentId: null,
};

await containerRepository.create(pageData);
},
await containerRepository.create(pageData);
},
},
},
});
};

if (useOidc) {
// OIDC authentication mode
authInstance = betterAuth({
database: createPool(environment.DB),
plugins: [
genericOAuth({
config: [
{
providerId: 'oidc',
clientId: environment.OIDC_CLIENT_ID!,
clientSecret: environment.OIDC_CLIENT_SECRET!,
authorizationUrl: environment.OIDC_AUTHORIZATION_URL!,
discoveryUrl: environment.OIDC_DISCOVERY_URL!,
scopes: ['openid', 'profile', 'email'],
},
],
}),
],
trustedOrigins: environment.NODE_ENV === 'development' ? ['http://localhost:3000'] : [],
secret: environment.BETTER_AUTH_SECRET,
hooks: {},
databaseHooks,
});
} else {
// Credentials (email/password) authentication mode
authInstance = betterAuth({
database: createPool(environment.DB),
emailAndPassword: {
enabled: true,
},
trustedOrigins: environment.NODE_ENV === 'development' ? ['http://localhost:3000'] : [],
secret: environment.BETTER_AUTH_SECRET,
hooks: {},
databaseHooks,
});
}
}
return authInstance;
}
Expand Down
9 changes: 5 additions & 4 deletions src/lib/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ const environmentSchema = {
default: 'info',
}),
BETTER_AUTH_SECRET: str(),
OIDC_CLIENT_ID: str(),
OIDC_CLIENT_SECRET: str(),
OIDC_DISCOVERY_URL: url(),
OIDC_AUTHORIZATION_URL: url(),
// OIDC variables are optional - if not set, credentials auth will be used
OIDC_CLIENT_ID: str({ default: undefined }),
OIDC_CLIENT_SECRET: str({ default: undefined }),
OIDC_DISCOVERY_URL: url({ default: undefined }),
OIDC_AUTHORIZATION_URL: url({ default: undefined }),
} as const;

type Environment = ReturnType<typeof cleanEnv<typeof environmentSchema>>;
Expand Down