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 0000000..2ba6d8b
Binary files /dev/null and b/data/shared/mnemosyne.db-shm differ
diff --git a/data/shared/mnemosyne.db-wal b/data/shared/mnemosyne.db-wal
new file mode 100644
index 0000000..43b72b6
Binary files /dev/null and b/data/shared/mnemosyne.db-wal differ
diff --git a/docs/CapacitorAndroidPlan.md b/docs/CapacitorAndroidPlan.md
new file mode 100644
index 0000000..f50a420
--- /dev/null
+++ b/docs/CapacitorAndroidPlan.md
@@ -0,0 +1,369 @@
+# Capacitor Android App — Implementation Plan
+
+> **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",
});
}
},