diff --git a/.env.example b/.env.example index c92c023..1643766 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,63 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr # VITE_LIGHTNING_ENABLED=true # OBSOLETE: Not used in codebase - config.market.defaultCurrency is never consumed # VITE_MARKET_DEFAULT_CURRENCY=sat + +# ─────────────────────────────────────────────────────────────────────── +# Hub → standalone navigation URLs +# +# Each chakra tile in the hub builds an from these env vars and +# (when authenticated) appends ?token= so the destination +# auto-logs in via acceptTokenFromUrl(). +# +# Trailing slash matters under path-mode deployment: +# ✓ https://demo.example.com/market/ asset URLs resolve correctly +# ✗ https://demo.example.com/market relies on nginx 301 to add the +# slash; brittle, extra round trip. +# +# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined +# in the vite configs): +# VITE_HUB_ACTIVITIES_URL=http://localhost:5181 +# VITE_HUB_CASTLE_URL=http://localhost:5180 +# VITE_HUB_WALLET_URL=http://localhost:5182 +# VITE_HUB_CHAT_URL=http://localhost:5183 +# VITE_HUB_FORUM_URL=http://localhost:5184 +# VITE_HUB_MARKET_URL=http://localhost:5185 +# VITE_HUB_TASKS_URL=http://localhost:5186 +# +# In PATH-MODE production (recommended for demo) — note the trailing slash: +# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/ +# VITE_HUB_CASTLE_URL=https://demo.example.com/castle/ +# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/ +# VITE_HUB_CHAT_URL=https://demo.example.com/chat/ +# VITE_HUB_FORUM_URL=https://demo.example.com/forum/ +# VITE_HUB_MARKET_URL=https://demo.example.com/market/ +# VITE_HUB_TASKS_URL=https://demo.example.com/tasks/ +# +# In SUBDOMAIN-MODE production: +# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com +# VITE_HUB_CASTLE_URL=https://castle.example.com +# ...etc +# ─────────────────────────────────────────────────────────────────────── +VITE_HUB_ACTIVITIES_URL= +VITE_HUB_CASTLE_URL= +VITE_HUB_WALLET_URL= +VITE_HUB_CHAT_URL= +VITE_HUB_FORUM_URL= +VITE_HUB_MARKET_URL= +VITE_HUB_TASKS_URL= + +# ─────────────────────────────────────────────────────────────────────── +# VITE_BASE_PATH — build-time only, NOT per .env +# +# Each standalone vite config (vite..config.ts) reads VITE_BASE_PATH +# at build time. For path-mode deployment, set it as a shell variable when +# you build, NOT in this .env file (which is read at runtime by the +# bundle): +# +# VITE_BASE_PATH=/market/ npm run build:market +# VITE_BASE_PATH=/wallet/ npm run build:wallet +# ... +# +# The default '/' (no override) is what you want for subdomain-mode and +# for `npm run dev:all`. +# ─────────────────────────────────────────────────────────────────────── diff --git a/activities.html b/activities.html index d227e52..b555a4d 100644 --- a/activities.html +++ b/activities.html @@ -3,6 +3,7 @@ + diff --git a/castle.html b/castle.html index fe3f567..cee9c6f 100644 --- a/castle.html +++ b/castle.html @@ -3,6 +3,7 @@ + diff --git a/chat.html b/chat.html new file mode 100644 index 0000000..fb83dee --- /dev/null +++ b/chat.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Chat — Encrypted + + + + +
+ + + diff --git a/forum.html b/forum.html new file mode 100644 index 0000000..8646936 --- /dev/null +++ b/forum.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Forum — Discussions + + + + +
+ + + diff --git a/index.html b/index.html index c1f84b2..76e7922 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + diff --git a/market.html b/market.html new file mode 100644 index 0000000..3fc32d5 --- /dev/null +++ b/market.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Market — Nostr + + + + +
+ + + diff --git a/nginx.conf.example b/nginx.conf.example index e22b16e..662b4c9 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -1,45 +1,169 @@ # Main context -worker_processes auto; # Automatically determine worker processes based on CPU cores +worker_processes auto; events { - worker_connections 1024; # Maximum connections per worker + worker_connections 1024; } http { default_type application/octet-stream; - # Trust the custom Docker network subnet set_real_ip_from 0.0.0.0; real_ip_header X-Forwarded-For; real_ip_recursive on; + # ─────────────────────────────────────────────────────────────────────── + # PATH-MODE deployment (recommended) + # + # demo../ — minimal AIO chakra hub + # demo../activities/ — Sortir / activities standalone + # demo../market/ — marketplace standalone + # demo../wallet/ — wallet standalone + # demo../chat/ — chat standalone + # demo../forum/ — forum standalone + # demo../tasks/ — tasks standalone + # demo../castle/ — castle (accounting) standalone + # + # Each standalone is built with VITE_BASE_PATH=// so its asset URLs + # are prefixed correctly. The hub's chakra tiles point at the canonical + # trailing-slash path (VITE_HUB__URL=https://demo.//). + # + # Per-app no-trailing-slash → with-slash 301 redirects exist for users + # who hand-type the URL or follow a stripped-slash link. + # + # All static assets (JS / CSS / images / SVGs) are MIME-typed and image + # types get a 6-month cache-control. + # ─────────────────────────────────────────────────────────────────────── server { listen 8080; - server_name .; + server_name demo..; - root /app; + # Hub at the root + root /var/www/aio/dist; index index.html; - + location = / { try_files $uri /index.html; } location / { + # Default: serve from hub bundle if no // prefix matched. try_files $uri $uri/ /index.html; } - location ~* \.js$ { - types { application/javascript js; } - default_type application/javascript; + # ── Activities (Sortir) ────────────────────────────────────────── + location = /activities { return 301 /activities/$is_args$args; } + location /activities/ { + alias /var/www/aio/dist-activities/; + try_files $uri $uri/ /activities.html; + } + + # ── Market ─────────────────────────────────────────────────────── + location = /market { return 301 /market/$is_args$args; } + location /market/ { + alias /var/www/aio/dist-market/; + try_files $uri $uri/ /market.html; + } + + # ── Wallet ─────────────────────────────────────────────────────── + location = /wallet { return 301 /wallet/$is_args$args; } + location /wallet/ { + alias /var/www/aio/dist-wallet/; + try_files $uri $uri/ /wallet.html; + } + + # ── Chat ───────────────────────────────────────────────────────── + location = /chat { return 301 /chat/$is_args$args; } + location /chat/ { + alias /var/www/aio/dist-chat/; + try_files $uri $uri/ /chat.html; + } + + # ── Forum ──────────────────────────────────────────────────────── + location = /forum { return 301 /forum/$is_args$args; } + location /forum/ { + alias /var/www/aio/dist-forum/; + try_files $uri $uri/ /forum.html; + } + + # ── Tasks ──────────────────────────────────────────────────────── + location = /tasks { return 301 /tasks/$is_args$args; } + location /tasks/ { + alias /var/www/aio/dist-tasks/; + try_files $uri $uri/ /tasks.html; + } + + # ── Castle (accounting) ────────────────────────────────────────── + location = /castle { return 301 /castle/$is_args$args; } + location /castle/ { + alias /var/www/aio/dist-castle/; + try_files $uri $uri/ /castle.html; + } + + # ── Static asset MIME / cache (applies to all bundles) ─────────── + location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } + location ~* \.css$ { types { text/css css; } default_type text/css; } + location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } } - # Serve CSS files with the correct MIME type - location ~* \.css$ { - types { text/css css; } - default_type text/css; + # ─────────────────────────────────────────────────────────────────────── + # Optional subdomain shortcuts → canonical path + # + # If you want pretty subdomain URLs that funnel into the path-mode + # canonical, add 301 redirects per app. Example: + # + # events.demo.. → demo../activities/ + # market.demo.. → demo../market/ + # ─────────────────────────────────────────────────────────────────────── + server { + listen 8080; + server_name events.demo..; + return 301 https://demo../activities/$request_uri; + } + server { + listen 8080; + server_name market.demo..; + return 301 https://demo../market/$request_uri; + } + server { + listen 8080; + server_name wallet.demo..; + return 301 https://demo../wallet/$request_uri; + } + server { + listen 8080; + server_name chat.demo..; + return 301 https://demo../chat/$request_uri; + } + server { + listen 8080; + server_name forum.demo..; + return 301 https://demo../forum/$request_uri; + } + server { + listen 8080; + server_name tasks.demo..; + return 301 https://demo../tasks/$request_uri; + } + server { + listen 8080; + server_name castle.demo..; + return 301 https://demo../castle/$request_uri; } - # Serve image files - location ~* \.(png|jpe?g|webp|ico)$ { - expires 6M; # Optional: Cache static assets for 6 months - access_log off; - } - - } + # ─────────────────────────────────────────────────────────────────────── + # SUBDOMAIN-MODE deployment (alternative — pure subdomains, no /path/) + # + # If you'd rather give each standalone its own subdomain and skip the + # path-mode entirely: + # + # server { server_name app.; root /var/www/aio/dist; ... } + # server { server_name market.; root /var/www/aio/dist-market; ... } + # server { server_name sortir.; root /var/www/aio/dist-activities; ... } + # server { server_name wallet.; root /var/www/aio/dist-wallet; ... } + # server { server_name chat.; root /var/www/aio/dist-chat; ... } + # server { server_name forum.; root /var/www/aio/dist-forum; ... } + # server { server_name tasks.; root /var/www/aio/dist-tasks; ... } + # server { server_name castle.; root /var/www/aio/dist-castle; ... } + # + # Each block uses `location / { try_files $uri $uri/ /.html; }`. + # In subdomain mode, build each standalone WITHOUT VITE_BASE_PATH (the + # default `/` is correct), and set VITE_HUB__URL to the subdomain + # in the hub's env (e.g. VITE_HUB_MARKET_URL=https://market.). + # ─────────────────────────────────────────────────────────────────────── } - diff --git a/package.json b/package.json index da537c9..b1b19cc 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,23 @@ "dev:castle": "vite --host --config vite.castle.config.ts", "build:castle": "vue-tsc -b && vite build --config vite.castle.config.ts", "preview:castle": "vite preview --host --config vite.castle.config.ts", + "dev:wallet": "vite --host --config vite.wallet.config.ts", + "build:wallet": "vue-tsc -b && vite build --config vite.wallet.config.ts", + "preview:wallet": "vite preview --host --config vite.wallet.config.ts", + "dev:chat": "vite --host --config vite.chat.config.ts", + "build:chat": "vue-tsc -b && vite build --config vite.chat.config.ts", + "preview:chat": "vite preview --host --config vite.chat.config.ts", + "dev:market": "vite --host --config vite.market.config.ts", + "build:market": "vue-tsc -b && vite build --config vite.market.config.ts", + "preview:market": "vite preview --host --config vite.market.config.ts", + "dev:tasks": "vite --host --config vite.tasks.config.ts", + "build:tasks": "vue-tsc -b && vite build --config vite.tasks.config.ts", + "preview:tasks": "vite preview --host --config vite.tasks.config.ts", + "dev:forum": "vite --host --config vite.forum.config.ts", + "build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts", + "preview:forum": "vite preview --host --config vite.forum.config.ts", + "dev:all": "concurrently -n hub,castle,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:castle\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"", + "build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/castle/ npm run build:castle && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/public/chakras/ajna.svg b/public/chakras/ajna.svg new file mode 100644 index 0000000..212d4e4 --- /dev/null +++ b/public/chakras/ajna.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/public/chakras/anahata.svg b/public/chakras/anahata.svg new file mode 100644 index 0000000..a2cc52b --- /dev/null +++ b/public/chakras/anahata.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/chakras/manipura.svg b/public/chakras/manipura.svg new file mode 100644 index 0000000..0c87aa8 --- /dev/null +++ b/public/chakras/manipura.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/chakras/muladhara.svg b/public/chakras/muladhara.svg new file mode 100644 index 0000000..cd21ee8 --- /dev/null +++ b/public/chakras/muladhara.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + diff --git a/public/chakras/sahasrara.svg b/public/chakras/sahasrara.svg new file mode 100644 index 0000000..f8f6323 --- /dev/null +++ b/public/chakras/sahasrara.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/chakras/swadhisthana.svg b/public/chakras/swadhisthana.svg new file mode 100644 index 0000000..9123a7d --- /dev/null +++ b/public/chakras/swadhisthana.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/chakras/vishuddha.svg b/public/chakras/vishuddha.svg new file mode 100644 index 0000000..7b71e72 --- /dev/null +++ b/public/chakras/vishuddha.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.vue b/src/App.vue index 559d0bf..8c7f8ba 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,77 +1,47 @@ diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts index bdf83e4..eb20adf 100644 --- a/src/accounting-app/app.ts +++ b/src/accounting-app/app.ts @@ -14,26 +14,8 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * This allows the main app to link users directly into Castle - * without requiring a separate login. The token is stored in - * localStorage and the parameter is stripped from the URL. - */ -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - // Also persist user data key so auth service picks it up - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Castle] Auth token accepted from URL') - } -} +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone Castle accounting app @@ -42,7 +24,7 @@ export async function createAppInstance() { console.log('Starting Castle — Accounting App...') // Accept token from URL before anything else (cross-subdomain auth relay) - acceptTokenFromUrl() + acceptTokenFromUrl('Castle') const app = createApp(App) @@ -89,9 +71,13 @@ export async function createAppInstance() { component: () => import('./views/SettingsPage.vue'), meta: { requiresAuth: false } }, + catchAllRoute, ] }) + // Castle has no public view — every non-login route requires auth. + installStrictAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -131,22 +117,11 @@ export async function createAppInstance() { await Promise.all(moduleRegistrations) await pluginManager.installAll() - // Initialize auth + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). const { auth } = await import('@/composables/useAuthService') await auth.initialize() - - // Auth guard — only redirect for routes that explicitly require auth - router.beforeEach(async (to, _from, next) => { - const requiresAuth = to.meta.requiresAuth === true - - if (requiresAuth && !auth.isAuthenticated.value) { - next('/login') - } else if (to.path === '/login' && auth.isAuthenticated.value) { - next('/') - } else { - next() - } - }) + markAuthReady(auth) // Global error handling app.config.errorHandler = (err, _vm, info) => { diff --git a/src/accounting-app/main.ts b/src/accounting-app/main.ts index 22efcbc..ed477b2 100644 --- a/src/accounting-app/main.ts +++ b/src/accounting-app/main.ts @@ -1,7 +1,10 @@ import { startApp } from './app' import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' import 'vue-sonner/style.css' +cleanupStaleDevServiceWorkers() + // PWA service worker with periodic updates const intervalMS = 60 * 60 * 1000 // 1 hour registerSW({ diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts index 918ed38..581a6e2 100644 --- a/src/activities-app/app.ts +++ b/src/activities-app/app.ts @@ -13,24 +13,8 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * Allows the main app to link users directly into this standalone - * app without requiring a separate login. - */ -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Sortir] Auth token accepted from URL') - } -} +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone activities app @@ -39,7 +23,7 @@ export async function createAppInstance() { console.log('🚀 Starting Sortir — Activities App...') // Accept token from URL before anything else (cross-subdomain auth relay) - acceptTokenFromUrl() + acceptTokenFromUrl('Sortir') const app = createApp(App) @@ -73,9 +57,12 @@ export async function createAppInstance() { component: () => import('./views/SettingsPage.vue'), meta: { requiresAuth: false } }, + catchAllRoute, ] }) + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -109,22 +96,11 @@ export async function createAppInstance() { await Promise.all(moduleRegistrations) await pluginManager.installAll() - // Initialize auth + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). const { auth } = await import('@/composables/useAuthService') await auth.initialize() - - // Auth guard — only redirect for routes that explicitly require auth - router.beforeEach(async (to, _from, next) => { - const requiresAuth = to.meta.requiresAuth === true - - if (requiresAuth && !auth.isAuthenticated.value) { - next('/login') - } else if (to.path === '/login' && auth.isAuthenticated.value) { - next('/') - } else { - next() - } - }) + markAuthReady(auth) // Global error handling app.config.errorHandler = (err, _vm, info) => { diff --git a/src/activities-app/main.ts b/src/activities-app/main.ts index c9c8429..e3bb49d 100644 --- a/src/activities-app/main.ts +++ b/src/activities-app/main.ts @@ -1,7 +1,10 @@ import { startApp } from './app' import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' import 'vue-sonner/style.css' +cleanupStaleDevServiceWorkers() + // PWA service worker with periodic updates const intervalMS = 60 * 60 * 1000 // 1 hour registerSW({ diff --git a/src/app.config.ts b/src/app.config.ts index da83333..dce6464 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,12 +1,11 @@ import type { AppConfig } from './core/types' -function parseMapCenter(envValue: string | undefined, fallback: { lat: number; lng: number }) { - if (!envValue) return fallback - const [lat, lng] = envValue.split(',').map(Number) - if (isNaN(lat) || isNaN(lng)) return fallback - return { lat, lng } -} - +/** + * Minimal AIO hub configuration. + * The all-in-one app at app.${domain} ships only the base module — + * each feature module (wallet, chat, market, tasks, forum, activities, + * castle) is now its own standalone PWA at its own subdomain. + */ export const appConfig: AppConfig = { modules: { base: { @@ -18,7 +17,7 @@ export const appConfig: AppConfig = { relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') }, auth: { - sessionTimeout: 24 * 60 * 60 * 1000, // 24 hours + sessionTimeout: 24 * 60 * 60 * 1000, }, pwa: { autoPrompt: true @@ -29,115 +28,9 @@ export const appConfig: AppConfig = { acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] } } - }, - 'nostr-feed': { - name: 'nostr-feed', - enabled: false, // Disabled - replaced by links module - lazy: false, - config: { - refreshInterval: 30000, - maxPosts: 100, - adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'), - feedTypes: ['announcements', 'general'] - } - }, - links: { - name: 'links', - enabled: true, - lazy: false, - config: { - maxSubmissions: 50, - corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '', - adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') - } - }, - tasks: { - name: 'tasks', - enabled: true, - lazy: false, - config: { - maxTasks: 200, - adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') - } - }, - market: { - name: 'market', - enabled: true, - lazy: false, - config: { - defaultCurrency: 'sats', - paymentTimeout: 300000, // 5 minutes - maxOrderHistory: 50, - apiConfig: { - baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' - } - } - }, - chat: { - name: 'chat', - enabled: true, - lazy: false, // Load on startup to register routes - config: { - maxMessages: 500, - autoScroll: true, - showTimestamps: true, - notifications: { - enabled: true, - soundEnabled: false, - wildcardSupport: true - } - } - }, - activities: { - name: 'activities', - enabled: true, - lazy: false, - config: { - apiConfig: { - baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', - apiKey: import.meta.env.VITE_API_KEY || '' - }, - defaultMapCenter: parseMapCenter(import.meta.env.VITE_DEFAULT_MAP_CENTER, { lat: 46.6034, lng: 1.8883 }), - maxTicketsPerUser: 10, - enableMap: true, - enablePrivateEvents: false - } - }, - wallet: { - name: 'wallet', - enabled: true, - lazy: false, - config: { - defaultReceiveAmount: 1000, // 1000 sats - maxReceiveAmount: 1000000, // 1M sats - apiConfig: { - baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' - }, - websocket: { - enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', // Can be disabled via env var - reconnectDelay: 2000, // 2 seconds (increased from 1s to reduce server load) - maxReconnectAttempts: 3, // Reduced from 5 to avoid overwhelming server - fallbackToPolling: true, // Enable polling fallback when WebSocket fails - pollingInterval: 10000 // 10 seconds for polling updates - } - } - }, - expenses: { - name: 'expenses', - enabled: true, - lazy: false, - config: { - apiConfig: { - baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', - timeout: 30000 // 30 seconds for API requests - }, - defaultCurrency: 'sats', - maxExpenseAmount: 1000000, // 1M sats - requireDescription: true - } } }, - + features: { pwa: true, pushNotifications: true, @@ -146,4 +39,4 @@ export const appConfig: AppConfig = { } } -export default appConfig \ No newline at end of file +export default appConfig diff --git a/src/app.ts b/src/app.ts index 0f45340..73fc5b2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,67 +1,44 @@ import { createApp } from 'vue' import { createRouter, createWebHistory } from 'vue-router' import { createPinia } from 'pinia' -// Core plugin system import { pluginManager } from './core/plugin-manager' import { eventBus } from './core/event-bus' import { container } from './core/di-container' -// App configuration import appConfig from './app.config' -// Base modules import baseModule from './modules/base' -import nostrFeedModule from './modules/nostr-feed' -import chatModule from './modules/chat' -import activitiesModule from './modules/activities' -import marketModule from './modules/market' -import walletModule from './modules/wallet' -import expensesModule from './modules/expenses' -import linksModule from './modules/links' -import tasksModule from './modules/tasks' -// Root component import App from './App.vue' -// Styles import './assets/index.css' - -// Use existing i18n setup import { i18n } from './i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' /** - * Initialize and start the modular application + * Initialize and start the minimal AIO hub. + * + * The all-in-one app at app.${domain} now ships only the base module + * plus a chakra icon hub linking out to the standalone module apps + * (wallet, chat, market, tasks, forum, activities, castle). */ export async function createAppInstance() { - console.log('🚀 Starting modular application...') + console.log('🚀 Starting AIO hub...') - // Create Vue app const app = createApp(App) - // Collect all module routes automatically to avoid duplication const moduleRoutes = [ - // Extract routes from modules directly ...baseModule.routes || [], - ...nostrFeedModule.routes || [], - ...chatModule.routes || [], - ...activitiesModule.routes || [], - ...marketModule.routes || [], - ...walletModule.routes || [], - ...expensesModule.routes || [], - ...linksModule.routes || [], - ...tasksModule.routes || [] ].filter(Boolean) - // Create router with all routes available immediately const router = createRouter({ history: createWebHistory(), routes: [ - // Default routes { path: '/', - name: 'home', - component: () => import('./pages/Home.vue'), - meta: { requiresAuth: true } + name: 'hub', + component: () => import('./pages/Hub.vue'), + meta: { requiresAuth: false } }, { path: '/login', @@ -71,175 +48,72 @@ export async function createAppInstance() { : () => import('./pages/Login.vue'), meta: { requiresAuth: false } }, - // Pre-register module routes - ...moduleRoutes + ...moduleRoutes, + catchAllRoute, ] }) - // Use existing i18n setup + // Register guards immediately (Vue Router docs: before app.use(router)). + // Guards await auth readiness internally — see router-helpers.ts. + installLenientAuthGuard(router) - // Create Pinia store const pinia = createPinia() - // Install core plugins app.use(router) app.use(pinia) app.use(i18n) - // Initialize plugin manager pluginManager.init(app, router) - // Register modules based on configuration const moduleRegistrations = [] - // Register base module first (required) if (appConfig.modules.base.enabled) { moduleRegistrations.push( pluginManager.register(baseModule, appConfig.modules.base) ) } - // Register nostr-feed module - if (appConfig.modules['nostr-feed'].enabled) { - moduleRegistrations.push( - pluginManager.register(nostrFeedModule, appConfig.modules['nostr-feed']) - ) - } - - // Register chat module - if (appConfig.modules.chat.enabled) { - moduleRegistrations.push( - pluginManager.register(chatModule, appConfig.modules.chat) - ) - } - - // Register activities module (events + ticketing) - if (appConfig.modules.activities?.enabled) { - moduleRegistrations.push( - pluginManager.register(activitiesModule, appConfig.modules.activities) - ) - } - - // Register market module - if (appConfig.modules.market.enabled) { - moduleRegistrations.push( - pluginManager.register(marketModule, appConfig.modules.market) - ) - } - - // Register wallet module - if (appConfig.modules.wallet?.enabled) { - moduleRegistrations.push( - pluginManager.register(walletModule, appConfig.modules.wallet) - ) - } - - // Register expenses module - if (appConfig.modules.expenses?.enabled) { - moduleRegistrations.push( - pluginManager.register(expensesModule, appConfig.modules.expenses) - ) - } - - // Register links module - if (appConfig.modules.links?.enabled) { - moduleRegistrations.push( - pluginManager.register(linksModule, appConfig.modules.links) - ) - } - - // Register tasks module - if (appConfig.modules.tasks?.enabled) { - moduleRegistrations.push( - pluginManager.register(tasksModule, appConfig.modules.tasks) - ) - } - - // Wait for all modules to register await Promise.all(moduleRegistrations) - - // Install all enabled modules await pluginManager.installAll() - // Initialize auth before setting up router guards + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API) so it can't be imported at + // the top of this file. Once initialized, we signal the router-guard + // promise so any pending navigations can resolve. const { auth } = await import('@/composables/useAuthService') await auth.initialize() + markAuthReady(auth) console.log('Auth initialized, isAuthenticated:', auth.isAuthenticated.value) - // Set up auth guard - router.beforeEach(async (to, _from, next) => { - // Default to requiring auth unless explicitly set to false - const requiresAuth = to.meta.requiresAuth !== false - - if (requiresAuth && !auth.isAuthenticated.value) { - console.log(`Auth guard: User not authenticated, redirecting from ${to.path} to login`) - next('/login') - } else if (to.path === '/login' && auth.isAuthenticated.value) { - console.log('Auth guard: User already authenticated, redirecting to home') - next('/') - } else { - console.log(`Auth guard: Allowing navigation to ${to.path} (requiresAuth: ${requiresAuth}, authenticated: ${auth.isAuthenticated.value})`) - next() - } - }) - - // Check initial route and redirect if needed - if (!auth.isAuthenticated.value) { - const currentRoute = router.currentRoute.value - const requiresAuth = currentRoute.meta.requiresAuth !== false - if (requiresAuth) { - console.log('Initial route requires auth but user not authenticated, redirecting to login') - await router.push('/login') - } - } - - // Global error handling app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) eventBus.emit('app:error', { error: err, info }, 'app') } - // Development helpers if (appConfig.features.developmentMode) { - // Expose debugging helpers globally ;(window as any).__pluginManager = pluginManager ;(window as any).__eventBus = eventBus ;(window as any).__container = container - - console.log('🔧 Development mode enabled') - console.log('Available globals: __pluginManager, __eventBus, __container') } - console.log('✅ Application initialized successfully') - + console.log('✅ AIO hub initialized') return { app, router } } -/** - * Start the application - */ export async function startApp() { try { const { app } = await createAppInstance() - - // Mount the app app.mount('#app') - - console.log('🎉 Application started!') - - // Emit app started event + console.log('🎉 AIO hub started!') eventBus.emit('app:started', {}, 'app') - } catch (error) { - console.error('💥 Failed to start application:', error) - - // Show error to user + console.error('💥 Failed to start AIO hub:', error) document.getElementById('app')!.innerHTML = `
-

Application Failed to Start

+

AIO hub failed to start

${error instanceof Error ? error.message : 'Unknown error'}

Please refresh the page or contact support.

` } -} \ No newline at end of file +} diff --git a/src/chat-app/App.vue b/src/chat-app/App.vue new file mode 100644 index 0000000..8c7f8ba --- /dev/null +++ b/src/chat-app/App.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/chat-app/app.config.ts b/src/chat-app/app.config.ts new file mode 100644 index 0000000..8fa20b8 --- /dev/null +++ b/src/chat-app/app.config.ts @@ -0,0 +1,55 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Chat app configuration. + * Only enables base + chat modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + chat: { + name: 'chat', + enabled: true, + lazy: false, + config: { + maxMessages: 500, + autoScroll: true, + showTimestamps: true, + notifications: { + enabled: true, + soundEnabled: false, + wildcardSupport: true + } + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/chat-app/app.ts b/src/chat-app/app.ts new file mode 100644 index 0000000..d4b2573 --- /dev/null +++ b/src/chat-app/app.ts @@ -0,0 +1,121 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import chatModule from '@/modules/chat' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Chat app...') + + acceptTokenFromUrl('Chat') + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...chatModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/chat' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + catchAllRoute, + ] + }) + + // Chat has no public view — every non-login route requires auth. + installStrictAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.chat?.enabled) { + moduleRegistrations.push( + pluginManager.register(chatModule, appConfig.modules.chat) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Chat app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Chat app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Chat app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/chat-app/main.ts b/src/chat-app/main.ts new file mode 100644 index 0000000..7493491 --- /dev/null +++ b/src/chat-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Chat app ready to work offline') + } +}) + +startApp() diff --git a/src/composables/useLocale.ts b/src/composables/useLocale.ts index e77568a..c53fc8e 100644 --- a/src/composables/useLocale.ts +++ b/src/composables/useLocale.ts @@ -27,9 +27,7 @@ export function useLocale() { const flagMap: Record = { 'en': '🇬🇧', 'es': '🇪🇸', - 'fr': '🇫🇷', - 'de': '🇩🇪', - 'zh': '🇨🇳' + 'fr': '🇫🇷' } return flagMap[locale] || '🌐' } diff --git a/src/forum-app/App.vue b/src/forum-app/App.vue new file mode 100644 index 0000000..c8c1abb --- /dev/null +++ b/src/forum-app/App.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/forum-app/app.config.ts b/src/forum-app/app.config.ts new file mode 100644 index 0000000..2860abc --- /dev/null +++ b/src/forum-app/app.config.ts @@ -0,0 +1,50 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Forum app configuration. + * Only enables base + forum modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + forum: { + name: 'forum', + enabled: true, + lazy: false, + config: { + maxSubmissions: 50, + corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '', + adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/forum-app/app.ts b/src/forum-app/app.ts new file mode 100644 index 0000000..b1e1d4c --- /dev/null +++ b/src/forum-app/app.ts @@ -0,0 +1,120 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import forumModule from '@/modules/forum' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Forum app...') + + acceptTokenFromUrl('Forum') + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...forumModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/forum' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + catchAllRoute, + ] + }) + + installLenientAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.forum?.enabled) { + moduleRegistrations.push( + pluginManager.register(forumModule, appConfig.modules.forum) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Forum app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Forum app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Forum app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/forum-app/main.ts b/src/forum-app/main.ts new file mode 100644 index 0000000..8b7df15 --- /dev/null +++ b/src/forum-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Forum app ready to work offline') + } +}) + +startApp() diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d57cae0..90f7979 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,7 +5,7 @@ import { useStorage } from '@vueuse/core' import en from './locales/en' // Define available locales -export const AVAILABLE_LOCALES = ['en', 'es', 'fr', 'de', 'zh'] as const +export const AVAILABLE_LOCALES = ['en', 'es', 'fr'] as const export type AvailableLocale = typeof AVAILABLE_LOCALES[number] // Type for our messages diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9b77759..3e1a597 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'Income submission is not yet available. This feature is coming soon.', }, }, + market: { + auth: { + loginPrompt: 'Log in to place your order', + logIn: 'Log in', + logInToCheckout: 'Log in to checkout', + nostrKeyRequired: 'A Nostr identity is required', + nostrKeyDescription: 'Configure your Nostr public key in Profile settings to place orders.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 82f0816..53a7d40 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', }, }, + market: { + auth: { + loginPrompt: 'Inicia sesi\u00f3n para realizar tu pedido', + logIn: 'Iniciar sesi\u00f3n', + logInToCheckout: 'Iniciar sesi\u00f3n para finalizar compra', + nostrKeyRequired: 'Se requiere una identidad Nostr', + nostrKeyDescription: 'Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index b4b4c08..5f11684 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', }, }, + market: { + auth: { + loginPrompt: 'Connectez-vous pour passer commande', + logIn: 'Se connecter', + logInToCheckout: 'Se connecter pour commander', + nostrKeyRequired: 'Une identit\u00e9 Nostr est requise', + nostrKeyDescription: 'Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index ddd20c6..d81753a 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -144,6 +144,16 @@ export interface LocaleMessages { notAvailable: string } } + // Market module + market?: { + auth: { + loginPrompt: string + logIn: string + logInToCheckout: string + nostrKeyRequired: string + nostrKeyDescription: string + } + } // Add date/time formats dateTimeFormats: { short: { diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index 1166cc8..c837861 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -195,6 +195,35 @@ export class LnbitsAPI extends BaseService { return !!this.accessToken } + /** + * Server-validate a token and adopt it if valid (issue #36). + * + * Called by AuthService.checkAuth() when a pending URL-supplied token is + * found in localStorage. We can't trust the token until the server has + * confirmed it represents a real session, so: + * 1. Temporarily set the candidate token on the API client + * 2. Try getCurrentUser() with it + * 3. On success → persist to AUTH_TOKEN_KEY, return the user + * 4. On failure → restore the previous token (if any), return null + * + * The pending token is the caller's responsibility to remove from + * localStorage afterwards. + */ + async tryAdoptToken(candidateToken: string): Promise { + const previousToken = this.accessToken + this.accessToken = candidateToken + try { + const user = await this.getCurrentUser() + // Server confirmed — persist for future page loads + setAuthToken(candidateToken) + return user + } catch (err) { + console.warn('[LnbitsAPI] Pending URL token rejected by server:', err) + this.accessToken = previousToken + return null + } + } + getAccessToken(): string | null { return this.accessToken } diff --git a/src/lib/config/lnbits.ts b/src/lib/config/lnbits.ts index 5d6ce0c..dec6c8e 100644 --- a/src/lib/config/lnbits.ts +++ b/src/lib/config/lnbits.ts @@ -13,6 +13,11 @@ export const LNBITS_CONFIG = { // Auth token storage key AUTH_TOKEN_KEY: 'lnbits_access_token', + // Transient key for tokens received via ?token=… URL params. They live here + // until validateAndAdoptPendingToken() server-checks them; only validated + // tokens get promoted to AUTH_TOKEN_KEY. See issue #36. + PENDING_AUTH_TOKEN_KEY: 'lnbits_pending_token', + // User storage key USER_STORAGE_KEY: 'lnbits_user_data' } @@ -42,4 +47,20 @@ export function setAuthToken(token: string): void { // Helper function to remove auth token from storage export function removeAuthToken(): void { localStorage.removeItem(LNBITS_CONFIG.AUTH_TOKEN_KEY) -} +} + +// Pending token (URL-supplied, unvalidated) helpers. +// Pending tokens land here from acceptTokenFromUrl() and only get promoted +// to the real AUTH_TOKEN_KEY after server validation. +export function getPendingAuthToken(): string | null { + return localStorage.getItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY) +} + +export function setPendingAuthToken(token: string): void { + localStorage.setItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY, token) +} + +export function removePendingAuthToken(): void { + localStorage.removeItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY) +} + diff --git a/src/lib/dev-sw-cleanup.ts b/src/lib/dev-sw-cleanup.ts new file mode 100644 index 0000000..fd3de93 --- /dev/null +++ b/src/lib/dev-sw-cleanup.ts @@ -0,0 +1,42 @@ +/** + * Unregister any service worker that was registered on this origin during + * a previous dev session (when VitePWA's devOptions.enabled was true). + * + * Once devOptions.enabled was turned off, Vite stopped registering SWs in + * dev — but the browser keeps the previously-registered SWs alive across + * server restarts. They then intercept navigation and serve cached, often + * stale, bundles. This call clears them out at app boot. + * + * Production builds skip this entirely so the legitimate SW from + * `registerSW()` survives. + */ +export async function cleanupStaleDevServiceWorkers(): Promise { + if (!import.meta.env.DEV) return + if (!('serviceWorker' in navigator)) return + + try { + const regs = await navigator.serviceWorker.getRegistrations() + if (regs.length === 0) return + + console.warn( + `[dev-sw-cleanup] Unregistering ${regs.length} stale service worker(s) from a previous dev session.` + ) + await Promise.all(regs.map(r => r.unregister())) + + // Also clear any cache the dev SW left behind. + if ('caches' in window) { + const keys = await caches.keys() + await Promise.all(keys.map(k => caches.delete(k))) + } + + // Reload once so the next request hits the network instead of the + // about-to-be-removed SW. Guard with a sessionStorage flag so we don't + // loop on browsers that take an extra tick to release the controller. + if (!sessionStorage.getItem('dev-sw-cleanup-reloaded')) { + sessionStorage.setItem('dev-sw-cleanup-reloaded', '1') + window.location.reload() + } + } catch (err) { + console.warn('[dev-sw-cleanup] failed to unregister:', err) + } +} diff --git a/src/lib/router-helpers.ts b/src/lib/router-helpers.ts new file mode 100644 index 0000000..c23bf9d --- /dev/null +++ b/src/lib/router-helpers.ts @@ -0,0 +1,79 @@ +import type { Router, RouteRecordRaw } from 'vue-router' + +/** + * Auth-readiness deferred promise. + * + * Each app boots in three phases: + * 1. createRouter(...) and install guards (this file) + * 2. pluginManager.installAll() registers services (incl. LNbits API) + * 3. dynamic-import('@/composables/useAuthService') and auth.initialize() + * + * The auth service depends on services registered in phase 2, so it can only + * be loaded after that completes. But Vue Router's docs recommend installing + * guards before app.use(router). The deferred promise resolves the order + * mismatch: guards register early but await this promise before reading + * auth state. Phase 3 calls markAuthReady() once auth is initialized. + */ +type AuthUserLike = { value: { pubkey?: string } | null } +type AuthLike = { + isAuthenticated: { value: boolean } + // Populated after server-validated getCurrentUser() in auth.checkAuth(). + // Guards require BOTH isAuthenticated and a user with a pubkey — token + // presence alone is not enough (issue #36). + currentUser: AuthUserLike +} + +let resolveAuth!: (a: AuthLike) => void +const authReady: Promise = new Promise(r => { resolveAuth = r }) + +export function markAuthReady(auth: AuthLike): void { + resolveAuth(auth) +} + +/** + * Belt-and-suspenders auth check: token presence in localStorage isn't + * sufficient — the server must have confirmed the token represents a real + * session, which is signalled by currentUser being populated with a pubkey. + */ +function isFullyAuthed(auth: AuthLike): boolean { + return auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey +} + +/** + * Strict guard — every non-/login route requires auth. + * Used by wallet, chat, castle (no public view). + */ +export function installStrictAuthGuard(router: Router): void { + router.beforeEach(async (to) => { + const auth = await authReady + const authed = isFullyAuthed(auth) + if (to.path === '/login') { + return authed ? '/' : true + } + return authed ? true : '/login' + }) +} + +/** + * Lenient guard — only routes with meta.requiresAuth === true require auth. + * Used by hub and the public standalones (forum, market, tasks, activities). + */ +export function installLenientAuthGuard(router: Router): void { + router.beforeEach(async (to) => { + const auth = await authReady + const requiresAuth = to.meta.requiresAuth === true + const authed = isFullyAuthed(auth) + if (requiresAuth && !authed) return '/login' + if (to.path === '/login' && authed) return '/' + return true + }) +} + +/** + * Catch-all 404 → redirect home. Add as the LAST entry in any router's + * routes array. Vue Router 4 warns if no catch-all is defined. + */ +export const catchAllRoute: RouteRecordRaw = { + path: '/:pathMatch(.*)*', + redirect: '/', +} diff --git a/src/lib/url-token.ts b/src/lib/url-token.ts new file mode 100644 index 0000000..01ee5a8 --- /dev/null +++ b/src/lib/url-token.ts @@ -0,0 +1,27 @@ +import { setPendingAuthToken } from '@/lib/config/lnbits' + +/** + * Cross-subdomain auth relay (issue #36): pull `?token=…` off the URL into + * the pending-token slot in localStorage, then strip it from history so it + * doesn't bleed into bookmarks or referrers. + * + * The token is NOT promoted to the real auth-token slot here. AuthService + * .checkAuth() server-validates it via lnbitsAPI.tryAdoptToken() and only + * persists it if the LNbits backend confirms it represents a real session. + * + * Call this synchronously at app boot, before createApp(), so the URL is + * cleaned before vue-router has a chance to read it. The pending token sits + * in localStorage until auth.initialize() picks it up later in the same + * page load. + */ +export function acceptTokenFromUrl(appName: string): void { + const params = new URLSearchParams(window.location.search) + const token = params.get('token') + if (!token) return + setPendingAuthToken(token) + params.delete('token') + const clean = params.toString() + const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash + window.history.replaceState({}, '', newUrl) + console.log(`[${appName}] URL token captured for server validation`) +} diff --git a/src/main.ts b/src/main.ts index ee1eff7..a12e2a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,12 @@ // New modular application entry point import { startApp } from './app' import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' import 'vue-sonner/style.css' +// Clean up any leftover dev-mode service workers from a previous session +cleanupStaleDevServiceWorkers() + // Simple periodic service worker updates const intervalMS = 60 * 60 * 1000 // 1 hour registerSW({ diff --git a/src/market-app/App.vue b/src/market-app/App.vue new file mode 100644 index 0000000..7d40951 --- /dev/null +++ b/src/market-app/App.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/market-app/app.config.ts b/src/market-app/app.config.ts new file mode 100644 index 0000000..e222aa4 --- /dev/null +++ b/src/market-app/app.config.ts @@ -0,0 +1,53 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Market app configuration. + * Only enables base + market modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + market: { + name: 'market', + enabled: true, + lazy: false, + config: { + defaultCurrency: 'sats', + paymentTimeout: 300000, + maxOrderHistory: 50, + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + } + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/market-app/app.ts b/src/market-app/app.ts new file mode 100644 index 0000000..103ce53 --- /dev/null +++ b/src/market-app/app.ts @@ -0,0 +1,120 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import marketModule from '@/modules/market' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Market app...') + + acceptTokenFromUrl('Market') + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...marketModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/market' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + catchAllRoute, + ] + }) + + installLenientAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.market?.enabled) { + moduleRegistrations.push( + pluginManager.register(marketModule, appConfig.modules.market) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Market app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Market app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Market app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/market-app/main.ts b/src/market-app/main.ts new file mode 100644 index 0000000..4965337 --- /dev/null +++ b/src/market-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Market app ready to work offline') + } +}) + +startApp() diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index 9f64931..7b4b6fe 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -5,6 +5,7 @@ import { eventBus } from '@/core/event-bus' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' import type { NostrMetadataService } from '../nostr/nostr-metadata-service' +import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' export class AuthService extends BaseService { // Service metadata @@ -49,6 +50,28 @@ export class AuthService extends BaseService { } async checkAuth(): Promise { + // Pending URL-supplied token (from acceptTokenFromUrl in app shells). + // Validate server-side before promoting to the real auth-token slot — + // see issue #36. Always remove the pending entry whether validation + // succeeds or fails so it can't recur on later boots. + const pending = getPendingAuthToken() + if (pending) { + removePendingAuthToken() + this.isLoading.value = true + try { + const adopted = await this.lnbitsAPI.tryAdoptToken(pending) + if (adopted) { + this.user.value = adopted + this.isAuthenticated.value = true + this.debug(`Adopted pending URL token for ${adopted.username || adopted.id}`) + return true + } + this.debug('Pending URL token rejected — falling through to existing token') + } finally { + this.isLoading.value = false + } + } + if (!this.lnbitsAPI.isAuthenticated()) { this.debug('No auth token found - user needs to login') this.isAuthenticated.value = false @@ -59,14 +82,14 @@ export class AuthService extends BaseService { try { this.isLoading.value = true const userData = await this.lnbitsAPI.getCurrentUser() - + this.user.value = userData this.isAuthenticated.value = true - + this.debug(`User authenticated: ${userData.username || userData.id} (${userData.pubkey?.slice(0, 8)})`) - + return true - + } catch (error) { this.handleError(error, 'checkAuth') this.isAuthenticated.value = false diff --git a/src/modules/base/components/ProfileSettings.vue b/src/modules/base/components/ProfileSettings.vue index d1dab22..fed1828 100644 --- a/src/modules/base/components/ProfileSettings.vue +++ b/src/modules/base/components/ProfileSettings.vue @@ -147,6 +147,34 @@ Use the "Broadcast to Nostr" button to manually re-broadcast your profile.

+ + + +
+ + + + + + + Log out of {{ user?.username || 'your account' }}? + + You'll need to sign in again to access your wallet, post in the + forum, place orders, or use any feature that needs your account. + + + + Cancel + + Log out + + + + +
@@ -168,14 +196,28 @@ import { FormMessage, } from '@/components/ui/form' import ImageUpload from './ImageUpload.vue' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { LogOut } from 'lucide-vue-next' import { useAuth } from '@/composables/useAuthService' +import { useRouter } from 'vue-router' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { ImageUploadService } from '../services/ImageUploadService' import type { NostrMetadataService } from '../nostr/nostr-metadata-service' import { useToast } from '@/core/composables/useToast' // Services -const { user, updateProfile } = useAuth() +const { user, updateProfile, logout } = useAuth() +const router = useRouter() const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const metadataService = injectService(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) const toast = useToast() @@ -322,4 +364,17 @@ const broadcastMetadata = async () => { isBroadcasting.value = false } } + +// Log out + redirect to /login on this app's origin. +const onLogout = async () => { + try { + await logout() + toast.success('Logged out') + router.push('/login') + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to log out' + console.error('Error logging out:', error) + toast.error(`Logout failed: ${errorMessage}`) + } +} diff --git a/src/modules/links/components/SortTabs.vue b/src/modules/forum/components/SortTabs.vue similarity index 100% rename from src/modules/links/components/SortTabs.vue rename to src/modules/forum/components/SortTabs.vue diff --git a/src/modules/links/components/SubmissionComment.vue b/src/modules/forum/components/SubmissionComment.vue similarity index 100% rename from src/modules/links/components/SubmissionComment.vue rename to src/modules/forum/components/SubmissionComment.vue diff --git a/src/modules/links/components/SubmissionDetail.vue b/src/modules/forum/components/SubmissionDetail.vue similarity index 100% rename from src/modules/links/components/SubmissionDetail.vue rename to src/modules/forum/components/SubmissionDetail.vue diff --git a/src/modules/links/components/SubmissionList.vue b/src/modules/forum/components/SubmissionList.vue similarity index 100% rename from src/modules/links/components/SubmissionList.vue rename to src/modules/forum/components/SubmissionList.vue diff --git a/src/modules/links/components/SubmissionRow.vue b/src/modules/forum/components/SubmissionRow.vue similarity index 100% rename from src/modules/links/components/SubmissionRow.vue rename to src/modules/forum/components/SubmissionRow.vue diff --git a/src/modules/links/components/SubmissionThumbnail.vue b/src/modules/forum/components/SubmissionThumbnail.vue similarity index 100% rename from src/modules/links/components/SubmissionThumbnail.vue rename to src/modules/forum/components/SubmissionThumbnail.vue diff --git a/src/modules/links/components/SubmitComposer.vue b/src/modules/forum/components/SubmitComposer.vue similarity index 100% rename from src/modules/links/components/SubmitComposer.vue rename to src/modules/forum/components/SubmitComposer.vue diff --git a/src/modules/links/components/VoteControls.vue b/src/modules/forum/components/VoteControls.vue similarity index 100% rename from src/modules/links/components/VoteControls.vue rename to src/modules/forum/components/VoteControls.vue diff --git a/src/modules/links/composables/useSubmissions.ts b/src/modules/forum/composables/useSubmissions.ts similarity index 100% rename from src/modules/links/composables/useSubmissions.ts rename to src/modules/forum/composables/useSubmissions.ts diff --git a/src/modules/links/index.ts b/src/modules/forum/index.ts similarity index 76% rename from src/modules/links/index.ts rename to src/modules/forum/index.ts index ebf7b74..c8b645b 100644 --- a/src/modules/links/index.ts +++ b/src/modules/forum/index.ts @@ -7,8 +7,8 @@ import { LinkPreviewService } from './services/LinkPreviewService' import SubmissionList from './components/SubmissionList.vue' import SubmitComposer from './components/SubmitComposer.vue' -export const linksModule: ModulePlugin = { - name: 'links', +export const forumModule: ModulePlugin = { + name: 'forum', version: '1.0.0', dependencies: ['base'], @@ -25,6 +25,12 @@ export const linksModule: ModulePlugin = { ], routes: [ + { + path: '/forum', + name: 'forum', + component: () => import('./views/ForumListPage.vue'), + meta: { title: 'Forum', requiresAuth: false } + }, { path: '/submission/:id', name: 'submission-detail', @@ -40,16 +46,16 @@ export const linksModule: ModulePlugin = { ], async install(app: App) { - console.log('links module: Starting installation...') + console.log('forum module: Starting installation...') const submissionService = new SubmissionService() const linkPreviewService = new LinkPreviewService() container.provide(SERVICE_TOKENS.SUBMISSION_SERVICE, submissionService) container.provide(SERVICE_TOKENS.LINK_PREVIEW_SERVICE, linkPreviewService) - console.log('links module: Services registered in DI container') + console.log('forum module: Services registered in DI container') - console.log('links module: Initializing services...') + console.log('forum module: Initializing services...') await Promise.all([ submissionService.initialize({ waitForDependencies: true, @@ -60,10 +66,10 @@ export const linksModule: ModulePlugin = { maxRetries: 3 }) ]) - console.log('links module: Services initialized') + console.log('forum module: Services initialized') app.component('SubmissionList', SubmissionList) - console.log('links module: Installation complete') + console.log('forum module: Installation complete') }, components: { @@ -74,4 +80,4 @@ export const linksModule: ModulePlugin = { composables: {} } -export default linksModule +export default forumModule diff --git a/src/modules/links/services/LinkPreviewService.ts b/src/modules/forum/services/LinkPreviewService.ts similarity index 100% rename from src/modules/links/services/LinkPreviewService.ts rename to src/modules/forum/services/LinkPreviewService.ts diff --git a/src/modules/links/services/SubmissionService.ts b/src/modules/forum/services/SubmissionService.ts similarity index 99% rename from src/modules/links/services/SubmissionService.ts rename to src/modules/forum/services/SubmissionService.ts index cd4a808..f9ad31b 100644 --- a/src/modules/links/services/SubmissionService.ts +++ b/src/modules/forum/services/SubmissionService.ts @@ -369,7 +369,7 @@ export class SubmissionService extends BaseService { this._submissions.set(submission.id, submissionWithMeta) // Emit event - eventBus.emit('submission:new', { submission: submissionWithMeta }, 'links') + eventBus.emit('submission:new', { submission: submissionWithMeta }, 'forum') } /** diff --git a/src/modules/links/types/index.ts b/src/modules/forum/types/index.ts similarity index 100% rename from src/modules/links/types/index.ts rename to src/modules/forum/types/index.ts diff --git a/src/modules/links/types/submission.ts b/src/modules/forum/types/submission.ts similarity index 100% rename from src/modules/links/types/submission.ts rename to src/modules/forum/types/submission.ts diff --git a/src/modules/forum/views/ForumListPage.vue b/src/modules/forum/views/ForumListPage.vue new file mode 100644 index 0000000..9c9246b --- /dev/null +++ b/src/modules/forum/views/ForumListPage.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/modules/links/views/SubmissionDetailPage.vue b/src/modules/forum/views/SubmissionDetailPage.vue similarity index 100% rename from src/modules/links/views/SubmissionDetailPage.vue rename to src/modules/forum/views/SubmissionDetailPage.vue diff --git a/src/modules/links/views/SubmitPage.vue b/src/modules/forum/views/SubmitPage.vue similarity index 100% rename from src/modules/links/views/SubmitPage.vue rename to src/modules/forum/views/SubmitPage.vue diff --git a/src/modules/market/components/CartButton.vue b/src/modules/market/components/CartButton.vue deleted file mode 100644 index 6377564..0000000 --- a/src/modules/market/components/CartButton.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index cadb14b..62c66da 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -3,7 +3,7 @@ import { useMarketStore } from '../stores/market' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { config } from '@/lib/config' import type { NostrmarketService } from '../services/nostrmarketService' -import { nip04 } from 'nostr-tools' +import { nip59 } from 'nostr-tools' import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { auth } from '@/composables/useAuthService' @@ -14,6 +14,32 @@ const MARKET_EVENT_KINDS = { PRODUCT: 30018 } as const +/** + * Resolve a product's parent stall id from the event. + * + * NIP-15 lists `stall_id` inside the JSON `content`, but some publishers + * (older nostrmarket builds, third-party clients) only emit the parent + * reference via an `a` tag of the form + * ["a", "30017::"] + * + * Read content first, then fall back to the tag, then a sentinel that won't + * match any real stall. Returning the tag form prevents "Unknown Stall" + * from sticking when the JSON omits the field. + */ +function resolveStallId(event: any, productData: any): string { + if (productData?.stall_id && typeof productData.stall_id === 'string') { + return productData.stall_id + } + const aTag = event.tags?.find( + (t: any) => Array.isArray(t) && t[0] === 'a' && typeof t[1] === 'string' && t[1].startsWith(`${MARKET_EVENT_KINDS.STALL}:`) + ) + if (aTag) { + const parts = aTag[1].split(':') + if (parts[2]) return parts[2] + } + return 'unknown' +} + export function useMarket() { const marketStore = useMarketStore() const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any @@ -28,19 +54,29 @@ export function useMarket() { throw new Error('AuthService not available. Make sure base module is installed.') } - // Register market DM handler with chat service (if available) + // Subscribe to incoming order gift wraps (NIP-17 / kind 1059) addressed to the user. + // + // The chat service still runs on NIP-04 (kind 4); when it migrates to NIP-17 it + // can take over routing of order DMs the way it does today via setMarketMessageHandler. + // Until then the market subscribes directly so order flows aren't dependent on chat. const registerMarketMessageHandler = () => { try { - // Try to get the chat service (it might not be available if chat module isn't loaded) - const chatService = (globalThis as any).chatService - if (chatService && chatService.setMarketMessageHandler) { - chatService.setMarketMessageHandler(handleOrderDM) - console.log('🛒 Registered market message handler with chat service') - } else { - console.log('🛒 Chat service not available, market will use its own DM subscription') + const userPubkey = authService.user.value?.pubkey || auth.currentUser.value?.pubkey + if (!userPubkey) { + console.log('🛒 No user pubkey available; skipping order gift-wrap subscription') + return } + + const unsubscribe = relayHub.subscribe({ + id: `market-orders-${userPubkey.slice(0, 16)}`, + filters: [{ kinds: [1059], '#p': [userPubkey] }], + onEvent: (event: any) => handleOrderDM(event) + }) + console.log('🎁 Subscribed to order gift wraps (kind 1059)') + // unsubscribe is currently not retained; market lifecycle owns this + void unsubscribe } catch (error) { - console.log('🛒 Could not register with chat service:', error) + console.warn('🛒 Failed to subscribe to order gift wraps:', error) } } @@ -64,17 +100,15 @@ export function useMarket() { return 'disconnected' }) - // Load market from naddr + // Load market from naddr (or empty for public browse mode) const loadMarket = async (naddr: string) => { return await marketOperation.execute(async () => { - // Parse naddr to get market data + // Parse naddr (when given) to get market identifier + pubkey. + // Empty naddr + unauth user → public browse mode (no pubkey filter). + const parts = naddr ? naddr.split(':') : [] const marketData = { - identifier: naddr.split(':')[2] || 'default', - pubkey: naddr.split(':')[1] || authService.user.value?.pubkey || '' - } - - if (!marketData.pubkey) { - throw new Error('No pubkey available for market') + identifier: parts[2] || 'default', + pubkey: parts[1] || authService.user.value?.pubkey || '' } await loadMarketData(marketData) @@ -87,8 +121,30 @@ export function useMarket() { // Load market data from Nostr events const loadMarketData = async (marketData: any) => { try { - console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) }) - + console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) || '(public browse)' }) + + // Public browse mode: no curated naddr and no logged-in user. + // Skip the kind 30019 query and use a "Discover" placeholder market; + // loadStalls/loadProducts treat browseAll=true as "no authors filter". + if (!marketData.pubkey) { + const market = { + d: marketData.identifier, + pubkey: '', + relays: config.nostr.relays, + selected: true, + browseAll: true, + opts: { + name: 'Discover', + description: 'Public stalls and products from your relays', + merchants: [], + ui: {} + } + } + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + return + } + // Check if we can query events (relays are connected) if (!isConnected.value) { console.log('🛒 Not connected to relays, creating default market') @@ -105,12 +161,12 @@ export function useMarket() { ui: {} } } - + marketStore.addMarket(market) marketStore.setActiveMarket(market) return } - + // Load market data from Nostr events // Fetch market configuration event const events = await relayHub.queryEvents([ @@ -148,7 +204,11 @@ export function useMarket() { relays: config.nostr.relays, selected: true, opts: { - name: `${import.meta.env.VITE_APP_NAME} Market`, + // Logged-in user has no published market event yet — show their + // namespace as "My Market". Avoids leaking VITE_APP_NAME (which + // is the brand of whichever standalone app is bundled, e.g. + // "Sortir" for activities) into the market label. + name: 'My Market', description: 'A communal market to sell your goods', merchants: [], ui: {} @@ -179,7 +239,7 @@ export function useMarket() { } } - // Load stalls from market merchants + // Load stalls from market merchants (or all stalls in public browse mode) const loadStalls = async () => { try { // Get the active market to filter by its merchants @@ -188,19 +248,20 @@ export function useMarket() { return } - const merchants = [...(activeMarket.opts.merchants || [])] - - if (merchants.length === 0) { - return - } + const browseAll = (activeMarket as any).browseAll === true + const merchants = [...(activeMarket.opts.merchants || [])] - // Fetch stall events from market merchants only - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.STALL], - authors: merchants - } - ]) + if (!browseAll && merchants.length === 0) { + return + } + + // Build filter: in browse-all mode no authors filter; otherwise scope to merchants. + const stallFilter: any = { kinds: [MARKET_EVENT_KINDS.STALL] } + if (!browseAll && merchants.length > 0) { + stallFilter.authors = merchants + } + + const events = await relayHub.queryEvents([stallFilter]) console.log('🛒 Found', events.length, 'stall events for', merchants.length, 'merchants') @@ -245,7 +306,7 @@ export function useMarket() { } } - // Load products from market stalls + // Load products from market stalls (or all products in public browse mode) const loadProducts = async () => { try { const activeMarket = marketStore.activeMarket @@ -253,18 +314,19 @@ export function useMarket() { return } + const browseAll = (activeMarket as any).browseAll === true const merchants = [...(activeMarket.opts.merchants || [])] - if (merchants.length === 0) { - return - } - // Fetch product events from market merchants - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.PRODUCT], - authors: merchants - } - ]) + if (!browseAll && merchants.length === 0) { + return + } + + const productFilter: any = { kinds: [MARKET_EVENT_KINDS.PRODUCT] } + if (!browseAll && merchants.length > 0) { + productFilter.authors = merchants + } + + const events = await relayHub.queryEvents([productFilter]) console.log('🛒 Found', events.length, 'product events for', merchants.length, 'merchants') @@ -289,7 +351,7 @@ export function useMarket() { try { const productData = JSON.parse(latestEvent.content) - const stallId = productData.stall_id || 'unknown' + const stallId = resolveStallId(latestEvent, productData) // Extract categories from Nostr event tags (standard approach) const categories = latestEvent.tags @@ -371,53 +433,46 @@ export function useMarket() { return null } - // Handle incoming order DMs (payment requests, status updates) + // Convert hex string to Uint8Array (browser-compatible) + const hexToUint8Array = (hex: string): Uint8Array => { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16) + } + return bytes + } + + // Handle incoming order gift wraps (kind 1059) — payment requests, status updates. + // + // The outer event's pubkey is an ephemeral key (NIP-59); the real merchant + // pubkey is on the unwrapped rumor. Content is JSON with a `type` field + // (1 = payment request, 2 = order status update). const handleOrderDM = async (event: any) => { try { - console.log('🔔 Received order-related DM:', event.id, 'from:', event.pubkey.slice(0, 8)) - - // Check both injected auth service AND global auth composable - const hasAuthService = authService.user.value?.prvkey - const hasGlobalAuth = auth.currentUser.value?.prvkey - - const userPrivkey = hasAuthService ? authService.user.value.prvkey : auth.currentUser.value?.prvkey - const userPubkey = hasAuthService ? authService.user.value.pubkey : auth.currentUser.value?.pubkey - - if (!userPrivkey || !userPubkey) { - console.warn('Cannot decrypt DM: no user private key available', { - hasAuthService: !!hasAuthService, - hasGlobalAuth: !!hasGlobalAuth, - authServicePrivkey: !!authService.user.value?.prvkey, - globalAuthPrivkey: !!auth.currentUser.value?.prvkey - }) + console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')') + + const userPrivkey = + authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey + + if (!userPrivkey) { + console.warn('Cannot unwrap gift wrap: no user private key available') return } - - console.log('🔐 Market DM decryption auth check:', { - hasAuthService: !!hasAuthService, - hasGlobalAuth: !!hasGlobalAuth, - usingAuthService: !!hasAuthService, - userPubkey: userPubkey.substring(0, 10) + '...' - }) - console.log('🔓 Attempting to decrypt DM with private key available') + const prvkeyBytes = hexToUint8Array(userPrivkey) + const rumor = nip59.unwrapEvent(event, prvkeyBytes) + console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...') - // Decrypt the DM content - const decryptedContent = await nip04.decrypt(userPrivkey, event.pubkey, event.content) - console.log('🔓 Decrypted DM content:', decryptedContent) - - // Parse the decrypted content as JSON - const messageData = JSON.parse(decryptedContent) + const messageData = JSON.parse(rumor.content) console.log('📨 Parsed message data:', messageData) - // Handle different types of messages switch (messageData.type) { case 1: // Payment request console.log('💰 Processing payment request for order:', messageData.id) await nostrmarketService.handlePaymentRequest(messageData) console.log('✅ Payment request processed successfully') break - case 2: // Order status update + case 2: // Order status update console.log('📦 Processing order status update for order:', messageData.id) await nostrmarketService.handleOrderStatusUpdate(messageData) console.log('✅ Order status update processed successfully') @@ -426,7 +481,7 @@ export function useMarket() { console.log('❓ Unknown message type:', messageData.type) } } catch (error) { - console.error('Failed to handle order DM:', error) + console.error('Failed to handle order gift wrap:', error) } } @@ -489,7 +544,7 @@ export function useMarket() { const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] if (productId) { const productData = JSON.parse(event.content) - const stallId = productData.stall_id || 'unknown' + const stallId = resolveStallId(event, productData) // Extract categories from Nostr event tags (standard approach) const categories = event.tags diff --git a/src/modules/market/composables/useMarketStallSelfHeal.ts b/src/modules/market/composables/useMarketStallSelfHeal.ts new file mode 100644 index 0000000..6a0eb65 --- /dev/null +++ b/src/modules/market/composables/useMarketStallSelfHeal.ts @@ -0,0 +1,99 @@ +import { useAuth } from '@/composables/useAuthService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { NostrmarketAPI } from '../services/nostrmarketAPI' + +const SESSION_FLAG = 'market-stall-self-heal-checked' +const STALL_EVENT_KIND = 30017 + +/** + * Detect-and-recover from the LNbits orphan-stall bug + * (aiolabs/lnbits#10): _create_default_merchant provisions the merchant + * + stall in nostrmarket's internal SQLite but historically never + * published the kind-30017 stall event to relays. The upstream fix is + * already in c0f3743c on aiolabs/lnbits@demo, but it only helps NEW + * signups. Existing accounts whose auto-stall never made it to a relay + * stay orphaned until somebody republishes — which manifests in our + * webapp as "Unknown Stall" on every product authored by them. + * + * This composable runs once per browser session (sessionStorage gate) + * for any logged-in user who lands on the merchant dashboard: + * + * 1. Ask the relay for kind-30017 events authored by their pubkey. + * 2. Ask LNbits for the merchant's known stalls. + * 3. For each stall in (2) whose id isn't represented in (1), PUT the + * stall back to LNbits. The PUT path on the LNbits side already + * calls sign_and_send_to_nostr, so the kind-30017 event lands on + * the relay without any user interaction. + * + * Silent on success. Logs to console.info on republish; console.warn on + * failure. Never toasts — this is supposed to be invisible. + * + * Tracked in aiolabs/webapp#38. + */ +export function useMarketStallSelfHeal() { + const { user } = useAuth() + const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any + const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI + + async function selfHealOnce(): Promise { + if (sessionStorage.getItem(SESSION_FLAG)) return + + const currentUser = user.value + if (!currentUser?.pubkey) return + + const wallets = (currentUser as any).wallets as Array<{ adminkey?: string; inkey?: string }> | undefined + if (!wallets?.length) return + + const adminWallet = wallets.find(w => w.adminkey) || wallets[0] + if (!adminWallet?.adminkey || !adminWallet?.inkey) return + + // Mark checked early — even on failure we don't want to retry on every + // dashboard mount during the same tab session. + sessionStorage.setItem(SESSION_FLAG, '1') + + if (!relayHub || !nostrmarketAPI) { + console.warn('[market-self-heal] Required services unavailable, skipping') + return + } + + try { + const relayEvents: Array<{ tags?: Array<[string, string?]> }> = await relayHub.queryEvents([ + { kinds: [STALL_EVENT_KIND], authors: [currentUser.pubkey] }, + ]) + const publishedStallIds = new Set() + for (const ev of relayEvents) { + const dTag = ev.tags?.find(t => Array.isArray(t) && t[0] === 'd') + const stallId = dTag?.[1] + if (stallId) publishedStallIds.add(stallId) + } + + const lnbitsStalls = await nostrmarketAPI.getStalls(adminWallet.inkey) + const orphans = lnbitsStalls.filter(s => !publishedStallIds.has(s.id)) + + if (orphans.length === 0) { + console.info( + `[market-self-heal] All ${lnbitsStalls.length} stall(s) have a relay event — no recovery needed.`, + ) + return + } + + console.info( + `[market-self-heal] Republishing ${orphans.length} orphan stall(s):`, + orphans.map(s => `${s.id} (${s.name})`), + ) + + for (const stall of orphans) { + try { + await nostrmarketAPI.updateStall(adminWallet.adminkey, stall) + console.info(`[market-self-heal] Republished ${stall.id} (${stall.name})`) + } catch (err) { + console.warn(`[market-self-heal] Failed to republish ${stall.id}:`, err) + } + } + } catch (err) { + console.warn('[market-self-heal] Self-heal check failed:', err) + } + } + + return { selfHealOnce } +} diff --git a/src/modules/market/services/nostrmarketService.ts b/src/modules/market/services/nostrmarketService.ts index 733879e..6598d38 100644 --- a/src/modules/market/services/nostrmarketService.ts +++ b/src/modules/market/services/nostrmarketService.ts @@ -1,4 +1,4 @@ -import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' +import { type EventTemplate, nip59 } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' import type { Order } from '@/modules/market/stores/market' @@ -159,12 +159,17 @@ export class NostrmarketService extends BaseService { // Stall and product publishing is now handled by LNbits API endpoints /** - * Publish an order event (kind 4 encrypted DM) to nostrmarket + * Publish an order as a NIP-59 gift-wrapped (kind 1059) event to nostrmarket. + * + * The order JSON is placed in an unsigned kind 14 rumor, sealed (kind 13) + * with the customer's key, and wrapped (kind 1059) with an ephemeral key. + * Only the merchant can decrypt the wrap; the public event reveals nothing + * about the sender. */ async publishOrder(order: Order, merchantPubkey: string): Promise { const { prvkey } = this.getAuth() - - // Convert order to nostrmarket format - exactly matching the specification + + // Convert order to nostrmarket format - matches NIP-15 customer order spec const orderData = { type: 0, // DirectMessageType.CUSTOMER_ORDER id: order.id, @@ -175,72 +180,37 @@ export class NostrmarketService extends BaseService { contact: { name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown', email: order.contactInfo?.email || '' - // Remove phone field - not in nostrmarket specification }, - // Only include address if it's a physical good and address is provided ...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? { address: order.contactInfo.address } : {}), shipping_id: order.shippingZone?.id || 'online' } - // Encrypt the message using NIP-04 - console.log('🔐 NIP-04 encryption debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - merchantPubkeyType: typeof merchantPubkey, - merchantPubkeyLength: merchantPubkey.length, - orderDataString: JSON.stringify(orderData).substring(0, 50) + '...' - }) - - let encryptedContent: string - try { - encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData)) - console.log('🔐 NIP-04 encryption successful:', { - encryptedContentLength: encryptedContent.length, - encryptedContentSample: encryptedContent.substring(0, 50) + '...' - }) - } catch (error) { - console.error('🔐 NIP-04 encryption failed:', error) - throw error - } - - const eventTemplate: EventTemplate = { - kind: 4, // Encrypted DM - tags: [['p', merchantPubkey]], // Recipient (merchant) - content: encryptedContent, // Use encrypted content + const rumorTemplate: Partial = { + kind: 14, + tags: [['p', merchantPubkey]], + content: JSON.stringify(orderData), created_at: Math.floor(Date.now() / 1000) } - console.log('🔧 finalizeEvent debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - encodedPrvkeyType: typeof new TextEncoder().encode(prvkey), - encodedPrvkeyLength: new TextEncoder().encode(prvkey).length, - eventTemplate - }) - - // Convert hex string to Uint8Array properly const prvkeyBytes = this.hexToUint8Array(prvkey) - console.log('🔧 prvkeyBytes debug:', { - prvkeyBytesType: typeof prvkeyBytes, - prvkeyBytesLength: prvkeyBytes.length, - prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array + const giftWrap = nip59.wrapEvent(rumorTemplate, prvkeyBytes, merchantPubkey) + + console.log('🎁 Order gift-wrapped (NIP-17):', { + orderId: order.id, + giftWrapId: giftWrap.id, + kind: giftWrap.kind, + merchantPubkey: merchantPubkey.substring(0, 10) + '...' }) - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await this.relayHub.publishEvent(event) - + const result = await this.relayHub.publishEvent(giftWrap) + console.log('Order published to nostrmarket:', { orderId: order.id, - eventId: result, + eventId: giftWrap.id, merchantPubkey, - content: orderData, - encryptedContent: encryptedContent.substring(0, 50) + '...' + content: orderData }) return result.success.toString() diff --git a/src/modules/market/stores/market.ts b/src/modules/market/stores/market.ts index b1a11ba..7c0d8b1 100644 --- a/src/modules/market/stores/market.ts +++ b/src/modules/market/stores/market.ts @@ -239,14 +239,23 @@ export const useMarketStore = defineStore('market', () => { } const addProduct = (product: Product) => { - const existingIndex = products.value.findIndex(p => p.id === product.id) + // Lookup stallName from the current stall set — the value passed in by + // the caller can be stale ("Unknown Stall") if the stall event hadn't + // arrived yet. The reverse race (stall arrives first) is handled in + // addStall below. + const matchedStall = stalls.value.find(s => s.id === product.stall_id) + const enriched: Product = matchedStall + ? { ...product, stallName: matchedStall.name } + : product + + const existingIndex = products.value.findIndex(p => p.id === enriched.id) if (existingIndex >= 0) { - products.value[existingIndex] = product + products.value[existingIndex] = enriched } else { - products.value.push(product) + products.value.push(enriched) } } - + const addStall = (stall: Stall) => { const existingIndex = stalls.value.findIndex(s => s.id === stall.id) if (existingIndex >= 0) { @@ -254,6 +263,14 @@ export const useMarketStore = defineStore('market', () => { } else { stalls.value.push(stall) } + // Re-stamp stallName on any products that arrived before this stall did + // (or whose stall name has changed). Direct property mutation on items + // in a reactive array triggers Vue's deep reactivity. + products.value.forEach(p => { + if (p.stall_id === stall.id && p.stallName !== stall.name) { + p.stallName = stall.name + } + }) } const addMarket = (market: Market) => { diff --git a/src/modules/market/views/CheckoutPage.vue b/src/modules/market/views/CheckoutPage.vue index 9e87ab7..997ad39 100644 --- a/src/modules/market/views/CheckoutPage.vue +++ b/src/modules/market/views/CheckoutPage.vue @@ -241,6 +241,7 @@
-

+ +

{{ orderValidationMessage }}

+

+ {{ t('market.auth.loginPrompt') }} +

@@ -262,7 +275,9 @@ diff --git a/src/modules/market/views/MarketPage.vue b/src/modules/market/views/MarketPage.vue index b4ca8e2..2428577 100644 --- a/src/modules/market/views/MarketPage.vue +++ b/src/modules/market/views/MarketPage.vue @@ -67,9 +67,6 @@ @view-stall="viewStall" /> - - - @@ -86,7 +83,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import MarketSearchBar from '../components/MarketSearchBar.vue' import ProductGrid from '../components/ProductGrid.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue' -import CartButton from '../components/CartButton.vue' import LoadingErrorState from '../components/LoadingErrorState.vue' import type { Product } from '../types/market' import type { FuzzySearchOptions } from '@/composables/useFuzzySearch' diff --git a/src/modules/market/views/StallView.vue b/src/modules/market/views/StallView.vue index 96322ad..5d64365 100644 --- a/src/modules/market/views/StallView.vue +++ b/src/modules/market/views/StallView.vue @@ -131,9 +131,6 @@ /> - - - + + diff --git a/src/tasks-app/App.vue b/src/tasks-app/App.vue new file mode 100644 index 0000000..8c7f8ba --- /dev/null +++ b/src/tasks-app/App.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/tasks-app/app.config.ts b/src/tasks-app/app.config.ts new file mode 100644 index 0000000..246e7d9 --- /dev/null +++ b/src/tasks-app/app.config.ts @@ -0,0 +1,49 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Tasks app configuration. + * Only enables base + tasks modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + tasks: { + name: 'tasks', + enabled: true, + lazy: false, + config: { + maxTasks: 200, + adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/tasks-app/app.ts b/src/tasks-app/app.ts new file mode 100644 index 0000000..2223842 --- /dev/null +++ b/src/tasks-app/app.ts @@ -0,0 +1,120 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import tasksModule from '@/modules/tasks' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Tasks app...') + + acceptTokenFromUrl('Tasks') + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...tasksModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/tasks' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + catchAllRoute, + ] + }) + + installLenientAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.tasks?.enabled) { + moduleRegistrations.push( + pluginManager.register(tasksModule, appConfig.modules.tasks) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Tasks app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Tasks app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Tasks app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/tasks-app/main.ts b/src/tasks-app/main.ts new file mode 100644 index 0000000..25e84da --- /dev/null +++ b/src/tasks-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Tasks app ready to work offline') + } +}) + +startApp() diff --git a/src/wallet-app/App.vue b/src/wallet-app/App.vue new file mode 100644 index 0000000..d4e6045 --- /dev/null +++ b/src/wallet-app/App.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/wallet-app/app.config.ts b/src/wallet-app/app.config.ts new file mode 100644 index 0000000..a38c7ab --- /dev/null +++ b/src/wallet-app/app.config.ts @@ -0,0 +1,59 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Wallet app configuration. + * Only enables base + wallet modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + wallet: { + name: 'wallet', + enabled: true, + lazy: false, + config: { + defaultReceiveAmount: 1000, + maxReceiveAmount: 1000000, + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + }, + websocket: { + enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', + reconnectDelay: 2000, + maxReconnectAttempts: 3, + fallbackToPolling: true, + pollingInterval: 10000 + } + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts new file mode 100644 index 0000000..e9fc6ae --- /dev/null +++ b/src/wallet-app/app.ts @@ -0,0 +1,123 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import walletModule from '@/modules/wallet' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Wallet app...') + + acceptTokenFromUrl('Wallet') + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...walletModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/wallet' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + catchAllRoute, + ] + }) + + // Wallet has no public view — every non-login route requires auth. + // Guard is installed before app.use(router); it awaits auth readiness + // internally (see router-helpers.ts). + installStrictAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.wallet?.enabled) { + moduleRegistrations.push( + pluginManager.register(walletModule, appConfig.modules.wallet) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Wallet app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Wallet app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Wallet app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/wallet-app/main.ts b/src/wallet-app/main.ts new file mode 100644 index 0000000..2039d3f --- /dev/null +++ b/src/wallet-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Wallet app ready to work offline') + } +}) + +startApp() diff --git a/tasks.html b/tasks.html new file mode 100644 index 0000000..6a5b49a --- /dev/null +++ b/tasks.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Tasks — Work Orders + + + + +
+ + + diff --git a/vite.activities.config.ts b/vite.activities.config.ts index 59ad437..b7627e4 100644 --- a/vite.activities.config.ts +++ b/vite.activities.config.ts @@ -15,13 +15,16 @@ function activitiesHtmlPlugin(): Plugin { name: 'activities-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { - // Rewrite all non-asset requests to activities.html + // Rewrite all non-asset requests to activities.html. + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') // skip files with extensions + !path.includes('.') ) { req.url = '/activities.html' } @@ -40,6 +43,12 @@ function activitiesHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-activities', + server: { + port: 5181, + strictPort: true, + }, plugins: [ activitiesHtmlPlugin(), vue(), @@ -47,7 +56,7 @@ export default defineConfig(({ mode }) => ({ VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: true, + enabled: false, }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], diff --git a/vite.castle.config.ts b/vite.castle.config.ts index c16c2a2..6ec7e0d 100644 --- a/vite.castle.config.ts +++ b/vite.castle.config.ts @@ -15,13 +15,16 @@ function castleHtmlPlugin(): Plugin { name: 'castle-html-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { - // Rewrite all non-asset requests to castle.html + // Rewrite all non-asset requests to castle.html. + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' if ( req.url && !req.url.startsWith('/@') && !req.url.startsWith('/src/') && !req.url.startsWith('/node_modules/') && - !req.url.includes('.') // skip files with extensions + !path.includes('.') ) { req.url = '/castle.html' } @@ -40,6 +43,12 @@ function castleHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-castle', + server: { + port: 5180, + strictPort: true, + }, plugins: [ castleHtmlPlugin(), vue(), @@ -47,7 +56,7 @@ export default defineConfig(({ mode }) => ({ VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: true, + enabled: false, }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], @@ -101,10 +110,13 @@ export default defineConfig(({ mode }) => ({ ], resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - // CRITICAL: Remap @/app.config to the castle app's config - // ExpensesAPI and other modules import from @/app.config directly + // ORDER MATTERS — @rollup/plugin-alias is first-match-wins. + // The more specific @/app.config remap must precede the @ prefix + // alias, otherwise '@/app.config' matches '@' first and resolves + // to ./src/app.config (the hub config). ExpensesAPI etc. import + // from @/app.config and need the per-app config. '@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, build: { diff --git a/vite.chat.config.ts b/vite.chat.config.ts new file mode 100644 index 0000000..c28535f --- /dev/null +++ b/vite.chat.config.ts @@ -0,0 +1,125 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +function chatHtmlPlugin(): Plugin { + return { + name: 'chat-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/chat.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Chat app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/chat/ → app.${domain}/chat/ (shared auth) + * (default: /) → chat.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-chat', + server: { + port: 5183, + strictPort: true, + }, + plugins: [ + chatHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: false }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'chat.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Chat — Encrypted', + short_name: 'Chat', + description: 'End-to-end encrypted Nostr chat', + theme_color: '#16a34a', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'chat-app', + categories: ['social', 'communication'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-chat/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-chat', + rollupOptions: { + input: 'chat.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/vite.config.ts b/vite.config.ts index fee1c90..fd07bc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,13 +9,21 @@ import { visualizer } from 'rollup-plugin-visualizer' // https://vite.dev/config/ export default defineConfig(({ mode }) => ({ + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-hub', + server: { + port: 5173, + strictPort: true, + }, plugins: [ vue(), tailwindcss(), VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: true + // SW disabled in dev — was caching stale bundles across restarts. + // Run `npm run preview` to test PWA behaviour against a real build. + enabled: false }, // strategies: 'injectManifest', srcDir: 'public', @@ -25,7 +33,7 @@ export default defineConfig(({ mode }) => ({ '**/*.{js,css,html,ico,png,svg}' ], // Don't intercept standalone app paths — they have their own service workers - navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//], + navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.forum.config.ts b/vite.forum.config.ts new file mode 100644 index 0000000..0fdebbe --- /dev/null +++ b/vite.forum.config.ts @@ -0,0 +1,125 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +function forumHtmlPlugin(): Plugin { + return { + name: 'forum-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/forum.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Forum app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/forum/ → app.${domain}/forum/ (shared auth) + * (default: /) → forum.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-forum', + server: { + port: 5184, + strictPort: true, + }, + plugins: [ + forumHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: false }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'forum.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Forum — Discussions', + short_name: 'Forum', + description: 'Decentralized link aggregator and discussion forum on Nostr', + theme_color: '#2563eb', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'forum-app', + categories: ['social', 'news'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-forum/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-forum', + rollupOptions: { + input: 'forum.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/vite.market.config.ts b/vite.market.config.ts new file mode 100644 index 0000000..bf38430 --- /dev/null +++ b/vite.market.config.ts @@ -0,0 +1,125 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +function marketHtmlPlugin(): Plugin { + return { + name: 'market-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/market.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Market app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/market/ → app.${domain}/market/ (shared auth) + * (default: /) → market.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-market', + server: { + port: 5185, + strictPort: true, + }, + plugins: [ + marketHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: false }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'market.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Market — Nostr', + short_name: 'Market', + description: 'Decentralized marketplace on Nostr with Lightning payments', + theme_color: '#dc2626', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'market-app', + categories: ['shopping', 'business'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-market/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-market', + rollupOptions: { + input: 'market.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts new file mode 100644 index 0000000..1edc3e6 --- /dev/null +++ b/vite.tasks.config.ts @@ -0,0 +1,125 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +function tasksHtmlPlugin(): Plugin { + return { + name: 'tasks-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/tasks.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Tasks app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/tasks/ → app.${domain}/tasks/ (shared auth) + * (default: /) → tasks.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-tasks', + server: { + port: 5186, + strictPort: true, + }, + plugins: [ + tasksHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: false }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'tasks.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Tasks — Work Orders', + short_name: 'Tasks', + description: 'Decentralized task management on Nostr', + theme_color: '#4338ca', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'tasks-app', + categories: ['productivity', 'business'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-tasks/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-tasks', + rollupOptions: { + input: 'tasks.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts new file mode 100644 index 0000000..f991672 --- /dev/null +++ b/vite.wallet.config.ts @@ -0,0 +1,131 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +/** + * Plugin to rewrite dev server requests to wallet.html + * (SPA fallback for the standalone Wallet app entry point) + */ +function walletHtmlPlugin(): Plugin { + return { + name: 'wallet-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/wallet.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Wallet app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/wallet/ → app.${domain}/wallet/ (shared auth) + * (default: /) → wallet.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-wallet', + server: { + port: 5182, + strictPort: true, + }, + plugins: [ + walletHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: false, + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'wallet.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Wallet — Lightning', + short_name: 'Wallet', + description: 'Lightning Network wallet — send, receive, and manage sats', + theme_color: '#eab308', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'wallet-app', + categories: ['finance'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-wallet/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-wallet', + rollupOptions: { + input: 'wallet.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/wallet.html b/wallet.html new file mode 100644 index 0000000..249f51d --- /dev/null +++ b/wallet.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Wallet — Lightning + + + + +
+ + +