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.tsxBlame1438 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)
655a200Claude14 *
15 * 2026 polish — gradient hairline hero, orb, eyebrow, gradient verb,
16 * featured app grid with logos, category filter pills, install CTA.
17 * All CSS scoped under `.mkt-*`.
06139e6Claude18 */
19
20import { Hono } from "hono";
21import { eq } from "drizzle-orm";
22import { db } from "../db";
655a200Claude23import { appInstallations } from "../db/schema";
06139e6Claude24import { Layout } from "../views/layout";
9b776daccantynz-alt25import { SettingsNav, settingsNavStyles } from "../views/components";
06139e6Claude26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 KNOWN_PERMISSIONS,
30 KNOWN_EVENTS,
31 countInstalls,
32 createApp,
33 getAppBySlug,
34 installApp,
35 issueInstallToken,
36 listEventsForApp,
37 listInstallationsForApp,
38 listInstallationsForTarget,
39 listPublicApps,
40 normalisePermissions,
41 parsePermissions,
42 uninstallApp,
43} from "../lib/marketplace";
44import { audit } from "../lib/notify";
45
46const marketplace = new Hono<AuthEnv>();
47marketplace.use("*", softAuth);
48
655a200Claude49/* ─────────────────────────────────────────────────────────────────────────
50 * Scoped CSS — every class prefixed `.mkt-` so this surface can't bleed
51 * into other pages. Mirrors the gradient hero + section card patterns
52 * from admin-integrations.tsx, admin-ops.tsx, error-page.tsx.
53 * ───────────────────────────────────────────────────────────────────── */
54const styles = `
eed4684Claude55 .mkt-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
655a200Claude56
57 /* ─── Hero ─── */
58 .mkt-hero {
59 position: relative;
60 margin-bottom: var(--space-5);
61 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 18px;
65 overflow: hidden;
66 }
67 .mkt-hero::before {
68 content: '';
69 position: absolute;
70 top: 0; left: 0; right: 0;
71 height: 2px;
6fd5915Claude72 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
655a200Claude73 opacity: 0.75;
74 pointer-events: none;
75 }
76 .mkt-hero-orb {
77 position: absolute;
78 inset: -30% -10% auto auto;
79 width: 460px; height: 460px;
6fd5915Claude80 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
655a200Claude81 filter: blur(80px);
82 opacity: 0.7;
83 pointer-events: none;
84 z-index: 0;
85 }
86 .mkt-hero-inner {
87 position: relative;
88 z-index: 1;
89 display: flex;
90 align-items: flex-end;
91 justify-content: space-between;
92 gap: var(--space-4);
93 flex-wrap: wrap;
94 }
95 .mkt-hero-text { max-width: 680px; flex: 1; min-width: 240px; }
96 .mkt-eyebrow {
97 display: inline-flex;
98 align-items: center;
99 gap: 8px;
100 font-family: var(--font-mono);
101 font-size: 11px;
102 letter-spacing: 0.18em;
103 text-transform: uppercase;
104 color: var(--text-muted);
105 font-weight: 600;
106 margin-bottom: 16px;
107 }
108 .mkt-eyebrow-dot {
109 width: 8px; height: 8px;
110 border-radius: 9999px;
6fd5915Claude111 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
112 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
655a200Claude113 }
114 .mkt-title {
115 font-family: var(--font-display);
116 font-size: clamp(32px, 5vw, 48px);
117 font-weight: 800;
118 letter-spacing: -0.030em;
119 line-height: 1.05;
120 margin: 0 0 var(--space-3);
121 color: var(--text-strong);
122 }
123 .mkt-title-grad {
6fd5915Claude124 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
655a200Claude125 -webkit-background-clip: text;
126 background-clip: text;
127 -webkit-text-fill-color: transparent;
128 color: transparent;
129 }
130 .mkt-sub {
131 font-size: 16px;
132 color: var(--text-muted);
133 margin: 0;
134 line-height: 1.55;
135 max-width: 580px;
136 }
137 .mkt-hero-cta {
138 display: inline-flex;
139 align-items: center;
140 gap: 6px;
141 padding: 9px 16px;
142 border-radius: 10px;
143 font-size: 13px;
144 font-weight: 600;
6fd5915Claude145 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
655a200Claude146 color: #fff;
147 text-decoration: none;
148 border: 1px solid transparent;
6fd5915Claude149 box-shadow: 0 6px 16px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
655a200Claude150 transition: transform 120ms ease, box-shadow 120ms ease;
151 }
152 .mkt-hero-cta:hover {
153 transform: translateY(-1px);
154 color: #fff;
155 text-decoration: none;
6fd5915Claude156 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60);
655a200Claude157 }
158
159 /* ─── Search + filter row ─── */
160 .mkt-toolbar {
161 display: flex;
162 align-items: center;
163 gap: var(--space-3);
164 flex-wrap: wrap;
165 margin-bottom: var(--space-4);
166 }
167 .mkt-search {
168 display: flex;
169 gap: 8px;
170 flex: 1;
171 min-width: 240px;
172 }
173 .mkt-search input {
174 flex: 1;
175 padding: 9px 12px;
176 background: var(--bg-elevated);
177 border: 1px solid var(--border);
178 border-radius: 10px;
179 font-size: 14px;
180 color: var(--text);
181 transition: border-color 120ms ease, box-shadow 120ms ease;
182 }
183 .mkt-search input:focus {
184 outline: none;
6fd5915Claude185 border-color: rgba(91,110,232,0.45);
186 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
655a200Claude187 }
188 .mkt-search button {
189 padding: 9px 16px;
190 border-radius: 10px;
191 background: rgba(255,255,255,0.04);
192 border: 1px solid var(--border);
193 color: var(--text);
194 font: inherit;
195 font-size: 13px;
196 font-weight: 600;
197 cursor: pointer;
198 transition: border-color 120ms ease, background 120ms ease;
199 }
200 .mkt-search button:hover {
6fd5915Claude201 border-color: rgba(91,110,232,0.45);
202 background: rgba(91,110,232,0.06);
655a200Claude203 }
204
205 /* ─── Category filter pills ─── */
206 .mkt-pills {
207 display: flex;
208 gap: 6px;
209 flex-wrap: wrap;
210 margin-bottom: var(--space-4);
211 }
212 .mkt-pill {
213 display: inline-flex;
214 align-items: center;
215 gap: 6px;
216 padding: 6px 14px;
217 border-radius: 9999px;
218 font-size: 12px;
219 font-weight: 600;
220 color: var(--text-muted);
221 background: rgba(255,255,255,0.03);
222 border: 1px solid var(--border);
223 text-decoration: none;
224 cursor: pointer;
225 transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
226 }
227 .mkt-pill:hover {
228 color: var(--text-strong);
6fd5915Claude229 border-color: rgba(91,110,232,0.45);
230 background: rgba(91,110,232,0.06);
655a200Claude231 text-decoration: none;
232 }
233 .mkt-pill.is-active {
6fd5915Claude234 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
655a200Claude235 color: #c5b3ff;
6fd5915Claude236 border-color: rgba(91,110,232,0.45);
655a200Claude237 }
238
239 /* ─── App grid ─── */
240 .mkt-grid {
241 display: grid;
242 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
243 gap: var(--space-3);
244 }
245 .mkt-card {
246 position: relative;
247 padding: var(--space-4);
248 background: var(--bg-elevated);
249 border: 1px solid var(--border);
250 border-radius: 14px;
251 display: flex;
252 flex-direction: column;
253 gap: 10px;
254 color: inherit;
255 text-decoration: none;
256 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
257 }
258 .mkt-card:hover {
6fd5915Claude259 border-color: rgba(91,110,232,0.45);
655a200Claude260 transform: translateY(-2px);
6fd5915Claude261 box-shadow: 0 10px 28px -10px rgba(91,110,232,0.30);
655a200Claude262 text-decoration: none;
263 color: inherit;
264 }
265 .mkt-card-head {
266 display: flex;
267 align-items: center;
268 gap: 12px;
269 }
270 .mkt-logo {
271 display: inline-flex;
272 align-items: center;
273 justify-content: center;
274 width: 44px; height: 44px;
275 border-radius: 11px;
276 flex-shrink: 0;
277 font-family: var(--font-display);
278 font-weight: 800;
279 font-size: 18px;
280 color: #fff;
281 letter-spacing: -0.02em;
282 box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), 0 4px 12px -6px rgba(0,0,0,0.45);
283 }
284 .mkt-card-name {
285 font-family: var(--font-display);
286 font-size: 16px;
287 font-weight: 700;
288 color: var(--text-strong);
289 margin: 0;
290 letter-spacing: -0.012em;
291 }
292 .mkt-card-bot {
293 font-family: var(--font-mono);
294 font-size: 11px;
295 color: var(--text-muted);
296 margin-top: 1px;
297 }
298 .mkt-card-desc {
299 font-size: 13px;
300 color: var(--text-muted);
301 line-height: 1.5;
302 margin: 0;
303 flex: 1;
304 }
305 .mkt-card-meta {
306 display: flex;
307 align-items: center;
308 gap: 12px;
309 margin-top: 4px;
310 font-size: 11.5px;
311 color: var(--text-muted);
312 font-variant-numeric: tabular-nums;
313 }
314 .mkt-card-meta .meta-item {
315 display: inline-flex;
316 align-items: center;
317 gap: 4px;
318 }
319 .mkt-card-meta .meta-item svg { color: var(--accent); opacity: 0.85; }
320 .mkt-card-foot {
321 display: flex;
322 align-items: center;
323 justify-content: space-between;
324 gap: 8px;
325 margin-top: 4px;
326 }
327 .mkt-card-perm {
328 font-size: 11px;
329 color: var(--text-muted);
330 text-transform: uppercase;
331 letter-spacing: 0.06em;
332 font-family: var(--font-mono);
333 }
334 .mkt-install-btn {
335 display: inline-flex;
336 align-items: center;
337 justify-content: center;
338 padding: 6px 14px;
339 border-radius: 8px;
340 font-size: 12px;
341 font-weight: 600;
6fd5915Claude342 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
655a200Claude343 color: #fff;
344 text-decoration: none;
6fd5915Claude345 box-shadow: 0 4px 12px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
655a200Claude346 transition: transform 120ms ease;
347 }
348 .mkt-install-btn:hover {
349 transform: translateY(-1px);
350 color: #fff;
351 text-decoration: none;
352 }
353
354 /* ─── Empty / zero state ─── */
355 .mkt-empty {
356 position: relative;
357 padding: clamp(28px, 4vw, 44px) clamp(20px, 4vw, 40px);
358 text-align: center;
359 background: var(--bg-elevated);
6fd5915Claude360 border: 1px dashed rgba(91,110,232,0.40);
655a200Claude361 border-radius: 16px;
362 overflow: hidden;
363 }
364 .mkt-empty-orb {
365 position: absolute;
366 inset: -40% -20% auto auto;
367 width: 320px; height: 320px;
6fd5915Claude368 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
655a200Claude369 filter: blur(60px);
370 opacity: 0.7;
371 pointer-events: none;
372 z-index: 0;
373 }
374 .mkt-empty-inner { position: relative; z-index: 1; }
375 .mkt-empty-glyph {
376 display: inline-flex;
377 align-items: center;
378 justify-content: center;
379 width: 56px; height: 56px;
380 border-radius: 9999px;
6fd5915Claude381 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
655a200Claude382 color: #c5b3ff;
6fd5915Claude383 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
655a200Claude384 margin-bottom: 14px;
385 }
386 .mkt-empty-title {
387 font-family: var(--font-display);
388 font-size: 20px;
389 font-weight: 700;
390 margin: 0 0 8px;
391 color: var(--text-strong);
392 letter-spacing: -0.018em;
393 }
394 .mkt-empty-sub {
395 font-size: 14px;
396 color: var(--text-muted);
397 margin: 0 auto 18px;
398 max-width: 480px;
399 line-height: 1.55;
400 }
401 .mkt-empty-actions {
402 display: flex;
403 gap: 10px;
404 justify-content: center;
405 flex-wrap: wrap;
406 }
407
408 /* ─── Section card (shared) ─── */
409 .mkt-section {
410 margin-bottom: var(--space-5);
411 background: var(--bg-elevated);
412 border: 1px solid var(--border);
413 border-radius: 14px;
414 overflow: hidden;
415 }
416 .mkt-section-head {
417 padding: var(--space-4) var(--space-5);
418 border-bottom: 1px solid var(--border);
419 display: flex;
420 align-items: flex-start;
421 justify-content: space-between;
422 gap: var(--space-3);
423 flex-wrap: wrap;
424 }
425 .mkt-section-title {
426 margin: 0;
427 font-family: var(--font-display);
428 font-size: 16px;
429 font-weight: 700;
430 color: var(--text-strong);
431 letter-spacing: -0.014em;
432 }
433 .mkt-section-sub {
434 margin: 4px 0 0;
435 font-size: 12.5px;
436 color: var(--text-muted);
437 }
438 .mkt-section-body { padding: var(--space-4) var(--space-5); }
439
440 /* ─── Buttons ─── */
441 .mkt-btn {
442 display: inline-flex;
443 align-items: center;
444 justify-content: center;
445 gap: 6px;
446 padding: 9px 16px;
447 border-radius: 10px;
448 font-size: 13px;
449 font-weight: 600;
450 text-decoration: none;
451 border: 1px solid transparent;
452 cursor: pointer;
453 font: inherit;
454 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
455 line-height: 1;
456 }
457 .mkt-btn-primary {
6fd5915Claude458 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
655a200Claude459 color: #ffffff;
6fd5915Claude460 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
655a200Claude461 }
462 .mkt-btn-primary:hover {
463 transform: translateY(-1px);
6fd5915Claude464 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
655a200Claude465 color: #ffffff;
466 text-decoration: none;
467 }
468 .mkt-btn-ghost {
469 background: transparent;
470 color: var(--text);
471 border-color: var(--border-strong, var(--border));
472 }
473 .mkt-btn-ghost:hover {
6fd5915Claude474 background: rgba(91,110,232,0.06);
475 border-color: rgba(91,110,232,0.45);
655a200Claude476 color: var(--text-strong);
477 text-decoration: none;
478 }
479 .mkt-btn-danger {
480 background: transparent;
e589f77ccantynz-alt481 color: var(--red);
655a200Claude482 border-color: rgba(248,113,113,0.35);
483 padding: 6px 12px;
484 font-size: 12px;
485 }
486 .mkt-btn-danger:hover {
487 border-style: dashed;
488 border-color: rgba(248,113,113,0.70);
489 background: rgba(248,113,113,0.06);
490 color: #fecaca;
491 }
492
493 /* ─── Detail page ─── */
494 .mkt-detail-head {
495 display: flex;
496 align-items: flex-start;
497 gap: var(--space-4);
498 margin-bottom: var(--space-4);
499 flex-wrap: wrap;
500 }
501 .mkt-detail-logo {
502 width: 64px; height: 64px;
503 border-radius: 14px;
504 flex-shrink: 0;
505 display: inline-flex;
506 align-items: center;
507 justify-content: center;
508 font-family: var(--font-display);
509 font-weight: 800;
510 font-size: 26px;
511 color: #fff;
512 letter-spacing: -0.02em;
513 box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 6px 18px -6px rgba(0,0,0,0.45);
514 }
515 .mkt-detail-name {
516 font-family: var(--font-display);
517 font-size: 28px;
518 font-weight: 800;
519 color: var(--text-strong);
520 margin: 0;
521 letter-spacing: -0.022em;
522 }
523 .mkt-detail-meta {
524 margin-top: 4px;
525 font-size: 12.5px;
526 color: var(--text-muted);
527 font-family: var(--font-mono);
528 }
529
530 /* ─── Perm list ─── */
531 .mkt-perm-list {
532 list-style: none;
533 margin: 0;
534 padding: 0;
535 display: flex;
536 flex-wrap: wrap;
537 gap: 6px;
538 }
539 .mkt-perm-list li code {
540 display: inline-flex;
541 align-items: center;
542 gap: 6px;
543 padding: 4px 10px;
544 border-radius: 9999px;
545 font-family: var(--font-mono);
546 font-size: 12px;
547 color: var(--text);
548 background: rgba(255,255,255,0.04);
549 border: 1px solid var(--border);
550 }
551
552 /* ─── Install form labels ─── */
553 .mkt-perm-labels {
554 display: flex;
555 flex-wrap: wrap;
556 gap: 8px 14px;
557 font-size: 13px;
558 color: var(--text);
559 margin: var(--space-3) 0;
560 }
561 .mkt-perm-labels label {
562 display: inline-flex;
563 align-items: center;
564 gap: 6px;
565 }
566
567 /* ─── Developer form ─── */
568 .mkt-form-group { margin-bottom: var(--space-3); }
569 .mkt-form-group label {
570 display: block;
571 font-size: 12.5px;
572 font-weight: 600;
573 color: var(--text-strong);
574 margin-bottom: 6px;
575 }
576 .mkt-form-group input[type="text"],
577 .mkt-form-group input[type="url"],
578 .mkt-form-group textarea,
579 .mkt-form-group select {
580 width: 100%;
581 padding: 9px 12px;
582 background: var(--bg-secondary, rgba(0,0,0,0.15));
583 border: 1px solid var(--border);
584 border-radius: 9px;
585 font: inherit;
586 font-size: 13.5px;
587 color: var(--text);
588 box-sizing: border-box;
589 transition: border-color 120ms ease, box-shadow 120ms ease;
590 }
591 .mkt-form-group input[type="text"]:focus,
592 .mkt-form-group input[type="url"]:focus,
593 .mkt-form-group textarea:focus {
594 outline: none;
6fd5915Claude595 border-color: rgba(91,110,232,0.45);
596 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
655a200Claude597 }
598 .mkt-checkbox-grid {
599 display: grid;
600 gap: 6px 14px;
601 font-size: 13px;
602 }
603 .mkt-checkbox-grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
604 .mkt-checkbox-grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
605 @media (max-width: 600px) {
606 .mkt-checkbox-grid.cols-2,
607 .mkt-checkbox-grid.cols-3 { grid-template-columns: 1fr; }
608 }
609
610 /* ─── Installs list (settings/apps + developer/manage) ─── */
611 .mkt-list-item {
612 display: flex;
613 align-items: center;
614 justify-content: space-between;
615 gap: 12px;
616 padding: 14px 18px;
617 border-bottom: 1px solid var(--border);
618 flex-wrap: wrap;
619 }
620 .mkt-list-item:last-child { border-bottom: none; }
621 .mkt-list-item-main { flex: 1; min-width: 0; }
622 .mkt-list-item-title {
623 font-weight: 600;
624 color: var(--text-strong);
625 text-decoration: none;
626 }
627 .mkt-list-item-title:hover { color: var(--accent); }
628 .mkt-list-item-meta {
629 font-size: 12px;
630 color: var(--text-muted);
631 margin-top: 2px;
632 font-variant-numeric: tabular-nums;
633 }
634
635 /* ─── Token-issued reveal ─── */
636 .mkt-token-block {
637 padding: 14px 16px;
638 margin-top: 12px;
639 background: rgba(255,255,255,0.04);
640 border: 1px solid var(--border);
641 border-radius: 10px;
642 font-family: var(--font-mono);
643 font-size: 13px;
644 color: var(--text-strong);
645 word-break: break-all;
646 }
647`;
648
649/* Inline SVG icons. */
650function IconDownload() {
651 return (
652 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
653 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
654 <polyline points="7 10 12 15 17 10" />
655 <line x1="12" y1="15" x2="12" y2="3" />
656 </svg>
657 );
658}
659function IconStar() {
660 return (
661 <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1" stroke-linejoin="round" aria-hidden="true">
662 <polygon points="12 2 15 9 22 9.5 17 14.5 18.5 22 12 18 5.5 22 7 14.5 2 9.5 9 9 12 2" />
663 </svg>
664 );
665}
666function IconArrowLeft() {
667 return (
668 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
669 <line x1="19" y1="12" x2="5" y2="12" />
670 <polyline points="12 19 5 12 12 5" />
671 </svg>
672 );
673}
674function IconGrid() {
675 return (
676 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
677 <rect x="3" y="3" width="7" height="7" />
678 <rect x="14" y="3" width="7" height="7" />
679 <rect x="14" y="14" width="7" height="7" />
680 <rect x="3" y="14" width="7" height="7" />
681 </svg>
682 );
683}
684
685/* Map a slug to a stable gradient so each app gets a unique-feeling logo.
686 * The same input always renders the same gradient — keeps it consistent
687 * across views and avoids the "rebuild → palette shuffled" effect. */
688const LOGO_GRADIENTS = [
6fd5915Claude689 "linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%)",
655a200Claude690 "linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)",
691 "linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)",
692 "linear-gradient(135deg, #10b981 0%, #14b8a6 100%)",
693 "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
694 "linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%)",
695 "linear-gradient(135deg, #84cc16 0%, #22c55e 100%)",
696 "linear-gradient(135deg, #f97316 0%, #fb7185 100%)",
697];
698
699function gradientFor(slug: string): string {
700 let h = 0;
701 for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0;
702 const idx = ((h % LOGO_GRADIENTS.length) + LOGO_GRADIENTS.length) % LOGO_GRADIENTS.length;
703 return LOGO_GRADIENTS[idx]!;
704}
705
706function appInitials(name: string): string {
707 const parts = name.trim().split(/[\s\-_]+/).filter(Boolean);
708 if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
709 return name.slice(0, 2).toUpperCase();
710}
711
06139e6Claude712// ---------- Public directory ----------
713
714marketplace.get("/marketplace", async (c) => {
715 const user = c.get("user");
716 const q = c.req.query("q") || "";
717 const list = await listPublicApps(q);
718 return c.html(
719 <Layout title="Marketplace — Gluecron" user={user}>
655a200Claude720 <style dangerouslySetInnerHTML={{ __html: styles }} />
721 <div class="mkt-wrap">
722 {/* ─── Hero ─── */}
723 <section class="mkt-hero">
724 <div class="mkt-hero-orb" aria-hidden="true" />
725 <div class="mkt-hero-inner">
726 <div class="mkt-hero-text">
727 <div class="mkt-eyebrow">
728 <span class="mkt-eyebrow-dot" aria-hidden="true" />
729 Marketplace
730 </div>
731 <h1 class="mkt-title">
732 <span class="mkt-title-grad">Extend.</span>
733 </h1>
734 <p class="mkt-sub">
735 Apps, bots, and integrations that plug into your repos. Install
736 in one click — every app runs with its own scoped bot identity.
737 </p>
738 </div>
5ca514aClaude739 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
740 <a href="/marketplace/agents" class="mkt-hero-cta">
741 Agents &rarr;
655a200Claude742 </a>
5ca514aClaude743 {user && (
744 <a
745 href="/developer/apps-new"
746 class="mkt-hero-cta"
747 style="background:transparent;border:1px solid var(--border);color:var(--text);box-shadow:none"
748 >
749 + Register app
750 </a>
751 )}
752 </div>
655a200Claude753 </div>
754 </section>
755
756 {/* ─── Search + category pills ─── */}
757 <form method="get" action="/marketplace" class="mkt-toolbar">
758 <div class="mkt-search">
759 <input
760 type="text"
761 name="q"
762 value={q}
763 placeholder="Search apps by name, description, or permission"
764 aria-label="Search apps"
765 />
766 <button type="submit">Search</button>
767 </div>
768 </form>
769
770 <div class="mkt-pills" role="tablist" aria-label="Filter by category">
771 <a href="/marketplace" class={"mkt-pill" + (!q ? " is-active" : "")}>
772 All apps
06139e6Claude773 </a>
655a200Claude774 <a href="/marketplace?q=ci" class={"mkt-pill" + (q === "ci" ? " is-active" : "")}>
775 CI / CD
776 </a>
777 <a href="/marketplace?q=ai" class={"mkt-pill" + (q === "ai" ? " is-active" : "")}>
778 AI
779 </a>
780 <a href="/marketplace?q=security" class={"mkt-pill" + (q === "security" ? " is-active" : "")}>
781 Security
782 </a>
783 <a href="/marketplace?q=chat" class={"mkt-pill" + (q === "chat" ? " is-active" : "")}>
784 Chat
785 </a>
786 <a href="/marketplace?q=monitoring" class={"mkt-pill" + (q === "monitoring" ? " is-active" : "")}>
787 Monitoring
788 </a>
789 </div>
790
791 {/* ─── App grid ─── */}
06139e6Claude792 {list.length === 0 ? (
655a200Claude793 <div class="mkt-empty">
794 <div class="mkt-empty-orb" aria-hidden="true" />
795 <div class="mkt-empty-inner">
796 <span class="mkt-empty-glyph" aria-hidden="true"><IconGrid /></span>
797 <h2 class="mkt-empty-title">
798 {q ? "No matching apps." : "No apps yet."}
799 </h2>
800 <p class="mkt-empty-sub">
801 {q
802 ? <>Nothing matches <code style="font-family:var(--font-mono);background:rgba(255,255,255,0.04);padding:1px 6px;border-radius:4px">{q}</code>. Try a different search or clear the filter.</>
803 : <>The marketplace is empty. Build the first app and list it for everyone.</>}
804 </p>
805 <div class="mkt-empty-actions">
806 {q && <a href="/marketplace" class="mkt-btn mkt-btn-ghost">Clear search</a>}
807 {user && <a href="/developer/apps-new" class="mkt-btn mkt-btn-primary">+ Register an app</a>}
808 {!user && <a href="/login?next=/developer/apps-new" class="mkt-btn mkt-btn-primary">Sign in to register</a>}
06139e6Claude809 </div>
655a200Claude810 </div>
811 </div>
812 ) : (
813 <div class="mkt-grid">
814 {list.map((a) => {
815 const perms = parsePermissions(a.permissions).length;
816 return (
817 <a href={`/marketplace/${a.slug}`} class="mkt-card">
818 <div class="mkt-card-head">
819 <span class="mkt-logo" aria-hidden="true" style={`background:${gradientFor(a.slug)}`}>
820 {appInitials(a.name)}
821 </span>
822 <div style="min-width:0">
823 <h3 class="mkt-card-name">{a.name}</h3>
824 <div class="mkt-card-bot">{a.slug}[bot]</div>
825 </div>
826 </div>
827 <p class="mkt-card-desc">
828 {(a.description || "No description.").slice(0, 140)}
829 </p>
830 <div class="mkt-card-meta">
831 <span class="meta-item" title="Permissions requested">
832 <IconDownload />
833 {perms} perm{perms === 1 ? "" : "s"}
834 </span>
835 <span class="meta-item" title="Verified">
836 <IconStar />
837 Verified
838 </span>
839 </div>
840 <div class="mkt-card-foot">
841 <span class="mkt-card-perm">Public app</span>
842 <span class="mkt-install-btn">Install &rarr;</span>
843 </div>
844 </a>
845 );
846 })}
847 </div>
06139e6Claude848 )}
849 </div>
850 </Layout>
851 );
852});
853
854marketplace.get("/marketplace/:slug", async (c) => {
855 const user = c.get("user");
856 const slug = c.req.param("slug");
857 const app = await getAppBySlug(slug);
858 if (!app || !app.isPublic) return c.notFound();
859 const [installs, perms] = await Promise.all([
860 countInstalls(app.id),
861 Promise.resolve(parsePermissions(app.permissions)),
862 ]);
863 return c.html(
864 <Layout title={`${app.name} — Marketplace`} user={user}>
655a200Claude865 <style dangerouslySetInnerHTML={{ __html: styles }} />
866 <div class="mkt-wrap">
867 <section class="mkt-hero">
868 <div class="mkt-hero-orb" aria-hidden="true" />
869 <div class="mkt-hero-inner">
870 <div class="mkt-hero-text" style="flex:1">
871 <div class="mkt-detail-head">
872 <span class="mkt-detail-logo" aria-hidden="true" style={`background:${gradientFor(app.slug)}`}>
873 {appInitials(app.name)}
874 </span>
875 <div style="min-width:0">
876 <h1 class="mkt-detail-name">{app.name}</h1>
877 <div class="mkt-detail-meta">
878 {app.slug}[bot] · {installs} install{installs === 1 ? "" : "s"}
879 </div>
880 </div>
881 </div>
882 <p class="mkt-sub">{app.description || "No description."}</p>
883 {app.homepageUrl && (
884 <p class="mkt-sub" style="font-size:13px;margin-top:8px">
885 Homepage: <a href={app.homepageUrl} style="color:var(--accent);text-decoration:none">{app.homepageUrl}</a>
886 </p>
887 )}
888 </div>
889 <a href="/marketplace" class="mkt-hero-cta" style="background:transparent;border-color:var(--border);color:var(--text-muted);box-shadow:none">
890 <IconArrowLeft /> Back
891 </a>
892 </div>
893 </section>
06139e6Claude894
655a200Claude895 <section class="mkt-section">
896 <header class="mkt-section-head">
897 <div>
898 <h3 class="mkt-section-title">Permissions</h3>
899 <p class="mkt-section-sub">
900 Granted to <strong style="color:var(--text)">{app.name}</strong> on install.
901 Revoke any time from <a href="/settings/apps" style="color:var(--accent);text-decoration:none">/settings/apps</a>.
902 </p>
903 </div>
904 </header>
905 <div class="mkt-section-body">
906 {perms.length === 0 ? (
907 <div class="mkt-empty" style="padding:20px;border-style:dashed">
908 <div class="mkt-empty-inner">No permissions requested.</div>
909 </div>
910 ) : (
911 <ul class="mkt-perm-list">
912 {perms.map((p) => (
913 <li><code>{p}</code></li>
914 ))}
915 </ul>
916 )}
917 </div>
918 </section>
919
920 {user ? (
921 <section class="mkt-section">
922 <header class="mkt-section-head">
923 <div>
924 <h3 class="mkt-section-title">Install on your account</h3>
925 <p class="mkt-section-sub">
926 Installing grants {perms.length} permission{perms.length === 1 ? "" : "s"}{" "}
927 to <strong style="color:var(--text)">{app.name}</strong> on your personal account.
928 </p>
929 </div>
930 </header>
931 <div class="mkt-section-body">
932 <form method="post" action={`/marketplace/${slug}/install`}>
933 <div class="mkt-perm-labels">
934 {perms.map((p) => (
935 <label>
936 <input type="checkbox" name="permissions" value={p} checked />
937 <code style="font-family:var(--font-mono);font-size:12px">{p}</code>
938 </label>
939 ))}
940 </div>
941 <button type="submit" class="mkt-btn mkt-btn-primary">
942 Install {app.name}
943 </button>
944 </form>
945 </div>
946 </section>
06139e6Claude947 ) : (
655a200Claude948 <div class="mkt-empty">
949 <div class="mkt-empty-inner">
950 <span class="mkt-empty-glyph" aria-hidden="true"><IconDownload /></span>
951 <h2 class="mkt-empty-title">Sign in to install</h2>
952 <p class="mkt-empty-sub">Apps are installed against your account so they can act on your repos.</p>
953 <div class="mkt-empty-actions">
954 <a href={`/login?next=/marketplace/${slug}`} class="mkt-btn mkt-btn-primary">Sign in</a>
955 </div>
06139e6Claude956 </div>
655a200Claude957 </div>
06139e6Claude958 )}
959 </div>
960 </Layout>
961 );
962});
963
964marketplace.post("/marketplace/:slug/install", requireAuth, async (c) => {
965 const user = c.get("user")!;
966 const slug = c.req.param("slug");
967 const app = await getAppBySlug(slug);
968 if (!app) return c.notFound();
969 const body = await c.req.parseBody({ all: true });
970 const rawPerms = body.permissions;
971 const perms = Array.isArray(rawPerms)
972 ? rawPerms.map(String)
973 : rawPerms
974 ? [String(rawPerms)]
975 : [];
976 const inst = await installApp({
977 appId: app.id,
978 installedBy: user.id,
979 targetType: "user",
980 targetId: user.id,
981 grantedPermissions: perms,
982 });
983 if (inst) {
984 await audit({
985 userId: user.id,
986 action: "marketplace.install",
987 targetType: "app",
988 targetId: app.id,
989 metadata: { grantedPermissions: normalisePermissions(perms) },
990 });
991 }
992 return c.redirect("/settings/apps");
993});
994
995marketplace.post(
996 "/marketplace/installations/:id/uninstall",
997 requireAuth,
998 async (c) => {
999 const user = c.get("user")!;
1000 const id = c.req.param("id");
1001 // Only the installer can uninstall
1002 const [inst] = await db
1003 .select()
1004 .from(appInstallations)
1005 .where(eq(appInstallations.id, id))
1006 .limit(1);
1007 if (!inst || inst.installedBy !== user.id) {
1008 return c.text("forbidden", 403);
1009 }
1010 const ok = await uninstallApp(id);
1011 if (ok) {
1012 await audit({
1013 userId: user.id,
1014 action: "marketplace.uninstall",
1015 targetType: "app_installation",
1016 targetId: id,
1017 });
1018 }
1019 return c.redirect("/settings/apps");
1020 }
1021);
1022
1023// ---------- Personal installs ----------
1024
1025marketplace.get("/settings/apps", requireAuth, async (c) => {
1026 const user = c.get("user")!;
1027 const installs = await listInstallationsForTarget("user", user.id);
1028 return c.html(
1029 <Layout title="Installed apps — Gluecron" user={user}>
655a200Claude1030 <style dangerouslySetInnerHTML={{ __html: styles }} />
9b776daccantynz-alt1031 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
1032 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
1033 <SettingsNav active="apps" />
1034 <div class="mkt-wrap settings-content" style="max-width:none;margin:0;padding:0">
655a200Claude1035 <section class="mkt-hero">
1036 <div class="mkt-hero-orb" aria-hidden="true" />
1037 <div class="mkt-hero-inner">
1038 <div class="mkt-hero-text">
1039 <div class="mkt-eyebrow">
1040 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1041 Installed apps · <strong>@{user.username}</strong>
1042 </div>
1043 <h1 class="mkt-title">
1044 <span class="mkt-title-grad">Your apps.</span>
1045 </h1>
1046 <p class="mkt-sub">
1047 Every app you've installed, plus the permissions you granted. Uninstall any time.
1048 </p>
1049 </div>
1050 <a href="/marketplace" class="mkt-hero-cta">
1051 Browse marketplace
1052 </a>
1053 </div>
1054 </section>
1055
06139e6Claude1056 {installs.length === 0 ? (
655a200Claude1057 <div class="mkt-empty">
1058 <div class="mkt-empty-orb" aria-hidden="true" />
1059 <div class="mkt-empty-inner">
1060 <span class="mkt-empty-glyph" aria-hidden="true"><IconGrid /></span>
1061 <h2 class="mkt-empty-title">No apps installed.</h2>
1062 <p class="mkt-empty-sub">
1063 Browse the marketplace and install your first integration —
1064 CI bots, AI reviewers, notification bridges, and more.
1065 </p>
1066 <div class="mkt-empty-actions">
1067 <a href="/marketplace" class="mkt-btn mkt-btn-primary">Browse the marketplace</a>
1068 </div>
1069 </div>
06139e6Claude1070 </div>
1071 ) : (
655a200Claude1072 <div class="mkt-section">
1073 {installs.map((i) => (
1074 <div class="mkt-list-item">
1075 <div class="mkt-list-item-main">
1076 <a
1077 href={i.app ? `/marketplace/${i.app.slug}` : "#"}
1078 class="mkt-list-item-title"
1079 >
1080 {i.app?.name || "(unknown app)"}
1081 </a>
1082 <div class="mkt-list-item-meta">
1083 {parsePermissions(i.grantedPermissions).length} permissions ·
1084 installed{" "}
1085 {i.createdAt
1086 ? new Date(i.createdAt).toLocaleDateString()
1087 : ""}
1088 </div>
06139e6Claude1089 </div>
655a200Claude1090 <form
1091 method="post"
1092 action={`/marketplace/installations/${i.id}/uninstall`}
1093 onsubmit="return confirm('Uninstall this app?')"
1094 style="margin:0"
1095 >
1096 <button type="submit" class="mkt-btn mkt-btn-danger">
1097 Uninstall
1098 </button>
1099 </form>
06139e6Claude1100 </div>
655a200Claude1101 ))}
1102 </div>
06139e6Claude1103 )}
1104 </div>
9b776daccantynz-alt1105 </div>
06139e6Claude1106 </Layout>
1107 );
1108});
1109
1110// ---------- Developer UX ----------
1111
1112marketplace.get("/developer/apps-new", requireAuth, async (c) => {
1113 const user = c.get("user")!;
1114 return c.html(
1115 <Layout title="New app — Marketplace" user={user}>
655a200Claude1116 <style dangerouslySetInnerHTML={{ __html: styles }} />
1117 <div class="mkt-wrap">
1118 <section class="mkt-hero">
1119 <div class="mkt-hero-orb" aria-hidden="true" />
1120 <div class="mkt-hero-inner">
1121 <div class="mkt-hero-text">
1122 <div class="mkt-eyebrow">
1123 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1124 Developer · New app
1125 </div>
1126 <h1 class="mkt-title">
1127 <span class="mkt-title-grad">Build.</span>
1128 </h1>
1129 <p class="mkt-sub">
1130 Register a new app, declare the permissions it needs, and pick the events it listens for.
1131 Your app gets a scoped bot identity and a webhook secret on submit.
1132 </p>
1133 </div>
1134 <a href="/marketplace" class="mkt-hero-cta" style="background:transparent;border:1px solid var(--border);color:var(--text-muted);box-shadow:none">
1135 <IconArrowLeft /> Back
1136 </a>
06139e6Claude1137 </div>
655a200Claude1138 </section>
1139
1140 <form method="post" action="/developer/apps-new" class="mkt-section">
1141 <div class="mkt-section-body">
1142 <div class="mkt-form-group">
1143 <label>Name</label>
1144 <input type="text" name="name" required aria-label="App name" />
1145 </div>
1146 <div class="mkt-form-group">
1147 <label>Description</label>
1148 <textarea name="description" rows={3} />
1149 </div>
1150 <div class="mkt-form-group">
1151 <label>Homepage URL</label>
1152 <input type="url" name="homepageUrl" aria-label="Homepage URL" />
1153 </div>
1154 <div class="mkt-form-group">
1155 <label>Webhook URL (optional)</label>
1156 <input type="url" name="webhookUrl" aria-label="Webhook URL" />
1157 </div>
1158 <div class="mkt-form-group">
1159 <label>Permissions</label>
1160 <div class="mkt-checkbox-grid cols-2">
1161 {KNOWN_PERMISSIONS.map((p) => (
1162 <label>
1163 <input type="checkbox" name="permissions" value={p} /> {p}
1164 </label>
1165 ))}
1166 </div>
1167 </div>
1168 <div class="mkt-form-group">
1169 <label>Events</label>
1170 <div class="mkt-checkbox-grid cols-3">
1171 {KNOWN_EVENTS.map((e) => (
1172 <label>
1173 <input type="checkbox" name="events" value={e} /> {e}
1174 </label>
1175 ))}
1176 </div>
1177 </div>
1178 <div class="mkt-form-group">
06139e6Claude1179 <label>
655a200Claude1180 <input type="checkbox" name="isPublic" value="1" checked /> List in public marketplace
06139e6Claude1181 </label>
655a200Claude1182 </div>
1183 <button type="submit" class="mkt-btn mkt-btn-primary">
1184 Create app
1185 </button>
06139e6Claude1186 </div>
655a200Claude1187 </form>
1188 </div>
06139e6Claude1189 </Layout>
1190 );
1191});
1192
1193marketplace.post("/developer/apps-new", requireAuth, async (c) => {
1194 const user = c.get("user")!;
1195 const body = await c.req.parseBody({ all: true });
1196 const name = String(body.name || "").trim();
1197 if (!name) return c.redirect("/developer/apps-new");
1198 const rawPerms = body.permissions;
1199 const perms = Array.isArray(rawPerms)
1200 ? rawPerms.map(String)
1201 : rawPerms
1202 ? [String(rawPerms)]
1203 : [];
1204 const rawEvents = body.events;
1205 const events = Array.isArray(rawEvents)
1206 ? rawEvents.map(String)
1207 : rawEvents
1208 ? [String(rawEvents)]
1209 : [];
1210 const app = await createApp({
1211 name,
1212 description: String(body.description || ""),
1213 homepageUrl: String(body.homepageUrl || "") || undefined,
1214 webhookUrl: String(body.webhookUrl || "") || undefined,
1215 creatorId: user.id,
1216 permissions: perms,
1217 defaultEvents: events,
1218 isPublic: !!body.isPublic,
1219 });
1220 if (!app) return c.text("failed to create", 500);
1221 await audit({
1222 userId: user.id,
1223 action: "marketplace.app.create",
1224 targetType: "app",
1225 targetId: app.id,
1226 });
1227 return c.redirect(`/developer/apps/${app.slug}/manage`);
1228});
1229
1230marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
1231 const user = c.get("user")!;
1232 const slug = c.req.param("slug");
1233 const app = await getAppBySlug(slug);
1234 if (!app) return c.notFound();
1235 if (app.creatorId !== user.id) return c.text("forbidden", 403);
1236 const [installs, events] = await Promise.all([
1237 listInstallationsForApp(app.id),
1238 listEventsForApp(app.id, 20),
1239 ]);
1240 return c.html(
1241 <Layout title={`Manage ${app.name}`} user={user}>
655a200Claude1242 <style dangerouslySetInnerHTML={{ __html: styles }} />
1243 <div class="mkt-wrap">
1244 <section class="mkt-hero">
1245 <div class="mkt-hero-orb" aria-hidden="true" />
1246 <div class="mkt-hero-inner">
1247 <div class="mkt-hero-text">
1248 <div class="mkt-detail-head">
1249 <span class="mkt-detail-logo" aria-hidden="true" style={`background:${gradientFor(app.slug)}`}>
1250 {appInitials(app.name)}
1251 </span>
1252 <div style="min-width:0">
1253 <h1 class="mkt-detail-name">{app.name}</h1>
1254 <div class="mkt-detail-meta">Developer · {installs.length} install{installs.length === 1 ? "" : "s"}</div>
1255 </div>
1256 </div>
1257 </div>
1258 <a href={`/marketplace/${app.slug}`} class="mkt-hero-cta" style="background:transparent;border:1px solid var(--border);color:var(--text-muted);box-shadow:none">
1259 Public page
1260 </a>
06139e6Claude1261 </div>
655a200Claude1262 </section>
06139e6Claude1263
655a200Claude1264 <section class="mkt-section">
1265 <header class="mkt-section-head">
1266 <div>
1267 <h3 class="mkt-section-title">Bot identity</h3>
1268 <p class="mkt-section-sub">
1269 The bot account that authors comments, opens PRs, and signs webhook payloads on this app's behalf.
1270 </p>
1271 </div>
1272 </header>
1273 <div class="mkt-section-body">
1274 <div style="font-family:var(--font-mono);font-size:14px;color:var(--text-strong)">{app.slug}[bot]</div>
1275 {app.webhookSecret && (
1276 <div style="margin-top:10px;font-size:12.5px;color:var(--text-muted)">
1277 Webhook secret:{" "}
1278 <code style="font-family:var(--font-mono);background:rgba(255,255,255,0.04);padding:3px 8px;border-radius:6px;color:var(--text)">{app.webhookSecret}</code>
06139e6Claude1279 </div>
655a200Claude1280 )}
1281 </div>
1282 </section>
1283
1284 <section class="mkt-section">
1285 <header class="mkt-section-head">
1286 <div>
1287 <h3 class="mkt-section-title">Installations ({installs.length})</h3>
1288 <p class="mkt-section-sub">Every user or org that has granted this app access.</p>
1289 </div>
1290 </header>
1291 {installs.length === 0 ? (
1292 <div class="mkt-section-body">
1293 <div class="mkt-empty" style="padding:20px;border-style:dashed">
1294 <div class="mkt-empty-inner">No installs yet.</div>
06139e6Claude1295 </div>
1296 </div>
655a200Claude1297 ) : (
1298 installs.map((i) => (
1299 <div class="mkt-list-item">
1300 <div class="mkt-list-item-main">
1301 {i.targetType}: <code style="font-family:var(--font-mono);font-size:12px">{i.targetId}</code>
1302 </div>
1303 <div style="font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums">
1304 {parsePermissions(i.grantedPermissions).length} perms ·{" "}
1305 {i.createdAt
1306 ? new Date(i.createdAt).toLocaleDateString()
1307 : ""}
1308 </div>
1309 </div>
1310 ))
1311 )}
1312 </section>
06139e6Claude1313
655a200Claude1314 <section class="mkt-section">
1315 <header class="mkt-section-head">
1316 <div>
1317 <h3 class="mkt-section-title">Recent events</h3>
1318 <p class="mkt-section-sub">Last 20 events delivered to this app.</p>
06139e6Claude1319 </div>
655a200Claude1320 </header>
1321 {events.length === 0 ? (
1322 <div class="mkt-section-body">
1323 <div class="mkt-empty" style="padding:20px;border-style:dashed">
1324 <div class="mkt-empty-inner">No events yet.</div>
1325 </div>
1326 </div>
1327 ) : (
1328 events.map((e) => (
1329 <div class="mkt-list-item">
1330 <span style="font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">{e.kind}</span>
1331 <span style="font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums">
1332 {e.createdAt
1333 ? new Date(e.createdAt).toLocaleString()
1334 : ""}
1335 </span>
1336 </div>
1337 ))
1338 )}
1339 </section>
06139e6Claude1340
655a200Claude1341 <section class="mkt-section">
1342 <header class="mkt-section-head">
1343 <div>
1344 <h3 class="mkt-section-title">Installation tokens</h3>
1345 <p class="mkt-section-sub">
1346 Issue a bearer token for an existing installation. Use this to test bot API calls. Tokens are shown once and expire after 1 hour.
1347 </p>
1348 </div>
1349 </header>
1350 <div class="mkt-section-body">
1351 <form
1352 method="post"
1353 action={`/developer/apps/${app.slug}/tokens/new`}
1354 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"
1355 >
1356 <select name="installationId" class="mkt-form-group" style="margin:0;padding:9px 12px;background:var(--bg-secondary,rgba(0,0,0,0.15));border:1px solid var(--border);border-radius:9px;color:var(--text);font:inherit;font-size:13.5px">
1357 {installs.map((i) => (
1358 <option value={i.id}>
1359 {i.targetType}:{i.targetId.slice(0, 8)}
1360 </option>
1361 ))}
1362 </select>
1363 <button type="submit" class="mkt-btn mkt-btn-primary" disabled={installs.length === 0}>
1364 Issue token
1365 </button>
1366 </form>
1367 </div>
1368 </section>
1369 </div>
06139e6Claude1370 </Layout>
1371 );
1372});
1373
1374marketplace.post(
1375 "/developer/apps/:slug/tokens/new",
1376 requireAuth,
1377 async (c) => {
1378 const user = c.get("user")!;
1379 const slug = c.req.param("slug");
1380 const app = await getAppBySlug(slug);
1381 if (!app) return c.notFound();
1382 if (app.creatorId !== user.id) return c.text("forbidden", 403);
1383 const body = await c.req.parseBody();
1384 const installationId = String(body.installationId || "");
1385 if (!installationId) return c.redirect(`/developer/apps/${slug}/manage`);
1386 // Validate the installation belongs to this app
1387 const [inst] = await db
1388 .select()
1389 .from(appInstallations)
1390 .where(eq(appInstallations.id, installationId))
1391 .limit(1);
1392 if (!inst || inst.appId !== app.id) return c.text("forbidden", 403);
1393 const t = await issueInstallToken(installationId);
1394 if (!t) return c.text("failed", 500);
1395 await audit({
1396 userId: user.id,
1397 action: "marketplace.token.issue",
1398 targetType: "app_installation",
1399 targetId: installationId,
1400 });
1401 return c.html(
1402 <Layout title="Token issued" user={user}>
655a200Claude1403 <style dangerouslySetInnerHTML={{ __html: styles }} />
1404 <div class="mkt-wrap">
1405 <section class="mkt-hero">
1406 <div class="mkt-hero-orb" aria-hidden="true" />
1407 <div class="mkt-hero-inner">
1408 <div class="mkt-hero-text">
1409 <div class="mkt-eyebrow">
1410 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1411 Token issued
1412 </div>
1413 <h1 class="mkt-title">
1414 <span class="mkt-title-grad">Copy now.</span>
1415 </h1>
1416 <p class="mkt-sub">
1417 This token is shown once — store it somewhere safe. Expires{" "}
1418 {t.expiresAt.toISOString()}.
1419 </p>
1420 </div>
1421 </div>
1422 </section>
1423
1424 <section class="mkt-section">
1425 <div class="mkt-section-body">
1426 <div class="mkt-token-block">{t.token}</div>
1427 <a href={`/developer/apps/${slug}/manage`} class="mkt-btn mkt-btn-ghost" style="margin-top:14px">
1428 <IconArrowLeft /> Back
1429 </a>
1430 </div>
1431 </section>
06139e6Claude1432 </div>
1433 </Layout>
1434 );
1435 }
1436);
1437
1438export default marketplace;