CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
marketplace-agents.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.
| 5ca514a | 1 | /** |
| 2 | * Agent Marketplace UI + admin moderation queue. | |
| 3 | * | |
| 4 | * GET /marketplace/agents — catalog grid | |
| 5 | * GET /marketplace/agents/publish — publisher submission form | |
| 6 | * POST /marketplace/agents/publish — submit listing (pending_review) | |
| 7 | * GET /marketplace/agents/:slug — listing detail + reviews | |
| 8 | * POST /marketplace/agents/:slug/install — install on a repo | |
| 9 | * POST /marketplace/agents/:slug/reviews — leave a review | |
| 10 | * GET /admin/marketplace/queue — moderation queue (admin) | |
| 11 | * POST /admin/marketplace/queue/:slug/:action — approve | reject | |
| 12 | * | |
| 13 | * Visual pattern lifted from `routes/marketplace.tsx` (gradient hairline | |
| 14 | * hero + orb + eyebrow + verb-as-title + category pills + card grid). | |
| 15 | * Every selector is scoped under `.amkt-*` so this surface can't bleed | |
| 16 | * into the existing `.mkt-*` marketplace. | |
| 17 | */ | |
| 18 | ||
| 19 | import { Hono } from "hono"; | |
| 20 | import { and, eq } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { Layout } from "../views/layout"; | |
| 23 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 24 | import type { AuthEnv } from "../middleware/auth"; | |
| 25 | import { requireAdmin } from "../middleware/admin"; | |
| 26 | import { audit } from "../lib/notify"; | |
| 27 | import { renderMarkdown } from "../lib/markdown"; | |
| 28 | import { | |
| 29 | repositories, | |
| 30 | users, | |
| 31 | agentMarketplaceInstalls, | |
| 32 | } from "../db/schema"; | |
| 33 | import { | |
| 34 | MARKETPLACE_CATEGORIES, | |
| 35 | PRICING_MODELS, | |
| 36 | approveListing, | |
| 37 | createListing, | |
| 38 | fetchListingBySlug, | |
| 39 | formatPrice, | |
| 40 | getListing, | |
| 41 | gradientForSlug, | |
| 42 | installListing, | |
| 43 | isValidCategory, | |
| 44 | isValidPricingModel, | |
| 45 | listListings, | |
| 46 | listingInitials, | |
| 47 | recordReview, | |
| 48 | rejectListing, | |
| 49 | uninstallListing, | |
| 50 | } from "../lib/agent-marketplace"; | |
| 51 | ||
| 52 | const marketplaceAgents = new Hono<AuthEnv>(); | |
| 53 | marketplaceAgents.use("*", softAuth); | |
| 54 | ||
| 55 | /* ───────────────────────────────────────────────────────────────────────── | |
| 56 | * Scoped CSS — every selector under `.amkt-*` so we can't bleed into the | |
| 57 | * pre-existing `.mkt-*` marketplace surface. Pattern mirrors that file. | |
| 58 | * ───────────────────────────────────────────────────────────────────── */ | |
| 59 | const styles = ` | |
| eed4684 | 60 | .amkt-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); } |
| 5ca514a | 61 | |
| 62 | /* ─── Hero ─── */ | |
| 63 | .amkt-hero { | |
| 64 | position: relative; | |
| 65 | margin-bottom: var(--space-5); | |
| 66 | padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px); | |
| 67 | background: var(--bg-elevated); | |
| 68 | border: 1px solid var(--border); | |
| 69 | border-radius: 18px; | |
| 70 | overflow: hidden; | |
| 71 | } | |
| 72 | .amkt-hero::before { | |
| 73 | content: ''; | |
| 74 | position: absolute; | |
| 75 | top: 0; left: 0; right: 0; | |
| 76 | height: 2px; | |
| 77 | background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%); | |
| 78 | opacity: 0.75; | |
| 79 | pointer-events: none; | |
| 80 | } | |
| 81 | .amkt-hero-orb { | |
| 82 | position: absolute; | |
| 83 | inset: -30% -10% auto auto; | |
| 84 | width: 460px; height: 460px; | |
| 85 | background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%); | |
| 86 | filter: blur(80px); | |
| 87 | opacity: 0.7; | |
| 88 | pointer-events: none; | |
| 89 | z-index: 0; | |
| 90 | } | |
| 91 | .amkt-hero-inner { | |
| 92 | position: relative; | |
| 93 | z-index: 1; | |
| 94 | display: flex; | |
| 95 | align-items: flex-end; | |
| 96 | justify-content: space-between; | |
| 97 | gap: var(--space-4); | |
| 98 | flex-wrap: wrap; | |
| 99 | } | |
| 100 | .amkt-hero-text { max-width: 680px; flex: 1; min-width: 240px; } | |
| 101 | .amkt-eyebrow { | |
| 102 | display: inline-flex; | |
| 103 | align-items: center; | |
| 104 | gap: 8px; | |
| 105 | font-family: var(--font-mono); | |
| 106 | font-size: 11px; | |
| 107 | letter-spacing: 0.18em; | |
| 108 | text-transform: uppercase; | |
| 109 | color: var(--text-muted); | |
| 110 | font-weight: 600; | |
| 111 | margin-bottom: 16px; | |
| 112 | } | |
| 113 | .amkt-eyebrow-dot { | |
| 114 | width: 8px; height: 8px; | |
| 115 | border-radius: 9999px; | |
| 116 | background: linear-gradient(135deg, #8c6dff, #36c5d6); | |
| 117 | box-shadow: 0 0 0 3px rgba(140,109,255,0.18); | |
| 118 | } | |
| 119 | .amkt-title { | |
| 120 | font-family: var(--font-display); | |
| 121 | font-size: clamp(32px, 5vw, 48px); | |
| 122 | font-weight: 800; | |
| 123 | letter-spacing: -0.030em; | |
| 124 | line-height: 1.05; | |
| 125 | margin: 0 0 var(--space-3); | |
| 126 | color: var(--text-strong); | |
| 127 | } | |
| 128 | .amkt-title-grad { | |
| 129 | background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%); | |
| 130 | -webkit-background-clip: text; | |
| 131 | background-clip: text; | |
| 132 | -webkit-text-fill-color: transparent; | |
| 133 | color: transparent; | |
| 134 | } | |
| 135 | .amkt-sub { | |
| 136 | font-size: 16px; | |
| 137 | color: var(--text-muted); | |
| 138 | margin: 0; | |
| 139 | line-height: 1.55; | |
| 140 | max-width: 580px; | |
| 141 | } | |
| 142 | .amkt-hero-cta { | |
| 143 | display: inline-flex; | |
| 144 | align-items: center; | |
| 145 | gap: 6px; | |
| 146 | padding: 9px 16px; | |
| 147 | border-radius: 10px; | |
| 148 | font-size: 13px; | |
| 149 | font-weight: 600; | |
| 150 | background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%); | |
| 151 | color: #fff; | |
| 152 | text-decoration: none; | |
| 153 | border: 1px solid transparent; | |
| 154 | box-shadow: 0 6px 16px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16); | |
| 155 | transition: transform 120ms ease, box-shadow 120ms ease; | |
| 156 | } | |
| 157 | .amkt-hero-cta:hover { | |
| 158 | transform: translateY(-1px); | |
| 159 | color: #fff; | |
| 160 | text-decoration: none; | |
| 161 | box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60); | |
| 162 | } | |
| 163 | ||
| 164 | /* ─── Category filter pills ─── */ | |
| 165 | .amkt-pills { | |
| 166 | display: flex; | |
| 167 | gap: 6px; | |
| 168 | flex-wrap: wrap; | |
| 169 | margin-bottom: var(--space-4); | |
| 170 | } | |
| 171 | .amkt-pill { | |
| 172 | display: inline-flex; | |
| 173 | align-items: center; | |
| 174 | gap: 6px; | |
| 175 | padding: 6px 14px; | |
| 176 | border-radius: 9999px; | |
| 177 | font-size: 12px; | |
| 178 | font-weight: 600; | |
| 179 | color: var(--text-muted); | |
| 180 | background: rgba(255,255,255,0.03); | |
| 181 | border: 1px solid var(--border); | |
| 182 | text-decoration: none; | |
| 183 | cursor: pointer; | |
| 184 | transition: color 120ms ease, background 120ms ease, border-color 120ms ease; | |
| 185 | } | |
| 186 | .amkt-pill:hover { | |
| 187 | color: var(--text-strong); | |
| 188 | border-color: rgba(140,109,255,0.45); | |
| 189 | background: rgba(140,109,255,0.06); | |
| 190 | text-decoration: none; | |
| 191 | } | |
| 192 | .amkt-pill.is-active { | |
| 193 | background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14)); | |
| 194 | color: #c5b3ff; | |
| 195 | border-color: rgba(140,109,255,0.45); | |
| 196 | } | |
| 197 | ||
| 198 | /* ─── Card grid ─── */ | |
| 199 | .amkt-grid { | |
| 200 | display: grid; | |
| 201 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| 202 | gap: var(--space-3); | |
| 203 | } | |
| 204 | .amkt-card { | |
| 205 | position: relative; | |
| 206 | padding: var(--space-4); | |
| 207 | background: var(--bg-elevated); | |
| 208 | border: 1px solid var(--border); | |
| 209 | border-radius: 14px; | |
| 210 | display: flex; | |
| 211 | flex-direction: column; | |
| 212 | gap: 10px; | |
| 213 | color: inherit; | |
| 214 | text-decoration: none; | |
| 215 | transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease; | |
| 216 | } | |
| 217 | .amkt-card:hover { | |
| 218 | border-color: rgba(140,109,255,0.45); | |
| 219 | transform: translateY(-2px); | |
| 220 | box-shadow: 0 10px 28px -10px rgba(140,109,255,0.30); | |
| 221 | text-decoration: none; | |
| 222 | color: inherit; | |
| 223 | } | |
| 224 | .amkt-card-head { | |
| 225 | display: flex; | |
| 226 | align-items: center; | |
| 227 | gap: 12px; | |
| 228 | } | |
| 229 | .amkt-logo { | |
| 230 | display: inline-flex; | |
| 231 | align-items: center; | |
| 232 | justify-content: center; | |
| 233 | width: 44px; height: 44px; | |
| 234 | border-radius: 11px; | |
| 235 | flex-shrink: 0; | |
| 236 | font-family: var(--font-display); | |
| 237 | font-weight: 800; | |
| 238 | font-size: 18px; | |
| 239 | color: #fff; | |
| 240 | letter-spacing: -0.02em; | |
| 241 | box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), 0 4px 12px -6px rgba(0,0,0,0.45); | |
| 242 | } | |
| 243 | .amkt-card-name { | |
| 244 | font-family: var(--font-display); | |
| 245 | font-size: 16px; | |
| 246 | font-weight: 700; | |
| 247 | color: var(--text-strong); | |
| 248 | margin: 0; | |
| 249 | letter-spacing: -0.012em; | |
| 250 | } | |
| 251 | .amkt-card-publisher { | |
| 252 | font-family: var(--font-mono); | |
| 253 | font-size: 11px; | |
| 254 | color: var(--text-muted); | |
| 255 | margin-top: 1px; | |
| 256 | } | |
| 257 | .amkt-card-tagline { | |
| 258 | font-size: 13px; | |
| 259 | color: var(--text-muted); | |
| 260 | line-height: 1.5; | |
| 261 | margin: 0; | |
| 262 | flex: 1; | |
| 263 | } | |
| 264 | .amkt-card-meta { | |
| 265 | display: flex; | |
| 266 | align-items: center; | |
| 267 | gap: 12px; | |
| 268 | margin-top: 4px; | |
| 269 | font-size: 11.5px; | |
| 270 | color: var(--text-muted); | |
| 271 | font-variant-numeric: tabular-nums; | |
| 272 | } | |
| 273 | .amkt-card-foot { | |
| 274 | display: flex; | |
| 275 | align-items: center; | |
| 276 | justify-content: space-between; | |
| 277 | gap: 8px; | |
| 278 | margin-top: 4px; | |
| 279 | } | |
| 280 | .amkt-price-pill { | |
| 281 | display: inline-flex; | |
| 282 | align-items: center; | |
| 283 | padding: 4px 10px; | |
| 284 | border-radius: 9999px; | |
| 285 | font-size: 11px; | |
| 286 | font-weight: 700; | |
| 287 | color: #c5b3ff; | |
| 288 | background: rgba(140,109,255,0.10); | |
| 289 | border: 1px solid rgba(140,109,255,0.30); | |
| 290 | font-family: var(--font-mono); | |
| 291 | } | |
| 292 | .amkt-price-pill.is-free { | |
| 293 | color: #86efac; | |
| 294 | background: rgba(34,197,94,0.10); | |
| 295 | border-color: rgba(34,197,94,0.30); | |
| 296 | } | |
| 297 | .amkt-install-btn { | |
| 298 | display: inline-flex; | |
| 299 | align-items: center; | |
| 300 | justify-content: center; | |
| 301 | padding: 6px 14px; | |
| 302 | border-radius: 8px; | |
| 303 | font-size: 12px; | |
| 304 | font-weight: 600; | |
| 305 | background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%); | |
| 306 | color: #fff; | |
| 307 | text-decoration: none; | |
| 308 | box-shadow: 0 4px 12px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16); | |
| 309 | transition: transform 120ms ease; | |
| 310 | } | |
| 311 | .amkt-install-btn:hover { | |
| 312 | transform: translateY(-1px); | |
| 313 | color: #fff; | |
| 314 | text-decoration: none; | |
| 315 | } | |
| 316 | .amkt-stars { display: inline-flex; gap: 2px; color: #f5b942; } | |
| 317 | ||
| 318 | /* ─── Empty / zero state ─── */ | |
| 319 | .amkt-empty { | |
| 320 | position: relative; | |
| 321 | padding: clamp(28px, 4vw, 44px) clamp(20px, 4vw, 40px); | |
| 322 | text-align: center; | |
| 323 | background: var(--bg-elevated); | |
| 324 | border: 1px dashed rgba(140,109,255,0.40); | |
| 325 | border-radius: 16px; | |
| 326 | overflow: hidden; | |
| 327 | } | |
| 328 | .amkt-empty-title { | |
| 329 | font-family: var(--font-display); | |
| 330 | font-size: 20px; | |
| 331 | font-weight: 700; | |
| 332 | margin: 0 0 8px; | |
| 333 | color: var(--text-strong); | |
| 334 | letter-spacing: -0.018em; | |
| 335 | } | |
| 336 | .amkt-empty-sub { | |
| 337 | font-size: 14px; | |
| 338 | color: var(--text-muted); | |
| 339 | margin: 0 auto 18px; | |
| 340 | max-width: 480px; | |
| 341 | line-height: 1.55; | |
| 342 | } | |
| 343 | ||
| 344 | /* ─── Section card ─── */ | |
| 345 | .amkt-section { | |
| 346 | margin-bottom: var(--space-5); | |
| 347 | background: var(--bg-elevated); | |
| 348 | border: 1px solid var(--border); | |
| 349 | border-radius: 14px; | |
| 350 | overflow: hidden; | |
| 351 | } | |
| 352 | .amkt-section-head { | |
| 353 | padding: var(--space-4) var(--space-5); | |
| 354 | border-bottom: 1px solid var(--border); | |
| 355 | } | |
| 356 | .amkt-section-title { | |
| 357 | margin: 0; | |
| 358 | font-family: var(--font-display); | |
| 359 | font-size: 16px; | |
| 360 | font-weight: 700; | |
| 361 | color: var(--text-strong); | |
| 362 | letter-spacing: -0.014em; | |
| 363 | } | |
| 364 | .amkt-section-sub { | |
| 365 | margin: 4px 0 0; | |
| 366 | font-size: 12.5px; | |
| 367 | color: var(--text-muted); | |
| 368 | } | |
| 369 | .amkt-section-body { padding: var(--space-4) var(--space-5); } | |
| 370 | ||
| 371 | /* ─── Detail page ─── */ | |
| 372 | .amkt-detail-head { | |
| 373 | display: flex; | |
| 374 | align-items: flex-start; | |
| 375 | gap: var(--space-4); | |
| 376 | margin-bottom: var(--space-4); | |
| 377 | flex-wrap: wrap; | |
| 378 | } | |
| 379 | .amkt-detail-logo { | |
| 380 | width: 64px; height: 64px; | |
| 381 | border-radius: 14px; | |
| 382 | flex-shrink: 0; | |
| 383 | display: inline-flex; | |
| 384 | align-items: center; | |
| 385 | justify-content: center; | |
| 386 | font-family: var(--font-display); | |
| 387 | font-weight: 800; | |
| 388 | font-size: 26px; | |
| 389 | color: #fff; | |
| 390 | letter-spacing: -0.02em; | |
| 391 | box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 6px 18px -6px rgba(0,0,0,0.45); | |
| 392 | } | |
| 393 | .amkt-detail-name { | |
| 394 | font-family: var(--font-display); | |
| 395 | font-size: 28px; | |
| 396 | font-weight: 800; | |
| 397 | color: var(--text-strong); | |
| 398 | margin: 0; | |
| 399 | letter-spacing: -0.022em; | |
| 400 | } | |
| 401 | .amkt-detail-meta { | |
| 402 | margin-top: 4px; | |
| 403 | font-size: 12.5px; | |
| 404 | color: var(--text-muted); | |
| 405 | font-family: var(--font-mono); | |
| 406 | } | |
| 407 | .amkt-prose { | |
| 408 | color: var(--text); | |
| 409 | font-size: 14.5px; | |
| 410 | line-height: 1.65; | |
| 411 | } | |
| 412 | .amkt-prose p { margin: 0 0 12px; } | |
| 413 | .amkt-prose h1, .amkt-prose h2, .amkt-prose h3 { | |
| 414 | font-family: var(--font-display); | |
| 415 | color: var(--text-strong); | |
| 416 | margin: 18px 0 8px; | |
| 417 | } | |
| 418 | .amkt-prose code { | |
| 419 | font-family: var(--font-mono); | |
| 420 | background: rgba(255,255,255,0.04); | |
| 421 | padding: 2px 6px; | |
| 422 | border-radius: 4px; | |
| 423 | font-size: 0.9em; | |
| 424 | } | |
| 425 | ||
| 426 | /* ─── Reviews ─── */ | |
| 427 | .amkt-review { | |
| 428 | padding: 14px 0; | |
| 429 | border-bottom: 1px solid var(--border); | |
| 430 | } | |
| 431 | .amkt-review:last-child { border-bottom: none; } | |
| 432 | .amkt-review-head { | |
| 433 | display: flex; | |
| 434 | align-items: center; | |
| 435 | gap: 8px; | |
| 436 | margin-bottom: 6px; | |
| 437 | } | |
| 438 | .amkt-review-author { | |
| 439 | font-family: var(--font-mono); | |
| 440 | font-size: 12px; | |
| 441 | color: var(--text); | |
| 442 | font-weight: 600; | |
| 443 | } | |
| 444 | .amkt-review-body { | |
| 445 | color: var(--text); | |
| 446 | font-size: 14px; | |
| 447 | line-height: 1.55; | |
| 448 | margin: 0; | |
| 449 | } | |
| 450 | .amkt-review-date { | |
| 451 | font-size: 11.5px; | |
| 452 | color: var(--text-muted); | |
| 453 | font-variant-numeric: tabular-nums; | |
| 454 | margin-left: auto; | |
| 455 | } | |
| 456 | ||
| 457 | /* ─── Form ─── */ | |
| 458 | .amkt-form-group { margin-bottom: var(--space-3); } | |
| 459 | .amkt-form-group label { | |
| 460 | display: block; | |
| 461 | font-size: 12.5px; | |
| 462 | font-weight: 600; | |
| 463 | color: var(--text-strong); | |
| 464 | margin-bottom: 6px; | |
| 465 | } | |
| 466 | .amkt-form-group input[type="text"], | |
| 467 | .amkt-form-group input[type="url"], | |
| 468 | .amkt-form-group input[type="number"], | |
| 469 | .amkt-form-group textarea, | |
| 470 | .amkt-form-group select { | |
| 471 | width: 100%; | |
| 472 | padding: 9px 12px; | |
| 473 | background: var(--bg-secondary, rgba(0,0,0,0.15)); | |
| 474 | border: 1px solid var(--border); | |
| 475 | border-radius: 9px; | |
| 476 | font: inherit; | |
| 477 | font-size: 13.5px; | |
| 478 | color: var(--text); | |
| 479 | box-sizing: border-box; | |
| 480 | transition: border-color 120ms ease, box-shadow 120ms ease; | |
| 481 | } | |
| 482 | .amkt-form-group input:focus, | |
| 483 | .amkt-form-group textarea:focus, | |
| 484 | .amkt-form-group select:focus { | |
| 485 | outline: none; | |
| 486 | border-color: rgba(140,109,255,0.45); | |
| 487 | box-shadow: 0 0 0 3px rgba(140,109,255,0.18); | |
| 488 | } | |
| 489 | ||
| 490 | /* ─── Buttons ─── */ | |
| 491 | .amkt-btn { | |
| 492 | display: inline-flex; | |
| 493 | align-items: center; | |
| 494 | justify-content: center; | |
| 495 | gap: 6px; | |
| 496 | padding: 9px 16px; | |
| 497 | border-radius: 10px; | |
| 498 | font-size: 13px; | |
| 499 | font-weight: 600; | |
| 500 | text-decoration: none; | |
| 501 | border: 1px solid transparent; | |
| 502 | cursor: pointer; | |
| 503 | font: inherit; | |
| 504 | transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease; | |
| 505 | line-height: 1; | |
| 506 | } | |
| 507 | .amkt-btn-primary { | |
| 508 | background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%); | |
| 509 | color: #ffffff; | |
| 510 | box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16); | |
| 511 | } | |
| 512 | .amkt-btn-primary:hover { | |
| 513 | transform: translateY(-1px); | |
| 514 | box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60); | |
| 515 | color: #ffffff; | |
| 516 | text-decoration: none; | |
| 517 | } | |
| 518 | .amkt-btn-ghost { | |
| 519 | background: transparent; | |
| 520 | color: var(--text); | |
| 521 | border-color: var(--border); | |
| 522 | } | |
| 523 | .amkt-btn-ghost:hover { | |
| 524 | background: rgba(140,109,255,0.06); | |
| 525 | border-color: rgba(140,109,255,0.45); | |
| 526 | color: var(--text-strong); | |
| 527 | text-decoration: none; | |
| 528 | } | |
| 529 | .amkt-btn-danger { | |
| 530 | background: transparent; | |
| 531 | color: #fca5a5; | |
| 532 | border-color: rgba(248,113,113,0.35); | |
| 533 | } | |
| 534 | .amkt-btn-danger:hover { | |
| 535 | background: rgba(248,113,113,0.06); | |
| 536 | border-color: rgba(248,113,113,0.70); | |
| 537 | color: #fecaca; | |
| 538 | } | |
| 539 | ||
| 540 | /* ─── Token reveal ─── */ | |
| 541 | .amkt-token-block { | |
| 542 | padding: 14px 16px; | |
| 543 | margin-top: 12px; | |
| 544 | background: rgba(255,255,255,0.04); | |
| 545 | border: 1px solid var(--border); | |
| 546 | border-radius: 10px; | |
| 547 | font-family: var(--font-mono); | |
| 548 | font-size: 13px; | |
| 549 | color: var(--text-strong); | |
| 550 | word-break: break-all; | |
| 551 | } | |
| 552 | ||
| 553 | /* ─── Queue row ─── */ | |
| 554 | .amkt-queue-row { | |
| 555 | display: flex; | |
| 556 | align-items: center; | |
| 557 | justify-content: space-between; | |
| 558 | gap: 12px; | |
| 559 | padding: 14px 18px; | |
| 560 | border-bottom: 1px solid var(--border); | |
| 561 | flex-wrap: wrap; | |
| 562 | } | |
| 563 | .amkt-queue-row:last-child { border-bottom: none; } | |
| 564 | .amkt-queue-actions { display: flex; gap: 6px; } | |
| 565 | `; | |
| 566 | ||
| 567 | function StarRow({ rating }: { rating: number }) { | |
| 568 | const full = Math.round(rating); | |
| 569 | return ( | |
| 570 | <span class="amkt-stars" aria-label={`${rating} out of 5`}> | |
| 571 | {[1, 2, 3, 4, 5].map((i) => ( | |
| 572 | <span style={`opacity:${i <= full ? 1 : 0.25}`}>★</span> | |
| 573 | ))} | |
| 574 | </span> | |
| 575 | ); | |
| 576 | } | |
| 577 | ||
| 578 | // -------- GET /marketplace/agents (catalog) --------------------------------- | |
| 579 | ||
| 580 | marketplaceAgents.get("/marketplace/agents", async (c) => { | |
| 581 | const user = c.get("user"); | |
| 582 | const category = c.req.query("category") || ""; | |
| 583 | const search = c.req.query("q") || ""; | |
| 584 | const sortRaw = c.req.query("sort") || "top"; | |
| 585 | const sort: "top" | "new" | "rated" = | |
| 586 | sortRaw === "new" || sortRaw === "rated" ? sortRaw : "top"; | |
| 587 | const listings = await listListings({ | |
| 588 | category: category || undefined, | |
| 589 | search: search || undefined, | |
| 590 | sort, | |
| 591 | }); | |
| 592 | ||
| 593 | return c.html( | |
| 594 | <Layout title="Agent Marketplace — Gluecron" user={user}> | |
| 595 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 596 | <div class="amkt-wrap"> | |
| 597 | <section class="amkt-hero"> | |
| 598 | <div class="amkt-hero-orb" aria-hidden="true" /> | |
| 599 | <div class="amkt-hero-inner"> | |
| 600 | <div class="amkt-hero-text"> | |
| 601 | <div class="amkt-eyebrow"> | |
| 602 | <span class="amkt-eyebrow-dot" aria-hidden="true" /> | |
| 603 | Marketplace · Agents | |
| 604 | </div> | |
| 605 | <h1 class="amkt-title"> | |
| 606 | <span class="amkt-title-grad">Agents from the community.</span> | |
| 607 | </h1> | |
| 608 | <p class="amkt-sub"> | |
| 609 | Third-party AI agents that plug into your repos with one click. | |
| 610 | Each install provisions a scoped session with its own branch | |
| 611 | namespace and daily budget — safe to run alongside the rest of | |
| 612 | your fleet. | |
| 613 | </p> | |
| 614 | </div> | |
| 615 | <div style="display:flex;gap:8px;align-items:center"> | |
| 616 | <a href="/marketplace" class="amkt-pill">All apps</a> | |
| 617 | {user && ( | |
| 618 | <a href="/marketplace/agents/publish" class="amkt-hero-cta"> | |
| 619 | + Publish agent | |
| 620 | </a> | |
| 621 | )} | |
| 622 | </div> | |
| 623 | </div> | |
| 624 | </section> | |
| 625 | ||
| 626 | <form | |
| 627 | method="get" | |
| 628 | action="/marketplace/agents" | |
| 629 | style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap" | |
| 630 | > | |
| 631 | <input | |
| 632 | type="text" | |
| 633 | name="q" | |
| 634 | value={search} | |
| 635 | placeholder="Search agents" | |
| 636 | aria-label="Search agents" | |
| 637 | style="flex:1;min-width:240px;padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;font-size:14px;color:var(--text)" | |
| 638 | /> | |
| 639 | {category && <input type="hidden" name="category" value={category} />} | |
| 640 | <select | |
| 641 | name="sort" | |
| 642 | aria-label="Sort" | |
| 643 | style="padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text)" | |
| 644 | > | |
| 645 | <option value="top" selected={sort === "top"}>Top installed</option> | |
| 646 | <option value="new" selected={sort === "new"}>Newest</option> | |
| 647 | <option value="rated" selected={sort === "rated"}>Highest rated</option> | |
| 648 | </select> | |
| 649 | <button | |
| 650 | type="submit" | |
| 651 | class="amkt-btn amkt-btn-ghost" | |
| 652 | style="padding:9px 16px" | |
| 653 | > | |
| 654 | Search | |
| 655 | </button> | |
| 656 | </form> | |
| 657 | ||
| 658 | <div class="amkt-pills" role="tablist" aria-label="Filter by category"> | |
| 659 | <a | |
| 660 | href={`/marketplace/agents${search ? `?q=${encodeURIComponent(search)}` : ""}`} | |
| 661 | class={"amkt-pill" + (!category ? " is-active" : "")} | |
| 662 | > | |
| 663 | All | |
| 664 | </a> | |
| 665 | {MARKETPLACE_CATEGORIES.map((cat) => { | |
| 666 | const params = new URLSearchParams(); | |
| 667 | params.set("category", cat); | |
| 668 | if (search) params.set("q", search); | |
| 669 | return ( | |
| 670 | <a | |
| 671 | href={`/marketplace/agents?${params.toString()}`} | |
| 672 | class={"amkt-pill" + (category === cat ? " is-active" : "")} | |
| 673 | > | |
| 674 | {cat} | |
| 675 | </a> | |
| 676 | ); | |
| 677 | })} | |
| 678 | </div> | |
| 679 | ||
| 680 | {listings.length === 0 ? ( | |
| 681 | <div class="amkt-empty"> | |
| 682 | <h2 class="amkt-empty-title">No agents match.</h2> | |
| 683 | <p class="amkt-empty-sub"> | |
| 684 | {search || category | |
| 685 | ? "Try clearing the filter or searching for something else." | |
| 686 | : "No agents have been published yet. Be the first."} | |
| 687 | </p> | |
| 688 | {user && ( | |
| 689 | <a href="/marketplace/agents/publish" class="amkt-btn amkt-btn-primary"> | |
| 690 | Publish an agent | |
| 691 | </a> | |
| 692 | )} | |
| 693 | </div> | |
| 694 | ) : ( | |
| 695 | <div class="amkt-grid"> | |
| 696 | {listings.map((l) => { | |
| 697 | const ratingNum = Number(l.ratingAvg) || 0; | |
| 698 | return ( | |
| 699 | <a href={`/marketplace/agents/${l.slug}`} class="amkt-card"> | |
| 700 | <div class="amkt-card-head"> | |
| 701 | <span | |
| 702 | class="amkt-logo" | |
| 703 | aria-hidden="true" | |
| 704 | style={`background:${gradientForSlug(l.slug)}`} | |
| 705 | > | |
| 706 | {listingInitials(l.name)} | |
| 707 | </span> | |
| 708 | <div style="min-width:0"> | |
| 709 | <h3 class="amkt-card-name">{l.name}</h3> | |
| 710 | <div class="amkt-card-publisher"> | |
| 711 | {l.category} · {l.installCount} install | |
| 712 | {l.installCount === 1 ? "" : "s"} | |
| 713 | </div> | |
| 714 | </div> | |
| 715 | </div> | |
| 716 | <p class="amkt-card-tagline"> | |
| 717 | {(l.tagline || "No tagline.").slice(0, 140)} | |
| 718 | </p> | |
| 719 | <div class="amkt-card-meta"> | |
| 720 | <span> | |
| 721 | <StarRow rating={ratingNum} /> {ratingNum.toFixed(1)}{" "} | |
| 722 | ({l.ratingCount}) | |
| 723 | </span> | |
| 724 | </div> | |
| 725 | <div class="amkt-card-foot"> | |
| 726 | <span | |
| 727 | class={ | |
| 728 | "amkt-price-pill" + | |
| 729 | (l.pricingModel === "free" ? " is-free" : "") | |
| 730 | } | |
| 731 | > | |
| 732 | {formatPrice(l.priceCents, l.pricingModel)} | |
| 733 | </span> | |
| 734 | <span class="amkt-install-btn">Install →</span> | |
| 735 | </div> | |
| 736 | </a> | |
| 737 | ); | |
| 738 | })} | |
| 739 | </div> | |
| 740 | )} | |
| 741 | </div> | |
| 742 | </Layout> | |
| 743 | ); | |
| 744 | }); | |
| 745 | ||
| 746 | // -------- GET /marketplace/agents/publish (form) ---------------------------- | |
| 747 | ||
| 748 | marketplaceAgents.get("/marketplace/agents/publish", requireAuth, async (c) => { | |
| 749 | const user = c.get("user")!; | |
| 750 | return c.html( | |
| 751 | <Layout title="Publish agent — Marketplace" user={user}> | |
| 752 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 753 | <div class="amkt-wrap"> | |
| 754 | <section class="amkt-hero"> | |
| 755 | <div class="amkt-hero-orb" aria-hidden="true" /> | |
| 756 | <div class="amkt-hero-inner"> | |
| 757 | <div class="amkt-hero-text"> | |
| 758 | <div class="amkt-eyebrow"> | |
| 759 | <span class="amkt-eyebrow-dot" aria-hidden="true" /> | |
| 760 | Publisher | |
| 761 | </div> | |
| 762 | <h1 class="amkt-title"> | |
| 763 | <span class="amkt-title-grad">Publish.</span> | |
| 764 | </h1> | |
| 765 | <p class="amkt-sub"> | |
| 766 | Submit your AI agent. Once approved by a moderator, it appears | |
| 767 | in the catalog and earns a 70% revenue share on paid | |
| 768 | invocations. | |
| 769 | </p> | |
| 770 | </div> | |
| 771 | <a href="/marketplace/agents" class="amkt-btn amkt-btn-ghost"> | |
| 772 | Cancel | |
| 773 | </a> | |
| 774 | </div> | |
| 775 | </section> | |
| 776 | ||
| 777 | <form method="post" action="/marketplace/agents/publish" class="amkt-section"> | |
| 778 | <div class="amkt-section-body"> | |
| 779 | <div class="amkt-form-group"> | |
| 780 | <label>Name</label> | |
| 781 | <input type="text" name="name" required maxlength={80} /> | |
| 782 | </div> | |
| 783 | <div class="amkt-form-group"> | |
| 784 | <label>Tagline (one line)</label> | |
| 785 | <input type="text" name="tagline" maxlength={280} /> | |
| 786 | </div> | |
| 787 | <div class="amkt-form-group"> | |
| 788 | <label>Description (markdown)</label> | |
| 789 | <textarea name="description" rows={6} /> | |
| 790 | </div> | |
| 791 | <div class="amkt-form-group"> | |
| 792 | <label>Category</label> | |
| 793 | <select name="category"> | |
| 794 | {MARKETPLACE_CATEGORIES.map((c) => ( | |
| 795 | <option value={c}>{c}</option> | |
| 796 | ))} | |
| 797 | </select> | |
| 798 | </div> | |
| 799 | <div class="amkt-form-group"> | |
| 800 | <label>Pricing model</label> | |
| 801 | <select name="pricingModel"> | |
| 802 | {PRICING_MODELS.map((p) => ( | |
| 803 | <option value={p}>{p}</option> | |
| 804 | ))} | |
| 805 | </select> | |
| 806 | </div> | |
| 807 | <div class="amkt-form-group"> | |
| 808 | <label>Price (cents)</label> | |
| 809 | <input type="number" name="priceCents" min={0} value={0} /> | |
| 810 | </div> | |
| 811 | <div class="amkt-form-group"> | |
| 812 | <label>Source URL</label> | |
| 813 | <input type="url" name="sourceUrl" /> | |
| 814 | </div> | |
| 815 | <button type="submit" class="amkt-btn amkt-btn-primary"> | |
| 816 | Submit for review | |
| 817 | </button> | |
| 818 | </div> | |
| 819 | </form> | |
| 820 | </div> | |
| 821 | </Layout> | |
| 822 | ); | |
| 823 | }); | |
| 824 | ||
| 825 | // -------- POST /marketplace/agents/publish ---------------------------------- | |
| 826 | ||
| 827 | marketplaceAgents.post("/marketplace/agents/publish", requireAuth, async (c) => { | |
| 828 | const user = c.get("user")!; | |
| 829 | const body = await c.req.parseBody(); | |
| 830 | const name = String(body.name || "").trim(); | |
| 831 | if (!name) return c.redirect("/marketplace/agents/publish"); | |
| 832 | const listing = await createListing({ | |
| 833 | publisherUserId: user.id, | |
| 834 | name, | |
| 835 | tagline: String(body.tagline || ""), | |
| 836 | description: String(body.description || ""), | |
| 837 | category: String(body.category || "custom"), | |
| 838 | pricingModel: String(body.pricingModel || "free"), | |
| 839 | priceCents: Number(body.priceCents || 0), | |
| 840 | sourceUrl: String(body.sourceUrl || "") || undefined, | |
| 841 | }); | |
| 842 | if (!listing) return c.text("failed to create", 500); | |
| 843 | await audit({ | |
| 844 | userId: user.id, | |
| 845 | action: "marketplace.agent.submit", | |
| 846 | targetType: "agent_marketplace_listing", | |
| 847 | targetId: listing.id, | |
| 848 | }); | |
| 849 | return c.redirect(`/marketplace/agents/${listing.slug}`); | |
| 850 | }); | |
| 851 | ||
| 852 | // -------- GET /marketplace/agents/:slug (detail) ---------------------------- | |
| 853 | ||
| 854 | marketplaceAgents.get("/marketplace/agents/:slug", async (c) => { | |
| 855 | const user = c.get("user"); | |
| 856 | const slug = c.req.param("slug"); | |
| 857 | const detail = await getListing(slug); | |
| 858 | if (!detail) return c.notFound(); | |
| 859 | // Only the publisher + admins can see a non-approved listing. | |
| 860 | const isOwner = user?.id === detail.listing.publisherUserId; | |
| 861 | const isAdmin = !!user?.isAdmin; | |
| 862 | if (detail.listing.status !== "approved" && !isOwner && !isAdmin) { | |
| 863 | return c.notFound(); | |
| 864 | } | |
| 865 | ||
| 866 | // Show the install form against the user's repos. | |
| 867 | const userRepos = user | |
| 868 | ? await db | |
| 869 | .select({ | |
| 870 | id: repositories.id, | |
| 871 | name: repositories.name, | |
| 872 | ownerName: users.username, | |
| 873 | }) | |
| 874 | .from(repositories) | |
| 875 | .leftJoin(users, eq(users.id, repositories.ownerId)) | |
| 876 | .where(eq(repositories.ownerId, user.id)) | |
| 877 | .limit(50) | |
| 878 | : []; | |
| 879 | ||
| 880 | const ratingNum = Number(detail.listing.ratingAvg) || 0; | |
| 881 | const descHtml = renderMarkdown(detail.listing.description || ""); | |
| 882 | ||
| 883 | return c.html( | |
| 884 | <Layout title={`${detail.listing.name} — Agent Marketplace`} user={user}> | |
| 885 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 886 | <div class="amkt-wrap"> | |
| 887 | <section class="amkt-hero"> | |
| 888 | <div class="amkt-hero-orb" aria-hidden="true" /> | |
| 889 | <div class="amkt-hero-inner"> | |
| 890 | <div class="amkt-hero-text" style="flex:1"> | |
| 891 | <div class="amkt-detail-head"> | |
| 892 | <span | |
| 893 | class="amkt-detail-logo" | |
| 894 | aria-hidden="true" | |
| 895 | style={`background:${gradientForSlug(detail.listing.slug)}`} | |
| 896 | > | |
| 897 | {listingInitials(detail.listing.name)} | |
| 898 | </span> | |
| 899 | <div style="min-width:0"> | |
| 900 | <h1 class="amkt-detail-name">{detail.listing.name}</h1> | |
| 901 | <div class="amkt-detail-meta"> | |
| 902 | by{" "} | |
| 903 | <strong style="color:var(--text)"> | |
| 904 | @{detail.listing.publisherUsername || "unknown"} | |
| 905 | </strong>{" "} | |
| 906 | · {detail.listing.installCount} install | |
| 907 | {detail.listing.installCount === 1 ? "" : "s"} ·{" "} | |
| 908 | <StarRow rating={ratingNum} /> {ratingNum.toFixed(1)} ( | |
| 909 | {detail.listing.ratingCount}) | |
| 910 | </div> | |
| 911 | </div> | |
| 912 | </div> | |
| 913 | <p class="amkt-sub">{detail.listing.tagline}</p> | |
| 914 | <div style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"> | |
| 915 | <span | |
| 916 | class={ | |
| 917 | "amkt-price-pill" + | |
| 918 | (detail.listing.pricingModel === "free" ? " is-free" : "") | |
| 919 | } | |
| 920 | > | |
| 921 | {formatPrice( | |
| 922 | detail.listing.priceCents, | |
| 923 | detail.listing.pricingModel | |
| 924 | )} | |
| 925 | </span> | |
| 926 | <span class="amkt-price-pill">{detail.listing.category}</span> | |
| 927 | {detail.listing.status !== "approved" && ( | |
| 928 | <span class="amkt-price-pill">{detail.listing.status}</span> | |
| 929 | )} | |
| 930 | {detail.listing.sourceUrl && ( | |
| 931 | <a | |
| 932 | href={detail.listing.sourceUrl} | |
| 933 | class="amkt-btn amkt-btn-ghost" | |
| 934 | style="padding:6px 12px;font-size:12px" | |
| 935 | > | |
| 936 | Source | |
| 937 | </a> | |
| 938 | )} | |
| 939 | </div> | |
| 940 | </div> | |
| 941 | </div> | |
| 942 | </section> | |
| 943 | ||
| 944 | <section class="amkt-section"> | |
| 945 | <header class="amkt-section-head"> | |
| 946 | <h3 class="amkt-section-title">About</h3> | |
| 947 | </header> | |
| 948 | <div class="amkt-section-body"> | |
| 949 | <div | |
| 950 | class="amkt-prose" | |
| 951 | dangerouslySetInnerHTML={{ | |
| 952 | __html: descHtml || "<p>No description provided.</p>", | |
| 953 | }} | |
| 954 | /> | |
| 955 | </div> | |
| 956 | </section> | |
| 957 | ||
| 958 | {user ? ( | |
| 959 | <section class="amkt-section"> | |
| 960 | <header class="amkt-section-head"> | |
| 961 | <h3 class="amkt-section-title">Install on a repo</h3> | |
| 962 | <p class="amkt-section-sub"> | |
| 963 | Provisions a scoped agent session with budget{" "} | |
| 964 | {detail.listing.agentTemplate?.budgetCentsPerDay ?? 500}¢/day. | |
| 965 | Token is shown once. | |
| 966 | </p> | |
| 967 | </header> | |
| 968 | <div class="amkt-section-body"> | |
| 969 | {userRepos.length === 0 ? ( | |
| 970 | <p style="color:var(--text-muted);font-size:13.5px;margin:0"> | |
| 971 | You don't own any repos yet —{" "} | |
| 972 | <a href="/new" style="color:var(--accent)"> | |
| 973 | create one | |
| 974 | </a>{" "} | |
| 975 | to install this agent. | |
| 976 | </p> | |
| 977 | ) : ( | |
| 978 | <form | |
| 979 | method="post" | |
| 980 | action={`/marketplace/agents/${slug}/install`} | |
| 981 | style="display:flex;gap:8px;align-items:center;flex-wrap:wrap" | |
| 982 | > | |
| 983 | <select | |
| 984 | name="repositoryId" | |
| 985 | aria-label="Repository" | |
| 986 | style="flex:1;min-width:200px;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" | |
| 987 | > | |
| 988 | {userRepos.map((r) => ( | |
| 989 | <option value={r.id}> | |
| 990 | {r.ownerName}/{r.name} | |
| 991 | </option> | |
| 992 | ))} | |
| 993 | </select> | |
| 994 | <button | |
| 995 | type="submit" | |
| 996 | class="amkt-btn amkt-btn-primary" | |
| 997 | disabled={detail.listing.status !== "approved"} | |
| 998 | > | |
| 999 | Install | |
| 1000 | </button> | |
| 1001 | </form> | |
| 1002 | )} | |
| 1003 | </div> | |
| 1004 | </section> | |
| 1005 | ) : ( | |
| 1006 | <section class="amkt-section"> | |
| 1007 | <div class="amkt-section-body"> | |
| 1008 | <p style="margin:0;color:var(--text-muted);font-size:14px"> | |
| 1009 | <a | |
| 1010 | href={`/login?next=/marketplace/agents/${slug}`} | |
| 1011 | style="color:var(--accent)" | |
| 1012 | > | |
| 1013 | Sign in | |
| 1014 | </a>{" "} | |
| 1015 | to install this agent on one of your repos. | |
| 1016 | </p> | |
| 1017 | </div> | |
| 1018 | </section> | |
| 1019 | )} | |
| 1020 | ||
| 1021 | <section class="amkt-section"> | |
| 1022 | <header class="amkt-section-head"> | |
| 1023 | <h3 class="amkt-section-title"> | |
| 1024 | Reviews ({detail.listing.ratingCount}) | |
| 1025 | </h3> | |
| 1026 | </header> | |
| 1027 | <div class="amkt-section-body"> | |
| 1028 | {detail.reviews.length === 0 ? ( | |
| 1029 | <p style="color:var(--text-muted);font-size:13.5px;margin:0"> | |
| 1030 | No reviews yet. | |
| 1031 | </p> | |
| 1032 | ) : ( | |
| 1033 | detail.reviews.map((r) => ( | |
| 1034 | <div class="amkt-review"> | |
| 1035 | <div class="amkt-review-head"> | |
| 1036 | <StarRow rating={r.rating} /> | |
| 1037 | <span class="amkt-review-author"> | |
| 1038 | @{r.reviewerUsername || "anon"} | |
| 1039 | </span> | |
| 1040 | <span class="amkt-review-date"> | |
| 1041 | {r.createdAt | |
| 1042 | ? new Date(r.createdAt).toLocaleDateString() | |
| 1043 | : ""} | |
| 1044 | </span> | |
| 1045 | </div> | |
| 1046 | <p class="amkt-review-body">{r.body || "(no body)"}</p> | |
| 1047 | </div> | |
| 1048 | )) | |
| 1049 | )} | |
| 1050 | {user && ( | |
| 1051 | <form | |
| 1052 | method="post" | |
| 1053 | action={`/marketplace/agents/${slug}/reviews`} | |
| 1054 | style="margin-top:18px;padding-top:18px;border-top:1px solid var(--border)" | |
| 1055 | > | |
| 1056 | <div class="amkt-form-group"> | |
| 1057 | <label>Rating</label> | |
| 1058 | <select name="rating" style="max-width:120px"> | |
| 1059 | {[5, 4, 3, 2, 1].map((n) => ( | |
| 1060 | <option value={n}>{n} ★</option> | |
| 1061 | ))} | |
| 1062 | </select> | |
| 1063 | </div> | |
| 1064 | <div class="amkt-form-group"> | |
| 1065 | <label>Review</label> | |
| 1066 | <textarea name="body" rows={3} maxlength={4000} /> | |
| 1067 | </div> | |
| 1068 | <button type="submit" class="amkt-btn amkt-btn-primary"> | |
| 1069 | Post review | |
| 1070 | </button> | |
| 1071 | </form> | |
| 1072 | )} | |
| 1073 | </div> | |
| 1074 | </section> | |
| 1075 | </div> | |
| 1076 | </Layout> | |
| 1077 | ); | |
| 1078 | }); | |
| 1079 | ||
| 1080 | // -------- POST /marketplace/agents/:slug/install ---------------------------- | |
| 1081 | ||
| 1082 | marketplaceAgents.post( | |
| 1083 | "/marketplace/agents/:slug/install", | |
| 1084 | requireAuth, | |
| 1085 | async (c) => { | |
| 1086 | const user = c.get("user")!; | |
| 1087 | const slug = c.req.param("slug"); | |
| 1088 | const listing = await fetchListingBySlug(slug); | |
| 1089 | if (!listing || listing.status !== "approved") { | |
| 1090 | return c.text("not found", 404); | |
| 1091 | } | |
| 1092 | const body = await c.req.parseBody(); | |
| 1093 | const repositoryId = String(body.repositoryId || ""); | |
| 1094 | if (!repositoryId) { | |
| 1095 | return c.redirect(`/marketplace/agents/${slug}`); | |
| 1096 | } | |
| 1097 | // Must own the repo. | |
| 1098 | const [repo] = await db | |
| 1099 | .select() | |
| 1100 | .from(repositories) | |
| 1101 | .where( | |
| 1102 | and(eq(repositories.id, repositoryId), eq(repositories.ownerId, user.id)) | |
| 1103 | ) | |
| 1104 | .limit(1); | |
| 1105 | if (!repo) return c.text("forbidden", 403); | |
| 1106 | ||
| 1107 | const result = await installListing({ | |
| 1108 | listingId: listing.id, | |
| 1109 | repositoryId: repo.id, | |
| 1110 | installedByUserId: user.id, | |
| 1111 | }); | |
| 1112 | if (!result) { | |
| 1113 | return c.text( | |
| 1114 | "install failed (already installed or session provisioning error)", | |
| 1115 | 409 | |
| 1116 | ); | |
| 1117 | } | |
| 1118 | await audit({ | |
| 1119 | userId: user.id, | |
| 1120 | action: "marketplace.agent.install", | |
| 1121 | targetType: "agent_marketplace_install", | |
| 1122 | targetId: result.install.id, | |
| 1123 | metadata: { slug, repositoryId }, | |
| 1124 | }); | |
| 1125 | ||
| 1126 | return c.html( | |
| 1127 | <Layout title="Agent installed" user={user}> | |
| 1128 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 1129 | <div class="amkt-wrap"> | |
| 1130 | <section class="amkt-hero"> | |
| 1131 | <div class="amkt-hero-orb" aria-hidden="true" /> | |
| 1132 | <div class="amkt-hero-inner"> | |
| 1133 | <div class="amkt-hero-text"> | |
| 1134 | <div class="amkt-eyebrow"> | |
| 1135 | <span class="amkt-eyebrow-dot" aria-hidden="true" /> | |
| 1136 | Token issued | |
| 1137 | </div> | |
| 1138 | <h1 class="amkt-title"> | |
| 1139 | <span class="amkt-title-grad">Copy now.</span> | |
| 1140 | </h1> | |
| 1141 | <p class="amkt-sub"> | |
| 1142 | This agent token is shown once. Store it safely — it's the | |
| 1143 | agent's bearer credential. | |
| 1144 | </p> | |
| 1145 | </div> | |
| 1146 | </div> | |
| 1147 | </section> | |
| 1148 | <section class="amkt-section"> | |
| 1149 | <div class="amkt-section-body"> | |
| 1150 | <div class="amkt-token-block">{result.agentToken}</div> | |
| 1151 | <a | |
| 1152 | href={`/marketplace/agents/${slug}`} | |
| 1153 | class="amkt-btn amkt-btn-ghost" | |
| 1154 | style="margin-top:14px" | |
| 1155 | > | |
| 1156 | Back to listing | |
| 1157 | </a> | |
| 1158 | </div> | |
| 1159 | </section> | |
| 1160 | </div> | |
| 1161 | </Layout> | |
| 1162 | ); | |
| 1163 | } | |
| 1164 | ); | |
| 1165 | ||
| 1166 | // -------- POST /marketplace/agents/:slug/reviews ---------------------------- | |
| 1167 | ||
| 1168 | marketplaceAgents.post( | |
| 1169 | "/marketplace/agents/:slug/reviews", | |
| 1170 | requireAuth, | |
| 1171 | async (c) => { | |
| 1172 | const user = c.get("user")!; | |
| 1173 | const slug = c.req.param("slug"); | |
| 1174 | const listing = await fetchListingBySlug(slug); | |
| 1175 | if (!listing) return c.text("not found", 404); | |
| 1176 | const body = await c.req.parseBody(); | |
| 1177 | const rating = Number(body.rating || 0); | |
| 1178 | await recordReview({ | |
| 1179 | listingId: listing.id, | |
| 1180 | reviewerUserId: user.id, | |
| 1181 | rating, | |
| 1182 | body: String(body.body || ""), | |
| 1183 | }); | |
| 1184 | return c.redirect(`/marketplace/agents/${slug}`); | |
| 1185 | } | |
| 1186 | ); | |
| 1187 | ||
| 1188 | // -------- POST /marketplace/installs/:id/uninstall -------------------------- | |
| 1189 | ||
| 1190 | marketplaceAgents.post( | |
| 1191 | "/marketplace/installs/:id/uninstall", | |
| 1192 | requireAuth, | |
| 1193 | async (c) => { | |
| 1194 | const user = c.get("user")!; | |
| 1195 | const id = c.req.param("id"); | |
| 1196 | const [row] = await db | |
| 1197 | .select() | |
| 1198 | .from(agentMarketplaceInstalls) | |
| 1199 | .where(eq(agentMarketplaceInstalls.id, id)) | |
| 1200 | .limit(1); | |
| 1201 | if (!row || row.installedByUserId !== user.id) { | |
| 1202 | return c.text("forbidden", 403); | |
| 1203 | } | |
| 1204 | const ok = await uninstallListing({ installId: id }); | |
| 1205 | if (ok) { | |
| 1206 | await audit({ | |
| 1207 | userId: user.id, | |
| 1208 | action: "marketplace.agent.uninstall", | |
| 1209 | targetType: "agent_marketplace_install", | |
| 1210 | targetId: id, | |
| 1211 | }); | |
| 1212 | } | |
| 1213 | return c.redirect("/marketplace/agents"); | |
| 1214 | } | |
| 1215 | ); | |
| 1216 | ||
| 1217 | // -------- GET /admin/marketplace/queue -------------------------------------- | |
| 1218 | ||
| 1219 | marketplaceAgents.get( | |
| 1220 | "/admin/marketplace/queue", | |
| 1221 | requireAuth, | |
| 1222 | requireAdmin, | |
| 1223 | async (c) => { | |
| 1224 | const user = c.get("user")!; | |
| 1225 | const pending = await listListings({ status: "pending_review", sort: "new" }); | |
| 1226 | const rejected = await listListings({ status: "rejected", sort: "new" }); | |
| 1227 | return c.html( | |
| 1228 | <Layout title="Marketplace moderation — Admin" user={user}> | |
| 1229 | <style dangerouslySetInnerHTML={{ __html: styles }} /> | |
| 1230 | <div class="amkt-wrap"> | |
| 1231 | <section class="amkt-hero"> | |
| 1232 | <div class="amkt-hero-orb" aria-hidden="true" /> | |
| 1233 | <div class="amkt-hero-inner"> | |
| 1234 | <div class="amkt-hero-text"> | |
| 1235 | <div class="amkt-eyebrow"> | |
| 1236 | <span class="amkt-eyebrow-dot" aria-hidden="true" /> | |
| 1237 | Admin · Marketplace | |
| 1238 | </div> | |
| 1239 | <h1 class="amkt-title"> | |
| 1240 | <span class="amkt-title-grad">Moderate.</span> | |
| 1241 | </h1> | |
| 1242 | <p class="amkt-sub"> | |
| 1243 | Approve or reject pending agent submissions. Approved listings | |
| 1244 | go live immediately. | |
| 1245 | </p> | |
| 1246 | </div> | |
| 1247 | </div> | |
| 1248 | </section> | |
| 1249 | ||
| 1250 | <section class="amkt-section"> | |
| 1251 | <header class="amkt-section-head"> | |
| 1252 | <h3 class="amkt-section-title"> | |
| 1253 | Pending ({pending.length}) | |
| 1254 | </h3> | |
| 1255 | </header> | |
| 1256 | {pending.length === 0 ? ( | |
| 1257 | <div class="amkt-section-body"> | |
| 1258 | <p style="color:var(--text-muted);margin:0">Queue is empty.</p> | |
| 1259 | </div> | |
| 1260 | ) : ( | |
| 1261 | pending.map((l) => ( | |
| 1262 | <div class="amkt-queue-row"> | |
| 1263 | <div style="min-width:0;flex:1"> | |
| 1264 | <a | |
| 1265 | href={`/marketplace/agents/${l.slug}`} | |
| 1266 | style="color:var(--text-strong);font-weight:600;text-decoration:none" | |
| 1267 | > | |
| 1268 | {l.name} | |
| 1269 | </a> | |
| 1270 | <div | |
| 1271 | style="font-size:12px;color:var(--text-muted);margin-top:2px" | |
| 1272 | > | |
| 1273 | {l.category} · {formatPrice(l.priceCents, l.pricingModel)}{" "} | |
| 1274 | · submitted{" "} | |
| 1275 | {l.createdAt | |
| 1276 | ? new Date(l.createdAt).toLocaleDateString() | |
| 1277 | : ""} | |
| 1278 | </div> | |
| 1279 | </div> | |
| 1280 | <div class="amkt-queue-actions"> | |
| 1281 | <form | |
| 1282 | method="post" | |
| 1283 | action={`/admin/marketplace/queue/${l.slug}/approve`} | |
| 1284 | style="margin:0" | |
| 1285 | > | |
| 1286 | <button class="amkt-btn amkt-btn-primary">Approve</button> | |
| 1287 | </form> | |
| 1288 | <form | |
| 1289 | method="post" | |
| 1290 | action={`/admin/marketplace/queue/${l.slug}/reject`} | |
| 1291 | style="margin:0" | |
| 1292 | > | |
| 1293 | <button class="amkt-btn amkt-btn-danger">Reject</button> | |
| 1294 | </form> | |
| 1295 | </div> | |
| 1296 | </div> | |
| 1297 | )) | |
| 1298 | )} | |
| 1299 | </section> | |
| 1300 | ||
| 1301 | {rejected.length > 0 && ( | |
| 1302 | <section class="amkt-section"> | |
| 1303 | <header class="amkt-section-head"> | |
| 1304 | <h3 class="amkt-section-title"> | |
| 1305 | Recently rejected ({rejected.length}) | |
| 1306 | </h3> | |
| 1307 | </header> | |
| 1308 | {rejected.map((l) => ( | |
| 1309 | <div class="amkt-queue-row"> | |
| 1310 | <div style="min-width:0;flex:1"> | |
| 1311 | <span style="color:var(--text);font-weight:600">{l.name}</span> | |
| 1312 | <div | |
| 1313 | style="font-size:12px;color:var(--text-muted);margin-top:2px" | |
| 1314 | > | |
| 1315 | {l.category} · rejected | |
| 1316 | </div> | |
| 1317 | </div> | |
| 1318 | </div> | |
| 1319 | ))} | |
| 1320 | </section> | |
| 1321 | )} | |
| 1322 | </div> | |
| 1323 | </Layout> | |
| 1324 | ); | |
| 1325 | } | |
| 1326 | ); | |
| 1327 | ||
| 1328 | // -------- POST /admin/marketplace/queue/:slug/:action ----------------------- | |
| 1329 | ||
| 1330 | marketplaceAgents.post( | |
| 1331 | "/admin/marketplace/queue/:slug/:action", | |
| 1332 | requireAuth, | |
| 1333 | requireAdmin, | |
| 1334 | async (c) => { | |
| 1335 | const user = c.get("user")!; | |
| 1336 | const slug = c.req.param("slug"); | |
| 1337 | const action = c.req.param("action"); | |
| 1338 | if (action === "approve") { | |
| 1339 | await approveListing(slug, user.id); | |
| 1340 | await audit({ | |
| 1341 | userId: user.id, | |
| 1342 | action: "marketplace.agent.approve", | |
| 1343 | targetType: "agent_marketplace_listing", | |
| 1344 | targetId: slug, | |
| 1345 | }); | |
| 1346 | } else if (action === "reject") { | |
| 1347 | const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>; | |
| 1348 | await rejectListing(slug, user.id, String(body.reason || "")); | |
| 1349 | await audit({ | |
| 1350 | userId: user.id, | |
| 1351 | action: "marketplace.agent.reject", | |
| 1352 | targetType: "agent_marketplace_listing", | |
| 1353 | targetId: slug, | |
| 1354 | }); | |
| 1355 | } | |
| 1356 | return c.redirect("/admin/marketplace/queue"); | |
| 1357 | } | |
| 1358 | ); | |
| 1359 | ||
| 1360 | // ============================================================================ | |
| 1361 | // API v2 endpoints — JSON surface for the same operations. | |
| 1362 | // Mounted as a separate sub-app at /api/v2/marketplace so the public auth | |
| 1363 | // stack (PAT / Bearer) applies cleanly. | |
| 1364 | // ============================================================================ | |
| 1365 | ||
| 1366 | import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth"; | |
| 1367 | import type { ApiAuthEnv } from "../middleware/api-auth"; | |
| 1368 | ||
| 1369 | const marketplaceAgentsApi = new Hono<ApiAuthEnv>().basePath( | |
| 1370 | "/api/v2/marketplace" | |
| 1371 | ); | |
| 1372 | marketplaceAgentsApi.use("*", apiAuth); | |
| 1373 | ||
| 1374 | marketplaceAgentsApi.get("/agents", async (c) => { | |
| 1375 | const list = await listListings({ | |
| 1376 | category: c.req.query("category") || undefined, | |
| 1377 | search: c.req.query("q") || undefined, | |
| 1378 | sort: ((): "top" | "new" | "rated" => { | |
| 1379 | const s = c.req.query("sort"); | |
| 1380 | return s === "new" || s === "rated" ? s : "top"; | |
| 1381 | })(), | |
| 1382 | }); | |
| 1383 | return c.json({ listings: list }); | |
| 1384 | }); | |
| 1385 | ||
| 1386 | marketplaceAgentsApi.get("/agents/:slug", async (c) => { | |
| 1387 | const slug = c.req.param("slug"); | |
| 1388 | const detail = await getListing(slug); | |
| 1389 | if (!detail || detail.listing.status !== "approved") { | |
| 1390 | return c.json({ error: "not found" }, 404); | |
| 1391 | } | |
| 1392 | return c.json(detail); | |
| 1393 | }); | |
| 1394 | ||
| 1395 | marketplaceAgentsApi.post( | |
| 1396 | "/agents", | |
| 1397 | requireApiAuth, | |
| 1398 | requireScope("repo"), | |
| 1399 | async (c) => { | |
| 1400 | const user = c.get("user")!; | |
| 1401 | const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 1402 | const name = String(body.name || "").trim(); | |
| 1403 | if (!name) return c.json({ error: "name required" }, 400); | |
| 1404 | const category = String(body.category || "custom"); | |
| 1405 | const pricingModel = String(body.pricingModel || "free"); | |
| 1406 | if (!isValidCategory(category)) return c.json({ error: "bad category" }, 400); | |
| 1407 | if (!isValidPricingModel(pricingModel)) { | |
| 1408 | return c.json({ error: "bad pricingModel" }, 400); | |
| 1409 | } | |
| 1410 | const listing = await createListing({ | |
| 1411 | publisherUserId: user.id, | |
| 1412 | name, | |
| 1413 | tagline: String(body.tagline || ""), | |
| 1414 | description: String(body.description || ""), | |
| 1415 | category, | |
| 1416 | pricingModel, | |
| 1417 | priceCents: Number(body.priceCents || 0), | |
| 1418 | sourceUrl: | |
| 1419 | typeof body.sourceUrl === "string" ? body.sourceUrl : undefined, | |
| 1420 | agentTemplate: | |
| 1421 | body.agentTemplate && typeof body.agentTemplate === "object" | |
| 1422 | ? (body.agentTemplate as Record<string, unknown>) | |
| 1423 | : undefined, | |
| 1424 | }); | |
| 1425 | if (!listing) return c.json({ error: "failed" }, 500); | |
| 1426 | return c.json({ listing }, 201); | |
| 1427 | } | |
| 1428 | ); | |
| 1429 | ||
| 1430 | marketplaceAgentsApi.post( | |
| 1431 | "/agents/:slug/install", | |
| 1432 | requireApiAuth, | |
| 1433 | requireScope("repo"), | |
| 1434 | async (c) => { | |
| 1435 | const user = c.get("user")!; | |
| 1436 | const slug = c.req.param("slug"); | |
| 1437 | const listing = await fetchListingBySlug(slug); | |
| 1438 | if (!listing || listing.status !== "approved") { | |
| 1439 | return c.json({ error: "not found" }, 404); | |
| 1440 | } | |
| 1441 | const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 1442 | const repositoryId = String(body.repositoryId || ""); | |
| 1443 | if (!repositoryId) { | |
| 1444 | return c.json({ error: "repositoryId required" }, 400); | |
| 1445 | } | |
| 1446 | const [repo] = await db | |
| 1447 | .select() | |
| 1448 | .from(repositories) | |
| 1449 | .where( | |
| 1450 | and(eq(repositories.id, repositoryId), eq(repositories.ownerId, user.id)) | |
| 1451 | ) | |
| 1452 | .limit(1); | |
| 1453 | if (!repo) return c.json({ error: "repo not found or not owned" }, 403); | |
| 1454 | ||
| 1455 | const result = await installListing({ | |
| 1456 | listingId: listing.id, | |
| 1457 | repositoryId: repo.id, | |
| 1458 | installedByUserId: user.id, | |
| 1459 | }); | |
| 1460 | if (!result) return c.json({ error: "install failed" }, 409); | |
| 1461 | return c.json( | |
| 1462 | { | |
| 1463 | installId: result.install.id, | |
| 1464 | agentToken: result.agentToken, | |
| 1465 | agentSessionId: result.install.agentSessionId, | |
| 1466 | }, | |
| 1467 | 201 | |
| 1468 | ); | |
| 1469 | } | |
| 1470 | ); | |
| 1471 | ||
| 1472 | marketplaceAgentsApi.delete( | |
| 1473 | "/installs/:id", | |
| 1474 | requireApiAuth, | |
| 1475 | requireScope("repo"), | |
| 1476 | async (c) => { | |
| 1477 | const user = c.get("user")!; | |
| 1478 | const id = c.req.param("id"); | |
| 1479 | const [row] = await db | |
| 1480 | .select() | |
| 1481 | .from(agentMarketplaceInstalls) | |
| 1482 | .where(eq(agentMarketplaceInstalls.id, id)) | |
| 1483 | .limit(1); | |
| 1484 | if (!row || row.installedByUserId !== user.id) { | |
| 1485 | return c.json({ error: "forbidden" }, 403); | |
| 1486 | } | |
| 1487 | const ok = await uninstallListing({ installId: id }); | |
| 1488 | return c.json({ ok }); | |
| 1489 | } | |
| 1490 | ); | |
| 1491 | ||
| 1492 | marketplaceAgentsApi.post( | |
| 1493 | "/agents/:slug/reviews", | |
| 1494 | requireApiAuth, | |
| 1495 | requireScope("repo"), | |
| 1496 | async (c) => { | |
| 1497 | const user = c.get("user")!; | |
| 1498 | const slug = c.req.param("slug"); | |
| 1499 | const listing = await fetchListingBySlug(slug); | |
| 1500 | if (!listing) return c.json({ error: "not found" }, 404); | |
| 1501 | const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 1502 | const rating = Number(body.rating || 0); | |
| 1503 | if (rating < 1 || rating > 5) { | |
| 1504 | return c.json({ error: "rating must be 1..5" }, 400); | |
| 1505 | } | |
| 1506 | const review = await recordReview({ | |
| 1507 | listingId: listing.id, | |
| 1508 | reviewerUserId: user.id, | |
| 1509 | rating, | |
| 1510 | body: String(body.body || ""), | |
| 1511 | }); | |
| 1512 | if (!review) return c.json({ error: "failed" }, 500); | |
| 1513 | return c.json({ review }, 201); | |
| 1514 | } | |
| 1515 | ); | |
| 1516 | ||
| 1517 | // Compose both sub-apps onto one mount. | |
| 1518 | marketplaceAgents.route("/", marketplaceAgentsApi); | |
| 1519 | ||
| 1520 | export default marketplaceAgents; |