# Opus 4.5 Prompt: Job-Info-Prod Initial Architecture & Scaffolding ## Project Context & Mission **Project:** Job Info - Production (Job-Info-Prod) **Purpose:** Professional field management application for construction/project teams **Critical Requirement:** Workers in field cannot afford frequent re-authentication **Environment:** Field-deployed, potentially offline, mission-critical --- ## Core Architecture Requirements ### 1. Dual-Token Authentication System The app uses **PocketBase OAuth through Microsoft** to acquire TWO tokens: 1. **PocketBase Token** (User session with PocketBase) - For all app operations (jobs, files, notes) - Managed by PocketBase authentication - Expires: 3600 seconds (1 hour) 2. **Microsoft Graph Token** (User's Microsoft account access) - For SharePoint file access & Office 365 integration - Obtained during PocketBase OAuth flow - Expires: 3600 seconds (1 hour) ### 2. Token Lifecycle (CRITICAL) **Goal:** Users authenticate ONCE at startup, tokens stay fresh for entire session (hours/days) ``` Login Flow: ├── User clicks "Sign In" ├── Redirected to PocketBase OAuth (which includes Microsoft) ├── User grants permissions to app ├── App receives: │ ├── PocketBase token → localStorage │ ├── Graph token → localStorage │ └── Refresh tokens → Valkey cache (backend) ├── App initializes background token refresh loop └── User sees app, never needs to re-login Token Refresh Loop (Backend): ├── Every 30 minutes (running in background) ├── Check token expiry times ├── Refresh if < 15 minutes to expiry ├── Update both Valkey cache AND localStorage ├── Silently continue (no UI interruption) └── Never ask user to re-authenticate ``` ### 3. Token Storage Strategy **Frontend (localStorage):** - `pocketbase_auth` - PocketBase session (includes token) - `graph_token` - Microsoft Graph token - `token_expires_pb` - Expiry timestamp - `token_expires_graph` - Expiry timestamp **Backend (Valkey/Redis Cache):** - `tokens:${userId}:pb` - Fresh PocketBase token + refresh token (TTL: 1 hour) - `tokens:${userId}:graph` - Fresh Graph token + refresh token (TTL: 1 hour) - `tokens:${userId}:refresh_pb` - PocketBase refresh token (TTL: 30 days) - `tokens:${userId}:refresh_graph` - Graph refresh token (TTL: 30 days) **Why dual storage:** - Frontend has tokens for immediate use - Backend cache keeps tokens warm & refreshed - If frontend token expires, backend can refresh silently - If Redis goes down, frontend token still works - If frontend loses token, can get from Redis ### 4. Single Token Check Logic Before ANY API call: ```javascript // Frontend check async function ensureToken(type) { // type: 'pb' or 'graph' // 1. Get from localStorage const token = localStorage.getItem(`${type}_token`); const expiry = localStorage.getItem(`token_expires_${type}`); // 2. Check if fresh (> 15 min remaining) if (token && expiry > Date.now() + 15*60*1000) { return token; // Use immediately } // 3. If not fresh, try backend refresh const refreshed = await fetch('/api/refresh-token', { method: 'POST', body: JSON.stringify({ type }) }); if (refreshed.ok) { const { token: newToken, expiresAt } = await refreshed.json(); localStorage.setItem(`${type}_token`, newToken); localStorage.setItem(`token_expires_${type}`, expiresAt); return newToken; } // 4. If backend refresh failed, redirect to login if (!token) { redirectToLogin(); throw new Error('Authentication required'); } // 5. Use stale token as fallback (better than nothing in field) console.warn('Using stale token - backend unreachable'); return token; } ``` ### 5. Background Token Refresh (CRITICAL) Backend runs continuous refresh loop: ```typescript // Backend service class TokenRefreshService { async refreshTokensInBackground() { // Every 30 minutes for each logged-in user for (const userId of getActiveUsers()) { // Check PocketBase token const pbToken = await cache.get(`tokens:${userId}:pb`); if (isExpiring(pbToken)) { const refreshed = await refreshPocketBaseToken(pbToken.refresh_token); await cache.set(`tokens:${userId}:pb`, refreshed, ttl: 3600); } // Check Graph token const graphToken = await cache.get(`tokens:${userId}:graph`); if (isExpiring(graphToken)) { const refreshed = await refreshMicrosoftGraphToken(graphToken.refresh_token); await cache.set(`tokens:${userId}:graph`, refreshed, ttl: 3600); } } } } ``` ### 6. Offline-First Capability **When backend is down:** - Frontend still works with cached tokens - File list cached in memory (Map) - File content cached (if accessed before) - UI shows "Offline" indicator but remains functional - All operations queue locally - Sync when backend returns **When network is intermittent:** - Requests timeout after 30 seconds - Automatic retry with exponential backoff - Cache provides fallback data - User sees "Trying to reconnect..." briefly - App continues working ### 7. Error Handling & Graceful Degradation ``` Token Missing ├── During Init → Redirect to login ├── During API call → Prompt to login (non-blocking overlay) └── In background → Log, don't interrupt user Token Expired ├── < 15 min to expiry → Silently refresh from backend ├── Expired & refresh fails → Use stale token if available └── Completely unavailable → Read from cache, queue for sync Backend Unreachable ├── Token refresh → Skip, use current token ├── API call → Use cache, retry when back └── File fetch → Use cached file list ``` --- ## Project Structure ``` Job-Info-Prod/ ├── auth/ # Auth rules from Job-Info-Test │ ├── RULES.md, FLOWMAP.md, VIEWS.md, etc. │ └── auth-universal.js # Base auth module (to be enhanced) │ ├── backend/ │ ├── src/ │ │ ├── index.ts # Main server │ │ ├── config/ │ │ │ ├── env.ts # Environment config │ │ │ └── constants.ts # App constants │ │ ├── middleware/ │ │ │ ├── auth.ts # Token validation │ │ │ ├── errorHandler.ts # Error handling │ │ │ └── logger.ts # Request logging │ │ ├── routes/ │ │ │ ├── auth.ts # /api/auth endpoints (login, refresh, logout) │ │ │ ├── jobs.ts # /api/jobs endpoints │ │ │ ├── files.ts # /api/job-files endpoints │ │ │ ├── health.ts # /health endpoint │ │ │ └── index.ts # Route registration │ │ ├── services/ │ │ │ ├── tokenService.ts # Token refresh & management │ │ │ ├── cacheService.ts # Redis operations │ │ │ ├── pocketbaseService.ts # PocketBase integration │ │ │ ├── graphService.ts # Microsoft Graph integration │ │ │ └── jobsService.ts # Business logic │ │ ├── types/ │ │ │ ├── auth.ts # Auth interfaces │ │ │ ├── job.ts # Job interfaces │ │ │ └── common.ts # Common types │ │ └── utils/ │ │ ├── logger.ts # Logging utility │ │ └── validators.ts # Input validation │ ├── tests/ │ │ ├── auth.test.ts │ │ ├── token-refresh.test.ts │ │ └── jobs.test.ts │ └── README.md │ ├── frontend/ │ ├── public/ │ │ ├── index.html # Main app │ │ ├── signin.html # OAuth redirect page │ │ ├── offline.html # Offline fallback │ │ └── assets/ │ ├── src/ │ │ ├── main.ts # Entry point │ │ ├── auth/ │ │ │ ├── client.ts # Token management │ │ │ ├── pocketbase.ts # PocketBase instance │ │ │ └── refresh-loop.ts # Background refresh │ │ ├── views/ │ │ │ ├── auth-view.ts # Sign in page │ │ │ ├── landing-view.ts # Jobs list │ │ │ ├── job-detail-view.ts # Job detail │ │ │ └── notes-view.ts # Expanded notes │ │ ├── services/ │ │ │ ├── api-client.ts # API requests │ │ │ ├── cache.ts # Local file cache │ │ │ ├── state.ts # Global state │ │ │ └── offline.ts # Offline handling │ │ ├── components/ │ │ │ ├── job-card.ts │ │ │ ├── file-list.ts │ │ │ └── sticky-notes.ts │ │ ├── types/ │ │ │ ├── auth.ts │ │ │ └── job.ts │ │ └── styles/ │ │ ├── main.css │ │ └── tailwind.css │ ├── tests/ │ └── README.md │ ├── shared/ │ ├── types/ │ │ ├── auth.ts # Auth interfaces │ │ └── job.ts # Job interfaces │ └── constants/ │ └── index.ts │ ├── docs/ │ ├── ARCHITECTURE.md # System design │ ├── API.md # API reference │ ├── TOKEN_LIFECYCLE.md # Token management detail │ ├── OFFLINE_STRATEGY.md # Offline-first approach │ └── DEPLOYMENT.md # Production deployment │ ├── scripts/ │ ├── setup.sh │ └── deploy.sh │ ├── .env.example ├── .gitignore ├── package.json ├── tsconfig.json ├── tailwind.config.js ├── postcss.config.js ├── bun.lockb ├── README.md └── CHANGELOG.md ``` --- ## Key Implementation Details ### Authentication Flow 1. **User Visits App** ``` App Load ├── Check localStorage for auth ├── If found & fresh → Load app ├── If not found → Redirect to signin.html └── If expired → Prompt to re-login ``` 2. **Sign In Page (signin.html)** ``` signin.html (Hosted by backend) ├── Redirects to PocketBase OAuth │ └── PocketBase includes Microsoft scopes │ ├── offline_access (for refresh tokens) │ ├── Files.Read (SharePoint) │ ├── Sites.Read.All (Sites) │ └── User.Read (Profile) ├── PocketBase handles OAuth flow ├── Returns both tokens └── Redirects back to app with tokens ``` 3. **Initial Token Setup** ```typescript // Backend endpoint: POST /api/auth/login-callback async function handleLoginCallback(req) { const { pbToken, graphToken } = req.body; // 1. Validate tokens const pbUser = await validatePocketBaseToken(pbToken); const graphUser = await validateGraphToken(graphToken); // 2. Store in Valkey cache const userId = pbUser.id; await cache.set(`tokens:${userId}:pb`, { token: pbToken, refresh_token: pbToken.refresh, expires_at: pbUser.expires }, ttl: 3600); await cache.set(`tokens:${userId}:graph`, { token: graphToken, refresh_token: graphToken.refresh, expires_at: graphUser.expires }, ttl: 3600); // 3. Start background refresh loop for this user tokenRefreshService.addUser(userId); // 4. Return tokens to frontend return { pbToken, graphToken, expiresAt_pb: pbUser.expires, expiresAt_graph: graphUser.expires }; } ``` ### Token Refresh Endpoints **POST /api/refresh-token** ```typescript async function refreshToken(c: Context) { const userId = c.get('userId'); // From auth middleware const type = c.req.query('type'); // 'pb' or 'graph' // 1. Try to get fresh token from cache const cached = await cache.get(`tokens:${userId}:${type}`); if (cached && !isExpiring(cached)) { return c.json({ token: cached.token, expiresAt: cached.expires_at, source: 'cache' }); } // 2. If expiring soon, refresh using refresh token const refreshToken = cached?.refresh_token; if (refreshToken) { const refreshed = type === 'pb' ? await refreshPocketBaseToken(refreshToken) : await refreshMicrosoftGraphToken(refreshToken); await cache.set(`tokens:${userId}:${type}`, refreshed, ttl: 3600); return c.json({ token: refreshed.token, expiresAt: refreshed.expires_at, source: 'refreshed' }); } // 3. If no refresh token, authentication required return c.json({ error: 'Authentication required' }, 401); } ``` ### Background Refresh Service ```typescript class TokenRefreshService { private refreshLoops = new Map(); addUser(userId: string) { if (this.refreshLoops.has(userId)) return; // Already running // Start 30-minute refresh loop for this user const loop = setInterval(() => { this.refreshUserTokens(userId).catch(err => { console.error(`Token refresh failed for ${userId}:`, err); }); }, 30 * 60 * 1000); // 30 minutes this.refreshLoops.set(userId, loop); console.log(`Token refresh loop started for user ${userId}`); } removeUser(userId: string) { const loop = this.refreshLoops.get(userId); if (loop) { clearInterval(loop); this.refreshLoops.delete(userId); console.log(`Token refresh loop stopped for user ${userId}`); } } private async refreshUserTokens(userId: string) { // PocketBase const pbToken = await cache.get(`tokens:${userId}:pb`); if (pbToken && isExpiring(pbToken)) { const refreshed = await refreshPocketBaseToken(pbToken.refresh_token); await cache.set(`tokens:${userId}:pb`, refreshed, ttl: 3600); console.log(`[Refresh] PocketBase token refreshed for ${userId}`); } // Graph const graphToken = await cache.get(`tokens:${userId}:graph`); if (graphToken && isExpiring(graphToken)) { const refreshed = await refreshMicrosoftGraphToken(graphToken.refresh_token); await cache.set(`tokens:${userId}:graph`, refreshed, ttl: 3600); console.log(`[Refresh] Graph token refreshed for ${userId}`); } } } ``` ### Frontend Token Management ```typescript // frontend/src/auth/client.ts class TokenClient { private pbToken: string | null = null; private graphToken: string | null = null; private pbExpiry: number = 0; private graphExpiry: number = 0; // Load from localStorage on app start loadTokens() { this.pbToken = localStorage.getItem('pocketbase_auth'); this.graphToken = localStorage.getItem('graph_token'); this.pbExpiry = parseInt(localStorage.getItem('token_expires_pb') || '0'); this.graphExpiry = parseInt(localStorage.getItem('token_expires_graph') || '0'); return this.pbToken && this.graphToken; } // Get token, refresh if needed async ensureToken(type: 'pb' | 'graph'): Promise { const token = type === 'pb' ? this.pbToken : this.graphToken; const expiry = type === 'pb' ? this.pbExpiry : this.graphExpiry; // If fresh (> 15 min remaining), return immediately if (token && expiry > Date.now() + 15*60*1000) { return token; } // Try to refresh from backend try { const response = await fetch(`/api/refresh-token?type=${type}`); if (response.ok) { const { token: newToken, expiresAt } = await response.json(); this.storeToken(type, newToken, expiresAt); return newToken; } } catch (err) { console.warn(`Failed to refresh ${type} token:`, err); } // Fallback to stale token if available if (token) { console.warn(`Using stale ${type} token (backend unreachable)`); return token; } // No token available - must re-authenticate throw new Error('Authentication required'); } private storeToken(type: 'pb' | 'graph', token: string, expiresAt: number) { if (type === 'pb') { this.pbToken = token; this.pbExpiry = expiresAt; localStorage.setItem('pocketbase_auth', token); localStorage.setItem('token_expires_pb', expiresAt.toString()); } else { this.graphToken = token; this.graphExpiry = expiresAt; localStorage.setItem('graph_token', token); localStorage.setItem('token_expires_graph', expiresAt.toString()); } } } ``` ### Logout ```typescript // POST /api/auth/logout async function logout(c: Context) { const userId = c.get('userId'); // 1. Stop background refresh tokenRefreshService.removeUser(userId); // 2. Clear backend cache await cache.del(`tokens:${userId}:pb`); await cache.del(`tokens:${userId}:graph`); await cache.del(`tokens:${userId}:refresh_pb`); await cache.del(`tokens:${userId}:refresh_graph`); // 3. Revoke tokens with PocketBase & Microsoft // (if APIs support it) return c.json({ success: true }); } // Frontend function handleLogout() { // 1. Clear localStorage localStorage.removeItem('pocketbase_auth'); localStorage.removeItem('graph_token'); localStorage.removeItem('token_expires_pb'); localStorage.removeItem('token_expires_graph'); // 2. Call backend logout fetch('/api/auth/logout', { method: 'POST' }); // 3. Redirect to signin window.location.href = 'signin.html'; } ``` --- ## Environment Variables ``` # .env.example # Server PORT=3005 NODE_ENV=production # Redis/Valkey REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= # PocketBase POCKETBASE_URL=https://pocketbase.ccllc.pro POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD= # Microsoft OAuth MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback # Logging LOG_LEVEL=info LOG_FILE=logs/app.log ERROR_LOG_FILE=logs/error.log ``` --- ## Tech Stack (ENFORCED) - **Runtime:** Bun (latest) - **Framework:** Hono ^4.10.8 - **Auth:** PocketBase ^0.26.5 + Microsoft OAuth - **Cache:** ioredis ^5.x (Valkey compatible) - **CSS:** Tailwind CSS ^4.1.18 - **Language:** TypeScript ^5 (strict mode) - **Testing:** Bun test + Vitest --- ## Deliverables for Phase 1 1. ✅ Complete project scaffolding 2. ✅ Backend structure (services, routes, types) 3. ✅ Frontend structure (views, services, auth) 4. ✅ Token lifecycle implementation 5. ✅ Background refresh service 6. ✅ Error handling & offline support 7. ✅ Configuration & environment 8. ✅ Comprehensive documentation 9. ✅ Example tests **No implementations needed yet** - just professional structure & architecture. --- ## Important Notes ### Why Dual Tokens? - PocketBase manages user session & app data - Microsoft Graph needed for future Office 365 integration - Both need to stay fresh independently - One failing doesn't stop the app ### Why Background Refresh? - Workers in field can't keep re-authenticating - Tokens are automatically kept fresh - Even if initial login was 8 hours ago, tokens stay valid - Only system restart resets auth state ### Why Offline-First? - Field workers lose connectivity - Cached data keeps app functional - When connectivity returns, data syncs - Users never feel stuck ### Why This Matters - **Field workers = mission critical** - **Every minute they're not working = lost productivity** - **Network is unreliable in field** - **App must be bulletproof** --- ## Key Success Criteria ✅ Users authenticate once, tokens stay fresh indefinitely ✅ No user-facing re-authentication (except system restart) ✅ Graceful degradation when backend is down ✅ Offline functionality for cached data ✅ Both tokens always warm in cache ✅ Automatic background refresh (invisible to user) ✅ Fallback to stale tokens if refresh fails ✅ Enterprise-grade error handling ✅ Comprehensive logging for debugging ✅ Production-ready from day one --- ## Ready for Implementation This prompt provides: - ✅ Complete architecture understanding - ✅ Dual-token lifecycle strategy - ✅ Field-worker requirements (no re-auth) - ✅ Offline-first approach - ✅ Detailed implementation patterns - ✅ Project structure - ✅ Environment setup - ✅ Tech stack enforcement **Proceed with scaffolding Job-Info-Prod following this architecture.**