diff --git a/.gitea/actions/setup-pnpm/action.yaml b/.gitea/actions/setup-pnpm/action.yaml new file mode 100644 index 0000000..0a91f3b --- /dev/null +++ b/.gitea/actions/setup-pnpm/action.yaml @@ -0,0 +1,34 @@ +name: setup-pnpm +description: Node 22 + кэш pnpm store / corepack / node_modules / .turbo по хешу lockfile +outputs: + cache-hit: + description: Был ли точный хит кэша (передайте в PNPM_CACHE_HIT для scripts/ci/pnpm-ci.sh) + value: ${{ steps.pnpm-cache.outputs.cache-hit }} +runs: + using: composite + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + - name: Export cache paths + shell: sh + run: sh scripts/ci/export-cache-env.sh + - id: pnpm-hash + shell: bash + run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT" + - id: pnpm-cache + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ${{ env.PNPM_STORE_DIR }} + ${{ env.COREPACK_HOME }} + .turbo + node_modules + apps/web/node_modules + apps/api/node_modules + packages/ui/node_modules + packages/shared/node_modules + packages/db/node_modules + key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} + restore-keys: | + pnpm-${{ runner.os }}- diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 1f83ccc..b6fae19 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -129,6 +129,8 @@ jobs: env: REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }} IMAGE_TAG: latest + # provenance + SBOM как attestations образа (только при push в registry) + ATTEST: "true" VERSION: ${{ steps.meta.outputs.version }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }} SHA_FULL: ${{ github.sha }} diff --git a/.gitea/workflows/nightly-perf.yaml b/.gitea/workflows/nightly-perf.yaml new file mode 100644 index 0000000..f1335b7 --- /dev/null +++ b/.gitea/workflows/nightly-perf.yaml @@ -0,0 +1,49 @@ +# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS. +name: nightly-perf + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + load: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - id: setup + uses: ./.gitea/actions/setup-pnpm + - name: Build image + working-directory: deploy/docker + env: + BUILDX_BAKE_ENTITLEMENTS_FS: "0" + BUILDX_BAKE_FILE_RELATIVE_PATHS: "1" + run: | + set -euxo pipefail + docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal + - name: Run container with production limits + run: | + set -euxo pipefail + docker volume create load-data + docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data' + docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \ + --read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -p 18080:8080 \ + -e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \ + -e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:18080 -e RATE_LIMIT_MAX=1000000 authportal:load + for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done + - name: Load test + env: + PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} + LOAD_PASSWORD: load-admin-pass-1 + run: | + set -euxo pipefail + sh scripts/ci/pnpm-ci.sh + node scripts/perf/load.mjs http://127.0.0.1:18080 authportal-load + - name: Container logs on failure + if: failure() + run: docker logs --tail 100 authportal-load || true diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml index 401f31b..7f1b9c6 100644 --- a/.gitea/workflows/quality.yaml +++ b/.gitea/workflows/quality.yaml @@ -110,7 +110,7 @@ jobs: while IFS= read -r f || [ -n "${f:-}" ]; do [ -z "${f:-}" ] && continue case "$f" in - .gitea/workflows/*|scripts/*) + .gitea/workflows/*|.gitea/actions/*|scripts/*) full_pipeline=true ;; .cursor/*|.claude/*|.codegraph/*|.agents/*) @@ -160,32 +160,11 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - - name: Export cache paths - run: sh scripts/ci/export-cache-env.sh - - id: pnpm-hash - run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT" - - id: pnpm-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ${{ env.PNPM_STORE_DIR }} - ${{ env.COREPACK_HOME }} - .turbo - node_modules - apps/web/node_modules - apps/api/node_modules - packages/ui/node_modules - packages/shared/node_modules - packages/db/node_modules - key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} - restore-keys: | - pnpm-${{ runner.os }}- + - id: setup + uses: ./.gitea/actions/setup-pnpm - name: pnpm install, lint, typecheck, build env: - PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} + PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | set -euxo pipefail sh scripts/ci/pnpm-ci.sh @@ -202,32 +181,11 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - - name: Export cache paths - run: sh scripts/ci/export-cache-env.sh - - id: pnpm-hash - run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT" - - id: pnpm-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ${{ env.PNPM_STORE_DIR }} - ${{ env.COREPACK_HOME }} - .turbo - node_modules - apps/web/node_modules - apps/api/node_modules - packages/ui/node_modules - packages/shared/node_modules - packages/db/node_modules - key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} - restore-keys: | - pnpm-${{ runner.os }}- + - id: setup + uses: ./.gitea/actions/setup-pnpm - name: pnpm install, lint, test, typecheck, build env: - PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} + PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | set -euxo pipefail sh scripts/ci/pnpm-ci.sh @@ -244,33 +202,13 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - - name: Export cache paths - run: sh scripts/ci/export-cache-env.sh - - id: pnpm-hash - run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT" - - id: pnpm-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ${{ env.PNPM_STORE_DIR }} - ${{ env.COREPACK_HOME }} - node_modules - apps/web/node_modules - apps/api/node_modules - packages/ui/node_modules - packages/shared/node_modules - packages/db/node_modules - key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} - restore-keys: | - pnpm-${{ runner.os }}- + - id: setup + uses: ./.gitea/actions/setup-pnpm - name: Lint commit messages env: BASE_SHA: ${{ inputs.base_sha }} HEAD_SHA: ${{ inputs.head_sha }} - PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} + PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | set -euxo pipefail sh scripts/ci/pnpm-ci.sh @@ -323,3 +261,7 @@ jobs: set -euxo pipefail docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke + - name: trivy (HIGH/CRITICAL; информативно, пайплайн не блокирует) + continue-on-error: true + run: | + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 authportal:smoke diff --git a/apps/api/package.json b/apps/api/package.json index e5093e4..530c745 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "dependencies": { "@authportal/db": "workspace:*", "@authportal/shared": "workspace:*", + "@fastify/compress": "^8.3.1", "@fastify/cors": "^11.0.1", "@fastify/formbody": "^9.0.0", "@fastify/helmet": "^13.0.1", @@ -22,22 +23,22 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.2.0", "@node-rs/argon2": "^2.0.2", - "better-sqlite3": "^11.10.0", "@simplewebauthn/server": "^13.3.2", + "better-sqlite3": "^11.10.0", "fastify": "^5.4.0", "jose": "^6.2.8", "zod": "catalog:" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@eslint/js": "catalog:", "@types/node": "^22.15.32", "@vitest/coverage-v8": "4.1.10", - "eslint": "^10.7.0", - "globals": "^17.7.0", + "eslint": "catalog:", + "globals": "catalog:", "tsup": "^8.5.0", "tsx": "^4.20.3", "typescript": "catalog:", - "typescript-eslint": "^8.64.0", + "typescript-eslint": "catalog:", "vitest": "catalog:" } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7285bca..c1b1bdf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,6 @@ import type { FastifyError, FastifyInstance } from 'fastify' import Fastify from 'fastify' +import compress from '@fastify/compress' import cors from '@fastify/cors' import helmet from '@fastify/helmet' import rateLimit from '@fastify/rate-limit' @@ -51,6 +52,9 @@ import { createOriginChecker } from './lib/cors.js' /** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/'] +function boolFlag(v: string): boolean { + return v === '1' || v.toLowerCase() === 'true' +} const HASHED_ASSET_RE = /[\\/]assets[\\/]/ const INDEX_HTML_RE = /[\\/]index\.html$/ @@ -167,14 +171,34 @@ export async function buildApp(opts: { const originAllowed = createOriginChecker(config, () => getAppSwitcherConfig(db).apps.map((a) => a.url), ) + if (boolFlag(config.enableCompress)) { + // Предсжатая статика уже с Content-Encoding — плагин её не трогает. + await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] }) + } await app.register(formbody) await app.register(cors, { origin: (origin, cb) => cb(null, !origin || originAllowed(origin)), credentials: true, }) - await app.register(helmet, { contentSecurityPolicy: false }) + await app.register(helmet, { + // SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты). + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], + }, + }, + }) await app.register(rateLimit, { - max: 200, + max: config.rateLimitMax, timeWindow: '1 minute', // пробы Docker/Traefik не должны упираться в лимит allowList: (req) => req.url === '/health' || req.url === '/ready', diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 6df2abb..b512da8 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -39,6 +39,10 @@ export const configSchema = z.object({ backupKeep: z.coerce.number().int().positive().default(7), /** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */ backupIntervalHours: z.coerce.number().min(0).default(0), + /** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */ + enableCompress: z.string().default('false'), + /** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */ + rateLimitMax: z.coerce.number().int().positive().default(200), /** Доп. разрешённые CORS-origins через запятую ('*' — все). */ corsOrigins: z.string().default(''), /** Одновременных argon2-хешей (каждый ~19 МиБ). */ @@ -121,6 +125,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { backupDir: env.BACKUP_DIR || undefined, backupKeep: env.BACKUP_KEEP ?? 7, backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0, + enableCompress: env.ENABLE_COMPRESS ?? 'false', + rateLimitMax: env.RATE_LIMIT_MAX ?? 200, corsOrigins: env.CORS_ORIGINS ?? '', argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2, trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY, diff --git a/apps/api/test/deploy-config.test.ts b/apps/api/test/deploy-config.test.ts index 5070671..5c5f52f 100644 --- a/apps/api/test/deploy-config.test.ts +++ b/apps/api/test/deploy-config.test.ts @@ -36,6 +36,18 @@ describe('Dockerfile', () => { expect(dockerfile).not.toMatch(/gzip -k/) }) + it('preview-маршруты дизайна вырезаются до сборки web', () => { + const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx') + const build = dockerfile.indexOf('pnpm turbo build') + expect(rm).toBeGreaterThan(0) + expect(rm).toBeLessThan(build) + }) + + it('базовый образ закреплён по digest', () => { + expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/) + expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/) + }) + it('нативные модули проверяются при сборке', () => { expect(dockerfile).toContain('native modules ok') }) @@ -59,6 +71,13 @@ describe.each(Object.entries(composes))('%s', (_name, text) => { }) describe('traefik', () => { + it('app ждёт healthy traefik, у traefik есть healthcheck', () => { + const t = composes['deploy/docker-compose.traefik.yml'] + expect(t).toMatch(/condition: service_healthy/) + expect(t).toContain('traefik", "healthcheck", "--ping') + expect(t).toContain('--ping=true') + }) + it('включено сжатие ответов', () => { expect(composes['deploy/docker-compose.traefik.yml']).toContain( 'middlewares.auth-portal-compress.compress=true', diff --git a/apps/api/test/http-layer.test.ts b/apps/api/test/http-layer.test.ts index ff9dcb0..3ac9de5 100644 --- a/apps/api/test/http-layer.test.ts +++ b/apps/api/test/http-layer.test.ts @@ -178,3 +178,55 @@ describe('CORS', () => { expect(all('https://anything.example')).toBe(true) }) }) + +describe('сжатие в Node (ENABLE_COMPRESS)', () => { + async function withBigRoute(env: NodeJS.ProcessEnv) { + app = await buildTestApp(env) + app.get('/__big', async () => ({ data: 'x'.repeat(5000) })) + app.get('/__small', async () => ({ ok: true })) + return app + } + + it('по умолчанию выключено', async () => { + const a = await withBigRoute({}) + const res = await a.inject({ + method: 'GET', + url: '/__big', + headers: { 'accept-encoding': 'gzip' }, + }) + expect(res.headers['content-encoding']).toBeUndefined() + }) + + it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => { + const a = await withBigRoute({ ENABLE_COMPRESS: 'true' }) + const big = await a.inject({ + method: 'GET', + url: '/__big', + headers: { 'accept-encoding': 'gzip' }, + }) + expect(big.headers['content-encoding']).toBe('gzip') + expect(big.rawPayload.length).toBeLessThan(500) + const small = await a.inject({ + method: 'GET', + url: '/__small', + headers: { 'accept-encoding': 'gzip' }, + }) + expect(small.headers['content-encoding']).toBeUndefined() + const none = await a.inject({ method: 'GET', url: '/__big' }) + expect(none.headers['content-encoding']).toBeUndefined() + }) +}); + +describe('CSP', () => { + it('отдаётся жёсткая политика без внешних источников', async () => { + app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/health' }) + const csp = String(res.headers['content-security-policy']) + expect(csp).toContain("default-src 'self'") + expect(csp).toContain("script-src 'self'") + expect(csp).toContain("frame-ancestors 'none'") + expect(csp).toContain("object-src 'none'") + expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/) + expect(csp).not.toContain('http:') + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 764720c..b2f203b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,13 +15,11 @@ "dependencies": { "@authportal/shared": "workspace:*", "@authportal/ui": "workspace:*", - "@base-ui/react": "^1.5.0", + "@base-ui/react": "catalog:", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", "@hookform/resolvers": "^5.4.0", "@simplewebauthn/browser": "^13.3.0", "@tailwindcss/vite": "^4.3.1", @@ -42,20 +40,21 @@ "zod": "catalog:" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@eslint/js": "catalog:", "@tanstack/router-cli": "^1.167.31", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^10.3.0", + "eslint": "catalog:", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.6.0", + "globals": "catalog:", + "jsdom": "^30.1.3", "rollup-plugin-visualizer": "^7.1.1", "tw-animate-css": "^1.0.0", "typescript": "catalog:", - "typescript-eslint": "^8.59.2", + "typescript-eslint": "catalog:", "vite": "^8.0.12", "vitest": "catalog:" } diff --git a/apps/web/src/components/portal/explain-banner.tsx b/apps/web/src/components/portal/explain-banner.tsx index 01307d5..8f62162 100644 --- a/apps/web/src/components/portal/explain-banner.tsx +++ b/apps/web/src/components/portal/explain-banner.tsx @@ -51,7 +51,12 @@ export function ExplainBanner({