feat(api, web): enhance agent management and linting capabilities
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m17s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks.
- Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`.
- Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization.
- Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions.

These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
Denozordec
2026-07-30 14:13:05 +07:00
parent fb95ef22b3
commit f160992d94
68 changed files with 3155 additions and 6899 deletions
+33
View File
@@ -0,0 +1,33 @@
import { eq, desc } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agents } from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
+153 -568
View File
@@ -1,571 +1,153 @@
import { eq, and, desc, asc, sql, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
export {
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
} from './agents.js'
export {
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
export {
bumpAgentGeneration,
bumpAgentsForSet,
bumpAllApprovedAgents,
bumpAgentsForList,
listPolicySets,
getPolicySet,
insertPolicySet,
updatePolicySet,
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
ensureSharedSetAssigned,
listPolicyRules,
listPolicyRulesForAgent,
getPolicyRule,
insertPolicyRule,
updatePolicyRule,
reorderPolicyRules,
nextRulePriority,
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
replaceResolvedForRule,
listOverrides,
insertOverride,
deleteOverride,
cloneRulesFrom,
} from './policy.js'
export {
insertStatsSample,
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
} from './stats.js'
export {
getSetting,
setSetting,
listSettings,
} from './settings.js'
export {
listInstallLinks,
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
} from './install-links.js'
import {
agents,
ipLists,
ipListEntries,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
agentStatsSamples,
agentInstallLinks,
settings,
SHARED_POLICY_SET_ID,
} from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
}
export function bumpAllApprovedAgents(db: Db) {
const rows = db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.status, 'approved'))
.all()
for (const r of rows) bumpAgentGeneration(db, r.id)
}
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
if (setIds.length === 0) {
bumpAllApprovedAgents(db)
return
}
for (const setId of setIds) bumpAgentsForSet(db, setId)
}
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(ipListEntries)
.values({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})
.run()
}
}
/* ── Policy sets ── */
export function listPolicySets(db: Db) {
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
}
export function getPolicySet(db: Db, id: string) {
return db.select().from(policySets).where(eq(policySets.id, id)).get()
}
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
db.insert(policySets).values(row).run()
return getPolicySet(db, row.id)
}
export function updatePolicySet(
db: Db,
id: string,
patch: Partial<typeof policySets.$inferInsert>,
) {
db.update(policySets)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policySets.id, id))
.run()
return getPolicySet(db, id)
}
export function deletePolicySet(db: Db, id: string) {
if (id === SHARED_POLICY_SET_ID) {
throw new Error('cannot delete shared default set')
}
db.delete(policySets).where(eq(policySets.id, id)).run()
}
export function countRulesInSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(policyRules)
.where(eq(policyRules.setId, setId))
.get()
return row?.n ?? 0
}
export function countAgentsForSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.get()
return row?.n ?? 0
}
export function listAgentIdsForSet(db: Db, setId: string): string[] {
return db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
.map((r) => r.agentId)
}
export function listSetsForAgent(db: Db, agentId: string) {
return db
.select({
setId: agentPolicySets.setId,
sort: agentPolicySets.sort,
name: policySets.name,
description: policySets.description,
enabled: policySets.enabled,
policyMode: policySets.policyMode,
})
.from(agentPolicySets)
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
.where(eq(agentPolicySets.agentId, agentId))
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
.all()
}
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
for (const setId of setIds) {
if (!getPolicySet(db, setId)) {
throw new Error(`policy set not found: ${setId}`)
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
}
/* ── Policy rules ── */
export function listPolicyRules(db: Db, setId?: string) {
if (setId) {
return db
.select()
.from(policyRules)
.where(eq(policyRules.setId, setId))
.orderBy(asc(policyRules.priority))
.all()
}
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
}
export function listPolicyRulesForAgent(db: Db, agentId: string) {
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
if (assignments.length === 0) return []
const setIds = assignments.map((a) => a.setId)
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
const rules = db
.select()
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.all()
return rules
.filter((r) => r.enabled !== 0)
.map((r) => ({
...r,
_setSort: sortBySet.get(r.setId) ?? 0,
}))
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
return getPolicyRule(db, row.id)
}
export function updatePolicyRule(
db: Db,
id: string,
patch: Partial<typeof policyRules.$inferInsert>,
) {
db.update(policyRules)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
return getPolicyRule(db, id)
}
/** Renumber priorities 10, 20, … in given order. */
export function reorderPolicyRules(
db: Db,
setId: string,
orderedIds: string[],
) {
const existing = listPolicyRules(db, setId)
const existingIds = new Set(existing.map((r) => r.id))
if (
orderedIds.length !== existing.length ||
orderedIds.some((id) => !existingIds.has(id))
) {
throw new Error('ordered_ids must list every rule in the set exactly once')
}
// Temporary priorities to avoid UNIQUE collisions
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
})
}
export function nextRulePriority(db: Db, setId: string): number {
const rows = listPolicyRules(db, setId)
if (rows.length === 0) return 10
const max = Math.max(...rows.map((r) => r.priority))
return Math.min(10000, max + 10)
}
export function deletePolicyRule(db: Db, id: string) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listHostnameRules(db: Db) {
return db
.select()
.from(policyRules)
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
.all()
}
export function listResolvedForRule(db: Db, ruleId: string) {
return db
.select()
.from(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.all()
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(policyRuleResolved)
.values({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})
.run()
}
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
}
export function insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
db.delete(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.run()
}
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
targetAgentId,
sourceSets.map((s) => s.setId),
)
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
defaultAction: source.defaultAction,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
export function listInstallLinks(db: Db) {
return db
.select()
.from(agentInstallLinks)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
}
export function getInstallLink(db: Db, id: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.id, id))
.get()
}
export function getInstallLinkBySlug(db: Db, slug: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.slug, slug))
.get()
}
export function getInstallLinkByAgentId(db: Db, agentId: string) {
return db
.select()
.from(agentInstallLinks)
.where(
and(
eq(agentInstallLinks.agentId, agentId),
sql`${agentInstallLinks.revokedAt} IS NULL`,
),
)
.orderBy(desc(agentInstallLinks.createdAt))
.get()
}
export function insertInstallLink(
db: Db,
row: typeof agentInstallLinks.$inferInsert,
) {
db.insert(agentInstallLinks).values(row).run()
return getInstallLink(db, row.id)
}
export function revokeInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({ revokedAt: now })
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
export function touchInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({
lastUsedAt: now,
useCount: sql`${agentInstallLinks.useCount} + 1`,
})
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
} from './agents.js'
import {
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
import {
bumpAgentGeneration,
bumpAgentsForSet,
bumpAllApprovedAgents,
bumpAgentsForList,
listPolicySets,
getPolicySet,
insertPolicySet,
updatePolicySet,
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
ensureSharedSetAssigned,
listPolicyRules,
listPolicyRulesForAgent,
getPolicyRule,
insertPolicyRule,
updatePolicyRule,
reorderPolicyRules,
nextRulePriority,
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
replaceResolvedForRule,
listOverrides,
insertOverride,
deleteOverride,
cloneRulesFrom,
} from './policy.js'
import {
insertStatsSample,
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
} from './stats.js'
import {
getSetting,
setSetting,
listSettings,
} from './settings.js'
import {
listInstallLinks,
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
} from './install-links.js'
export const repos = {
listAgents,
@@ -584,6 +166,7 @@ export const repos = {
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
listPolicySets,
getPolicySet,
@@ -592,6 +175,7 @@ export const repos = {
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
@@ -622,9 +206,10 @@ export const repos = {
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
}
export { SHARED_POLICY_SET_ID }
export { SHARED_POLICY_SET_ID } from '../schema.js'
@@ -0,0 +1,86 @@
import { eq, and, desc, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentInstallLinks } from '../schema.js'
export function listInstallLinks(db: Db) {
return db
.select()
.from(agentInstallLinks)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
}
export function getInstallLink(db: Db, id: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.id, id))
.get()
}
export function getInstallLinkBySlug(db: Db, slug: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.slug, slug))
.get()
}
export function getInstallLinkByAgentId(db: Db, agentId: string) {
return db
.select()
.from(agentInstallLinks)
.where(
and(
eq(agentInstallLinks.agentId, agentId),
sql`${agentInstallLinks.revokedAt} IS NULL`,
),
)
.orderBy(desc(agentInstallLinks.createdAt))
.get()
}
/** Latest active install link per agent (one query). */
export function mapActiveInstallLinksByAgentId(db: Db) {
const rows = db
.select()
.from(agentInstallLinks)
.where(sql`${agentInstallLinks.revokedAt} IS NULL`)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
const map = new Map<string, (typeof rows)[number]>()
for (const row of rows) {
if (!row.agentId) continue
if (!map.has(row.agentId)) map.set(row.agentId, row)
}
return map
}
export function insertInstallLink(
db: Db,
row: typeof agentInstallLinks.$inferInsert,
) {
db.insert(agentInstallLinks).values(row).run()
return getInstallLink(db, row.id)
}
export function revokeInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({ revokedAt: now })
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
export function touchInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({
lastUsedAt: now,
useCount: sql`${agentInstallLinks.useCount} + 1`,
})
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
+76
View File
@@ -0,0 +1,76 @@
import { eq, desc, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
import { ipLists, ipListEntries } from '../schema.js'
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
/** Entry counts for many lists in one query. */
export function countEntriesByListIds(
db: Db,
listIds: string[],
): Map<string, number> {
const map = new Map<string, number>()
if (listIds.length === 0) return map
const rows = db
.select({
listId: ipListEntries.listId,
n: count(),
})
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.groupBy(ipListEntries.listId)
.all()
for (const r of rows) map.set(r.listId, r.n)
return map
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})),
)
.run()
}
+395
View File
@@ -0,0 +1,395 @@
import { eq, and, asc, sql, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agents,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
SHARED_POLICY_SET_ID,
} from '../schema.js'
import { getAgent, updateAgent } from './agents.js'
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
}
export function bumpAllApprovedAgents(db: Db) {
const rows = db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.status, 'approved'))
.all()
for (const r of rows) bumpAgentGeneration(db, r.id)
}
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
// Unused list (no rules) — do not churn all approved agents.
if (setIds.length === 0) return
for (const setId of setIds) bumpAgentsForSet(db, setId)
}
/* ── Policy sets ── */
export function listPolicySets(db: Db) {
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
}
export function getPolicySet(db: Db, id: string) {
return db.select().from(policySets).where(eq(policySets.id, id)).get()
}
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
db.insert(policySets).values(row).run()
return getPolicySet(db, row.id)
}
export function updatePolicySet(
db: Db,
id: string,
patch: Partial<typeof policySets.$inferInsert>,
) {
db.update(policySets)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policySets.id, id))
.run()
return getPolicySet(db, id)
}
export function deletePolicySet(db: Db, id: string) {
if (id === SHARED_POLICY_SET_ID) {
throw new Error('cannot delete shared default set')
}
db.delete(policySets).where(eq(policySets.id, id)).run()
}
export function countRulesInSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(policyRules)
.where(eq(policyRules.setId, setId))
.get()
return row?.n ?? 0
}
export function countAgentsForSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.get()
return row?.n ?? 0
}
/** Rule + agent counts for many sets in two queries. */
export function countRulesAndAgentsBySetIds(
db: Db,
setIds: string[],
): Map<string, { rules: number; agents: number }> {
const map = new Map<string, { rules: number; agents: number }>()
for (const id of setIds) map.set(id, { rules: 0, agents: 0 })
if (setIds.length === 0) return map
const ruleRows = db
.select({ setId: policyRules.setId, n: count() })
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.groupBy(policyRules.setId)
.all()
for (const r of ruleRows) {
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
cur.rules = r.n
map.set(r.setId, cur)
}
const agentRows = db
.select({ setId: agentPolicySets.setId, n: count() })
.from(agentPolicySets)
.where(inArray(agentPolicySets.setId, setIds))
.groupBy(agentPolicySets.setId)
.all()
for (const r of agentRows) {
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
cur.agents = r.n
map.set(r.setId, cur)
}
return map
}
export function listAgentIdsForSet(db: Db, setId: string): string[] {
return db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
.map((r) => r.agentId)
}
export function listSetsForAgent(db: Db, agentId: string) {
return db
.select({
setId: agentPolicySets.setId,
sort: agentPolicySets.sort,
name: policySets.name,
description: policySets.description,
enabled: policySets.enabled,
policyMode: policySets.policyMode,
})
.from(agentPolicySets)
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
.where(eq(agentPolicySets.agentId, agentId))
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
.all()
}
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
for (const setId of setIds) {
if (!getPolicySet(db, setId)) {
throw new Error(`policy set not found: ${setId}`)
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
}
/* ── Policy rules ── */
export function listPolicyRules(db: Db, setId?: string) {
if (setId) {
return db
.select()
.from(policyRules)
.where(eq(policyRules.setId, setId))
.orderBy(asc(policyRules.priority))
.all()
}
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
}
export function listPolicyRulesForAgent(db: Db, agentId: string) {
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
if (assignments.length === 0) return []
const setIds = assignments.map((a) => a.setId)
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
const rules = db
.select()
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.all()
return rules
.filter((r) => r.enabled !== 0)
.map((r) => ({
...r,
_setSort: sortBySet.get(r.setId) ?? 0,
}))
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
return getPolicyRule(db, row.id)
}
export function updatePolicyRule(
db: Db,
id: string,
patch: Partial<typeof policyRules.$inferInsert>,
) {
db.update(policyRules)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
return getPolicyRule(db, id)
}
/** Renumber priorities 10, 20, … in given order. */
export function reorderPolicyRules(
db: Db,
setId: string,
orderedIds: string[],
) {
const existing = listPolicyRules(db, setId)
const existingIds = new Set(existing.map((r) => r.id))
if (
orderedIds.length !== existing.length ||
orderedIds.some((id) => !existingIds.has(id))
) {
throw new Error('ordered_ids must list every rule in the set exactly once')
}
// Temporary priorities to avoid UNIQUE collisions
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
})
}
export function nextRulePriority(db: Db, setId: string): number {
const rows = listPolicyRules(db, setId)
if (rows.length === 0) return 10
const max = Math.max(...rows.map((r) => r.priority))
return Math.min(10000, max + 10)
}
export function deletePolicyRule(db: Db, id: string) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listHostnameRules(db: Db) {
return db
.select()
.from(policyRules)
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
.all()
}
export function listResolvedForRule(db: Db, ruleId: string) {
return db
.select()
.from(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.all()
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(policyRuleResolved)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})),
)
.run()
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
targetAgentId,
sourceSets.map((s) => s.setId),
)
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
defaultAction: source.defaultAction,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
+25
View File
@@ -0,0 +1,25 @@
import { eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { settings } from '../schema.js'
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
+35
View File
@@ -0,0 +1,35 @@
import { eq, desc } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentStatsSamples } from '../schema.js'
export function insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
db.delete(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.run()
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { putSettingsBodySchema } from './contracts.js'
describe('putSettingsBodySchema', () => {
it('accepts whitelisted keys', () => {
const parsed = putSettingsBodySchema.parse({
enroll_seed: 'x',
show_quick_actions: 'true',
})
expect(parsed.show_quick_actions).toBe('true')
})
it('rejects unknown keys', () => {
expect(() =>
putSettingsBodySchema.parse({ evil_key: 'nope' }),
).toThrow()
})
})
+24
View File
@@ -320,6 +320,29 @@ export const evobgpCommunitySchema = z.object({
title: z.string().nullable().optional(),
})
/** Whitelist keys for PUT /api/v1/settings. */
export const SETTINGS_KEYS = [
'enroll_seed',
'evobgp_api_url',
'evobgp_api_token',
'agent_sync_interval_sec',
'show_quick_actions',
] as const
export const putSettingsBodySchema = z
.record(z.string(), z.string())
.superRefine((obj, ctx) => {
for (const key of Object.keys(obj)) {
if (!(SETTINGS_KEYS as readonly string[]).includes(key)) {
ctx.addIssue({
code: 'custom',
message: `Unknown settings key: ${key}`,
path: [key],
})
}
}
})
export type Agent = z.infer<typeof agentSchema>
export type IpList = z.infer<typeof ipListSchema>
export type PolicyRule = z.infer<typeof policyRuleSchema>
@@ -331,3 +354,4 @@ export type DashboardStats = z.infer<typeof dashboardStatsSchema>
export type InstallLink = z.infer<typeof installLinkSchema>
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
export type DefaultAction = z.infer<typeof defaultActionSchema>
export type PutSettingsBody = z.infer<typeof putSettingsBodySchema>
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { permissionForRequest } from '@evofw/shared'
describe('permissionForRequest', () => {
it('maps install-links to agents permissions', () => {
expect(permissionForRequest('GET', '/api/v1/install-links')).toBe(
'fw:agents:read',
)
expect(permissionForRequest('POST', '/api/v1/install-links')).toBe(
'fw:agents:write',
)
})
it('maps integrations to lists read / settings admin', () => {
expect(
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
).toBe('fw:lists:read')
expect(
permissionForRequest('POST', '/api/v1/integrations/evobgp/refresh'),
).toBe('fw:settings:admin')
})
})
+4 -1
View File
@@ -29,7 +29,7 @@ export function permissionForRequest(
const m = method.toUpperCase()
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
if (path.startsWith('/api/v1/agents')) {
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
return write ? 'fw:agents:write' : 'fw:agents:read'
}
if (path.startsWith('/api/v1/lists')) {
@@ -45,6 +45,9 @@ export function permissionForRequest(
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
return 'fw:stats:read'
}
if (path.startsWith('/api/v1/integrations')) {
return write ? 'fw:settings:admin' : 'fw:lists:read'
}
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
return write ? 'fw:settings:admin' : 'fw:settings:read'
}