Skip to content
Open
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
13 changes: 10 additions & 3 deletions config.defaults.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# User data directory for vault, conversations, etc.
# Platform defaults (when empty ""):
# Linux: $XDG_DATA_HOME/lumo-tamer or ~/.local/share/lumo-tamer
# macOS: ~/Library/Application Support/lumo-tamer
# Windows: %APPDATA%/lumo-tamer
# Docker (/.dockerenv exists): /data
# Supports: absolute paths, ~ for home, relative to project root
dataDir: ""

# Authentication configuration
auth:
# - login: enter Proton credentials via go-proton-api
Expand All @@ -8,8 +17,8 @@ auth:

# Encrypted vault for secure token storage (AES-256-GCM)
# Key is stored in OS keychain (desktop) or Docker secret (containers)
# Vault file is stored at <dataDir>/vault.enc
vault:
path: "sessions/vault.enc"
keychain:
service: "lumo-tamer"
account: "vault-key"
Expand Down Expand Up @@ -66,8 +75,6 @@ log:
# - do they still work/make sense with upstream store?
# - can they still be overwritten for cli/server? (ie. overwriting dbpath doesn't make sense if sync enabled, does it otherwise?)
conversations:
# Path for IndexedDB SQLite files (used when useUpstreamStorage is true)
databasePath: "sessions/"

# WORKAROUND for clients without conversation_id support (e.g., Home Assistant).
# Derives conversation ID from the `user` field in the request.
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ services:
# NOTE: config.yaml must exist before first run (touch config.yaml).
# Docker creates a directory if the file doesn't exist, causing errors.
- ./config.yaml:/app/config.yaml
- ./sessions:/app/sessions
- ./data:/data
secrets:
- lumo-vault-key
environment:
Expand Down
8 changes: 3 additions & 5 deletions src/api/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import express from 'express';
import { getServerConfig, getMetricsConfig, authConfig } from '../app/config.js';
import { resolveProjectPath } from '../app/paths.js';
import { getServerConfig, getMetricsConfig } from '../app/config.js';
import { getVaultPath } from '../app/paths.js';
import { logger } from '../app/logger.js';
import { setupAuthMiddleware, setupLoggingMiddleware, setupMetricsMiddleware } from './middleware.js';
import { setupApiErrorHandler } from './error-handler.js';
Expand Down Expand Up @@ -59,15 +59,13 @@ export class APIServer {
}

private getDependencies(): EndpointDependencies {
const vaultPath = resolveProjectPath(authConfig.vault.path);

return {
queue: this.queue,
lumoClient: this.app.getLumoClient(),
conversationStore: this.app.getConversationStore(),
syncInitialized: this.app.isSyncInitialized(),
authManager: this.app.getAuthManager(),
vaultPath,
vaultPath: getVaultPath(),
};
}

Expand Down
7 changes: 5 additions & 2 deletions src/app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';
import merge from 'lodash/merge.js';
import bytes from 'bytes';
import { fatalExit, loadConfigYaml, loadDefaultsYaml } from './config-file.js';
import { setDataDir } from './paths.js';

// Load defaults from YAML (single source of truth)
const configDefaults = loadDefaultsYaml();
Expand All @@ -26,7 +27,6 @@ const logConfigSchema = z.object({

const conversationsConfigSchema = z.object({
deriveIdFromUser: z.boolean(),
databasePath: z.string(),
useFallbackStore: z.boolean(),
enableSync: z.boolean(),
projectName: z.string().min(1),
Expand Down Expand Up @@ -91,7 +91,6 @@ export const authMethodSchema = z.enum(['login', 'browser', 'rclone']);
const authConfigSchema = z.object({
method: authMethodSchema,
vault: z.object({
path: z.string(),
keychain: z.object({
service: z.string(),
account: z.string(),
Expand All @@ -115,6 +114,7 @@ const authConfigSchema = z.object({

// Server merged config schema
const serverMergedConfigSchema = z.object({
dataDir: z.string(),
auth: authConfigSchema,
log: logConfigSchema,
conversations: conversationsConfigSchema,
Expand All @@ -131,6 +131,7 @@ const serverMergedConfigSchema = z.object({

// CLI merged config schema
const cliMergedConfigSchema = z.object({
dataDir: z.string(),
auth: authConfigSchema,
log: logConfigSchema,
conversations: conversationsConfigSchema,
Expand Down Expand Up @@ -204,6 +205,8 @@ function catchZodErrors(error: unknown, path="") {
export function initConfig(mode: ConfigMode): void {
configMode = mode;
config = loadMergedConfig(mode);
// Initialize data directory from config (empty string = platform default)
setDataDir(config.dataDir);
// Note: replacePatterns regex validation happens in src/api/instructions/
// at module load time, when logger is available
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { getConversationsConfig, authConfig, mockConfig } from './config.js';
import { logger } from './logger.js';
import { resolveProjectPath } from './paths.js';
import { getVaultPath } from './paths.js';
import { LumoClient } from '../lumo-client/index.js';
import { createAuthProvider, AuthManager, type AuthProvider, type ProtonApi } from '../auth/index.js';
import { getConversationStore, getFallbackStore, setConversationStore, type ConversationStore, initializeSync, initializeConversationStore, FallbackStore } from '../conversations/index.js';
Expand Down Expand Up @@ -71,7 +71,7 @@ export class Application {
this.authProvider = await createAuthProvider();

// Create AuthManager with auto-refresh configuration
const vaultPath = resolveProjectPath(authConfig.vault.path);
const vaultPath = getVaultPath();
const autoRefreshConfig = authConfig.autoRefresh;

this.authManager = new AuthManager({
Expand Down
117 changes: 117 additions & 0 deletions src/app/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* This allows CLI to run from any directory.
*/

import { existsSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join, isAbsolute } from 'path';

Expand All @@ -28,3 +29,119 @@ export function resolveProjectPath(path: string): string {
}
return join(PROJECT_ROOT, path);
}

// ============================================
// Data Directory
// ============================================

const APP_NAME = 'lumo-tamer';
const VAULT_FILENAME = 'vault.enc';

/**
* Detect if running inside Docker container
*/
function isDocker(): boolean {
return existsSync('/.dockerenv');
}

/**
* Get platform-specific default data directory
*
* - Docker: /data
* - Linux: $XDG_DATA_HOME/lumo-tamer or ~/.local/share/lumo-tamer
* - macOS: ~/Library/Application Support/lumo-tamer
* - Windows: %APPDATA%/lumo-tamer
*/
export function getDefaultDataDir(): string {
if (isDocker()) {
return '/data';
}

const home = process.env.HOME || '';

switch (process.platform) {
case 'darwin':
return join(home, 'Library', 'Application Support', APP_NAME);
case 'win32':
return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), APP_NAME);
default:
// Linux and other Unix-like systems: follow XDG Base Directory Specification
const xdgDataHome = process.env.XDG_DATA_HOME || join(home, '.local', 'share');
return join(xdgDataHome, APP_NAME);
}
}

// Cached resolved data directory
let resolvedDataDir: string | null = null;

/**
* Get the data directory, resolving from config or using platform default
* Call setDataDir() first if using config value, otherwise uses platform default
*/
export function getDataDir(): string {
if (resolvedDataDir === null) {
resolvedDataDir = getDefaultDataDir();
}
return resolvedDataDir;
}

/**
* Set the data directory from config value
* Empty string means use platform default
*/
export function setDataDir(configValue: string): void {
if (configValue === '') {
resolvedDataDir = getDefaultDataDir();
} else {
resolvedDataDir = resolveProjectPath(configValue);
}
}

/**
* Reset data directory (for testing)
*/
export function resetDataDir(): void {
resolvedDataDir = null;
}

/**
* Ensure data directory exists, creating it with secure permissions if needed
*/
export function ensureDataDir(): void {
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}

// TODO: add same checks as before:
// Verify databaseBasePath is a writable directory
// try {
// const stat = fs.statSync(databaseBasePath);
// if (!stat.isDirectory()) {
// throw new Error(`databasePath "${databaseBasePath}" is not a directory`);
// }
// fs.accessSync(databaseBasePath, fs.constants.W_OK);
// } catch (err) {
// if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
// throw new Error(`databasePath "${databaseBasePath}" does not exist`);
// }
// if ((err as NodeJS.ErrnoException).code === 'EACCES') {
// throw new Error(`databasePath "${databaseBasePath}" is not writable`);
// }
// throw err;
// }

/**
* Get path to the encrypted vault file
*/
export function getVaultPath(): string {
return join(getDataDir(), VAULT_FILENAME);
}

/**
* Get path for IndexedDB SQLite files (the data directory itself)
*/
export function getConversationsDbPath(): string {
return getDataDir();
}
4 changes: 2 additions & 2 deletions src/auth/browser/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { PROTON_URLS } from '../../app/urls.js';
import { logger } from '../../app/logger.js';
import { decryptPersistedSession } from '../session-keys.js';
import { writeVault, type VaultKeyConfig } from '../vault/index.js';
import { resolveProjectPath } from '../../app/paths.js';
import { getVaultPath } from '../../app/paths.js';

export interface ExtractionOptions {
/** CDP endpoint to connect to browser */
Expand Down Expand Up @@ -724,7 +724,7 @@ export async function runBrowserAuthentication(): Promise<ExtractionResult> {
});

// Write tokens to encrypted vault
const vaultPath = resolveProjectPath(authConfig.vault.path);
const vaultPath = getVaultPath();
const keyConfig: VaultKeyConfig = {
keychain: authConfig.vault.keychain,
keyFilePath: authConfig.vault.keyFilePath,
Expand Down
4 changes: 2 additions & 2 deletions src/auth/login/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { authConfig } from '../../app/config.js';
import { logger } from '../../app/logger.js';
import { resolveProjectPath } from '../../app/paths.js';
import { resolveProjectPath, getVaultPath } from '../../app/paths.js';
import { runProtonAuth } from './proton-auth-cli.js';
import { readVault, writeVault, type VaultKeyConfig } from '../vault/index.js';
import type { StoredTokens } from '../types.js';
Expand All @@ -24,7 +24,7 @@ export async function runLoginAuthentication(): Promise<void> {
// Run the Go binary (interactive prompts for credentials)
const result = await runProtonAuth(binaryPath);

const vaultPath = resolveProjectPath(authConfig.vault.path);
const vaultPath = getVaultPath();
const keyConfig: VaultKeyConfig = {
keychain: authConfig.vault.keychain,
keyFilePath: authConfig.vault.keyFilePath,
Expand Down
4 changes: 2 additions & 2 deletions src/auth/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { existsSync } from 'fs';
import { logger } from '../../app/logger.js';
import { authConfig, getConversationsConfig } from '../../app/config.js';
import { resolveProjectPath } from '../../app/paths.js';
import { getVaultPath } from '../../app/paths.js';
import { createProtonApi } from '../api-factory.js';
import { refreshWithRefreshToken, canRefreshWithToken } from '../token-refresh.js';
import { readVault, writeVault } from '../vault/index.js';
Expand All @@ -40,7 +40,7 @@ export interface ProviderConfig {
*/
export function getProviderConfig(): ProviderConfig {
return {
vaultPath: resolveProjectPath(authConfig.vault.path),
vaultPath: getVaultPath(),
keyConfig: {
keychain: authConfig.vault.keychain,
keyFilePath: authConfig.vault.keyFilePath,
Expand Down
4 changes: 2 additions & 2 deletions src/auth/rclone/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as readline from 'readline';
import { parseRcloneSection } from './parser.js';
import { authConfig } from '../../app/config.js';
import { logger } from '../../app/logger.js';
import { resolveProjectPath } from '../../app/paths.js';
import { getVaultPath } from '../../app/paths.js';
import { readVault, writeVault, type VaultKeyConfig } from '../vault/index.js';
import type { StoredTokens } from '../types.js';
import { print } from '../../app/terminal.js';
Expand Down Expand Up @@ -66,7 +66,7 @@ export async function runRcloneAuthentication(): Promise<void> {
// Parse the pasted content
const rcloneTokens = parseRcloneSection(content);

const vaultPath = resolveProjectPath(authConfig.vault.path);
const vaultPath = getVaultPath();
const keyConfig: VaultKeyConfig = {
keychain: authConfig.vault.keychain,
keyFilePath: authConfig.vault.keyFilePath,
Expand Down
29 changes: 5 additions & 24 deletions src/shims/indexeddb-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,15 @@
* Source: https://www.npmjs.com/package/indexeddbshim
*/

import fs from 'fs';
import indexeddbshim from 'indexeddbshim';

import { getConversationsConfig } from '../app/config.js';
import { resolveProjectPath } from '../app/paths.js';
import { ensureDataDir, getConversationsDbPath } from '../app/paths.js';

// Ensure data directory exists (creates with 0o700 if missing)
ensureDataDir();

const config = getConversationsConfig();

// databaseBasePath - where SQLite files are stored (resolved to absolute path)
const databaseBasePath = resolveProjectPath(config.databasePath);

// Verify databaseBasePath is a writable directory
try {
const stat = fs.statSync(databaseBasePath);
if (!stat.isDirectory()) {
throw new Error(`databasePath "${databaseBasePath}" is not a directory`);
}
fs.accessSync(databaseBasePath, fs.constants.W_OK);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`databasePath "${databaseBasePath}" does not exist`);
}
if ((err as NodeJS.ErrnoException).code === 'EACCES') {
throw new Error(`databasePath "${databaseBasePath}" is not writable`);
}
throw err;
}
// databaseBasePath - where SQLite files are stored
const databaseBasePath = getConversationsDbPath();


// Initialize indexeddbshim with Node.js-compatible settings
Expand Down
Loading