Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ac92f14
feat: add support for custom domain and managed certificate in deploy…
webmaxru Apr 1, 2026
98cc3b9
feat: add user-assigned managed identity for ACR pull and update role…
webmaxru Apr 1, 2026
8620d10
feat: add favicon assets and generation script
webmaxru Apr 1, 2026
bd2e967
Refactor code structure for improved readability and maintainability
webmaxru Apr 1, 2026
a77e8fe
feat: add support for custom domain configuration and ACR admin crede…
webmaxru Apr 1, 2026
f267f90
feat: update Open Graph and Twitter Card meta tags to support dynamic…
webmaxru Apr 1, 2026
26d42fe
feat: enhance custom domain handling with validation and dynamic site…
webmaxru Apr 1, 2026
c756d33
fix: correct frontend directory path in Dockerfile
webmaxru Apr 1, 2026
b6fb117
Update webapp/backend/src/server.js
webmaxru Apr 1, 2026
4dc269c
Update infra/webapp/main.bicep
webmaxru Apr 1, 2026
2390cab
Update webapp/backend/src/server.js
webmaxru Apr 1, 2026
9bad285
test: add progress spinner tests and fix CSS hidden attribute override
webmaxru Apr 1, 2026
6bd5b21
Update webapp/backend/src/server.js
webmaxru Apr 1, 2026
b5a497e
Update webapp/backend/tests/routes.test.js
webmaxru Apr 1, 2026
c6b755f
fix: update custom domain condition to check for DNS verification rea…
webmaxru Apr 1, 2026
c865eef
fix: handle comma-separated host headers in createApp function
webmaxru Apr 1, 2026
7f6b6ca
fix: align runtime.port with actual ephemeral port for correct URL re…
webmaxru Apr 1, 2026
c5b3158
Update webapp/backend/src/server.js
webmaxru Apr 1, 2026
032d1a3
fix: enable cross-origin access to static assets for social media cra…
webmaxru Apr 1, 2026
633ff6f
Merge branch 'webapp-custom-domain' of https://github.com/webmaxru/ag…
webmaxru Apr 1, 2026
3a7e63c
Update webapp/.env.example
webmaxru Apr 1, 2026
8a3be6b
fix: update root route to serve index.html and prevent direct serving…
webmaxru Apr 1, 2026
f464041
Merge branch 'webapp-custom-domain' of https://github.com/webmaxru/ag…
webmaxru Apr 1, 2026
0f8f160
fix: update Open Graph image for improved social media sharing
webmaxru Apr 1, 2026
9dd8e1b
Update webapp/backend/src/server.js
webmaxru Apr 1, 2026
3956c9a
Update infra/webapp/main.bicep
webmaxru Apr 2, 2026
629cebb
feat: add deployment instructions for webapp and update test environm…
webmaxru Apr 2, 2026
a2b6a0d
docs: update local webapp setup instructions for clarity and synchron…
webmaxru Apr 2, 2026
4b5e233
chore: remove .github instruction files to unblock fork PR eval
webmaxru Apr 2, 2026
ca78797
Update .github/workflows/webapp-cd.yml
webmaxru Apr 2, 2026
bccaf10
refactor: improve server closure handling in API route tests
webmaxru Apr 3, 2026
e95975d
Merge branch 'webapp-custom-domain' of https://github.com/webmaxru/ag…
webmaxru Apr 3, 2026
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
24 changes: 14 additions & 10 deletions .github/prompts/align-cli-webapp-reports.prompt.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,40 @@
---
description: Compare CLI visual readiness report with local webapp report for a given repo, identify differences in checks/rendering/scoring, and fix them
description: Compare CLI visual readiness report with local webapp report for a given repo, identify rendering/scoring differences, and fix them in the webapp only
---

You are debugging consistency between two readiness report outputs for the **AgentRC** project:

1. **CLI visual report** — generated by `npm run dev -- readiness --visual` from the repo root (produces an HTML file)
2. **Webapp report** — rendered by the local dev server at `http://localhost:3000/{owner}/{repo}`

Both should produce identical results for the same repository because they share the same core engine (`packages/core/src/services/readiness.ts`). In practice they can diverge due to rendering differences or scoring logic bugs.
Both should produce identical results for the same repository because they share the same core engine (`packages/core/src/services/readiness.ts`). In practice the webapp rendering can diverge from the CLI due to its independent rendering logic.

> **IMPORTANT CONSTRAINT**: The CLI report and `packages/core/` are the **source of truth**. All fixes MUST be made exclusively in the `webapp/` directory (frontend and/or backend). **Never modify files in `packages/core/` or `src/`** — if the webapp disagrees with the CLI, the webapp is wrong.

## Architecture Reference

### Shared Core (source of truth for checks)
### Shared Core (source of truth — DO NOT MODIFY)

- `packages/core/src/services/readiness.ts` — All criteria definitions, `countStatus()`, `buildCriteria()`, `buildExtras()`, pillar/level aggregation
- Criteria scopes: `repo` (always), `app` (per-application), `area` (only with `--per-area`)
- `countStatus()` **excludes** skipped checks from the denominator when computing pillar pass/total
- Extras (bonus checks) are **not scored** — they don't affect levels or totals

### CLI Rendering
### CLI Rendering (source of truth — DO NOT MODIFY)

- `packages/core/src/services/visualReport.ts` — Generates the standalone HTML report
- `calculateAiToolingData()` — Aggregates AI criteria across repos (counts all including skipped in the hero display)
- Total checks: `report.pillars.reduce((s, p) => s + p.total, 0)`
- Does **not** render bonus/extras section in HTML output

### Webapp Backend
### Webapp Backend (fix here if needed)

- `webapp/backend/src/services/scanner.js` — Clones repo, calls `runReadinessReport()` from `@agentrc/core`
- `webapp/backend/src/routes/scan.js` — `POST /api/scan` endpoint
- Returns the raw `ReadinessReport` JSON (same shape as CLI)
- Uses `@agentrc/core` as a `file:../../packages/core` dependency — always uses local source code

### Webapp Frontend
### Webapp Frontend (fix here if needed)

- `webapp/frontend/src/report.js` — Independent rendering implementation (NOT shared with CLI)
- `buildHero()` — Total from `report.pillars.reduce((s, p) => s + p.total, 0)`
Expand Down Expand Up @@ -122,12 +124,14 @@ Both should produce identical results for the same repository because they share

### Phase 4: Root Cause & Fix

> **Reminder**: The CLI and `packages/core/` are the source of truth. All fixes go in `webapp/` only.

10. For each difference found, classify as:
- **Rendering divergence** → Fix in either `visualReport.ts` (CLI) or `report.js` (webapp) to align
- **Scoring logic bug** → Fix in `readiness.ts` (core) which fixes both
- **Icon/label mapping gap** → Update the icon map in the affected renderer
- **Rendering divergence** → Fix in `webapp/frontend/src/report.js` to match CLI behavior
- **Scoring logic divergence** → Fix in `webapp/backend/` or `webapp/frontend/` to align with core's scoring
- **Icon/label mapping gap** → Update the icon/label map in `webapp/frontend/src/report.js`

11. Implement the fixes directly in the source files.
11. Implement the fixes directly in the `webapp/` source files. **Never edit** files in `packages/core/`, `src/`, or any other directory outside `webapp/`.

12. After fixing, restart the webapp dev server and re-run Phase 1-2 to verify alignment.

Expand Down
27 changes: 25 additions & 2 deletions .github/workflows/webapp-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ permissions:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/agentrc-webapp
RESOURCE_GROUP: agentrc-webapp-rg
RESOURCE_GROUP: agentrc-webapp
BICEP_FILE: infra/webapp/main.bicep

jobs:
Expand Down Expand Up @@ -133,14 +133,30 @@ jobs:
2>&1 || echo "Storage mount does not exist yet"
sleep 10

- name: Deploy infrastructure
- name: Deploy infrastructure (step 1 — app + domain registration)
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.RESOURCE_GROUP }}
template: ${{ env.BICEP_FILE }}
parameters: >
containerImageTag=${{ needs.build-push.outputs.image-tag }}
ghTokenForScan=${{ secrets.GH_TOKEN_FOR_SCAN }}
useAcrAdminCredentials=${{ vars.USE_ACR_ADMIN_CREDENTIALS == 'true' }}
customDomain=${{ vars.CUSTOM_DOMAIN || '' }}
customDomainCertReady=false

Comment thread
webmaxru marked this conversation as resolved.
- name: Deploy infrastructure (step 2 — bind managed certificate)
if: vars.CUSTOM_DOMAIN != '' && vars.CUSTOM_DOMAIN_CERT_READY == 'true'
Comment thread
webmaxru marked this conversation as resolved.
Outdated
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.RESOURCE_GROUP }}
template: ${{ env.BICEP_FILE }}
parameters: >
containerImageTag=${{ needs.build-push.outputs.image-tag }}
ghTokenForScan=${{ secrets.GH_TOKEN_FOR_SCAN }}
useAcrAdminCredentials=${{ vars.USE_ACR_ADMIN_CREDENTIALS == 'true' }}
customDomain=${{ vars.CUSTOM_DOMAIN || '' }}
customDomainCertReady=true

- name: Ensure image in ACR
run: |
Expand Down Expand Up @@ -176,3 +192,10 @@ jobs:
curl -sf "https://${APP_URL}/api/health" | grep -q '"ok"'
curl -sf "https://${APP_URL}/" | grep -q "AgentRC"
echo "Smoke tests passed!"

CUSTOM_DOMAIN="${{ vars.CUSTOM_DOMAIN }}"
if [ -n "$CUSTOM_DOMAIN" ] && [ "${{ vars.CUSTOM_DOMAIN_CERT_READY }}" = "true" ]; then
echo "Testing custom domain https://${CUSTOM_DOMAIN}"
curl -sf "https://${CUSTOM_DOMAIN}/api/health" | grep -q '"ok"'
echo "Custom domain smoke test passed!"
fi
2 changes: 1 addition & 1 deletion Dockerfile.webapp
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ COPY webapp/backend/package.json ./package.json
COPY --from=deps /app/webapp/backend/node_modules ./node_modules
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/webapp/backend/dist ./dist
COPY webapp/frontend/ /app/frontend/
COPY webapp/frontend/ /app/webapp/frontend/
ENV REPORTS_DIR=/app/data/reports
RUN mkdir -p /app/data \
&& chown -R node:node /app
Expand Down
73 changes: 61 additions & 12 deletions infra/webapp/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,15 @@ param ghTokenForScan string = ''
@allowed(['scale-to-zero', 'keep-warm'])
param containerStartupStrategy string = 'keep-warm'

@description('Custom domain (optional, leave empty to skip)')
@description('Custom domain (optional, leave empty to skip). Requires DNS to be configured first — see outputs.')
param customDomain string = ''

@description('Set to true only after DNS records (CNAME + TXT) are verified. First deploy with false to get verification ID.')
param customDomainCertReady bool = false

@description('Use ACR admin credentials instead of managed identity (set to true when the deploying SP lacks role-assignment write permissions)')
param useAcrAdminCredentials bool = false

@description('Tags for all resources')
param tags object = {}

Expand Down Expand Up @@ -102,6 +108,13 @@ resource fileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-0
}
}

// ===== User-Assigned Managed Identity (for ACR pull) =====
resource acrPullIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: '${namePrefix}-acr-pull'
location: location
tags: tags
}

// ===== Azure Container Registry =====
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: take(toLower(replace('${namePrefix}webapp', '-', '')), 50)
Expand All @@ -111,7 +124,7 @@ resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: 'Basic'
}
properties: {
adminUserEnabled: false
adminUserEnabled: useAcrAdminCredentials
}
}

Expand All @@ -129,15 +142,27 @@ resource envStorage 'Microsoft.App/managedEnvironments/storages@2024-03-01' = if
}
}

// ===== AcrPull Role Assignment (system-assigned managed identity) =====
// ===== Managed Certificate for Custom Domain =====
resource managedCert 'Microsoft.App/managedEnvironments/managedCertificates@2024-03-01' = if (!empty(customDomain) && customDomainCertReady) {
parent: containerAppsEnv
name: 'cert-${replace(customDomain, '.', '-')}'
location: location
tags: tags
properties: {
subjectName: customDomain
domainControlValidation: 'CNAME'
}
}

// ===== AcrPull Role Assignment (user-assigned managed identity) =====
@description('AcrPull built-in role')
var acrPullRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')

resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(acr.id, containerApp.id, acrPullRoleId)
resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useAcrAdminCredentials) {
name: guid(acr.id, acrPullIdentity.id, acrPullRoleId)
scope: acr
properties: {
principalId: containerApp.identity.principalId
principalId: acrPullIdentity.properties.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: acrPullRoleId
}
Expand All @@ -148,7 +173,10 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
location: location
tags: tags
identity: {
type: 'SystemAssigned'
type: 'UserAssigned'
userAssignedIdentities: {
'${acrPullIdentity.id}': {}
}
}
properties: {
managedEnvironmentId: containerAppsEnv.id
Expand All @@ -161,18 +189,30 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
customDomains: !empty(customDomain) ? [
{
name: customDomain
bindingType: 'SniEnabled'
bindingType: customDomainCertReady ? 'SniEnabled' : 'Disabled'
certificateId: customDomainCertReady ? managedCert.id : null
Comment thread
webmaxru marked this conversation as resolved.
Outdated
}
] : []
}
registries: [
registries: useAcrAdminCredentials ? [
{
server: acr.properties.loginServer
username: acr.listCredentials().username
passwordSecretRef: 'acr-password'
}
] : [
{
server: acr.properties.loginServer
identity: 'system'
identity: acrPullIdentity.id
}
]
secrets: concat(
[],
useAcrAdminCredentials ? [
{
name: 'acr-password'
value: acr.listCredentials().passwords[0].value
}
] : [],
!empty(ghTokenForScan) ? [
{
name: 'gh-token-for-scan'
Expand Down Expand Up @@ -214,6 +254,12 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
value: enableSharing ? '/app/data/reports' : ':memory:'
}
],
!empty(customDomain) ? [
{
name: 'CUSTOM_DOMAIN'
value: customDomain
}
] : [],
Comment thread
webmaxru marked this conversation as resolved.
!empty(ghTokenForScan) ? [
{
name: 'GH_TOKEN_FOR_SCAN'
Expand Down Expand Up @@ -277,7 +323,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
}
}
}
dependsOn: enableSharing ? [envStorage] : []
dependsOn: enableSharing ? [acrPullRoleAssignment, envStorage] : [acrPullRoleAssignment]
Comment thread
webmaxru marked this conversation as resolved.
Outdated
}

// ===== Outputs =====
Expand All @@ -295,3 +341,6 @@ output appInsightsConnectionString string = enableAppInsights ? appInsights!.pro

@description('Log Analytics Workspace ID')
output logAnalyticsWorkspaceId string = logAnalytics.id

@description('Custom domain verification ID (use as TXT record value for asuid.{subdomain})')
output domainVerificationId string = containerAppsEnv.properties.customDomainConfiguration.customDomainVerificationId
4 changes: 4 additions & 0 deletions infra/webapp/main.bicepparam
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ using './main.bicep'

param namePrefix = 'agentrc'
param containerImageTag = 'latest'
param useAcrAdminCredentials = false
param enableSharing = true
param enableAppInsights = true
param containerStartupStrategy = 'keep-warm'
// To bind a custom domain, set e.g. customDomain = 'agentrc.isainative.dev'
param customDomain = ''
param customDomainCertReady = false // Set to true after DNS CNAME + TXT records are verified
param tags = {
application: 'agentrc-webapp'
managedBy: 'bicep'
Expand Down
4 changes: 4 additions & 0 deletions webapp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ ENABLE_SHARING=true
# Report storage directory (use :memory: for in-memory storage in dev/tests)
REPORTS_DIR=:memory:

# Custom domain hostname for OG/Twitter meta tags (bare hostname, no protocol)
# Must match the domain configured in GitHub vars.CUSTOM_DOMAIN
Comment thread
webmaxru marked this conversation as resolved.
Outdated
CUSTOM_DOMAIN=

# Environment
NODE_ENV=development
60 changes: 53 additions & 7 deletions webapp/backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
* Express server factory and startup.
* createRuntime() → createApp(runtime) → listen
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { dirname, resolve, join } from "node:path";
import express from "express";
import cors from "cors";
import helmet from "helmet";
Expand All @@ -18,13 +19,37 @@ import { startStaleDirSweeper, stopStaleDirSweeper } from "./services/scanner.js

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
* Validate and normalise CUSTOM_DOMAIN to a bare hostname.
* Strips protocol, path, port, and whitespace. Throws on
* clearly-invalid values so misconfigurations surface at startup.
*/
function parseCustomDomain(raw) {
if (!raw) return "";
let host = raw.trim();
// Strip protocol prefix if provided (e.g. "https://example.com")
host = host.replace(/^https?:\/\//i, "");
// Strip path, query, fragment
host = host.split("/")[0].split("?")[0].split("#")[0];
// Strip port (e.g. "example.com:443")
host = host.replace(/:\d+$/, "");
Comment thread
webmaxru marked this conversation as resolved.
if (!host || /\s/.test(host) || !/\./.test(host)) {
throw new Error(
`Invalid CUSTOM_DOMAIN: "${raw}". Expected a bare hostname (e.g. "app.example.com").`
);
}
Comment thread
webmaxru marked this conversation as resolved.
Outdated
return host;
}

/** Load env vars and build computed runtime config. */
export function createRuntime() {
const port = parseInt(process.env.PORT || "3000", 10);
const githubToken = process.env.GH_TOKEN_FOR_SCAN || "";
const sharingEnabled = process.env.ENABLE_SHARING === "true";
const reportsDir = process.env.REPORTS_DIR || ":memory:";
const frontendPath = resolve(__dirname, "../../frontend");
const customDomain = parseCustomDomain(process.env.CUSTOM_DOMAIN);
const siteUrl = customDomain ? `https://${customDomain}` : "";
const appInsightsConnectionString =
process.env.APPLICATIONINSIGHTS_CONNECTION_STRING ||
process.env.PUBLIC_APPLICATIONINSIGHTS_CONNECTION_STRING ||
Expand All @@ -37,6 +62,7 @@ export function createRuntime() {
sharingEnabled,
reportsDir,
frontendPath,
siteUrl,
appInsightsConnectionString,
storage: createStorage(reportsDir),
cloneTimeoutMs: parseInt(process.env.SCAN_CLONE_TIMEOUT_MS || "60000", 10),
Expand Down Expand Up @@ -87,14 +113,34 @@ export function createApp(runtime) {
app.use("/api/scan", createScanRateLimiter(runtime), createScanRouter(runtime));
app.use("/api/report", createReportRateLimiter(runtime), createReportRouter(runtime));

// Static frontend files
// Read the raw index.html template once at startup.
// %SITE_URL% is replaced at request time so OG/Twitter tags always
// contain absolute URLs — even when CUSTOM_DOMAIN is not configured.
const rawIndexHtml = readFileSync(join(runtime.frontendPath, "index.html"), "utf-8");

// If a custom domain is configured, pre-render once (fast path).
// Otherwise, derive the base URL per-request from the Host header.
const preRenderedHtml = runtime.siteUrl
? rawIndexHtml.replaceAll("%SITE_URL%", runtime.siteUrl)
: null;
Comment thread
webmaxru marked this conversation as resolved.

function renderIndex(req) {
if (preRenderedHtml) return preRenderedHtml;
const baseUrl = `${req.protocol}://${req.get("host")}`;
Comment thread
webmaxru marked this conversation as resolved.
Outdated
return rawIndexHtml.replaceAll("%SITE_URL%", baseUrl);
}

// Serve processed index.html for root requests
app.get("/", (req, res) => {
res.type("html").send(renderIndex(req));
});

// Static frontend files (other assets)
app.use(express.static(runtime.frontendPath));

// SPA catch-all: serve index.html for non-API routes
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
res.sendFile("index.html", { root: runtime.frontendPath }, (err) => {
if (err) next(err);
});
// SPA catch-all: serve processed index.html for non-API routes
app.get(/^\/(?!api\/).*/, (req, res) => {
res.type("html").send(renderIndex(req));
});

// Error handling
Expand Down
Binary file added webapp/frontend/assets/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/frontend/assets/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/frontend/assets/favicon-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/frontend/assets/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/frontend/assets/favicon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/frontend/assets/favicon.ico
Binary file not shown.
Loading
Loading