Files
Job-Info/instructions/OPUS_PROMPT_PHASE1.md
T
2026-01-09 04:08:06 +00:00

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:

  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:

// 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

  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

    // 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

  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.