← Every release

GFG RKGIT platform

Live site: gfg.rkgit.in ↗

The GFG Campus Body RKGIT is our college coding club. This platform is how we manage the whole club and every event we run: registrations, attendance, the team and its members. 300+ students have signed up on it through our events. 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.

What it does

Public website
gfg.rkgit.in. Anyone can see upcoming events, register for one, send feedback, apply to join the team, and browse the current team and alumni.
Admin portal
For the core team. Create and edit events, manage members, and give volunteers scanner access to check people in at an event.
Team portal
For team members. Each member keeps their own profile up to date: photo, bio, skills and links.

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

Key decisions

01 Admin requests prove who you are

Three apps share one database, and the students running them change every year. So every admin request carries a Firebase ID token, and the Worker checks it before it does anything else. Google's signing keys are cached in KV, so the check does not call Google on every request.

See the codeHide the code gfg-rkgit-worker · src/middleware/auth.js · 43 lines
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

02 Firestore rules, a second lock behind the API

If the Worker ever has a bug, the database should still say no. The Firestore rules repeat the important checks: only an active admin can manage data, and a team member can change only their own bio, skills and links, never their role. Keeping two layers in step is extra work, but one mistake no longer opens everything.

See the codeHide the code gfg-rkgit-infra · firestore.rules · 61 lines
// 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
  }
}

03 Access that expires on its own

Volunteers get scanner access to check people in at an event, and nobody remembers to take it back. A cron job on the same Worker clears scanner access for every past event each day, and writes an audit entry for each one it removes.

See the codeHide the code gfg-rkgit-worker · src/scheduled/revoke-scanner-access.js · 69 lines
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;
  }
}

What I'd do differently

Results