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