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