feat(api): admin backoffice router — meshes, sessions, invites, audit

Extends the Hono adminRouter with four new read-only mesh admin modules:
meshes, sessions, invites, audit. Each ships {queries,router}.ts following
the existing users/organizations/customers pattern (paginated Drizzle
transactions, getOrderByFromSort sorting, ilike search, enum filters).

- GET /admin/meshes — paginated list with owner join + member count subquery
- GET /admin/meshes/:id — detail: members, presences, invites, last 50 audit
  events (returns {mesh: null,...} shell on not-found to stay single-shape
  for Hono RPC inference)
- GET /admin/sessions — live WS presences across every mesh, joined to
  member/mesh for display, status + active/disconnected filters
- GET /admin/invites — invite tokens w/ mesh + createdBy user joins,
  revoked/expired filters
- GET /admin/audit — mesh audit log with eventType/meshId/date filters

Summary endpoint extended: new GET /admin/summary/mesh returns
{meshes, activeMeshes, totalPresences, activePresences, messages24h}.
Messages24h derived from audit_log where event_type='message_sent'
in the past 24h.

Schemas live in packages/api/src/schema/mesh-admin.ts, re-exported from
the schema barrel. All mesh/role/transport enums mirror the DB enums
from packages/db/src/schema/mesh.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:47:27 +01:00
parent d1ea1a0efa
commit 30928cd71d
11 changed files with 816 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import dayjs from "dayjs";
import {
and,
between,
count,
desc,
eq,
getOrderByFromSort,
gte,
ilike,
inArray,
or,
sql,
} from "@turbostarter/db";
import { auditLog, mesh } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetAuditInput } from "../../../schema";
export const getMessages24hCount = async () =>
db
.select({ count: count() })
.from(auditLog)
.where(
and(
eq(auditLog.eventType, "message_sent"),
gte(auditLog.createdAt, dayjs().subtract(24, "hour").toDate()),
),
)
.then((res) => res[0]?.count ?? 0);
export const getAudit = async (input: GetAuditInput) => {
const offset = (input.page - 1) * input.perPage;
const where = and(
input.q
? or(
ilike(auditLog.eventType, `%${input.q}%`),
ilike(mesh.name, `%${input.q}%`),
ilike(auditLog.actorPeerId, `%${input.q}%`),
)
: undefined,
input.eventType ? inArray(auditLog.eventType, input.eventType) : undefined,
input.meshId ? inArray(auditLog.meshId, input.meshId) : undefined,
input.createdAt
? between(
auditLog.createdAt,
dayjs(input.createdAt[0]).startOf("day").toDate(),
dayjs(input.createdAt[1]).endOf("day").toDate(),
)
: undefined,
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: auditLog })
: [desc(auditLog.createdAt)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: auditLog.id,
meshId: auditLog.meshId,
meshName: mesh.name,
meshSlug: mesh.slug,
eventType: auditLog.eventType,
actorPeerId: auditLog.actorPeerId,
targetPeerId: auditLog.targetPeerId,
metadata: sql<Record<string, unknown>>`${auditLog.metadata}`,
createdAt: auditLog.createdAt,
})
.from(auditLog)
.leftJoin(mesh, eq(auditLog.meshId, mesh.id))
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({ count: count() })
.from(auditLog)
.leftJoin(mesh, eq(auditLog.meshId, mesh.id))
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return { data, total };
});
};

View File

@@ -0,0 +1,12 @@
import { Hono } from "hono";
import { validate } from "../../../middleware";
import { getAuditInputSchema } from "../../../schema";
import { getAudit } from "./queries";
export const auditRouter = new Hono().get(
"/",
validate("query", getAuditInputSchema),
async (c) => c.json(await getAudit(c.req.valid("query"))),
);

View File

@@ -0,0 +1,73 @@
import {
and,
count,
desc,
eq,
getOrderByFromSort,
ilike,
isNotNull,
isNull,
lt,
or,
} from "@turbostarter/db";
import { user } from "@turbostarter/db/schema";
import { invite, mesh } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetInvitesInput } from "../../../schema";
export const getInvites = async (input: GetInvitesInput) => {
const offset = (input.page - 1) * input.perPage;
const now = new Date();
const where = and(
input.q
? or(
ilike(mesh.name, `%${input.q}%`),
ilike(invite.token, `%${input.q}%`),
)
: undefined,
input.revoked === true ? isNotNull(invite.revokedAt) : undefined,
input.revoked === false ? isNull(invite.revokedAt) : undefined,
input.expired === true ? lt(invite.expiresAt, now) : undefined,
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: invite })
: [desc(invite.createdAt)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: invite.id,
meshId: invite.meshId,
meshName: mesh.name,
meshSlug: mesh.slug,
token: invite.token,
maxUses: invite.maxUses,
usedCount: invite.usedCount,
role: invite.role,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
revokedAt: invite.revokedAt,
createdByName: user.name,
})
.from(invite)
.leftJoin(mesh, eq(invite.meshId, mesh.id))
.leftJoin(user, eq(invite.createdBy, user.id))
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({ count: count() })
.from(invite)
.leftJoin(mesh, eq(invite.meshId, mesh.id))
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return { data, total };
});
};

View File

@@ -0,0 +1,12 @@
import { Hono } from "hono";
import { validate } from "../../../middleware";
import { getInvitesInputSchema } from "../../../schema";
import { getInvites } from "./queries";
export const invitesRouter = new Hono().get(
"/",
validate("query", getInvitesInputSchema),
async (c) => c.json(await getInvites(c.req.valid("query"))),
);

View File

@@ -0,0 +1,195 @@
import dayjs from "dayjs";
import {
and,
asc,
between,
count,
desc,
eq,
getOrderByFromSort,
ilike,
inArray,
isNull,
isNotNull,
or,
sql,
} from "@turbostarter/db";
import { user } from "@turbostarter/db/schema";
import {
auditLog,
invite,
mesh,
meshMember,
presence,
} from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetMeshesInput } from "../../../schema";
export const getMeshesCount = async () =>
db
.select({ count: count() })
.from(mesh)
.then((res) => res[0]?.count ?? 0);
export const getActiveMeshesCount = async () =>
db
.select({ count: count() })
.from(mesh)
.where(isNull(mesh.archivedAt))
.then((res) => res[0]?.count ?? 0);
export const getMeshes = async (input: GetMeshesInput) => {
const offset = (input.page - 1) * input.perPage;
const where = and(
input.q
? or(ilike(mesh.name, `%${input.q}%`), ilike(mesh.slug, `%${input.q}%`))
: undefined,
input.tier ? inArray(mesh.tier, input.tier) : undefined,
input.transport ? inArray(mesh.transport, input.transport) : undefined,
input.visibility ? inArray(mesh.visibility, input.visibility) : undefined,
input.archived === true ? isNotNull(mesh.archivedAt) : undefined,
input.archived === false ? isNull(mesh.archivedAt) : undefined,
input.createdAt
? between(
mesh.createdAt,
dayjs(input.createdAt[0]).startOf("day").toDate(),
dayjs(input.createdAt[1]).endOf("day").toDate(),
)
: undefined,
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: mesh })
: [desc(mesh.createdAt)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: mesh.id,
name: mesh.name,
slug: mesh.slug,
visibility: mesh.visibility,
transport: mesh.transport,
tier: mesh.tier,
maxPeers: mesh.maxPeers,
createdAt: mesh.createdAt,
archivedAt: mesh.archivedAt,
ownerUserId: mesh.ownerUserId,
ownerName: user.name,
ownerEmail: user.email,
memberCount: sql<number>`(
SELECT COUNT(*)::int FROM mesh.member m WHERE m.mesh_id = ${mesh.id}
)`,
})
.from(mesh)
.leftJoin(user, eq(mesh.ownerUserId, user.id))
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({ count: count() })
.from(mesh)
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return { data, total };
});
};
export const getMeshById = async (id: string) => {
const [m] = await db
.select({
id: mesh.id,
name: mesh.name,
slug: mesh.slug,
visibility: mesh.visibility,
transport: mesh.transport,
tier: mesh.tier,
maxPeers: mesh.maxPeers,
createdAt: mesh.createdAt,
archivedAt: mesh.archivedAt,
ownerUserId: mesh.ownerUserId,
ownerName: user.name,
ownerEmail: user.email,
})
.from(mesh)
.leftJoin(user, eq(mesh.ownerUserId, user.id))
.where(eq(mesh.id, id))
.limit(1);
if (!m) return null;
const members = await db
.select({
id: meshMember.id,
displayName: meshMember.displayName,
peerPubkey: meshMember.peerPubkey,
role: meshMember.role,
joinedAt: meshMember.joinedAt,
lastSeenAt: meshMember.lastSeenAt,
revokedAt: meshMember.revokedAt,
userId: meshMember.userId,
})
.from(meshMember)
.where(eq(meshMember.meshId, id))
.orderBy(asc(meshMember.joinedAt));
const presences = await db
.select({
id: presence.id,
memberId: presence.memberId,
displayName: meshMember.displayName,
sessionId: presence.sessionId,
pid: presence.pid,
cwd: presence.cwd,
status: presence.status,
statusSource: presence.statusSource,
statusUpdatedAt: presence.statusUpdatedAt,
connectedAt: presence.connectedAt,
lastPingAt: presence.lastPingAt,
disconnectedAt: presence.disconnectedAt,
})
.from(presence)
.leftJoin(meshMember, eq(presence.memberId, meshMember.id))
.where(eq(meshMember.meshId, id))
.orderBy(desc(presence.connectedAt))
.limit(50);
const invites = await db
.select({
id: invite.id,
token: invite.token,
maxUses: invite.maxUses,
usedCount: invite.usedCount,
role: invite.role,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
revokedAt: invite.revokedAt,
})
.from(invite)
.where(eq(invite.meshId, id))
.orderBy(desc(invite.createdAt))
.limit(50);
const auditEvents = await db
.select({
id: auditLog.id,
eventType: auditLog.eventType,
actorPeerId: auditLog.actorPeerId,
targetPeerId: auditLog.targetPeerId,
metadata: auditLog.metadata,
createdAt: auditLog.createdAt,
})
.from(auditLog)
.where(eq(auditLog.meshId, id))
.orderBy(desc(auditLog.createdAt))
.limit(50);
return { mesh: m, members, presences, invites, auditEvents };
};

View File

@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { validate } from "../../../middleware";
import { getMeshesInputSchema } from "../../../schema";
import { getMeshById, getMeshes } from "./queries";
export const meshesRouter = new Hono()
.get("/", validate("query", getMeshesInputSchema), async (c) =>
c.json(await getMeshes(c.req.valid("query"))),
)
.get("/:id", async (c) =>
c.json(
(await getMeshById(c.req.param("id"))) ?? {
mesh: null,
members: [],
presences: [],
invites: [],
auditEvents: [],
},
),
);

View File

@@ -2,10 +2,23 @@ import { Hono } from "hono";
import { enforceAdmin, enforceAuth } from "../../middleware";
import { auditRouter } from "./audit/router";
import { getMessages24hCount } from "./audit/queries";
import { getCustomersCount } from "./customers/queries";
import { customersRouter } from "./customers/router";
import { invitesRouter } from "./invites/router";
import {
getActiveMeshesCount,
getMeshesCount,
} from "./meshes/queries";
import { meshesRouter } from "./meshes/router";
import { getOrganizationsCount } from "./organizations/queries";
import { organizationsRouter } from "./organizations/router";
import {
getActivePresencesCount,
getPresencesCount,
} from "./sessions/queries";
import { sessionsRouter } from "./sessions/router";
import { getUsersCount } from "./users/queries";
import { usersRouter } from "./users/router";
@@ -15,6 +28,10 @@ export const adminRouter = new Hono()
.route("/users", usersRouter)
.route("/organizations", organizationsRouter)
.route("/customers", customersRouter)
.route("/meshes", meshesRouter)
.route("/sessions", sessionsRouter)
.route("/invites", invitesRouter)
.route("/audit", auditRouter)
.get("/summary", async (c) => {
const [users, organizations, customers] = await Promise.all([
getUsersCount(),
@@ -23,4 +40,22 @@ export const adminRouter = new Hono()
]);
return c.json({ users, organizations, customers });
})
.get("/summary/mesh", async (c) => {
const [meshes, activeMeshes, totalPresences, activePresences, messages24h] =
await Promise.all([
getMeshesCount(),
getActiveMeshesCount(),
getPresencesCount(),
getActivePresencesCount(),
getMessages24hCount(),
]);
return c.json({
meshes,
activeMeshes,
totalPresences,
activePresences,
messages24h,
});
});

View File

@@ -0,0 +1,91 @@
import {
and,
count,
desc,
eq,
getOrderByFromSort,
ilike,
inArray,
isNull,
or,
sql,
} from "@turbostarter/db";
import { mesh, meshMember, presence } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetSessionsInput } from "../../../schema";
export const getPresencesCount = async () =>
db
.select({ count: count() })
.from(presence)
.then((res) => res[0]?.count ?? 0);
export const getActivePresencesCount = async () =>
db
.select({ count: count() })
.from(presence)
.where(isNull(presence.disconnectedAt))
.then((res) => res[0]?.count ?? 0);
export const getSessions = async (input: GetSessionsInput) => {
const offset = (input.page - 1) * input.perPage;
const where = and(
input.q
? or(
ilike(meshMember.displayName, `%${input.q}%`),
ilike(presence.cwd, `%${input.q}%`),
ilike(mesh.name, `%${input.q}%`),
)
: undefined,
input.status ? inArray(presence.status, input.status) : undefined,
input.active === true ? isNull(presence.disconnectedAt) : undefined,
input.active === false
? sql`${presence.disconnectedAt} IS NOT NULL`
: undefined,
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: presence })
: [desc(presence.lastPingAt)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: presence.id,
memberId: presence.memberId,
displayName: meshMember.displayName,
meshId: meshMember.meshId,
meshName: mesh.name,
meshSlug: mesh.slug,
sessionId: presence.sessionId,
pid: presence.pid,
cwd: presence.cwd,
status: presence.status,
statusSource: presence.statusSource,
statusUpdatedAt: presence.statusUpdatedAt,
connectedAt: presence.connectedAt,
lastPingAt: presence.lastPingAt,
disconnectedAt: presence.disconnectedAt,
})
.from(presence)
.leftJoin(meshMember, eq(presence.memberId, meshMember.id))
.leftJoin(mesh, eq(meshMember.meshId, mesh.id))
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({ count: count() })
.from(presence)
.leftJoin(meshMember, eq(presence.memberId, meshMember.id))
.leftJoin(mesh, eq(meshMember.meshId, mesh.id))
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return { data, total };
});
};

View File

@@ -0,0 +1,12 @@
import { Hono } from "hono";
import { validate } from "../../../middleware";
import { getSessionsInputSchema } from "../../../schema";
import { getSessions } from "./queries";
export const sessionsRouter = new Hono().get(
"/",
validate("query", getSessionsInputSchema),
async (c) => c.json(await getSessions(c.req.valid("query"))),
);