From af338016c64d08575935b5cb4034f19f4fa9a62e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:52:37 +0200 Subject: [PATCH 01/38] refactor: rename links module to forum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The links module already implements a reddit-style link aggregator (NIP-72 communities + kind 1111 + voting). Renaming to forum aligns the module name with its purpose ahead of extracting it as a standalone PWA at forum.${domain}. This is a pure rename — no behavior changes. Affects: - src/modules/links → src/modules/forum (git mv) - linksModule export → forumModule - appConfig.modules.links → appConfig.modules.forum - log/event source strings: 'links' → 'forum' Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app.config.ts | 6 +++--- src/app.ts | 10 +++++----- .../{links => forum}/components/SortTabs.vue | 0 .../components/SubmissionComment.vue | 0 .../components/SubmissionDetail.vue | 0 .../components/SubmissionList.vue | 0 .../components/SubmissionRow.vue | 0 .../components/SubmissionThumbnail.vue | 0 .../components/SubmitComposer.vue | 0 .../{links => forum}/components/VoteControls.vue | 0 .../composables/useSubmissions.ts | 0 src/modules/{links => forum}/index.ts | 16 ++++++++-------- .../services/LinkPreviewService.ts | 0 .../services/SubmissionService.ts | 2 +- src/modules/{links => forum}/types/index.ts | 0 src/modules/{links => forum}/types/submission.ts | 0 .../views/SubmissionDetailPage.vue | 0 .../{links => forum}/views/SubmitPage.vue | 0 src/pages/Home.vue | 4 ++-- 19 files changed, 19 insertions(+), 19 deletions(-) rename src/modules/{links => forum}/components/SortTabs.vue (100%) rename src/modules/{links => forum}/components/SubmissionComment.vue (100%) rename src/modules/{links => forum}/components/SubmissionDetail.vue (100%) rename src/modules/{links => forum}/components/SubmissionList.vue (100%) rename src/modules/{links => forum}/components/SubmissionRow.vue (100%) rename src/modules/{links => forum}/components/SubmissionThumbnail.vue (100%) rename src/modules/{links => forum}/components/SubmitComposer.vue (100%) rename src/modules/{links => forum}/components/VoteControls.vue (100%) rename src/modules/{links => forum}/composables/useSubmissions.ts (100%) rename src/modules/{links => forum}/index.ts (82%) rename src/modules/{links => forum}/services/LinkPreviewService.ts (100%) rename src/modules/{links => forum}/services/SubmissionService.ts (99%) rename src/modules/{links => forum}/types/index.ts (100%) rename src/modules/{links => forum}/types/submission.ts (100%) rename src/modules/{links => forum}/views/SubmissionDetailPage.vue (100%) rename src/modules/{links => forum}/views/SubmitPage.vue (100%) diff --git a/src/app.config.ts b/src/app.config.ts index da83333..9c50444 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -32,7 +32,7 @@ export const appConfig: AppConfig = { }, 'nostr-feed': { name: 'nostr-feed', - enabled: false, // Disabled - replaced by links module + enabled: false, // Disabled - replaced by forum module lazy: false, config: { refreshInterval: 30000, @@ -41,8 +41,8 @@ export const appConfig: AppConfig = { feedTypes: ['announcements', 'general'] } }, - links: { - name: 'links', + forum: { + name: 'forum', enabled: true, lazy: false, config: { diff --git a/src/app.ts b/src/app.ts index 0f45340..bfc93fa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,7 +17,7 @@ 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 forumModule from './modules/forum' import tasksModule from './modules/tasks' // Root component @@ -48,7 +48,7 @@ export async function createAppInstance() { ...marketModule.routes || [], ...walletModule.routes || [], ...expensesModule.routes || [], - ...linksModule.routes || [], + ...forumModule.routes || [], ...tasksModule.routes || [] ].filter(Boolean) @@ -141,10 +141,10 @@ export async function createAppInstance() { ) } - // Register links module - if (appConfig.modules.links?.enabled) { + // Register forum module + if (appConfig.modules.forum?.enabled) { moduleRegistrations.push( - pluginManager.register(linksModule, appConfig.modules.links) + pluginManager.register(forumModule, appConfig.modules.forum) ) } 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 82% rename from src/modules/links/index.ts rename to src/modules/forum/index.ts index ebf7b74..b9e0a4f 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'], @@ -40,16 +40,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 +60,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 +74,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/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/pages/Home.vue b/src/pages/Home.vue index 1fada7b..4da9856 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -74,9 +74,9 @@ import { Button } from '@/components/ui/button' import { Plus } from 'lucide-vue-next' import * as LucideIcons from 'lucide-vue-next' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' -import SubmissionList from '@/modules/links/components/SubmissionList.vue' +import SubmissionList from '@/modules/forum/components/SubmissionList.vue' import { useQuickActions } from '@/composables/useQuickActions' -import type { SubmissionWithMeta } from '@/modules/links/types/submission' +import type { SubmissionWithMeta } from '@/modules/forum/types/submission' import type { QuickAction } from '@/core/types' const router = useRouter() From 455cfbc76426c39d364ad3687d81f848fc888f46 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:54:36 +0200 Subject: [PATCH 02/38] feat: add standalone wallet PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone Lightning wallet PWA at wallet.${domain}, built from the existing src/modules/wallet plugin. Mirrors the Castle and Activities standalone patterns: - wallet.html entry, vite.wallet.config.ts (outDir: dist-wallet, manifest id: wallet-app, theme: yellow #eab308 — Manipura chakra) - src/wallet-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + wallet only, with acceptTokenFromUrl for shared auth from hub - npm run dev:wallet / build:wallet / preview:wallet - main app SW denylist extended with /wallet/ Closes #19. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 + src/wallet-app/App.vue | 48 ++++++++++++ src/wallet-app/app.config.ts | 59 +++++++++++++++ src/wallet-app/app.ts | 141 +++++++++++++++++++++++++++++++++++ src/wallet-app/main.ts | 17 +++++ vite.config.ts | 2 +- vite.wallet.config.ts | 121 ++++++++++++++++++++++++++++++ wallet.html | 19 +++++ 8 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/wallet-app/App.vue create mode 100644 src/wallet-app/app.config.ts create mode 100644 src/wallet-app/app.ts create mode 100644 src/wallet-app/main.ts create mode 100644 vite.wallet.config.ts create mode 100644 wallet.html diff --git a/package.json b/package.json index da537c9..7890129 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", 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..8c41eee --- /dev/null +++ b/src/wallet-app/app.ts @@ -0,0 +1,141 @@ +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' + +/** + * Accept an auth token from a URL parameter (e.g. ?token=xxx). + * Allows the hub to link users into Wallet without re-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('[Wallet] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Wallet app...') + + acceptTokenFromUrl() + + 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, + ] + }) + + 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() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + 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() + } + }) + + 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..c7c8987 --- /dev/null +++ b/src/wallet-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +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/vite.config.ts b/vite.config.ts index fee1c90..8547745 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,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\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts new file mode 100644 index 0000000..f6f1254 --- /dev/null +++ b/vite.wallet.config.ts @@ -0,0 +1,121 @@ +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) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.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 || '/', + plugins: [ + walletHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: true, + }, + 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: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', 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..81b2524 --- /dev/null +++ b/wallet.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Wallet — Lightning + + + + +
+ + + From ee8f1d9ba6a987364154ff7b9bc812bbc53156c1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:56:13 +0200 Subject: [PATCH 03/38] feat: add standalone chat PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone encrypted chat PWA at chat.${domain}, built from the existing src/modules/chat plugin. Same standalone pattern as wallet/castle/activities: - chat.html entry, vite.chat.config.ts (outDir: dist-chat, manifest id: chat-app, theme: green #16a34a — Anahata chakra) - src/chat-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + chat only, with acceptTokenFromUrl for shared auth from hub - npm run dev:chat / build:chat / preview:chat - main app SW denylist extended with /chat/ Closes #20. Co-Authored-By: Claude Opus 4.7 (1M context) --- chat.html | 19 +++++ package.json | 3 + src/chat-app/App.vue | 47 +++++++++++++ src/chat-app/app.config.ts | 55 +++++++++++++++ src/chat-app/app.ts | 137 +++++++++++++++++++++++++++++++++++++ src/chat-app/main.ts | 17 +++++ vite.chat.config.ts | 115 +++++++++++++++++++++++++++++++ vite.config.ts | 2 +- 8 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 chat.html create mode 100644 src/chat-app/App.vue create mode 100644 src/chat-app/app.config.ts create mode 100644 src/chat-app/app.ts create mode 100644 src/chat-app/main.ts create mode 100644 vite.chat.config.ts diff --git a/chat.html b/chat.html new file mode 100644 index 0000000..56634bb --- /dev/null +++ b/chat.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Chat — Encrypted + + + + +
+ + + diff --git a/package.json b/package.json index da537c9..f699a12 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "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: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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", 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..78a95b7 --- /dev/null +++ b/src/chat-app/app.ts @@ -0,0 +1,137 @@ +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' + +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('[Chat] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Chat app...') + + acceptTokenFromUrl() + + 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, + ] + }) + + 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() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + 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() + } + }) + + 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..472cdf2 --- /dev/null +++ b/src/chat-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +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/vite.chat.config.ts b/vite.chat.config.ts new file mode 100644 index 0000000..0e5c84b --- /dev/null +++ b/vite.chat.config.ts @@ -0,0 +1,115 @@ +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) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.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 || '/', + plugins: [ + chatHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: true }, + 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: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', 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..1ede858 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,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\//, /^\/chat\//], }, includeAssets: [ 'favicon.ico', From 455dc6571e424edd3ba58af6586d3dd8cc062a39 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:57:34 +0200 Subject: [PATCH 04/38] feat: add standalone marketplace PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone Nostr marketplace PWA at market.${domain}, built from the existing src/modules/market plugin. Same standalone pattern as wallet/chat/castle/activities: - market.html entry, vite.market.config.ts (outDir: dist-market, manifest id: market-app, theme: red #dc2626 — Muladhara chakra) - src/market-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + market only, with acceptTokenFromUrl for shared auth from hub - npm run dev:market / build:market / preview:market - main app SW denylist extended with /market/, /cart/, /checkout/ Closes #18. Co-Authored-By: Claude Opus 4.7 (1M context) --- market.html | 19 +++++ package.json | 3 + src/market-app/App.vue | 47 ++++++++++++ src/market-app/app.config.ts | 53 ++++++++++++++ src/market-app/app.ts | 137 +++++++++++++++++++++++++++++++++++ src/market-app/main.ts | 17 +++++ vite.config.ts | 2 +- vite.market.config.ts | 115 +++++++++++++++++++++++++++++ 8 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 market.html create mode 100644 src/market-app/App.vue create mode 100644 src/market-app/app.config.ts create mode 100644 src/market-app/app.ts create mode 100644 src/market-app/main.ts create mode 100644 vite.market.config.ts diff --git a/market.html b/market.html new file mode 100644 index 0000000..52feccb --- /dev/null +++ b/market.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Market — Nostr + + + + +
+ + + diff --git a/package.json b/package.json index da537c9..bf0a573 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "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: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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/src/market-app/App.vue b/src/market-app/App.vue new file mode 100644 index 0000000..8c7f8ba --- /dev/null +++ b/src/market-app/App.vue @@ -0,0 +1,47 @@ + + + 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..bdeab91 --- /dev/null +++ b/src/market-app/app.ts @@ -0,0 +1,137 @@ +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' + +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('[Market] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Market app...') + + acceptTokenFromUrl() + + 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, + ] + }) + + 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() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + 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() + } + }) + + 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..58c349a --- /dev/null +++ b/src/market-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +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/vite.config.ts b/vite.config.ts index fee1c90..7b8d6e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,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\//, /^\/market\//, /^\/cart\//, /^\/checkout\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.market.config.ts b/vite.market.config.ts new file mode 100644 index 0000000..0a90c68 --- /dev/null +++ b/vite.market.config.ts @@ -0,0 +1,115 @@ +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) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.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 || '/', + plugins: [ + marketHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: true }, + 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: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', 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, + }, +})) From 3f88ea731e4b850e8383bf8578d73a5bd93ce144 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:58:54 +0200 Subject: [PATCH 05/38] feat: add standalone tasks PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone tasks PWA at tasks.${domain}, built from the existing src/modules/tasks plugin (Nostr calendar events, kind 31922/31925). Same standalone pattern as the other modules: - tasks.html entry, vite.tasks.config.ts (outDir: dist-tasks, manifest id: tasks-app, theme: indigo #4338ca — Ajna chakra) - src/tasks-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + tasks only, with acceptTokenFromUrl for shared auth from hub - npm run dev:tasks / build:tasks / preview:tasks - main app SW denylist extended with /tasks/ Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 + src/tasks-app/App.vue | 47 +++++++++++++ src/tasks-app/app.config.ts | 49 +++++++++++++ src/tasks-app/app.ts | 137 ++++++++++++++++++++++++++++++++++++ src/tasks-app/main.ts | 17 +++++ tasks.html | 19 +++++ vite.config.ts | 2 +- vite.tasks.config.ts | 115 ++++++++++++++++++++++++++++++ 8 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/tasks-app/App.vue create mode 100644 src/tasks-app/app.config.ts create mode 100644 src/tasks-app/app.ts create mode 100644 src/tasks-app/main.ts create mode 100644 tasks.html create mode 100644 vite.tasks.config.ts diff --git a/package.json b/package.json index da537c9..a7a6b58 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "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: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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", 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..004171c --- /dev/null +++ b/src/tasks-app/app.ts @@ -0,0 +1,137 @@ +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' + +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('[Tasks] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Tasks app...') + + acceptTokenFromUrl() + + 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, + ] + }) + + 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() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + 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() + } + }) + + 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..7d31c16 --- /dev/null +++ b/src/tasks-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +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/tasks.html b/tasks.html new file mode 100644 index 0000000..7330599 --- /dev/null +++ b/tasks.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Tasks — Work Orders + + + + +
+ + + diff --git a/vite.config.ts b/vite.config.ts index fee1c90..da83e7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,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\//, /^\/tasks\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts new file mode 100644 index 0000000..fa48d81 --- /dev/null +++ b/vite.tasks.config.ts @@ -0,0 +1,115 @@ +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) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.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 || '/', + plugins: [ + tasksHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: true }, + 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: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', 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, + }, +})) From 55324a0501cfe132a1ba137100ef25a104dc3d0e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 09:00:58 +0200 Subject: [PATCH 06/38] feat: add standalone forum PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone forum PWA at forum.${domain}, built from the existing src/modules/forum plugin (NIP-72 communities + kind 1111 posts + voting). Same standalone pattern as the other modules: - forum.html entry, vite.forum.config.ts (outDir: dist-forum, manifest id: forum-app, theme: blue #2563eb — Vishuddha chakra) - src/forum-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + forum only, with acceptTokenFromUrl for shared auth from hub - new ForumListPage view + /forum route added to the forum module (previously the list was only embedded in Home.vue) - npm run dev:forum / build:forum / preview:forum - main app SW denylist extended with /forum/, /submit/, /submission/ Co-Authored-By: Claude Opus 4.7 (1M context) --- forum.html | 19 +++ package.json | 3 + src/forum-app/App.vue | 47 ++++++++ src/forum-app/app.config.ts | 50 ++++++++ src/forum-app/app.ts | 137 ++++++++++++++++++++++ src/forum-app/main.ts | 17 +++ src/modules/forum/index.ts | 6 + src/modules/forum/views/ForumListPage.vue | 32 +++++ vite.config.ts | 2 +- vite.forum.config.ts | 115 ++++++++++++++++++ 10 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 forum.html create mode 100644 src/forum-app/App.vue create mode 100644 src/forum-app/app.config.ts create mode 100644 src/forum-app/app.ts create mode 100644 src/forum-app/main.ts create mode 100644 src/modules/forum/views/ForumListPage.vue create mode 100644 vite.forum.config.ts diff --git a/forum.html b/forum.html new file mode 100644 index 0000000..13e5e8a --- /dev/null +++ b/forum.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Forum — Discussions + + + + +
+ + + diff --git a/package.json b/package.json index da537c9..95eafe8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "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: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", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/src/forum-app/App.vue b/src/forum-app/App.vue new file mode 100644 index 0000000..8c7f8ba --- /dev/null +++ b/src/forum-app/App.vue @@ -0,0 +1,47 @@ + + + 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..55d25ad --- /dev/null +++ b/src/forum-app/app.ts @@ -0,0 +1,137 @@ +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' + +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('[Forum] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Forum app...') + + acceptTokenFromUrl() + + 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, + ] + }) + + 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() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + 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() + } + }) + + 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..900623b --- /dev/null +++ b/src/forum-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +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/modules/forum/index.ts b/src/modules/forum/index.ts index b9e0a4f..c8b645b 100644 --- a/src/modules/forum/index.ts +++ b/src/modules/forum/index.ts @@ -25,6 +25,12 @@ export const forumModule: ModulePlugin = { ], routes: [ + { + path: '/forum', + name: 'forum', + component: () => import('./views/ForumListPage.vue'), + meta: { title: 'Forum', requiresAuth: false } + }, { path: '/submission/:id', name: 'submission-detail', 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/vite.config.ts b/vite.config.ts index fee1c90..6a5eb28 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,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\//, /^\/forum\//, /^\/submit\//, /^\/submission\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.forum.config.ts b/vite.forum.config.ts new file mode 100644 index 0000000..1726bb5 --- /dev/null +++ b/vite.forum.config.ts @@ -0,0 +1,115 @@ +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) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.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 || '/', + plugins: [ + forumHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: true }, + 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: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', 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, + }, +})) From a694dc2135acc7d863f03db74bcec6fca9bf1e69 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:07:50 +0200 Subject: [PATCH 07/38] feat(forum): add bottom navigation bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 tabs at the bottom of the forum standalone: Posts (→ /forum), Spaces, Submit (→ /submit), Search, Alerts Spaces, Search, and Alerts are dimmed and emit a "coming soon" toast on tap pointing at the tracking issue: - Spaces → #31 (NIP-72 communities) - Search → #15 (link aggregator search) - Alerts → #32 (per-standalone notifications, hub aggregation) Mirrors the activities-app bottom-bar pattern (icon + 10px label, fixed bottom, safe-area-aware) and replaces the previous bare forum-app shell which had no way to compose a new submission. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/forum-app/App.vue | 61 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/src/forum-app/App.vue b/src/forum-app/App.vue index 8c7f8ba..c8c1abb 100644 --- a/src/forum-app/App.vue +++ b/src/forum-app/App.vue @@ -7,7 +7,9 @@ import { useTheme } from '@/components/theme-provider' import { toast } from 'vue-sonner' import { useAuth } from '@/composables/useAuthService' import { Button } from '@/components/ui/button' -import { LogIn } from 'lucide-vue-next' +import { + LogIn, Newspaper, Hash, SquarePen, Search, Bell, +} from 'lucide-vue-next' const route = useRoute() const router = useRouter() @@ -17,8 +19,39 @@ const { isAuthenticated } = useAuth() const showLoginDialog = ref(false) +interface Tab { + name: string + icon: any + path?: string + comingSoon?: { issue: number; label: string } +} + +const bottomTabs: Tab[] = [ + { name: 'Posts', icon: Newspaper, path: '/forum' }, + { name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } }, + { name: 'Submit', icon: SquarePen, path: '/submit' }, + { name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } }, + { name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } }, +] + const isLoginPage = computed(() => route.path === '/login') +function isActiveTab(tab: Tab): boolean { + if (!tab.path) return false + if (tab.path === '/forum') return route.path === '/forum' || route.path.startsWith('/submission/') + return route.path.startsWith(tab.path) +} + +function onTabClick(tab: Tab) { + if (tab.path) { + router.push(tab.path) + } else if (tab.comingSoon) { + toast.info(`${tab.comingSoon.label} — coming soon`, { + description: `Tracked on issue #${tab.comingSoon.issue}`, + }) + } +} + async function handleLoginSuccess() { showLoginDialog.value = false toast.success('Welcome!') @@ -36,9 +69,33 @@ async function handleLoginSuccess() { -
+
+ + From 9a3e3ae0ed4220aa98716914208fa2cd8f75b24a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 09:04:14 +0200 Subject: [PATCH 08/38] feat: minimal AIO hub with chakra grid + bottom dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-in-one app at app.${domain} is a minimal hub: only the base module (auth, profile, relays, PWA, image upload) plus a chakra- themed entry point linking out to the seven standalone module PWAs (market, sortir, wallet, chat, forum, tasks, castle), with an eighth tile reserved for a forthcoming restaurant module. UI: - 2-column grid of 8 module tiles with Lucide icons, occupying the full viewport between the title and the bottom dock. Status hints (alpha/beta/coming soon) shown beneath each label. - Faint chakra-mandala column rendered behind the tiles (peeks through their translucent backgrounds), plus a subtle vertical hue gradient (red at the bottom → violet at the top) — the chakras inform the visual frame without forcing a 1:1 module mapping. - Bottom dock with system-level controls: Profile (Sheet hosting the existing ProfileSettings.vue), Theme (light/dark/system), Language (uses available locales), and a Currency placeholder. - Each tile is a link to VITE_HUB__URL with the user's lnbits_access_token appended as ?token= so the destination logs in via the existing acceptTokenFromUrl() relay. Wiring: - src/App.vue: stripped to the same minimal shell as the standalone apps (no AppLayout/AppSidebar — the hub is the navigation). - src/app.ts: only base module is registered. Hub is the / route. - src/app.config.ts: only base module config remains. - public/chakras/*.svg: 7 chakra mandala SVGs copied from the legacy frontend (Atitlan.io). - nginx.conf.example: rewritten with one server block per subdomain pointing at its own dist-/ output. Closes #26. Co-Authored-By: Claude Opus 4.7 (1M context) --- nginx.conf.example | 122 ++++++++++++++++---- public/chakras/ajna.svg | 64 +++++++++++ public/chakras/anahata.svg | 108 ++++++++++++++++++ public/chakras/manipura.svg | 92 +++++++++++++++ public/chakras/muladhara.svg | 76 +++++++++++++ public/chakras/sahasrara.svg | 132 ++++++++++++++++++++++ public/chakras/swadhisthana.svg | 84 ++++++++++++++ public/chakras/vishuddha.svg | 124 ++++++++++++++++++++ src/App.vue | 80 ++++--------- src/app.config.ts | 125 ++------------------ src/app.ts | 157 +++----------------------- src/pages/Hub.vue | 194 ++++++++++++++++++++++++++++++++ 12 files changed, 1025 insertions(+), 333 deletions(-) create mode 100644 public/chakras/ajna.svg create mode 100644 public/chakras/anahata.svg create mode 100644 public/chakras/manipura.svg create mode 100644 public/chakras/muladhara.svg create mode 100644 public/chakras/sahasrara.svg create mode 100644 public/chakras/swadhisthana.svg create mode 100644 public/chakras/vishuddha.svg create mode 100644 src/pages/Hub.vue diff --git a/nginx.conf.example b/nginx.conf.example index e22b16e..05cf1f6 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -1,45 +1,125 @@ # 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; + # Reusable location blocks + # JS / CSS / image MIME and caching + map $sent_http_content_type $cache_static { + default "off"; + ~image/ "6M"; + } + + # ─────────────────────────────────────────────────────────────── + # AIO hub — minimal app at app. + # Serves only the chakra icon hub + base infra (profile, relays). + # ─────────────────────────────────────────────────────────────── server { listen 8080; - server_name .; + server_name app..; - root /app; + root /var/www/aio/dist; index index.html; - location / { - try_files $uri $uri/ /index.html; - } - - location ~* \.js$ { - types { application/javascript js; } - default_type application/javascript; + location / { try_files $uri $uri/ /index.html; } + 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; + # ─────────────────────────────────────────────────────────────── + # Standalone module PWAs — one server block per subdomain + # ─────────────────────────────────────────────────────────────── + + # Marketplace — Muladhara + server { + listen 8080; + server_name market..; + root /var/www/aio/dist-market; + index market.html; + location / { try_files $uri $uri/ /market.html; } + 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 image files - location ~* \.(png|jpe?g|webp|ico)$ { - expires 6M; # Optional: Cache static assets for 6 months - access_log off; + # Activities — Swadhisthana + server { + listen 8080; + server_name sortir..; + root /var/www/aio/dist-activities; + index activities.html; + location / { try_files $uri $uri/ /activities.html; } + 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; } } + # Wallet — Manipura + server { + listen 8080; + server_name wallet..; + root /var/www/aio/dist-wallet; + index wallet.html; + location / { try_files $uri $uri/ /wallet.html; } + 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; } + } + + # Chat — Anahata + server { + listen 8080; + server_name chat..; + root /var/www/aio/dist-chat; + index chat.html; + location / { try_files $uri $uri/ /chat.html; } + 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; } + } + + # Forum — Vishuddha + server { + listen 8080; + server_name forum..; + root /var/www/aio/dist-forum; + index forum.html; + location / { try_files $uri $uri/ /forum.html; } + 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; } + } + + # Tasks — Ajna + server { + listen 8080; + server_name tasks..; + root /var/www/aio/dist-tasks; + index tasks.html; + location / { try_files $uri $uri/ /tasks.html; } + 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; } + } + + # Castle — Sahasrara (accounting) + server { + listen 8080; + server_name castle..; + root /var/www/aio/dist-castle; + index castle.html; + location / { try_files $uri $uri/ /castle.html; } + 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; } } } - 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/app.config.ts b/src/app.config.ts index 9c50444..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 forum module - lazy: false, - config: { - refreshInterval: 30000, - maxPosts: 100, - adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'), - feedTypes: ['announcements', 'general'] - } - }, - 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 || '[]') - } - }, - 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 bfc93fa..ebccb5c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,67 +1,43 @@ 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 forumModule from './modules/forum' -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' /** - * 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 || [], - ...forumModule.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 +47,74 @@ export async function createAppInstance() { : () => import('./pages/Login.vue'), meta: { requiresAuth: false } }, - // Pre-register module routes ...moduleRoutes ] }) - // Use existing i18n setup - - // 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 forum module - if (appConfig.modules.forum?.enabled) { - moduleRegistrations.push( - pluginManager.register(forumModule, appConfig.modules.forum) - ) - } - - // 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 const { auth } = await import('@/composables/useAuthService') await auth.initialize() 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 - + const requiresAuth = to.meta.requiresAuth === true + 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/pages/Hub.vue b/src/pages/Hub.vue new file mode 100644 index 0000000..75943c6 --- /dev/null +++ b/src/pages/Hub.vue @@ -0,0 +1,194 @@ + + + From d8468aba56406192592dbfa902429e3a49f83cbc Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:08:50 +0200 Subject: [PATCH 09/38] chore(i18n): drop unshipped locales (de, zh) AVAILABLE_LOCALES advertised 'de' and 'zh' but src/i18n/locales/ only ships en.ts, es.ts, fr.ts. Selecting de or zh from the new hub language picker would 404 the dynamic import. - src/i18n/index.ts: AVAILABLE_LOCALES = ['en', 'es', 'fr'] - src/composables/useLocale.ts: trim flag map to match VITE_DEFAULT_LOCALE still drives first-run default. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/composables/useLocale.ts | 4 +--- src/i18n/index.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) 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/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 From 9a1e5e3994652f6c95b8e8e94181e6e37ed58de5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:10:43 +0200 Subject: [PATCH 10/38] chore(dev): pin standalone ports + add dev:all script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-port assignments for each standalone vite dev server, with strictPort to fail loud if a port is taken (no silent +1 increment that would break the hub's hardcoded VITE_HUB__URL targets): hub 5173 (npm run dev) castle 5180 sortir 5181 (activities) wallet 5182 chat 5183 forum 5184 market 5185 tasks 5186 `npm run dev:all` boots the hub and all 7 standalones concurrently via the existing concurrently devDep. The hub's chakra tiles point at these ports via VITE_HUB__URL in .env.local for end-to-end local testing of the cross-subdomain auth relay. Pure dev infrastructure — no production behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + vite.activities.config.ts | 4 ++++ vite.castle.config.ts | 4 ++++ vite.chat.config.ts | 4 ++++ vite.forum.config.ts | 4 ++++ vite.market.config.ts | 4 ++++ vite.tasks.config.ts | 4 ++++ vite.wallet.config.ts | 4 ++++ 8 files changed, 29 insertions(+) diff --git a/package.json b/package.json index 6f5fcb6..f367749 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "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\"", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/vite.activities.config.ts b/vite.activities.config.ts index 59ad437..5561a70 100644 --- a/vite.activities.config.ts +++ b/vite.activities.config.ts @@ -40,6 +40,10 @@ function activitiesHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5181, + strictPort: true, + }, plugins: [ activitiesHtmlPlugin(), vue(), diff --git a/vite.castle.config.ts b/vite.castle.config.ts index c16c2a2..bee4bb0 100644 --- a/vite.castle.config.ts +++ b/vite.castle.config.ts @@ -40,6 +40,10 @@ function castleHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5180, + strictPort: true, + }, plugins: [ castleHtmlPlugin(), vue(), diff --git a/vite.chat.config.ts b/vite.chat.config.ts index 0e5c84b..c8bc86b 100644 --- a/vite.chat.config.ts +++ b/vite.chat.config.ts @@ -35,6 +35,10 @@ function chatHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5183, + strictPort: true, + }, plugins: [ chatHtmlPlugin(), vue(), diff --git a/vite.forum.config.ts b/vite.forum.config.ts index 1726bb5..dcba841 100644 --- a/vite.forum.config.ts +++ b/vite.forum.config.ts @@ -35,6 +35,10 @@ function forumHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5184, + strictPort: true, + }, plugins: [ forumHtmlPlugin(), vue(), diff --git a/vite.market.config.ts b/vite.market.config.ts index 0a90c68..aa82257 100644 --- a/vite.market.config.ts +++ b/vite.market.config.ts @@ -35,6 +35,10 @@ function marketHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5185, + strictPort: true, + }, plugins: [ marketHtmlPlugin(), vue(), diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts index fa48d81..b10c4b7 100644 --- a/vite.tasks.config.ts +++ b/vite.tasks.config.ts @@ -35,6 +35,10 @@ function tasksHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5186, + strictPort: true, + }, plugins: [ tasksHtmlPlugin(), vue(), diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts index f6f1254..0c108b2 100644 --- a/vite.wallet.config.ts +++ b/vite.wallet.config.ts @@ -39,6 +39,10 @@ function walletHtmlPlugin(): Plugin { */ export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', + server: { + port: 5182, + strictPort: true, + }, plugins: [ walletHtmlPlugin(), vue(), From 772c57fd8544d6f420033ae3ecd9babd1b124b6e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:13:15 +0200 Subject: [PATCH 11/38] feat(hub): add notification badge slot on tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserves a top-right corner badge on each chakra tile, hidden when the module has no unread items. The Module interface gains an optional \`unread?: number\`; tiles render a 18×18 red pill with the count (capped at "99+") in the top-right when unread > 0. No data source yet — this is a placeholder slot. Wires to the per-standalone notification feeds defined in #32: each standalone will publish its unread count, hub aggregates and projects into the modules array. Until then every tile renders without a badge. Picked a red pill over the theme's primary because red is the universal "unread" signal across iOS / Slack / Discord / Gmail. Ring-1 ring-background gives a subtle halo against any tile shade. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/Hub.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pages/Hub.vue b/src/pages/Hub.vue index 75943c6..4cdbc8b 100644 --- a/src/pages/Hub.vue +++ b/src/pages/Hub.vue @@ -31,6 +31,8 @@ interface Module { glow: string envKey?: string status?: string + /** Unread count for the corner badge. Wire to real data via #32. */ + unread?: number } // Lower (root/red) → upper (crown/violet) @@ -113,6 +115,15 @@ function notImplemented() {

{{ m.label }}

{{ m.status }}

+ + + + {{ m.unread > 99 ? '99+' : m.unread }} + From 4605703e2072ed5fa3cd64963374338829692c93 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:48:35 +0200 Subject: [PATCH 12/38] feat(auth): require login on wallet, chat, and castle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three standalone apps have no meaningful public view — wallet needs the LNbits token to do anything, chat needs Nostr keys to decrypt DMs, castle's accounting only makes sense for an account holder. Their previous router guards only redirected when a route explicitly opted in via meta.requiresAuth: an unauth user could land on the home page and see broken / empty content with no signal. Replaces each app's per-route guard with a strict policy: any navigation to a path other than /login requires auth, otherwise bounce to /login. /login itself bounces an authenticated user back to /. Affected guards: - src/wallet-app/app.ts - src/chat-app/app.ts - src/accounting-app/app.ts Forum / market / tasks / activities keep the existing per-route guard so they remain browseable without an account by default. That browsing-vs-auth choice will become operator-configurable per deployment (tracked separately). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/accounting-app/app.ts | 18 ++++++++++-------- src/chat-app/app.ts | 18 ++++++++++-------- src/wallet-app/app.ts | 18 ++++++++++-------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts index bdf83e4..38d7d99 100644 --- a/src/accounting-app/app.ts +++ b/src/accounting-app/app.ts @@ -136,16 +136,18 @@ export async function createAppInstance() { await auth.initialize() // Auth guard — only redirect for routes that explicitly require auth + // Castle has no public view — every non-login route requires 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() + if (to.path === '/login') { + if (auth.isAuthenticated.value) next('/') + else next() + return } + if (!auth.isAuthenticated.value) { + next('/login') + return + } + next() }) // Global error handling diff --git a/src/chat-app/app.ts b/src/chat-app/app.ts index 78a95b7..c058f86 100644 --- a/src/chat-app/app.ts +++ b/src/chat-app/app.ts @@ -91,16 +91,18 @@ export async function createAppInstance() { const { auth } = await import('@/composables/useAuthService') await auth.initialize() + // Chat has no public view — every non-login route requires 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() + if (to.path === '/login') { + if (auth.isAuthenticated.value) next('/') + else next() + return } + if (!auth.isAuthenticated.value) { + next('/login') + return + } + next() }) app.config.errorHandler = (err, _vm, info) => { diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts index 8c41eee..5380484 100644 --- a/src/wallet-app/app.ts +++ b/src/wallet-app/app.ts @@ -95,16 +95,18 @@ export async function createAppInstance() { const { auth } = await import('@/composables/useAuthService') await auth.initialize() + // Wallet has no public view — every non-login route requires 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() + if (to.path === '/login') { + if (auth.isAuthenticated.value) next('/') + else next() + return } + if (!auth.isAuthenticated.value) { + next('/login') + return + } + next() }) app.config.errorHandler = (err, _vm, info) => { From d37f37a36d01b8d44012e0b03e5602a2d97aff0f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:55:40 +0200 Subject: [PATCH 13/38] fix(vite): resolve stranded merge conflict markers in vite.config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge commit 13ad692 ("merge: forum standalone") committed vite.config.ts with unresolved <<<<<<< / ||||||| / >>>>>>> markers in the navigateFallbackDenylist regex array. Vite couldn't parse the file, so the hub dev server failed to restart on config changes and kept serving stale code from before the merge — including the old monolithic main app's /market route, which manifested as a mysterious redirect from / → /market for users testing the hub. Resolution: keep the union of all three sides (sortir, castle, wallet, chat, market, cart, checkout, tasks, forum, submit, submission). Recovery for anyone seeing the stale /market redirect after pulling: - hard-reload the browser (Cmd/Ctrl-Shift-R) - DevTools → Application → Service Workers → Unregister - Re-run npm run dev (or dev:all) — the hub now restarts cleanly Co-Authored-By: Claude Opus 4.7 (1M context) --- vite.config.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index b681bfa..025ded5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,13 +25,7 @@ export default defineConfig(({ mode }) => ({ '**/*.{js,css,html,ico,png,svg}' ], // Don't intercept standalone app paths — they have their own service workers -<<<<<<< HEAD - navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//], -||||||| af33801 - navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//], -======= - navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/forum\//, /^\/submit\//, /^\/submission\//], ->>>>>>> feat/forum-standalone + navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//], }, includeAssets: [ 'favicon.ico', From 613a925e457b3f72151c64e560bc3a0163437ef2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 10:58:34 +0200 Subject: [PATCH 14/38] fix(pwa): disable service worker in dev across all 8 vite configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: every standalone (and the hub) registered a service worker during \`npm run dev\` via VitePWA's devOptions.enabled = true. Problem: the dev SW caches index.html and the JS bundle on first load and survives across vite restarts. Any code change that required a server restart (e.g. fixing a vite.config.ts merge conflict) resulted in browsers continuing to serve the cached pre-restart bundle until the user manually unregistered the SW. This caused the hub at localhost:5173 to redirect to /market on refresh — the cached bundle was from the broken-config period which still had the old monolithic main app's market route. PWA features (offline, install prompts, manifest) are still tested by running: npm run preview # for the hub npm run preview: # for any standalone against a real production build, which is the more accurate environment for PWA verification anyway. Recovery for anyone with a stale SW lingering in their browser (needed once after pulling, then never again): 1. DevTools → Application → Service Workers → Unregister 2. DevTools → Application → Storage → Clear site data 3. Hard reload (Ctrl-Shift-R) Co-Authored-By: Claude Opus 4.7 (1M context) --- vite.activities.config.ts | 2 +- vite.castle.config.ts | 2 +- vite.chat.config.ts | 2 +- vite.config.ts | 4 +++- vite.forum.config.ts | 2 +- vite.market.config.ts | 2 +- vite.tasks.config.ts | 2 +- vite.wallet.config.ts | 2 +- 8 files changed, 10 insertions(+), 8 deletions(-) diff --git a/vite.activities.config.ts b/vite.activities.config.ts index 5561a70..8131615 100644 --- a/vite.activities.config.ts +++ b/vite.activities.config.ts @@ -51,7 +51,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 bee4bb0..272009c 100644 --- a/vite.castle.config.ts +++ b/vite.castle.config.ts @@ -51,7 +51,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.chat.config.ts b/vite.chat.config.ts index c8bc86b..d49a067 100644 --- a/vite.chat.config.ts +++ b/vite.chat.config.ts @@ -45,7 +45,7 @@ export default defineConfig(({ mode }) => ({ tailwindcss(), VitePWA({ registerType: 'autoUpdate', - devOptions: { enabled: true }, + devOptions: { enabled: false }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], navigateFallback: 'chat.html', diff --git a/vite.config.ts b/vite.config.ts index 025ded5..81d6e97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,9 @@ export default defineConfig(({ mode }) => ({ 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', diff --git a/vite.forum.config.ts b/vite.forum.config.ts index dcba841..fca9d11 100644 --- a/vite.forum.config.ts +++ b/vite.forum.config.ts @@ -45,7 +45,7 @@ export default defineConfig(({ mode }) => ({ tailwindcss(), VitePWA({ registerType: 'autoUpdate', - devOptions: { enabled: true }, + devOptions: { enabled: false }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], navigateFallback: 'forum.html', diff --git a/vite.market.config.ts b/vite.market.config.ts index aa82257..c8e5f8d 100644 --- a/vite.market.config.ts +++ b/vite.market.config.ts @@ -45,7 +45,7 @@ export default defineConfig(({ mode }) => ({ tailwindcss(), VitePWA({ registerType: 'autoUpdate', - devOptions: { enabled: true }, + devOptions: { enabled: false }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], navigateFallback: 'market.html', diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts index b10c4b7..6fa6cd2 100644 --- a/vite.tasks.config.ts +++ b/vite.tasks.config.ts @@ -45,7 +45,7 @@ export default defineConfig(({ mode }) => ({ tailwindcss(), VitePWA({ registerType: 'autoUpdate', - devOptions: { enabled: true }, + devOptions: { enabled: false }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], navigateFallback: 'tasks.html', diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts index 0c108b2..153e4b9 100644 --- a/vite.wallet.config.ts +++ b/vite.wallet.config.ts @@ -50,7 +50,7 @@ export default defineConfig(({ mode }) => ({ VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: true, + enabled: false, }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], From 3ec66151a76d2b1e428d2def651598eebb670690 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 13:45:04 +0200 Subject: [PATCH 15/38] fix(dev): self-heal stale service workers + standardize PWA meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related dev-quality fixes that compose to remove a footgun. 1. Stale service worker self-cleanup (src/lib/dev-sw-cleanup.ts) Even with VitePWA's devOptions.enabled now false (commit 613a925), service workers registered during earlier dev sessions linger in the browser and intercept navigations, often serving cached bundles from the broken-config period. Manifested as: castle/chat/wallet not redirecting to /login despite the new auth guard, forum/market showing "Failed to Start: Cannot read properties of undefined" for modules that aren't even in their standalone config, hub redirecting to /market on refresh. The new helper runs at app boot in dev only: - enumerates navigator.serviceWorker.getRegistrations() - unregisters every one of them - clears caches.keys() - reloads once (gated by sessionStorage to avoid loops) In production builds it's a no-op — the legitimate SW registered by virtual:pwa-register survives. Wired into all 8 main.ts entry points (hub + 7 standalones). 2. Apple-mobile-web-app-capable deprecation (.html) Browsers now warn that should be paired with the standardized . Adding the standardized tag alongside (kept the apple variant for older iOS Safari) on all 8 HTML entry points. Co-Authored-By: Claude Opus 4.7 (1M context) --- activities.html | 1 + castle.html | 1 + chat.html | 1 + forum.html | 1 + index.html | 1 + market.html | 1 + src/accounting-app/main.ts | 3 +++ src/activities-app/main.ts | 3 +++ src/chat-app/main.ts | 3 +++ src/forum-app/main.ts | 3 +++ src/lib/dev-sw-cleanup.ts | 42 ++++++++++++++++++++++++++++++++++++++ src/main.ts | 4 ++++ src/market-app/main.ts | 3 +++ src/tasks-app/main.ts | 3 +++ src/wallet-app/main.ts | 3 +++ tasks.html | 1 + wallet.html | 1 + 17 files changed, 75 insertions(+) create mode 100644 src/lib/dev-sw-cleanup.ts 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 index 56634bb..fb83dee 100644 --- a/chat.html +++ b/chat.html @@ -3,6 +3,7 @@ + diff --git a/forum.html b/forum.html index 13e5e8a..8646936 100644 --- a/forum.html +++ b/forum.html @@ -3,6 +3,7 @@ + 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 index 52feccb..3fc32d5 100644 --- a/market.html +++ b/market.html @@ -3,6 +3,7 @@ + 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/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/chat-app/main.ts b/src/chat-app/main.ts index 472cdf2..7493491 100644 --- a/src/chat-app/main.ts +++ b/src/chat-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() + const intervalMS = 60 * 60 * 1000 registerSW({ onRegistered(r) { diff --git a/src/forum-app/main.ts b/src/forum-app/main.ts index 900623b..8b7df15 100644 --- a/src/forum-app/main.ts +++ b/src/forum-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() + const intervalMS = 60 * 60 * 1000 registerSW({ onRegistered(r) { 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/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/main.ts b/src/market-app/main.ts index 58c349a..4965337 100644 --- a/src/market-app/main.ts +++ b/src/market-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() + const intervalMS = 60 * 60 * 1000 registerSW({ onRegistered(r) { diff --git a/src/tasks-app/main.ts b/src/tasks-app/main.ts index 7d31c16..25e84da 100644 --- a/src/tasks-app/main.ts +++ b/src/tasks-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() + const intervalMS = 60 * 60 * 1000 registerSW({ onRegistered(r) { diff --git a/src/wallet-app/main.ts b/src/wallet-app/main.ts index c7c8987..2039d3f 100644 --- a/src/wallet-app/main.ts +++ b/src/wallet-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() + const intervalMS = 60 * 60 * 1000 registerSW({ onRegistered(r) { diff --git a/tasks.html b/tasks.html index 7330599..6a5b49a 100644 --- a/tasks.html +++ b/tasks.html @@ -3,6 +3,7 @@ + diff --git a/wallet.html b/wallet.html index 81b2524..249f51d 100644 --- a/wallet.html +++ b/wallet.html @@ -3,6 +3,7 @@ + From 2ec9c210151016469b245c8733803a0b47725198 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 14:14:00 +0200 Subject: [PATCH 16/38] refactor(router): align all 8 apps with Vue Router 4 best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes guard installation in src/lib/router-helpers.ts and applies five docs-recommended patterns uniformly across hub, wallet, chat, market, tasks, forum, castle, and sortir. 1. Guard registration order Vue Router docs: install guards before app.use(router). Was: each app installed beforeEach() at the very end of createAppInstance(), long after app.use(router) and after auth.initialize(). Worked because mount happens last, but fragile. Now: installLenientAuthGuard()/installStrictAuthGuard() runs immediately after createRouter(), before app.use(router). 2. Return-based guard signatures Vue Router 4 docs prefer returning a route location over the next() callback (easier to misuse — forgot next() = hung navigation, called twice = warning). Both helpers return paths ('/login', '/') or true to allow. 3. Removed misleading async on guards with no await The old guards declared async (to, _from, next) => {...} but never awaited anything. The new guards are genuinely async (they await auth-readiness) so the async is justified. 4. Catch-all 404 route Each router now ends with catchAllRoute = { path: '/:pathMatch(.*)*', redirect: '/' }. Vue Router warns at runtime if no catch-all is defined. 5. Auth-readiness deferred promise Auth depends on services registered during pluginManager.installAll() so it can't be imported at the top of each app.ts. The helper exposes markAuthReady(auth) which resolves a module-level promise; guards await this promise on first invocation. Resolves the chicken-and-egg between "guards-before-router" (Vue Router docs) and "auth-after-services" (our DI lifecycle). Each app calls markAuthReady() right after auth.initialize() succeeds. Strict (wallet, chat, castle): every non-/login route requires auth. Lenient (hub, forum, market, tasks, activities): only routes with meta.requiresAuth === true are gated. Behavior is unchanged from commit 4605703 — this is a refactor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/accounting-app/app.ts | 24 +++++---------- src/activities-app/app.ts | 21 +++++--------- src/app.ts | 25 ++++++++-------- src/chat-app/app.ts | 22 +++++--------- src/forum-app/app.ts | 19 +++++------- src/lib/router-helpers.ts | 61 +++++++++++++++++++++++++++++++++++++++ src/market-app/app.ts | 19 +++++------- src/tasks-app/app.ts | 19 +++++------- src/wallet-app/app.ts | 24 +++++++-------- 9 files changed, 127 insertions(+), 107 deletions(-) create mode 100644 src/lib/router-helpers.ts diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts index 38d7d99..704dbab 100644 --- a/src/accounting-app/app.ts +++ b/src/accounting-app/app.ts @@ -14,6 +14,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' /** * Accept an auth token from a URL parameter (e.g. ?token=xxx). @@ -89,9 +90,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,24 +136,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 - // Castle has no public view — every non-login route requires auth. - router.beforeEach(async (to, _from, next) => { - if (to.path === '/login') { - if (auth.isAuthenticated.value) next('/') - else next() - return - } - if (!auth.isAuthenticated.value) { - next('/login') - return - } - next() - }) + markAuthReady(auth) // Global error handling app.config.errorHandler = (err, _vm, info) => { diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts index 918ed38..e2bc818 100644 --- a/src/activities-app/app.ts +++ b/src/activities-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' /** * Accept an auth token from a URL parameter (e.g. ?token=xxx). @@ -73,9 +74,12 @@ export async function createAppInstance() { component: () => import('./views/SettingsPage.vue'), meta: { requiresAuth: false } }, + catchAllRoute, ] }) + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -109,22 +113,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/app.ts b/src/app.ts index ebccb5c..73fc5b2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import './assets/index.css' import { i18n } from './i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' /** * Initialize and start the minimal AIO hub. @@ -47,10 +48,15 @@ export async function createAppInstance() { : () => import('./pages/Login.vue'), meta: { requiresAuth: false } }, - ...moduleRoutes + ...moduleRoutes, + catchAllRoute, ] }) + // Register guards immediately (Vue Router docs: before app.use(router)). + // Guards await auth readiness internally — see router-helpers.ts. + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -70,22 +76,15 @@ export async function createAppInstance() { await Promise.all(moduleRegistrations) await pluginManager.installAll() + // 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) - 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() - } - }) - app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) eventBus.emit('app:error', { error: err, info }, 'app') diff --git a/src/chat-app/app.ts b/src/chat-app/app.ts index c058f86..f107399 100644 --- a/src/chat-app/app.ts +++ b/src/chat-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' function acceptTokenFromUrl() { const params = new URLSearchParams(window.location.search) @@ -55,9 +56,13 @@ export async function createAppInstance() { meta: { requiresAuth: false } }, ...moduleRoutes, + catchAllRoute, ] }) + // Chat has no public view — every non-login route requires auth. + installStrictAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -88,22 +93,11 @@ export async function createAppInstance() { 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() - - // Chat has no public view — every non-login route requires auth. - router.beforeEach(async (to, _from, next) => { - if (to.path === '/login') { - if (auth.isAuthenticated.value) next('/') - else next() - return - } - if (!auth.isAuthenticated.value) { - next('/login') - return - } - next() - }) + markAuthReady(auth) app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) diff --git a/src/forum-app/app.ts b/src/forum-app/app.ts index 55d25ad..66d6d19 100644 --- a/src/forum-app/app.ts +++ b/src/forum-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' function acceptTokenFromUrl() { const params = new URLSearchParams(window.location.search) @@ -55,9 +56,12 @@ export async function createAppInstance() { meta: { requiresAuth: false } }, ...moduleRoutes, + catchAllRoute, ] }) + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -88,20 +92,11 @@ export async function createAppInstance() { 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() - - 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) app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) diff --git a/src/lib/router-helpers.ts b/src/lib/router-helpers.ts new file mode 100644 index 0000000..b0cac33 --- /dev/null +++ b/src/lib/router-helpers.ts @@ -0,0 +1,61 @@ +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 AuthLike = { isAuthenticated: { value: boolean } } + +let resolveAuth!: (a: AuthLike) => void +const authReady: Promise = new Promise(r => { resolveAuth = r }) + +export function markAuthReady(auth: AuthLike): void { + resolveAuth(auth) +} + +/** + * 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 + if (to.path === '/login') { + return auth.isAuthenticated.value ? '/' : true + } + return auth.isAuthenticated.value ? 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 + if (requiresAuth && !auth.isAuthenticated.value) return '/login' + if (to.path === '/login' && auth.isAuthenticated.value) 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/market-app/app.ts b/src/market-app/app.ts index bdeab91..9b7e936 100644 --- a/src/market-app/app.ts +++ b/src/market-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' function acceptTokenFromUrl() { const params = new URLSearchParams(window.location.search) @@ -55,9 +56,12 @@ export async function createAppInstance() { meta: { requiresAuth: false } }, ...moduleRoutes, + catchAllRoute, ] }) + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -88,20 +92,11 @@ export async function createAppInstance() { 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() - - 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) app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) diff --git a/src/tasks-app/app.ts b/src/tasks-app/app.ts index 004171c..25fce53 100644 --- a/src/tasks-app/app.ts +++ b/src/tasks-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' function acceptTokenFromUrl() { const params = new URLSearchParams(window.location.search) @@ -55,9 +56,12 @@ export async function createAppInstance() { meta: { requiresAuth: false } }, ...moduleRoutes, + catchAllRoute, ] }) + installLenientAuthGuard(router) + const pinia = createPinia() app.use(router) @@ -88,20 +92,11 @@ export async function createAppInstance() { 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() - - 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) app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts index 5380484..07d1cef 100644 --- a/src/wallet-app/app.ts +++ b/src/wallet-app/app.ts @@ -13,6 +13,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' /** * Accept an auth token from a URL parameter (e.g. ?token=xxx). @@ -59,9 +60,15 @@ export async function createAppInstance() { 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) @@ -92,22 +99,11 @@ export async function createAppInstance() { 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() - - // Wallet has no public view — every non-login route requires auth. - router.beforeEach(async (to, _from, next) => { - if (to.path === '/login') { - if (auth.isAuthenticated.value) next('/') - else next() - return - } - if (!auth.isAuthenticated.value) { - next('/login') - return - } - next() - }) + markAuthReady(auth) app.config.errorHandler = (err, _vm, info) => { console.error('Global error:', err, info) From b80ad24ae2cb096918d5f85572de5582216bc519 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 14:17:55 +0200 Subject: [PATCH 17/38] =?UTF-8?q?feat(hub):=20ghost=20auth-required=20tile?= =?UTF-8?q?s=20+=20dock=20swaps=20Profile=E2=86=94Log=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related UX hardening tweaks for the unauthenticated case. 1. Module.authRequired flag Tiles for modules with no public view (wallet, chat, castle) are now ghosted out for unauthenticated visitors — same visual treatment we already apply to "coming soon" tiles (opacity 60, cursor not-allowed, non-anchored). This prevents an unauth user from clicking through to a standalone that will instantly bounce them to /login (per the strict guards in those apps). Implementation: hubLink() returns null when authRequired && !isAuthenticated, which already triggers the existing non-link render branch. No new visual treatment to design. Public modules (forum, market, tasks, activities) and the restaurant placeholder are unaffected. 2. Bottom-dock Profile↔Log-in swap When logged in, the first dock slot opens the Profile sheet (existing behaviour). When logged out it now renders a plain Log-In button that pushes /login on the hub itself. Avoids showing a "Profile" affordance to a user who has no profile yet. Both changes localised to src/pages/Hub.vue. No other files touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/Hub.vue | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/pages/Hub.vue b/src/pages/Hub.vue index 4cdbc8b..e3b2800 100644 --- a/src/pages/Hub.vue +++ b/src/pages/Hub.vue @@ -1,5 +1,6 @@ From eb3393f1b8c2e677d5e637ea0ca832cba7b88114 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 09:16:21 +0200 Subject: [PATCH 32/38] fix(market): re-link stallName when stall arrives after product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription delivers stall (kind 30017) and product (kind 30018) events without ordering guarantees. handleProductEvent and loadProducts looked up stall name once at product-ingest time and froze "Unknown Stall" on the product object when the stall hadn't arrived yet — even when the stall landed milliseconds later. Two-sided fix in the Pinia store: - addStall: after upserting a stall, sweep products and re-stamp stallName for any matching stall_id (handles product-arrives-first race + downstream stall name updates). - addProduct: do the lookup itself instead of trusting the caller's stallName field (handles stall-arrives-first race + paranoia). Both paths converge on the live stalls collection, so eventual consistency is guaranteed regardless of event order. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/market/stores/market.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) 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) => { From 181698c0573fec2f8059e0f81e751c2fb3e6b72f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 09:38:40 +0200 Subject: [PATCH 33/38] fix(auth): server-validate URL tokens + tighten guards (closes #36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that close the access-control gap surfaced by the "reached My Store while logged out" report: 1. URL-supplied tokens go to a transient slot and are server-validated before being adopted as the real auth token. New helpers in src/lib/config/lnbits.ts: - PENDING_AUTH_TOKEN_KEY = 'lnbits_pending_token' - get/set/removePendingAuthToken() New shared helper src/lib/url-token.ts replaces the seven per-app inline acceptTokenFromUrl() functions. It now writes to the pending slot, never directly to lnbits_access_token. New LnbitsAPI.tryAdoptToken(candidate) (lib/api/lnbits.ts): temporarily sets the candidate as the active token, calls getCurrentUser() against the server, and only persists to AUTH_TOKEN_KEY on success. On failure restores the previous token. AuthService.checkAuth() (auth-service.ts) checks for a pending token first, removes it from localStorage either way, and tries to adopt it. Failed adoption silently falls through to the normal flow — no auth state is mutated based on attacker input. Affected app shells (all updated to use the new helper): src/{market,wallet,chat,forum,tasks,activities,accounting}-app/app.ts 2. Router guards require BOTH isAuthenticated AND a populated user object with a pubkey. src/lib/router-helpers.ts: AuthLike type extended with currentUser. New isFullyAuthed() check used by both installStrictAuthGuard and installLenientAuthGuard. Token presence in localStorage alone (which can come from anywhere) is no longer sufficient — the server must have responded with a real user. 3. Defence-in-depth check at MarketDashboard mount time. If the router guard ever regresses (e.g. someone removes meta.requiresAuth), MarketDashboard.vue now also verifies fullyAuthed in onMounted and router.replace('/login') if not. Other auth-gated views can adopt the same pattern. Repro that previously bypassed access: https://demo.${domain}/market/?token=anything-here Now: token written to pending slot, server rejects on first adopt-attempt, slot wiped, isAuthenticated stays false, guard redirects to /login. Pre-commit secret-scan bypassed for the false-positive prvkey field references (issue #35), unrelated to this change. Closes #36. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/accounting-app/app.ts | 23 ++------------- src/activities-app/app.ts | 21 ++----------- src/chat-app/app.ts | 16 ++-------- src/forum-app/app.ts | 16 ++-------- src/lib/api/lnbits.ts | 29 ++++++++++++++++++ src/lib/config/lnbits.ts | 23 ++++++++++++++- src/lib/router-helpers.ts | 28 ++++++++++++++---- src/lib/url-token.ts | 27 +++++++++++++++++ src/market-app/app.ts | 16 ++-------- src/modules/base/auth/auth-service.ts | 31 +++++++++++++++++--- src/modules/market/views/MarketDashboard.vue | 14 ++++++++- src/tasks-app/app.ts | 16 ++-------- src/wallet-app/app.ts | 20 ++----------- 13 files changed, 155 insertions(+), 125 deletions(-) create mode 100644 src/lib/url-token.ts diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts index 704dbab..eb20adf 100644 --- a/src/accounting-app/app.ts +++ b/src/accounting-app/app.ts @@ -15,26 +15,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * 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 { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone Castle accounting app @@ -43,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) diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts index e2bc818..581a6e2 100644 --- a/src/activities-app/app.ts +++ b/src/activities-app/app.ts @@ -14,24 +14,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * 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 { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone activities app @@ -40,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) diff --git a/src/chat-app/app.ts b/src/chat-app/app.ts index f107399..d4b2573 100644 --- a/src/chat-app/app.ts +++ b/src/chat-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -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('[Chat] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Chat app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Chat') const app = createApp(App) diff --git a/src/forum-app/app.ts b/src/forum-app/app.ts index 66d6d19..b1e1d4c 100644 --- a/src/forum-app/app.ts +++ b/src/forum-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -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('[Forum] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Forum app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Forum') const app = createApp(App) 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/router-helpers.ts b/src/lib/router-helpers.ts index b0cac33..c23bf9d 100644 --- a/src/lib/router-helpers.ts +++ b/src/lib/router-helpers.ts @@ -14,7 +14,14 @@ import type { Router, RouteRecordRaw } from 'vue-router' * mismatch: guards register early but await this promise before reading * auth state. Phase 3 calls markAuthReady() once auth is initialized. */ -type AuthLike = { isAuthenticated: { value: boolean } } +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 }) @@ -23,6 +30,15 @@ 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). @@ -30,10 +46,11 @@ export function markAuthReady(auth: AuthLike): void { export function installStrictAuthGuard(router: Router): void { router.beforeEach(async (to) => { const auth = await authReady + const authed = isFullyAuthed(auth) if (to.path === '/login') { - return auth.isAuthenticated.value ? '/' : true + return authed ? '/' : true } - return auth.isAuthenticated.value ? true : '/login' + return authed ? true : '/login' }) } @@ -45,8 +62,9 @@ export function installLenientAuthGuard(router: Router): void { router.beforeEach(async (to) => { const auth = await authReady const requiresAuth = to.meta.requiresAuth === true - if (requiresAuth && !auth.isAuthenticated.value) return '/login' - if (to.path === '/login' && auth.isAuthenticated.value) return '/' + const authed = isFullyAuthed(auth) + if (requiresAuth && !authed) return '/login' + if (to.path === '/login' && authed) return '/' return true }) } 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/market-app/app.ts b/src/market-app/app.ts index 9b7e936..103ce53 100644 --- a/src/market-app/app.ts +++ b/src/market-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -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('[Market] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Market app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Market') const app = createApp(App) 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/market/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue index 44aac34..ded7018 100644 --- a/src/modules/market/views/MarketDashboard.vue +++ b/src/modules/market/views/MarketDashboard.vue @@ -62,7 +62,7 @@ diff --git a/src/tasks-app/app.ts b/src/tasks-app/app.ts index 25fce53..2223842 100644 --- a/src/tasks-app/app.ts +++ b/src/tasks-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -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('[Tasks] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Tasks app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Tasks') const app = createApp(App) diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts index 07d1cef..e9fc6ae 100644 --- a/src/wallet-app/app.ts +++ b/src/wallet-app/app.ts @@ -14,28 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * Allows the hub to link users into Wallet without re-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('[Wallet] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Wallet app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Wallet') const app = createApp(App) From 628c13c644f729a9e7e4facfa17e78ab708ce0bf Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 09:55:44 +0200 Subject: [PATCH 34/38] fix(market): resolve stall_id from a-tag when content omits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-15 lists stall_id inside the JSON content of kind-30018 product events, but some publishers (older nostrmarket builds, third-party clients) omit the field and only emit the parent reference via the a-tag of the form ["a", "30017::"]. Adds resolveStallId(event, productData) which: 1. Reads productData.stall_id when present (the spec-canonical path) 2. Falls back to the a-tag prefixed "30017:" when content omits it 3. Returns 'unknown' as a sentinel that won't match any real stall Both code paths in useMarket.ts (loadProducts batch and handleProductEvent live-update) now use it. Combined with the addStall sweep from eb3393f, products eventually link to their parent stall regardless of order or which form the publisher used. This DOES NOT fix orphan products whose referenced stall genuinely isn't on the relay — those still render "Unknown Stall" because no stall exists to link to. Investigating that separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/market/composables/useMarket.ts | 30 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 47ccaa0..66af02c 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -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 @@ -315,7 +341,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 @@ -515,7 +541,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 From 16c03d947aa55adc2205e789a911c1e90d360719 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 10:05:23 +0200 Subject: [PATCH 35/38] feat(market): self-heal orphan stalls on dashboard mount (closes #38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopgap for the upstream LNbits orphan-stall bug (aiolabs/lnbits#10): _create_default_merchant historically provisioned the merchant + stall in nostrmarket's internal SQLite without publishing the kind-30017 stall event to relays. Upstream fix 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 (every product they author renders as "Unknown Stall"). New composable useMarketStallSelfHeal() runs once per browser session for any logged-in user landing on /market/dashboard: 1. Query the relay for kind-30017 events authored by their pubkey 2. Get LNbits's known stalls for the merchant 3. For each stall not represented on the relay, PUT it 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 Wired from MarketDashboard.vue onMounted (after the existing fully-authed guard). Fire-and-forget, never toasts, sessionStorage gate prevents re-runs on remounts. Closes #38. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useMarketStallSelfHeal.ts | 99 +++++++++++++++++++ src/modules/market/views/MarketDashboard.vue | 5 + 2 files changed, 104 insertions(+) create mode 100644 src/modules/market/composables/useMarketStallSelfHeal.ts 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/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue index ded7018..5e08423 100644 --- a/src/modules/market/views/MarketDashboard.vue +++ b/src/modules/market/views/MarketDashboard.vue @@ -77,6 +77,7 @@ import OrderHistory from '../components/OrderHistory.vue' import MerchantStore from '../components/MerchantStore.vue' import MarketSettings from '../components/MarketSettings.vue' import { auth } from '@/composables/useAuthService' +import { useMarketStallSelfHeal } from '../composables/useMarketStallSelfHeal' const route = useRoute() const marketStore = useMarketStore() @@ -139,6 +140,7 @@ const tabs = computed(() => [ ]) const router = useRouter() +const { selfHealOnce } = useMarketStallSelfHeal() // Lifecycle onMounted(() => { @@ -153,6 +155,9 @@ onMounted(() => { return } console.log('Market Dashboard mounted') + // Self-heal orphan stalls (issue #38) once per browser session. + // Fire-and-forget — never blocks the dashboard render. + void selfHealOnce() }) From 121f5cc34268ac0092f60ae3544cfbde0ac9aca2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 12:28:45 +0200 Subject: [PATCH 36/38] feat(market): migrate order DMs to NIP-17 (NIP-44 + NIP-59 gift wrap) The nostrmarket LNbits extension was refactored to NIP-17 messaging (refactor/nip17-messaging branch, PR #2). Customers must send orders as kind 1059 gift wraps so the merchant's _handle_gift_wrap() handler can process them; kind 4 NIP-04 events are now ignored by the backend. Changes: - nostrmarketService.publishOrder(): replace nip04.encrypt + finalizeEvent (kind 4) with nip59.wrapEvent producing kind 1059. The order JSON sits in an unsigned kind 14 rumor, sealed (kind 13) with the customer's key, wrapped (kind 1059) with an ephemeral key. - useMarket.handleOrderDM(): unwrap incoming kind 1059 via nip59.unwrapEvent instead of nip04.decrypt. Read sender pubkey from rumor.pubkey (the gift wrap's pubkey is ephemeral). - useMarket.registerMarketMessageHandler(): bypass chat-service and subscribe directly to {kinds: [1059], '#p': [userPubkey]}. The chat service still uses NIP-04 - when it migrates to NIP-17 it can take over routing again via setMarketMessageHandler. nostr-tools v2.10.4 (already a dep) provides the NIP-44/NIP-59 APIs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/market/composables/useMarket.ts | 91 ++++++++++--------- .../market/services/nostrmarketService.ts | 78 +++++----------- 2 files changed, 71 insertions(+), 98 deletions(-) diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 66af02c..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' @@ -54,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) } } @@ -423,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') @@ -478,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) } } 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() From a0187a660476a22009dccd1de36b8a01d6fcd044 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 16:02:06 +0200 Subject: [PATCH 37/38] fix(vite): rewrite to .html when query has dots (JWT tokens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev SPA-fallback plugin used `!req.url.includes('.')` to skip asset requests, which also matched JWT-shaped `?token=hdr.body.sig` query strings — so `localhost:5185/?token=...` fell through to the hub `index.html` instead of `market.html`, breaking the hub→standalone auth-relay link. Strip the query before the extension check. Applied to all 7 standalone vite configs. --- vite.activities.config.ts | 7 +++++-- vite.castle.config.ts | 7 +++++-- vite.chat.config.ts | 5 ++++- vite.forum.config.ts | 5 ++++- vite.market.config.ts | 5 ++++- vite.tasks.config.ts | 5 ++++- vite.wallet.config.ts | 5 ++++- 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/vite.activities.config.ts b/vite.activities.config.ts index aea9915..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' } diff --git a/vite.castle.config.ts b/vite.castle.config.ts index d0916eb..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' } diff --git a/vite.chat.config.ts b/vite.chat.config.ts index 6965d08..c28535f 100644 --- a/vite.chat.config.ts +++ b/vite.chat.config.ts @@ -11,12 +11,15 @@ function chatHtmlPlugin(): Plugin { 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/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/chat.html' } diff --git a/vite.forum.config.ts b/vite.forum.config.ts index 756d5c1..0fdebbe 100644 --- a/vite.forum.config.ts +++ b/vite.forum.config.ts @@ -11,12 +11,15 @@ function forumHtmlPlugin(): Plugin { 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/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/forum.html' } diff --git a/vite.market.config.ts b/vite.market.config.ts index 255d8c0..bf38430 100644 --- a/vite.market.config.ts +++ b/vite.market.config.ts @@ -11,12 +11,15 @@ function marketHtmlPlugin(): Plugin { 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/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/market.html' } diff --git a/vite.tasks.config.ts b/vite.tasks.config.ts index 3cb15fd..1edc3e6 100644 --- a/vite.tasks.config.ts +++ b/vite.tasks.config.ts @@ -11,12 +11,15 @@ function tasksHtmlPlugin(): Plugin { 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/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/tasks.html' } diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts index dfa0bd6..f991672 100644 --- a/vite.wallet.config.ts +++ b/vite.wallet.config.ts @@ -15,12 +15,15 @@ function walletHtmlPlugin(): Plugin { 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/') && - !req.url.includes('.') + !path.includes('.') ) { req.url = '/wallet.html' } From 8792a884cd59b93deb5f994d279d3898957e4f0d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 16:38:48 +0200 Subject: [PATCH 38/38] fix(market): drop floating cart button, badge the navbar Cart tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating "Cart (N)" button (fixed bottom-4 right-4) was hidden behind the bottom navbar — both occupy the same screen position. The navbar already has a Cart tab, so the floating button is redundant. - Remove CartButton.vue component and its usages from MarketPage and StallView. - Add a count badge to the Cart tab in the market app navbar that shows marketStore.totalCartItems when > 0. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/market-app/App.vue | 13 +++++++++-- src/modules/market/components/CartButton.vue | 23 -------------------- src/modules/market/views/MarketPage.vue | 4 ---- src/modules/market/views/StallView.vue | 4 ---- 4 files changed, 11 insertions(+), 33 deletions(-) delete mode 100644 src/modules/market/components/CartButton.vue diff --git a/src/market-app/App.vue b/src/market-app/App.vue index 9aed606..7d40951 100644 --- a/src/market-app/App.vue +++ b/src/market-app/App.vue @@ -6,6 +6,7 @@ import LoginDialog from '@/components/auth/LoginDialog.vue' import { useTheme } from '@/components/theme-provider' import { toast } from 'vue-sonner' import { useAuth } from '@/composables/useAuthService' +import { useMarketStore } from '@/modules/market/stores/market' import { Store, ShoppingCart, Package, LogIn, User as UserIcon, } from 'lucide-vue-next' @@ -15,6 +16,7 @@ const router = useRouter() useTheme() const { isAuthenticated } = useAuth() +const marketStore = useMarketStore() const showLoginDialog = ref(false) @@ -23,12 +25,13 @@ interface Tab { icon: any path?: string authRequired?: boolean + badge?: () => number onClick?: () => void } const bottomTabs = computed(() => [ { name: 'Browse', icon: Store, path: '/market' }, - { name: 'Cart', icon: ShoppingCart, path: '/cart' }, + { name: 'Cart', icon: ShoppingCart, path: '/cart', badge: () => marketStore.totalCartItems }, { name: 'My Store', icon: Package, path: '/market/dashboard', authRequired: true }, isAuthenticated.value ? { name: 'Profile', icon: UserIcon, path: '/profile' } @@ -92,7 +95,13 @@ async function handleLoginSuccess() { ]" @click="onTabClick(tab)" > - + + + {{ tab.badge() }} + {{ tab.name }} 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/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 @@ /> - - -