Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
name: Read Debug Temp Collection (one-off, read-only)
name: Drop Debug Temp Collection (one-off, cleanup)

# Manual, one-off read for the 2026-09-25 live-bug investigation.
# Dumps the throwaway debug_temp_2026_09_25 collection written by
# temporary instrumentation in programScope.ts. Read-only, no writes.
# One-time cleanup for the 2026-09-25 live-bug investigation. Drops
# the throwaway debug_temp_2026_09_25 collection. Safe/idempotent.

on:
workflow_dispatch:
inputs:
userId:
description: "Optional userId to filter debug entries by"
required: false
default: ""
type: string

env:
INFISICAL_ENVIRONMENT: prod
INFISICAL_SECRET_PATH: /

jobs:
read:
name: SSH Read Debug Data
cleanup:
name: SSH Drop Collection
runs-on: ubuntu-latest
environment: production

Expand All @@ -31,12 +24,9 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.CSFAQ_HOST }} >> ~/.ssh/known_hosts

- name: Read debug data
env:
FILTER_USER_ID: ${{ inputs.userId }}
- name: Drop collection
run: |
FILTER_B64=$(printf '%s' "$FILTER_USER_ID" | base64)
ssh -i ~/.ssh/id_ed25519 csfaq-deploy@${{ secrets.CSFAQ_HOST }} "FILTER_B64='${FILTER_B64}' bash" << 'READ'
ssh -i ~/.ssh/id_ed25519 csfaq-deploy@${{ secrets.CSFAQ_HOST }} bash << 'CLEANUP'
set -e

cd /home/csfaq-prod/crowd-source-faq
Expand Down Expand Up @@ -72,9 +62,8 @@ jobs:
cd apps/backend
pnpm install --frozen-lockfile

FILTER_USER_ID=$(printf '%s' "$FILTER_B64" | base64 -d)
echo "==> Reading debug_temp_2026_09_25 (filter: ${FILTER_USER_ID:-none})..."
npx tsx src/scripts/read-debug-temp.ts "$FILTER_USER_ID"
echo "==> Dropping debug_temp_2026_09_25..."
npx tsx src/scripts/drop-debug-temp-collection.ts

echo "==> Done. Read-only, no writes made."
READ
echo "==> Done."
CLEANUP
34 changes: 0 additions & 34 deletions .github/workflows/tail-csfaq-logs.yml

This file was deleted.

17 changes: 16 additions & 1 deletion apps/backend/src/bootstrap/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@ import helmet from 'helmet';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import connectDB from '../config/db.js';
import { runWithContext } from '../utils/http/requestContext.js';
import { requestLogger } from '../utils/http/requestLogger.js';
import { ingestFrontendLog } from '../utils/http/fileLogger.js';
import { publicBasePath } from '../utils/publicBasePath.js';
import { programScope } from '../middleware/programScope.js';

// CodeQL fix (js/missing-rate-limiting): this middleware runs a DB
// lookup (Batch.findById) for every request carrying a batchId, on
// EVERY route in the app — with no rate limit at all before this fix.
// Generous cap (this is a global, cross-route gate, not a single
// endpoint's own limiter) so normal multi-tab/shared-IP browsing never
// trips it, while still bounding a scripted flood.
const globalProgramScopeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 600,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests. Please slow down.' },
});

// Absolute path to the `uploads/` directory.
// Resolved from the source file location so it works regardless of
// process.cwd() — which may not be the backend root (e.g. on a
Expand Down Expand Up @@ -152,7 +167,7 @@ export function registerMiddleware(app: Express, config: any): void {
app.use(express.json());

// 7.5. Global Program Scoping (soft)
app.use(programScope({ required: false }));
app.use(globalProgramScopeLimiter, programScope({ required: false }));

// 8. Minimal Cookie parser
app.use((req: Request, _res: Response, next: (e?: unknown) => void) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* programScope-double-invocation.test — 2026-09-25 incident.
*
* Root cause of a real production bug: `programScope()` is mounted
* BOTH globally (bootstrap/middleware.ts, before any route-specific
* auth middleware runs) AND again per-route after `optionalAuth`/
* `protect`. The global pass runs with no `req.user` yet, so it could
* resolve `req.programContext` but never had a user to attach
* `req.programEnrollment` for. The old "already attached, skip" early
* return then made the per-route pass — the only one that ever has a
* real user — skip re-running entirely, so req.programEnrollment
* never got attached even for a genuinely, correctly enrolled user.
*
* This test reproduces exactly that: call programScope() once with no
* req.user (simulating the global pre-auth pass), then call it again
* on the SAME req object after attaching req.user (simulating the
* per-route post-auth pass) — req.programEnrollment must end up set.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import mongoose, { Types } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { Request, Response, NextFunction } from 'express';

let mongo: MongoMemoryServer;

beforeAll(async () => {
mongo = await MongoMemoryServer.create();
await mongoose.connect(mongo.getUri());
}, 120_000);

afterAll(async () => {
await mongoose.disconnect();
await mongo.stop();
});

beforeEach(async () => {
const db = mongoose.connection.db;
if (!db) throw new Error('no db');
await db.collection('yaksha_faq_batches').deleteMany({});
await db.collection('yaksha_program_enrollments').deleteMany({});
});

const { default: Batch } = await import('../../modules/program/batch.model.js');
const { default: ProgramEnrollment } = await import('../../modules/program/program-enrollment.model.js');
const { programScope } = await import('../programScope.js');

describe('programScope — global-then-per-route double invocation', () => {
it('attaches programEnrollment on the SECOND call once req.user is available, even though programContext was already resolved by the first (userless) call', async () => {
const batch = await Batch.create({
name: 'summership',
description: '',
startDate: new Date('2026-05-15'),
endDate: new Date('2026-12-31'),
isActive: true,
status: 'active',
});
const userId = new Types.ObjectId();
await ProgramEnrollment.create({
userId,
batchId: batch._id,
programRole: 'student',
isActive: true,
});

const req = {
query: { batchId: String(batch._id) },
params: {},
body: {},
headers: {},
} as unknown as Request;
const res = { status: () => res, json: () => res } as unknown as Response;
const next1: NextFunction = (() => {}) as NextFunction;

// Pass 1: simulates the global mount — runs BEFORE auth, no req.user.
await programScope()(req, res, next1);
expect(req.programContext?.batchId).toBe(String(batch._id));
expect(req.programEnrollment).toBeUndefined();

// Auth middleware would run here in the real chain and set req.user.
(req as unknown as { user?: unknown }).user = { _id: userId, role: 'user' };

// Pass 2: simulates the per-route mount — runs AFTER auth, on the
// SAME req object, so req.programContext is already set.
const next2: NextFunction = (() => {}) as NextFunction;
await programScope()(req, res, next2);

expect(req.programEnrollment).toBeDefined();
expect(req.programEnrollment?.userId).toBe(String(userId));
expect(req.programEnrollment?.batchId).toBe(String(batch._id));
});
});
Loading
Loading