feat(crash-dashboard): fold skill/MCP registry into the admin backend

Give the owner one admin console instead of a standalone, undeployed
registry worker. The registry source moves under the crash worker as a
second data plane (REGISTRY_DB); its public + publish API is served by
delegating to the folded Hono app, and a server-rendered Community tab
moderates user-published skills/MCP (approve/reject/hide/verify) gated on
the unified dashboard admin role, writing the shared audit log.

Requires a reasonix-registry D1 (schema in registry-schema.sql) before
the auto-deploy runs; the id in wrangler.toml is a placeholder.
This commit is contained in:
yhh committed 2026-07-02 20:13:14 -07:00
1 parent ac5f37f40b
commit 606d782379
31 files changed
+197 -1716

No files matched your search

@@ -1,4 +1,2 @@
node_modules/
.wrangler/
.dev.vars
dist/
+10 -48
View File
@@ -6,6 +6,7 @@
"": {
"name": "reasonix-crash-report",
"dependencies": {
"hono": "^4.6.14",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -696,9 +697,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -716,9 +714,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -736,9 +731,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -756,9 +748,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -776,9 +765,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -796,9 +782,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -816,9 +799,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -836,9 +816,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -856,9 +833,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -882,9 +856,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -908,9 +879,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -934,9 +902,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -960,9 +925,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -986,9 +948,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1012,9 +971,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1038,9 +994,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1311,6 +1264,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hono": {
"version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+1
View File
@@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.6.14",
"zod": "^3.25.0"
},
"devDependencies": {
+4 -1
View File
@@ -161,6 +161,9 @@ export async function logAction(env: Env, actor: User, action: string, target =
}
export function userNav(user: User): string {
const admin = user.role === "admin" ? `<a class="navlink" href="/admin">Admin</a>` : "";
const admin =
user.role === "admin"
? `<a class="navlink" href="/community">Community</a><a class="navlink" href="/admin">Admin</a>`
: "";
return `<span class="chip"><span class="badge ${user.role}">${user.role}</span>${esc(user.email)}</span><a class="navlink" href="/account">Account</a>${admin}<form method="post" action="/logout" class="inline"><button class="btn ghost sm">Sign out</button></form>`;
}
+68
View File
@@ -0,0 +1,68 @@
// Moderation console for user-published skills/MCP servers. Gated on the unified
// dashboard admin role; reads and writes the registry database.
import { esc, page } from "./shell";
import { type User, userNav } from "./auth";
import type { PackageRow } from "./registry/types";
const STATUS_TABS = [
{ key: "pending", label: "Pending" },
{ key: "active", label: "Active" },
{ key: "hidden", label: "Hidden" },
{ key: "rejected", label: "Rejected" },
];
function actionForm(pkg: PackageRow, action: string, label: string, cls: string, backStatus: string, confirm?: string): string {
const onsubmit = confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : "";
return `<form method="post" action="/community/${esc(pkg.scope_handle)}/${esc(pkg.name)}/${action}" class="inline"${onsubmit}>
<input type="hidden" name="status" value="${esc(backStatus)}"><button class="btn ${cls} sm" type="submit">${esc(label)}</button></form>`;
}
function rowActions(pkg: PackageRow, backStatus: string): string {
if (pkg.status === "active") {
const verify = pkg.verified
? actionForm(pkg, "unverify", "Unverify", "ghost", backStatus)
: actionForm(pkg, "verify", "Verify", "ghost", backStatus);
return `${verify}${actionForm(pkg, "hide", "Hide", "danger", backStatus, `Hide ${pkg.slug}?`)}`;
}
const approve = actionForm(pkg, "approve", "Approve", "", backStatus);
const reject = pkg.status === "rejected" ? "" : actionForm(pkg, "reject", "Reject", "danger", backStatus, `Reject ${pkg.slug}?`);
return `${approve}${reject}`;
}
function sourceLinks(pkg: PackageRow): string {
const links: string[] = [];
if (pkg.repo_url) links.push(`<a class="navlink" href="${esc(pkg.repo_url)}" target="_blank" rel="noopener">repo</a>`);
if (pkg.homepage) links.push(`<a class="navlink" href="${esc(pkg.homepage)}" target="_blank" rel="noopener">home</a>`);
return links.join(" · ");
}
export function renderCommunity(viewer: User, packages: PackageRow[], status: string): string {
const tabs = STATUS_TABS.map(
(t) => `<a class="filter-tab${t.key === status ? " active" : ""}" href="/community?status=${t.key}">${t.label}</a>`,
).join("");
const rows = packages.length
? packages
.map((p) => {
const verified = p.verified ? ` <span class="badge admin">verified</span>` : "";
const links = sourceLinks(p);
return `<tr>
<td><div class="crash-summary"><span>${esc(p.slug)}${verified}</span><small>${esc(p.summary || "—")}</small></div></td>
<td><span class="pill">${esc(p.kind)}</span></td>
<td>@${esc(p.scope_handle)}</td>
<td class="n">${esc(p.latest_version || "—")} · ${p.install_count} inst · ${p.star_count}★</td>
<td class="n">${esc(p.created_at.slice(0, 10))}</td>
<td><div class="actions">${rowActions(p, status)}</div>${links ? `<div class="muted">${links}</div>` : ""}</td>
</tr>`;
})
.join("")
: `<tr><td colspan="6"><div class="empty">No ${esc(status)} packages</div></td></tr>`;
return page(
"Reasonix · Community",
"community",
`<h1>Community</h1><p class="sub">Review user-published skills and MCP servers — approve to publish, verify to badge, hide to take down</p>
<div class="filter-tabs">${tabs}</div>
<div class="card full"><table><thead><tr><th>package</th><th>kind</th><th>publisher</th><th>version · installs · stars</th><th>submitted</th><th></th></tr></thead>
<tbody>${rows}</tbody></table></div>`,
userNav(viewer),
);
}
+6
View File
@@ -4,12 +4,18 @@ export interface RateLimiter {
export interface Env {
DB: D1Database;
// Skill/MCP registry database — the folded registry API + moderation console
// read and write it; the crash tables stay in DB.
REGISTRY_DB: D1Database;
RATE_LIMITER: RateLimiter;
PING_LIMITER: RateLimiter;
METRICS_LIMITER: RateLimiter;
WRITE_LIMITER?: RateLimiter;
ADMIN_EMAILS?: string;
// Shared identity service (id.reasonix.io) and the site that hosts its login
// page (reasonix.io). Overridable for local dev.
ID_ORIGIN?: string;
APP_ORIGIN?: string;
// Browsers allowed to call the registry API with credentials (comma-separated).
ALLOWED_ORIGINS?: string;
}
+89 -1
View File
@@ -17,6 +17,11 @@ import {
type Role,
type User,
} from "./auth";
import registryApp from "./registry/app";
import type { Bindings as RegistryBindings } from "./registry/env";
import { PackageRepo } from "./registry/db/packages";
import { EventRepo } from "./registry/db/events";
import { renderCommunity } from "./community";
const MAX_BODY_BYTES = 96 * 1024;
const LATEST_SAMPLES_PER_GROUP = 5;
@@ -902,6 +907,71 @@ function requireViewer(user: User | null, login: string): Response | null {
return null;
}
// The folded registry API runs against its own database and resolves identity
// itself; hand it the second binding plus the account/site origins it expects.
function registryBindings(env: Env): RegistryBindings {
return {
DB: env.REGISTRY_DB,
WRITE_LIMITER: env.WRITE_LIMITER,
ACCOUNTS_ORIGIN: env.ID_ORIGIN ?? "https://id.reasonix.io",
APP_ORIGIN: env.APP_ORIGIN ?? "https://reasonix.io",
ALLOWED_ORIGINS: env.ALLOWED_ORIGINS ?? "https://reasonix.io,https://www.reasonix.io",
};
}
function communityStatus(url: URL): string {
const s = url.searchParams.get("status") ?? "pending";
return ["pending", "active", "hidden", "rejected"].includes(s) ? s : "pending";
}
async function handleCommunityList(env: Env, admin: User, status: string): Promise<Response> {
const rows = await new PackageRepo(env.REGISTRY_DB).listByStatus(status, 200);
return html(renderCommunity(admin, rows, status));
}
async function handleCommunityAction(
request: Request,
env: Env,
admin: User,
handle: string,
name: string,
action: string,
): Promise<Response> {
if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
const form = await formObject(request);
const backStatus = ["pending", "active", "hidden", "rejected"].includes(form.status) ? form.status : "pending";
const back = redirect(`/community?status=${backStatus}`);
const slug = `${handle}/${name}`;
const repo = new PackageRepo(env.REGISTRY_DB);
const now = new Date().toISOString();
if (action === "verify" || action === "unverify") {
await repo.setVerified(slug, action === "verify", now);
await logAction(env, admin, `pkg_${action}`, slug);
return back;
}
if (action === "approve") {
const before = await repo.bySlug(slug);
const row = await repo.setStatus(slug, "active", now);
// Emit the publish event on first approval so the feed only announces
// packages that actually went public.
if (row && before && before.status !== "active") {
await new EventRepo(env.REGISTRY_DB).log({
type: "publish",
packageId: row.id,
actorHandle: row.scope_handle,
summary: `published ${row.slug}@${row.latest_version}`,
now,
});
}
await logAction(env, admin, "pkg_approve", slug);
return back;
}
await repo.setStatus(slug, action === "reject" ? "rejected" : "hidden", now);
await logAction(env, admin, `pkg_${action}`, slug);
return back;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
@@ -912,6 +982,13 @@ export default {
if (path === "/v1/ping" && method === "POST") return handlePing(request, env);
if (path === "/v1/metrics" && method === "POST") return handleMetrics(request, env);
// Skill/MCP registry API — the folded Hono app handles its own auth, CORS
// and rate limiting against the registry database (public reads + publish,
// plus the JSON /v1/admin the site's moderation panel calls).
if (path.startsWith("/v1/packages") || path === "/v1/activity" || path.startsWith("/v1/admin")) {
return registryApp.fetch(request, registryBindings(env));
}
const login = loginUrl(env, request);
// Authentication moved to id.reasonix.io; these paths just bounce there.
@@ -947,6 +1024,16 @@ export default {
return handleAdminUsers(request, env, user);
}
if (path === "/community" && method === "GET") {
if (!user) return redirect(login);
return user.role === "admin" ? handleCommunityList(env, user, communityStatus(url)) : redirect("/account");
}
const pkgActionMatch = path.match(/^\/community\/([^/]+)\/([^/]+)\/(approve|reject|hide|verify|unverify)$/);
if (pkgActionMatch && method === "POST") {
if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
return handleCommunityAction(request, env, user, pkgActionMatch[1], pkgActionMatch[2], pkgActionMatch[3]);
}
if (
path === "/v1/report" ||
path === "/v1/ping" ||
@@ -956,7 +1043,8 @@ export default {
path === "/logout" ||
path === "/account" ||
path.startsWith("/stats") ||
path.startsWith("/admin")
path.startsWith("/admin") ||
path.startsWith("/community")
) {
return new Response("method not allowed", { status: 405 });
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
View File
@@ -6,6 +6,7 @@
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
+18
View File
@@ -13,12 +13,23 @@ ADMIN_EMAILS = "[email protected]"
# page. Override for local dev.
ID_ORIGIN = "https://id.reasonix.io"
APP_ORIGIN = "https://reasonix.io"
# Browsers allowed to call the folded registry API with credentials.
ALLOWED_ORIGINS = "https://reasonix.io,https://www.reasonix.io"
[[d1_databases]]
binding = "DB"
database_name = "reasonix-crash"
database_id = "54825184-366f-4877-80fc-924efab0239b"
# Skill/MCP registry data plane, folded in from the standalone worker. Before
# merging (the crash worker auto-deploys on push to main-v2):
# wrangler d1 create reasonix-registry # paste the id below
# wrangler d1 execute reasonix-registry --remote --file=registry-schema.sql
[[d1_databases]]
binding = "REGISTRY_DB"
database_name = "reasonix-registry"
database_id = "PLACEHOLDER-run-wrangler-d1-create-reasonix-registry"
[[unsafe.bindings]]
name = "RATE_LIMITER"
type = "ratelimit"
@@ -39,3 +50,10 @@ name = "METRICS_LIMITER"
type = "ratelimit"
namespace_id = "1003"
simple = { limit = 30, period = 60 }
# Per-IP throttle for registry writes (publish / install-ping / star).
[[unsafe.bindings]]
name = "WRITE_LIMITER"
type = "ratelimit"
namespace_id = "3001"
simple = { limit = 30, period = 60 }
-1548
View File
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "reasonix-registry",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"db:apply:local": "wrangler d1 migrations apply reasonix-registry --local",
"db:apply:remote": "wrangler d1 migrations apply reasonix-registry --remote"
},
"dependencies": {
"hono": "^4.6.14",
"zod": "^3.25.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260601.0",
"typescript": "^5.8.0",
"wrangler": "^4.102.0"
},
"overrides": {
"esbuild": "0.28.1"
}
}
-38
View File
@@ -1,38 +0,0 @@
-- Local demo data for the registry. Apply to the LOCAL D1 only:
-- wrangler d1 execute reasonix-registry --local --file=seed.sql
DELETE FROM events;
DELETE FROM stars;
DELETE FROM package_versions;
DELETE FROM packages;
INSERT INTO packages
(kind, scope_handle, name, slug, summary, source, install_kind, repo_url, tags, latest_version, install_count, star_count, verified, publisher_id, created_at, updated_at)
VALUES
('skill','esengine','pr-review','esengine/pr-review','Adversarial multi-pass PR review with inline findings','https://github.com/esengine/reasonix-skills/pr-review','skill','https://github.com/esengine/reasonix-skills','review,git,quality','1.2.0',428,96,1,1,datetime('now','-40 days'),datetime('now','-3 days')),
('skill','esengine','go-test-gen','esengine/go-test-gen','Generate table-driven Go tests for the current package','https://github.com/esengine/reasonix-skills/go-test-gen','skill','https://github.com/esengine/reasonix-skills','go,testing','0.4.1',213,44,1,1,datetime('now','-22 days'),datetime('now','-6 days')),
('mcp','sivancola','feishu-notify','sivancola/feishu-notify','Send Feishu/Lark interactive cards from your agent','https://github.com/SivanCola/feishu-mcp/.mcp.json','mcp','https://github.com/SivanCola/feishu-mcp','feishu,notify,im','0.3.0',156,31,0,2,datetime('now','-14 days'),datetime('now','-2 days')),
('skill','huqiantao','commit-msg','huqiantao/commit-msg','Write conventional commit messages from the staged diff','https://raw.githubusercontent.com/HUQIANTAO/rx/main/commit-msg/SKILL.md','skill','https://github.com/HUQIANTAO/rx','git,commits','1.0.0',502,73,0,3,datetime('now','-9 days'),datetime('now','-1 day')),
('mcp','community','postgres','community/postgres','Query and inspect a Postgres database over MCP','npx -y @modelcontextprotocol/server-postgres','mcp','https://github.com/modelcontextprotocol/servers','database,sql,postgres','0.6.2',89,18,0,4,datetime('now','-5 days'),datetime('now','-5 days')),
('skill','gtc2080','changelog','gtc2080/changelog','Draft a CHANGELOG entry from merged PRs since the last tag','https://github.com/GTC2080/rx-skills/changelog','skill','https://github.com/GTC2080/rx-skills','release,docs','0.2.0',37,9,0,5,datetime('now','-2 days'),datetime('now','-2 days'));
INSERT INTO package_versions (package_id, version, source, content_hash, risk_level, created_at)
SELECT id, latest_version, source, '', 'low', updated_at FROM packages;
INSERT INTO events (type, package_id, actor_handle, summary, created_at)
SELECT 'publish', id, scope_handle, 'published ' || slug || '@' || latest_version, created_at FROM packages;
INSERT INTO events (type, package_id, actor_handle, summary, created_at) VALUES
((SELECT id FROM packages WHERE slug='huqiantao/commit-msg'),'milestone','huqiantao','huqiantao/commit-msg reached 500 installs',datetime('now','-6 hours')),
((SELECT id FROM packages WHERE slug='esengine/pr-review'),'star','sivancola','starred esengine/pr-review',datetime('now','-3 hours')),
((SELECT id FROM packages WHERE slug='gtc2080/changelog'),'publish','gtc2080','published gtc2080/[email protected]',datetime('now','-90 minutes'));
-- install pings over the last week to give the trending rank something to sort on
INSERT INTO events (type, package_id, actor_handle, summary, created_at)
SELECT 'install', p.id, '', 'installed ' || p.slug, datetime('now','-' || (abs(random()) % 6) || ' days')
FROM packages p, (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5);
-- a pending submission (moderation): must stay out of the public list/detail until approved
INSERT INTO packages
(kind, scope_handle, name, slug, summary, source, install_kind, tags, latest_version, status, publisher_id, created_at, updated_at)
VALUES
('skill','newbie','untested-skill','newbie/untested-skill','A freshly submitted skill awaiting review','https://example.com/untested/SKILL.md','skill','experimental','0.1.0','pending',9,datetime('now','-20 minutes'),datetime('now','-20 minutes'));
-5
View File
@@ -1,5 +0,0 @@
// Reasonix skill + MCP registry — publish, discover, and track installs for
// reasonix.io. The Hono app is itself the Workers fetch handler.
import app from "./app";
export default app;
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
-33
View File
@@ -1,33 +0,0 @@
# Skill + MCP registry for reasonix.io — publish, discover, and track installs.
# Identity is delegated to the account service (id.reasonix.io); this worker
# never holds SESSION_PEPPER. First deploy:
# wrangler d1 create reasonix-registry # paste database_id below
# wrangler d1 migrations apply reasonix-registry --remote
# wrangler deploy
name = "reasonix-registry"
main = "src/index.ts"
compatibility_date = "2026-06-01"
routes = [{ pattern = "registry.reasonix.io", custom_domain = true }]
[vars]
# Account service that resolves the session cookie / CLI bearer to an account.
ACCOUNTS_ORIGIN = "https://id.reasonix.io"
# Where the web frontend lives (canonical links back to package pages).
APP_ORIGIN = "https://reasonix.io"
# Browsers that may call this API with credentials (comma-separated).
ALLOWED_ORIGINS = "https://reasonix.io,https://www.reasonix.io"
[[d1_databases]]
binding = "DB"
database_name = "reasonix-registry"
# Fill in after `wrangler d1 create reasonix-registry`.
database_id = "PLACEHOLDER-run-wrangler-d1-create-reasonix-registry"
migrations_dir = "migrations"
# Per-IP throttle for writes (publish / install-ping / star).
[[unsafe.bindings]]
name = "WRITE_LIMITER"
type = "ratelimit"
namespace_id = "3001"
simple = { limit = 30, period = 60 }