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
cron: graduates archived every 1 June
The code
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 helpersshow all 43 linesshow less
// 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
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
- Used by 300+ members of GFG Campus Body RKGIT
- Admin actions need a token and an active admin record