From 08e5d92059de2b62499f13009182711fb9d987f6 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 16 Jul 2026 14:46:17 -0400 Subject: [PATCH] fix: handle 401 gracefully on web; add Bearer token auth for Tauri - fetchMe() no longer surfaces 401 as a user-facing error (it just means 'no session', not a failure) - API client auto-clears auth state on 401 mid-session so the user gets redirected to login instead of seeing 'ERR: Request failed: 401' - Session middleware now accepts Authorization: Bearer header as fallback when no cookie is present (for Tauri/native clients) - Login, register, and WebAuthn endpoints expose X-Session-Token header so non-browser clients can capture the token --- cmd/server/main.go | 2 +- data/shared/mnemosyne.db-shm | Bin 0 -> 32768 bytes data/shared/mnemosyne.db-wal | Bin 0 -> 152472 bytes docs/CapacitorAndroidPlan.md | 369 +++++++++++++++++++++++++++++++++ internal/auth/handlers.go | 2 + internal/auth/webauthn.go | 1 + internal/middleware/session.go | 14 +- web/src/lib/api.ts | 13 ++ web/src/stores/auth.ts | 11 +- 9 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 data/shared/mnemosyne.db-shm create mode 100644 data/shared/mnemosyne.db-wal create mode 100644 docs/CapacitorAndroidPlan.md diff --git a/cmd/server/main.go b/cmd/server/main.go index 8d3a673..cd883f5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -125,7 +125,7 @@ func main() { AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, + ExposedHeaders: []string{"Link", "X-Session-Token"}, AllowCredentials: true, MaxAge: 300, })) diff --git a/data/shared/mnemosyne.db-shm b/data/shared/mnemosyne.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..2ba6d8b2ed70872c42909890f07ff29b92da87d2 GIT binary patch literal 32768 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 jfB*=900@8p2!H?xfB*=900@A **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play. + +**Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app. + +**Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build) + +--- + +### Phase 1: Capacitor Init + +#### Task 1: Add Capacitor dependencies + +**Objective:** Install Capacitor core + CLI in the web project. + +**Files:** +- Modify: `web/package.json` + +**Steps:** + +```bash +cd web +npm install @capacitor/core @capacitor/cli @capacitor/android +``` + +Then init Capacitor: + +```bash +npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist +``` + +This creates `capacitor.config.ts` at the web root. + +**Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`. + +--- + +#### Task 2: Configure capacitor.config.ts + +**Objective:** Set server URL for dev, configure Android-specific settings. + +**Files:** +- Modify: `web/capacitor.config.ts` + +**Content:** + +```ts +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'coffee.dustin.dumpster', + appName: 'Dumpster Chat', + webDir: 'dist', + server: { + // ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts. + androidScheme: 'https', // cookies work over https scheme in WebView + }, + plugins: { + PushNotifications: { + presentationOptions: ['badge', 'sound', 'alert'], + }, + }, +}; + +export default config; +``` + +**Key decisions:** +- `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them). +- No `server.url` — local assets load from the APK, not from the web. Faster, works offline. + +--- + +#### Task 3: Add Android platform + +**Objective:** Generate the native Android project. + +**Files:** +- Create: `web/android/` (generated by Capacitor) + +**Steps:** + +```bash +cd web +npx cap add android +``` + +**Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists. + +--- + +### Phase 2: API Client Fix + +#### Task 4: Update API base URL for native + +**Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA. + +**Files:** +- Modify: `web/src/lib/api.ts` + +**Changes:** + +```ts +import { Capacitor } from '@capacitor/core'; + +// ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin). +const API_BASE = Capacitor.isNativePlatform() + ? 'https://dumpster.dustin.coffee/api/v1' + : '/api/v1'; +``` + +The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView. + +**Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break. + +--- + +### Phase 3: Push Notifications (FCM) + +This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM. + +#### Task 5: Create Firebase project + +**Objective:** Set up FCM credentials for native push. + +**Steps (manual, one-time):** +1. Go to https://console.firebase.google.com +2. Create project (or use existing) named `dumpster-chat` +3. Add Android app with package name `coffee.dustin.dumpster` +4. Download `google-services.json` → place in `web/android/app/google-services.json` +5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account + +**Verify:** `google-services.json` exists in `web/android/app/`. + +--- + +#### Task 6: Add Capacitor Push Notifications plugin + +**Objective:** Register for FCM token on Android, send it to the server. + +**Files:** +- Modify: `web/package.json` (install plugin) +- Modify: `web/src/stores/push.ts` (add native branch) + +**Install:** +```bash +cd web +npm install @capacitor/push-notifications +``` + +**Modify `push.ts`** — add a native registration path alongside the existing web push: + +```ts +import { Capacitor } from '@capacitor/core'; + +// Existing web push subscribe stays as-is for PWA. +// Add native branch: +async function subscribeNative() { + const { PushNotifications } = await import('@capacitor/push-notifications'); + + const permStatus = await PushNotifications.requestPermissions(); + if (permStatus.receive !== 'granted') return; + + await PushNotifications.register(); + + // Server sends us the FCM token via this event + PushNotifications.addListener('registration', async (token) => { + await api.post('/push/subscribe', { + endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints + keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape + }); + }); + + PushNotifications.addListener('pushNotificationReceived', (notification) => { + // Foreground notification — show in-app toast or badge + // ponytail: handled by existing in-app notification system + }); +} +``` + +Then in the existing `subscribe()` function, branch: + +```ts +if (Capacitor.isNativePlatform()) { + return subscribeNative(); +} +// ... existing web push logic +``` + +--- + +#### Task 7: Server-side FCM send support + +**Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID. + +**Files:** +- Modify: `internal/push/handlers.go` + +**Changes:** +1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it). +2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send. + +**Install Go Firebase Admin:** +```bash +go get firebase.google.com/go/v4 +``` + +**Pattern:** +```go +// ponytail: one if/else in the send loop. endpoint prefix = routing key. +if strings.HasPrefix(sub.Endpoint, "fcm:") { + token := strings.TrimPrefix(sub.Endpoint, "fcm:") + msg := &messaging.Message{ + Token: token, + Notification: &messaging.Notification{ + Title: title, + Body: body, + }, + Data: map[string]string{"url": url}, + } + _, err = fcmClient.Send(ctx, msg) +} else { + // existing VAPID webpush send +} +``` + +**Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose. + +**Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows. + +--- + +### Phase 4: Gradle / Build Config + +#### Task 8: Configure Android build + +**Objective:** Set minimum SDK, app icon, theme. + +**Files:** +- Modify: `web/android/app/build.gradle` +- Modify: `web/android/app/src/main/res/values/strings.xml` + +**Changes in `build.gradle`:** +```gradle +minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs. +``` + +**App name in `strings.xml`:** +```xml +Dumpster Chat +``` + +**App icon:** Copy existing PWA icons into Android mipmap directories: +```bash +# Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024) +# or manually: web/android/app/src/main/res/mipmap-*/ +npx cap assets generate # if a 1024x1024 source icon exists +``` + +--- + +### Phase 5: Build & Publish + +#### Task 9: Sync and build debug APK + +**Objective:** Verify the app runs on a real device or emulator. + +**Steps:** +```bash +cd web +npm run build # builds Vite → dist/ +npx cap sync android # copies dist/ into android/assets, syncs plugins +cd android +./gradlew assembleDebug +``` + +**Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk` + +**Verify:** Install on Android device: +```bash +adb install app-debug.apk +``` + +--- + +#### Task 10: Build signed release AAB for Play Store + +**Objective:** Create a signed Android App Bundle (.aab) for Google Play upload. + +**Steps:** +1. Generate keystore (one-time): +```bash +keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster +``` +Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app. + +2. Add signing config to `web/android/app/build.gradle`: +```gradle +android { + signingConfigs { + release { + storeFile file('dumpster-release.jks') + storePassword System.getenv('KEYSTORE_PASSWORD') + keyAlias 'dumpster' + keyPassword System.getenv('KEY_PASSWORD') + } + } + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} +``` + +3. Build AAB: +```bash +cd web/android +KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease +``` + +**Output:** `web/android/app/build/outputs/bundle/release/app-release.aab` + +4. Upload to Google Play Console → your new developer account → Create app → Upload AAB. + +--- + +#### Task 11: Clean up Tauri dependencies + +**Objective:** Remove unused Tauri packages (pivoted away from Tauri). + +**Files:** +- Modify: `web/package.json` + +**Steps:** +```bash +cd web +npm uninstall @tauri-apps/api @tauri-apps/cli +``` + +--- + +### Summary + +| Phase | What | Time estimate | +|-------|------|---------------| +| 1 | Capacitor init + Android platform | 10 min | +| 2 | API base URL native branch | 5 min | +| 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) | +| 4 | Gradle config / icons | 15 min | +| 5 | Build, test, publish | 30 min | + +**Total:** ~2-3 hours end-to-end. Phase 3 is the only real work. + +**Dependencies between tasks:** +- Tasks 1-3 sequential (Capacitor init) +- Task 4 independent of 5-7 +- Tasks 5-7 sequential (FCM chain) +- Task 8 depends on 1-3 +- Task 9 depends on all above +- Task 10 depends on 9 +- Task 11 independent, do anytime + +**After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too. diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 614b680..cc2c314 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -331,6 +331,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { } h.setSessionCookie(w, token) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } @@ -383,6 +384,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { } h.setSessionCookie(w, token) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go index 0d832a0..f4e5aff 100644 --- a/internal/auth/webauthn.go +++ b/internal/auth/webauthn.go @@ -346,6 +346,7 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) { Secure: true, }) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"}) } diff --git a/internal/middleware/session.go b/internal/middleware/session.go index 6a55bb7..04bb0e3 100644 --- a/internal/middleware/session.go +++ b/internal/middleware/session.go @@ -18,13 +18,23 @@ type SessionStore interface { func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var token string cookie, err := r.Cookie(cfg.Session.CookieName) - if err != nil { + if err == nil { + token = cookie.Value + } else { + authHeader := r.Header.Get("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + token = authHeader[7:] + } + } + + if token == "" { next.ServeHTTP(w, r) return } - userID, err := store.GetUserIDByToken(r.Context(), cookie.Value) + userID, err := store.GetUserIDByToken(r.Context(), token) if err != nil { next.ServeHTTP(w, r) return diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 410569d..c960d1f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -40,6 +40,19 @@ async function request(method: string, path: string, body?: unknown): Promise const ra = response.headers.get('Retry-After'); err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined); } + + // Auto-logout on 401 for any non-login endpoint so expired sessions + // redirect to the login page instead of showing cryptic errors. + if (response.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/auth/register')) { + // Dynamically import to avoid circular deps + import('../stores/auth.ts').then(({ useAuthStore }) => { + const state = useAuthStore.getState(); + if (state.isAuthenticated) { + useAuthStore.setState({ user: null, isAuthenticated: false, error: null }); + } + }); + } + throw err; } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 459d132..43d1dbf 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -151,12 +151,19 @@ export const useAuthStore = create((set) => ({ set({ user, isAuthenticated: true, isLoading: false }); autoSubscribePush(); } catch (error) { + // 401 from /auth/me simply means "no active session" — not a + // user-facing error. Only surface non-auth failures. + const isAuthError = + error instanceof Error && (error as import('../lib/api.ts').ApiError).status === 401; set({ user: null, isAuthenticated: false, isLoading: false, - error: - error instanceof Error ? error.message : "Failed to fetch user", + error: isAuthError + ? null + : error instanceof Error + ? error.message + : "Failed to fetch user", }); } },