From 15b5cce8cc2385ff47529ca3d08ea69fdd218993 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 20:08:14 +0700 Subject: [PATCH] =?UTF-8?q?chore(ci):=20request-id,=20RBAC=20stats,=20web-?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20=D0=B3=D0=B8=D0=B3?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D0=B0=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - genReqId (uuid) + x-request-id в каждом ответе и request_id в error envelope — корреляция ошибок между клиентом и логами - RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются как fw:stats:read (reset остаётся под fw:agents:write) - web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav) - typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck всех пакетов - гигиена: .node-version (22), актуальный .dockerignore, drizzle out → ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test --- .dockerignore | 2 - .gitea/workflows/quality.yaml | 8 +- .node-version | 1 + apps/api/package.json | 1 + apps/api/src/app.ts | 6 ++ apps/api/src/plugins/.gitkeep | 0 apps/api/src/plugins/error-handler.ts | 15 +++- apps/api/src/routes/.gitkeep | 0 apps/api/src/services/.gitkeep | 0 apps/api/src/services/agents-policy.test.ts | 1 + apps/api/src/services/bulk-ops.test.ts | 1 + apps/api/src/services/install-links.test.ts | 1 + apps/api/src/services/ip-block-stats.test.ts | 1 + .../src/services/policy/policy-mode.test.ts | 1 + apps/api/src/services/port-acl.test.ts | 1 + .../src/services/retention-pagination.test.ts | 1 + apps/api/src/services/settings-bump.test.ts | 1 + apps/api/test/.gitkeep | 0 apps/web/package.json | 1 + .../agents/agents-fleet-kpis.test.ts | 82 +++++++++++++++++++ .../components/reui-kit/filter-utils.test.ts | 80 ++++++++++++++++++ apps/web/src/hooks/.gitkeep | 0 apps/web/src/lib/.gitkeep | 0 apps/web/src/lib/auth.test.ts | 36 ++++++++ apps/web/src/lib/nav.test.ts | 27 ++++++ apps/web/src/queries/.gitkeep | 0 deploy/.gitkeep | 0 packages/db/drizzle.config.ts | 4 +- packages/db/package.json | 1 + packages/shared/package.json | 1 + packages/shared/src/contracts/.gitkeep | 0 packages/shared/src/permissions.test.ts | 14 ++++ packages/shared/src/permissions.ts | 6 ++ packages/ui/src/hooks/.gitkeep | 0 packages/ui/src/lib/.gitkeep | 0 scripts/.gitkeep | 0 36 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 .node-version delete mode 100644 apps/api/src/plugins/.gitkeep delete mode 100644 apps/api/src/routes/.gitkeep delete mode 100644 apps/api/src/services/.gitkeep delete mode 100644 apps/api/test/.gitkeep create mode 100644 apps/web/src/components/agents/agents-fleet-kpis.test.ts create mode 100644 apps/web/src/components/reui-kit/filter-utils.test.ts delete mode 100644 apps/web/src/hooks/.gitkeep delete mode 100644 apps/web/src/lib/.gitkeep create mode 100644 apps/web/src/lib/auth.test.ts create mode 100644 apps/web/src/lib/nav.test.ts delete mode 100644 apps/web/src/queries/.gitkeep delete mode 100644 deploy/.gitkeep delete mode 100644 packages/shared/src/contracts/.gitkeep delete mode 100644 packages/ui/src/hooks/.gitkeep delete mode 100644 packages/ui/src/lib/.gitkeep delete mode 100644 scripts/.gitkeep diff --git a/.dockerignore b/.dockerignore index c76835a..2bff11d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,7 +21,6 @@ node_modules **/node_modules **/dist apps/web/src/routeTree.gen.ts -apps/web/playwright-report apps/web/test-results *.md @@ -30,7 +29,6 @@ CONTRIBUTING.md LICENSE docs -.pre-commit-config.yaml .releaserc.json .commitlintrc.* commitlint.config.cjs diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml index 05e6db2..861d22c 100644 --- a/.gitea/workflows/quality.yaml +++ b/.gitea/workflows/quality.yaml @@ -221,6 +221,7 @@ jobs: set -euxo pipefail sh scripts/ci/pnpm-ci.sh pnpm --filter @evofw/web run typecheck + pnpm --filter @evofw/web run test pnpm --filter @evofw/web run build api: @@ -251,13 +252,16 @@ jobs: key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} restore-keys: | pnpm-${{ runner.os }}- - - name: pnpm install, test, build + - name: pnpm install, typecheck, test, build env: PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} run: | set -euxo pipefail sh scripts/ci/pnpm-ci.sh - pnpm exec turbo run test --filter=@evofw/api + pnpm --filter @evofw/api run typecheck + pnpm --filter @evofw/db run typecheck + pnpm --filter @evofw/shared run typecheck + pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared pnpm exec turbo run build --filter=@evofw/api commitlint: diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/apps/api/package.json b/apps/api/package.json index 822b773..1d6e57c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "tsx watch src/server.ts", + "typecheck": "tsc --noEmit", "build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"", "start": "node dist/server.js", "test": "vitest run" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6306681..af303c7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -33,8 +33,14 @@ export async function buildApp(opts: BuildAppOptions = {}) { const app = Fastify({ logger: { level: config.logLevel }, + genReqId: () => crypto.randomUUID(), }).withTypeProvider() + // Correlation id in every response and in the error envelope. + app.addHook('onSend', async (req, reply) => { + reply.header('x-request-id', req.id) + }) + app.setValidatorCompiler(validatorCompiler) app.setSerializerCompiler(serializerCompiler) diff --git a/apps/api/src/plugins/.gitkeep b/apps/api/src/plugins/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/src/plugins/error-handler.ts b/apps/api/src/plugins/error-handler.ts index f9d9c7c..e78cb61 100644 --- a/apps/api/src/plugins/error-handler.ts +++ b/apps/api/src/plugins/error-handler.ts @@ -14,17 +14,25 @@ export class AppError extends Error { } async function errorHandlerPlugin(app: FastifyInstance) { - app.setErrorHandler((err, _req, reply) => { + app.setErrorHandler((err, req, reply) => { if (err instanceof AppError) { return reply.code(err.statusCode).send({ - error: { code: err.code, message: err.message }, + error: { + code: err.code, + message: err.message, + request_id: String(req.id), + }, }) } if (err instanceof ZodError) { const message = err.issues.map((i) => i.message).join('; ') || 'Validation error' return reply.code(400).send({ - error: { code: 'VALIDATION_ERROR', message }, + error: { + code: 'VALIDATION_ERROR', + message, + request_id: String(req.id), + }, }) } const e = err as { statusCode?: number; message?: string } @@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) { error: { code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR', message, + request_id: String(req.id), }, }) }) diff --git a/apps/api/src/routes/.gitkeep b/apps/api/src/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/src/services/.gitkeep b/apps/api/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/src/services/agents-policy.test.ts b/apps/api/src/services/agents-policy.test.ts index 8ec9208..d6cf246 100644 --- a/apps/api/src/services/agents-policy.test.ts +++ b/apps/api/src/services/agents-policy.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/bulk-ops.test.ts b/apps/api/src/services/bulk-ops.test.ts index 85d9954..e76f32c 100644 --- a/apps/api/src/services/bulk-ops.test.ts +++ b/apps/api/src/services/bulk-ops.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 6e47148..6b99bee 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/ip-block-stats.test.ts b/apps/api/src/services/ip-block-stats.test.ts index dfa0de3..c027404 100644 --- a/apps/api/src/services/ip-block-stats.test.ts +++ b/apps/api/src/services/ip-block-stats.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/policy/policy-mode.test.ts b/apps/api/src/services/policy/policy-mode.test.ts index abd42b9..955ab56 100644 --- a/apps/api/src/services/policy/policy-mode.test.ts +++ b/apps/api/src/services/policy/policy-mode.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts index ee7949b..66f432c 100644 --- a/apps/api/src/services/port-acl.test.ts +++ b/apps/api/src/services/port-acl.test.ts @@ -15,6 +15,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/retention-pagination.test.ts b/apps/api/src/services/retention-pagination.test.ts index 66c716a..c7d2c52 100644 --- a/apps/api/src/services/retention-pagination.test.ts +++ b/apps/api/src/services/retention-pagination.test.ts @@ -16,6 +16,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/src/services/settings-bump.test.ts b/apps/api/src/services/settings-bump.test.ts index ddfaaee..dec9ae9 100644 --- a/apps/api/src/services/settings-bump.test.ts +++ b/apps/api/src/services/settings-bump.test.ts @@ -16,6 +16,7 @@ const testConfig: AppConfig = { publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], + authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } diff --git a/apps/api/test/.gitkeep b/apps/api/test/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/package.json b/apps/web/package.json index 0ca8d72..acd0d2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "typecheck": "tsc --noEmit", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { diff --git a/apps/web/src/components/agents/agents-fleet-kpis.test.ts b/apps/web/src/components/agents/agents-fleet-kpis.test.ts new file mode 100644 index 0000000..79f2ad0 --- /dev/null +++ b/apps/web/src/components/agents/agents-fleet-kpis.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis' +import type { Agent } from '@evofw/shared' + +function agent(patch: Partial): Agent { + return { + id: 'a1', + name: 'a', + hostname: null, + platform: 'linux', + token_prefix: 'x', + status: 'approved', + default_action: 'accept', + policy_mode: 'blacklist', + policy_generation: 1, + last_seen_at: null, + last_seen_ip: null, + last_apply_at: null, + last_apply_status: null, + last_apply_error: null, + last_apply_prefix_count: null, + last_apply_packets_dropped: null, + last_apply_packets_accepted: null, + total_packets_dropped: 0, + total_packets_accepted: 0, + last_apply_kernel_method: null, + client_version: null, + created_at: '2026-01-01T00:00:00Z', + approved_at: null, + revoked_at: null, + install_curl: null, + install_link_id: null, + ...patch, + } as Agent +} + +describe('agents-fleet-kpis', () => { + it('stale = approved + unseen or seen >24h ago; never for pending', () => { + const now = Date.parse('2026-09-20T12:00:00Z') + expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true) + expect( + isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now), + ).toBe(false) + expect( + isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now), + ).toBe(true) + expect( + isAgentStale( + agent({ status: 'pending', last_seen_at: null }), + now, + ), + ).toBe(false) + expect( + isAgentStale(agent({ last_seen_at: 'garbage' }), now), + ).toBe(true) + }) + + it('computeFleetCounts tallies statuses, stale and apply errors', () => { + const now = Date.now() + const counts = computeFleetCounts([ + agent({ id: '1', status: 'pending' }), + agent({ id: '2', status: 'invited' }), + agent({ id: '3', status: 'revoked' }), + agent({ id: '4', last_seen_at: new Date(now).toISOString() }), + agent({ + id: '5', + last_seen_at: new Date(now - 25 * 3600_000).toISOString(), + }), + agent({ + id: '6', + last_seen_at: new Date(now).toISOString(), + last_apply_error: 'boom', + }), + ]) + expect(counts.pending).toBe(1) + expect(counts.invited).toBe(1) + expect(counts.revoked).toBe(1) + expect(counts.approved).toBe(3) + expect(counts.stale).toBe(1) + expect(counts.applyErrors).toBe(1) + }) +}) diff --git a/apps/web/src/components/reui-kit/filter-utils.test.ts b/apps/web/src/components/reui-kit/filter-utils.test.ts new file mode 100644 index 0000000..366299d --- /dev/null +++ b/apps/web/src/components/reui-kit/filter-utils.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { + getActiveFilters, + applyFiltersToData, +} from '@/components/reui-kit/filter-utils' + +type Row = { name: string; status: string } + +const rows: Row[] = [ + { name: 'web-01', status: 'approved' }, + { name: 'db-01', status: 'pending' }, + { name: 'mt-01', status: 'revoked' }, +] + +const field = (item: Row, f: string) => + f === 'name' ? item.name : f === 'status' ? item.status : undefined + +describe('filter-utils', () => { + it('getActiveFilters drops empty filters', () => { + const active = getActiveFilters([ + { id: '1', field: 'status', operator: 'is', values: ['approved'] }, + { id: '2', field: 'name', operator: 'contains', values: [''] }, + { id: '3', field: 'x', operator: 'is', values: [] }, + { id: '4', field: 'x', operator: 'is', values: [null, undefined] }, + ]) + expect(active.map((f) => f.id)).toEqual(['1']) + }) + + it('applies is / is_not / is_any_of operators', () => { + expect( + applyFiltersToData( + rows, + [{ id: '1', field: 'status', operator: 'is', values: ['pending'] }], + field, + ), + ).toEqual([rows[1]]) + + expect( + applyFiltersToData( + rows, + [ + { + id: '1', + field: 'status', + operator: 'is_any_of', + values: ['pending', 'revoked'], + }, + ], + field, + ), + ).toEqual([rows[1], rows[2]]) + + expect( + applyFiltersToData( + rows, + [ + { + id: '1', + field: 'status', + operator: 'is_not', + values: ['revoked'], + }, + ], + field, + ), + ).toEqual([rows[0], rows[1]]) + }) + + it('combines multiple filters with AND', () => { + const out = applyFiltersToData( + rows, + [ + { id: '1', field: 'status', operator: 'is', values: ['approved'] }, + { id: '2', field: 'name', operator: 'contains', values: ['web'] }, + ], + field, + ) + expect(out).toEqual([rows[0]]) + }) +}) diff --git a/apps/web/src/hooks/.gitkeep b/apps/web/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/lib/.gitkeep b/apps/web/src/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/lib/auth.test.ts b/apps/web/src/lib/auth.test.ts new file mode 100644 index 0000000..5781a85 --- /dev/null +++ b/apps/web/src/lib/auth.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { parseClaims } from './auth' + +function makeToken(payload: object): string { + const b64 = (obj: object) => + btoa(JSON.stringify(obj)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return `${b64({ alg: 'none' })}.${b64(payload)}.sig` +} + +describe('parseClaims', () => { + it('parses a portal JWT payload', () => { + const claims = parseClaims( + makeToken({ + sub: 'u1', + email: 'a@b.c', + name: 'Admin', + apps: ['fw'], + permissions: ['fw:agents:write'], + is_admin: true, + exp: 1893456000, + }), + ) + expect(claims?.sub).toBe('u1') + expect(claims?.apps).toEqual(['fw']) + expect(claims?.is_admin).toBe(true) + }) + + it('returns null for garbage / wrong shapes', () => { + expect(parseClaims('not-a-jwt')).toBeNull() + expect(parseClaims('a.b')).toBeNull() + expect(parseClaims('%%%')).toBeNull() + }) +}) diff --git a/apps/web/src/lib/nav.test.ts b/apps/web/src/lib/nav.test.ts new file mode 100644 index 0000000..67f88c7 --- /dev/null +++ b/apps/web/src/lib/nav.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { NAV_ITEMS, NAV_SECTIONS, navLabel, navParentForDetail } from './nav' + +describe('nav config', () => { + it('every nav item has label, icon, keywords and a known section', () => { + const sectionIds = new Set(NAV_SECTIONS.map((s) => s.id)) + for (const item of NAV_ITEMS) { + expect(item.label.length).toBeGreaterThan(0) + expect(item.keywords.length).toBeGreaterThan(0) + expect(sectionIds.has(item.section)).toBe(true) + } + }) + + it('navLabel resolves known routes', () => { + expect(navLabel('/')).toBe('Панель управления') + expect(navLabel('/agents')).toBe('Агенты') + expect(navLabel('/nope')).toBeUndefined() + }) + + it('navParentForDetail maps detail routes to parents', () => { + expect(navParentForDetail('/agents/abc')).toBe('/agents') + expect(navParentForDetail('/lists/abc')).toBe('/lists') + expect(navParentForDetail('/rules/set-1')).toBe('/rules') + expect(navParentForDetail('/settings')).toBeUndefined() + expect(navParentForDetail('/agents/abc/preview')).toBeUndefined() + }) +}) diff --git a/apps/web/src/queries/.gitkeep b/apps/web/src/queries/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/deploy/.gitkeep b/deploy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index b7fdc1e..63be454 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -2,7 +2,9 @@ import { defineConfig } from 'drizzle-kit' export default defineConfig({ schema: './src/schema.ts', - out: './drizzle', + // Migration SQL files live here and are applied by the custom runner in + // src/client.ts (runMigrations); drizzle-kit generate adds new files to it. + out: './migrations', dialect: 'sqlite', dbCredentials: { url: 'data/app.db' }, }) diff --git a/packages/db/package.json b/packages/db/package.json index bd79447..56e7bba 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,6 +12,7 @@ } }, "scripts": { + "typecheck": "tsc --noEmit", "build": "tsup src/index.ts --format esm --dts", "dev": "tsup src/index.ts --format esm --dts --watch", "db:generate": "drizzle-kit generate", diff --git a/packages/shared/package.json b/packages/shared/package.json index f685aaf..0e52aa4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,7 @@ } }, "scripts": { + "typecheck": "tsc --noEmit", "build": "tsup src/index.ts --format esm --dts", "dev": "tsup src/index.ts --format esm --dts --watch", "test": "vitest run --passWithNoTests" diff --git a/packages/shared/src/contracts/.gitkeep b/packages/shared/src/contracts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/shared/src/permissions.test.ts b/packages/shared/src/permissions.test.ts index 5d1f88a..642a422 100644 --- a/packages/shared/src/permissions.test.ts +++ b/packages/shared/src/permissions.test.ts @@ -11,6 +11,20 @@ describe('permissionForRequest', () => { ) }) + it('maps agent stats endpoints to stats permission, not agents', () => { + expect( + permissionForRequest('GET', '/api/v1/agents/a1/stats'), + ).toBe('fw:stats:read') + expect( + permissionForRequest('GET', '/api/v1/agents/a1/blocked-ips'), + ).toBe('fw:stats:read') + // reset is destructive — stays under agents write + expect( + permissionForRequest('POST', '/api/v1/agents/a1/stats/reset'), + ).toBe('fw:agents:write') + expect(permissionForRequest('GET', '/api/v1/agents')).toBe('fw:agents:read') + }) + it('maps integrations to lists read / settings admin', () => { expect( permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'), diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index b8d35ee..49aa374 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -29,6 +29,12 @@ export function permissionForRequest( const m = method.toUpperCase() const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS' + // Agent statistics live under /agents/:id/… — classify as stats, not agents. + if ( + /^\/api\/v1\/agents\/[^/]+\/(stats|blocked-ips|blocked-ports)$/.test(path) + ) { + return 'fw:stats:read' + } if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) { return write ? 'fw:agents:write' : 'fw:agents:read' } diff --git a/packages/ui/src/hooks/.gitkeep b/packages/ui/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/ui/src/lib/.gitkeep b/packages/ui/src/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000