Skip to content
File

Blob: src/worker/api/admin/launcher-apps.ts

typescript180 lines
1import { asc, eq } from "drizzle-orm";
2 
3import {
4 isLauncherIconName,
5 isLauncherTintName,
6 type LauncherIconName,
7 type LauncherTintName,
8} from "@/shared/launcher";
9import { isNonEmptyString, readJsonBody, requiredParam } from "@/worker/api/request";
10import { launcherApps } from "@/worker/db/schema";
11import type { AppContext } from "@/worker/hono";
12import { HttpError } from "@/worker/http";
13import { isSafeHttpUrl } from "@/worker/services/url";
14 
15interface CreateLauncherAppBody {
16 name?: unknown;
17 url?: unknown;
18 icon?: unknown;
19 tint?: unknown;
20 enabled?: unknown;
21}
22 
23interface UpdateLauncherAppBody {
24 name?: unknown;
25 url?: unknown;
26 icon?: unknown;
27 tint?: unknown;
28 enabled?: unknown;
29}
30 
31const toApiShape = (row: typeof launcherApps.$inferSelect) => ({
32 id: row.id,
33 name: row.name,
34 url: row.url,
35 icon: isLauncherIconName(row.icon) ? row.icon : null,
36 tint: isLauncherTintName(row.tint) ? row.tint : null,
37 enabled: row.enabled,
38 createdAt: row.createdAt,
39 updatedAt: row.updatedAt,
40});
41 
42const validateUrl = (raw: unknown): string => {
43 if (!isNonEmptyString(raw)) {
44 throw new HttpError(400, "invalid_url", "url is required.");
45 }
46 const trimmed = raw.trim();
47 if (!isSafeHttpUrl(trimmed)) {
48 throw new HttpError(400, "invalid_url", "url must be an absolute https URL (http allowed only on loopback).");
49 }
50 return trimmed;
51};
52 
53const validateName = (raw: unknown): string => {
54 if (!isNonEmptyString(raw)) {
55 throw new HttpError(400, "invalid_name", "name is required.");
56 }
57 const trimmed = raw.trim();
58 if (trimmed.length === 0) {
59 throw new HttpError(400, "invalid_name", "name is required.");
60 }
61 return trimmed;
62};
63 
64// Tri-state on PATCH: missing key leaves the field unchanged, `null` or
65// `""` clears it, a registry-valid string sets it.
66const validateOptionalIcon = (raw: unknown): { provided: boolean; value: LauncherIconName | null } => {
67 if (raw === undefined) return { provided: false, value: null };
68 if (raw === null || raw === "") return { provided: true, value: null };
69 if (!isLauncherIconName(raw)) {
70 throw new HttpError(400, "invalid_icon", "icon must match the curated launcher registry.");
71 }
72 return { provided: true, value: raw };
73};
74 
75const validateOptionalTint = (raw: unknown): { provided: boolean; value: LauncherTintName | null } => {
76 if (raw === undefined) return { provided: false, value: null };
77 if (raw === null || raw === "") return { provided: true, value: null };
78 if (!isLauncherTintName(raw)) {
79 throw new HttpError(400, "invalid_tint", "tint must match the curated launcher registry.");
80 }
81 return { provided: true, value: raw };
82};
83 
84// `enabled` gates public launcher visibility, so reject anything that
85// isn't a real boolean. Truthy-coercing a string ("false" -> true) would
86// silently invert intent.
87const validateEnabled = (raw: unknown): boolean => {
88 if (typeof raw !== "boolean") {
89 throw new HttpError(400, "invalid_enabled", "enabled must be a boolean.");
90 }
91 return raw;
92};
93 
94export const handleListLauncherApps = async (c: AppContext): Promise<Response> => {
95 const rows = await c.var.db.select().from(launcherApps).orderBy(asc(launcherApps.name));
96 return c.json(rows.map(toApiShape));
97};
98 
99export const handleCreateLauncherApp = async (c: AppContext): Promise<Response> => {
100 const logger = c.var.log.child({ component: "admin.launcher-apps" });
101 const body = await readJsonBody<CreateLauncherAppBody>(c);
102 
103 const name = validateName(body.name);
104 const url = validateUrl(body.url);
105 const icon = validateOptionalIcon(body.icon).value;
106 const tint = validateOptionalTint(body.tint).value;
107 const enabled = body.enabled === undefined ? true : validateEnabled(body.enabled);
108 
109 const id = crypto.randomUUID();
110 const [created] = await c.var.db.insert(launcherApps).values({ id, name, url, icon, tint, enabled }).returning();
111 if (!created) {
112 throw new HttpError(500, "create_failed", "Could not load created launcher app.");
113 }
114 
115 logger.info("launcher_app_created", {
116 appId: id,
117 byUserId: c.var.session?.user.id,
118 enabled,
119 iconSet: icon !== null,
120 tintSet: tint !== null,
121 });
122 return c.json(toApiShape(created), 201);
123};
124 
125export const handleUpdateLauncherApp = async (c: AppContext): Promise<Response> => {
126 const logger = c.var.log.child({ component: "admin.launcher-apps" });
127 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Launcher app id required." });
128 const body = await readJsonBody<UpdateLauncherAppBody>(c);
129 
130 const updates: Partial<typeof launcherApps.$inferInsert> = {};
131 const changed: string[] = [];
132 
133 if (body.name !== undefined) {
134 updates.name = validateName(body.name);
135 changed.push("name");
136 }
137 if (body.url !== undefined) {
138 updates.url = validateUrl(body.url);
139 changed.push("url");
140 }
141 const iconCheck = validateOptionalIcon(body.icon);
142 if (iconCheck.provided) {
143 updates.icon = iconCheck.value;
144 changed.push("icon");
145 }
146 const tintCheck = validateOptionalTint(body.tint);
147 if (tintCheck.provided) {
148 updates.tint = tintCheck.value;
149 changed.push("tint");
150 }
151 if (body.enabled !== undefined) {
152 updates.enabled = validateEnabled(body.enabled);
153 changed.push("enabled");
154 }
155 
156 if (changed.length === 0) {
157 throw new HttpError(400, "invalid_body", "At least one field is required.");
158 }
159 
160 const [updated] = await c.var.db.update(launcherApps).set(updates).where(eq(launcherApps.id, id)).returning();
161 if (!updated) {
162 throw new HttpError(404, "not_found", "Launcher app not found.");
163 }
164 
165 logger.info("launcher_app_updated", {
166 appId: id,
167 byUserId: c.var.session?.user.id,
168 fields: changed,
169 });
170 return c.json(toApiShape(updated));
171};
172 
173export const handleDeleteLauncherApp = async (c: AppContext): Promise<Response> => {
174 const logger = c.var.log.child({ component: "admin.launcher-apps" });
175 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Launcher app id required." });
176 await c.var.db.delete(launcherApps).where(eq(launcherApps.id, id));
177 logger.info("launcher_app_deleted", { appId: id, byUserId: c.var.session?.user.id });
178 return c.body(null, 204);
179};