diff --git a/Dockerfile b/Dockerfile index e564df6..80e28f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # Multi-stage Dockerfile for no-package-malware -# Supports both microservice and monolith modes via SERVICE_MODE +# Builds artifacts once, then produces three focused, rootless runtime images: +# - api-runtime: unified API (services/api) +# - app-runtime: unified SPA served by nginx-unprivileged (services/app) +# - registry-runtime: Verdaccio registry (strict/lenient) # --------- Builder stage --------- FROM node:24-bullseye-slim AS builder @@ -15,8 +18,7 @@ COPY secure-registry/tsconfig.base.json secure-registry/tsconfig.base.json # Copy workspace package manifests (without full src yet) to resolve deps COPY secure-registry/plugins ./secure-registry/plugins -COPY secure-registry/services ./secure-registry/services -COPY apps ./apps +COPY services ./services # Install dependencies for all workspaces RUN pnpm install --frozen-lockfile @@ -24,35 +26,71 @@ RUN pnpm install --frozen-lockfile # Copy the rest of the repo (src, configs, docs) COPY . . -# Build all workspaces (plugins, services, apps) +# Build all workspaces (plugins, services, app) RUN pnpm -r build -# --------- Runtime stage --------- -FROM node:24-bullseye-slim AS runtime + +# --------- Unified API runtime (Node, rootless) --------- +FROM node:24-bullseye-slim AS api-runtime WORKDIR /app -# Install a tiny init system to handle signals properly -RUN apt-get update && apt-get install -y --no-install-recommends tini \ - && rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production \ + PNPM_HOME=/usr/local/share/pnpm \ + PATH="/app/node_modules/.bin:$PNPM_HOME:$PATH" + +# Copy built repo and dependencies from builder +COPY --from=builder /app /app + +# Create unprivileged user with numeric UID for Kubernetes policies +RUN useradd -u 1000 -r -s /usr/sbin/nologin appuser && \ + chown -R 1000:1000 /app + +USER 1000 + +WORKDIR /app/services/api + +EXPOSE 4000 + +CMD ["node", "dist/index.js"] + + +# --------- Unified SPA runtime (nginx-unprivileged, rootless) --------- +FROM nginxinc/nginx-unprivileged:stable-alpine AS app-runtime + +# Ensure numeric USER is declared explicitly for policy checks +USER 101 + +# Copy built SPA assets +COPY --from=builder /app/services/app/dist /usr/share/nginx/html + +# Custom nginx config with SPA routing +COPY services/app/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + + +# --------- Verdaccio registry runtime (strict/lenient, rootless) --------- +FROM node:24-bullseye-slim AS registry-runtime + +WORKDIR /app ENV NODE_ENV=production \ PNPM_HOME=/usr/local/share/pnpm \ PATH="/app/node_modules/.bin:$PNPM_HOME:$PATH" -# Copy node_modules and built artifacts -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml ./ -COPY --from=builder /app/secure-registry ./secure-registry -COPY --from=builder /app/apps ./apps +# Copy built repo and dependencies from builder (includes Verdaccio plugins) +COPY --from=builder /app /app + +# Create unprivileged user with numeric UID for Kubernetes policies +RUN useradd -u 1001 -r -s /usr/sbin/nologin registry && \ + chown -R 1001:1001 /app -# Copy Procfile-based runtime supervisor config (will be added in repo) -COPY Procfile.runtime ./Procfile.runtime +USER 1001 -# Copy entrypoint script -COPY docker-entrypoint.sh ./docker-entrypoint.sh -RUN chmod +x ./docker-entrypoint.sh +EXPOSE 4873 -EXPOSE 4000 4100 4873 4874 4875 4173 4174 +# REGISTRY_MODE determines which Verdaccio config to use: "strict" or "lenient" +ENV REGISTRY_MODE=strict -ENTRYPOINT ["/usr/bin/tini", "--", "./docker-entrypoint.sh"] +CMD ["sh", "-c", "if [ \"$REGISTRY_MODE\" = \"lenient\" ]; then CONFIG=secure-registry/verdaccio/lenient/config.docker.yaml; else CONFIG=secure-registry/verdaccio/strict/config.docker.yaml; fi; npx verdaccio --config \"$CONFIG\" --listen 0.0.0.0:4873"] diff --git a/Procfile.dev b/Procfile.dev index 430342e..ffe4982 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,9 +1,5 @@ -api: cd secure-registry/services/security-decision-api && pnpm dev -workers: cd secure-registry/services/security-workers && TARBALL_REGISTRY_URL=http://localhost:4875 pnpm dev +api: cd services/api && pnpm dev +app: cd services/app && pnpm dev verdaccio-strict: pnpm dev:verdaccio:strict verdaccio-lenient: pnpm dev:verdaccio:lenient verdaccio-malware: pnpm dev:verdaccio:malware -access-admin-api: cd secure-registry/services/access-admin-api && pnpm dev -security-dashboard: cd apps/security-dashboard && pnpm dev -access-admin-dashboard: cd apps/access-admin-dashboard && pnpm dev -request-access-portal: cd apps/request-access-portal && pnpm dev diff --git a/Procfile.runtime b/Procfile.runtime deleted file mode 100644 index 98b209b..0000000 --- a/Procfile.runtime +++ /dev/null @@ -1,6 +0,0 @@ -api: cd secure-registry/services/security-decision-api && node dist/index.js -workers: cd secure-registry/services/security-workers && node dist/index.js -access-admin-api: cd secure-registry/services/access-admin-api && node dist/index.js -verdaccio-strict: verdaccio --config secure-registry/verdaccio/strict/config.docker.yaml --listen 0.0.0.0:${VERDACCIO_STRICT_PORT:-4873} -verdaccio-lenient: verdaccio --config secure-registry/verdaccio/lenient/config.docker.yaml --listen 0.0.0.0:${VERDACCIO_LENIENT_PORT:-4874} -verdaccio-malware: verdaccio --config secure-registry/verdaccio/malware/config.docker.yaml --listen 0.0.0.0:${VERDACCIO_MALWARE_PORT:-4875} diff --git a/README.md b/README.md index b137b4d..5cc62f3 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,61 @@ This repository implements a secure npm registry built on Verdaccio, acting as a - Token auth plugin: [`index.ts`](secure-registry/plugins/verdaccio-security-token-auth/src/index.ts:1) - HTTP rate limiting middleware: [`index.ts`](secure-registry/plugins/verdaccio-rate-limit-middleware/src/index.ts:1) - **Services** - - Security decision API: [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:1) - - Security workers: [`index.ts`](secure-registry/services/security-workers/src/index.ts:1) + - Unified API service: [`index.ts`](services/api/src/index.ts:1) + - Background workers (integrated into unified API): [`workers.ts`](services/api/src/workers.ts:1) - **Infra** - MongoDB and Redis via [`docker-compose.yml`](secure-registry/infra/docker-compose.yml:1) +## HTTP API endpoints + +All HTTP APIs are now served by the **unified API service** in `services/api`. + +- **Base URL (dev & Docker):** `http://localhost:4000` + +### Security decision API + +- `POST /api/security/bulk-report/:name` + - Given a package name and list of versions, returns a map of versions to security reports (status, riskScore, riskLevel, summary, lastScanAt). +- `GET /api/security/report/:name/:version` + - Return a single security report for one package version. +- `POST /api/security/scan-download/:name/:version` + - Trigger (or wait on) a security scan for the given version, enforcing scan budgets and audit rate limits. +- `GET /api/security/reports` + - Paginated list endpoint used by the security dashboard. + +### Token validation API + +- `POST /api/auth/token/validate` + - Validate a raw npm auth token issued by the access-admin API and map it to an organization. + +### Access request & admin APIs + +- `POST /api/access-requests` + - Public endpoint for creating an access request (email + optional orgName). +- `GET /request-access` + - HTTP redirect to the request-access portal SPA (`ACCESS_REQUEST_PORTAL_URL`). +- `POST /api/admin/login` + - Admin login (default dev credentials: `admin / admin`). Returns a short-lived admin bearer token. +- `GET /api/admin/access-requests` + - List access requests with filters (`status`, `page`, `pageSize`) and token usage stats. +- `POST /api/admin/access-requests/:id/approve` + - Approve an access request, creating an org + token and returning the raw token. +- `POST /api/admin/access-requests/:id/reject` + - Reject an access request. +- `POST /api/admin/tokens/:id/audit-rate-limit` + - Configure per-token rate limits for new package audits. + +### Health endpoints + +- `GET /health/live` – process liveness for the unified API. +- `GET /health/ready` – readiness, aggregating MongoDB, Redis, access-admin API, and workers health. +- `GET /health` – backwards-compatible readiness alias. + +Workers also expose their own health server on `http://localhost:4101` (inside the cluster or docker-compose network): + +- `GET /health/live` +- `GET /health/ready` + ## High level flow 1. Client (npm/yarn/pnpm) installs from Verdaccio strict or lenient. @@ -79,7 +129,7 @@ The lenient instance has the same structure but uses `policyId: "lenient"` and c ## Tokens, orgs and policies -The decision API models organizations and their policies in Mongo via [`db.ts`](secure-registry/services/security-decision-api/src/db.ts:1): +The unified API models organizations and their policies in Mongo via [`db.ts`](services/api/src/db.ts:1): - `OrgDoc` – organizations. - `TokenDoc` – API tokens (stored as SHA‑256 hashes), each linked to an org. @@ -91,7 +141,7 @@ The decision API models organizations and their policies in Mongo via [`db.ts`]( ### Token validation API -Token validation is implemented in [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:310) as `POST /api/auth/token/validate`. +Token validation is implemented in [`index.ts`](services/api/src/index.ts:1) as `POST /api/auth/token/validate`. - Request: @@ -110,7 +160,7 @@ The Verdaccio auth plugin [`index.ts`](secure-registry/plugins/verdaccio-securit ### Policy resolution -For every request, the decision API resolves a `PolicyContext` in [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:121): +For every request, the unified API resolves a `PolicyContext` in [`index.ts`](services/api/src/index.ts:1): - Reads `X-Security-Policy` (`strict` or `lenient`). - Optionally reads `X-Security-Token`. @@ -122,7 +172,7 @@ For every request, the decision API resolves a `PolicyContext` in [`index.ts`](s ### Risk to status mapping -`mapRiskToStatusForPolicy` in [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:157) takes: +`mapRiskToStatusForPolicy` in [`index.ts`](services/api/src/index.ts:1) takes: - Stored `risk_score` and `status` from `security_reports`. - The active `EffectivePolicy`. @@ -138,7 +188,7 @@ It returns one of: Per org scan budgets are enforced when the decision API sees new versions in `POST /api/security/bulk-report/:name`: -- Budget logic is implemented in [`budget.ts`](secure-registry/services/security-decision-api/src/budget.ts:1). +- Budget logic is implemented in [`budget.ts`](services/api/src/budget.ts:1). - If `max_scans_per_day` on the selected `SecurityPolicyDoc` is set, a daily counter `budget:scan::` in Redis is incremented. - When the count exceeds the limit: - The new version is given an `ERROR` status with summary `Scan budget exceeded for this org today.` @@ -156,11 +206,11 @@ The same budget module provides `checkAndConsumeGptBudget`, intended to be used - Workers skip the GPT call. - They rely solely on heuristics and record in `details` that GPT was skipped due to budget exhaustion. -The decision API already enqueues scan jobs with `orgId` and `policyId` in the job payload via [`queue.ts`](secure-registry/services/security-decision-api/src/queue.ts:4), so workers can attribute GPT usage to an org and policy. +The unified API already enqueues scan jobs with `orgId` and `policyId` in the job payload via [`queue.ts`](services/api/src/queue.ts:1), so workers can attribute GPT usage to an org and policy. ## Security workers -Workers are implemented in [`index.ts`](secure-registry/services/security-workers/src/index.ts:1) and GPT logic in [`llm.ts`](secure-registry/services/security-workers/src/llm.ts:1). +Workers are implemented in [`workers.ts`](services/api/src/workers.ts:1) and GPT logic in [`llm.ts`](services/api/src/llm.ts:1). Responsibilities: @@ -193,56 +243,51 @@ The rate limiting middleware plugin is implemented in [`index.ts`](secure-regist Strict and lenient instances can use different `maxRequests` values to enforce different HTTP traffic limits. -## Docker deployment (microservices vs monolith) +## Docker deployment (unified services) -You can build a single Docker image and run it either as separate microservices or as a monolith (all app services in one container), while MongoDB and Redis remain separate containers. +The deployment model is now: -### Build the image +- **API** – unified decision + access-admin + workers (`services/api`), one container. +- **APP** – unified SPA (`services/app`) built with Vite and served by nginx-unprivileged. +- **Registry** – two Verdaccio instances (`strict` and `lenient`) backed by the unified API and Redis. -From the repo root: +MongoDB and Redis remain separate containers. -```bash -# Build the multi-stage image used by both modes -docker build -t no-package-malware-app . -``` +### Build the images -### Microservices mode (multiple app containers) - -This mode runs each service in its own container (decision API, workers, access-admin-api, Verdaccio strict/lenient/malware), plus MongoDB and Redis. +From the repo root, use the multi-stage `Dockerfile` to build three images: ```bash -cd secure-registry/infra -# Start Mongo, Redis, decision-api, workers, access-admin-api, Verdaccio strict/lenient/malware -docker compose -f docker-compose.app.yml up -``` +# Unified API (Node, rootless USER 1000) +docker build -t no-package-malware-api --target api-runtime . -Available endpoints: +# Unified SPA (nginx-unprivileged, rootless USER 101) +docker build -t no-package-malware-app --target app-runtime . -- Decision API: `http://localhost:4000` -- Access admin API: `http://localhost:4100` -- Verdaccio strict gate: `http://localhost:4873` -- Verdaccio lenient gate: `http://localhost:4874` -- Verdaccio malware test registry: `http://localhost:4875` +# Verdaccio registry (strict/lenient, rootless USER 1001) +docker build -t no-package-malware-registry --target registry-runtime . +``` -### Monolith mode (single app container) +### Run the stack with docker-compose -This mode runs all Node/JS app services (decision-api, workers, access-admin-api, Verdaccio strict/lenient/malware) in **one container**, with Mongo and Redis as separate containers. +A single compose file defines the full stack (Mongo, Redis, API, APP, strict & lenient registries): ```bash cd secure-registry/infra -# Start Mongo, Redis and a single app container running all services -docker compose -f docker-compose.monolith.yml up +# Start Mongo, Redis, unified API, unified SPA and strict/lenient registries +docker compose -f docker-compose.app.yml up ``` -Endpoints are the same as above: +Available endpoints: -- Decision API: `http://localhost:4000` -- Access admin API: `http://localhost:4100` +- Unified API: `http://localhost:4000` +- Unified SPA: `http://localhost:4173` - Verdaccio strict gate: `http://localhost:4873` - Verdaccio lenient gate: `http://localhost:4874` -- Verdaccio malware test registry: `http://localhost:4875` -You can still use the existing local dev workflow described below. +All application containers are rootless and declare an explicit numeric `USER` so Kubernetes and platform policies can enforce non-root execution. + +You can still use the local dev workflow described below for iterative development. ## Running locally (high level) @@ -264,15 +309,16 @@ pnpm dev This will: - Start MongoDB and Redis via [`docker-compose.yml`](secure-registry/infra/docker-compose.yml:1). -- Run the security decision API in dev mode on `http://localhost:4000` using [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:1). -- Run the security workers consuming the `security-scan` queue using [`index.ts`](secure-registry/services/security-workers/src/index.ts:1). +- Run the **unified API** (security decision + access-admin + workers) in dev mode on `http://localhost:4000` using [`services/api`](services/api/src/index.ts). - Start two Verdaccio instances: - Strict gate at `http://localhost:${VERDACCIO_STRICT_PORT:-4873}` using [`config.yaml`](secure-registry/verdaccio/strict/config.yaml:1). - Lenient gate at `http://localhost:${VERDACCIO_LENIENT_PORT:-4874}` using [`config.yaml`](secure-registry/verdaccio/lenient/config.yaml:1). -- Start the web dashboards: - - Security reports dashboard at `http://localhost:5174`. - - Access admin dashboard at `http://localhost:5175`. - - Request access portal at `http://localhost:5173` (public form for creating access requests). +- Start the **unified web app** in [`services/app`](services/app) with Vite dev server on `http://localhost:5174`. + + The same SPA will expose: + - `http://localhost:5174/dashboard/security` – security reports dashboard + - `http://localhost:5174/dashboard/access-admin` – access admin dashboard (once routing is fully wired) + - `http://localhost:5174/request-access` – public request access portal The access admin API also exposes `/request-access`, which now redirects to the request access portal. The target URL is configured via the `ACCESS_REQUEST_PORTAL_URL` environment variable (see `.env.example`). @@ -316,7 +362,7 @@ To seed demo data (org, token, strict/lenient policies) you can run: task seed ``` -which delegates to the decision API seeding script defined in [`package.json`](secure-registry/services/security-decision-api/package.json:7). +which delegates to the unified API seeding script defined in [`package.json`](services/api/package.json:1). You can still use the raw `pnpm dev` and `pnpm --filter ... seed` commands directly if you prefer. @@ -412,8 +458,8 @@ You can point the consumer app to the lenient registry instead by adjusting `.np ## Further reading - Functional and architectural guide: [`guide_implementation_registry_securisee_verdaccio_gpt_5.md`](docs/guide_implementation_registry_securisee_verdaccio_gpt_5.md:1) -- Security decision API implementation: [`index.ts`](secure-registry/services/security-decision-api/src/index.ts:1) -- Workers and GPT integration: [`index.ts`](secure-registry/services/security-workers/src/index.ts:1), [`llm.ts`](secure-registry/services/security-workers/src/llm.ts:1) +- Unified API implementation: [`index.ts`](services/api/src/index.ts:1) +- Workers and GPT integration: [`workers.ts`](services/api/src/workers.ts:1), [`llm.ts`](services/api/src/llm.ts:1) - Verdaccio filter plugin: [`index.ts`](secure-registry/plugins/verdaccio-security-gate-filter/src/index.ts:1) - Auth plugin: [`index.ts`](secure-registry/plugins/verdaccio-security-token-auth/src/index.ts:1) - HTTP rate limiting middleware: [`index.ts`](secure-registry/plugins/verdaccio-rate-limit-middleware/src/index.ts:1) diff --git a/TODO b/TODO new file mode 100644 index 0000000..e9f2447 --- /dev/null +++ b/TODO @@ -0,0 +1,12 @@ +- We want to remove all that monolithic/microservices things, we'll keep only a deployment way with 2 separated services app and api, and a npm registry service (with both registry lenient and strict) + +- We want to serve all spa using nginx unprivileged images + +- We should ensure that all docker images are rootless and have a USER instruction followed by number (so kubernetes policies can detect that is rootless and let is pass) + +- Deploy and test deployed (no-package-malware.fabrique.social.gouv.fr) +- kaizo + +- change initial limit to 1000 new audit per 24h + +- test on domifa diff --git a/Taskfile.yml b/Taskfile.yml index 3139242..d7cf008 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -10,4 +10,4 @@ tasks: seed: desc: Seed demo org, token and strict/lenient policies cmds: - - pnpm --filter @secure-registry/security-decision-api seed \ No newline at end of file + - pnpm --filter @secure-registry/api seed diff --git a/apps/access-admin-dashboard/index.html b/apps/access-admin-dashboard/index.html deleted file mode 100644 index 66b4779..0000000 --- a/apps/access-admin-dashboard/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Access Admin Dashboard — NoPackageMalware - - - -
- - - diff --git a/apps/access-admin-dashboard/package.json b/apps/access-admin-dashboard/package.json deleted file mode 100644 index f8219ce..0000000 --- a/apps/access-admin-dashboard/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "access-admin-dashboard", - "version": "0.0.1", - "description": "Admin SPA for managing access requests and tokens for the secure registry.", - "private": true, - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "typescript": "^5.6.3", - "vite": "^5.4.11" - } -} diff --git a/apps/access-admin-dashboard/vite.config.ts b/apps/access-admin-dashboard/vite.config.ts deleted file mode 100644 index 0a59346..0000000 --- a/apps/access-admin-dashboard/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - root: '.', - build: { - outDir: 'dist', - sourcemap: true - }, - server: { - port: 5175 - } -}); diff --git a/apps/request-access-portal/index.html b/apps/request-access-portal/index.html deleted file mode 100644 index 288c2d7..0000000 --- a/apps/request-access-portal/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Request Access — NoPackageMalware - - - -
- - - diff --git a/apps/request-access-portal/package.json b/apps/request-access-portal/package.json deleted file mode 100644 index b615b0c..0000000 --- a/apps/request-access-portal/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@secure-registry/request-access-portal", - "version": "0.0.1", - "description": "Public SPA for requesting access tokens for the secure registry.", - "private": true, - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "typescript": "^5.6.3", - "vite": "^5.4.11" - } -} diff --git a/apps/request-access-portal/postcss.config.cjs b/apps/request-access-portal/postcss.config.cjs deleted file mode 100644 index 5cbc2c7..0000000 --- a/apps/request-access-portal/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/apps/request-access-portal/tailwind.config.cjs b/apps/request-access-portal/tailwind.config.cjs deleted file mode 100644 index aad57c0..0000000 --- a/apps/request-access-portal/tailwind.config.cjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./index.html', './src/**/*.{ts,tsx}'], - theme: { - extend: {} - }, - plugins: [] -}; diff --git a/apps/request-access-portal/tsconfig.json b/apps/request-access-portal/tsconfig.json deleted file mode 100644 index 5eabbe2..0000000 --- a/apps/request-access-portal/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "types": ["vite/client"] - }, - "include": ["src"] -} diff --git a/apps/request-access-portal/vite.config.ts b/apps/request-access-portal/vite.config.ts deleted file mode 100644 index dff4607..0000000 --- a/apps/request-access-portal/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - root: '.', - build: { - outDir: 'dist', - sourcemap: true - }, - server: { - port: 5173 - } -}); diff --git a/apps/security-dashboard/postcss.config.cjs b/apps/security-dashboard/postcss.config.cjs deleted file mode 100644 index 5cbc2c7..0000000 --- a/apps/security-dashboard/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/apps/security-dashboard/src/main.tsx b/apps/security-dashboard/src/main.tsx deleted file mode 100644 index a2bf01b..0000000 --- a/apps/security-dashboard/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - -); diff --git a/apps/security-dashboard/tailwind.config.cjs b/apps/security-dashboard/tailwind.config.cjs deleted file mode 100644 index aad57c0..0000000 --- a/apps/security-dashboard/tailwind.config.cjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./index.html', './src/**/*.{ts,tsx}'], - theme: { - extend: {} - }, - plugins: [] -}; diff --git a/apps/security-dashboard/tsconfig.json b/apps/security-dashboard/tsconfig.json deleted file mode 100644 index 5eabbe2..0000000 --- a/apps/security-dashboard/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "types": ["vite/client"] - }, - "include": ["src"] -} diff --git a/charts/no-package-malware/templates/NOTES.txt b/charts/no-package-malware/templates/NOTES.txt index 5575c98..043778e 100644 --- a/charts/no-package-malware/templates/NOTES.txt +++ b/charts/no-package-malware/templates/NOTES.txt @@ -1,28 +1,32 @@ 1. Installing the chart ======================= -By default, the chart deploys the stack in **microservices** mode: +The chart deploys the stack as a small set of focused services: - helm install npm-secure ./charts/no-package-malware +- Unified API (decision + access-admin + workers) +- Unified SPA (dashboards + request access portal) served by nginx-unprivileged +- Verdaccio strict & lenient registries +- MongoDB and Valkey -To deploy in **monolith** mode instead: +Install with defaults: - helm install npm-secure ./charts/no-package-malware --set mode=monolith + helm install npm-secure ./charts/no-package-malware 2. Services and Ports ===================== -The following ports are used by default (both modes): +The following ports are used by default: -- Decision API: 4000 -- Access Admin API: 4100 -- Workers health server: 4101 (microservices only) +- Unified API: 4000 - Verdaccio strict: 4873 - Verdaccio lenient: 4874 -- Verdaccio malware: 4875 -- Security dashboard: 4173 -- Access-admin dashboard: 4174 +- Unified SPA (dashboards + request access): 4173 + +MongoDB and Valkey ports are internal to the cluster: + +- MongoDB Service: {{ include "no-package-malware.mongo.fullname" . }}:27017 +- Valkey Service: {{ include "no-package-malware.valkey.fullname" . }}:6379 3. MongoDB and Valkey @@ -31,9 +35,6 @@ The following ports are used by default (both modes): MongoDB and Valkey (Redis-compatible) are deployed as part of this chart by default. Both use a single-replica StatefulSet with persistent volumes enabled by default. -- MongoDB Service: {{ include "no-package-malware.mongo.fullname" . }}:27017 -- Valkey Service: {{ include "no-package-malware.valkey.fullname" . }}:6379 - You can disable or tune them via values: mongo.enabled=false @@ -48,15 +49,12 @@ and override `persistence`/`resources` if needed. If you enable ingress: helm install npm-secure ./charts/no-package-malware \ - --set mode=microservices \ --set ingress.enabled=true The ingress will route based on `ingress.hosts` configured in `values.yaml`, typically: -- /api/security -> decision API -- /api/access-admin -> access-admin API -- /dashboard/security -> security dashboard -- /dashboard/access-admin-> access-admin dashboard +- /api -> unified API +- / -> unified SPA (dashboards + request access portal) 5. Health Probes @@ -64,12 +62,9 @@ The ingress will route based on `ingress.hosts` configured in `values.yaml`, typ Kubernetes liveness/readiness probes are wired as follows: -- Decision API: /health/live and /health/ready on port 4000 -- Access Admin API: /health/live and /health/ready on port 4100 -- Workers (microservices):/health/live and /health/ready on port 4101 +- Unified API: /health/live and /health/ready on port 4000 - Verdaccio instances: /-/ping on their respective ports -- Dashboards: / on ports 4173 and 4174 +- Unified SPA: / on port 80 (container), exposed as 4173 by default via Service -In monolith mode, probes target the decision API inside the container; the -process supervisor ensures that if any internal service fails, the whole Pod -restarts. +All application pods run as non-root with explicit numeric `runAsUser` values to +match the rootless Docker images. diff --git a/charts/no-package-malware/templates/app-microservices.yaml b/charts/no-package-malware/templates/app-microservices.yaml index 8b8e931..1c05d33 100644 --- a/charts/no-package-malware/templates/app-microservices.yaml +++ b/charts/no-package-malware/templates/app-microservices.yaml @@ -1,17 +1,13 @@ -{{- if eq .Values.mode "microservices" }} - -{{/* Decision API */}} -{{- if .Values.components.decisionApi.enabled }} --- apiVersion: v1 kind: Service metadata: - name: {{ include "no-package-malware.fullname" . }}-decision-api + name: {{ include "no-package-malware.fullname" . }}-api labels: {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: decision-api + app.kubernetes.io/component: api spec: - type: ClusterIP + type: {{ .Values.app.service.type }} ports: - name: http port: {{ .Values.app.ports.decisionApi }} @@ -19,35 +15,36 @@ spec: selector: app.kubernetes.io/name: {{ include "no-package-malware.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: decision-api + app.kubernetes.io/component: api --- apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "no-package-malware.fullname" . }}-decision-api + name: {{ include "no-package-malware.fullname" . }}-api labels: {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: decision-api + app.kubernetes.io/component: api spec: - replicas: {{ .Values.components.decisionApi.replicaCount }} + replicas: {{ .Values.components.api.replicaCount }} selector: matchLabels: app.kubernetes.io/name: {{ include "no-package-malware.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: decision-api + app.kubernetes.io/component: api template: metadata: labels: {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: decision-api + app.kubernetes.io/component: api spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 containers: - - name: decision-api - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} + - name: api + image: "{{ .Values.image.api.repository }}:{{ .Values.image.api.tag }}" + imagePullPolicy: {{ .Values.image.api.pullPolicy }} env: - - name: SERVICE_MODE - value: security-decision-api - name: SECURITY_DB_URI value: mongodb://{{ include "no-package-malware.mongo.fullname" . }}:27017 - name: SECURITY_DB_NAME @@ -72,230 +69,70 @@ spec: initialDelaySeconds: 20 periodSeconds: 10 resources: - {{- toYaml .Values.components.decisionApi.resources | nindent 12 }} -{{- end }} - -{{/* Security Workers */}} -{{- if .Values.components.workers.enabled }} + {{- toYaml .Values.components.api.resources | nindent 12 }} --- apiVersion: v1 kind: Service metadata: - name: {{ include "no-package-malware.fullname" . }}-workers + name: {{ include "no-package-malware.fullname" . }}-app labels: {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: workers + app.kubernetes.io/component: app spec: - type: ClusterIP + type: {{ .Values.app.service.type }} ports: - name: http - port: {{ .Values.app.ports.workersHealth }} - targetPort: {{ .Values.app.ports.workersHealth }} + port: {{ .Values.app.ports.securityDashboard }} + targetPort: 80 selector: app.kubernetes.io/name: {{ include "no-package-malware.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: workers + app.kubernetes.io/component: app --- apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "no-package-malware.fullname" . }}-workers + name: {{ include "no-package-malware.fullname" . }}-app labels: {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: workers + app.kubernetes.io/component: app spec: - replicas: {{ .Values.components.workers.replicaCount }} + replicas: {{ .Values.components.app.replicaCount }} selector: matchLabels: app.kubernetes.io/name: {{ include "no-package-malware.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: workers + app.kubernetes.io/component: app template: metadata: labels: {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: workers + app.kubernetes.io/component: app spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 containers: - - name: workers - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: SERVICE_MODE - value: security-workers - - name: SECURITY_DB_URI - value: mongodb://{{ include "no-package-malware.mongo.fullname" . }}:27017 - - name: SECURITY_DB_NAME - value: {{ .Values.app.securityDbName }} - - name: REDIS_URL - value: redis://{{ include "no-package-malware.valkey.fullname" . }}:6379 - - name: TARBALL_REGISTRY_URL - value: https://registry.npmjs.org + - name: app + image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}" + imagePullPolicy: {{ .Values.image.app.pullPolicy }} ports: - name: http - containerPort: {{ .Values.app.ports.workersHealth }} + containerPort: 80 livenessProbe: httpGet: - path: /health/live - port: {{ .Values.app.ports.workersHealth }} - initialDelaySeconds: 15 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /health/ready - port: {{ .Values.app.ports.workersHealth }} + path: / + port: 80 initialDelaySeconds: 20 periodSeconds: 10 - resources: - {{- toYaml .Values.components.workers.resources | nindent 12 }} -{{- end }} - -{{/* Access Admin API */}} -{{- if .Values.components.accessAdminApi.enabled }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "no-package-malware.fullname" . }}-access-admin-api - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: access-admin-api -spec: - type: ClusterIP - ports: - - name: http - port: {{ .Values.app.ports.accessAdminApi }} - targetPort: {{ .Values.app.ports.accessAdminApi }} - selector: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: access-admin-api ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "no-package-malware.fullname" . }}-access-admin-api - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: access-admin-api -spec: - replicas: {{ .Values.components.accessAdminApi.replicaCount }} - selector: - matchLabels: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: access-admin-api - template: - metadata: - labels: - {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: access-admin-api - spec: - containers: - - name: access-admin-api - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: SERVICE_MODE - value: access-admin-api - - name: SECURITY_DB_URI - value: mongodb://{{ include "no-package-malware.mongo.fullname" . }}:27017 - - name: SECURITY_DB_NAME - value: {{ .Values.app.securityDbName }} - - name: AUDIT_RATE_LIMIT_REDIS_URL - value: redis://{{ include "no-package-malware.valkey.fullname" . }}:6379/0 - - name: ACCESS_ADMIN_API_PORT - value: {{ .Values.app.ports.accessAdminApi | quote }} - ports: - - name: http - containerPort: {{ .Values.app.ports.accessAdminApi }} - livenessProbe: - httpGet: - path: /health/live - port: {{ .Values.app.ports.accessAdminApi }} - initialDelaySeconds: 15 - periodSeconds: 10 readinessProbe: httpGet: - path: /health/ready - port: {{ .Values.app.ports.accessAdminApi }} - initialDelaySeconds: 20 - periodSeconds: 10 - resources: - {{- toYaml .Values.components.accessAdminApi.resources | nindent 12 }} -{{- end }} - -{{/* Verdaccio Malware */}} -{{- if .Values.components.verdaccioMalware.enabled }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "no-package-malware.fullname" . }}-verdaccio-malware - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: verdaccio-malware -spec: - type: ClusterIP - ports: - - name: http - port: {{ .Values.app.ports.verdaccioMalware }} - targetPort: {{ .Values.app.ports.verdaccioMalware }} - selector: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: verdaccio-malware ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "no-package-malware.fullname" . }}-verdaccio-malware - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: verdaccio-malware -spec: - replicas: {{ .Values.components.verdaccioMalware.replicaCount }} - selector: - matchLabels: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: verdaccio-malware - template: - metadata: - labels: - {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: verdaccio-malware - spec: - containers: - - name: verdaccio-malware - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: SERVICE_MODE - value: verdaccio-malware - - name: VERDACCIO_MALWARE_PORT - value: {{ .Values.app.ports.verdaccioMalware | quote }} - ports: - - name: http - containerPort: {{ .Values.app.ports.verdaccioMalware }} - livenessProbe: - httpGet: - path: /-/ping - port: {{ .Values.app.ports.verdaccioMalware }} - initialDelaySeconds: 15 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /-/ping - port: {{ .Values.app.ports.verdaccioMalware }} - initialDelaySeconds: 20 + path: / + port: 80 + initialDelaySeconds: 25 periodSeconds: 10 resources: - {{- toYaml .Values.components.verdaccioMalware.resources | nindent 12 }} -{{- end }} - -{{/* Verdaccio Strict */}} -{{- if .Values.components.verdaccioStrict.enabled }} + {{- toYaml .Values.components.app.resources | nindent 12 }} --- apiVersion: v1 kind: Service @@ -305,7 +142,7 @@ metadata: {{- include "no-package-malware.labels" . | nindent 4 }} app.kubernetes.io/component: verdaccio-strict spec: - type: ClusterIP + type: {{ .Values.app.service.type }} ports: - name: http port: {{ .Values.app.ports.verdaccioStrict }} @@ -335,19 +172,20 @@ spec: {{- include "no-package-malware.labels" . | nindent 8 }} app.kubernetes.io/component: verdaccio-strict spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 containers: - name: verdaccio-strict - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} + image: "{{ .Values.image.registry.repository }}:{{ .Values.image.registry.tag }}" + imagePullPolicy: {{ .Values.image.registry.pullPolicy }} env: - - name: SERVICE_MODE - value: verdaccio-strict + - name: REGISTRY_MODE + value: strict - name: VERDACCIO_STRICT_PORT value: {{ .Values.app.ports.verdaccioStrict | quote }} - - name: VERDACCIO_MALWARE_UPLINK_URL - value: "http://{{ include "no-package-malware.fullname" . }}-verdaccio-malware:{{ .Values.app.ports.verdaccioMalware }}/" - name: SECURITY_DECISION_API_URL - value: "http://{{ include "no-package-malware.fullname" . }}-decision-api:{{ .Values.app.ports.decisionApi }}" + value: "http://{{ include "no-package-malware.fullname" . }}-api:{{ .Values.app.ports.decisionApi }}" - name: VERDACCIO_REDIS_URL value: "redis://{{ include "no-package-malware.valkey.fullname" . }}:6379" ports: @@ -367,10 +205,6 @@ spec: periodSeconds: 10 resources: {{- toYaml .Values.components.verdaccioStrict.resources | nindent 12 }} -{{- end }} - -{{/* Verdaccio Lenient */}} -{{- if .Values.components.verdaccioLenient.enabled }} --- apiVersion: v1 kind: Service @@ -380,7 +214,7 @@ metadata: {{- include "no-package-malware.labels" . | nindent 4 }} app.kubernetes.io/component: verdaccio-lenient spec: - type: ClusterIP + type: {{ .Values.app.service.type }} ports: - name: http port: {{ .Values.app.ports.verdaccioLenient }} @@ -410,19 +244,20 @@ spec: {{- include "no-package-malware.labels" . | nindent 8 }} app.kubernetes.io/component: verdaccio-lenient spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 containers: - name: verdaccio-lenient - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} + image: "{{ .Values.image.registry.repository }}:{{ .Values.image.registry.tag }}" + imagePullPolicy: {{ .Values.image.registry.pullPolicy }} env: - - name: SERVICE_MODE - value: verdaccio-lenient + - name: REGISTRY_MODE + value: lenient - name: VERDACCIO_LENIENT_PORT value: {{ .Values.app.ports.verdaccioLenient | quote }} - - name: VERDACCIO_MALWARE_UPLINK_URL - value: "http://{{ include "no-package-malware.fullname" . }}-verdaccio-malware:{{ .Values.app.ports.verdaccioMalware }}/" - name: SECURITY_DECISION_API_URL - value: "http://{{ include "no-package-malware.fullname" . }}-decision-api:{{ .Values.app.ports.decisionApi }}" + value: "http://{{ include "no-package-malware.fullname" . }}-api:{{ .Values.app.ports.decisionApi }}" - name: VERDACCIO_REDIS_URL value: "redis://{{ include "no-package-malware.valkey.fullname" . }}:6379" ports: @@ -442,144 +277,3 @@ spec: periodSeconds: 10 resources: {{- toYaml .Values.components.verdaccioLenient.resources | nindent 12 }} -{{- end }} - -{{/* Security Dashboard */}} -{{- if .Values.components.securityDashboard.enabled }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "no-package-malware.fullname" . }}-security-dashboard - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: security-dashboard -spec: - type: ClusterIP - ports: - - name: http - port: {{ .Values.app.ports.securityDashboard }} - targetPort: {{ .Values.app.ports.securityDashboard }} - selector: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: security-dashboard ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "no-package-malware.fullname" . }}-security-dashboard - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: security-dashboard -spec: - replicas: {{ .Values.components.securityDashboard.replicaCount }} - selector: - matchLabels: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: security-dashboard - template: - metadata: - labels: - {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: security-dashboard - spec: - containers: - - name: security-dashboard - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: SERVICE_MODE - value: security-dashboard - - name: VITE_DECISION_API_URL - value: "http://{{ include "no-package-malware.fullname" . }}-decision-api:{{ .Values.app.ports.decisionApi }}" - ports: - - name: http - containerPort: {{ .Values.app.ports.securityDashboard }} - livenessProbe: - httpGet: - path: / - port: {{ .Values.app.ports.securityDashboard }} - initialDelaySeconds: 20 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: {{ .Values.app.ports.securityDashboard }} - initialDelaySeconds: 25 - periodSeconds: 10 - resources: - {{- toYaml .Values.components.securityDashboard.resources | nindent 12 }} -{{- end }} - -{{/* Access Admin Dashboard */}} -{{- if .Values.components.accessAdminDashboard.enabled }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "no-package-malware.fullname" . }}-access-admin-dashboard - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: access-admin-dashboard -spec: - type: ClusterIP - ports: - - name: http - port: {{ .Values.app.ports.accessAdminDashboard }} - targetPort: {{ .Values.app.ports.accessAdminDashboard }} - selector: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: access-admin-dashboard ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "no-package-malware.fullname" . }}-access-admin-dashboard - labels: - {{- include "no-package-malware.labels" . | nindent 4 }} - app.kubernetes.io/component: access-admin-dashboard -spec: - replicas: {{ .Values.components.accessAdminDashboard.replicaCount }} - selector: - matchLabels: - app.kubernetes.io/name: {{ include "no-package-malware.name" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: access-admin-dashboard - template: - metadata: - labels: - {{- include "no-package-malware.labels" . | nindent 8 }} - app.kubernetes.io/component: access-admin-dashboard - spec: - containers: - - name: access-admin-dashboard - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: SERVICE_MODE - value: access-admin-dashboard - - name: VITE_ACCESS_ADMIN_API_URL - value: "http://{{ include "no-package-malware.fullname" . }}-access-admin-api:{{ .Values.app.ports.accessAdminApi }}" - ports: - - name: http - containerPort: {{ .Values.app.ports.accessAdminDashboard }} - livenessProbe: - httpGet: - path: / - port: {{ .Values.app.ports.accessAdminDashboard }} - initialDelaySeconds: 20 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: {{ .Values.app.ports.accessAdminDashboard }} - initialDelaySeconds: 25 - periodSeconds: 10 - resources: - {{- toYaml .Values.components.accessAdminDashboard.resources | nindent 12 }} -{{- end }} - -{{- end }} diff --git a/charts/no-package-malware/templates/ingress.yaml b/charts/no-package-malware/templates/ingress.yaml index 9c96ff7..c6caa61 100644 --- a/charts/no-package-malware/templates/ingress.yaml +++ b/charts/no-package-malware/templates/ingress.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.ingress.enabled (eq .Values.mode "microservices") }} +{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: diff --git a/charts/no-package-malware/values.yaml b/charts/no-package-malware/values.yaml index b430ef7..b94396e 100644 --- a/charts/no-package-malware/values.yaml +++ b/charts/no-package-malware/values.yaml @@ -1,15 +1,19 @@ # Default values for no-package-malware Helm chart. -# Deployment mode for the application stack. -# - "microservices": one Deployment/Service per component (APIs, workers, verdaccio, dashboards). -# - "monolith": a single app Deployment/Service running all Node.js services, plus Mongo & Valkey. -mode: microservices - -# Base image for the application containers (APIs, workers, verdaccio, dashboards). +# Base images for the application containers. image: - repository: ghcr.io/socialgouv/no-package-malware-app - tag: "latest" - pullPolicy: IfNotPresent + api: + repository: ghcr.io/socialgouv/no-package-malware-api + tag: "latest" + pullPolicy: IfNotPresent + app: + repository: ghcr.io/socialgouv/no-package-malware-app + tag: "latest" + pullPolicy: IfNotPresent + registry: + repository: ghcr.io/socialgouv/no-package-malware-registry + tag: "latest" + pullPolicy: IfNotPresent # Global settings for the application. app: @@ -26,7 +30,7 @@ app: accessAdminDashboard: 4174 service: - # Type for externally exposed Services (monolith or selected microservices). + # Type for externally exposed Services. type: ClusterIP annotations: {} @@ -71,45 +75,21 @@ valkey: resources: {} -# Per-component configuration for microservices mode. +# Per-component configuration for the application stack. components: - decisionApi: - enabled: true + api: replicaCount: 1 resources: {} - workers: - enabled: true - replicaCount: 1 - resources: {} - - accessAdminApi: - enabled: true - replicaCount: 1 - resources: {} - - verdaccioMalware: - enabled: true + app: replicaCount: 1 resources: {} verdaccioStrict: - enabled: true replicaCount: 1 resources: {} verdaccioLenient: - enabled: true - replicaCount: 1 - resources: {} - - securityDashboard: - enabled: true - replicaCount: 1 - resources: {} - - accessAdminDashboard: - enabled: true replicaCount: 1 resources: {} @@ -124,24 +104,15 @@ ingress: hosts: - host: no-package-malware.local paths: - # Security decision API - - path: /api/security + # Unified API + - path: /api pathType: Prefix - service: decision-api + service: api port: 4000 - # Access-admin API - - path: /api/access-admin - pathType: Prefix - service: access-admin-api - port: 4100 - # Dashboards - - path: /dashboard/security + # Unified dashboards + request access SPA + - path: / pathType: Prefix - service: security-dashboard + service: app port: 4173 - - path: /dashboard/access-admin - pathType: Prefix - service: access-admin-dashboard - port: 4174 tls: [] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 7cd77d9..0000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SERVICE_MODE="${SERVICE_MODE:-monolith}" - -# Default ports (can be overridden via env) -export VERDACCIO_STRICT_PORT="${VERDACCIO_STRICT_PORT:-4873}" -export VERDACCIO_LENIENT_PORT="${VERDACCIO_LENIENT_PORT:-4874}" -export VERDACCIO_MALWARE_PORT="${VERDACCIO_MALWARE_PORT:-4875}" -export ACCESS_ADMIN_API_PORT="${ACCESS_ADMIN_API_PORT:-4100}" -export PORT="${PORT:-4000}" - -# Default DB/Redis URLs (should usually be overridden in docker-compose) -export SECURITY_DB_URI="${SECURITY_DB_URI:-mongodb://mongo:27017}" -export SECURITY_DB_NAME="${SECURITY_DB_NAME:-secure_registry}" -export REDIS_URL="${REDIS_URL:-redis://redis:6379}" -export AUDIT_RATE_LIMIT_REDIS_URL="${AUDIT_RATE_LIMIT_REDIS_URL:-redis://redis:6379/0}" - -cd /app - -run_monolith() { - echo "[entrypoint] Starting monolith mode" - - # Start security-decision-api - cd /app/secure-registry/services/security-decision-api - node dist/index.js & - API_PID=$! - - # Start security-workers - cd /app/secure-registry/services/security-workers - node dist/index.js & - WORKERS_PID=$! - - # Start access-admin-api - cd /app/secure-registry/services/access-admin-api - node dist/index.js & - ADMIN_API_PID=$! - - # Start Verdaccio instances (strict, lenient, malware) using Docker-specific configs - cd /app - verdaccio --config secure-registry/verdaccio/strict/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_STRICT_PORT}" & - VERDACCIO_STRICT_PID=$! - - verdaccio --config secure-registry/verdaccio/lenient/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_LENIENT_PORT}" & - VERDACCIO_LENIENT_PID=$! - - verdaccio --config secure-registry/verdaccio/malware/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_MALWARE_PORT}" & - VERDACCIO_MALWARE_PID=$! - - # Start dashboards - cd /app/apps/security-dashboard - vite preview --host 0.0.0.0 --port 4173 & - SECURITY_DASHBOARD_PID=$! - - cd /app/apps/access-admin-dashboard - vite preview --host 0.0.0.0 --port 4174 & - ACCESS_ADMIN_DASHBOARD_PID=$! - - # Clean exit on SIGTERM/SIGINT - trap 'echo "[entrypoint] Caught signal, stopping services"; kill "$API_PID" "$WORKERS_PID" "$ADMIN_API_PID" "$VERDACCIO_STRICT_PID" "$VERDACCIO_LENIENT_PID" "$VERDACCIO_MALWARE_PID" "$SECURITY_DASHBOARD_PID" "$ACCESS_ADMIN_DASHBOARD_PID" 2>/dev/null || true; wait' SIGTERM SIGINT - - # Wait for any process to exit - wait -n || true - echo "[entrypoint] One of the services exited, shutting down" - kill "$API_PID" "$WORKERS_PID" "$ADMIN_API_PID" "$VERDACCIO_STRICT_PID" "$VERDACCIO_LENIENT_PID" "$VERDACCIO_MALWARE_PID" "$SECURITY_DASHBOARD_PID" "$ACCESS_ADMIN_DASHBOARD_PID" 2>/dev/null || true - wait || true -} - -case "$SERVICE_MODE" in - monolith) - run_monolith - ;; - security-decision-api) - echo "[entrypoint] Starting security-decision-api (single service mode)" - cd /app/secure-registry/services/security-decision-api - exec node dist/index.js - ;; - security-workers) - echo "[entrypoint] Starting security-workers (single service mode)" - cd /app/secure-registry/services/security-workers - exec node dist/index.js - ;; - access-admin-api) - echo "[entrypoint] Starting access-admin-api (single service mode)" - cd /app/secure-registry/services/access-admin-api - exec node dist/index.js - ;; - security-dashboard) - echo "[entrypoint] Starting security-dashboard (single service mode)" - cd /app/apps/security-dashboard - exec vite preview --host 0.0.0.0 --port 4173 - ;; - access-admin-dashboard) - echo "[entrypoint] Starting access-admin-dashboard (single service mode)" - cd /app/apps/access-admin-dashboard - exec vite preview --host 0.0.0.0 --port 4174 - ;; - verdaccio-strict) - echo "[entrypoint] Starting Verdaccio strict (single service mode)" - cd /app - exec verdaccio --config secure-registry/verdaccio/strict/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_STRICT_PORT}" - ;; - verdaccio-lenient) - echo "[entrypoint] Starting Verdaccio lenient (single service mode)" - cd /app - exec verdaccio --config secure-registry/verdaccio/lenient/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_LENIENT_PORT}" - ;; - verdaccio-malware) - echo "[entrypoint] Starting Verdaccio malware (single service mode)" - cd /app - exec verdaccio --config secure-registry/verdaccio/malware/config.docker.yaml --listen 0.0.0.0:"${VERDACCIO_MALWARE_PORT}" - ;; - *) - echo "[entrypoint] Unknown SERVICE_MODE: $SERVICE_MODE" >&2 - exit 1 - ;; -esac diff --git a/package.json b/package.json index 2bfa85d..80e2922 100644 --- a/package.json +++ b/package.json @@ -6,25 +6,21 @@ "main": "index.js", "workspaces": [ "secure-registry/plugins/*", - "secure-registry/services/*", - "apps/*" + "services/api", + "services/app" ], "scripts": { "lint": "pnpm -r lint", "test": "pnpm -r test", "build": "pnpm -r build", "infra:up": "cd secure-registry/infra && docker compose up -d", - "dev:api": "pnpm --filter @secure-registry/security-decision-api dev", - "dev:workers": "pnpm --filter @secure-registry/security-workers dev", - "dev:access-admin-api": "pnpm --filter @secure-registry/access-admin-api dev", + "dev:api": "pnpm --filter @secure-registry/api dev", + "dev:app": "pnpm --filter @secure-registry/app dev", "dev:verdaccio:strict": "verdaccio --config secure-registry/verdaccio/strict/config.yaml --listen 0.0.0.0:${VERDACCIO_STRICT_PORT:-4873}", "dev:verdaccio:lenient": "verdaccio --config secure-registry/verdaccio/lenient/config.yaml --listen 0.0.0.0:${VERDACCIO_LENIENT_PORT:-4874}", "dev:verdaccio:malware": "verdaccio --config secure-registry/verdaccio/malware/config.yaml --listen 0.0.0.0:${VERDACCIO_MALWARE_PORT:-4875}", "malware:publish-fixtures": "tsx scripts/publish-malware-fixtures.ts", - "dev:security-dashboard": "pnpm --filter @secure-registry/security-dashboard dev", - "dev:access-admin-dashboard": "pnpm --filter access-admin-dashboard dev", - "dev:request-access-portal": "pnpm --filter @secure-registry/request-access-portal dev", - "dev": "concurrently -k -n infra,api,workers,admin,strict,lenient,malware,sec-ui,admin-ui,req-ui -c cyan,green,magenta,red,yellow,blue,white,gray,magenta,cyan \"pnpm infra:up\" \"pnpm dev:api\" \"pnpm dev:workers\" \"pnpm dev:access-admin-api\" \"pnpm dev:verdaccio:strict\" \"pnpm dev:verdaccio:lenient\" \"pnpm dev:verdaccio:malware\" \"pnpm dev:security-dashboard\" \"pnpm dev:access-admin-dashboard\" \"pnpm dev:request-access-portal\"", + "dev": "concurrently -k -n infra,api,strict,lenient,malware,app -c cyan,green,yellow,blue,white,magenta \"pnpm infra:up\" \"pnpm dev:api\" \"pnpm dev:verdaccio:strict\" \"pnpm dev:verdaccio:lenient\" \"pnpm dev:verdaccio:malware\" \"pnpm dev:app\"", "release": "commit-and-tag-version" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4484f44..95ac0a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,108 +37,6 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@20.19.25)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - apps/access-admin-dashboard: - dependencies: - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.3.12 - version: 18.3.27 - '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21(@types/node@20.19.25)) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.22(postcss@8.5.6) - postcss: - specifier: ^8.4.49 - version: 8.5.6 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vite: - specifier: ^5.4.11 - version: 5.4.21(@types/node@20.19.25) - - apps/request-access-portal: - dependencies: - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.3.12 - version: 18.3.27 - '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21(@types/node@20.19.25)) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.22(postcss@8.5.6) - postcss: - specifier: ^8.4.49 - version: 8.5.6 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vite: - specifier: ^5.4.11 - version: 5.4.21(@types/node@20.19.25) - - apps/security-dashboard: - dependencies: - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.3.12 - version: 18.3.27 - '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21(@types/node@20.19.25)) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.22(postcss@8.5.6) - postcss: - specifier: ^8.4.49 - version: 8.5.6 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vite: - specifier: ^5.4.11 - version: 5.4.21(@types/node@20.19.25) - secure-registry/plugins/verdaccio-download-guard-middleware: dependencies: '@verdaccio/types': @@ -215,42 +113,14 @@ importers: specifier: ^5.6.0 version: 5.9.3 - secure-registry/services/access-admin-api: - dependencies: - cors: - specifier: ^2.8.5 - version: 2.8.5 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^5.0.0 - version: 5.1.0 - ioredis: - specifier: ^5.4.1 - version: 5.8.2 - mongodb: - specifier: ^6.9.0 - version: 6.21.0 - devDependencies: - '@types/cors': - specifier: ^2.8.17 - version: 2.8.19 - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/node': - specifier: ^20.17.0 - version: 20.19.25 - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.6.0 - version: 5.9.3 - - secure-registry/services/security-decision-api: + services/api: dependencies: + '@ai-sdk/openai': + specifier: ^1.0.0 + version: 1.3.24(zod@3.25.76) + ai: + specifier: ^4.0.0 + version: 4.3.19(react@18.3.1)(zod@3.25.76) bullmq: specifier: ^5.12.0 version: 5.65.0 @@ -261,14 +131,23 @@ importers: specifier: ^16.4.5 version: 16.6.1 express: - specifier: ^5.0.0 - version: 5.1.0 + specifier: ^5.2.0 + version: 5.2.0 ioredis: specifier: ^5.4.1 version: 5.8.2 mongodb: specifier: ^6.9.0 version: 6.21.0 + tar: + specifier: ^7.4.3 + version: 7.5.2 + undici: + specifier: ^7.0.0 + version: 7.16.0 + zod: + specifier: ^3.23.8 + version: 3.25.76 devDependencies: '@types/cors': specifier: ^2.8.17 @@ -286,48 +165,39 @@ importers: specifier: ^5.6.0 version: 5.9.3 - secure-registry/services/security-workers: + services/app: dependencies: - '@ai-sdk/openai': - specifier: ^1.0.0 - version: 1.3.24(zod@3.25.76) - '@nodesecure/js-x-ray': - specifier: ^7.0.0 - version: 7.3.0 - ai: - specifier: ^4.0.0 - version: 4.3.19(react@19.2.0)(zod@3.25.76) - bullmq: - specifier: ^5.12.0 - version: 5.65.0 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - ioredis: - specifier: ^5.4.1 - version: 5.8.2 - mongodb: - specifier: ^6.9.0 - version: 6.21.0 - tar: - specifier: ^7.4.3 - version: 7.5.2 - undici: - specifier: ^6.19.2 - version: 6.22.0 - zod: - specifier: ^3.23.8 - version: 3.25.76 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) devDependencies: - '@types/node': - specifier: ^20.17.0 - version: 20.19.25 - tsx: - specifier: ^4.19.0 - version: 4.21.0 + '@types/react': + specifier: ^18.3.12 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.27) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@5.4.21(@types/node@20.19.25)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.22(postcss@8.5.6) + postcss: + specifier: ^8.4.49 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) typescript: - specifier: ^5.6.0 + specifier: ^5.6.3 version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@20.19.25) packages: @@ -942,9 +812,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@file-type/xml@0.4.4': - resolution: {integrity: sha512-NhCyXoHlVZ8TqM476hyzwGJ24+D5IPSaZhmrPj7qXnEVb3q6jrFzA3mM9TBpknKSI9EuQeGTKRg2DXGUwvBBoQ==} - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1033,16 +900,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nodesecure/estree-ast-utils@1.6.0': - resolution: {integrity: sha512-NtHIYbzEO6+eiP+tgP5lKTTfqxP4aEGC1s17aMR0+zDftSrZmhsGwh0Wb0+87lOQjQ9r6avGFeE76ysERKjZAg==} - - '@nodesecure/js-x-ray@7.3.0': - resolution: {integrity: sha512-vvPWewLTm0ANYBxOe+Lm3FBLYEa6+G2kSxsuz6QsJWZfmLmyxSXdImm8r0+6qFIapMdal9fquICXUbFu/zkibg==} - engines: {node: '>=18.0.0'} - - '@nodesecure/sec-literal@1.4.0': - resolution: {integrity: sha512-E4oxm/Ww2mvm20UbtLfoluF/NNaTcIs1lUUD+Crk9idBGBKwxgnSlW7MhWOwbPO/ccC6WPaqDtRv/cjB02nDIw==} - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1168,9 +1025,6 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1433,10 +1287,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1825,6 +1675,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -2128,8 +1982,8 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} - express@5.1.0: - resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + express@5.2.0: + resolution: {integrity: sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==} engines: {node: '>= 18'} extend@3.0.2: @@ -2190,9 +2044,9 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} @@ -2234,12 +2088,6 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - frequency-set@1.0.2: - resolution: {integrity: sha512-Qip6vS0fY/et08sZXumws05weoYvj2ZLkBq3xIwFDFLg8v5IMQiRa+P30tXL0CU6DiYUPLuN3HyRcwW6yWPdeA==} - - frequency-set@2.1.0: - resolution: {integrity: sha512-a/bcqldePC/qnIpds12Oj345eN+9hP0a6rNgVMHBZ2ckO4K0iruch9kNWIq3nq2N5PYjcviY9uDTUtA4y2d/pA==} - fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -2264,10 +2112,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2472,9 +2316,6 @@ packages: resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} engines: {node: '>=0.10.0'} - is-minified-code@2.0.0: - resolution: {integrity: sha512-I1BHmOxm7owypunUWnYx2Ggdhg3lzdyJXLepi8NuR/IsvgVgkwjLj+12iYAGUklu0Xvy3nXGcDSKGbE0Q0Nkag==} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2493,10 +2334,6 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-svg@6.1.0: - resolution: {integrity: sha512-i7YPdvYuSCYcaLQrKwt8cvKTlwHcdA6Hp8N9SO3Q5jIzo8x6kH3N47W0BvPP7NdxVBmIHx7X9DK36czYYW7lHg==} - engines: {node: '>=20'} - is-text-path@1.0.1: resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} engines: {node: '>=0.10.0'} @@ -2739,10 +2576,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - meriyah@4.5.0: - resolution: {integrity: sha512-Rbiu0QPIxTXgOXwiIpRVJfZRQ2FWyfzYrOGBs9SN5RbaXg1CN5ELn/plodwWwluX93yzc4qO/bNIen1ThGFCxw==} - engines: {node: '>=10.4.0'} - methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -3224,10 +3057,6 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - react@19.2.0: - resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} - engines: {node: '>=0.10.0'} - read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -3278,10 +3107,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - regexp-tree@0.1.27: - resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} - hasBin: true - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3333,9 +3158,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex@2.1.1: - resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} - safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -3343,9 +3165,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.4.3: - resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} - scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3500,10 +3319,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@8.1.0: - resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} - engines: {node: '>=20'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3514,10 +3329,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3536,10 +3347,6 @@ packages: strnum@2.1.1: resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} - engines: {node: '>=18'} - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3664,9 +3471,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-pattern@5.9.0: - resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3728,6 +3532,10 @@ packages: resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} engines: {node: '>=18.17'} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unix-crypt-td-js@1.1.4: resolution: {integrity: sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==} @@ -4004,12 +3812,12 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.2.0)(zod@3.25.76)': + '@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) - react: 19.2.0 - swr: 2.3.7(react@19.2.0) + react: 18.3.1 + swr: 2.3.7(react@18.3.1) throttleit: 2.1.0 optionalDependencies: zod: 3.25.76 @@ -4427,11 +4235,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@file-type/xml@0.4.4': - dependencies: - sax: 1.4.3 - strtok3: 10.3.4 - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4504,27 +4307,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@nodesecure/estree-ast-utils@1.6.0': - dependencies: - '@nodesecure/sec-literal': 1.4.0 - - '@nodesecure/js-x-ray@7.3.0': - dependencies: - '@nodesecure/estree-ast-utils': 1.6.0 - '@nodesecure/sec-literal': 1.4.0 - estree-walker: 3.0.3 - frequency-set: 1.0.2 - is-minified-code: 2.0.0 - meriyah: 4.5.0 - safe-regex: 2.1.1 - ts-pattern: 5.9.0 - - '@nodesecure/sec-literal@1.4.0': - dependencies: - frequency-set: 2.1.0 - is-svg: 6.1.0 - string-width: 8.1.0 - '@opentelemetry/api@1.9.0': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -4601,8 +4383,6 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tokenizer/token@0.3.0': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -4965,17 +4745,17 @@ snapshots: transitivePeerDependencies: - supports-color - ai@4.3.19(react@19.2.0)(zod@3.25.76): + ai@4.3.19(react@18.3.1)(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@19.2.0)(zod@3.25.76) + '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 zod: 3.25.76 optionalDependencies: - react: 19.2.0 + react: 18.3.1 ajv@6.12.6: dependencies: @@ -4993,8 +4773,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -5090,7 +4868,7 @@ snapshots: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.7.0 on-finished: 2.4.1 qs: 6.14.0 @@ -5429,6 +5207,8 @@ snapshots: cookie@0.7.1: {} + cookie@0.7.2: {} + core-util-is@1.0.2: {} core-util-is@1.0.3: {} @@ -5800,21 +5580,22 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.1.0: + express@5.2.0: dependencies: accepts: 2.0.0 body-parser: 2.2.1 content-disposition: 1.0.1 content-type: 1.0.5 - cookie: 0.7.1 + cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3 + depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 2.0.0 mime-types: 3.0.2 on-finished: 2.4.1 @@ -5826,7 +5607,7 @@ snapshots: router: 2.2.0 send: 1.2.0 serve-static: 2.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: @@ -5890,14 +5671,14 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.0: + finalhandler@2.1.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -5942,10 +5723,6 @@ snapshots: fraction.js@5.3.4: {} - frequency-set@1.0.2: {} - - frequency-set@2.1.0: {} - fresh@0.5.2: {} fresh@2.0.0: {} @@ -5959,8 +5736,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6190,8 +5965,6 @@ snapshots: is-gzip@1.0.0: {} - is-minified-code@2.0.0: {} - is-number@7.0.0: {} is-obj@2.0.0: {} @@ -6202,10 +5975,6 @@ snapshots: is-promise@4.0.0: {} - is-svg@6.1.0: - dependencies: - '@file-type/xml': 0.4.4 - is-text-path@1.0.1: dependencies: text-extensions: 1.9.0 @@ -6425,8 +6194,6 @@ snapshots: merge2@1.4.1: {} - meriyah@4.5.0: {} - methods@1.1.2: {} micromatch@4.0.8: @@ -6828,8 +6595,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - react@19.2.0: {} - read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -6899,8 +6664,6 @@ snapshots: dependencies: redis-errors: 1.2.0 - regexp-tree@0.1.27: {} - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -6973,16 +6736,10 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex@2.1.1: - dependencies: - regexp-tree: 0.1.27 - safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} - sax@1.4.3: {} - scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -7022,12 +6779,12 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -7176,11 +6933,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@8.1.0: - dependencies: - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -7193,10 +6945,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -7211,10 +6959,6 @@ snapshots: strnum@2.1.1: {} - strtok3@10.3.4: - dependencies: - '@tokenizer/token': 0.3.0 - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -7239,11 +6983,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.7(react@19.2.0): + swr@2.3.7(react@18.3.1): dependencies: dequal: 2.0.3 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -7362,8 +7106,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-pattern@5.9.0: {} - tslib@2.8.1: {} tsx@4.21.0: @@ -7413,6 +7155,8 @@ snapshots: undici@6.22.0: {} + undici@7.16.0: {} + unix-crypt-td-js@1.1.4: {} unpipe@1.0.0: {} @@ -7427,9 +7171,9 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.0): + use-sync-external-store@1.6.0(react@18.3.1): dependencies: - react: 19.2.0 + react: 18.3.1 util-deprecate@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 17b926d..630f568 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - "secure-registry/plugins/*" - - "secure-registry/services/*" - - "apps/*" + - "services/api" + - "services/app" diff --git a/secure-registry/infra/docker-compose.app.yml b/secure-registry/infra/docker-compose.app.yml index e3ccbca..d05abc0 100644 --- a/secure-registry/infra/docker-compose.app.yml +++ b/secure-registry/infra/docker-compose.app.yml @@ -1,12 +1,14 @@ version: '3.9' services: + # Datastores mongo: image: mongo:7 container_name: secure-registry-mongo restart: unless-stopped ports: - - "27018:27017" # Avoid conflict with local MongoDB + # Bind host 27018 -> container 27017 to avoid conflicts with a local MongoDB + - "27018:27017" volumes: - mongo-data:/data/db @@ -18,12 +20,12 @@ services: - "6379:6379" command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] - decision-api: - image: no-package-malware-app:latest - container_name: secure-registry-decision-api + # Unified API (decision API + access-admin API + workers) + api: + image: no-package-malware-api:latest + container_name: secure-registry-api restart: unless-stopped environment: - SERVICE_MODE: security-decision-api SECURITY_DB_URI: mongodb://mongo:27017 SECURITY_DB_NAME: secure_registry REDIS_URL: redis://redis:6379 @@ -34,106 +36,47 @@ services: ports: - "4000:4000" - security-workers: - image: no-package-malware-app:latest - container_name: secure-registry-workers - restart: unless-stopped - environment: - SERVICE_MODE: security-workers - SECURITY_DB_URI: mongodb://mongo:27017 - SECURITY_DB_NAME: secure_registry - REDIS_URL: redis://redis:6379 - TARBALL_REGISTRY_URL: https://registry.npmjs.org - depends_on: - - mongo - - redis - - access-admin-api: - image: no-package-malware-app:latest - container_name: secure-registry-access-admin-api - restart: unless-stopped - environment: - SERVICE_MODE: access-admin-api - SECURITY_DB_URI: mongodb://mongo:27017 - SECURITY_DB_NAME: secure_registry - AUDIT_RATE_LIMIT_REDIS_URL: redis://redis:6379/0 - ACCESS_ADMIN_API_PORT: 4100 - # Optionally override admin credentials - # ACCESS_ADMIN_USERNAME: admin - # ACCESS_ADMIN_PASSWORD: admin - depends_on: - - mongo - - redis - ports: - - "4100:4100" - - security-dashboard: - image: no-package-malware-app:latest - container_name: secure-registry-security-dashboard - restart: unless-stopped - environment: - SERVICE_MODE: security-dashboard - VITE_DECISION_API_URL: http://decision-api:4000 - depends_on: - - decision-api - ports: - - "4173:4173" - - access-admin-dashboard: + # Unified SPA (dashboards + request access), served via nginx-unprivileged + app: image: no-package-malware-app:latest - container_name: secure-registry-access-admin-dashboard + container_name: secure-registry-app restart: unless-stopped - environment: - SERVICE_MODE: access-admin-dashboard - VITE_ACCESS_ADMIN_API_URL: http://access-admin-api:4100 depends_on: - - access-admin-api + - api ports: - - "4174:4174" - - verdaccio-malware: - image: no-package-malware-app:latest - container_name: secure-registry-verdaccio-malware - restart: unless-stopped - environment: - SERVICE_MODE: verdaccio-malware - VERDACCIO_MALWARE_PORT: 4875 - ports: - - "4875:4875" + - "4173:80" + # Verdaccio strict registry verdaccio-strict: - image: no-package-malware-app:latest + image: no-package-malware-registry:latest container_name: secure-registry-verdaccio-strict restart: unless-stopped environment: - SERVICE_MODE: verdaccio-strict + REGISTRY_MODE: strict VERDACCIO_STRICT_PORT: 4873 - VERDACCIO_MALWARE_UPLINK_URL: http://verdaccio-malware:4875/ - SECURITY_DECISION_API_URL: http://decision-api:4000 + SECURITY_DECISION_API_URL: http://api:4000 VERDACCIO_REDIS_URL: redis://redis:6379 depends_on: - - decision-api + - api - redis - - verdaccio-malware ports: - "4873:4873" + # Verdaccio lenient registry verdaccio-lenient: - image: no-package-malware-app:latest + image: no-package-malware-registry:latest container_name: secure-registry-verdaccio-lenient restart: unless-stopped environment: - SERVICE_MODE: verdaccio-lenient + REGISTRY_MODE: lenient VERDACCIO_LENIENT_PORT: 4874 - VERDACCIO_MALWARE_UPLINK_URL: http://verdaccio-malware:4875/ - SECURITY_DECISION_API_URL: http://decision-api:4000 + SECURITY_DECISION_API_URL: http://api:4000 VERDACCIO_REDIS_URL: redis://redis:6379 depends_on: - - decision-api + - api - redis - - verdaccio-malware ports: - - "4874:4874" + - "4874:4873" volumes: mongo-data: diff --git a/secure-registry/infra/docker-compose.monolith.yml b/secure-registry/infra/docker-compose.monolith.yml deleted file mode 100644 index c07538b..0000000 --- a/secure-registry/infra/docker-compose.monolith.yml +++ /dev/null @@ -1,54 +0,0 @@ -version: '3.9' - -services: - mongo: - image: mongo:7 - container_name: secure-registry-mongo - restart: unless-stopped - ports: - - "27018:27017" - volumes: - - mongo-data:/data/db - - redis: - image: redis:7 - container_name: secure-registry-redis - restart: unless-stopped - ports: - - "6379:6379" - command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] - - app: - image: no-package-malware-app:latest - container_name: secure-registry-app - restart: unless-stopped - environment: - SERVICE_MODE: monolith - SECURITY_DB_URI: mongodb://mongo:27017 - SECURITY_DB_NAME: secure_registry - REDIS_URL: redis://redis:6379 - AUDIT_RATE_LIMIT_REDIS_URL: redis://redis:6379/0 - VERDACCIO_STRICT_PORT: 4873 - VERDACCIO_LENIENT_PORT: 4874 - VERDACCIO_MALWARE_PORT: 4875 - ACCESS_ADMIN_API_PORT: 4100 - PORT: 4000 - # Uplink from strict/lenient to malware instance within same container - VERDACCIO_MALWARE_UPLINK_URL: http://localhost:4875/ - # Decision API URL as seen from Verdaccio inside the same container - SECURITY_DECISION_API_URL: http://localhost:4000 - VERDACCIO_REDIS_URL: redis://redis:6379 - depends_on: - - mongo - - redis - ports: - - "4000:4000" # security-decision-api - - "4100:4100" # access-admin-api - - "4873:4873" # Verdaccio strict - - "4874:4874" # Verdaccio lenient - - "4875:4875" # Verdaccio malware - - "4173:4173" # security dashboard - - "4174:4174" # access-admin dashboard - -volumes: - mongo-data: diff --git a/secure-registry/services/access-admin-api/package.json b/secure-registry/services/access-admin-api/package.json deleted file mode 100644 index c1191e7..0000000 --- a/secure-registry/services/access-admin-api/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@secure-registry/access-admin-api", - "version": "0.0.1", - "description": "Access request and admin API for secure registry tokens (Express 5).", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc -p tsconfig.json", - "start": "node dist/index.js" - }, - "private": true, - "dependencies": { - "express": "^5.0.0", - "cors": "^2.8.5", - "mongodb": "^6.9.0", - "dotenv": "^16.4.5", - "ioredis": "^5.4.1" - }, - "devDependencies": { - "typescript": "^5.6.0", - "tsx": "^4.19.0", - "@types/node": "^20.17.0", - "@types/express": "^4.17.21", - "@types/cors": "^2.8.17" - } -} diff --git a/secure-registry/services/access-admin-api/src/index.ts b/secure-registry/services/access-admin-api/src/index.ts deleted file mode 100644 index aa9f86e..0000000 --- a/secure-registry/services/access-admin-api/src/index.ts +++ /dev/null @@ -1,671 +0,0 @@ -import path from 'path'; -import dotenv from 'dotenv'; -import { createHmac } from 'crypto'; - -// Load root .env first (repo root), then local .env overrides -dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); -dotenv.config(); - -import express, { Request, Response, NextFunction } from 'express'; -import cors from 'cors'; -import Redis from 'ioredis'; -import { - AccessRequestDoc, - AccessRequestStatus, - TokenDoc, - createAccessRequest, - createOrg, - createToken, - findAccessRequestById, - generateRawToken, - hashToken, - listAccessRequests, - saveAccessRequest, - getTokensByIds, - updateTokenAuditRateLimit -} from './db'; - -const app = express(); -// Use a dedicated env var for this service to avoid conflicts with Procfile/hivemind -// which may inject a generic PORT. Default to 4100. -const PORT = process.env.ACCESS_ADMIN_API_PORT || 4100; - -const ADMIN_USERNAME = process.env.ACCESS_ADMIN_USERNAME || 'admin'; -const ADMIN_PASSWORD = process.env.ACCESS_ADMIN_PASSWORD || 'admin'; -const ADMIN_SECRET = process.env.ACCESS_ADMIN_SECRET || 'dev-admin-secret'; - -const ACCESS_REQUEST_PORTAL_URL = - process.env.ACCESS_REQUEST_PORTAL_URL || 'http://localhost:5173'; - -// Audit (new package scan) rate limit defaults & Redis config (must match security-decision-api) -const DEFAULT_AUDIT_WINDOW_SECONDS = Number( - process.env.AUDIT_RATE_LIMIT_WINDOW_SECONDS || 60 * 60 * 24 -); - -const DEFAULT_AUDIT_MAX_REQUESTS = Number( - process.env.AUDIT_RATE_LIMIT_MAX_REQUESTS || 400 -); - -const AUDIT_RATE_LIMIT_REDIS_URL = - process.env.AUDIT_RATE_LIMIT_REDIS_URL || 'redis://127.0.0.1:6379/0'; - -const auditRedis = new Redis(AUDIT_RATE_LIMIT_REDIS_URL); - -app.use(cors()); -app.use(express.json()); - -// --- Types for API requests/responses --- - -interface AccessRequestCreateBody { - email: string; - orgName?: string; -} - -interface TokenUsageInfo { - currentWindowCount: number; - windowSeconds: number; - maxRequests: number; - windowResetAt: string; - last24hCount: number; - isUsingDefaultLimit: boolean; -} - -interface AccessRequestItem { - id: string; - email: string; - orgName: string; - status: AccessRequestStatus; - createdAt: string; - approvedAt?: string | null; - rejectedAt?: string | null; - tokenLast4?: string | null; - tokenId?: string | null; - tokenUsage?: TokenUsageInfo | null; -} - -interface AccessRequestsListResponse { - items: AccessRequestItem[]; - total: number; - page: number; - pageSize: number; -} - -interface ApproveAccessRequestResponse extends AccessRequestItem { - token: string; -} - -interface AdminLoginBody { - username: string; - password: string; -} - -interface AdminLoginResponse { - token: string; - username: string; -} - -// --- Helpers --- - -function getCurrentWindowBounds(windowSeconds: number): { - bucketStart: number; - bucketEnd: number; -} { - const nowSeconds = Math.floor(Date.now() / 1000); - const bucketStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds; - return { bucketStart, bucketEnd: bucketStart + windowSeconds }; -} - -function formatDateDay(now = new Date()): string { - const y = now.getUTCFullYear(); - const m = String(now.getUTCMonth() + 1).padStart(2, '0'); - const d = String(now.getUTCDate()).padStart(2, '0'); - return `${y}${m}${d}`; -} - -function getEffectiveAuditConfigForToken(token: TokenDoc | null | undefined): { - windowSeconds: number; - maxRequests: number; - isUsingDefaultLimit: boolean; -} { - const windowSeconds = - token?.audit_window_seconds && token.audit_window_seconds > 0 - ? token.audit_window_seconds - : DEFAULT_AUDIT_WINDOW_SECONDS; - - const maxRequests = - token?.audit_max_requests && token.audit_max_requests > 0 - ? token.audit_max_requests - : DEFAULT_AUDIT_MAX_REQUESTS; - - const isUsingDefaultLimit = - !token?.audit_window_seconds && !token?.audit_max_requests; - - return { windowSeconds, maxRequests, isUsingDefaultLimit }; -} - -function mapDocToItem( - doc: AccessRequestDoc, - tokenId?: string | null, - tokenUsage?: TokenUsageInfo | null -): AccessRequestItem { - return { - id: String(doc._id), - email: doc.email, - orgName: doc.org_name, - status: doc.status, - createdAt: doc.created_at.toISOString(), - approvedAt: doc.approved_at ? doc.approved_at.toISOString() : null, - rejectedAt: doc.rejected_at ? doc.rejected_at.toISOString() : null, - tokenLast4: doc.token_last4 ?? null, - tokenId: tokenId ?? (doc.token_id ? String(doc.token_id) : null), - tokenUsage: tokenUsage ?? null - }; -} - -function isValidEmail(email: string): boolean { - const trimmed = email.trim(); - // Simple, non-exhaustive email check - return /.+@.+\..+/.test(trimmed); -} - -function deriveOrgName(email: string, orgName?: string): string { - if (orgName && orgName.trim().length > 0) { - return orgName.trim(); - } - - const atIndex = email.indexOf('@'); - if (atIndex > 0 && atIndex < email.length - 1) { - return email.slice(atIndex + 1).trim(); - } - - return email.trim(); -} - -function createAdminToken(username: string): string { - const ts = Date.now().toString(); - const payload = `${username}|${ts}`; - const sig = createHmac('sha256', ADMIN_SECRET).update(payload).digest('base64url'); - return `${username}.${ts}.${sig}`; -} - -function verifyAdminToken(token: string): string | null { - const parts = token.split('.'); - if (parts.length !== 3) return null; - const [username, ts, sig] = parts; - const payload = `${username}|${ts}`; - const expected = createHmac('sha256', ADMIN_SECRET).update(payload).digest('base64url'); - if (sig !== expected) return null; - - const issuedAt = Number.parseInt(ts, 10); - if (!Number.isFinite(issuedAt)) return null; - const maxAgeMs = 24 * 60 * 60 * 1000; // 24h - if (Date.now() - issuedAt > maxAgeMs) return null; - - return username; -} - -function requireAdmin( - req: Request, - res: Response, - next: NextFunction -): void { - const header = req.header('authorization') || req.header('Authorization') || ''; - const parts = header.split(' '); - const token = parts.length === 2 && /^Bearer$/i.test(parts[0]) ? parts[1] : ''; - - if (!token) { - res.status(401).json({ error: 'unauthorized' }); - return; - } - - const username = verifyAdminToken(token); - if (!username) { - res.status(401).json({ error: 'unauthorized' }); - return; - } - - (req as any).adminUser = username; - next(); -} - -// --- Public routes --- - -/** - * POST /api/access-requests - * Create a new access request for a given email/orgName. - */ -app.post( - '/api/access-requests', - async ( - req: Request, - res: Response, - next: NextFunction - ) => { - try { - const { email, orgName } = req.body || ({} as AccessRequestCreateBody); - - if (!email || typeof email !== 'string' || !isValidEmail(email)) { - return res.status(400).json({ error: 'invalid_email' }); - } - - const finalOrgName = deriveOrgName(email, orgName); - const doc = await createAccessRequest(email, finalOrgName); - - const item = mapDocToItem(doc); - return res.status(201).json(item); - } catch (err) { - return next(err); - } - } -); - -/** - * Redirect /request-access to the dedicated request access portal SPA. - * The target URL is configurable via ACCESS_REQUEST_PORTAL_URL. - */ -app.get('/request-access', (_req: Request, res: Response) => { - res.redirect(302, ACCESS_REQUEST_PORTAL_URL); -}); - -// --- Admin auth routes --- - -app.post( - '/api/admin/login', - async ( - req: Request, - res: Response, - next: NextFunction - ) => { - try { - const { username, password } = req.body || ({} as AdminLoginBody); - - if ( - !username || - typeof username !== 'string' || - !password || - typeof password !== 'string' - ) { - return res.status(400).json({ - error: 'invalid_body', - message: 'username and password are required' - }); - } - - if (username !== ADMIN_USERNAME || password !== ADMIN_PASSWORD) { - return res.status(401).json({ - error: 'invalid_credentials', - message: 'Invalid username or password' - }); - } - - const token = createAdminToken(username); - return res.json({ token, username }); - } catch (err) { - return next(err); - } - } -); - -// --- Admin routes --- - -app.get( - '/api/admin/access-requests', - requireAdmin, - async (req: Request, res: Response, next: NextFunction) => { - try { - const rawPage = req.query.page; - const rawPageSize = req.query.pageSize; - const rawStatus = req.query.status; - - const page = Math.max(1, Number.parseInt(String(rawPage ?? '1'), 10) || 1); - const pageSizeRaw = Number.parseInt(String(rawPageSize ?? '20'), 10) || 20; - const pageSize = Math.min(Math.max(1, pageSizeRaw), 100); - - let status: AccessRequestStatus | undefined; - if (typeof rawStatus === 'string' && rawStatus.length > 0) { - if (rawStatus === 'PENDING' || rawStatus === 'APPROVED' || rawStatus === 'REJECTED') { - status = rawStatus; - } else { - return res.status(400).json({ - error: 'invalid_status', - message: 'Invalid status filter' - }); - } - } - - const { items, total } = await listAccessRequests(status, page, pageSize); - - // Enrich with token usage (current window + last 24h) when token ids are present - const tokenIds = Array.from( - new Set( - items - .map((doc) => (doc.token_id ? String(doc.token_id) : null)) - .filter((id): id is string => !!id) - ) - ); - - let tokensById = new Map(); - let usageByTokenId = new Map(); - - if (tokenIds.length > 0) { - const tokens = await getTokensByIds(tokenIds); - tokensById = new Map(tokens.map((t) => [String(t._id), t])); - - const day = formatDateDay(); - - for (const tokenId of tokenIds) { - const tokenDoc = tokensById.get(tokenId) ?? null; - const { windowSeconds, maxRequests, isUsingDefaultLimit } = - getEffectiveAuditConfigForToken(tokenDoc); - - const { bucketStart, bucketEnd } = getCurrentWindowBounds(windowSeconds); - const windowKey = `audit:new:${tokenId}:win:${bucketStart}`; - const dayKey = `audit:new:${tokenId}:d:${day}`; - - let currentWindowCount = 0; - let last24hCount = 0; - - try { - const [winRaw, dayRaw] = await Promise.all([ - auditRedis.get(windowKey), - auditRedis.get(dayKey) - ]); - currentWindowCount = winRaw ? Number(winRaw) || 0 : 0; - last24hCount = dayRaw ? Number(dayRaw) || 0 : 0; - } catch (err) { - // eslint-disable-next-line no-console - console.error('failed to read audit usage from redis', err); - } - - const usage: TokenUsageInfo = { - currentWindowCount, - windowSeconds, - maxRequests, - windowResetAt: new Date(bucketEnd * 1000).toISOString(), - last24hCount, - isUsingDefaultLimit - }; - - usageByTokenId.set(tokenId, usage); - } - } - - const mapped: AccessRequestItem[] = items.map((doc) => { - const tokenId = doc.token_id ? String(doc.token_id) : null; - const usage = tokenId ? usageByTokenId.get(tokenId) ?? null : null; - return mapDocToItem(doc, tokenId, usage); - }); - - return res.json({ - items: mapped, - total, - page, - pageSize - }); - } catch (err) { - return next(err); - } - } -); - -app.post( - '/api/admin/access-requests/:id/approve', - requireAdmin, - async ( - req: Request<{ id: string }>, - res: Response, - next: NextFunction - ) => { - try { - const { id } = req.params; - const doc = await findAccessRequestById(id); - - if (!doc) { - return res.status(404).json({ - // @ts-expect-error returning error shape - error: 'not_found', - message: 'Access request not found' - }); - } - - if (doc.status !== 'PENDING') { - return res.status(400).json({ - // @ts-expect-error returning error shape - error: 'already_processed', - message: 'Access request is not pending' - }); - } - - const now = new Date(); - - // One org per request - const org = await createOrg(doc.org_name); - const rawToken = generateRawToken(); - const tokenHash = hashToken(rawToken); - const token = await createToken(org._id, tokenHash); - - doc.status = 'APPROVED'; - doc.approved_at = now; - doc.updated_at = now; - doc.org_id = org._id; - doc.token_id = token._id; - doc.token_last4 = rawToken.slice(-4); - - await saveAccessRequest(doc); - - const item = mapDocToItem(doc); - const response: ApproveAccessRequestResponse = { - ...item, - token: rawToken - }; - - return res.json(response); - } catch (err) { - return next(err); - } - } -); - -app.post( - '/api/admin/access-requests/:id/reject', - requireAdmin, - async ( - req: Request<{ id: string }>, - res: Response, - next: NextFunction - ) => { - try { - const { id } = req.params; - const doc = await findAccessRequestById(id); - - if (!doc) { - return res.status(404).json({ - // @ts-expect-error returning error shape - error: 'not_found', - message: 'Access request not found' - }); - } - - if (doc.status !== 'PENDING') { - return res.status(400).json({ - // @ts-expect-error returning error shape - error: 'already_processed', - message: 'Access request is not pending' - }); - } - - const now = new Date(); - doc.status = 'REJECTED'; - doc.rejected_at = now; - doc.updated_at = now; - - await saveAccessRequest(doc); - - const item = mapDocToItem(doc); - return res.json(item); - } catch (err) { - return next(err); - } - } -); - -interface UpdateTokenAuditRateLimitBody { - windowSeconds?: number | null; - maxRequests?: number | null; -} - -app.post( - '/api/admin/tokens/:id/audit-rate-limit', - requireAdmin, - async ( - req: Request<{ id: string }, unknown, UpdateTokenAuditRateLimitBody>, - res: Response, - next: NextFunction - ) => { - try { - const { id } = req.params; - const { windowSeconds, maxRequests } = req.body || {}; - - let parsedWindow: number | null | undefined; - if (windowSeconds === null || windowSeconds === undefined) { - parsedWindow = null; - } else if (typeof windowSeconds === 'number' && Number.isFinite(windowSeconds)) { - if (windowSeconds <= 0 || windowSeconds > 60 * 60 * 24) { - return res.status(400).json({ - error: 'invalid_windowSeconds', - message: 'windowSeconds must be between 1 and 86400 seconds or null' - }); - } - parsedWindow = Math.floor(windowSeconds); - } else { - return res.status(400).json({ - error: 'invalid_windowSeconds', - message: 'windowSeconds must be a number or null' - }); - } - - let parsedMax: number | null | undefined; - if (maxRequests === null || maxRequests === undefined) { - parsedMax = null; - } else if (typeof maxRequests === 'number' && Number.isFinite(maxRequests)) { - if (maxRequests <= 0 || maxRequests > 1000000) { - return res.status(400).json({ - error: 'invalid_maxRequests', - message: 'maxRequests must be between 1 and 1000000 or null' - }); - } - parsedMax = Math.floor(maxRequests); - } else { - return res.status(400).json({ - error: 'invalid_maxRequests', - message: 'maxRequests must be a number or null' - }); - } - - await updateTokenAuditRateLimit(id, parsedWindow, parsedMax); - - return res.status(204).send(); - } catch (err: any) { - if (err && err.message === 'invalid_token_id') { - return res.status(400).json({ - error: 'invalid_token_id', - message: 'Invalid token id' - }); - } - return next(err); - } - } -); - -// --- Health & error handling --- - -async function checkReadiness(): Promise<{ ok: boolean; details?: unknown }> { - try { - // Lightly touch MongoDB by listing access requests with a tiny page - await listAccessRequests(undefined, 1, 1); - } catch (err) { - return { ok: false, details: { mongo: 'unhealthy', error: (err as Error).message } }; - } - - try { - // Check Redis connectivity for audit usage stats (simple GET on a dummy key) - await auditRedis.get('health:probe'); - } catch (err) { - return { ok: false, details: { redis: 'unhealthy', error: (err as Error).message } }; - } - - return { ok: true }; -} - -// Liveness probe: process is up and can handle requests -app.get('/health/live', (_req: Request, res: Response) => { - res.json({ - status: 'ok', - service: 'access-admin-api', - type: 'live', - version: '0.1.0' - }); -}); - -// Readiness probe: dependencies (Mongo, Redis) are reachable -app.get('/health/ready', async (_req: Request, res: Response) => { - const result = await checkReadiness(); - if (!result.ok) { - return res.status(503).json({ - status: 'error', - service: 'access-admin-api', - type: 'ready', - version: '0.1.0', - details: result.details - }); - } - - return res.json({ - status: 'ok', - service: 'access-admin-api', - type: 'ready', - version: '0.1.0' - }); -}); - -// Backwards-compatible health endpoint (acts as readiness) -app.get('/health', async (_req: Request, res: Response) => { - const result = await checkReadiness(); - if (!result.ok) { - return res.status(503).json({ - status: 'error', - service: 'access-admin-api', - type: 'ready', - version: '0.1.0', - details: result.details - }); - } - - return res.json({ - status: 'ok', - service: 'access-admin-api', - type: 'ready', - version: '0.1.0' - }); -}); - -app.use( - ( - err: Error, - _req: Request, - res: Response, - _next: NextFunction - ) => { - // eslint-disable-next-line no-console - console.error(err); - res.status(500).json({ - error: 'internal_error', - message: err.message - }); - } -); - -app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`access-admin-api listening on http://localhost:${PORT}`); -}); diff --git a/secure-registry/services/access-admin-api/src/ioredis-shim.d.ts b/secure-registry/services/access-admin-api/src/ioredis-shim.d.ts deleted file mode 100644 index d4bb6a4..0000000 --- a/secure-registry/services/access-admin-api/src/ioredis-shim.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Minimal ambient module declaration for ioredis so that TypeScript -// in this service can compile without having to resolve the full types. -// The security-decision-api service already depends on ioredis and uses -// its bundled typings; here we only rely on a small subset of the API. - -declare module 'ioredis' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - export default class Redis { - constructor(url: string); - // We only use a few methods in this service; type them loosely. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - incr(key: string): Promise; - expire(key: string, seconds: number): Promise; - get(key: string): Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on(event: string, listener: (...args: any[]) => void): void; - } -} diff --git a/secure-registry/services/security-decision-api/tsconfig.json b/secure-registry/services/security-decision-api/tsconfig.json deleted file mode 100644 index f345b2d..0000000 --- a/secure-registry/services/security-decision-api/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src"] -} \ No newline at end of file diff --git a/secure-registry/services/security-workers/package.json b/secure-registry/services/security-workers/package.json deleted file mode 100644 index ff7a8d8..0000000 --- a/secure-registry/services/security-workers/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@secure-registry/security-workers", - "version": "0.0.1", - "description": "Security scanning workers for the Verdaccio security gate (Milestone 4).", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc -p tsconfig.json", - "start": "node dist/index.js" - }, - "private": true, - "dependencies": { - "@nodesecure/js-x-ray": "^7.0.0", - "@ai-sdk/openai": "^1.0.0", - "ai": "^4.0.0", - "bullmq": "^5.12.0", - "ioredis": "^5.4.1", - "mongodb": "^6.9.0", - "tar": "^7.4.3", - "undici": "^6.19.2", - "zod": "^3.23.8", - "dotenv": "^16.4.5" - }, - "devDependencies": { - "typescript": "^5.6.0", - "tsx": "^4.19.0", - "@types/node": "^20.17.0" - } -} diff --git a/secure-registry/services/security-workers/tsconfig.json b/secure-registry/services/security-workers/tsconfig.json deleted file mode 100644 index f345b2d..0000000 --- a/secure-registry/services/security-workers/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src"] -} \ No newline at end of file diff --git a/secure-registry/services/security-decision-api/package.json b/services/api/package.json similarity index 70% rename from secure-registry/services/security-decision-api/package.json rename to services/api/package.json index ff502b7..f94c3b4 100644 --- a/secure-registry/services/security-decision-api/package.json +++ b/services/api/package.json @@ -1,7 +1,7 @@ { - "name": "@secure-registry/security-decision-api", + "name": "@secure-registry/api", "version": "0.0.1", - "description": "Mocked security decision service for Verdaccio security gate (Milestone 2, Express 5).", + "description": "Unified API service for security decisions, access admin, and background workers.", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { @@ -14,12 +14,17 @@ }, "private": true, "dependencies": { - "express": "^5.0.0", + "express": "^5.2.0", "cors": "^2.8.5", "mongodb": "^6.9.0", "bullmq": "^5.12.0", "ioredis": "^5.4.1", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "undici": "^7.0.0", + "tar": "^7.4.3", + "@ai-sdk/openai": "^1.0.0", + "ai": "^4.0.0", + "zod": "^3.23.8" }, "devDependencies": { "typescript": "^5.6.0", diff --git a/secure-registry/services/access-admin-api/src/db.ts b/services/api/src/access-admin-db.ts similarity index 100% rename from secure-registry/services/access-admin-api/src/db.ts rename to services/api/src/access-admin-db.ts diff --git a/services/api/src/access-admin.ts b/services/api/src/access-admin.ts new file mode 100644 index 0000000..c6a4d93 --- /dev/null +++ b/services/api/src/access-admin.ts @@ -0,0 +1,588 @@ +import { createHmac } from 'crypto'; +import type { Request, Response, NextFunction, Router } from 'express'; +import Redis from 'ioredis'; +import { + AccessRequestDoc, + AccessRequestStatus, + TokenDoc, + createAccessRequest, + createOrg, + createToken, + findAccessRequestById, + generateRawToken, + hashToken, + listAccessRequests, + saveAccessRequest, + getTokensByIds, + updateTokenAuditRateLimit +} from './access-admin-db'; + +// Use a dedicated env var for this service to avoid conflicts with Procfile/hivemind +// which may inject a generic PORT. Historically defaulted to 4100. +const ADMIN_USERNAME = process.env.ACCESS_ADMIN_USERNAME || 'admin'; +const ADMIN_PASSWORD = process.env.ACCESS_ADMIN_PASSWORD || 'admin'; +const ADMIN_SECRET = process.env.ACCESS_ADMIN_SECRET || 'dev-admin-secret'; + +const ACCESS_REQUEST_PORTAL_URL = + process.env.ACCESS_REQUEST_PORTAL_URL || 'http://localhost:5173'; + +// Audit (new package scan) rate limit defaults & Redis config (must match security-decision-api) +const DEFAULT_AUDIT_WINDOW_SECONDS = Number( + process.env.AUDIT_RATE_LIMIT_WINDOW_SECONDS || 60 * 60 * 24 +); + +const DEFAULT_AUDIT_MAX_REQUESTS = Number( + process.env.AUDIT_RATE_LIMIT_MAX_REQUESTS || 400 +); + +const AUDIT_RATE_LIMIT_REDIS_URL = + process.env.AUDIT_RATE_LIMIT_REDIS_URL || 'redis://127.0.0.1:6379/0'; + +const auditRedis = new Redis(AUDIT_RATE_LIMIT_REDIS_URL); + +// --- Types for API requests/responses --- + +interface AccessRequestCreateBody { + email: string; + orgName?: string; +} + +interface TokenUsageInfo { + currentWindowCount: number; + windowSeconds: number; + maxRequests: number; + windowResetAt: string; + last24hCount: number; + isUsingDefaultLimit: boolean; +} + +interface AccessRequestItem { + id: string; + email: string; + orgName: string; + status: AccessRequestStatus; + createdAt: string; + approvedAt?: string | null; + rejectedAt?: string | null; + tokenLast4?: string | null; + tokenId?: string | null; + tokenUsage?: TokenUsageInfo | null; +} + +interface AccessRequestsListResponse { + items: AccessRequestItem[]; + total: number; + page: number; + pageSize: number; +} + +interface ApproveAccessRequestResponse extends AccessRequestItem { + token: string; +} + +interface AdminLoginBody { + username: string; + password: string; +} + +interface AdminLoginResponse { + token: string; + username: string; +} + +// --- Helpers --- + +function getCurrentWindowBounds(windowSeconds: number): { + bucketStart: number; + bucketEnd: number; +} { + const nowSeconds = Math.floor(Date.now() / 1000); + const bucketStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds; + return { bucketStart, bucketEnd: bucketStart + windowSeconds }; +} + +function formatDateDay(now = new Date()): string { + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + return `${y}${m}${d}`; +} + +function getEffectiveAuditConfigForToken(token: TokenDoc | null | undefined): { + windowSeconds: number; + maxRequests: number; + isUsingDefaultLimit: boolean; +} { + const windowSeconds = + token?.audit_window_seconds && token.audit_window_seconds > 0 + ? token.audit_window_seconds + : DEFAULT_AUDIT_WINDOW_SECONDS; + + const maxRequests = + token?.audit_max_requests && token.audit_max_requests > 0 + ? token.audit_max_requests + : DEFAULT_AUDIT_MAX_REQUESTS; + + const isUsingDefaultLimit = + !token?.audit_window_seconds && !token?.audit_max_requests; + + return { windowSeconds, maxRequests, isUsingDefaultLimit }; +} + +function mapDocToItem( + doc: AccessRequestDoc, + tokenId?: string | null, + tokenUsage?: TokenUsageInfo | null +): AccessRequestItem { + return { + id: String(doc._id), + email: doc.email, + orgName: doc.org_name, + status: doc.status, + createdAt: doc.created_at.toISOString(), + approvedAt: doc.approved_at ? doc.approved_at.toISOString() : null, + rejectedAt: doc.rejected_at ? doc.rejected_at.toISOString() : null, + tokenLast4: doc.token_last4 ?? null, + tokenId: tokenId ?? (doc.token_id ? String(doc.token_id) : null), + tokenUsage: tokenUsage ?? null + }; +} + +function isValidEmail(email: string): boolean { + const trimmed = email.trim(); + // Simple, non-exhaustive email check + return /.+@.+\..+/.test(trimmed); +} + +function deriveOrgName(email: string, orgName?: string): string { + if (orgName && orgName.trim().length > 0) { + return orgName.trim(); + } + + const atIndex = email.indexOf('@'); + if (atIndex > 0 && atIndex < email.length - 1) { + return email.slice(atIndex + 1).trim(); + } + + return email.trim(); +} + +function createAdminToken(username: string): string { + const ts = Date.now().toString(); + const payload = `${username}|${ts}`; + const sig = createHmac('sha256', ADMIN_SECRET).update(payload).digest('base64url'); + return `${username}.${ts}.${sig}`; +} + +function verifyAdminToken(token: string): string | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [username, ts, sig] = parts; + const payload = `${username}|${ts}`; + const expected = createHmac('sha256', ADMIN_SECRET).update(payload).digest('base64url'); + if (sig !== expected) return null; + + const issuedAt = Number.parseInt(ts, 10); + if (!Number.isFinite(issuedAt)) return null; + const maxAgeMs = 24 * 60 * 60 * 1000; // 24h + if (Date.now() - issuedAt > maxAgeMs) return null; + + return username; +} + +function requireAdmin( + req: Request, + res: Response, + next: NextFunction +): void { + const header = req.header('authorization') || req.header('Authorization') || ''; + const parts = header.split(' '); + const token = parts.length === 2 && /^Bearer$/i.test(parts[0]) ? parts[1] : ''; + + if (!token) { + res.status(401).json({ error: 'unauthorized' }); + return; + } + + const username = verifyAdminToken(token); + if (!username) { + res.status(401).json({ error: 'unauthorized' }); + return; + } + + (req as any).adminUser = username; + next(); +} + +// --- Route registration --- + +export function registerAccessAdminRoutes(router: Router): void { + // Public routes + + /** + * POST /api/access-requests + * Create a new access request for a given email/orgName. + */ + router.post( + '/api/access-requests', + async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const { email, orgName } = req.body || ({} as AccessRequestCreateBody); + + if (!email || typeof email !== 'string' || !isValidEmail(email)) { + return res.status(400).json({ error: 'invalid_email' }); + } + + const finalOrgName = deriveOrgName(email, orgName); + const doc = await createAccessRequest(email, finalOrgName); + + const item = mapDocToItem(doc); + return res.status(201).json(item); + } catch (err) { + return next(err); + } + } + ); + + /** + * Redirect /request-access to the dedicated request access portal SPA. + * The target URL is configurable via ACCESS_REQUEST_PORTAL_URL. + */ + router.get('/request-access', (_req: Request, res: Response) => { + res.redirect(302, ACCESS_REQUEST_PORTAL_URL); + }); + + // Admin auth routes + + router.post( + '/api/admin/login', + async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const { username, password } = req.body || ({} as AdminLoginBody); + + if ( + !username || + typeof username !== 'string' || + !password || + typeof password !== 'string' + ) { + return res.status(400).json({ + error: 'invalid_body', + message: 'username and password are required' + }); + } + + if (username !== ADMIN_USERNAME || password !== ADMIN_PASSWORD) { + return res.status(401).json({ + error: 'invalid_credentials', + message: 'Invalid username or password' + }); + } + + const token = createAdminToken(username); + return res.json({ token, username }); + } catch (err) { + return next(err); + } + } + ); + + // Admin routes + + router.get( + '/api/admin/access-requests', + requireAdmin, + async (req: Request, res: Response, next: NextFunction) => { + try { + const rawPage = req.query.page; + const rawPageSize = req.query.pageSize; + const rawStatus = req.query.status; + + const page = Math.max(1, Number.parseInt(String(rawPage ?? '1'), 10) || 1); + const pageSizeRaw = Number.parseInt(String(rawPageSize ?? '20'), 10) || 20; + const pageSize = Math.min(Math.max(1, pageSizeRaw), 100); + + let status: AccessRequestStatus | undefined; + if (typeof rawStatus === 'string' && rawStatus.length > 0) { + if (rawStatus === 'PENDING' || rawStatus === 'APPROVED' || rawStatus === 'REJECTED') { + status = rawStatus; + } else { + return res.status(400).json({ + error: 'invalid_status', + message: 'Invalid status filter' + }); + } + } + + const { items, total } = await listAccessRequests(status, page, pageSize); + + // Enrich with token usage (current window + last 24h) when token ids are present + const tokenIds = Array.from( + new Set( + items + .map((doc) => (doc.token_id ? String(doc.token_id) : null)) + .filter((id): id is string => !!id) + ) + ); + + let tokensById = new Map(); + let usageByTokenId = new Map(); + + if (tokenIds.length > 0) { + const tokens = await getTokensByIds(tokenIds); + tokensById = new Map(tokens.map((t) => [String(t._id), t])); + + const day = formatDateDay(); + + for (const tokenId of tokenIds) { + const tokenDoc = tokensById.get(tokenId) ?? null; + const { windowSeconds, maxRequests, isUsingDefaultLimit } = + getEffectiveAuditConfigForToken(tokenDoc); + + const { bucketStart, bucketEnd } = getCurrentWindowBounds(windowSeconds); + const windowKey = `audit:new:${tokenId}:win:${bucketStart}`; + const dayKey = `audit:new:${tokenId}:d:${day}`; + + let currentWindowCount = 0; + let last24hCount = 0; + + try { + const [winRaw, dayRaw] = await Promise.all([ + auditRedis.get(windowKey), + auditRedis.get(dayKey) + ]); + currentWindowCount = winRaw ? Number(winRaw) || 0 : 0; + last24hCount = dayRaw ? Number(dayRaw) || 0 : 0; + } catch (err) { + // eslint-disable-next-line no-console + console.error('failed to read audit usage from redis', err); + } + + const usage: TokenUsageInfo = { + currentWindowCount, + windowSeconds, + maxRequests, + windowResetAt: new Date(bucketEnd * 1000).toISOString(), + last24hCount, + isUsingDefaultLimit + }; + + usageByTokenId.set(tokenId, usage); + } + } + + const mapped: AccessRequestItem[] = items.map((doc) => { + const tokenId = doc.token_id ? String(doc.token_id) : null; + const usage = tokenId ? usageByTokenId.get(tokenId) ?? null : null; + return mapDocToItem(doc, tokenId, usage); + }); + + return res.json({ + items: mapped, + total, + page, + pageSize + }); + } catch (err) { + return next(err); + } + } + ); + + router.post( + '/api/admin/access-requests/:id/approve', + requireAdmin, + async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction + ) => { + try { + const { id } = req.params; + const doc = await findAccessRequestById(id); + + if (!doc) { + return res.status(404).json({ + // @ts-expect-error returning error shape + error: 'not_found', + message: 'Access request not found' + }); + } + + if (doc.status !== 'PENDING') { + return res.status(400).json({ + // @ts-expect-error returning error shape + error: 'already_processed', + message: 'Access request is not pending' + }); + } + + const now = new Date(); + + // One org per request + const org = await createOrg(doc.org_name); + const rawToken = generateRawToken(); + const tokenHash = hashToken(rawToken); + const token = await createToken(org._id, tokenHash); + + doc.status = 'APPROVED'; + doc.approved_at = now; + doc.updated_at = now; + doc.org_id = org._id; + doc.token_id = token._id; + doc.token_last4 = rawToken.slice(-4); + + await saveAccessRequest(doc); + + const item = mapDocToItem(doc); + const response: ApproveAccessRequestResponse = { + ...item, + token: rawToken + }; + + return res.json(response); + } catch (err) { + return next(err); + } + } + ); + + router.post( + '/api/admin/access-requests/:id/reject', + requireAdmin, + async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction + ) => { + try { + const { id } = req.params; + const doc = await findAccessRequestById(id); + + if (!doc) { + return res.status(404).json({ + // @ts-expect-error returning error shape + error: 'not_found', + message: 'Access request not found' + }); + } + + if (doc.status !== 'PENDING') { + return res.status(400).json({ + // @ts-expect-error returning error shape + error: 'already_processed', + message: 'Access request is not pending' + }); + } + + const now = new Date(); + doc.status = 'REJECTED'; + doc.rejected_at = now; + doc.updated_at = now; + + await saveAccessRequest(doc); + + const item = mapDocToItem(doc); + return res.json(item); + } catch (err) { + return next(err); + } + } + ); + + interface UpdateTokenAuditRateLimitBody { + windowSeconds?: number | null; + maxRequests?: number | null; + } + + router.post( + '/api/admin/tokens/:id/audit-rate-limit', + requireAdmin, + async ( + req: Request<{ id: string }, unknown, UpdateTokenAuditRateLimitBody>, + res: Response, + next: NextFunction + ) => { + try { + const { id } = req.params; + const { windowSeconds, maxRequests } = req.body || {}; + + let parsedWindow: number | null | undefined; + if (windowSeconds === null || windowSeconds === undefined) { + parsedWindow = null; + } else if (typeof windowSeconds === 'number' && Number.isFinite(windowSeconds)) { + if (windowSeconds <= 0 || windowSeconds > 60 * 60 * 24) { + return res.status(400).json({ + error: 'invalid_windowSeconds', + message: 'windowSeconds must be between 1 and 86400 seconds or null' + }); + } + parsedWindow = Math.floor(windowSeconds); + } else { + return res.status(400).json({ + error: 'invalid_windowSeconds', + message: 'windowSeconds must be a number or null' + }); + } + + let parsedMax: number | null | undefined; + if (maxRequests === null || maxRequests === undefined) { + parsedMax = null; + } else if (typeof maxRequests === 'number' && Number.isFinite(maxRequests)) { + if (maxRequests <= 0 || maxRequests > 1000000) { + return res.status(400).json({ + error: 'invalid_maxRequests', + message: 'maxRequests must be between 1 and 1000000 or null' + }); + } + parsedMax = Math.floor(maxRequests); + } else { + return res.status(400).json({ + error: 'invalid_maxRequests', + message: 'maxRequests must be a number or null' + }); + } + + await updateTokenAuditRateLimit(id, parsedWindow, parsedMax); + + return res.status(204).send(); + } catch (err: any) { + if (err && err.message === 'invalid_token_id') { + return res.status(400).json({ + error: 'invalid_token_id', + message: 'Invalid token id' + }); + } + return next(err); + } + } + ); +} + +// --- Readiness helper for unified API --- + +export async function checkAccessAdminReadiness(): Promise<{ ok: boolean; details?: unknown }> { + try { + // Lightly touch MongoDB by listing access requests with a tiny page + await listAccessRequests(undefined, 1, 1); + } catch (err) { + return { ok: false, details: { mongo: 'unhealthy', error: (err as Error).message } }; + } + + try { + // Check Redis connectivity for audit usage stats (simple GET on a dummy key) + await auditRedis.get('health:probe'); + } catch (err) { + return { ok: false, details: { redis: 'unhealthy', error: (err as Error).message } }; + } + + return { ok: true }; +} diff --git a/secure-registry/services/security-decision-api/src/budget.ts b/services/api/src/budget.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/budget.ts rename to services/api/src/budget.ts diff --git a/secure-registry/services/security-decision-api/src/db.ts b/services/api/src/db.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/db.ts rename to services/api/src/db.ts diff --git a/secure-registry/services/security-decision-api/src/index.ts b/services/api/src/index.ts similarity index 93% rename from secure-registry/services/security-decision-api/src/index.ts rename to services/api/src/index.ts index 6904a4f..f9d02c7 100644 --- a/secure-registry/services/security-decision-api/src/index.ts +++ b/services/api/src/index.ts @@ -1,7 +1,7 @@ import path from 'path'; import dotenv from 'dotenv'; -dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); dotenv.config(); import express, { Request, Response, NextFunction } from 'express'; @@ -22,6 +22,8 @@ import { } from './db'; import { enqueueScanJob } from './queue'; import { checkAndConsumeScanBudget } from './budget'; +import { registerAccessAdminRoutes, checkAccessAdminReadiness } from './access-admin'; +import { startWorkers, checkWorkersReadiness } from './workers'; const app = express(); const PORT = process.env.PORT || 4000; @@ -29,6 +31,9 @@ const PORT = process.env.PORT || 4000; app.use(cors()); app.use(express.json()); +// Register access-admin API routes on the same Express app +registerAccessAdminRoutes(app); + interface BulkReportRequestBody { versions: string[]; } @@ -746,18 +751,42 @@ app.get( ); async function checkReadiness(): Promise<{ ok: boolean; details?: unknown }> { + const details: Record = {}; + + // Core security-decision API dependencies try { - // Check MongoDB connectivity via a lightweight collection access await getSecurityReportsCollection(); } catch (err) { - return { ok: false, details: { mongo: 'unhealthy', error: (err as Error).message } }; + details.securityDecisionApi = { + mongo: 'unhealthy', + error: (err as Error).message + }; + return { ok: false, details }; } try { - // Check Redis connectivity for audit rate limiting (simple GET on a dummy key) await auditRedis.get('health:probe'); } catch (err) { - return { ok: false, details: { redis: 'unhealthy', error: (err as Error).message } }; + details.securityDecisionApi = { + ...(details.securityDecisionApi as object), + redis: 'unhealthy', + error: (err as Error).message + }; + return { ok: false, details }; + } + + // Access-admin API readiness + const accessAdmin = await checkAccessAdminReadiness(); + if (!accessAdmin.ok) { + details.accessAdminApi = accessAdmin.details ?? { status: 'unhealthy' }; + return { ok: false, details }; + } + + // Workers readiness + const workers = await checkWorkersReadiness(); + if (!workers.ok) { + details.workers = workers.details ?? { status: 'unhealthy' }; + return { ok: false, details }; } return { ok: true }; @@ -767,19 +796,19 @@ async function checkReadiness(): Promise<{ ok: boolean; details?: unknown }> { app.get('/health/live', (_req: Request, res: Response) => { res.json({ status: 'ok', - service: 'security-decision-api', + service: 'api', type: 'live', version: '0.1.0' }); }); -// Readiness probe: dependencies (Mongo, Redis) are reachable +// Readiness probe: dependencies (Mongo, Redis, access-admin, workers) are reachable app.get('/health/ready', async (_req: Request, res: Response) => { const result = await checkReadiness(); if (!result.ok) { return res.status(503).json({ status: 'error', - service: 'security-decision-api', + service: 'api', type: 'ready', version: '0.1.0', details: result.details @@ -788,7 +817,7 @@ app.get('/health/ready', async (_req: Request, res: Response) => { return res.json({ status: 'ok', - service: 'security-decision-api', + service: 'api', type: 'ready', version: '0.1.0' }); @@ -800,7 +829,7 @@ app.get('/health', async (_req: Request, res: Response) => { if (!result.ok) { return res.status(503).json({ status: 'error', - service: 'security-decision-api', + service: 'api', type: 'ready', version: '0.1.0', details: result.details @@ -809,7 +838,7 @@ app.get('/health', async (_req: Request, res: Response) => { return res.json({ status: 'ok', - service: 'security-decision-api', + service: 'api', type: 'ready', version: '0.1.0' }); @@ -831,7 +860,13 @@ app.use( } ); +// Start background workers within the same process +startWorkers().catch((err) => { + // eslint-disable-next-line no-console + console.error('Failed to start security-workers from unified API', err); +}); + app.listen(PORT, () => { // eslint-disable-next-line no-console - console.log(`security-decision-api listening on http://localhost:${PORT}`); + console.log(`unified api listening on http://localhost:${PORT}`); }); diff --git a/secure-registry/services/security-workers/src/llm.ts b/services/api/src/llm.ts similarity index 100% rename from secure-registry/services/security-workers/src/llm.ts rename to services/api/src/llm.ts diff --git a/secure-registry/services/security-decision-api/src/queue.ts b/services/api/src/queue.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/queue.ts rename to services/api/src/queue.ts diff --git a/secure-registry/services/security-decision-api/src/scripts/reset-malware-fixtures.ts b/services/api/src/scripts/reset-malware-fixtures.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/scripts/reset-malware-fixtures.ts rename to services/api/src/scripts/reset-malware-fixtures.ts diff --git a/secure-registry/services/security-decision-api/src/scripts/seed-malware-fixtures.ts b/services/api/src/scripts/seed-malware-fixtures.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/scripts/seed-malware-fixtures.ts rename to services/api/src/scripts/seed-malware-fixtures.ts diff --git a/secure-registry/services/security-decision-api/src/scripts/seed-security-data.ts b/services/api/src/scripts/seed-security-data.ts similarity index 100% rename from secure-registry/services/security-decision-api/src/scripts/seed-security-data.ts rename to services/api/src/scripts/seed-security-data.ts diff --git a/secure-registry/services/security-workers/src/index.ts b/services/api/src/workers.ts similarity index 98% rename from secure-registry/services/security-workers/src/index.ts rename to services/api/src/workers.ts index e2bcc97..66c7986 100644 --- a/secure-registry/services/security-workers/src/index.ts +++ b/services/api/src/workers.ts @@ -869,6 +869,11 @@ async function checkReadiness(): Promise<{ ok: boolean; details?: unknown }> { return { ok: true }; } +// Exposed helper so the unified API service can include workers in its readiness checks. +export async function checkWorkersReadiness(): Promise<{ ok: boolean; details?: unknown }> { + return checkReadiness(); +} + async function processJob(job: Job): Promise { const { packageName, version, orgId, policyId } = job.data; // eslint-disable-next-line no-console @@ -1273,8 +1278,16 @@ async function main(): Promise { }); } -main().catch((err) => { - // eslint-disable-next-line no-console - console.error('Fatal error in security-workers', err); - process.exit(1); -}); +// Start workers when invoked directly (backwards compatibility), +// but allow the unified API service to call startWorkers() as well. +export async function startWorkers(): Promise { + await main(); +} + +if (require.main === module) { + startWorkers().catch((err) => { + // eslint-disable-next-line no-console + console.error('Fatal error in security-workers', err); + process.exit(1); + }); +} diff --git a/secure-registry/services/access-admin-api/tsconfig.json b/services/api/tsconfig.json similarity index 62% rename from secure-registry/services/access-admin-api/tsconfig.json rename to services/api/tsconfig.json index 5285d28..5bf9e03 100644 --- a/secure-registry/services/access-admin-api/tsconfig.json +++ b/services/api/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../secure-registry/tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" diff --git a/apps/security-dashboard/index.html b/services/app/index.html similarity index 100% rename from apps/security-dashboard/index.html rename to services/app/index.html diff --git a/services/app/nginx.conf b/services/app/nginx.conf new file mode 100644 index 0000000..53dde27 --- /dev/null +++ b/services/app/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Serve static assets directly + location /assets/ { + try_files $uri =404; + } + + # SPA routes: always serve index.html so the client router can handle + location / { + try_files $uri /index.html; + } +} diff --git a/apps/security-dashboard/package.json b/services/app/package.json similarity index 92% rename from apps/security-dashboard/package.json rename to services/app/package.json index 3173066..5914bc8 100644 --- a/apps/security-dashboard/package.json +++ b/services/app/package.json @@ -1,5 +1,5 @@ { - "name": "@secure-registry/security-dashboard", + "name": "@secure-registry/app", "version": "0.0.1", "description": "Web dashboard to explore security reports for audited npm packages.", "private": true, diff --git a/apps/access-admin-dashboard/postcss.config.cjs b/services/app/postcss.config.cjs similarity index 100% rename from apps/access-admin-dashboard/postcss.config.cjs rename to services/app/postcss.config.cjs diff --git a/apps/security-dashboard/public/android-icon-144x144.png b/services/app/public/android-icon-144x144.png similarity index 100% rename from apps/security-dashboard/public/android-icon-144x144.png rename to services/app/public/android-icon-144x144.png diff --git a/apps/security-dashboard/public/android-icon-192x192.png b/services/app/public/android-icon-192x192.png similarity index 100% rename from apps/security-dashboard/public/android-icon-192x192.png rename to services/app/public/android-icon-192x192.png diff --git a/apps/security-dashboard/public/android-icon-36x36.png b/services/app/public/android-icon-36x36.png similarity index 100% rename from apps/security-dashboard/public/android-icon-36x36.png rename to services/app/public/android-icon-36x36.png diff --git a/apps/security-dashboard/public/android-icon-48x48.png b/services/app/public/android-icon-48x48.png similarity index 100% rename from apps/security-dashboard/public/android-icon-48x48.png rename to services/app/public/android-icon-48x48.png diff --git a/apps/security-dashboard/public/android-icon-72x72.png b/services/app/public/android-icon-72x72.png similarity index 100% rename from apps/security-dashboard/public/android-icon-72x72.png rename to services/app/public/android-icon-72x72.png diff --git a/apps/security-dashboard/public/android-icon-96x96.png b/services/app/public/android-icon-96x96.png similarity index 100% rename from apps/security-dashboard/public/android-icon-96x96.png rename to services/app/public/android-icon-96x96.png diff --git a/apps/security-dashboard/public/apple-icon-114x114.png b/services/app/public/apple-icon-114x114.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-114x114.png rename to services/app/public/apple-icon-114x114.png diff --git a/apps/security-dashboard/public/apple-icon-120x120.png b/services/app/public/apple-icon-120x120.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-120x120.png rename to services/app/public/apple-icon-120x120.png diff --git a/apps/security-dashboard/public/apple-icon-144x144.png b/services/app/public/apple-icon-144x144.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-144x144.png rename to services/app/public/apple-icon-144x144.png diff --git a/apps/security-dashboard/public/apple-icon-152x152.png b/services/app/public/apple-icon-152x152.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-152x152.png rename to services/app/public/apple-icon-152x152.png diff --git a/apps/security-dashboard/public/apple-icon-180x180.png b/services/app/public/apple-icon-180x180.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-180x180.png rename to services/app/public/apple-icon-180x180.png diff --git a/apps/security-dashboard/public/apple-icon-57x57.png b/services/app/public/apple-icon-57x57.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-57x57.png rename to services/app/public/apple-icon-57x57.png diff --git a/apps/security-dashboard/public/apple-icon-60x60.png b/services/app/public/apple-icon-60x60.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-60x60.png rename to services/app/public/apple-icon-60x60.png diff --git a/apps/security-dashboard/public/apple-icon-72x72.png b/services/app/public/apple-icon-72x72.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-72x72.png rename to services/app/public/apple-icon-72x72.png diff --git a/apps/security-dashboard/public/apple-icon-76x76.png b/services/app/public/apple-icon-76x76.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-76x76.png rename to services/app/public/apple-icon-76x76.png diff --git a/apps/security-dashboard/public/apple-icon-precomposed.png b/services/app/public/apple-icon-precomposed.png similarity index 100% rename from apps/security-dashboard/public/apple-icon-precomposed.png rename to services/app/public/apple-icon-precomposed.png diff --git a/apps/security-dashboard/public/apple-icon.png b/services/app/public/apple-icon.png similarity index 100% rename from apps/security-dashboard/public/apple-icon.png rename to services/app/public/apple-icon.png diff --git a/apps/security-dashboard/public/browserconfig.xml b/services/app/public/browserconfig.xml similarity index 100% rename from apps/security-dashboard/public/browserconfig.xml rename to services/app/public/browserconfig.xml diff --git a/apps/security-dashboard/public/favicon-16x16.png b/services/app/public/favicon-16x16.png similarity index 100% rename from apps/security-dashboard/public/favicon-16x16.png rename to services/app/public/favicon-16x16.png diff --git a/apps/security-dashboard/public/favicon-32x32.png b/services/app/public/favicon-32x32.png similarity index 100% rename from apps/security-dashboard/public/favicon-32x32.png rename to services/app/public/favicon-32x32.png diff --git a/apps/security-dashboard/public/favicon-96x96.png b/services/app/public/favicon-96x96.png similarity index 100% rename from apps/security-dashboard/public/favicon-96x96.png rename to services/app/public/favicon-96x96.png diff --git a/apps/security-dashboard/public/favicon.ico b/services/app/public/favicon.ico similarity index 100% rename from apps/security-dashboard/public/favicon.ico rename to services/app/public/favicon.ico diff --git a/apps/security-dashboard/public/manifest.json b/services/app/public/manifest.json similarity index 100% rename from apps/security-dashboard/public/manifest.json rename to services/app/public/manifest.json diff --git a/apps/security-dashboard/public/ms-icon-144x144.png b/services/app/public/ms-icon-144x144.png similarity index 100% rename from apps/security-dashboard/public/ms-icon-144x144.png rename to services/app/public/ms-icon-144x144.png diff --git a/apps/security-dashboard/public/ms-icon-150x150.png b/services/app/public/ms-icon-150x150.png similarity index 100% rename from apps/security-dashboard/public/ms-icon-150x150.png rename to services/app/public/ms-icon-150x150.png diff --git a/apps/security-dashboard/public/ms-icon-310x310.png b/services/app/public/ms-icon-310x310.png similarity index 100% rename from apps/security-dashboard/public/ms-icon-310x310.png rename to services/app/public/ms-icon-310x310.png diff --git a/apps/security-dashboard/public/ms-icon-70x70.png b/services/app/public/ms-icon-70x70.png similarity index 100% rename from apps/security-dashboard/public/ms-icon-70x70.png rename to services/app/public/ms-icon-70x70.png diff --git a/apps/security-dashboard/src/App.tsx b/services/app/src/App.tsx similarity index 100% rename from apps/security-dashboard/src/App.tsx rename to services/app/src/App.tsx diff --git a/apps/access-admin-dashboard/src/App.tsx b/services/app/src/access-admin/App.tsx similarity index 100% rename from apps/access-admin-dashboard/src/App.tsx rename to services/app/src/access-admin/App.tsx diff --git a/apps/access-admin-dashboard/src/api.ts b/services/app/src/access-admin/api.ts similarity index 96% rename from apps/access-admin-dashboard/src/api.ts rename to services/app/src/access-admin/api.ts index 70b08ed..3ab2edb 100644 --- a/apps/access-admin-dashboard/src/api.ts +++ b/services/app/src/access-admin/api.ts @@ -5,7 +5,9 @@ import type { } from './types'; const API_BASE = - import.meta.env.VITE_ACCESS_ADMIN_API_URL ?? 'http://localhost:4100'; + import.meta.env.VITE_ACCESS_ADMIN_API_URL ?? + import.meta.env.VITE_API_BASE_URL ?? + 'http://localhost:4000'; function adminHeaders(token?: string): Record { const headers: Record = { diff --git a/apps/access-admin-dashboard/src/index.css b/services/app/src/access-admin/index.css similarity index 100% rename from apps/access-admin-dashboard/src/index.css rename to services/app/src/access-admin/index.css diff --git a/apps/access-admin-dashboard/src/main.tsx b/services/app/src/access-admin/main.tsx similarity index 100% rename from apps/access-admin-dashboard/src/main.tsx rename to services/app/src/access-admin/main.tsx diff --git a/apps/access-admin-dashboard/src/types.ts b/services/app/src/access-admin/types.ts similarity index 100% rename from apps/access-admin-dashboard/src/types.ts rename to services/app/src/access-admin/types.ts diff --git a/apps/access-admin-dashboard/src/vite-env.d.ts b/services/app/src/access-admin/vite-env.d.ts similarity index 100% rename from apps/access-admin-dashboard/src/vite-env.d.ts rename to services/app/src/access-admin/vite-env.d.ts diff --git a/apps/security-dashboard/src/api.ts b/services/app/src/api.ts similarity index 100% rename from apps/security-dashboard/src/api.ts rename to services/app/src/api.ts diff --git a/apps/security-dashboard/src/components/RiskLevelTag.tsx b/services/app/src/components/RiskLevelTag.tsx similarity index 100% rename from apps/security-dashboard/src/components/RiskLevelTag.tsx rename to services/app/src/components/RiskLevelTag.tsx diff --git a/apps/security-dashboard/src/components/SecurityReportsTable.tsx b/services/app/src/components/SecurityReportsTable.tsx similarity index 100% rename from apps/security-dashboard/src/components/SecurityReportsTable.tsx rename to services/app/src/components/SecurityReportsTable.tsx diff --git a/apps/security-dashboard/src/components/StatusBadge.tsx b/services/app/src/components/StatusBadge.tsx similarity index 100% rename from apps/security-dashboard/src/components/StatusBadge.tsx rename to services/app/src/components/StatusBadge.tsx diff --git a/apps/request-access-portal/src/index.css b/services/app/src/index.css similarity index 100% rename from apps/request-access-portal/src/index.css rename to services/app/src/index.css diff --git a/services/app/src/main.tsx b/services/app/src/main.tsx new file mode 100644 index 0000000..61d3bba --- /dev/null +++ b/services/app/src/main.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import AccessAdminApp from './access-admin/App'; +import RequestAccessApp from './request-access/App'; +import './index.css'; + +function Router() { + const path = window.location.pathname; + + if (path.startsWith('/dashboard/access-admin')) { + return ; + } + + if (path.startsWith('/request-access')) { + return ; + } + + // Default: security dashboard + return ; +} + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +); diff --git a/apps/request-access-portal/src/App.tsx b/services/app/src/request-access/App.tsx similarity index 100% rename from apps/request-access-portal/src/App.tsx rename to services/app/src/request-access/App.tsx diff --git a/apps/request-access-portal/src/api.ts b/services/app/src/request-access/api.ts similarity index 84% rename from apps/request-access-portal/src/api.ts rename to services/app/src/request-access/api.ts index 1f8cd5c..9d5df34 100644 --- a/apps/request-access-portal/src/api.ts +++ b/services/app/src/request-access/api.ts @@ -4,7 +4,10 @@ export interface AccessRequestCreateBody { } const API_BASE = - import.meta.env.VITE_ACCESS_ADMIN_API_URL ?? window.location.origin ?? 'http://localhost:4100'; + import.meta.env.VITE_ACCESS_ADMIN_API_URL ?? + import.meta.env.VITE_API_BASE_URL ?? + window.location.origin ?? + 'http://localhost:4000'; export async function requestAccess(body: AccessRequestCreateBody): Promise { const url = `${API_BASE.replace(/\/$/, '')}/api/access-requests`; diff --git a/apps/security-dashboard/src/index.css b/services/app/src/request-access/index.css similarity index 100% rename from apps/security-dashboard/src/index.css rename to services/app/src/request-access/index.css diff --git a/apps/request-access-portal/src/main.tsx b/services/app/src/request-access/main.tsx similarity index 100% rename from apps/request-access-portal/src/main.tsx rename to services/app/src/request-access/main.tsx diff --git a/apps/request-access-portal/src/vite-env.d.ts b/services/app/src/request-access/vite-env.d.ts similarity index 100% rename from apps/request-access-portal/src/vite-env.d.ts rename to services/app/src/request-access/vite-env.d.ts diff --git a/apps/security-dashboard/src/types.ts b/services/app/src/types.ts similarity index 100% rename from apps/security-dashboard/src/types.ts rename to services/app/src/types.ts diff --git a/apps/security-dashboard/src/vite-env.d.ts b/services/app/src/vite-env.d.ts similarity index 100% rename from apps/security-dashboard/src/vite-env.d.ts rename to services/app/src/vite-env.d.ts diff --git a/apps/access-admin-dashboard/tailwind.config.cjs b/services/app/tailwind.config.cjs similarity index 100% rename from apps/access-admin-dashboard/tailwind.config.cjs rename to services/app/tailwind.config.cjs diff --git a/apps/access-admin-dashboard/tsconfig.json b/services/app/tsconfig.json similarity index 100% rename from apps/access-admin-dashboard/tsconfig.json rename to services/app/tsconfig.json diff --git a/apps/security-dashboard/vite.config.ts b/services/app/vite.config.ts similarity index 100% rename from apps/security-dashboard/vite.config.ts rename to services/app/vite.config.ts