Skip to content

Commit dfb3da2

Browse files
committed
feat: harden analysis and export pipeline
1 parent 1f69b6f commit dfb3da2

59 files changed

Lines changed: 136691 additions & 129333 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ jobs:
3030
cache: npm
3131
- run: npm ci
3232
- run: npm run lint
33+
- run: npm run typecheck
3334
- run: npm run test:unit
3435
- run: npm run build
3536
- run: npm run build:vercel
37+
38+
browser-smoke:
39+
runs-on: ubuntu-latest
40+
needs: visualizer
41+
steps:
42+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
43+
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
44+
with:
45+
node-version: '22'
46+
cache: npm
47+
- run: npm ci
48+
- run: npx playwright install --with-deps chromium
49+
- run: npm run test:e2e

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ UPSTASH_REDIS_REST_TOKEN="your-upstash-token"
145145
# Run the Vitest unit, security invariant, analyzer, and export-contract tests
146146
npm run test:unit
147147

148-
# Run Python core test suite (28 unit, parity, and conformance tests)
148+
# Run Python core test suite (30 unit, parity, and conformance tests)
149149
npm run test:python
150150

151151
# Run Playwright browser contracts (use REPODNA_E2E_PORT if port 3000 is busy)

app/api/analyze/route.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import { IngestionError, PUBLIC_REPOSITORY_INGESTION_LIMITS } from '../../lib/an
55
import { auth } from '../../lib/auth';
66
import { checkAnalysisRateLimit } from '../../lib/ratelimit';
77
import { createApiErrorResponse } from '../../lib/api-error';
8+
import { isJsonBodyTooLarge, readBoundedJson } from '../../lib/bounded-json';
89
import { validateRepoDNAProject } from '../../lib/schema/validator';
910
import { getGitHubAccessToken } from '../../lib/github-session';
1011
import { recordScannedPublicRepository } from '../../lib/stats/scanned-repositories';
1112

1213
export const dynamic = 'force-dynamic';
1314

15+
const NO_STORE_HEADERS = {
16+
'Cache-Control': 'no-store, private, max-age=0',
17+
'X-Content-Type-Options': 'nosniff',
18+
};
19+
1420
interface StructuredLog {
1521
requestId: string;
1622
timestamp: string;
@@ -235,7 +241,7 @@ async function handleAnalyze(url: string | null, method: string, request: NextRe
235241
failureCategory: null,
236242
});
237243

238-
return NextResponse.json({ success: true, project }, { status: 200 });
244+
return NextResponse.json({ success: true, project }, { status: 200, headers: NO_STORE_HEADERS });
239245
} catch (error: unknown) {
240246
const durationMs = Date.now() - startTime;
241247

@@ -286,13 +292,16 @@ async function handleAnalyze(url: string | null, method: string, request: NextRe
286292
export async function POST(request: NextRequest) {
287293
let url: string | null = null;
288294
try {
289-
const body = (await request.json()) as { url?: unknown; repo?: unknown };
295+
const body = await readBoundedJson<{ url?: unknown; repo?: unknown }>(request);
290296
if (typeof body?.url === 'string') {
291297
url = body.url;
292298
} else if (typeof body?.repo === 'string') {
293299
url = body.repo;
294300
}
295-
} catch {
301+
} catch (error) {
302+
if (isJsonBodyTooLarge(error)) {
303+
return createApiErrorResponse('PAYLOAD_TOO_LARGE', 'Request body exceeds the 16 KB limit.', 413);
304+
}
296305
return createApiErrorResponse(
297306
'MALFORMED_JSON',
298307
'Invalid JSON request body. Expected {"url": "https://github.com/owner/repo"}.',

app/api/auth/revoke/route.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import { revokeGitHubAccessToken } from '../../../lib/github-oauth';
44
import { getGitHubAccessToken } from '../../../lib/github-session';
55

66
export const dynamic = 'force-dynamic';
7+
const NO_STORE_HEADERS = { 'Cache-Control': 'no-store, private, max-age=0', 'X-Content-Type-Options': 'nosniff' };
78

89
export async function POST(request: NextRequest) {
910
try {
1011
const session = await auth();
1112
const accessToken = await getGitHubAccessToken(request);
1213
if (!session?.user || !accessToken) {
13-
return NextResponse.json({ success: false, message: 'Not authenticated' }, { status: 401 });
14+
return NextResponse.json({ success: false, message: 'Not authenticated' }, { status: 401, headers: NO_STORE_HEADERS });
1415
}
1516

1617
const controller = new AbortController();
@@ -32,17 +33,20 @@ export async function POST(request: NextRequest) {
3233
? 'GitHub revocation is not configured correctly.'
3334
: 'GitHub did not confirm token revocation. Your local session remains active.',
3435
},
35-
{ status }
36+
{ status, headers: NO_STORE_HEADERS }
3637
);
3738
}
3839

39-
return NextResponse.json({
40-
success: true,
41-
message: result.alreadyRevoked
42-
? 'GitHub access was already revoked.'
43-
: 'GitHub access token revoked successfully.',
44-
githubSettingsUrl: 'https://github.com/settings/installations',
45-
});
40+
return NextResponse.json(
41+
{
42+
success: true,
43+
message: result.alreadyRevoked
44+
? 'GitHub access was already revoked.'
45+
: 'GitHub access token revoked successfully.',
46+
githubSettingsUrl: 'https://github.com/settings/installations',
47+
},
48+
{ headers: NO_STORE_HEADERS }
49+
);
4650
} catch (err: unknown) {
4751
const timedOut = err instanceof Error && err.name === 'AbortError';
4852
return NextResponse.json(
@@ -52,7 +56,7 @@ export async function POST(request: NextRequest) {
5256
? 'GitHub revocation timed out. Your local session remains active.'
5357
: 'GitHub revocation failed. Your local session remains active.',
5458
},
55-
{ status: timedOut ? 504 : 502 }
59+
{ status: timedOut ? 504 : 502, headers: NO_STORE_HEADERS }
5660
);
5761
}
5862
}

app/api/feedback/route.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface FeedbackPayload {
1212
}
1313

1414
const MAX_FEEDBACK_BYTES = 16 * 1024;
15+
const NO_STORE_HEADERS = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
1516

1617
async function readBoundedJson<T>(request: NextRequest, maxBytes: number): Promise<T> {
1718
const contentLength = request.headers.get('content-length');
@@ -71,12 +72,12 @@ export async function POST(request: NextRequest) {
7172
if (err instanceof Error && err.message === 'PAYLOAD_TOO_LARGE') {
7273
return NextResponse.json(
7374
{ success: false, error: 'Request body exceeds 16 KB limit', requestId },
74-
{ status: 413 }
75+
{ status: 413, headers: NO_STORE_HEADERS }
7576
);
7677
}
7778
return NextResponse.json(
7879
{ success: false, error: 'Invalid or malformed JSON body.', requestId },
79-
{ status: 400 }
80+
{ status: 400, headers: NO_STORE_HEADERS }
8081
);
8182
}
8283

@@ -101,14 +102,16 @@ export async function POST(request: NextRequest) {
101102
if (!usefulnessScore) {
102103
return NextResponse.json(
103104
{ success: false, error: 'usefulnessScore must be an integer between 1 and 5', requestId },
104-
{ status: 400 }
105+
{ status: 400, headers: NO_STORE_HEADERS }
105106
);
106107
}
107108

108109
const entry = {
109110
requestId,
110111
timestamp: new Date().toISOString(),
111-
user: session?.user?.id || 'anonymous',
112+
user: session?.user?.id
113+
? crypto.createHash('sha256').update(session.user.id).digest('hex').slice(0, 16)
114+
: 'anonymous',
112115
usefulnessScore,
113116
primaryUsecase,
114117
missingCapabilities,
@@ -118,11 +121,14 @@ export async function POST(request: NextRequest) {
118121

119122
console.log(`[RepoDNA:Feedback] ${JSON.stringify(entry)}`);
120123

121-
return NextResponse.json({ success: true, message: 'Thank you for your feedback!', requestId });
124+
return NextResponse.json(
125+
{ success: true, message: 'Thank you for your feedback!', requestId },
126+
{ headers: NO_STORE_HEADERS }
127+
);
122128
} catch {
123129
return NextResponse.json(
124130
{ success: false, error: 'Failed to process feedback submission.', requestId },
125-
{ status: 400 }
131+
{ status: 400, headers: NO_STORE_HEADERS }
126132
);
127133
}
128134
}

app/api/internal/cleanup-artifacts/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const runtime = 'nodejs';
88

99
const MAX_BLOBS_PER_RUN = 5000;
1010
const BATCH_SIZE = 100;
11+
const NO_STORE_HEADERS = {
12+
'Cache-Control': 'no-store, private, max-age=0',
13+
'X-Content-Type-Options': 'nosniff',
14+
};
1115

1216
export async function GET(request: NextRequest) {
1317
return handleCleanup(request);
@@ -20,13 +24,13 @@ export async function POST(request: NextRequest) {
2024
async function handleCleanup(request: NextRequest): Promise<NextResponse> {
2125
const cronSecret = process.env.CRON_SECRET;
2226
if (!cronSecret) {
23-
return NextResponse.json({ code: 'CRON_SECRET_NOT_CONFIGURED', message: 'Cron secret is not configured.' }, { status: 503 });
27+
return NextResponse.json({ code: 'CRON_SECRET_NOT_CONFIGURED', message: 'Cron secret is not configured.' }, { status: 503, headers: NO_STORE_HEADERS });
2428
}
2529

2630
const authHeader = request.headers.get('authorization');
2731
const expected = `Bearer ${cronSecret}`;
2832
if (authHeader !== expected) {
29-
return NextResponse.json({ code: 'UNAUTHORIZED', message: 'Invalid cron token.' }, { status: 401 });
33+
return NextResponse.json({ code: 'UNAUTHORIZED', message: 'Invalid cron token.' }, { status: 401, headers: NO_STORE_HEADERS });
3034
}
3135

3236
try {
@@ -90,8 +94,8 @@ async function handleCleanup(request: NextRequest): Promise<NextResponse> {
9094
deletedTotal: deletedCanonical + deletedExports,
9195
failedDeletions,
9296
failedBatches,
93-
});
97+
}, { headers: NO_STORE_HEADERS });
9498
} catch {
95-
return NextResponse.json({ code: 'CLEANUP_FAILED', message: 'Cleanup failed.' }, { status: 500 });
99+
return NextResponse.json({ code: 'CLEANUP_FAILED', message: 'Cleanup failed.' }, { status: 500, headers: NO_STORE_HEADERS });
96100
}
97101
}

app/api/v2/analyses/[runId]/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { PublicAnalysisWorkflowResult } from '../../../../workflows/analyze
66

77
export const dynamic = 'force-dynamic';
88

9+
const NO_STORE_HEADERS = {
10+
'Cache-Control': 'no-store, private, max-age=0',
11+
'X-Content-Type-Options': 'nosniff',
12+
};
13+
914
export async function GET(_request: Request, context: { params: Promise<{ runId: string }> }) {
1015
const { runId } = await context.params;
1116

@@ -46,7 +51,7 @@ export async function GET(_request: Request, context: { params: Promise<{ runId:
4651
: status === 'cancelled'
4752
? { code: 'WORKFLOW_CANCELLED', message: 'The durable analysis was cancelled.' }
4853
: null,
49-
});
54+
}, { headers: NO_STORE_HEADERS });
5055
} catch {
5156
return createApiErrorResponse('RUN_NOT_FOUND', 'Unknown or expired analysis run.', 404);
5257
}

app/api/v2/analyses/route.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { getRun, start } from 'workflow/api';
33

44
import { createApiErrorResponse } from '../../../lib/api-error';
5+
import { isJsonBodyTooLarge, readBoundedJson } from '../../../lib/bounded-json';
56
import { parseGitHubUrl } from '../../../lib/analyzer';
67
import { isPublicArtifactCacheConfigured } from '../../../lib/analyzer/v2/artifact-cache';
78
import { auth } from '../../../lib/auth';
@@ -13,6 +14,11 @@ import {
1314

1415
export const dynamic = 'force-dynamic';
1516

17+
const NO_STORE_HEADERS = {
18+
'Cache-Control': 'no-store, private, max-age=0',
19+
'X-Content-Type-Options': 'nosniff',
20+
};
21+
1622
async function resolveCommitSha(owner: string, name: string): Promise<string | null> {
1723
try {
1824
const response = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/HEAD`, {
@@ -103,8 +109,11 @@ export async function POST(request: NextRequest) {
103109

104110
let bodyUrl: unknown;
105111
try {
106-
bodyUrl = ((await request.json()) as { url?: unknown })?.url;
107-
} catch {
112+
bodyUrl = (await readBoundedJson<{ url?: unknown }>(request))?.url;
113+
} catch (error) {
114+
if (isJsonBodyTooLarge(error)) {
115+
return createApiErrorResponse('PAYLOAD_TOO_LARGE', 'Request body exceeds the 16 KB limit.', 413);
116+
}
108117
return createApiErrorResponse('INVALID_REQUEST', 'Body must be JSON with a "url" field.', 400);
109118
}
110119
if (typeof bodyUrl !== 'string' || !bodyUrl.trim()) {
@@ -157,7 +166,7 @@ export async function POST(request: NextRequest) {
157166
commitSha,
158167
...runEndpoints(run.runId),
159168
},
160-
{ status: 202 }
169+
{ status: 202, headers: NO_STORE_HEADERS }
161170
);
162171
} catch (error) {
163172
console.error('[RepoDNA:WorkflowStartFailed]', error);
@@ -178,7 +187,7 @@ export async function GET(request: NextRequest) {
178187
try {
179188
const status = await runStatus(runId);
180189
return status
181-
? NextResponse.json(status)
190+
? NextResponse.json(status, { headers: NO_STORE_HEADERS })
182191
: createApiErrorResponse('RUN_NOT_FOUND', 'Unknown or expired analysis run.', 404);
183192
} catch {
184193
return createApiErrorResponse('RUN_NOT_FOUND', 'Unknown or expired analysis run.', 404);

0 commit comments

Comments
 (0)