import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import type { Database, InferSelectModel } from "@homarr/db"; import { asc, eq, inArray, like } from "@homarr/db"; import { apps } from "@homarr/db/schema"; import { selectAppSchema } from "@homarr/db/validationSchemas"; import { getIconForName } from "@homarr/icons"; import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { convertIntersectionToZodObject } from "../schema-merger"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { AppAccessControl } from "./app/app-access-control"; const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; export const appRouter = createTRPCRouter({ getPaginated: permissionRequiredProcedure .requiresPermission("board-modify-all") .input(paginatedSchema) .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) .meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page", }, }) .query(async ({ input, ctx }) => { const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined; const totalCount = await ctx.db.$count(apps, whereQuery); const dbApps = await ctx.db.query.apps.findMany({ limit: input.pageSize, offset: (input.page - 1) * input.pageSize, where: whereQuery, orderBy: asc(apps.name), }); return { items: dbApps, totalCount, }; }), all: protectedProcedure .input(z.void()) .output(z.array(selectAppSchema)) .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "List all apps" }, }) .query(({ ctx }) => { return ctx.db.query.apps.findMany({ orderBy: asc(apps.name), }); }), search: protectedProcedure .input( z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10), }), ) .output(z.array(selectAppSchema)) .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)", }, }) .query(({ ctx, input }) => { return ctx.db.query.apps.findMany({ where: like(apps.name, `%${input.query}%`), orderBy: asc(apps.name), limit: input.limit, }); }), selectable: protectedProcedure .input(z.void()) .output( z.array( selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true, }), ), ) .meta({ openapi: { method: "GET", path: "/api/apps/selectable", tags: ["apps"], protect: true, }, }) .query(({ ctx }) => { return ctx.db.query.apps.findMany({ columns: { id: true, name: true, iconUrl: true, description: true, href: true, pingUrl: true, }, orderBy: asc(apps.name), }); }), byId: publicProcedure .input(byIdSchema) .output(selectAppSchema) .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" }, }) .query(async ({ ctx, input }) => { const repository = new AppRepository(ctx.db, ctx.session?.user ?? null); const app = await repository.getByIdAsync(input.id); if (!app) { throw new TRPCError({ code: "NOT_FOUND", message: "App not found", }); } return app; }), byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { const repository = new AppRepository(ctx.db, ctx.session?.user ?? null); return await repository.getByIdsAsync(input); }), create: permissionRequiredProcedure .requiresPermission("app-create") .input(appManageSchema) .output(z.object({ appId: z.string() }).and(selectAppSchema)) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)", }, }) .mutation(async ({ ctx, input }) => { const id = createId(); const insertValues = { id, name: input.name, description: input.description, iconUrl: input.iconUrl, href: input.href, pingUrl: input.pingUrl === "" ? null : input.pingUrl, }; await ctx.db.insert(apps).values(insertValues); // TODO: breaking change necessary for removing appId property return { appId: id, ...insertValues }; }), createMany: permissionRequiredProcedure .requiresPermission("app-create") .input(appCreateManySchema) .output(z.void()) .mutation(async ({ ctx, input }) => { await ctx.db.insert(apps).values( input.map((app) => ({ id: createId(), name: app.name, description: app.description, iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon, href: app.href, })), ); }), update: permissionRequiredProcedure .requiresPermission("app-modify-all") .input(convertIntersectionToZodObject(appEditSchema)) .output(z.void()) .meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)", }, }) .mutation(async ({ ctx, input }) => { const app = await ctx.db.query.apps.findFirst({ where: eq(apps.id, input.id), }); if (!app) { throw new TRPCError({ code: "NOT_FOUND", message: "App not found", }); } await ctx.db .update(apps) .set({ name: input.name, description: input.description, iconUrl: input.iconUrl, href: input.href, pingUrl: input.pingUrl === "" ? null : input.pingUrl, }) .where(eq(apps.id, input.id)); }), delete: permissionRequiredProcedure .requiresPermission("app-full-all") .output(z.void()) .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true, }, mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" }, }) .input(byIdSchema) .mutation(async ({ ctx, input }) => { await ctx.db.delete(apps).where(eq(apps.id, input.id)); }), }); type App = InferSelectModel; export class AppRepository { private readonly accessControl: AppAccessControl; constructor( private db: Database, user: Session["user"] | null, ) { this.accessControl = new AppAccessControl(db, user); } public async getByIdAsync(id: string): Promise { const apps = await this.getByIdsAsync([id]); return apps[0] ?? null; } public async getByIdsAsync(ids: string[]): Promise { const canUserSeeApps = await this.accessControl.canUserSeeAppsAsync(ids); const dbApps = await this.db.query.apps.findMany({ where: inArray(apps.id, ids), }); return canUserSeeApps ? dbApps : []; } }