diff --git a/.github/prompts/align-cli-webapp-reports.prompt.md b/.github/prompts/align-cli-webapp-reports.prompt.md index aefaa05..6012df4 100644 --- a/.github/prompts/align-cli-webapp-reports.prompt.md +++ b/.github/prompts/align-cli-webapp-reports.prompt.md @@ -1,5 +1,5 @@ --- -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: @@ -7,32 +7,34 @@ You are debugging consistency between two readiness report outputs for the **Age 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)` @@ -49,9 +51,23 @@ Both should produce identical results for the same repository because they share ## Step-by-Step Procedure -### Phase 0: Start Local Webapp +### Phase 0: Sync & Start Local Webapp + +1. Pull the latest changes from upstream to ensure you're working against the current source of truth: -1. Start the webapp backend dev server (from the repo root): + ``` + git fetch upstream && git merge upstream/main + ``` + + If the workspace uses a different default branch or remote name, adjust accordingly. Resolve any merge conflicts before proceeding. + +2. Install dependencies in case they changed: + + ``` + npm install + ``` + +3. Start the webapp backend dev server (from the repo root): ``` cd webapp/backend && npm run dev @@ -59,14 +75,14 @@ Both should produce identical results for the same repository because they share This starts the Express server at `http://localhost:3000` with the local `@agentrc/core` source. -2. Optionally serve the frontend for full visual testing: +4. Optionally serve the frontend for full visual testing: ``` cd webapp/frontend && npx vite --port 5173 ``` ### Phase 1: Generate Both Reports -3. Run the CLI against the target repo to produce the visual HTML report: +5. Run the CLI against the target repo to produce the visual HTML report: ``` npm run dev -- readiness --visual --repo {owner}/{repo} @@ -74,7 +90,7 @@ Both should produce identical results for the same repository because they share Save the output HTML (typically `readiness-report.html`). -4. Hit the local webapp API to get the raw JSON: +6. Hit the local webapp API to get the raw JSON: ``` POST http://localhost:3000/api/scan @@ -87,13 +103,13 @@ Both should produce identical results for the same repository because they share $response = Invoke-RestMethod -Uri "http://localhost:3000/api/scan" -Method POST -ContentType "application/json" -Body '{"repo_url":"https://github.com/{owner}/{repo}"}' -TimeoutSec 120 ``` -5. Also open the local webapp page for visual comparison: `http://localhost:5173/{owner}/{repo}` (if frontend dev server is running) or `http://localhost:3000/{owner}/{repo}` (if backend serves static files). +7. Also open the local webapp page for visual comparison: `http://localhost:5173/{owner}/{repo}` (if frontend dev server is running) or `http://localhost:3000/{owner}/{repo}` (if backend serves static files). ### Phase 2: Compare Data Layer -6. Extract from CLI HTML: total checks, per-pillar passed/total, AI hero passed/total/percentage, criteria list with statuses, achieved level, fix-first items. +8. Extract from CLI HTML: total checks, per-pillar passed/total, AI hero passed/total/percentage, criteria list with statuses, achieved level, fix-first items. -7. Extract from webapp JSON: same fields. Use: +9. Extract from webapp JSON: same fields. Use: ```powershell $pillars = $response.pillars @@ -103,46 +119,50 @@ Both should produce identical results for the same repository because they share Write-Host "Criteria count: $($response.criteria.Count)" ``` -8. Diff the two — check for: - - **Missing criteria** in either side (criteria list length mismatch) - - **Status mismatches** for the same criterion ID - - **Total check count** differences (pillar aggregation) - - **AI Tooling hero** percentage/label differences - - **Achieved level** and next-level calculation differences - - **Fix-first** list ordering differences +10. Diff the two — check for: + +- **Missing criteria** in either side (criteria list length mismatch) +- **Status mismatches** for the same criterion ID +- **Total check count** differences (pillar aggregation) +- **AI Tooling hero** percentage/label differences +- **Achieved level** and next-level calculation differences +- **Fix-first** list ordering differences ### Phase 3: Compare Rendering Layer -9. Compare how both renderers handle: - - Skipped checks display (icon, text, inclusion in totals) - - Bonus/extras section presence - - Pillar grouping (repo-health vs ai-setup) - - AI criterion icons for new/unknown IDs - - Score thresholds for labels (Excellent/Good/Fair/Getting Started/Not Started) +11. Compare how both renderers handle: + +- Skipped checks display (icon, text, inclusion in totals) +- Bonus/extras section presence +- Pillar grouping (repo-health vs ai-setup) +- AI criterion icons for new/unknown IDs +- Score thresholds for labels (Excellent/Good/Fair/Getting Started/Not Started) ### Phase 4: Root Cause & Fix -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 +> **Reminder**: The CLI and `packages/core/` are the source of truth. All fixes go in `webapp/` only. + +12. For each difference found, classify as: + - **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. +13. 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. +14. After fixing, restart the webapp dev server and re-run Phase 1-2 to verify alignment. ### Phase 5: Validate -13. Confirm both reports show identical: +15. Confirm both reports show identical: - Total check count (e.g., "11 of 20") - Per-pillar passed/total - AI Tooling hero percentage and label - Achieved maturity level - Fix-first items (same set, same order) -14. Note any **acceptable differences** that are by-design (e.g., webapp shows bonus checks, CLI doesn't). +16. Note any **acceptable differences** that are by-design (e.g., webapp shows bonus checks, CLI doesn't). -15. Run existing tests to ensure no regressions: +17. Run existing tests to ensure no regressions: ``` npm test cd webapp/backend && npm test diff --git a/.github/workflows/webapp-cd.yml b/.github/workflows/webapp-cd.yml index 24c7f0c..aecbe12 100644 --- a/.github/workflows/webapp-cd.yml +++ b/.github/workflows/webapp-cd.yml @@ -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: @@ -133,7 +133,7 @@ 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 }} @@ -141,6 +141,22 @@ jobs: 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 + + - name: Deploy infrastructure (step 2 — bind managed certificate) + if: vars.CUSTOM_DOMAIN && vars.CUSTOM_DOMAIN_CERT_READY == 'true' + 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: | @@ -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 diff --git a/Dockerfile.webapp b/Dockerfile.webapp index 5844213..32e46f8 100644 --- a/Dockerfile.webapp +++ b/Dockerfile.webapp @@ -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 diff --git a/infra/webapp/main.bicep b/infra/webapp/main.bicep index 2137c6a..a9cf626 100644 --- a/infra/webapp/main.bicep +++ b/infra/webapp/main.bicep @@ -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 = {} @@ -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) @@ -111,7 +124,7 @@ resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { name: 'Basic' } properties: { - adminUserEnabled: false + adminUserEnabled: useAcrAdminCredentials } } @@ -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 } @@ -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 @@ -161,18 +189,32 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { customDomains: !empty(customDomain) ? [ { name: customDomain - bindingType: 'SniEnabled' + bindingType: customDomainCertReady ? 'SniEnabled' : 'Disabled' + ...(customDomainCertReady ? { + certificateId: managedCert.id + } : {}) } ] : [] } - 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' @@ -214,6 +256,12 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { value: enableSharing ? '/app/data/reports' : ':memory:' } ], + !empty(customDomain) && customDomainCertReady ? [ + { + name: 'CUSTOM_DOMAIN' + value: customDomain + } + ] : [], !empty(ghTokenForScan) ? [ { name: 'GH_TOKEN_FOR_SCAN' @@ -277,7 +325,9 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { } } } - dependsOn: enableSharing ? [envStorage] : [] + dependsOn: enableSharing + ? (useAcrAdminCredentials ? [envStorage] : [acrPullRoleAssignment, envStorage]) + : (useAcrAdminCredentials ? [] : [acrPullRoleAssignment]) } // ===== Outputs ===== @@ -295,3 +345,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 diff --git a/infra/webapp/main.bicepparam b/infra/webapp/main.bicepparam index 6eb0d31..237cb9f 100644 --- a/infra/webapp/main.bicepparam +++ b/infra/webapp/main.bicepparam @@ -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' diff --git a/webapp/.env.example b/webapp/.env.example index 74e1989..fcf7c28 100644 --- a/webapp/.env.example +++ b/webapp/.env.example @@ -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 (real DNS hostname with at least one dot, no protocol) +# Must match the domain configured in GitHub vars.CUSTOM_DOMAIN (e.g. app.example.com) +CUSTOM_DOMAIN= + # Environment NODE_ENV=development diff --git a/webapp/backend/src/server.js b/webapp/backend/src/server.js index 3f03d31..c8d7858 100644 --- a/webapp/backend/src/server.js +++ b/webapp/backend/src/server.js @@ -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"; @@ -18,6 +19,74 @@ 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+$/, ""); + + // Basic sanity checks before detailed hostname validation + if (!host || /\s/.test(host)) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Expected a bare hostname (e.g. "app.example.com").` + ); + } + + // Reject obvious delimiters / malformed forms: commas, leading/trailing dot, consecutive dots + if (host.includes(",") || host.startsWith(".") || host.endsWith(".") || host.includes("..")) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Expected a valid DNS hostname without commas or malformed dots.` + ); + } + + // Enforce overall hostname length (per RFC 1034/1035 recommendations) + if (host.length > 253) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Hostname is too long; must be 253 characters or fewer.` + ); + } + + const labels = host.split("."); + // Require at least one dot (i.e. two labels) + if (labels.length < 2) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Expected a bare hostname with at least one dot (e.g. "app.example.com").` + ); + } + + const labelRegex = /^[a-zA-Z0-9-]+$/; + for (const label of labels) { + // Each label must be non-empty and 1–63 characters + if (!label || label.length > 63) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Each hostname label must be between 1 and 63 characters.` + ); + } + // Only allow letters, digits, and hyphens (supports punycode "xn--" labels) + if (!labelRegex.test(label)) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Hostname labels may only contain letters, digits, and hyphens.` + ); + } + // Labels must not start or end with a hyphen + if (label.startsWith("-") || label.endsWith("-")) { + throw new Error( + `Invalid CUSTOM_DOMAIN: "${raw}". Hostname labels must not start or end with a hyphen.` + ); + } + } + return host; +} + /** Load env vars and build computed runtime config. */ export function createRuntime() { const port = parseInt(process.env.PORT || "3000", 10); @@ -25,6 +94,8 @@ export function createRuntime() { 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 || @@ -37,6 +108,7 @@ export function createRuntime() { sharingEnabled, reportsDir, frontendPath, + siteUrl, appInsightsConnectionString, storage: createStorage(reportsDir), cloneTimeoutMs: parseInt(process.env.SCAN_CLONE_TIMEOUT_MS || "60000", 10), @@ -87,14 +159,94 @@ export function createApp(runtime) { app.use("/api/scan", createScanRateLimiter(runtime), createScanRouter(runtime)); app.use("/api/report", createReportRateLimiter(runtime), createReportRouter(runtime)); - // Static frontend files - app.use(express.static(runtime.frontendPath)); + // 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"); - // 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); - }); + // 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; + + function renderIndex(req) { + if (preRenderedHtml) return preRenderedHtml; + + // Derive a safe base URL when CUSTOM_DOMAIN/runtime.siteUrl is not set. + // Prefer X-Forwarded-Host (for reverse proxies) and fall back to Host. + const allowedDevHosts = new Set(["localhost", "127.0.0.1", "::1"]); + const hostHeader = req.headers["x-forwarded-host"] || req.headers.host; + + let baseUrl; + + if (hostHeader) { + // Host header is typically "hostname" or "hostname:port". + // It may be comma-separated when multiple proxies are involved; + // take the first (client-facing) value. + const raw = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; + const host = raw.split(",")[0].trim(); + let hostname; + try { + // Use URL parsing to correctly handle IPv6, ports, etc. + const parsed = new URL(`${req.protocol}://${host}`); + hostname = parsed.hostname; + } catch { + // If the header is malformed, fall back to a safe default. + hostname = "localhost"; + } + + // Allow only safe hostname characters (alnum, dot, hyphen, colon for IPv6). + const safeHostnamePattern = /^[0-9A-Za-z.\-:]+$/; + if (!safeHostnamePattern.test(hostname)) { + hostname = "localhost"; + } + const isDevHost = allowedDevHosts.has(hostname); + const isAzureContainerApp = /\.azurecontainerapps\.io$/i.test(hostname); + // For IPv6 literals, URL.hostname omits brackets; add them back when + // constructing absolute URLs so they remain syntactically valid. + const needsIpv6Brackets = + hostname.includes(":") && !hostname.startsWith("[") && !hostname.endsWith("]"); + const formattedHostname = needsIpv6Brackets ? `[${hostname}]` : hostname; + + if (isDevHost) { + // In true local-dev, keep using the configured runtime.port. + baseUrl = `${req.protocol}://localhost:${runtime.port}`; + } else if (isAzureContainerApp) { + // For default Container Apps FQDNs, trust the hostname and omit port. + baseUrl = `${req.protocol}://${formattedHostname}`; + } else { + // For any other host, be conservative: use the hostname without an + // explicit port to avoid leaking internal ports in absolute URLs. + baseUrl = `${req.protocol}://${formattedHostname}`; + } + } else { + // Last-resort fallback when no host information is available. + baseUrl = `${req.protocol}://localhost:${runtime.port}`; + } + return rawIndexHtml.replaceAll("%SITE_URL%", baseUrl); + } + + // Serve processed index.html for root and /index.html requests + app.get(["/", "/index.html"], (req, res) => { + res.type("html").send(renderIndex(req)); + }); + + // Allow cross-origin access to static assets (OG images, favicons, manifest) + // so social-media crawlers and cross-origin embeds can load them. + app.use("/assets", (_req, res, next) => { + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + next(); + }); + + // Static frontend files (other assets). + // index: false prevents express.static from serving /index.html directly, + // ensuring all HTML responses go through renderIndex() with replaced placeholders. + app.use(express.static(runtime.frontendPath, { index: false })); + + // SPA catch-all: serve processed index.html for non-API routes + app.get(/^\/(?!api\/).*/, (req, res) => { + res.type("html").send(renderIndex(req)); }); // Error handling diff --git a/webapp/backend/tests/routes.test.js b/webapp/backend/tests/routes.test.js index bfe302a..dd73033 100644 --- a/webapp/backend/tests/routes.test.js +++ b/webapp/backend/tests/routes.test.js @@ -48,13 +48,26 @@ function listen(app) { }); } +/** Gracefully close an HTTP server, resolving once the underlying handle is freed. */ +function closeServer(s) { + return new Promise((resolve, reject) => { + if (!s) return resolve(); + s.close((err) => (err ? reject(err) : resolve())); + }); +} + describe("API routes", () => { let app; let runtime; let base; let server; + const savedCustomDomain = process.env.CUSTOM_DOMAIN; beforeEach(async () => { + // Clear CUSTOM_DOMAIN so createRuntime()'s parseCustomDomain validation + // never throws due to host-environment values (e.g. "localhost"). + delete process.env.CUSTOM_DOMAIN; + runtime = { ...createRuntime(), githubToken: "", @@ -75,7 +88,15 @@ describe("API routes", () => { ({ base, server } = await listen(app)); }); - afterEach(() => server?.close()); + afterEach(async () => { + await closeServer(server); + // Restore original CUSTOM_DOMAIN so we don't leak state to other suites. + if (savedCustomDomain !== undefined) { + process.env.CUSTOM_DOMAIN = savedCustomDomain; + } else { + delete process.env.CUSTOM_DOMAIN; + } + }); describe("GET /api/health", () => { it("returns ok status", async () => { @@ -171,7 +192,7 @@ describe("API routes", () => { }); it("returns 503 when sharing is disabled", async () => { - server?.close(); + await closeServer(server); runtime.sharingEnabled = false; app = createApp(runtime); ({ base, server } = await listen(app)); @@ -220,4 +241,67 @@ describe("API routes", () => { expect(getBody.achievedLevel).toBe(0); }); }); + + describe("index.html templating (%SITE_URL% replacement)", () => { + it("replaces %SITE_URL% with runtime.siteUrl when CUSTOM_DOMAIN is configured", async () => { + await closeServer(server); + runtime.siteUrl = "https://app.example.com"; + app = createApp(runtime); + ({ base, server } = await listen(app)); + + const res = await fetch(`${base}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('content="https://app.example.com"'); + expect(html).toContain('content="https://app.example.com/assets/og-image.jpg"'); + expect(html).not.toContain("%SITE_URL%"); + }); + + it("derives %SITE_URL% from the request host when siteUrl is empty", async () => { + await closeServer(server); + runtime.siteUrl = ""; + app = createApp(runtime); + ({ base, server } = await listen(app)); + // Align runtime.port with the actual ephemeral port so renderIndex + // produces a URL that matches the real server origin. + runtime.port = server.address().port; + + const res = await fetch(`${base}/`); + expect(res.status).toBe(200); + const html = await res.text(); + // Should derive from localhost + the actual bound port + expect(html).toContain(`http://localhost:${runtime.port}`); + expect(html).not.toContain("%SITE_URL%"); + }); + + it("replaces %SITE_URL% on SPA catch-all routes", async () => { + await closeServer(server); + runtime.siteUrl = "https://spa.example.com"; + app = createApp(runtime); + ({ base, server } = await listen(app)); + + const res = await fetch(`${base}/some/spa/route`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('content="https://spa.example.com"'); + expect(html).toContain('content="https://spa.example.com/assets/og-image.jpg"'); + expect(html).not.toContain("%SITE_URL%"); + }); + + it("derives %SITE_URL% from request host on SPA catch-all when siteUrl is empty", async () => { + await closeServer(server); + runtime.siteUrl = ""; + app = createApp(runtime); + ({ base, server } = await listen(app)); + // Align runtime.port with the actual ephemeral port so renderIndex + // produces a URL that matches the real server origin. + runtime.port = server.address().port; + + const res = await fetch(`${base}/report/abc`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(`http://localhost:${runtime.port}`); + expect(html).not.toContain("%SITE_URL%"); + }); + }); }); diff --git a/webapp/frontend/assets/apple-touch-icon.png b/webapp/frontend/assets/apple-touch-icon.png new file mode 100644 index 0000000..3c461b8 Binary files /dev/null and b/webapp/frontend/assets/apple-touch-icon.png differ diff --git a/webapp/frontend/assets/favicon-16x16.png b/webapp/frontend/assets/favicon-16x16.png new file mode 100644 index 0000000..33a2c85 Binary files /dev/null and b/webapp/frontend/assets/favicon-16x16.png differ diff --git a/webapp/frontend/assets/favicon-192x192.png b/webapp/frontend/assets/favicon-192x192.png new file mode 100644 index 0000000..cf0a392 Binary files /dev/null and b/webapp/frontend/assets/favicon-192x192.png differ diff --git a/webapp/frontend/assets/favicon-32x32.png b/webapp/frontend/assets/favicon-32x32.png new file mode 100644 index 0000000..e10e593 Binary files /dev/null and b/webapp/frontend/assets/favicon-32x32.png differ diff --git a/webapp/frontend/assets/favicon-512x512.png b/webapp/frontend/assets/favicon-512x512.png new file mode 100644 index 0000000..51aaab1 Binary files /dev/null and b/webapp/frontend/assets/favicon-512x512.png differ diff --git a/webapp/frontend/assets/favicon.ico b/webapp/frontend/assets/favicon.ico new file mode 100644 index 0000000..b27eca8 Binary files /dev/null and b/webapp/frontend/assets/favicon.ico differ diff --git a/webapp/frontend/assets/favicon.svg b/webapp/frontend/assets/favicon.svg new file mode 100644 index 0000000..7f1689c --- /dev/null +++ b/webapp/frontend/assets/favicon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/frontend/assets/og-image.jpg b/webapp/frontend/assets/og-image.jpg new file mode 100644 index 0000000..ab73445 Binary files /dev/null and b/webapp/frontend/assets/og-image.jpg differ diff --git a/webapp/frontend/assets/site.webmanifest b/webapp/frontend/assets/site.webmanifest new file mode 100644 index 0000000..fe22f74 --- /dev/null +++ b/webapp/frontend/assets/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "AgentRC", + "short_name": "AgentRC", + "description": "AI Readiness Scanner — Context engineering for AI coding agents", + "start_url": "/", + "display": "standalone", + "background_color": "#0d1117", + "theme_color": "#0d1117", + "icons": [ + { + "src": "/assets/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html index 172f871..a09ae63 100644 --- a/webapp/frontend/index.html +++ b/webapp/frontend/index.html @@ -5,10 +5,31 @@ AgentRC — AI Readiness Scanner + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/frontend/package-lock.json b/webapp/frontend/package-lock.json index 875f0f8..966a81e 100644 --- a/webapp/frontend/package-lock.json +++ b/webapp/frontend/package-lock.json @@ -8,9 +8,21 @@ "name": "@agentrc/webapp-frontend", "version": "1.0.0", "devDependencies": { + "sharp": "^0.34.5", "vitest": "^3.1.1" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -453,6 +465,496 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1025,6 +1527,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1288,6 +1800,64 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1393,6 +1963,14 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/webapp/frontend/package.json b/webapp/frontend/package.json index 7de7161..d5ee5c6 100644 --- a/webapp/frontend/package.json +++ b/webapp/frontend/package.json @@ -7,6 +7,7 @@ "test": "vitest run" }, "devDependencies": { + "sharp": "^0.34.5", "vitest": "^3.1.1" } } diff --git a/webapp/frontend/scripts/generate-favicons.mjs b/webapp/frontend/scripts/generate-favicons.mjs new file mode 100644 index 0000000..223eb40 --- /dev/null +++ b/webapp/frontend/scripts/generate-favicons.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generate favicon PNGs from the SVG source. + * Run: node scripts/generate-favicons.mjs + * Requires: sharp (npm i -D sharp) + */ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const assetsDir = join(__dirname, "..", "assets"); +const svgPath = join(assetsDir, "favicon.svg"); + +const sizes = [ + { name: "favicon-16x16.png", size: 16 }, + { name: "favicon-32x32.png", size: 32 }, + { name: "apple-touch-icon.png", size: 180 }, + { name: "favicon-192x192.png", size: 192 }, + { name: "favicon-512x512.png", size: 512 }, +]; + +const svgBuf = await readFile(svgPath); + +for (const { name, size } of sizes) { + await sharp(svgBuf).resize(size, size).png().toFile(join(assetsDir, name)); + console.log(`✓ ${name} (${size}×${size})`); +} + +// Generate ICO (32×32 PNG wrapped in ICO container) +const png32 = await sharp(svgBuf).resize(32, 32).png().toBuffer(); +const ico = createIco(png32, 32, 32); +await writeFile(join(assetsDir, "favicon.ico"), ico); +console.log("✓ favicon.ico (32×32)"); + +/** + * Minimal ICO file from a single 32-bit PNG buffer. + * ICO = ICONDIR + ICONDIRENTRY + PNG data + */ +function createIco(pngBuffer, width, height) { + const dirSize = 6 + 16; // ICONDIR (6) + 1 × ICONDIRENTRY (16) + const buf = Buffer.alloc(dirSize + pngBuffer.length); + + // ICONDIR + buf.writeUInt16LE(0, 0); // reserved + buf.writeUInt16LE(1, 2); // type = ICO + buf.writeUInt16LE(1, 4); // count = 1 + + // ICONDIRENTRY + buf.writeUInt8(width >= 256 ? 0 : width, 6); + buf.writeUInt8(height >= 256 ? 0 : height, 7); + buf.writeUInt8(0, 8); // color palette + buf.writeUInt8(0, 9); // reserved + buf.writeUInt16LE(1, 10); // color planes + buf.writeUInt16LE(32, 12); // bits per pixel + buf.writeUInt32LE(pngBuffer.length, 14); // image size + buf.writeUInt32LE(dirSize, 18); // offset to image data + + pngBuffer.copy(buf, dirSize); + return buf; +} diff --git a/webapp/frontend/src/main.css b/webapp/frontend/src/main.css index 84ab10e..0cec756 100644 --- a/webapp/frontend/src/main.css +++ b/webapp/frontend/src/main.css @@ -282,6 +282,9 @@ a:hover { text-decoration: underline; } align-items: center; gap: 8px; } +.progress-area[hidden] { + display: none; +} .spinner { display: inline-block; diff --git a/webapp/frontend/tests/favicon-og.test.js b/webapp/frontend/tests/favicon-og.test.js new file mode 100644 index 0000000..edf3534 --- /dev/null +++ b/webapp/frontend/tests/favicon-og.test.js @@ -0,0 +1,202 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const frontendDir = join(__dirname, ".."); +const assetsDir = join(frontendDir, "assets"); +const html = readFileSync(join(frontendDir, "index.html"), "utf-8"); + +// ── Favicon assets on disk ─────────────────────────────────────────── +describe("favicon assets exist", () => { + const requiredFiles = [ + "favicon.svg", + "favicon.ico", + "favicon-16x16.png", + "favicon-32x32.png", + "apple-touch-icon.png", + "favicon-192x192.png", + "favicon-512x512.png", + "og-image.jpg", + "site.webmanifest", + ]; + + for (const file of requiredFiles) { + it(`assets/${file} exists`, () => { + expect(existsSync(join(assetsDir, file))).toBe(true); + }); + } +}); + +// ── PNG file signature ─────────────────────────────────────────────── +describe("PNG files have valid signature", () => { + const pngFiles = [ + "favicon-16x16.png", + "favicon-32x32.png", + "apple-touch-icon.png", + "favicon-192x192.png", + "favicon-512x512.png", + ]; + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + for (const file of pngFiles) { + it(`${file} starts with PNG signature`, () => { + const buf = readFileSync(join(assetsDir, file)); + expect(buf.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + }); + } +}); + +// ── JPEG file signature ────────────────────────────────────────────── +describe("og-image.jpg", () => { + it("has valid JPEG signature", () => { + const buf = readFileSync(join(assetsDir, "og-image.jpg")); + // JPEG magic: FF D8 FF + expect(buf[0]).toBe(0xff); + expect(buf[1]).toBe(0xd8); + expect(buf[2]).toBe(0xff); + }); +}); + +// ── ICO file signature ─────────────────────────────────────────────── +describe("favicon.ico", () => { + it("has valid ICO header", () => { + const buf = readFileSync(join(assetsDir, "favicon.ico")); + // ICO magic: 00 00 01 00 + expect(buf[0]).toBe(0); + expect(buf[1]).toBe(0); + expect(buf.readUInt16LE(2)).toBe(1); // type = ICO + expect(buf.readUInt16LE(4)).toBeGreaterThanOrEqual(1); // at least 1 image + }); +}); + +// ── Favicon tags in HTML ────────────────────────────────────── +describe("index.html favicon link tags", () => { + it("has SVG favicon", () => { + expect(html).toContain(' { + expect(html).toContain('sizes="32x32" href="/assets/favicon-32x32.png"'); + }); + + it("has 16×16 PNG favicon", () => { + expect(html).toContain('sizes="16x16" href="/assets/favicon-16x16.png"'); + }); + + it("has ICO shortcut icon", () => { + expect(html).toContain('href="/assets/favicon.ico"'); + }); + + it("has apple-touch-icon", () => { + expect(html).toContain(' { + expect(html).toContain(' { + it("has og:title", () => { + expect(html).toMatch(/ { + expect(html).toMatch(/ { + expect(html).toContain('property="og:type" content="website"'); + }); + + it("has og:url", () => { + expect(html).toMatch(/ { + const match = html.match(/ { + expect(html).toContain('property="og:image:width"'); + }); + + it("has og:image:height", () => { + expect(html).toContain('property="og:image:height"'); + }); + + it("has og:site_name", () => { + expect(html).toContain('property="og:site_name"'); + }); +}); + +// ── Twitter Card meta tags ─────────────────────────────────────────── +describe("index.html Twitter Card meta tags", () => { + it("has twitter:card set to summary_large_image", () => { + expect(html).toContain('name="twitter:card" content="summary_large_image"'); + }); + + it("has twitter:title", () => { + expect(html).toMatch(/ { + expect(html).toMatch(/ { + const match = html.match(/ { + it("has theme-color", () => { + expect(html).toContain('name="theme-color"'); + }); + + it("has description", () => { + expect(html).toMatch(/ { + expect(html).toMatch(/]*lang="en"/); + }); +}); + +// ── site.webmanifest ───────────────────────────────────────────────── +describe("site.webmanifest", () => { + const manifest = JSON.parse(readFileSync(join(assetsDir, "site.webmanifest"), "utf-8")); + + it("has name", () => { + expect(manifest.name).toBeTruthy(); + }); + + it("has theme_color", () => { + expect(manifest.theme_color).toBeTruthy(); + }); + + it("has background_color", () => { + expect(manifest.background_color).toBeTruthy(); + }); + + it("has at least 2 icons", () => { + expect(manifest.icons.length).toBeGreaterThanOrEqual(2); + }); + + it("includes 192×192 icon", () => { + expect(manifest.icons.some((i) => i.sizes === "192x192")).toBe(true); + }); + + it("includes 512×512 icon", () => { + expect(manifest.icons.some((i) => i.sizes === "512x512")).toBe(true); + }); +}); diff --git a/webapp/frontend/tests/progress-spinner.test.js b/webapp/frontend/tests/progress-spinner.test.js new file mode 100644 index 0000000..ebdedd5 --- /dev/null +++ b/webapp/frontend/tests/progress-spinner.test.js @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const frontendDir = join(__dirname, ".."); +const css = readFileSync(join(frontendDir, "src", "main.css"), "utf-8"); +const html = readFileSync(join(frontendDir, "index.html"), "utf-8"); + +// ── Progress area HTML contract ────────────────────────────────────── + +describe("progress area HTML", () => { + it("has a progress element with the hidden attribute", () => { + // The progress div must start hidden so the spinner is invisible on page load + expect(html).toMatch(/]*id="progress"[^>]*hidden[^>]*>/); + }); + + it("contains a progress-text element", () => { + expect(html).toMatch(/id="progress-text"/); + }); +}); + +// ── CSS: hidden-attribute override fix ─────────────────────────────── + +describe("progress-area CSS", () => { + it("has a .progress-area rule with display:flex", () => { + expect(css).toMatch(/\.progress-area\s*\{[^}]*display:\s*flex/); + }); + + it("has a .progress-area[hidden] rule that sets display:none", () => { + // This is the critical fix — without it, display:flex overrides [hidden] + expect(css).toMatch(/\.progress-area\[hidden\]\s*\{[^}]*display:\s*none/); + }); +}); + +// ── Error banner hidden-attribute ──────────────────────────────────── + +describe("error banner CSS", () => { + it("has an error-banner[hidden] rule", () => { + expect(css).toMatch(/\.error-banner\[hidden\]\s*\{/); + }); +});