20 KiB
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:
-
PocketBase Token (User session with PocketBase)
- For all app operations (jobs, files, notes)
- Managed by PocketBase authentication
- Expires: 3600 seconds (1 hour)
-
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 tokentoken_expires_pb- Expiry timestamptoken_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:
// 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:
// 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
-
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 -
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 -
Initial Token Setup
// 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
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
class TokenRefreshService {
private refreshLoops = new Map<string, NodeJS.Timer>();
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
// 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<string> {
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
// 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
- ✅ Complete project scaffolding
- ✅ Backend structure (services, routes, types)
- ✅ Frontend structure (views, services, auth)
- ✅ Token lifecycle implementation
- ✅ Background refresh service
- ✅ Error handling & offline support
- ✅ Configuration & environment
- ✅ Comprehensive documentation
- ✅ 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.