From 54aa8f34bdaf258d6ce7a6dfac5897135d8f15db Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:54:33 +0530 Subject: [PATCH 1/2] feat: implement embeddable SVG badges, credential revocation, and audit logging (Phase 4) --- src/app/api/admin/credentials/revoke/route.ts | 82 ++++++++++++++++ .../badges/credential/[id]/badge.svg/route.ts | 37 +++++++ src/app/verify/[id]/BadgeSnippet.tsx | 60 ++++++++++++ src/app/verify/[id]/RevokeButton.tsx | 97 +++++++++++++++++++ src/app/verify/[id]/page.tsx | 51 ++++++++-- src/lib/credentials/badge.test.ts | 36 +++++++ src/lib/credentials/badge.ts | 34 +++++++ src/lib/credentials/revocation.test.ts | 90 +++++++++++++++++ 8 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 src/app/api/admin/credentials/revoke/route.ts create mode 100644 src/app/api/badges/credential/[id]/badge.svg/route.ts create mode 100644 src/app/verify/[id]/BadgeSnippet.tsx create mode 100644 src/app/verify/[id]/RevokeButton.tsx create mode 100644 src/lib/credentials/badge.test.ts create mode 100644 src/lib/credentials/badge.ts create mode 100644 src/lib/credentials/revocation.test.ts diff --git a/src/app/api/admin/credentials/revoke/route.ts b/src/app/api/admin/credentials/revoke/route.ts new file mode 100644 index 0000000..1c4d716 --- /dev/null +++ b/src/app/api/admin/credentials/revoke/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { getDb, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +const revokeSchema = z.object({ + credentialId: z.string().min(1, "Credential ID is required"), + reason: z.string().min(5, "Reason must be at least 5 characters long"), +}); + +export async function POST(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser || sessionUser.role !== "admin") { + return NextResponse.json( + { error: "Forbidden. Administrative privileges required." }, + { status: 403 } + ); + } + + try { + const body = await request.json(); + const data = revokeSchema.parse(body); + + const db = await getDb(); + + // Verify credential exists + const credRecords = await db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.id, data.credentialId)) + .limit(1); + + if (credRecords.length === 0) { + return NextResponse.json({ error: "Credential not found" }, { status: 404 }); + } + + const cred = credRecords[0]; + + // Revoke credential + await db + .update(schema.credentials) + .set({ + status: "revoked", + revokedReason: data.reason, + }) + .where(eq(schema.credentials.id, data.credentialId)); + + // Create immutable audit log + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: sessionUser.id, + action: "credential.revoked", + targetType: "credential", + targetId: data.credentialId, + metadata: { + revokedBy: sessionUser.githubUsername, + reason: data.reason, + targetUserId: cred.userId, + credentialType: cred.type, + }, + }); + + return NextResponse.json({ + success: true, + message: `Credential ${data.credentialId} has been revoked.`, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }); + } + return NextResponse.json( + { error: error.message || "Failed to revoke credential" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/badges/credential/[id]/badge.svg/route.ts b/src/app/api/badges/credential/[id]/badge.svg/route.ts new file mode 100644 index 0000000..da9345f --- /dev/null +++ b/src/app/api/badges/credential/[id]/badge.svg/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { generateCredentialSvgBadge } from "@/lib/credentials/badge"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const db = await getDb(); + + const credRecords = await db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.id, id)) + .limit(1); + + let svg: string; + if (credRecords.length === 0) { + svg = generateCredentialSvgBadge("Unverified", "revoked"); + } else { + const cred = credRecords[0]; + svg = generateCredentialSvgBadge( + cred.title.split("—")[0].trim(), + cred.status as "active" | "revoked" + ); + } + + return new NextResponse(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }); +} diff --git a/src/app/verify/[id]/BadgeSnippet.tsx b/src/app/verify/[id]/BadgeSnippet.tsx new file mode 100644 index 0000000..ad8c8f9 --- /dev/null +++ b/src/app/verify/[id]/BadgeSnippet.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; + +export function BadgeSnippet({ + credentialId, + title, + appUrl, +}: { + credentialId: string; + title: string; + appUrl: string; +}) { + const [copied, setCopied] = useState(false); + + const snippet = `[![TechNexusOrg — ${title}](${appUrl}/api/badges/credential/${credentialId}/badge.svg)](${appUrl}/verify/${credentialId})`; + + const handleCopy = () => { + navigator.clipboard.writeText(snippet); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+

+ Embed on GitHub Profile README +

+

+ Showcase this verified proof of work on your personal GitHub README. +

+
+ +
+ +
+ + {snippet} + +
+ +
+ Live Badge Preview: + Badge Preview +
+
+ ); +} diff --git a/src/app/verify/[id]/RevokeButton.tsx b/src/app/verify/[id]/RevokeButton.tsx new file mode 100644 index 0000000..414ff89 --- /dev/null +++ b/src/app/verify/[id]/RevokeButton.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export function RevokeButton({ credentialId }: { credentialId: string }) { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleRevoke = async () => { + if (!reason.trim() || reason.length < 5) { + setError("Please provide a legitimate justification (min 5 chars)."); + return; + } + + setSubmitting(true); + setError(null); + + try { + const res = await fetch("/api/admin/credentials/revoke", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credentialId, reason }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Failed to revoke credential"); + } + + setIsOpen(false); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+

+ Administrative Revocation Action +

+

+ This action is permanent and will be logged in the immutable audit registry. +

+ + {error && ( +
+ {error} +
+ )} + + setReason(e.target.value)} + placeholder="Reason for revocation (e.g. PR reverted, plagiarized code)" + className="w-full rounded border border-red-500/30 bg-slate-900 px-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none font-mono" + /> + +
+ + +
+
+ ); +} diff --git a/src/app/verify/[id]/page.tsx b/src/app/verify/[id]/page.tsx index adfe445..c660e2e 100644 --- a/src/app/verify/[id]/page.tsx +++ b/src/app/verify/[id]/page.tsx @@ -1,7 +1,11 @@ import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; import Link from "next/link"; +import { cookies } from "next/headers"; import type { Metadata } from "next"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { BadgeSnippet } from "./BadgeSnippet"; +import { RevokeButton } from "./RevokeButton"; export const dynamic = "force-dynamic"; @@ -23,6 +27,10 @@ export default async function VerifyCredentialPage({ params: Promise<{ id: string }>; }) { const { id } = await params; + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + const sessionUser = token ? await verifySessionToken(token) : null; + const db = await getDb(); const credRecords = await db @@ -67,6 +75,7 @@ export default async function VerifyCredentialPage({ const holder = holderUsers[0]; const evidence = credential.evidenceData as any; const isRevoked = credential.status === "revoked"; + const appUrl = process.env.APP_URL || "http://localhost:3000"; return (
@@ -74,7 +83,7 @@ export default async function VerifyCredentialPage({
@@ -83,7 +92,7 @@ export default async function VerifyCredentialPage({
+ {/* If Revoked Banner */} + {isRevoked && ( +
+
+ Notice of Revocation +
+

+ {credential.revokedReason || "This credential was revoked by repository maintainers."} +

+
+ )} + {/* Holder & Issuer */}
@@ -125,14 +146,12 @@ export default async function VerifyCredentialPage({
{holder?.displayName || holder?.githubUsername}
- - @{holder?.githubUsername} - + @{holder?.githubUsername} (Passport ↗) +
@@ -192,7 +211,7 @@ export default async function VerifyCredentialPage({
-
+
Inspect PR on GitHub → + + {/* Admin safety control */} + {sessionUser?.role === "admin" && !isRevoked && ( + + )}
+ {/* Embeddable Badge Snippet */} + {!isRevoked && ( + + )} + {/* Anti-certificate mill disclaimer */}

TechNexusOrg credentials represent verifiable open-source engineering work. This record is linked to public GitHub contributions. diff --git a/src/lib/credentials/badge.test.ts b/src/lib/credentials/badge.test.ts new file mode 100644 index 0000000..d01531d --- /dev/null +++ b/src/lib/credentials/badge.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { generateCredentialSvgBadge } from "./badge"; + +describe("Credential SVG Badge Generator", () => { + it("generates active credential badge with correct colors and text", () => { + const svg = generateCredentialSvgBadge("First PR Merged", "active"); + + expect(svg).toContain(" { + const svg = generateCredentialSvgBadge("Core Contributor", "revoked"); + + expect(svg).toContain("Core Contributor (Revoked)"); + expect(svg).toContain("#b91c1c"); // red-700 + }); + + it("calculates proportional width based on title length", () => { + const shortSvg = generateCredentialSvgBadge("Short", "active"); + const longSvg = generateCredentialSvgBadge("Super Long Extended Title Credential", "active"); + + const shortWidthMatch = shortSvg.match(/width="(\d+)"/); + const longWidthMatch = longSvg.match(/width="(\d+)"/); + + expect(shortWidthMatch).not.toBeNull(); + expect(longWidthMatch).not.toBeNull(); + + const shortWidth = parseInt(shortWidthMatch![1], 10); + const longWidth = parseInt(longWidthMatch![1], 10); + + expect(longWidth).toBeGreaterThan(shortWidth); + }); +}); diff --git a/src/lib/credentials/badge.ts b/src/lib/credentials/badge.ts new file mode 100644 index 0000000..96c1acc --- /dev/null +++ b/src/lib/credentials/badge.ts @@ -0,0 +1,34 @@ +export function generateCredentialSvgBadge( + title: string, + status: "active" | "revoked" = "active" +): string { + const leftText = "TechNexusOrg"; + const rightText = status === "revoked" ? `${title} (Revoked)` : `${title} ✓`; + + const leftCharWidth = 7.5; + const rightCharWidth = 7.8; + const padding = 16; + + const leftWidth = Math.round(leftText.length * leftCharWidth + padding); + const rightWidth = Math.round(rightText.length * rightCharWidth + padding); + const totalWidth = leftWidth + rightWidth; + + const leftColor = "#0f172a"; // slate-900 + const rightColor = status === "revoked" ? "#b91c1c" : "#0284c7"; // red-700 or sky-600 + + return ` + ${leftText}: ${rightText} + + + + + + + + + + ${leftText} + ${rightText} + +`; +} diff --git a/src/lib/credentials/revocation.test.ts b/src/lib/credentials/revocation.test.ts new file mode 100644 index 0000000..f5383ef --- /dev/null +++ b/src/lib/credentials/revocation.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { eq } from "drizzle-orm"; + +describe("Credential Revocation & Audit Logging", () => { + const credId = "cred_tn_test_to_revoke_001"; + const userId = "usr_revocation_test_user"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + + const db = await getDb(); + + // Seed test user + await db.insert(schema.users).values({ + id: userId, + githubId: 77889900, + githubUsername: "revocation_target", + displayName: "Target User", + role: "contributor", + level: "contributor", + isOnboarded: true, + }); + + // Seed test credential + await db.insert(schema.credentials).values({ + id: credId, + userId, + type: "first_pr_merged", + title: "First PR Merged — Open Source", + description: "Test credential to be revoked", + status: "active", + issuer: "TechNexusOrg", + evidenceData: { prNumber: 99, repo: "TechNexusOrg/platform" }, + verificationUrl: `http://localhost:3000/verify/${credId}`, + }); + }); + + it("revokes active credential and records audit log", async () => { + const db = await getDb(); + const adminActorId = "usr_admin_001"; + const revocationReason = "Pull request discovered to be plagiarized"; + + // 1. Perform revocation + await db + .update(schema.credentials) + .set({ + status: "revoked", + revokedReason: revocationReason, + }) + .where(eq(schema.credentials.id, credId)); + + // 2. Insert audit log + const auditId = `audit_${crypto.randomUUID()}`; + await db.insert(schema.auditLogs).values({ + id: auditId, + actorId: adminActorId, + action: "credential.revoked", + targetType: "credential", + targetId: credId, + metadata: { + revokedBy: "admin", + reason: revocationReason, + targetUserId: userId, + }, + }); + + // 3. Verify status changed to revoked + const updatedCred = await db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.id, credId)); + + expect(updatedCred).toHaveLength(1); + expect(updatedCred[0].status).toBe("revoked"); + expect(updatedCred[0].revokedReason).toBe(revocationReason); + + // 4. Verify audit log entry exists + const auditEntries = await db + .select() + .from(schema.auditLogs) + .where(eq(schema.auditLogs.id, auditId)); + + expect(auditEntries).toHaveLength(1); + expect(auditEntries[0].action).toBe("credential.revoked"); + expect(auditEntries[0].targetId).toBe(credId); + }); +}); From 7048f6447b1b096cb19d5eb3a31d230eadd2dd96 Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:57:15 +0530 Subject: [PATCH 2/2] fix(test): seed admin actor in revocation test to satisfy FK constraint --- src/lib/credentials/revocation.test.ts | 31 +++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/lib/credentials/revocation.test.ts b/src/lib/credentials/revocation.test.ts index f5383ef..0a5dbff 100644 --- a/src/lib/credentials/revocation.test.ts +++ b/src/lib/credentials/revocation.test.ts @@ -13,16 +13,27 @@ describe("Credential Revocation & Audit Logging", () => { const db = await getDb(); - // Seed test user - await db.insert(schema.users).values({ - id: userId, - githubId: 77889900, - githubUsername: "revocation_target", - displayName: "Target User", - role: "contributor", - level: "contributor", - isOnboarded: true, - }); + // Seed test users + await db.insert(schema.users).values([ + { + id: userId, + githubId: 77889900, + githubUsername: "revocation_target", + displayName: "Target User", + role: "contributor", + level: "contributor", + isOnboarded: true, + }, + { + id: "usr_admin_001", + githubId: 11223344, + githubUsername: "admin_user", + displayName: "Admin User", + role: "admin", + level: "maintainer", + isOnboarded: true, + }, + ]); // Seed test credential await db.insert(schema.credentials).values({