Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

marketplace.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

marketplace.tsxBlame542 lines · 1 contributor
06139e6Claude1/**
2 * Block H — Marketplace UI + developer-side app management.
3 *
4 * GET /marketplace — public app directory (search)
5 * GET /marketplace/:slug — app detail + install CTA
6 * POST /marketplace/:slug/install — install to user (v1 only)
7 * POST /marketplace/installations/:id/uninstall
8 * — revoke access
9 * GET /settings/apps — list installed apps
10 * GET /developer/apps-new — register a new app
11 * POST /developer/apps-new — create app + bot
12 * GET /developer/apps/:slug/manage — event log + install count
13 * POST /developer/apps/:slug/tokens/new — issue install token (for testing)
14 */
15
16import { Hono } from "hono";
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { apps, appInstallations } from "../db/schema";
20import { Layout } from "../views/layout";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 KNOWN_PERMISSIONS,
25 KNOWN_EVENTS,
26 countInstalls,
27 createApp,
28 getAppBySlug,
29 installApp,
30 issueInstallToken,
31 listEventsForApp,
32 listInstallationsForApp,
33 listInstallationsForTarget,
34 listPublicApps,
35 normalisePermissions,
36 parsePermissions,
37 uninstallApp,
38} from "../lib/marketplace";
39import { audit } from "../lib/notify";
40
41const marketplace = new Hono<AuthEnv>();
42marketplace.use("*", softAuth);
43
44// ---------- Public directory ----------
45
46marketplace.get("/marketplace", async (c) => {
47 const user = c.get("user");
48 const q = c.req.query("q") || "";
49 const list = await listPublicApps(q);
50 return c.html(
51 <Layout title="Marketplace — Gluecron" user={user}>
52 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
53 <h2>Marketplace</h2>
54 {user && (
55 <a href="/developer/apps-new" class="btn btn-sm">
56 + Register app
57 </a>
58 )}
59 </div>
cce7944Claude60 <form method="get" action="/marketplace" style="margin-bottom:16px">
06139e6Claude61 <input
62 type="text"
63 name="q"
64 value={q}
65 placeholder="Search apps"
66 style="width:320px"
67 />{" "}
68 <button type="submit" class="btn">
69 Search
70 </button>
71 </form>
72 <div
73 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px"
74 >
75 {list.length === 0 ? (
76 <div class="panel-empty">No apps found.</div>
77 ) : (
78 list.map((a) => (
79 <a
80 href={`/marketplace/${a.slug}`}
81 class="panel"
82 style="padding:16px;color:inherit;text-decoration:none"
83 >
84 <div style="font-size:15px;font-weight:700">{a.name}</div>
85 <div
86 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
87 >
88 {a.description.slice(0, 140) || "No description."}
89 </div>
90 <div
91 style="font-size:11px;color:var(--text-muted);margin-top:8px;text-transform:uppercase"
92 >
93 {parsePermissions(a.permissions).length} permissions
94 </div>
95 </a>
96 ))
97 )}
98 </div>
99 </Layout>
100 );
101});
102
103marketplace.get("/marketplace/:slug", async (c) => {
104 const user = c.get("user");
105 const slug = c.req.param("slug");
106 const app = await getAppBySlug(slug);
107 if (!app || !app.isPublic) return c.notFound();
108 const [installs, perms] = await Promise.all([
109 countInstalls(app.id),
110 Promise.resolve(parsePermissions(app.permissions)),
111 ]);
112 return c.html(
113 <Layout title={`${app.name} — Marketplace`} user={user}>
114 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
115 <h2>{app.name}</h2>
116 <a href="/marketplace" class="btn btn-sm">
117 Back
118 </a>
119 </div>
120 <div class="panel" style="padding:16px;margin-bottom:20px">
121 <p>{app.description || "No description."}</p>
122 {app.homepageUrl && (
123 <p style="margin-top:8px">
124 Homepage: <a href={app.homepageUrl}>{app.homepageUrl}</a>
125 </p>
126 )}
127 <div style="font-size:12px;color:var(--text-muted);margin-top:8px">
128 {installs} install{installs === 1 ? "" : "s"} · bot username{" "}
129 <code>{app.slug}[bot]</code>
130 </div>
131 </div>
132
133 <h3>Permissions</h3>
134 <div class="panel" style="margin-bottom:20px">
135 {perms.length === 0 ? (
136 <div class="panel-empty">No permissions requested.</div>
137 ) : (
138 perms.map((p) => (
139 <div class="panel-item">
140 <code>{p}</code>
141 </div>
142 ))
143 )}
144 </div>
145
146 {user ? (
cce7944Claude147 <form method="post" action={`/marketplace/${slug}/install`}>
06139e6Claude148 <div class="form-group">
149 <p
150 style="font-size:13px;color:var(--text-muted);margin-bottom:8px"
151 >
152 Installing grants {perms.length} permission
153 {perms.length === 1 ? "" : "s"} to <strong>{app.name}</strong>{" "}
154 on your personal account.
155 </p>
156 </div>
157 {perms.map((p) => (
158 <label
159 style="display:inline-block;margin-right:10px;font-size:13px"
160 >
161 <input
162 type="checkbox"
163 name="permissions"
164 value={p}
165 checked
166 />{" "}
167 {p}
168 </label>
169 ))}
170 <div style="margin-top:12px">
171 <button type="submit" class="btn btn-primary">
172 Install
173 </button>
174 </div>
175 </form>
176 ) : (
177 <div class="panel" style="padding:16px;text-align:center">
178 <a href={`/login?next=/marketplace/${slug}`}>Sign in to install</a>
179 </div>
180 )}
181 </Layout>
182 );
183});
184
185marketplace.post("/marketplace/:slug/install", requireAuth, async (c) => {
186 const user = c.get("user")!;
187 const slug = c.req.param("slug");
188 const app = await getAppBySlug(slug);
189 if (!app) return c.notFound();
190 const body = await c.req.parseBody({ all: true });
191 const rawPerms = body.permissions;
192 const perms = Array.isArray(rawPerms)
193 ? rawPerms.map(String)
194 : rawPerms
195 ? [String(rawPerms)]
196 : [];
197 const inst = await installApp({
198 appId: app.id,
199 installedBy: user.id,
200 targetType: "user",
201 targetId: user.id,
202 grantedPermissions: perms,
203 });
204 if (inst) {
205 await audit({
206 userId: user.id,
207 action: "marketplace.install",
208 targetType: "app",
209 targetId: app.id,
210 metadata: { grantedPermissions: normalisePermissions(perms) },
211 });
212 }
213 return c.redirect("/settings/apps");
214});
215
216marketplace.post(
217 "/marketplace/installations/:id/uninstall",
218 requireAuth,
219 async (c) => {
220 const user = c.get("user")!;
221 const id = c.req.param("id");
222 // Only the installer can uninstall
223 const [inst] = await db
224 .select()
225 .from(appInstallations)
226 .where(eq(appInstallations.id, id))
227 .limit(1);
228 if (!inst || inst.installedBy !== user.id) {
229 return c.text("forbidden", 403);
230 }
231 const ok = await uninstallApp(id);
232 if (ok) {
233 await audit({
234 userId: user.id,
235 action: "marketplace.uninstall",
236 targetType: "app_installation",
237 targetId: id,
238 });
239 }
240 return c.redirect("/settings/apps");
241 }
242);
243
244// ---------- Personal installs ----------
245
246marketplace.get("/settings/apps", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const installs = await listInstallationsForTarget("user", user.id);
249 return c.html(
250 <Layout title="Installed apps — Gluecron" user={user}>
251 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
252 <h2>Installed apps</h2>
253 <a href="/marketplace" class="btn btn-sm">
254 Browse marketplace
255 </a>
256 </div>
257 <div class="panel">
258 {installs.length === 0 ? (
259 <div class="panel-empty">
260 No apps installed.{" "}
261 <a href="/marketplace">Browse the marketplace</a>.
262 </div>
263 ) : (
264 installs.map((i) => (
265 <div class="panel-item" style="justify-content:space-between">
266 <div style="flex:1;min-width:0">
267 <a
268 href={i.app ? `/marketplace/${i.app.slug}` : "#"}
269 style="font-weight:600"
270 >
271 {i.app?.name || "(unknown app)"}
272 </a>
273 <div
274 style="font-size:12px;color:var(--text-muted);margin-top:2px"
275 >
276 {parsePermissions(i.grantedPermissions).length} permissions ·
277 installed{" "}
278 {i.createdAt
279 ? new Date(i.createdAt).toLocaleDateString()
280 : ""}
281 </div>
282 </div>
283 <form
cce7944Claude284 method="post"
06139e6Claude285 action={`/marketplace/installations/${i.id}/uninstall`}
286 onsubmit="return confirm('Uninstall this app?')"
287 >
288 <button type="submit" class="btn btn-sm btn-danger">
289 Uninstall
290 </button>
291 </form>
292 </div>
293 ))
294 )}
295 </div>
296 </Layout>
297 );
298});
299
300// ---------- Developer UX ----------
301
302marketplace.get("/developer/apps-new", requireAuth, async (c) => {
303 const user = c.get("user")!;
304 return c.html(
305 <Layout title="New app — Marketplace" user={user}>
306 <h2>Register a new app</h2>
cce7944Claude307 <form method="post" action="/developer/apps-new" class="panel" style="padding:16px">
06139e6Claude308 <div class="form-group">
309 <label>Name</label>
310 <input type="text" name="name" required style="width:100%" />
311 </div>
312 <div class="form-group">
313 <label>Description</label>
9daaf0cClaude314 <textarea name="description" rows={3} style="width:100%" />
06139e6Claude315 </div>
316 <div class="form-group">
317 <label>Homepage URL</label>
318 <input type="url" name="homepageUrl" style="width:100%" />
319 </div>
320 <div class="form-group">
321 <label>Webhook URL (optional)</label>
322 <input type="url" name="webhookUrl" style="width:100%" />
323 </div>
324 <div class="form-group">
325 <label>Permissions</label>
326 <div
327 style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px;font-size:13px"
328 >
329 {KNOWN_PERMISSIONS.map((p) => (
330 <label>
331 <input type="checkbox" name="permissions" value={p} /> {p}
332 </label>
333 ))}
334 </div>
335 </div>
336 <div class="form-group">
337 <label>Events</label>
338 <div
339 style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;font-size:13px"
340 >
341 {KNOWN_EVENTS.map((e) => (
342 <label>
343 <input type="checkbox" name="events" value={e} /> {e}
344 </label>
345 ))}
346 </div>
347 </div>
348 <div class="form-group">
349 <label>
350 <input type="checkbox" name="isPublic" value="1" checked /> List in
351 public marketplace
352 </label>
353 </div>
354 <button type="submit" class="btn btn-primary">
355 Create app
356 </button>
357 </form>
358 </Layout>
359 );
360});
361
362marketplace.post("/developer/apps-new", requireAuth, async (c) => {
363 const user = c.get("user")!;
364 const body = await c.req.parseBody({ all: true });
365 const name = String(body.name || "").trim();
366 if (!name) return c.redirect("/developer/apps-new");
367 const rawPerms = body.permissions;
368 const perms = Array.isArray(rawPerms)
369 ? rawPerms.map(String)
370 : rawPerms
371 ? [String(rawPerms)]
372 : [];
373 const rawEvents = body.events;
374 const events = Array.isArray(rawEvents)
375 ? rawEvents.map(String)
376 : rawEvents
377 ? [String(rawEvents)]
378 : [];
379 const app = await createApp({
380 name,
381 description: String(body.description || ""),
382 homepageUrl: String(body.homepageUrl || "") || undefined,
383 webhookUrl: String(body.webhookUrl || "") || undefined,
384 creatorId: user.id,
385 permissions: perms,
386 defaultEvents: events,
387 isPublic: !!body.isPublic,
388 });
389 if (!app) return c.text("failed to create", 500);
390 await audit({
391 userId: user.id,
392 action: "marketplace.app.create",
393 targetType: "app",
394 targetId: app.id,
395 });
396 return c.redirect(`/developer/apps/${app.slug}/manage`);
397});
398
399marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
400 const user = c.get("user")!;
401 const slug = c.req.param("slug");
402 const app = await getAppBySlug(slug);
403 if (!app) return c.notFound();
404 if (app.creatorId !== user.id) return c.text("forbidden", 403);
405 const [installs, events] = await Promise.all([
406 listInstallationsForApp(app.id),
407 listEventsForApp(app.id, 20),
408 ]);
409 return c.html(
410 <Layout title={`Manage ${app.name}`} user={user}>
411 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
412 <h2>{app.name} · Developer</h2>
413 <a href={`/marketplace/${app.slug}`} class="btn btn-sm">
414 Public page
415 </a>
416 </div>
417 <div class="panel" style="padding:12px;margin-bottom:20px">
418 <div style="font-size:12px;color:var(--text-muted)">Bot identity</div>
419 <div style="font-family:var(--font-mono)">{app.slug}[bot]</div>
420 {app.webhookSecret && (
421 <div style="margin-top:8px;font-size:12px">
422 <span style="color:var(--text-muted)">Webhook secret:</span>{" "}
423 <code>{app.webhookSecret}</code>
424 </div>
425 )}
426 </div>
427
428 <h3>Installations ({installs.length})</h3>
429 <div class="panel" style="margin-bottom:20px">
430 {installs.length === 0 ? (
431 <div class="panel-empty">No installs yet.</div>
432 ) : (
433 installs.map((i) => (
434 <div class="panel-item" style="justify-content:space-between">
435 <div>
436 {i.targetType}: <code>{i.targetId}</code>
437 </div>
438 <div style="font-size:12px;color:var(--text-muted)">
439 {parsePermissions(i.grantedPermissions).length} perms ·{" "}
440 {i.createdAt
441 ? new Date(i.createdAt).toLocaleDateString()
442 : ""}
443 </div>
444 </div>
445 ))
446 )}
447 </div>
448
449 <h3>Recent events</h3>
450 <div class="panel" style="margin-bottom:20px">
451 {events.length === 0 ? (
452 <div class="panel-empty">No events yet.</div>
453 ) : (
454 events.map((e) => (
455 <div class="panel-item" style="justify-content:space-between">
456 <span>{e.kind}</span>
457 <span style="font-size:12px;color:var(--text-muted)">
458 {e.createdAt
459 ? new Date(e.createdAt).toLocaleString()
460 : ""}
461 </span>
462 </div>
463 ))
464 )}
465 </div>
466
467 <h3>Installation tokens</h3>
468 <form
cce7944Claude469 method="post"
06139e6Claude470 action={`/developer/apps/${app.slug}/tokens/new`}
471 class="panel"
472 style="padding:16px"
473 >
474 <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
475 Issue a bearer token for an existing installation. Use this to test
476 bot API calls. Tokens are shown once and expire after 1 hour.
477 </p>
478 <select name="installationId">
479 {installs.map((i) => (
480 <option value={i.id}>
481 {i.targetType}:{i.targetId.slice(0, 8)}
482 </option>
483 ))}
484 </select>{" "}
485 <button type="submit" class="btn btn-sm" disabled={installs.length === 0}>
486 Issue token
487 </button>
488 </form>
489 </Layout>
490 );
491});
492
493marketplace.post(
494 "/developer/apps/:slug/tokens/new",
495 requireAuth,
496 async (c) => {
497 const user = c.get("user")!;
498 const slug = c.req.param("slug");
499 const app = await getAppBySlug(slug);
500 if (!app) return c.notFound();
501 if (app.creatorId !== user.id) return c.text("forbidden", 403);
502 const body = await c.req.parseBody();
503 const installationId = String(body.installationId || "");
504 if (!installationId) return c.redirect(`/developer/apps/${slug}/manage`);
505 // Validate the installation belongs to this app
506 const [inst] = await db
507 .select()
508 .from(appInstallations)
509 .where(eq(appInstallations.id, installationId))
510 .limit(1);
511 if (!inst || inst.appId !== app.id) return c.text("forbidden", 403);
512 const t = await issueInstallToken(installationId);
513 if (!t) return c.text("failed", 500);
514 await audit({
515 userId: user.id,
516 action: "marketplace.token.issue",
517 targetType: "app_installation",
518 targetId: installationId,
519 });
520 return c.html(
521 <Layout title="Token issued" user={user}>
522 <h2>Token issued</h2>
523 <div class="panel" style="padding:16px">
524 <p>Copy this token now — it won't be shown again.</p>
525 <pre
526 style="font-family:var(--font-mono);background:var(--bg-secondary);padding:12px;border-radius:6px;word-break:break-all"
527 >
528 {t.token}
529 </pre>
530 <p style="font-size:12px;color:var(--text-muted)">
531 Expires at {t.expiresAt.toISOString()}
532 </p>
533 <a href={`/developer/apps/${slug}/manage`} class="btn" style="margin-top:12px">
534 Back
535 </a>
536 </div>
537 </Layout>
538 );
539 }
540);
541
542export default marketplace;