🔧 refactor: implement 12-factor runtime config for all web apps

Replace window injection and build-time env vars with runtime config
loaded from /config.json (generated by Docker entrypoint). This fixes
the staging deployment issue where apps were requesting localhost URLs
instead of production URLs.

Changes:
- Add runtime.ts config loader with Zod validation (fail-hard in prod)
- Disable SSR via +layout.ts (apps are client-only SPAs)
- Update API clients and auth stores to use async config getters
- Add docker-entrypoint.sh scripts to generate config.json at startup
- Update Dockerfiles with ENTRYPOINT for config generation
- Simplify docker-compose.staging.yml env vars (12-factor pattern)
- Add static/config.json as dev fallback (localhost defaults)
- Fix onMount return type (Svelte 5 compatibility)
- Add zod dependency to Picture app
- Add backward compat exports for Contacts app

Apps updated:
- Clock (port 3017)
- Chat (port 3002)
- Picture (port 3006)
- Contacts (port 3015)
- Calendar (port 3016)
- Manacore (multi-app platform)

Benefits:
- Build once, deploy anywhere (same Docker image for all envs)
- Configuration in environment, not code (12-factor compliance)
- Fail-hard on missing/invalid config in production
- No accidental SSR localhost fallbacks
- Schema validation ensures all required URLs are present
This commit is contained in:
Wuesteon 2025-12-15 21:33:50 +01:00
parent f414aecda1
commit 2c30867251
55 changed files with 1596 additions and 610 deletions

View file

@ -70,6 +70,10 @@ COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/calendar/apps/web/build ./build
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/calendar/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5186
@ -82,5 +86,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5186/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3016"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/calendar/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/calendar/apps/web/build/client/config.json"
cat /app/apps/calendar/apps/web/build/client/config.json
echo "🚀 Starting Calendar web app..."
exec "$@"

View file

@ -1,25 +1,33 @@
/**
* API Client for Calendar Backend
*
* Uses runtime configuration (12-factor pattern) instead of build-time env vars.
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*/
import { env } from '$env/dynamic/public';
import { getBackendUrl } from '$lib/config/runtime';
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
let calendarClient: ReturnType<typeof createApiClient> | null = null;
const calendarClient = createApiClient({
baseUrl: API_BASE,
apiPrefix: '/api/v1',
});
async function getClient() {
if (!calendarClient) {
const backendUrl = await getBackendUrl();
calendarClient = createApiClient({
baseUrl: backendUrl,
apiPrefix: '/api/v1',
});
}
return calendarClient;
}
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
const client = await getClient();
return client.fetchApi<T>(endpoint, options);
}
// Re-export types for backwards compatibility

View file

@ -0,0 +1,116 @@
/**
* Runtime Configuration for Calendar App
*
* 12-Factor Pattern: Configuration is loaded from /config.json at runtime,
* generated by Docker entrypoint from environment variables.
* This allows the same Docker image to run in different environments
* without rebuilding.
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
/**
* Schema validation for config.json
* Ensures all required configuration is present and valid
*/
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
/**
* Development defaults - only used when:
* 1. dev === true (from $app/environment)
* 2. /config.json fetch fails
*
* In production, missing config.json is a deployment error.
*/
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3016',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load configuration from /config.json
* Fail-hard in production if config is missing or invalid
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Calendar] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Calendar] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) return cachedConfig;
// Return existing promise if already loading
if (configPromise) return configPromise;
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Calendar] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Calendar] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Calendar] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await loadConfig();
return config.AUTH_URL;
}
/**
* Get backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await loadConfig();
return config.BACKEND_URL;
}
/**
* Initialize configuration (call early in app lifecycle)
* This triggers the config load and caches it for subsequent calls
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,25 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration (12-factor pattern)
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3014';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
}
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +27,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +60,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +87,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +114,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +144,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +164,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +188,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +200,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -15,6 +15,10 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Wait for i18n locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3016",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -68,6 +68,10 @@ COPY --from=builder /app/apps/chat/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/chat/apps/web/build ./build
COPY --from=builder /app/apps/chat/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/chat/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
@ -80,5 +84,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3002"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/chat/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/chat/apps/web/build/client/config.json"
cat /app/apps/chat/apps/web/build/client/config.json
echo "🚀 Starting Chat web app..."
exec "$@"

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3002',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Chat] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Chat] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Chat] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Chat] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Chat] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -6,10 +6,12 @@
*
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*
* Uses runtime configuration for 12-factor compliance.
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
import { getBackendUrl } from '$lib/config/runtime';
import type {
Conversation,
Message,
@ -35,8 +37,6 @@ export type {
ChatCompletionResponse,
};
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
@ -56,8 +56,11 @@ async function fetchApi<T>(
return { data: null, error: new Error('No authentication token') };
}
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Now using Mana Core Auth instead of Supabase Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3002';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -207,7 +186,7 @@ export const authStore = {
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -226,7 +205,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -238,7 +217,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -2,11 +2,16 @@
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { initializeConfig } from '$lib/config/runtime';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
onMount(() => {
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
await initializeConfig();
// Initialize theme
const cleanup = theme.initialize();
return cleanup;
});

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3002",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -68,6 +68,10 @@ COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/clock/apps/web/build ./build
COPY --from=builder /app/apps/clock/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/clock/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5187
@ -80,5 +84,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
API_BASE_URL=${API_BASE_URL:-"http://localhost:3017"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " API_BASE_URL: $API_BASE_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/clock/apps/web/build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/clock/apps/web/build/client/config.json"
cat /app/apps/clock/apps/web/build/client/config.json
echo "🚀 Starting Clock web app..."
exec "$@"

View file

@ -1,10 +1,10 @@
/**
* API Client for Clock backend
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017/api/v1';
import { getApiBaseUrl } from '$lib/config/runtime';
export interface ApiResponse<T> {
data?: T;
@ -17,6 +17,7 @@ export async function fetchApi<T>(
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -27,7 +28,7 @@ export async function fetchApi<T>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
const response = await fetch(`${apiBaseUrl}/api/v1${endpoint}`, {
...options,
headers,
});

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
API_BASE_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
API_BASE_URL: z.string().url().min(1, 'API_BASE_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
API_BASE_URL: 'http://localhost:3017',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Clock] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Clock] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Clock] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Clock] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Clock] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the API base URL
*/
export async function getApiBaseUrl(): Promise<string> {
const config = await getConfig();
return config.API_BASE_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,44 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3017';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
}
import { getAuthUrl, getApiBaseUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getApiBaseUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -46,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -79,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -106,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -133,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -163,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -183,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -207,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -219,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -5,6 +5,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { initializeConfig } from '$lib/config/runtime';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
@ -13,6 +14,9 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
await initializeConfig();
// Wait for locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"API_BASE_URL": "http://localhost:3017",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -0,0 +1,94 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://contacts-backend:3015
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by contacts web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-help-content ./packages/shared-help-content
COPY packages/shared-help-types ./packages/shared-help-types
COPY packages/shared-help-ui ./packages/shared-help-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-splitscreen ./packages/shared-splitscreen
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-tags ./packages/shared-tags
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy contacts packages
COPY apps/contacts/packages ./apps/contacts/packages
COPY apps/contacts/apps/web ./apps/contacts/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/contacts/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/contacts/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/contacts/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/contacts/apps/web/build ./build
COPY --from=builder /app/apps/contacts/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/contacts/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3015"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/contacts/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/contacts/apps/web/build/client/config.json"
cat /app/apps/contacts/apps/web/build/client/config.json
echo "🚀 Starting Contacts web app..."
exec "$@"

View file

@ -1,9 +1,10 @@
/**
* Centralized API client with authentication
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { getApiBase } from './config';
/**
* Make an authenticated API request
@ -16,6 +17,7 @@ export async function fetchWithAuth<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -26,7 +28,7 @@ export async function fetchWithAuth<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});
@ -48,6 +50,7 @@ export async function fetchWithAuthFormData<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
...(options.headers || {}),
@ -57,7 +60,7 @@ export async function fetchWithAuthFormData<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});

View file

@ -1,13 +1,33 @@
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variables with fallbacks for development
* Uses runtime configuration for 12-factor compliance
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
import { getBackendUrl, getAuthUrl } from '$lib/config/runtime';
/**
* Mana Core Auth URL
* Central authentication service URL
* Get API base URL with /api/v1 suffix
*/
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export async function getApiBase(): Promise<string> {
const backendUrl = await getBackendUrl();
return `${backendUrl}/api/v1`;
}
/**
* Get Mana Core Auth URL
*/
export async function getManaAuthUrl(): Promise<string> {
return await getAuthUrl();
}
/**
* @deprecated Use getApiBase() instead for runtime config
* This export is kept for backward compatibility
*/
export const API_BASE = 'http://localhost:3015/api/v1';
/**
* @deprecated Use getManaAuthUrl() instead for runtime config
* This export is kept for backward compatibility
*/
export const MANA_AUTH_URL = 'http://localhost:3001';

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3015',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Contacts] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Contacts] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Contacts] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Contacts] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Contacts] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3015';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3015';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -74,6 +74,10 @@
}
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Setup global error handling
setupGlobalErrorHandling();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3015",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -69,6 +69,10 @@ COPY --from=builder /app/apps/manacore/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/manacore/apps/web/build ./build
COPY --from=builder /app/apps/manacore/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/manacore/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5173
@ -81,5 +85,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5173/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,37 @@
#!/bin/sh
set -e
# Docker Entrypoint for Manacore Web
# Generates runtime config from environment variables
# Implements "build once, configure at runtime" pattern
echo "🔧 Generating runtime configuration..."
# Default values for local development
API_BASE_URL=${API_BASE_URL:-"http://localhost:5173"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CALENDAR_API_URL=${CALENDAR_API_URL:-"http://localhost:3016"}
CLOCK_API_URL=${CLOCK_API_URL:-"http://localhost:3017"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
# Generate config.json from template
cat > /app/build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
"CLOCK_API_URL": "${CLOCK_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF
echo "✅ Runtime configuration generated:"
cat /app/build/client/config.json
echo ""
echo "🚀 Starting Node server..."
# Execute the CMD (node build)
exec "$@"

View file

@ -1,11 +1,12 @@
/**
* Credits Service for ManaCore Web App
* Handles credit balance, transactions, and packages
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface CreditBalance {
@ -52,8 +53,9 @@ export interface CreditPurchase {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -99,7 +101,8 @@ export const creditsService = {
* Get available credit packages (public endpoint)
*/
async getPackages(): Promise<CreditPackage[]> {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/credits/packages`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/credits/packages`);
if (!response.ok) {
throw new Error('Failed to fetch packages');
}

View file

@ -1,14 +1,27 @@
/**
* Feedback Service Instance for ManaCore Web App
*
* Uses runtime configuration for 12-factor compliance
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { getAuthUrl } from '$lib/config/runtime';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
// Lazy initialization to allow runtime config to load first
let _feedbackService: ReturnType<typeof createFeedbackService> | null = null;
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
async function getFeedbackService() {
if (!_feedbackService) {
const authUrl = await getAuthUrl();
_feedbackService = createFeedbackService({
apiUrl: authUrl,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
}
return _feedbackService;
}
// Export the async getter for components
export { getFeedbackService as getService };

View file

@ -1,11 +1,12 @@
/**
* Referrals Service for ManaCore Web App
* Handles referral codes, stats, and referral tracking
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface ReferralStats {
@ -54,8 +55,9 @@ export interface ReferralValidation {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -109,7 +111,8 @@ export const referralsService = {
*/
async validateCode(code: string): Promise<ReferralValidation> {
try {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/referrals/validate/${code}`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/referrals/validate/${code}`);
if (!response.ok) {
return { valid: false, error: 'Invalid code' };
}

View file

@ -0,0 +1,16 @@
/**
* API Configuration (Legacy - Deprecated)
*
* @deprecated Use runtime.ts instead for proper 12-factor config
* This file is kept for backward compatibility during migration
*/
import { getAuthUrl as getRuntimeAuthUrl } from './runtime';
/**
* Get the Mana Core Auth URL dynamically at runtime
* @deprecated Use getAuthUrl() from './runtime' instead
*/
export async function getAuthUrl(): Promise<string> {
return getRuntimeAuthUrl();
}

View file

@ -0,0 +1,117 @@
/**
* Runtime Configuration Loader
*
* Implements the 12-factor "config in environment" principle.
* Loads configuration from /config.json at runtime, allowing the same
* Docker image to be deployed across dev/staging/prod environments.
*
* Pattern: Build once, configure at runtime
*/
import { browser } from '$app/environment';
export interface RuntimeConfig {
API_BASE_URL: string;
AUTH_URL: string;
TODO_API_URL: string;
CALENDAR_API_URL: string;
CLOCK_API_URL: string;
CONTACTS_API_URL: string;
}
// Development fallbacks (only used in local dev, not in Docker)
const DEV_CONFIG: RuntimeConfig = {
API_BASE_URL: 'http://localhost:5173',
AUTH_URL: 'http://localhost:3001',
TODO_API_URL: 'http://localhost:3018',
CALENDAR_API_URL: 'http://localhost:3016',
CLOCK_API_URL: 'http://localhost:3017',
CONTACTS_API_URL: 'http://localhost:3015',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* This file is generated by the Docker entrypoint script from environment variables
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Server-side: use dev config (SSR doesn't need runtime config)
if (!browser) {
return DEV_CONFIG;
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// Return existing promise if loading
if (configPromise) {
return configPromise;
}
// Load config from /config.json
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
console.warn('Failed to load /config.json, using dev config');
return DEV_CONFIG;
}
return res.json();
})
.then((config: RuntimeConfig) => {
cachedConfig = config;
return config;
})
.catch((error) => {
console.warn('Error loading runtime config:', error);
return DEV_CONFIG;
});
return configPromise;
}
/**
* Get runtime configuration
* Must be called after app initialization
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get synchronous config (only use after initialization)
* Throws if config not loaded yet
*/
export function getConfigSync(): RuntimeConfig {
if (!cachedConfig) {
throw new Error('Runtime config not loaded. Call getConfig() first.');
}
return cachedConfig;
}
/**
* Initialize runtime config on app start
* Call this in your root +layout.svelte
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}
/**
* Helper to get auth URL (most commonly used)
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Helper to get backend URL
*/
export async function getApiBaseUrl(): Promise<string> {
const config = await getConfig();
return config.API_BASE_URL;
}

View file

@ -1,43 +1,32 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
import { getAuthUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
const authUrl = await getAuthUrl();
const auth = initializeWebAuth({ baseUrl: authUrl });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -67,7 +56,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -94,7 +83,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -133,7 +122,7 @@ export const authStore = {
* @param referralCode Optional referral code for bonus credits
*/
async signUp(email: string, password: string, referralCode?: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -161,6 +150,7 @@ export const authStore = {
/**
* Validate a referral code
* @deprecated Use referralsService.validateCode() instead
*/
async validateReferralCode(code: string) {
try {
@ -184,7 +174,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -204,7 +194,7 @@ export const authStore = {
* Send password reset email
*/
async forgotPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -227,7 +217,7 @@ export const authStore = {
* Reset password with token
*/
async resetPassword(token: string, newPassword: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -251,7 +241,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -263,7 +253,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -1,7 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { getService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
import type { createFeedbackService } from '@manacore/shared-feedback-service';
let feedbackService = $state<ReturnType<typeof createFeedbackService> | null>(null);
onMount(async () => {
feedbackService = await getService();
});
</script>
<FeedbackPage {feedbackService} appName="ManaCore" currentUserId={authStore.user?.id} />
{#if feedbackService}
<FeedbackPage {feedbackService} appName="ManaCore" currentUserId={authStore.user?.id} />
{:else}
<div class="flex items-center justify-center min-h-screen">
<div class="text-muted-foreground">Loading...</div>
</div>
{/if}

View file

@ -3,18 +3,23 @@
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { initializeConfig } from '$lib/config/runtime';
let { children } = $props();
onMount(() => {
// Initialize theme
const cleanupTheme = theme.initialize();
// Initialize runtime config first (12-factor config pattern)
initializeConfig().then(() => {
// Initialize theme
const cleanupTheme = theme.initialize();
// Initialize auth (non-blocking)
authStore.initialize();
// Initialize auth (non-blocking)
authStore.initialize();
});
// Return cleanup function
return () => {
cleanupTheme();
// Theme cleanup will be handled when theme is initialized
};
});
</script>

View file

@ -0,0 +1,8 @@
{
"API_BASE_URL": "http://localhost:5173",
"AUTH_URL": "http://localhost:3001",
"TODO_API_URL": "http://localhost:3018",
"CALENDAR_API_URL": "http://localhost:3016",
"CLOCK_API_URL": "http://localhost:3017",
"CONTACTS_API_URL": "http://localhost:3015"
}

View file

@ -0,0 +1,8 @@
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
"CLOCK_API_URL": "${CLOCK_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}

View file

@ -34,7 +34,8 @@
"@picture/shared": "workspace:*",
"konva": "^10.0.2",
"posthog-js": "^1.273.1",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^4.2.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",

View file

@ -6,12 +6,12 @@
* - Uses authStore.getValidToken() which automatically refreshes expired tokens
* - The fetch interceptor (setupFetchInterceptor) handles 401 responses by refreshing and retrying
* - If refresh fails, the request fails and user should be redirected to login
*
* Uses runtime configuration for 12-factor compliance.
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
import { getBackendUrl } from '$lib/config/runtime';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
@ -29,6 +29,9 @@ export async function fetchApi<T>(
// Get a valid token (auto-refreshes if expired)
const authToken = token || (await authStore.getValidToken());
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const headers: Record<string, string> = {};
@ -41,7 +44,7 @@ export async function fetchApi<T>(
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
@ -81,6 +84,9 @@ export async function uploadFile(
// Get a valid token (auto-refreshes if expired)
const authToken = token || (await authStore.getValidToken());
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const formData = new FormData();
formData.append('file', file);
@ -90,7 +96,7 @@ export async function uploadFile(
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method: 'POST',
headers,
body: formData,
@ -125,6 +131,9 @@ export async function uploadFiles(
// Get a valid token (auto-refreshes if expired)
const authToken = token || (await authStore.getValidToken());
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const formData = new FormData();
files.forEach((file) => {
@ -136,7 +145,7 @@ export async function uploadFiles(
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method: 'POST',
headers,
body: formData,

View file

@ -0,0 +1,124 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3006',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Picture] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Picture] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Picture] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Picture] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
const errors = result.error.errors
.map((e: { path: (string | number)[]; message: string }) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
throw new Error(`[Picture] Invalid config.json schema: ${errors}`);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,13 +1,10 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Now using Mana Core Auth instead of Supabase Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const MANA_AUTH_URL = env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
export interface UserData {
id: string;
@ -28,10 +25,12 @@ async function getAuthService() {
if (!browser) return null;
if (!_authService) {
try {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const { initializeWebAuth } = await import('@manacore/shared-auth');
const auth = initializeWebAuth({
baseUrl: MANA_AUTH_URL,
backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;

View file

@ -5,6 +5,7 @@
import Toast from '$lib/components/ui/Toast.svelte';
import { onMount } from 'svelte';
import { initPostHog, analytics } from '$lib/analytics/posthog';
import { initializeConfig } from '$lib/config/runtime';
// Import and initialize theme
import { theme } from '$lib/stores/theme';
@ -15,25 +16,28 @@
let { children, data } = $props();
onMount(() => {
// Initialize theme (applies CSS variables and loads from localStorage)
const cleanupTheme = theme.initialize();
// Initialize runtime config first (12-factor pattern)
initializeConfig().then(() => {
// Initialize theme (applies CSS variables and loads from localStorage)
const cleanupTheme = theme.initialize();
// Initialize PostHog
initPostHog();
// Initialize PostHog
initPostHog();
// Initialize auth with Mana Core
authStore.initialize().then(() => {
// Identify user in PostHog if logged in
if (authStore.user) {
analytics.identify(authStore.user.id, {
email: authStore.user.email,
});
}
// Initialize auth with Mana Core
authStore.initialize().then(() => {
// Identify user in PostHog if logged in
if (authStore.user) {
analytics.identify(authStore.user.id, {
email: authStore.user.email,
});
}
});
return () => {
cleanupTheme();
};
});
return () => {
cleanupTheme();
};
});
</script>

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3006",
"AUTH_URL": "http://localhost:3001"
}