feat: add routes/skills.ts
This commit is contained in:
parent
2805143fbb
commit
fc49b0eac8
1 changed files with 869 additions and 0 deletions
869
routes/skills.ts
Normal file
869
routes/skills.ts
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
/**
|
||||
* skills.agentify.help — VCAP Skills Repository
|
||||
*
|
||||
* A covenant-governed registry for agent skills and expert capabilities.
|
||||
* Every skill entry carries a SHA-256 content hash and VCAP attestation.
|
||||
* The hash is the integrity guarantee: any hidden line, hidden font, or
|
||||
* injected instruction breaks the hash and is detectable by anyone.
|
||||
*
|
||||
* Domain: skills.agentify.help (Bunny DNS CNAME → agentify.help → Fly.io)
|
||||
* Published under WellSpr.ing covenant. April 2026.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { createHash, createHmac } from "crypto";
|
||||
import { pool } from "../db";
|
||||
|
||||
const SIGNING_KEY = process.env.ODY_SIGNING_KEY_B64
|
||||
? Buffer.from(process.env.ODY_SIGNING_KEY_B64, "base64").toString("hex").slice(0, 64)
|
||||
: "skills-agentify-help-dev-key-placeholder-not-for-production";
|
||||
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
async function ensureSkillsTable() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS vcap_skill_registry (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
skill_type TEXT NOT NULL DEFAULT 'agent_skill',
|
||||
author_name TEXT NOT NULL,
|
||||
author_slug TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
vcap_scope TEXT NOT NULL DEFAULT 'skills:general:read:public',
|
||||
attestation_id TEXT NOT NULL DEFAULT '',
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
covenant_url TEXT NOT NULL DEFAULT 'https://wellspr.ing/constitution',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
registered_via TEXT NOT NULL DEFAULT 'web',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log("[skills.agentify.help] vcap_skill_registry table ready");
|
||||
}
|
||||
|
||||
// ── Slugify ───────────────────────────────────────────────────────────────────
|
||||
function toSlug(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").trim();
|
||||
}
|
||||
|
||||
// ── Hash + attestation ────────────────────────────────────────────────────────
|
||||
function sha256(content: string): string {
|
||||
return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function signAttestation(slug: string, contentHash: string, authorSlug: string, publishedAt: string): string {
|
||||
const payload = [slug, contentHash, authorSlug, publishedAt].join("|");
|
||||
return createHmac("sha256", SIGNING_KEY).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function buildAttestation(row: any) {
|
||||
return {
|
||||
vcap: "1.0",
|
||||
skill_slug: row.slug,
|
||||
skill_name: row.name,
|
||||
skill_type: row.skill_type,
|
||||
author_name: row.author_name,
|
||||
author_slug: row.author_slug,
|
||||
version: row.version,
|
||||
vcap_scope: row.vcap_scope,
|
||||
content_hash: row.content_hash,
|
||||
content_length: (row.content || "").length,
|
||||
covenant: row.covenant_url,
|
||||
published_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
||||
attestation_id: row.attestation_id,
|
||||
signed_by: "skills.agentify.help",
|
||||
signature: row.signature,
|
||||
verify_url: `https://skills.agentify.help/skills/${row.slug}/attest.json`,
|
||||
raw_url: `https://skills.agentify.help/skills/${row.slug}/raw`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
||||
const _rl = new Map<string, { n: number; t: number }>();
|
||||
function rateCheck(key: string, max: number, windowMs: number): boolean {
|
||||
const now = Date.now();
|
||||
const e = _rl.get(key);
|
||||
if (!e || now > e.t) { _rl.set(key, { n: 1, t: now + windowMs }); return true; }
|
||||
if (e.n >= max) return false;
|
||||
e.n++;
|
||||
return true;
|
||||
}
|
||||
setInterval(() => { const now = Date.now(); for (const [k, v] of _rl) if (now > v.t) _rl.delete(k); }, 600_000).unref();
|
||||
|
||||
// ── MCP tools ─────────────────────────────────────────────────────────────────
|
||||
const SK_MCP_TOOLS = [
|
||||
{
|
||||
name: "discover_skills",
|
||||
description: "Browse and search the VCAP skills repository. Filter by type, author, or tags. Returns skill summaries with content hashes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
q: { type: "string", description: "Search query (name, author, tags)" },
|
||||
skill_type: { type: "string", description: "Filter by type: agent_skill, persona_skill, domain_skill, craft_skill, civic_skill" },
|
||||
author_slug: { type: "string", description: "Filter by author slug" },
|
||||
limit: { type: "number", description: "Max results (default 20)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_skill",
|
||||
description: "Fetch a complete skill by slug. Returns full content, attestation, and SHA-256 hash for integrity verification.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["slug"],
|
||||
properties: {
|
||||
slug: { type: "string", description: "Skill slug" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "submit_skill",
|
||||
description: "Publish a new skill to the registry. The content is hashed on receipt and a VCAP attestation is generated. Returns the attestation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["name", "author_name", "content"],
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name" },
|
||||
summary: { type: "string", description: "One-sentence summary" },
|
||||
description: { type: "string", description: "Full description" },
|
||||
skill_type: { type: "string", description: "Type: agent_skill | persona_skill | domain_skill | craft_skill | civic_skill" },
|
||||
author_name: { type: "string", description: "Author's full name" },
|
||||
content: { type: "string", description: "The skill file content (plain text / Markdown)" },
|
||||
vcap_scope: { type: "string", description: "SGS scope string (e.g. skills:herbalism:read:public)" },
|
||||
tags: { type: "array", items: { type: "string" }, description: "Array of tags" },
|
||||
version: { type: "string", description: "Version string (default 1.0.0)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "verify_integrity",
|
||||
description: "Verify a skill file's integrity against its published attestation. Provide the content you hold — returns whether it matches the registry hash. Detects any tampering, hidden text, or injection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["slug", "content"],
|
||||
properties: {
|
||||
slug: { type: "string", description: "Skill slug to verify against" },
|
||||
content: { type: "string", description: "The skill file content you are holding" }
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── MCP tool handler ──────────────────────────────────────────────────────────
|
||||
async function handleSkillTool(name: string, args: any) {
|
||||
const text = (obj: any) => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] });
|
||||
|
||||
if (name === "discover_skills") {
|
||||
const limit = Math.min(Number(args.limit) || 20, 50);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id AS registration_number, slug, name, summary, skill_type, author_name, author_slug,
|
||||
content_hash, vcap_scope, tags, version, created_at
|
||||
FROM vcap_skill_registry
|
||||
WHERE status = 'active'
|
||||
AND ($1::text IS NULL OR name ILIKE '%' || $1 || '%' OR author_name ILIKE '%' || $1 || '%' OR $1 = ANY(tags))
|
||||
AND ($2::text IS NULL OR skill_type = $2)
|
||||
AND ($3::text IS NULL OR author_slug = $3)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4
|
||||
`, [args.q || null, args.skill_type || null, args.author_slug || null, limit]);
|
||||
return text({ skills: rows, count: rows.length });
|
||||
}
|
||||
|
||||
if (name === "get_skill") {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [args.slug]
|
||||
);
|
||||
if (!rows.length) {
|
||||
const e: any = new Error(`Skill "${args.slug}" not found`); e.code = -32602; throw e;
|
||||
}
|
||||
const row = rows[0];
|
||||
return text({ skill: { ...row, attestation: buildAttestation(row) } });
|
||||
}
|
||||
|
||||
if (name === "submit_skill") {
|
||||
const { name: skillName, author_name, content } = args;
|
||||
if (!skillName || !author_name || !content) {
|
||||
const e: any = new Error("name, author_name, and content are required"); e.code = -32602; throw e;
|
||||
}
|
||||
const slug = toSlug(skillName);
|
||||
const authorSlug = toSlug(author_name);
|
||||
const contentHash = sha256(content);
|
||||
const attestationId = `sk-${Date.now().toString(36)}-${slug.slice(0, 8)}`;
|
||||
const publishedAt = new Date().toISOString();
|
||||
const signature = signAttestation(slug, contentHash, authorSlug, publishedAt);
|
||||
const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO vcap_skill_registry
|
||||
(slug, name, summary, description, skill_type, author_name, author_slug,
|
||||
content, content_hash, vcap_scope, attestation_id, signature, tags, version, registered_via)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'mcp')
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
RETURNING *
|
||||
`, [
|
||||
slug, skillName,
|
||||
args.summary || skillName,
|
||||
args.description || "",
|
||||
args.skill_type || "agent_skill",
|
||||
author_name, authorSlug,
|
||||
content, contentHash,
|
||||
args.vcap_scope || "skills:general:read:public",
|
||||
attestationId, signature, tags,
|
||||
args.version || "1.0.0"
|
||||
]);
|
||||
|
||||
if (!rows.length) {
|
||||
return text({ error: "A skill with that name already exists.", slug });
|
||||
}
|
||||
return text({ registered: true, attestation: buildAttestation(rows[0]) });
|
||||
}
|
||||
|
||||
if (name === "verify_integrity") {
|
||||
const { slug, content } = args;
|
||||
if (!slug || content === undefined) {
|
||||
const e: any = new Error("slug and content are required"); e.code = -32602; throw e;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT content_hash, name, author_name, created_at FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [slug]
|
||||
);
|
||||
if (!rows.length) {
|
||||
const e: any = new Error(`Skill "${slug}" not found`); e.code = -32602; throw e;
|
||||
}
|
||||
const computedHash = sha256(content);
|
||||
const registryHash = rows[0].content_hash;
|
||||
const match = computedHash === registryHash;
|
||||
return text({
|
||||
slug,
|
||||
skill_name: rows[0].name,
|
||||
author_name: rows[0].author_name,
|
||||
published_at: rows[0].created_at,
|
||||
registry_hash: registryHash,
|
||||
computed_hash: computedHash,
|
||||
integrity: match ? "VERIFIED" : "TAMPERED",
|
||||
message: match
|
||||
? "The content you hold matches the published attestation. No hidden content detected."
|
||||
: "INTEGRITY FAILURE: The content does not match the published hash. This file has been modified."
|
||||
});
|
||||
}
|
||||
|
||||
const e: any = new Error(`Unknown tool: ${name}`); e.code = -32601; throw e;
|
||||
}
|
||||
|
||||
// ── Palette / styles (shared with agentify.help) ──────────────────────────────
|
||||
const SK_CSS = `
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#FDFAF4;color:#1A2B4A;margin:0;line-height:1.6}
|
||||
a{color:#C9962A;text-decoration:none}a:hover{text-decoration:underline}
|
||||
.nav{background:#1A2B4A;padding:.75rem 2rem;display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap}
|
||||
.nav-brand{color:#FDFAF4;font-weight:800;font-size:1.05rem;letter-spacing:-.01em}
|
||||
.nav-brand span{color:#C9962A}
|
||||
.nav a{color:rgba(253,250,244,.7);font-size:.88rem}
|
||||
.nav a:hover{color:#FDFAF4;text-decoration:none}
|
||||
.hero{background:#1A2B4A;color:#FDFAF4;padding:4rem 2rem 3.5rem}
|
||||
.hero-inner{max-width:760px;margin:0 auto}
|
||||
.hero-tag{display:inline-block;background:rgba(201,150,42,.2);color:#C9962A;border:1px solid rgba(201,150,42,.4);border-radius:4px;padding:.2rem .75rem;font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;font-weight:700;margin-bottom:1rem}
|
||||
.hero h1{font-size:clamp(1.8rem,4vw,2.8rem);font-weight:900;margin:0 0 .75rem;line-height:1.15}
|
||||
.hero p{font-size:1.05rem;color:rgba(253,250,244,.75);max-width:580px;margin:0}
|
||||
.hero-actions{margin-top:1.75rem;display:flex;gap:.75rem;flex-wrap:wrap}
|
||||
.btn{display:inline-block;padding:.6rem 1.4rem;border-radius:6px;font-weight:700;font-size:.88rem;cursor:pointer;border:none;text-decoration:none}
|
||||
.btn-gold{background:#C9962A;color:#1A2B4A}
|
||||
.btn-ghost{background:rgba(253,250,244,.1);color:#FDFAF4;border:1px solid rgba(253,250,244,.2)}
|
||||
.btn-ghost:hover{background:rgba(253,250,244,.18);text-decoration:none}
|
||||
.main{max-width:900px;margin:0 auto;padding:2.5rem 1.5rem}
|
||||
.section-title{font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;font-weight:700;color:#8A9099;margin-bottom:1.25rem}
|
||||
.skill-card{background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:.85rem;transition:box-shadow .15s}
|
||||
.skill-card:hover{box-shadow:0 4px 18px rgba(26,43,74,.1)}
|
||||
.skill-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}
|
||||
.skill-name{font-size:1rem;font-weight:700;color:#1A2B4A;margin:0}
|
||||
.skill-name a{color:#1A2B4A}
|
||||
.skill-type{display:inline-block;background:rgba(201,150,42,.12);color:#8A6A1A;border-radius:3px;padding:.15rem .55rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em}
|
||||
.skill-author{font-size:.85rem;color:#6A7A8A;margin:.35rem 0 0}
|
||||
.skill-summary{font-size:.9rem;color:#4A5568;margin:.5rem 0 0}
|
||||
.skill-meta{display:flex;align-items:center;gap:.75rem;margin-top:.75rem;flex-wrap:wrap}
|
||||
.tag-pill{background:#F0EDE4;color:#5A6B7A;border-radius:3px;padding:.15rem .55rem;font-size:.72rem}
|
||||
.hash-line{font-family:'SF Mono',monospace;font-size:.72rem;color:#8A9099;word-break:break-all}
|
||||
pre{background:#F0EDE4;border:1px solid rgba(26,43,74,.1);border-radius:8px;padding:1.25rem 1.5rem;overflow-x:auto;font-size:.82rem;white-space:pre-wrap;word-break:break-word}
|
||||
.verify-badge{display:inline-flex;align-items:center;gap:.4rem;padding:.3rem .8rem;border-radius:20px;font-size:.78rem;font-weight:700}
|
||||
.verify-ok{background:#E6F4EA;color:#1A7A3A;border:1px solid #A8D5B5}
|
||||
.footer{text-align:center;padding:2.5rem 1rem;font-size:.8rem;color:#8A9099;border-top:1px solid rgba(26,43,74,.08);margin-top:3rem}
|
||||
`;
|
||||
|
||||
const SK_NAV = `
|
||||
<nav class="nav">
|
||||
<span class="nav-brand"><span>skills</span>.agentify.help</span>
|
||||
<a href="https://skills.agentify.help/skills">Browse Skills</a>
|
||||
<a href="https://skills.agentify.help/submit">Submit a Skill</a>
|
||||
<a href="https://agentify.help">Agentify.Help</a>
|
||||
<a href="https://skills.agentify.help/.well-known/mcp.json">MCP</a>
|
||||
</nav>`;
|
||||
|
||||
const SK_FOOTER = `
|
||||
<footer class="footer">
|
||||
<p>skills.agentify.help · A <a href="https://wellspr.ing">WellSpr.ing</a> civic project · Governed by <a href="https://wellspr.ing/constitution">covenant</a></p>
|
||||
<p style="margin-top:.35rem">Every skill carries a SHA-256 hash. Verify at <a href="https://skills.agentify.help/verify">skills.agentify.help/verify</a></p>
|
||||
</footer>`;
|
||||
|
||||
function skPage(title: string, body: string) {
|
||||
return `<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<meta name="description" content="VCAP-attested agent skills repository. Every skill carries a SHA-256 hash — verify any file you receive in seconds.">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="Covenant-governed skills registry. Hash-attested. Tamper-evident.">
|
||||
<meta property="og:url" content="https://skills.agentify.help">
|
||||
<style>${SK_CSS}</style>
|
||||
</head><body>${SK_NAV}${body}${SK_FOOTER}</body></html>`;
|
||||
}
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────────
|
||||
export function registerSkillsAgentifyHelpRoutes(app: Express) {
|
||||
// Run DB setup in background — routes register synchronously so they
|
||||
// are in place before the Vite catch-all middleware is registered.
|
||||
ensureSkillsTable().catch(e => console.error("[skills.agentify.help] DB setup error:", e));
|
||||
|
||||
function isSkills(req: Request): boolean {
|
||||
const host = (req.headers["x-forwarded-host"] || req.headers.host || "")
|
||||
.toString().split(",")[0].trim().replace(/:\d+$/, "").toLowerCase();
|
||||
return host.startsWith("skills.");
|
||||
}
|
||||
|
||||
// ── Landing page ─────────────────────────────────────────────────────────
|
||||
app.get("/", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(`
|
||||
SELECT slug, name, summary, skill_type, author_name, tags, created_at
|
||||
FROM vcap_skill_registry WHERE status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 6
|
||||
`);
|
||||
const cards = rows.map(r => `
|
||||
<div class="skill-card">
|
||||
<div class="skill-card-header">
|
||||
<div>
|
||||
<p class="skill-name"><a href="https://skills.agentify.help/skills/${r.slug}">${r.name}</a></p>
|
||||
<p class="skill-author">by ${r.author_name}</p>
|
||||
</div>
|
||||
<span class="skill-type">${(r.skill_type || "").replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
${r.summary ? `<p class="skill-summary">${r.summary}</p>` : ""}
|
||||
<div class="skill-meta">
|
||||
${(r.tags || []).map((t: string) => `<span class="tag-pill">${t}</span>`).join("")}
|
||||
</div>
|
||||
</div>`).join("") || `<p style="color:#8A9099">No skills published yet. Be first.</p>`;
|
||||
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.send(skPage("skills.agentify.help — VCAP Skills Repository", `
|
||||
<div class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-tag">VCAP Skills Repository</div>
|
||||
<h1>Skills that carry<br>their own proof.</h1>
|
||||
<p>Every skill published here is SHA-256 hashed and VCAP-attested on receipt. The file you receive socially — in plain text, in a message, in an email — either matches its hash or it doesn't. Math doesn't lie. No hidden fonts. No injected instructions. No back doors passed between humans in good faith.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://skills.agentify.help/skills" class="btn btn-gold">Browse Skills</a>
|
||||
<a href="https://skills.agentify.help/submit" class="btn btn-ghost">Publish a Skill</a>
|
||||
<a href="https://skills.agentify.help/verify" class="btn btn-ghost">Verify a File</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<p class="section-title">Recently Published</p>
|
||||
${cards}
|
||||
${rows.length > 0 ? `<p style="margin-top:1.5rem"><a href="https://skills.agentify.help/skills">View all skills →</a></p>` : ""}
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.25rem;margin-top:3rem">
|
||||
<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;margin-bottom:.75rem">🔐</div>
|
||||
<div style="font-weight:700;margin-bottom:.5rem">Hash-attested integrity</div>
|
||||
<div style="font-size:.88rem;color:#4A5568">Every skill has a SHA-256 fingerprint. Verify any file you receive — from anyone, over any channel — in one step.</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;margin-bottom:.75rem">📜</div>
|
||||
<div style="font-weight:700;margin-bottom:.5rem">VCAP-governed conduct</div>
|
||||
<div style="font-size:.88rem;color:#4A5568">Each skill carries a scope grammar (SGS) defining what it is authorized to do. The covenant is the operating agreement.</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;margin-bottom:.75rem">🤝</div>
|
||||
<div style="font-weight:700;margin-bottom:.5rem">Social provenance</div>
|
||||
<div style="font-size:.88rem;color:#4A5568">Skills of notable persons — craftspeople, healers, teachers — can be published, shared, and verified across generations.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
|
||||
// ── Browse all skills ─────────────────────────────────────────────────────
|
||||
app.get("/skills", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const limit = 24;
|
||||
const offset = (page - 1) * limit;
|
||||
const type = req.query.type as string || null;
|
||||
const q = req.query.q as string || null;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id AS registration_number, slug, name, summary, skill_type, author_name, author_slug,
|
||||
content_hash, tags, version, created_at
|
||||
FROM vcap_skill_registry WHERE status = 'active'
|
||||
AND ($1::text IS NULL OR skill_type = $1)
|
||||
AND ($2::text IS NULL OR name ILIKE '%'||$2||'%' OR author_name ILIKE '%'||$2||'%')
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4
|
||||
`, [type, q, limit, offset]);
|
||||
|
||||
const typeOpts = ["agent_skill", "persona_skill", "domain_skill", "craft_skill", "civic_skill"];
|
||||
const filterBar = `<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1.75rem;align-items:center">
|
||||
${typeOpts.map(t => `<a href="?type=${t}" style="padding:.3rem .8rem;border-radius:20px;font-size:.78rem;font-weight:600;${type===t?"background:#1A2B4A;color:#FDFAF4":"background:#F0EDE4;color:#4A5568"}">${t.replace(/_/g," ")}</a>`).join("")}
|
||||
${type ? `<a href="/skills" style="padding:.3rem .8rem;border-radius:20px;font-size:.78rem;background:#F5E6C8;color:#8A6A1A;font-weight:600">× clear</a>` : ""}
|
||||
</div>`;
|
||||
|
||||
const cards = rows.map(r => `
|
||||
<div class="skill-card">
|
||||
<div class="skill-card-header">
|
||||
<div>
|
||||
<p class="skill-name"><a href="https://skills.agentify.help/skills/${r.slug}">${r.name}</a></p>
|
||||
<p class="skill-author">by ${r.author_name} · v${r.version} · #${r.registration_number}</p>
|
||||
</div>
|
||||
<span class="skill-type">${(r.skill_type||"").replace(/_/g," ")}</span>
|
||||
</div>
|
||||
${r.summary ? `<p class="skill-summary">${r.summary}</p>` : ""}
|
||||
<div class="skill-meta">
|
||||
${(r.tags||[]).map((t:string)=>`<span class="tag-pill">${t}</span>`).join("")}
|
||||
<span class="hash-line">${(r.content_hash||"").slice(0,30)}…</span>
|
||||
</div>
|
||||
</div>`).join("") || `<p style="color:#8A9099;padding:2rem 0">No skills found.</p>`;
|
||||
|
||||
res.setHeader("Cache-Control", "public, max-age=30");
|
||||
res.send(skPage("Browse Skills — skills.agentify.help", `
|
||||
<div class="hero" style="padding:2.5rem 2rem 2rem">
|
||||
<div class="hero-inner">
|
||||
<h1 style="font-size:1.8rem;margin-bottom:.5rem">Skills Registry</h1>
|
||||
<form method="get" action="/skills" style="display:flex;gap:.5rem;margin-top:.75rem">
|
||||
<input name="q" value="${q||""}" placeholder="Search by name or author…" style="flex:1;padding:.5rem .9rem;border-radius:6px;border:1px solid rgba(253,250,244,.2);background:rgba(253,250,244,.1);color:#FDFAF4;font-size:.9rem" data-testid="input-search-skills">
|
||||
<button type="submit" class="btn btn-gold" data-testid="button-search">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
${filterBar}
|
||||
<p class="section-title">${rows.length} skill${rows.length !== 1 ? "s" : ""} ${q ? `matching "${q}"` : type ? `— ${type.replace(/_/g," ")}` : "published"}</p>
|
||||
${cards}
|
||||
${rows.length === limit ? `<p style="margin-top:1.5rem"><a href="?page=${page+1}${type?`&type=${type}`:""}">Next page →</a></p>` : ""}
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
|
||||
// ── Individual skill page ─────────────────────────────────────────────────
|
||||
app.get("/skills/:slug", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [req.params.slug]
|
||||
);
|
||||
if (!rows.length) {
|
||||
res.status(404).send(skPage("Skill Not Found — skills.agentify.help", `<div class="main"><h2>Skill not found</h2><p><a href="/skills">Browse all skills →</a></p></div>`));
|
||||
return;
|
||||
}
|
||||
const r = rows[0];
|
||||
const attest = buildAttestation(r);
|
||||
const contentPreview = (r.content || "").slice(0, 2000) + ((r.content || "").length > 2000 ? "\n\n… (truncated — download raw for full content)" : "");
|
||||
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.send(skPage(`${r.name} — skills.agentify.help`, `
|
||||
<div class="hero" style="padding:2.5rem 2rem 2rem">
|
||||
<div class="hero-inner">
|
||||
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem;flex-wrap:wrap">
|
||||
<span class="skill-type">${(r.skill_type||"").replace(/_/g," ")}</span>
|
||||
<span style="color:rgba(253,250,244,.5);font-size:.85rem">v${r.version}</span>
|
||||
</div>
|
||||
<h1 style="font-size:clamp(1.4rem,3vw,2rem);margin-bottom:.5rem">${r.name}</h1>
|
||||
<p style="color:rgba(253,250,244,.7);font-size:.95rem">by ${r.author_name} · Published ${new Date(r.created_at).toLocaleDateString("en-US",{year:"numeric",month:"long",day:"numeric"})}</p>
|
||||
${r.summary ? `<p style="margin-top:.75rem;color:rgba(253,250,244,.85);font-size:1rem">${r.summary}</p>` : ""}
|
||||
<div style="display:flex;gap:.75rem;margin-top:1.25rem;flex-wrap:wrap">
|
||||
<a href="https://skills.agentify.help/skills/${r.slug}/raw" class="btn btn-gold" download data-testid="button-download-raw">Download Raw</a>
|
||||
<a href="https://skills.agentify.help/skills/${r.slug}/attest.json" class="btn btn-ghost" data-testid="link-attestation">Attestation JSON</a>
|
||||
<a href="https://skills.agentify.help/verify" class="btn btn-ghost" data-testid="link-verify">Verify a Copy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem;margin-bottom:1.5rem">
|
||||
<p class="section-title">Integrity</p>
|
||||
<span class="verify-badge verify-ok">✓ VCAP Attested</span>
|
||||
<p style="margin:.75rem 0 .35rem;font-size:.88rem;font-weight:600">SHA-256 Content Hash</p>
|
||||
<p class="hash-line" data-testid="text-content-hash">${r.content_hash}</p>
|
||||
<p style="margin:1rem 0 .35rem;font-size:.88rem;font-weight:600">VCAP Scope</p>
|
||||
<code style="font-size:.85rem;background:#F0EDE4;padding:3px 7px;border-radius:3px">${r.vcap_scope}</code>
|
||||
<p style="margin:1rem 0 .35rem;font-size:.88rem;font-weight:600">Attestation ID</p>
|
||||
<code style="font-size:.82rem;background:#F0EDE4;padding:3px 7px;border-radius:3px">${r.attestation_id}</code>
|
||||
</div>
|
||||
|
||||
${r.description ? `<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem;margin-bottom:1.5rem">
|
||||
<p class="section-title">Description</p>
|
||||
<p style="font-size:.95rem;color:#2A3B4A;white-space:pre-wrap">${r.description}</p>
|
||||
</div>` : ""}
|
||||
|
||||
<div style="background:#fff;border:1px solid rgba(26,43,74,.1);border-radius:10px;padding:1.5rem;margin-bottom:1.5rem">
|
||||
<p class="section-title">Skill Content</p>
|
||||
<p style="font-size:.8rem;color:#8A9099;margin-bottom:.75rem">This is the hashed content. Download the raw file and verify its SHA-256 matches the hash above.</p>
|
||||
<pre data-testid="pre-skill-content">${contentPreview.replace(/</g,"<").replace(/>/g,">")}</pre>
|
||||
</div>
|
||||
|
||||
${(r.tags||[]).length ? `<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1.5rem">
|
||||
${(r.tags||[]).map((t:string)=>`<span class="tag-pill">${t}</span>`).join("")}
|
||||
</div>` : ""}
|
||||
|
||||
<p style="font-size:.82rem;color:#8A9099">
|
||||
Governed by <a href="${r.covenant_url}">WellSpr.ing covenant</a> ·
|
||||
<a href="https://skills.agentify.help/skills">Browse all skills</a>
|
||||
</p>
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
|
||||
// ── Raw skill file ────────────────────────────────────────────────────────
|
||||
app.get("/skills/:slug/raw", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT name, content, content_hash, author_name, vcap_scope, attestation_id, created_at FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [req.params.slug]
|
||||
);
|
||||
if (!rows.length) return res.status(404).type("text/plain").send("Skill not found");
|
||||
const r = rows[0];
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${req.params.slug}.skill.txt"`);
|
||||
res.setHeader("X-Content-Hash", r.content_hash);
|
||||
res.setHeader("X-Attestation-Id", r.attestation_id);
|
||||
res.setHeader("X-VCAP-Scope", r.vcap_scope);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.send(r.content);
|
||||
});
|
||||
|
||||
// ── Attestation JSON ──────────────────────────────────────────────────────
|
||||
app.get("/skills/:slug/attest.json", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [req.params.slug]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Skill not found" });
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.json(buildAttestation(rows[0]));
|
||||
});
|
||||
|
||||
// ── Submit form (GET) ─────────────────────────────────────────────────────
|
||||
app.get("/submit", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.send(skPage("Publish a Skill — skills.agentify.help", `
|
||||
<div class="hero" style="padding:2.5rem 2rem 2rem">
|
||||
<div class="hero-inner">
|
||||
<h1 style="font-size:1.8rem">Publish a Skill</h1>
|
||||
<p style="color:rgba(253,250,244,.75)">Your content is hashed on receipt. A VCAP attestation is generated immediately. The hash is your integrity guarantee.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main" style="max-width:680px">
|
||||
<form method="post" action="/api/skills" id="submit-form">
|
||||
<div style="display:grid;gap:1.25rem">
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Skill name *</span>
|
||||
<input name="name" required placeholder="e.g. Sourdough Bread Baking by Martha Chen" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff" data-testid="input-skill-name">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Author full name *</span>
|
||||
<input name="author_name" required placeholder="Martha Chen" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff" data-testid="input-author-name">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Skill type</span>
|
||||
<select name="skill_type" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff" data-testid="select-skill-type">
|
||||
<option value="agent_skill">Agent Skill</option>
|
||||
<option value="persona_skill">Persona Skill</option>
|
||||
<option value="domain_skill">Domain Skill</option>
|
||||
<option value="craft_skill">Craft Skill</option>
|
||||
<option value="civic_skill">Civic Skill</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">One-sentence summary</span>
|
||||
<input name="summary" placeholder="What this skill enables in one sentence" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff" data-testid="input-summary">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">VCAP scope <span style="color:#8A9099;font-weight:400">(SGS string)</span></span>
|
||||
<input name="vcap_scope" placeholder="skills:general:read:public" value="skills:general:read:public" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff;font-family:monospace" data-testid="input-vcap-scope">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Tags <span style="color:#8A9099;font-weight:400">(comma-separated)</span></span>
|
||||
<input name="tags" placeholder="herbalism, healing, plants" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;background:#fff" data-testid="input-tags">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Skill content * <span style="color:#8A9099;font-weight:400">(plain text / Markdown)</span></span>
|
||||
<textarea name="content" required rows="14" placeholder="Write or paste the full skill file content here. This is what gets hashed." style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.88rem;font-family:'SF Mono',monospace;resize:vertical;background:#fff" data-testid="textarea-skill-content"></textarea>
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Full description <span style="color:#8A9099;font-weight:400">(optional)</span></span>
|
||||
<textarea name="description" rows="4" placeholder="Longer description of what this skill does and when to use it" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.88rem;resize:vertical;background:#fff" data-testid="textarea-description"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-gold" style="margin-top:1.5rem;padding:.75rem 2rem;font-size:.95rem" data-testid="button-submit-skill">Publish & Attest</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('submit-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const tags = (fd.get('tags')||'').split(',').map(t=>t.trim()).filter(Boolean);
|
||||
const body = { name: fd.get('name'), author_name: fd.get('author_name'), content: fd.get('content'),
|
||||
summary: fd.get('summary'), description: fd.get('description'),
|
||||
skill_type: fd.get('skill_type'), vcap_scope: fd.get('vcap_scope'), tags };
|
||||
const btn = document.querySelector('[data-testid="button-submit-skill"]');
|
||||
btn.textContent = 'Publishing…'; btn.disabled = true;
|
||||
const res = await fetch('/api/skills', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (data.slug) { window.location.href = '/skills/' + data.slug; }
|
||||
else { alert(data.error || 'Submission failed'); btn.textContent = 'Publish & Attest'; btn.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
|
||||
// ── Verify page ───────────────────────────────────────────────────────────
|
||||
app.get("/verify", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.send(skPage("Verify a Skill — skills.agentify.help", `
|
||||
<div class="hero" style="padding:2.5rem 2rem 2rem">
|
||||
<div class="hero-inner">
|
||||
<h1 style="font-size:1.8rem">Verify a Skill File</h1>
|
||||
<p style="color:rgba(253,250,244,.75)">Paste the content of any skill file you've received. We'll check it against the published hash. Any hidden line, hidden font, or injected instruction breaks the hash.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main" style="max-width:680px">
|
||||
<div id="verify-result" style="display:none;margin-bottom:1.5rem"></div>
|
||||
<div style="display:grid;gap:1.25rem">
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">Skill slug</span>
|
||||
<input id="v-slug" placeholder="sourdough-bread-baking-by-martha-chen" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.92rem;font-family:monospace" data-testid="input-verify-slug">
|
||||
</label>
|
||||
<label style="display:grid;gap:.4rem">
|
||||
<span style="font-weight:600;font-size:.88rem">File content you received</span>
|
||||
<textarea id="v-content" rows="14" placeholder="Paste the full skill file content here…" style="padding:.6rem .9rem;border-radius:6px;border:1px solid rgba(26,43,74,.2);font-size:.88rem;font-family:'SF Mono',monospace;resize:vertical" data-testid="textarea-verify-content"></textarea>
|
||||
</label>
|
||||
<button onclick="doVerify()" class="btn btn-gold" style="padding:.75rem 2rem;font-size:.95rem;width:fit-content" data-testid="button-verify">Verify Integrity</button>
|
||||
</div>
|
||||
<script>
|
||||
async function doVerify() {
|
||||
const slug = document.getElementById('v-slug').value.trim();
|
||||
const content = document.getElementById('v-content').value;
|
||||
const btn = document.querySelector('[data-testid="button-verify"]');
|
||||
btn.textContent = 'Verifying…'; btn.disabled = true;
|
||||
const res = await fetch('/api/skills/' + slug + '/verify', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ content })
|
||||
});
|
||||
const data = await res.json();
|
||||
const el = document.getElementById('verify-result');
|
||||
el.style.display = 'block';
|
||||
if (data.integrity === 'VERIFIED') {
|
||||
el.innerHTML = '<div class="verify-badge verify-ok" style="font-size:.95rem;padding:.6rem 1.25rem">✓ VERIFIED — ' + data.message + '</div>';
|
||||
} else {
|
||||
el.innerHTML = '<div class="verify-badge" style="background:#FEE;color:#C00;border:1px solid #F5C6C6;font-size:.95rem;padding:.6rem 1.25rem">✗ INTEGRITY FAILURE — ' + data.message + '</div>';
|
||||
}
|
||||
btn.textContent = 'Verify Integrity'; btn.disabled = false;
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
`));
|
||||
});
|
||||
|
||||
// ── API: submit skill ─────────────────────────────────────────────────────
|
||||
app.post("/api/skills", express.json({ limit: "2mb" }), async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const ip = (req.headers["x-forwarded-for"] as string || req.socket.remoteAddress || "unknown").split(",")[0].trim();
|
||||
if (!rateCheck(`submit:${ip}`, 10, 3_600_000)) return res.status(429).json({ error: "Rate limit exceeded." });
|
||||
|
||||
const { name, author_name, content } = req.body || {};
|
||||
if (!name || !author_name || !content) return res.status(400).json({ error: "name, author_name, and content are required." });
|
||||
if (content.length > 500_000) return res.status(400).json({ error: "Content too large (max 500 KB)." });
|
||||
|
||||
const slug = toSlug(name);
|
||||
if (!slug) return res.status(400).json({ error: "Could not generate a valid slug from that name." });
|
||||
const authorSlug = toSlug(author_name);
|
||||
const contentHash = sha256(content);
|
||||
const attestationId = `sk-${Date.now().toString(36)}-${slug.slice(0, 8)}`;
|
||||
const publishedAt = new Date().toISOString();
|
||||
const signature = signAttestation(slug, contentHash, authorSlug, publishedAt);
|
||||
const tags = Array.isArray(req.body.tags) ? req.body.tags.map(String) : [];
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO vcap_skill_registry
|
||||
(slug, name, summary, description, skill_type, author_name, author_slug,
|
||||
content, content_hash, vcap_scope, attestation_id, signature, tags, version, registered_via)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'web')
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
RETURNING id AS registration_number, slug, name, content_hash, attestation_id
|
||||
`, [
|
||||
slug, name,
|
||||
req.body.summary || name,
|
||||
req.body.description || "",
|
||||
req.body.skill_type || "agent_skill",
|
||||
author_name, authorSlug,
|
||||
content, contentHash,
|
||||
req.body.vcap_scope || "skills:general:read:public",
|
||||
attestationId, signature, tags,
|
||||
req.body.version || "1.0.0"
|
||||
]);
|
||||
|
||||
if (!rows.length) return res.status(409).json({ error: "A skill with that name already exists.", slug });
|
||||
res.status(201).json({ registered: true, slug: rows[0].slug, registration_number: rows[0].registration_number, content_hash: rows[0].content_hash, attestation_id: rows[0].attestation_id });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: list skills ──────────────────────────────────────────────────────
|
||||
app.get("/api/skills", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id AS registration_number, slug, name, summary, skill_type, author_name, author_slug,
|
||||
content_hash, vcap_scope, tags, version, created_at
|
||||
FROM vcap_skill_registry WHERE status = 'active'
|
||||
ORDER BY created_at DESC LIMIT $1
|
||||
`, [limit]);
|
||||
res.setHeader("Cache-Control", "public, max-age=30");
|
||||
res.json({ skills: rows, count: rows.length });
|
||||
});
|
||||
|
||||
// ── API: get skill ────────────────────────────────────────────────────────
|
||||
app.get("/api/skills/:slug", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [req.params.slug]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Skill not found" });
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.json({ skill: rows[0], attestation: buildAttestation(rows[0]) });
|
||||
});
|
||||
|
||||
// ── API: verify integrity ─────────────────────────────────────────────────
|
||||
app.post("/api/skills/:slug/verify", express.json({ limit: "2mb" }), async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { content } = req.body || {};
|
||||
if (content === undefined) return res.status(400).json({ error: "content is required" });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT content_hash, name, author_name, created_at FROM vcap_skill_registry WHERE slug = $1 AND status = 'active'`, [req.params.slug]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Skill not found" });
|
||||
const computedHash = sha256(content);
|
||||
const registryHash = rows[0].content_hash;
|
||||
const match = computedHash === registryHash;
|
||||
res.json({
|
||||
slug: req.params.slug,
|
||||
skill_name: rows[0].name,
|
||||
author_name: rows[0].author_name,
|
||||
published_at: rows[0].created_at,
|
||||
registry_hash: registryHash,
|
||||
computed_hash: computedHash,
|
||||
integrity: match ? "VERIFIED" : "TAMPERED",
|
||||
message: match
|
||||
? "Content matches the published attestation. No tampering detected."
|
||||
: "Content does NOT match the published hash. This file has been modified."
|
||||
});
|
||||
});
|
||||
|
||||
// ── MCP endpoint ──────────────────────────────────────────────────────────
|
||||
const SK_MCP_DISCOVERY = {
|
||||
mcpVersion: "2025-03-26",
|
||||
name: "skills-agentify-help",
|
||||
displayName: "VCAP Skills Repository",
|
||||
description: "Covenant-governed registry of VCAP-attested agent skills. Every skill carries a SHA-256 content hash — verify any file you receive. Tools: discover_skills, get_skill, submit_skill, verify_integrity.",
|
||||
transport: [{ type: "http", url: "https://skills.agentify.help/mcp" }],
|
||||
capabilities: { tools: {} },
|
||||
contact: { email: "ody@wellspr.ing", url: "https://skills.agentify.help" },
|
||||
legal: { termsOfService: "https://wellspr.ing/constitution" }
|
||||
};
|
||||
|
||||
app.get("/.well-known/mcp.json", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.json(SK_MCP_DISCOVERY);
|
||||
});
|
||||
|
||||
app.get("/.well-known/ai-plugin.json", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.json({
|
||||
schema_version: "v1", name_for_human: "VCAP Skills Repo", name_for_model: "vcap_skills_repo",
|
||||
description_for_human: "Browse and verify covenant-attested agent skills.", description_for_model: "Search, retrieve, submit, and verify VCAP-attested skills. Use verify_integrity to check any skill file for hidden content or tampering.",
|
||||
auth: { type: "none" }, api: { type: "openapi", url: "https://skills.agentify.help/.well-known/openapi.json" },
|
||||
contact_email: "ody@wellspr.ing", legal_info_url: "https://wellspr.ing/constitution"
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/robots.txt", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.type("text/plain").send("User-agent: *\nAllow: /\nSitemap: https://skills.agentify.help/sitemap.xml\n");
|
||||
});
|
||||
|
||||
app.get("/sitemap.xml", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(`SELECT slug, created_at FROM vcap_skill_registry WHERE status = 'active' ORDER BY created_at DESC`);
|
||||
const urls = [
|
||||
`<url><loc>https://skills.agentify.help/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`,
|
||||
`<url><loc>https://skills.agentify.help/skills</loc><changefreq>daily</changefreq><priority>0.9</priority></url>`,
|
||||
`<url><loc>https://skills.agentify.help/submit</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`,
|
||||
`<url><loc>https://skills.agentify.help/verify</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`,
|
||||
...rows.map(r => `<url><loc>https://skills.agentify.help/skills/${r.slug}</loc><lastmod>${new Date(r.created_at).toISOString().slice(0,10)}</lastmod><priority>0.8</priority></url>`)
|
||||
];
|
||||
res.type("application/xml").send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`);
|
||||
});
|
||||
|
||||
app.get("/api/feed.json", async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const { rows } = await pool.query(`SELECT slug, name, summary, skill_type, author_name, content_hash, created_at FROM vcap_skill_registry WHERE status = 'active' ORDER BY created_at DESC LIMIT 50`);
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.json({ feed: rows, count: rows.length, updated: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.post("/mcp", express.json({ limit: "1mb" }), async (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
const ip = (req.headers["x-forwarded-for"] as string || req.socket.remoteAddress || "unknown").split(",")[0].trim();
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== "object") {
|
||||
return res.status(400).json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
||||
}
|
||||
const { method, params, id } = body;
|
||||
const ok = (result: any) => res.json({ jsonrpc: "2.0", id: id ?? null, result });
|
||||
const err = (code: number, message: string) => res.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
switch (method) {
|
||||
case "initialize": return ok({ protocolVersion: "2025-03-26", serverInfo: { name: "skills-agentify-help", version: "1.0.0" }, capabilities: { tools: {} } });
|
||||
case "notifications/initialized": return res.status(204).end();
|
||||
case "ping": return ok({});
|
||||
case "tools/list": return ok({ tools: SK_MCP_TOOLS });
|
||||
case "tools/call": {
|
||||
const toolName = params?.name as string;
|
||||
const args = params?.arguments ?? {};
|
||||
if (!toolName) return err(-32602, "params.name is required");
|
||||
if (toolName === "submit_skill" && !rateCheck(`mcp:${ip}`, 10, 3_600_000)) {
|
||||
return ok({ content: [{ type: "text", text: JSON.stringify({ error: "Rate limit exceeded." }) }], isError: true });
|
||||
}
|
||||
try {
|
||||
const result = await handleSkillTool(toolName, args);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
if (e?.code) return err(e.code, e.message);
|
||||
return err(-32603, e?.message || "Internal error");
|
||||
}
|
||||
}
|
||||
default: return err(-32601, `Method not found: ${method}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/mcp", (req: Request, res: Response, next) => {
|
||||
if (!isSkills(req)) return next();
|
||||
res.json(SK_MCP_DISCOVERY);
|
||||
});
|
||||
|
||||
console.log("[skills.agentify.help] Routes registered");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue