← Every release

GFG RKGIT platform

Our GFG Campus Body runs events, registrations and attendance for 300+ members. A club platform has to outlive the students who built it, so I made the browser untrusted: admin actions go through one API that checks who you are, and Firestore rules limit what the browser can write directly.

How it works

Public site (React)Admin portal (React)Team portal (React)Worker API · api.gfg.rkgit.inFirestore + rulesKV · keys, rate limitsR2 · photoscron: graduates archived every 1 June

The code

Admin requests prove who you are gfg-rkgit-worker · src/middleware/auth.jsFirebase ID token checked in the Worker, keys cached in KV
import { Auth, WorkersKVStoreSingle } from 'firebase-auth-cloudflare-workers';

// … cut: role and permission tables

/**
 * Auth middleware - verifies Firebase ID token and checks user role
 */
export async function requireAuth(c, next) {
  const authHeader = c.req.header('Authorization');

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized', message: 'Missing or invalid authorization header' }, 401);
  }

  const token = authHeader.substring(7);

  // Initialize KV store wrapper first (like in src/auth.js)
  const kvStore = WorkersKVStoreSingle.getOrInitialize(
    c.env.FIREBASE_PROJECT_ID,
    c.env.JWK_CACHE
  );

  const auth = Auth.getOrInitialize(c.env.FIREBASE_PROJECT_ID, kvStore);

  try {
    const decodedToken = await auth.verifyIdToken(token, false);

    if (!decodedToken || !decodedToken.uid) {
      return c.json({ error: 'Unauthorized', message: 'Invalid token' }, 401);
    }

    // Attach user info to context
    c.set('uid', decodedToken.uid);
    c.set('email', decodedToken.email);

    await next();
  } catch (error) {
    console.error('Token verification failed:', error);
    return c.json({ error: 'Unauthorized', message: 'Token verification failed' }, 401);
  }
}

// … cut: requireRole, Firestore lookup helpers
show all 43 linesshow less
Rules for what the browser may write directly gfg-rkgit-infra · firestore.rulesthe repo has an emulator test suite for these rules
// Security Model:
// - Public/unauthenticated users: read-only access to active public data
// - Team members (authenticated, in team_members collection): self-service profile updates only
// - Admin users (in admin-users collection): full management access
// … cut: club handoff notes
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns the role string from the caller's admin-users document, or null.
    function getUserRole() {
      return exists(/databases/$(database)/documents/admin-users/$(request.auth.uid))
        ? get(/databases/$(database)/documents/admin-users/$(request.auth.uid)).data.role
        : null;
    }

    // True if the caller is authenticated AND has an active admin-users record.
    function isActiveAdmin() {
      return request.auth != null &&
             exists(/databases/$(database)/documents/admin-users/$(request.auth.uid)) &&
             get(/databases/$(database)/documents/admin-users/$(request.auth.uid)).data.isActive == true;
    }

    // True if the caller is super_admin.
    function isSuperAdmin() {
      return isActiveAdmin() && getUserRole() == 'super_admin';
    }

    // … cut: the other role helpers

    // Fields a team member may self-update. Admin update has no field restriction.
    // blockedFields are admin-only fields.
    function onlyMemberEditableFields() {
      let allowedFields = ['bio', 'skills', 'achievements', 'qualifications', 'social', 'socialLinks', 'image', 'imageUrl', 'currentProjects', 'lastProfileUpdate', 'profileCompleteness', 'updatedAt', 'lastEditedBy'];
      let blockedFields = ['role', 'department', 'priority', 'isActive', 'isAlumni', 'memberType', 'userId', 'joiningYear', 'graduationYear', 'name', 'email'];
      return request.resource.data.diff(resource.data).affectedKeys().hasOnly(allowedFields)
          && !request.resource.data.diff(resource.data).affectedKeys().hasAny(blockedFields)
          // Array size limits — prevents document bloat toward 1MB hard limit
          && (!('skills' in request.resource.data) || request.resource.data.skills.size() <= 50)
          && (!('achievements' in request.resource.data) || request.resource.data.achievements.size() <= 50)
          && (!('currentProjects' in request.resource.data) || request.resource.data.currentProjects.size() <= 20)
          // bio size cap — client (ProfileEdit.jsx) also enforces 500, but that's
          // only UI-level; this is the actual unbypassable limit since profile
          // saves write directly from the client SDK, not through the Worker.
          && (!('bio' in request.resource.data) || request.resource.data.bio is string && request.resource.data.bio.size() <= 500);
    }

    // … cut: admin-users, events and team collections

    // feedback — Anyone can submit (with document size limit). Admins read. No update or delete (data integrity).
    match /feedback/{feedbackId} {
      allow create: if request.resource.data.size() < 10000;
      allow read: if canViewData();
      allow update: if false;
      allow delete: if false;
    }


    // … cut: remaining collections
  }
}
show all 61 linesshow less
Access that expires on its own gfg-rkgit-worker · src/scheduled/revoke-scanner-access.jsa daily cron removes event-scanner access
import { queryDocuments, setDocument, logAudit } from '../utils/firestore.js';

/**
 * Auto-revoke scanner access after event date has passed
 * Runs daily via cron trigger
 *
 * Scanner access is stored as an array within the events document:
 * events/{eventId}.scanners = [{ userId, userName, grantedAt, grantedBy }]
 */
export async function revokeExpiredScannerAccess(env) {
  try {
    const now = new Date().toISOString();

    // Find all events that have passed.
    // event.date is stored as a plain ISO string (stringValue), not a
    // Firestore timestamp — createEventSchema validates it as a string and
    // it's written through unconverted. String comparison still works
    // correctly for ISO 8601 dates since lexicographic order matches
    // chronological order.
    const events = await queryDocuments(env, 'events', [
      { field: 'date', op: 'LESS_THAN', value: { stringValue: now } }
    ]);

    if (!events || events.length === 0) {
      console.log('No past events found');
      return { success: true, revokedCount: 0, processedEvents: 0 };
    }

    let revokedCount = 0;
    let processedEvents = 0;

    for (const event of events) {
      // Check if event has any scanners
      if (!event.scanners || event.scanners.length === 0) {
        continue;
      }

      // Clear the scanners array for this past event
      const scannerCount = event.scanners.length;

      await setDocument(env, 'events', event.id, {
        ...event,
        scanners: []
      });

      // Log audit trail for each revoked scanner
      for (const scanner of event.scanners) {
        await logAudit(env, 'auto_revoke_scanner_access', 'system', `${event.id}-${scanner.userId}`, {
          eventId: event.id,
          eventTitle: event.title,
          userId: scanner.userId,
          userName: scanner.userName,
          originallyGrantedAt: scanner.grantedAt,
          originallyGrantedBy: scanner.grantedBy
        });
      }

      revokedCount += scannerCount;
      processedEvents++;
    }

    console.log(`Auto-revoked ${revokedCount} scanner access grants from ${processedEvents} past events`);
    return { success: true, revokedCount, processedEvents };

  } catch (error) {
    console.error('Auto-revoke error:', error);
    throw error;
  }
}
show all 69 linesshow less

Results