PREVIEW · gfg-rkgit case study, new drawing← all roundsround 3current homeoption B homedrawings before/afterGFGNivaranPrometheusevery releasehow it's builtlink image

← Every release

GFG RKGIT platform

Live site: gfg.rkgit.in ↗

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: every app, the public site included, goes through one API that checks who you are before anything reaches the database.

How it works

Public sitegfg.rkgit.in · formsAdmin portalevents, membersTeam portalown profileFirebase Authsign-in → ID tokenWorker APIchecks token + roleCron triggersdaily · 1 JuneFirestoreevents, members, formsKVtoken keys, rate limitsR2event photossign informs, teamBearer ID tokenrole, datakeys, limitsphotosone door: every app goes through the Worker

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
Firestore rules, a second lock behind the API 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