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.tsxBlame1424 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 = `
54 .mkt-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
55
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>
738 {user && (
739 <a href="/developer/apps-new" class="mkt-hero-cta">
740 + Register app
741 </a>
742 )}
743 </div>
744 </section>
745
746 {/* ─── Search + category pills ─── */}
747 <form method="get" action="/marketplace" class="mkt-toolbar">
748 <div class="mkt-search">
749 <input
750 type="text"
751 name="q"
752 value={q}
753 placeholder="Search apps by name, description, or permission"
754 aria-label="Search apps"
755 />
756 <button type="submit">Search</button>
757 </div>
758 </form>
759
760 <div class="mkt-pills" role="tablist" aria-label="Filter by category">
761 <a href="/marketplace" class={"mkt-pill" + (!q ? " is-active" : "")}>
762 All apps
06139e6Claude763 </a>
655a200Claude764 <a href="/marketplace?q=ci" class={"mkt-pill" + (q === "ci" ? " is-active" : "")}>
765 CI / CD
766 </a>
767 <a href="/marketplace?q=ai" class={"mkt-pill" + (q === "ai" ? " is-active" : "")}>
768 AI
769 </a>
770 <a href="/marketplace?q=security" class={"mkt-pill" + (q === "security" ? " is-active" : "")}>
771 Security
772 </a>
773 <a href="/marketplace?q=chat" class={"mkt-pill" + (q === "chat" ? " is-active" : "")}>
774 Chat
775 </a>
776 <a href="/marketplace?q=monitoring" class={"mkt-pill" + (q === "monitoring" ? " is-active" : "")}>
777 Monitoring
778 </a>
779 </div>
780
781 {/* ─── App grid ─── */}
06139e6Claude782 {list.length === 0 ? (
655a200Claude783 <div class="mkt-empty">
784 <div class="mkt-empty-orb" aria-hidden="true" />
785 <div class="mkt-empty-inner">
786 <span class="mkt-empty-glyph" aria-hidden="true"><IconGrid /></span>
787 <h2 class="mkt-empty-title">
788 {q ? "No matching apps." : "No apps yet."}
789 </h2>
790 <p class="mkt-empty-sub">
791 {q
792 ? <>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.</>
793 : <>The marketplace is empty. Build the first app and list it for everyone.</>}
794 </p>
795 <div class="mkt-empty-actions">
796 {q && <a href="/marketplace" class="mkt-btn mkt-btn-ghost">Clear search</a>}
797 {user && <a href="/developer/apps-new" class="mkt-btn mkt-btn-primary">+ Register an app</a>}
798 {!user && <a href="/login?next=/developer/apps-new" class="mkt-btn mkt-btn-primary">Sign in to register</a>}
06139e6Claude799 </div>
655a200Claude800 </div>
801 </div>
802 ) : (
803 <div class="mkt-grid">
804 {list.map((a) => {
805 const perms = parsePermissions(a.permissions).length;
806 return (
807 <a href={`/marketplace/${a.slug}`} class="mkt-card">
808 <div class="mkt-card-head">
809 <span class="mkt-logo" aria-hidden="true" style={`background:${gradientFor(a.slug)}`}>
810 {appInitials(a.name)}
811 </span>
812 <div style="min-width:0">
813 <h3 class="mkt-card-name">{a.name}</h3>
814 <div class="mkt-card-bot">{a.slug}[bot]</div>
815 </div>
816 </div>
817 <p class="mkt-card-desc">
818 {(a.description || "No description.").slice(0, 140)}
819 </p>
820 <div class="mkt-card-meta">
821 <span class="meta-item" title="Permissions requested">
822 <IconDownload />
823 {perms} perm{perms === 1 ? "" : "s"}
824 </span>
825 <span class="meta-item" title="Verified">
826 <IconStar />
827 Verified
828 </span>
829 </div>
830 <div class="mkt-card-foot">
831 <span class="mkt-card-perm">Public app</span>
832 <span class="mkt-install-btn">Install &rarr;</span>
833 </div>
834 </a>
835 );
836 })}
837 </div>
06139e6Claude838 )}
839 </div>
840 </Layout>
841 );
842});
843
844marketplace.get("/marketplace/:slug", async (c) => {
845 const user = c.get("user");
846 const slug = c.req.param("slug");
847 const app = await getAppBySlug(slug);
848 if (!app || !app.isPublic) return c.notFound();
849 const [installs, perms] = await Promise.all([
850 countInstalls(app.id),
851 Promise.resolve(parsePermissions(app.permissions)),
852 ]);
853 return c.html(
854 <Layout title={`${app.name} — Marketplace`} user={user}>
655a200Claude855 <style dangerouslySetInnerHTML={{ __html: styles }} />
856 <div class="mkt-wrap">
857 <section class="mkt-hero">
858 <div class="mkt-hero-orb" aria-hidden="true" />
859 <div class="mkt-hero-inner">
860 <div class="mkt-hero-text" style="flex:1">
861 <div class="mkt-detail-head">
862 <span class="mkt-detail-logo" aria-hidden="true" style={`background:${gradientFor(app.slug)}`}>
863 {appInitials(app.name)}
864 </span>
865 <div style="min-width:0">
866 <h1 class="mkt-detail-name">{app.name}</h1>
867 <div class="mkt-detail-meta">
868 {app.slug}[bot] · {installs} install{installs === 1 ? "" : "s"}
869 </div>
870 </div>
871 </div>
872 <p class="mkt-sub">{app.description || "No description."}</p>
873 {app.homepageUrl && (
874 <p class="mkt-sub" style="font-size:13px;margin-top:8px">
875 Homepage: <a href={app.homepageUrl} style="color:var(--accent);text-decoration:none">{app.homepageUrl}</a>
876 </p>
877 )}
878 </div>
879 <a href="/marketplace" class="mkt-hero-cta" style="background:transparent;border-color:var(--border);color:var(--text-muted);box-shadow:none">
880 <IconArrowLeft /> Back
881 </a>
882 </div>
883 </section>
06139e6Claude884
655a200Claude885 <section class="mkt-section">
886 <header class="mkt-section-head">
887 <div>
888 <h3 class="mkt-section-title">Permissions</h3>
889 <p class="mkt-section-sub">
890 Granted to <strong style="color:var(--text)">{app.name}</strong> on install.
891 Revoke any time from <a href="/settings/apps" style="color:var(--accent);text-decoration:none">/settings/apps</a>.
892 </p>
893 </div>
894 </header>
895 <div class="mkt-section-body">
896 {perms.length === 0 ? (
897 <div class="mkt-empty" style="padding:20px;border-style:dashed">
898 <div class="mkt-empty-inner">No permissions requested.</div>
899 </div>
900 ) : (
901 <ul class="mkt-perm-list">
902 {perms.map((p) => (
903 <li><code>{p}</code></li>
904 ))}
905 </ul>
906 )}
907 </div>
908 </section>
909
910 {user ? (
911 <section class="mkt-section">
912 <header class="mkt-section-head">
913 <div>
914 <h3 class="mkt-section-title">Install on your account</h3>
915 <p class="mkt-section-sub">
916 Installing grants {perms.length} permission{perms.length === 1 ? "" : "s"}{" "}
917 to <strong style="color:var(--text)">{app.name}</strong> on your personal account.
918 </p>
919 </div>
920 </header>
921 <div class="mkt-section-body">
922 <form method="post" action={`/marketplace/${slug}/install`}>
923 <div class="mkt-perm-labels">
924 {perms.map((p) => (
925 <label>
926 <input type="checkbox" name="permissions" value={p} checked />
927 <code style="font-family:var(--font-mono);font-size:12px">{p}</code>
928 </label>
929 ))}
930 </div>
931 <button type="submit" class="mkt-btn mkt-btn-primary">
932 Install {app.name}
933 </button>
934 </form>
935 </div>
936 </section>
06139e6Claude937 ) : (
655a200Claude938 <div class="mkt-empty">
939 <div class="mkt-empty-inner">
940 <span class="mkt-empty-glyph" aria-hidden="true"><IconDownload /></span>
941 <h2 class="mkt-empty-title">Sign in to install</h2>
942 <p class="mkt-empty-sub">Apps are installed against your account so they can act on your repos.</p>
943 <div class="mkt-empty-actions">
944 <a href={`/login?next=/marketplace/${slug}`} class="mkt-btn mkt-btn-primary">Sign in</a>
945 </div>
06139e6Claude946 </div>
655a200Claude947 </div>
06139e6Claude948 )}
949 </div>
950 </Layout>
951 );
952});
953
954marketplace.post("/marketplace/:slug/install", requireAuth, async (c) => {
955 const user = c.get("user")!;
956 const slug = c.req.param("slug");
957 const app = await getAppBySlug(slug);
958 if (!app) return c.notFound();
959 const body = await c.req.parseBody({ all: true });
960 const rawPerms = body.permissions;
961 const perms = Array.isArray(rawPerms)
962 ? rawPerms.map(String)
963 : rawPerms
964 ? [String(rawPerms)]
965 : [];
966 const inst = await installApp({
967 appId: app.id,
968 installedBy: user.id,
969 targetType: "user",
970 targetId: user.id,
971 grantedPermissions: perms,
972 });
973 if (inst) {
974 await audit({
975 userId: user.id,
976 action: "marketplace.install",
977 targetType: "app",
978 targetId: app.id,
979 metadata: { grantedPermissions: normalisePermissions(perms) },
980 });
981 }
982 return c.redirect("/settings/apps");
983});
984
985marketplace.post(
986 "/marketplace/installations/:id/uninstall",
987 requireAuth,
988 async (c) => {
989 const user = c.get("user")!;
990 const id = c.req.param("id");
991 // Only the installer can uninstall
992 const [inst] = await db
993 .select()
994 .from(appInstallations)
995 .where(eq(appInstallations.id, id))
996 .limit(1);
997 if (!inst || inst.installedBy !== user.id) {
998 return c.text("forbidden", 403);
999 }
1000 const ok = await uninstallApp(id);
1001 if (ok) {
1002 await audit({
1003 userId: user.id,
1004 action: "marketplace.uninstall",
1005 targetType: "app_installation",
1006 targetId: id,
1007 });
1008 }
1009 return c.redirect("/settings/apps");
1010 }
1011);
1012
1013// ---------- Personal installs ----------
1014
1015marketplace.get("/settings/apps", requireAuth, async (c) => {
1016 const user = c.get("user")!;
1017 const installs = await listInstallationsForTarget("user", user.id);
1018 return c.html(
1019 <Layout title="Installed apps — Gluecron" user={user}>
655a200Claude1020 <style dangerouslySetInnerHTML={{ __html: styles }} />
1021 <div class="mkt-wrap">
1022 <section class="mkt-hero">
1023 <div class="mkt-hero-orb" aria-hidden="true" />
1024 <div class="mkt-hero-inner">
1025 <div class="mkt-hero-text">
1026 <div class="mkt-eyebrow">
1027 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1028 Installed apps · <strong>@{user.username}</strong>
1029 </div>
1030 <h1 class="mkt-title">
1031 <span class="mkt-title-grad">Your apps.</span>
1032 </h1>
1033 <p class="mkt-sub">
1034 Every app you've installed, plus the permissions you granted. Uninstall any time.
1035 </p>
1036 </div>
1037 <a href="/marketplace" class="mkt-hero-cta">
1038 Browse marketplace
1039 </a>
1040 </div>
1041 </section>
1042
06139e6Claude1043 {installs.length === 0 ? (
655a200Claude1044 <div class="mkt-empty">
1045 <div class="mkt-empty-orb" aria-hidden="true" />
1046 <div class="mkt-empty-inner">
1047 <span class="mkt-empty-glyph" aria-hidden="true"><IconGrid /></span>
1048 <h2 class="mkt-empty-title">No apps installed.</h2>
1049 <p class="mkt-empty-sub">
1050 Browse the marketplace and install your first integration —
1051 CI bots, AI reviewers, notification bridges, and more.
1052 </p>
1053 <div class="mkt-empty-actions">
1054 <a href="/marketplace" class="mkt-btn mkt-btn-primary">Browse the marketplace</a>
1055 </div>
1056 </div>
06139e6Claude1057 </div>
1058 ) : (
655a200Claude1059 <div class="mkt-section">
1060 {installs.map((i) => (
1061 <div class="mkt-list-item">
1062 <div class="mkt-list-item-main">
1063 <a
1064 href={i.app ? `/marketplace/${i.app.slug}` : "#"}
1065 class="mkt-list-item-title"
1066 >
1067 {i.app?.name || "(unknown app)"}
1068 </a>
1069 <div class="mkt-list-item-meta">
1070 {parsePermissions(i.grantedPermissions).length} permissions ·
1071 installed{" "}
1072 {i.createdAt
1073 ? new Date(i.createdAt).toLocaleDateString()
1074 : ""}
1075 </div>
06139e6Claude1076 </div>
655a200Claude1077 <form
1078 method="post"
1079 action={`/marketplace/installations/${i.id}/uninstall`}
1080 onsubmit="return confirm('Uninstall this app?')"
1081 style="margin:0"
1082 >
1083 <button type="submit" class="mkt-btn mkt-btn-danger">
1084 Uninstall
1085 </button>
1086 </form>
06139e6Claude1087 </div>
655a200Claude1088 ))}
1089 </div>
06139e6Claude1090 )}
1091 </div>
1092 </Layout>
1093 );
1094});
1095
1096// ---------- Developer UX ----------
1097
1098marketplace.get("/developer/apps-new", requireAuth, async (c) => {
1099 const user = c.get("user")!;
1100 return c.html(
1101 <Layout title="New app — Marketplace" user={user}>
655a200Claude1102 <style dangerouslySetInnerHTML={{ __html: styles }} />
1103 <div class="mkt-wrap">
1104 <section class="mkt-hero">
1105 <div class="mkt-hero-orb" aria-hidden="true" />
1106 <div class="mkt-hero-inner">
1107 <div class="mkt-hero-text">
1108 <div class="mkt-eyebrow">
1109 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1110 Developer · New app
1111 </div>
1112 <h1 class="mkt-title">
1113 <span class="mkt-title-grad">Build.</span>
1114 </h1>
1115 <p class="mkt-sub">
1116 Register a new app, declare the permissions it needs, and pick the events it listens for.
1117 Your app gets a scoped bot identity and a webhook secret on submit.
1118 </p>
1119 </div>
1120 <a href="/marketplace" class="mkt-hero-cta" style="background:transparent;border:1px solid var(--border);color:var(--text-muted);box-shadow:none">
1121 <IconArrowLeft /> Back
1122 </a>
06139e6Claude1123 </div>
655a200Claude1124 </section>
1125
1126 <form method="post" action="/developer/apps-new" class="mkt-section">
1127 <div class="mkt-section-body">
1128 <div class="mkt-form-group">
1129 <label>Name</label>
1130 <input type="text" name="name" required aria-label="App name" />
1131 </div>
1132 <div class="mkt-form-group">
1133 <label>Description</label>
1134 <textarea name="description" rows={3} />
1135 </div>
1136 <div class="mkt-form-group">
1137 <label>Homepage URL</label>
1138 <input type="url" name="homepageUrl" aria-label="Homepage URL" />
1139 </div>
1140 <div class="mkt-form-group">
1141 <label>Webhook URL (optional)</label>
1142 <input type="url" name="webhookUrl" aria-label="Webhook URL" />
1143 </div>
1144 <div class="mkt-form-group">
1145 <label>Permissions</label>
1146 <div class="mkt-checkbox-grid cols-2">
1147 {KNOWN_PERMISSIONS.map((p) => (
1148 <label>
1149 <input type="checkbox" name="permissions" value={p} /> {p}
1150 </label>
1151 ))}
1152 </div>
1153 </div>
1154 <div class="mkt-form-group">
1155 <label>Events</label>
1156 <div class="mkt-checkbox-grid cols-3">
1157 {KNOWN_EVENTS.map((e) => (
1158 <label>
1159 <input type="checkbox" name="events" value={e} /> {e}
1160 </label>
1161 ))}
1162 </div>
1163 </div>
1164 <div class="mkt-form-group">
06139e6Claude1165 <label>
655a200Claude1166 <input type="checkbox" name="isPublic" value="1" checked /> List in public marketplace
06139e6Claude1167 </label>
655a200Claude1168 </div>
1169 <button type="submit" class="mkt-btn mkt-btn-primary">
1170 Create app
1171 </button>
06139e6Claude1172 </div>
655a200Claude1173 </form>
1174 </div>
06139e6Claude1175 </Layout>
1176 );
1177});
1178
1179marketplace.post("/developer/apps-new", requireAuth, async (c) => {
1180 const user = c.get("user")!;
1181 const body = await c.req.parseBody({ all: true });
1182 const name = String(body.name || "").trim();
1183 if (!name) return c.redirect("/developer/apps-new");
1184 const rawPerms = body.permissions;
1185 const perms = Array.isArray(rawPerms)
1186 ? rawPerms.map(String)
1187 : rawPerms
1188 ? [String(rawPerms)]
1189 : [];
1190 const rawEvents = body.events;
1191 const events = Array.isArray(rawEvents)
1192 ? rawEvents.map(String)
1193 : rawEvents
1194 ? [String(rawEvents)]
1195 : [];
1196 const app = await createApp({
1197 name,
1198 description: String(body.description || ""),
1199 homepageUrl: String(body.homepageUrl || "") || undefined,
1200 webhookUrl: String(body.webhookUrl || "") || undefined,
1201 creatorId: user.id,
1202 permissions: perms,
1203 defaultEvents: events,
1204 isPublic: !!body.isPublic,
1205 });
1206 if (!app) return c.text("failed to create", 500);
1207 await audit({
1208 userId: user.id,
1209 action: "marketplace.app.create",
1210 targetType: "app",
1211 targetId: app.id,
1212 });
1213 return c.redirect(`/developer/apps/${app.slug}/manage`);
1214});
1215
1216marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
1217 const user = c.get("user")!;
1218 const slug = c.req.param("slug");
1219 const app = await getAppBySlug(slug);
1220 if (!app) return c.notFound();
1221 if (app.creatorId !== user.id) return c.text("forbidden", 403);
1222 const [installs, events] = await Promise.all([
1223 listInstallationsForApp(app.id),
1224 listEventsForApp(app.id, 20),
1225 ]);
1226 return c.html(
1227 <Layout title={`Manage ${app.name}`} user={user}>
655a200Claude1228 <style dangerouslySetInnerHTML={{ __html: styles }} />
1229 <div class="mkt-wrap">
1230 <section class="mkt-hero">
1231 <div class="mkt-hero-orb" aria-hidden="true" />
1232 <div class="mkt-hero-inner">
1233 <div class="mkt-hero-text">
1234 <div class="mkt-detail-head">
1235 <span class="mkt-detail-logo" aria-hidden="true" style={`background:${gradientFor(app.slug)}`}>
1236 {appInitials(app.name)}
1237 </span>
1238 <div style="min-width:0">
1239 <h1 class="mkt-detail-name">{app.name}</h1>
1240 <div class="mkt-detail-meta">Developer · {installs.length} install{installs.length === 1 ? "" : "s"}</div>
1241 </div>
1242 </div>
1243 </div>
1244 <a href={`/marketplace/${app.slug}`} class="mkt-hero-cta" style="background:transparent;border:1px solid var(--border);color:var(--text-muted);box-shadow:none">
1245 Public page
1246 </a>
06139e6Claude1247 </div>
655a200Claude1248 </section>
06139e6Claude1249
655a200Claude1250 <section class="mkt-section">
1251 <header class="mkt-section-head">
1252 <div>
1253 <h3 class="mkt-section-title">Bot identity</h3>
1254 <p class="mkt-section-sub">
1255 The bot account that authors comments, opens PRs, and signs webhook payloads on this app's behalf.
1256 </p>
1257 </div>
1258 </header>
1259 <div class="mkt-section-body">
1260 <div style="font-family:var(--font-mono);font-size:14px;color:var(--text-strong)">{app.slug}[bot]</div>
1261 {app.webhookSecret && (
1262 <div style="margin-top:10px;font-size:12.5px;color:var(--text-muted)">
1263 Webhook secret:{" "}
1264 <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>
06139e6Claude1265 </div>
655a200Claude1266 )}
1267 </div>
1268 </section>
1269
1270 <section class="mkt-section">
1271 <header class="mkt-section-head">
1272 <div>
1273 <h3 class="mkt-section-title">Installations ({installs.length})</h3>
1274 <p class="mkt-section-sub">Every user or org that has granted this app access.</p>
1275 </div>
1276 </header>
1277 {installs.length === 0 ? (
1278 <div class="mkt-section-body">
1279 <div class="mkt-empty" style="padding:20px;border-style:dashed">
1280 <div class="mkt-empty-inner">No installs yet.</div>
06139e6Claude1281 </div>
1282 </div>
655a200Claude1283 ) : (
1284 installs.map((i) => (
1285 <div class="mkt-list-item">
1286 <div class="mkt-list-item-main">
1287 {i.targetType}: <code style="font-family:var(--font-mono);font-size:12px">{i.targetId}</code>
1288 </div>
1289 <div style="font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums">
1290 {parsePermissions(i.grantedPermissions).length} perms ·{" "}
1291 {i.createdAt
1292 ? new Date(i.createdAt).toLocaleDateString()
1293 : ""}
1294 </div>
1295 </div>
1296 ))
1297 )}
1298 </section>
06139e6Claude1299
655a200Claude1300 <section class="mkt-section">
1301 <header class="mkt-section-head">
1302 <div>
1303 <h3 class="mkt-section-title">Recent events</h3>
1304 <p class="mkt-section-sub">Last 20 events delivered to this app.</p>
06139e6Claude1305 </div>
655a200Claude1306 </header>
1307 {events.length === 0 ? (
1308 <div class="mkt-section-body">
1309 <div class="mkt-empty" style="padding:20px;border-style:dashed">
1310 <div class="mkt-empty-inner">No events yet.</div>
1311 </div>
1312 </div>
1313 ) : (
1314 events.map((e) => (
1315 <div class="mkt-list-item">
1316 <span style="font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">{e.kind}</span>
1317 <span style="font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums">
1318 {e.createdAt
1319 ? new Date(e.createdAt).toLocaleString()
1320 : ""}
1321 </span>
1322 </div>
1323 ))
1324 )}
1325 </section>
06139e6Claude1326
655a200Claude1327 <section class="mkt-section">
1328 <header class="mkt-section-head">
1329 <div>
1330 <h3 class="mkt-section-title">Installation tokens</h3>
1331 <p class="mkt-section-sub">
1332 Issue a bearer token for an existing installation. Use this to test bot API calls. Tokens are shown once and expire after 1 hour.
1333 </p>
1334 </div>
1335 </header>
1336 <div class="mkt-section-body">
1337 <form
1338 method="post"
1339 action={`/developer/apps/${app.slug}/tokens/new`}
1340 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"
1341 >
1342 <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">
1343 {installs.map((i) => (
1344 <option value={i.id}>
1345 {i.targetType}:{i.targetId.slice(0, 8)}
1346 </option>
1347 ))}
1348 </select>
1349 <button type="submit" class="mkt-btn mkt-btn-primary" disabled={installs.length === 0}>
1350 Issue token
1351 </button>
1352 </form>
1353 </div>
1354 </section>
1355 </div>
06139e6Claude1356 </Layout>
1357 );
1358});
1359
1360marketplace.post(
1361 "/developer/apps/:slug/tokens/new",
1362 requireAuth,
1363 async (c) => {
1364 const user = c.get("user")!;
1365 const slug = c.req.param("slug");
1366 const app = await getAppBySlug(slug);
1367 if (!app) return c.notFound();
1368 if (app.creatorId !== user.id) return c.text("forbidden", 403);
1369 const body = await c.req.parseBody();
1370 const installationId = String(body.installationId || "");
1371 if (!installationId) return c.redirect(`/developer/apps/${slug}/manage`);
1372 // Validate the installation belongs to this app
1373 const [inst] = await db
1374 .select()
1375 .from(appInstallations)
1376 .where(eq(appInstallations.id, installationId))
1377 .limit(1);
1378 if (!inst || inst.appId !== app.id) return c.text("forbidden", 403);
1379 const t = await issueInstallToken(installationId);
1380 if (!t) return c.text("failed", 500);
1381 await audit({
1382 userId: user.id,
1383 action: "marketplace.token.issue",
1384 targetType: "app_installation",
1385 targetId: installationId,
1386 });
1387 return c.html(
1388 <Layout title="Token issued" user={user}>
655a200Claude1389 <style dangerouslySetInnerHTML={{ __html: styles }} />
1390 <div class="mkt-wrap">
1391 <section class="mkt-hero">
1392 <div class="mkt-hero-orb" aria-hidden="true" />
1393 <div class="mkt-hero-inner">
1394 <div class="mkt-hero-text">
1395 <div class="mkt-eyebrow">
1396 <span class="mkt-eyebrow-dot" aria-hidden="true" />
1397 Token issued
1398 </div>
1399 <h1 class="mkt-title">
1400 <span class="mkt-title-grad">Copy now.</span>
1401 </h1>
1402 <p class="mkt-sub">
1403 This token is shown once — store it somewhere safe. Expires{" "}
1404 {t.expiresAt.toISOString()}.
1405 </p>
1406 </div>
1407 </div>
1408 </section>
1409
1410 <section class="mkt-section">
1411 <div class="mkt-section-body">
1412 <div class="mkt-token-block">{t.token}</div>
1413 <a href={`/developer/apps/${slug}/manage`} class="mkt-btn mkt-btn-ghost" style="margin-top:14px">
1414 <IconArrowLeft /> Back
1415 </a>
1416 </div>
1417 </section>
06139e6Claude1418 </div>
1419 </Layout>
1420 );
1421 }
1422);
1423
1424export default marketplace;