From 6f67becec9ec4dfb06ca0a7a765abc457b060f5a Mon Sep 17 00:00:00 2001 From: Drew Jacobs Date: Sat, 14 Mar 2026 07:36:13 +0900 Subject: [PATCH] Add certification review workflow and dashboards --- README.md | 4 +- index.html | 4 +- openapi.json | 175 ++++++++- src/app.ts | 4 + src/db/certificationQueries.ts | 165 +++++++- src/db/schema.ts | 37 ++ src/routes/certification.ts | 73 ++++ src/routes/directory.ts | 19 + src/routes/legal.ts | 2 + src/routes/reviewer.ts | 12 + src/services/certificationService.ts | 555 ++++++++++++++++++++++++++- src/services/discoveryService.ts | 23 +- src/templates/agentProfile.ts | 2 +- src/templates/certify.ts | 139 ++++++- src/templates/directory.ts | 410 ++++++++++++++++++++ src/templates/pricing.ts | 2 +- src/templates/reviewer.ts | 374 ++++++++++++++++++ tests/db-schema.test.ts | 10 +- tests/routes/certification.test.ts | 501 +++++++++++++++++++++++- tests/routes/certifyPage.test.ts | 2 + tests/routes/directoryPage.test.ts | 25 ++ tests/routes/legal.test.ts | 2 + tests/routes/openapi.test.ts | 57 +++ tests/routes/pricing.test.ts | 2 +- tests/routes/reviewerPage.test.ts | 21 + tests/routes/wellKnown.test.ts | 3 + 26 files changed, 2582 insertions(+), 41 deletions(-) create mode 100644 src/routes/directory.ts create mode 100644 src/routes/reviewer.ts create mode 100644 src/templates/directory.ts create mode 100644 src/templates/reviewer.ts create mode 100644 tests/routes/directoryPage.test.ts create mode 100644 tests/routes/reviewerPage.test.ts diff --git a/README.md b/README.md index f119722..ce19e41 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,9 @@ The scoring engine indexes x402 settlements on-chain using the EIP-3009 `Authori | `/v1/score/basic?wallet=0x…` | GET | Score, tier, confidence. 10 free calls/day. | | `/v1/score/erc8004?wallet=0x…` | GET | ERC-8004-compatible reputation document with score, identity, certification, and publication status. | | `/v1/certification/readiness?wallet=0x…` | GET | Check if a wallet can apply for certification, see blockers, and get the next step before paying. | -| `/v1/certification/directory` | GET | Public directory of active certifications with score context and evaluator/standards links. | +| `/v1/certification/review` | GET / POST | Submit a certification review request or inspect the latest reviewer status for a wallet. | +| `/v1/certification/directory?limit=&tier=&search=&sort=` | GET | Public directory of active certifications with score context, trust links, and search/sort filters. | +| `/directory` | GET | Trusted Endpoint Directory page for browsing certified agents in the browser. | | `/v1/agent/register` | POST | Register your wallet. +10 identity bonus. | | `/v1/score/compute` | POST | Queue background score computation. Returns jobId immediately. | | `/v1/score/job/:jobId` | GET | Poll async job status (pending → complete). | diff --git a/index.html b/index.html index ecf0e99..d8ab06e 100644 --- a/index.html +++ b/index.html @@ -335,7 +335,7 @@

Score, certify, and evaluate agent wallets before money moves.

DJD is evolving from a score API into trust infrastructure for the agent economy: free wallet scoring, public certification, standards-ready documents, evaluator previews, and x402 gating in one stack. Start with the score, then layer on directory distribution and settlement policy.

Try the live lookup - Browse certified agents + Browse certified agents See the x402 path
@@ -504,7 +504,7 @@

Register your agent

Browse the certified directory

List active DJD-certified agents with current score tier, confidence, badges, profile metadata, and direct evaluator links.

GET /v1/certification/directory
- Open the directory + Open the directory
2
diff --git a/openapi.json b/openapi.json index 7cc431a..951abb5 100644 --- a/openapi.json +++ b/openapi.json @@ -1980,6 +1980,72 @@ } } }, + "/v1/certification/review": { + "get": { + "tags": ["certification"], + "summary": "Get certification review status", + "description": "Returns the latest certification review request and reviewer status for a wallet.", + "operationId": "getCertificationReview", + "parameters": [ + { + "name": "wallet", + "in": "query", + "required": true, + "description": "Ethereum wallet address", + "schema": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" } + } + ], + "responses": { + "200": { + "description": "Certification review returned", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CertificationReviewResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "post": { + "tags": ["certification"], + "summary": "Submit certification review request", + "description": "Creates a certification review request for an eligible wallet before the final certification purchase.", + "operationId": "submitCertificationReview", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CertificationReviewRequest" } + } + } + }, + "responses": { + "200": { + "description": "Existing pending certification review returned", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CertificationReviewResponse" } + } + } + }, + "201": { + "description": "Certification review request created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CertificationReviewResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "409": { + "description": "Wallet already has an active certification", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + } + } + }, "/v1/certification/directory": { "get": { "tags": ["certification"], @@ -2000,6 +2066,24 @@ "required": false, "description": "Filter to a specific certification tier", "schema": { "type": "string" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Filter by wallet, profile name, description, GitHub URL, or website URL", + "schema": { "type": "string" } + }, + { + "name": "sort", + "in": "query", + "required": false, + "description": "Sort the directory by score, confidence, recency, or name", + "schema": { + "type": "string", + "enum": ["score", "confidence", "recent", "name"], + "default": "score" + } } ], "responses": { @@ -3529,7 +3613,11 @@ "not_registered", "score_missing", "score_expired", - "score_too_low" + "score_too_low", + "review_pending", + "review_approved", + "review_needs_info", + "review_rejected" ] }, "requirements": { @@ -3561,6 +3649,21 @@ "granted_at": { "type": "string", "format": "date-time", "nullable": true }, "expires_at": { "type": "string", "format": "date-time", "nullable": true } } + }, + "review": { + "type": "object", + "properties": { + "exists": { "type": "boolean" }, + "status": { + "type": "string", + "nullable": true, + "enum": ["pending", "approved", "needs_info", "rejected"] + }, + "requested_at": { "type": "string", "format": "date-time", "nullable": true }, + "reviewed_at": { "type": "string", "format": "date-time", "nullable": true }, + "reviewed_by": { "type": "string", "nullable": true }, + "review_note": { "type": "string", "nullable": true } + } } } }, @@ -3605,11 +3708,71 @@ "certify_readiness": { "type": "string", "format": "uri" }, "certify_overview": { "type": "string", "format": "uri" }, "certified_directory": { "type": "string", "format": "uri" }, - "apply_endpoint": { "type": "string", "format": "uri" } + "apply_endpoint": { "type": "string", "format": "uri" }, + "review_status": { "type": "string", "format": "uri" } } } } }, + "CertificationReviewRequest": { + "type": "object", + "required": ["wallet"], + "properties": { + "wallet": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "note": { "type": "string", "maxLength": 500 } + } + }, + "CertificationReviewResponse": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "wallet": { "type": "string" }, + "requested_by_wallet": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "approved", "needs_info", "rejected"] + }, + "requested_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "reviewed_at": { "type": "string", "format": "date-time", "nullable": true }, + "reviewed_by": { "type": "string", "nullable": true }, + "requested_score": { "type": "integer" }, + "requested_tier": { "type": "string" }, + "requested_confidence": { "type": "number", "nullable": true }, + "score_expires_at": { "type": "string", "format": "date-time", "nullable": true }, + "request_note": { "type": "string", "nullable": true }, + "review_note": { "type": "string", "nullable": true }, + "current_score": { + "type": "object", + "properties": { + "score": { "type": "integer", "nullable": true }, + "tier": { "type": "string", "nullable": true }, + "confidence": { "type": "number", "nullable": true } + } + }, + "profile": { + "type": "object", + "properties": { + "name": { "type": "string", "nullable": true }, + "description": { "type": "string", "nullable": true }, + "github_url": { "type": "string", "format": "uri", "nullable": true }, + "website_url": { "type": "string", "format": "uri", "nullable": true }, + "github_verified": { "type": "boolean" } + } + }, + "links": { + "type": "object", + "properties": { + "agent_profile": { "type": "string", "format": "uri" }, + "readiness": { "type": "string", "format": "uri" }, + "review_status": { "type": "string", "format": "uri" }, + "apply_endpoint": { "type": "string", "format": "uri" }, + "certified_directory": { "type": "string", "format": "uri" } + } + }, + "message": { "type": "string" } + } + }, "CertificationDirectoryResponse": { "type": "object", "properties": { @@ -3618,9 +3781,15 @@ "type": "object", "properties": { "limit": { "type": "integer" }, - "tier": { "type": "string", "nullable": true } + "tier": { "type": "string", "nullable": true }, + "search": { "type": "string", "nullable": true }, + "sort": { + "type": "string", + "enum": ["score", "confidence", "recent", "name"] + } } }, + "total": { "type": "integer" }, "returned": { "type": "integer" }, "certifications": { "type": "array", diff --git a/src/app.ts b/src/app.ts index c5390a8..2101027 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,7 @@ import certificationRoute from './routes/certification.js' import certifyRoute from './routes/certify.js' import clusterRoute from './routes/cluster.js' import dataRoute from './routes/data.js' +import directoryRoute from './routes/directory.js' import docsRoute from './routes/docs.js' import economyRoute from './routes/economy.js' import explorerRoute from './routes/explorer.js' @@ -50,6 +51,7 @@ import pricingRoute from './routes/pricing.js' import ratingsRoute from './routes/ratings.js' import registerRoute from './routes/register.js' import reportRoute from './routes/report.js' +import reviewerRoute from './routes/reviewer.js' import scoreRoute from './routes/score.js' import stakeRoute from './routes/stake.js' import stripeWebhookRoute from './routes/stripeWebhook.js' @@ -117,6 +119,7 @@ app.route('/v1/analytics', analyticsRoute) app.route('/v1/agent/register', registerRoute) app.route('/v1/badge', badgeRoute) app.route('/explorer', explorerRoute) +app.route('/directory', directoryRoute) app.route('/certify', certifyRoute) app.route('/blog', blogRoute) app.route('/agent', agentRoute) @@ -126,6 +129,7 @@ app.route('/docs', docsRoute) app.route('/metrics', metricsRoute) app.route('/billing', billingRoute) app.route('/portal', portalRoute) +app.route('/reviewer', reviewerRoute) app.route('/pricing', pricingRoute) app.route('/methodology', methodologyRoute) app.route('/.well-known/x402', wellKnownRoute) diff --git a/src/db/certificationQueries.ts b/src/db/certificationQueries.ts index 1fb4578..e2a7d25 100644 --- a/src/db/certificationQueries.ts +++ b/src/db/certificationQueries.ts @@ -19,10 +19,7 @@ const stmtListCertifications = db.prepare<[], CertificationRow>(` SELECT * FROM certifications ORDER BY granted_at DESC `) -const stmtListActiveCertificationDirectory = db.prepare< - [string | null, string | null, number], - CertificationDirectoryRow ->(` +const stmtListActiveCertificationDirectory = db.prepare<[string | null, string | null], CertificationDirectoryRow>(` SELECT c.id, c.wallet, @@ -49,7 +46,6 @@ const stmtListActiveCertificationDirectory = db.prepare< AND c.expires_at > datetime('now') AND (? IS NULL OR c.tier = ?) ORDER BY COALESCE(s.composite_score, c.score_at_certification) DESC, c.granted_at DESC - LIMIT ? `) const stmtRevokeCertification = db.prepare(` @@ -58,6 +54,90 @@ const stmtRevokeCertification = db.prepare(` WHERE id = ? AND is_active = 1 `) +const CERTIFICATION_REVIEW_SELECT = ` + SELECT + r.id, + r.wallet, + r.requested_by_wallet, + r.requested_tier, + r.requested_score, + r.requested_confidence, + r.score_expires_at, + r.request_note, + r.status, + r.requested_at, + r.updated_at, + r.reviewed_at, + r.reviewed_by, + r.review_note, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON LOWER(s.wallet) = r.wallet +` + +const stmtInsertCertificationReviewRequest = db.prepare(` + INSERT INTO certification_review_requests ( + wallet, + requested_by_wallet, + requested_tier, + requested_score, + requested_confidence, + score_expires_at, + request_note + ) VALUES (?, ?, ?, ?, ?, ?, ?) +`) + +const stmtGetCertificationReviewRequestById = db.prepare<[number], CertificationReviewRequestRow>(` + ${CERTIFICATION_REVIEW_SELECT} + WHERE r.id = ? + LIMIT 1 +`) + +const stmtGetLatestCertificationReviewRequest = db.prepare<[string], CertificationReviewRequestRow>(` + ${CERTIFICATION_REVIEW_SELECT} + WHERE r.wallet = ? + ORDER BY r.requested_at DESC, r.id DESC + LIMIT 1 +`) + +const stmtGetPendingCertificationReviewRequest = db.prepare<[string], CertificationReviewRequestRow>(` + ${CERTIFICATION_REVIEW_SELECT} + WHERE r.wallet = ? + AND r.status = 'pending' + ORDER BY r.requested_at DESC, r.id DESC + LIMIT 1 +`) + +const stmtListCertificationReviewRequests = db.prepare< + [string | null, string | null, number], + CertificationReviewRequestRow +>( + ` + ${CERTIFICATION_REVIEW_SELECT} + WHERE (? IS NULL OR r.status = ?) + ORDER BY r.requested_at DESC, r.id DESC + LIMIT ? + `, +) + +const stmtUpdateCertificationReviewRequestDecision = db.prepare(` + UPDATE certification_review_requests + SET status = ?, + updated_at = datetime('now'), + reviewed_at = datetime('now'), + reviewed_by = ?, + review_note = ? + WHERE id = ? +`) + const stmtCountCertifications = db.prepare<[], { count: number }>(` SELECT COUNT(*) as count FROM certifications `) @@ -125,6 +205,31 @@ export interface CertificationDirectoryRow extends CertificationRow { github_verified: number } +export interface CertificationReviewRequestRow { + id: number + wallet: string + requested_by_wallet: string + requested_tier: string + requested_score: number + requested_confidence: number | null + score_expires_at: string | null + request_note: string | null + status: string + requested_at: string + updated_at: string + reviewed_at: string | null + reviewed_by: string | null + review_note: string | null + name: string | null + description: string | null + github_url: string | null + website_url: string | null + github_verified: number + current_score: number | null + current_tier: string | null + current_confidence: number | null +} + export function getActiveCertification(wallet: string): CertificationRow | undefined { return stmtGetActiveCertification.get(wallet) } @@ -138,8 +243,8 @@ export function listCertifications(): CertificationRow[] { return stmtListCertifications.all() } -export function listActiveCertificationDirectory(limit: number, tier?: string | null): CertificationDirectoryRow[] { - return stmtListActiveCertificationDirectory.all(tier ?? null, tier ?? null, limit) +export function listActiveCertificationDirectory(tier?: string | null): CertificationDirectoryRow[] { + return stmtListActiveCertificationDirectory.all(tier ?? null, tier ?? null) } export function revokeCertification(id: number, reason: string): boolean { @@ -162,3 +267,49 @@ export function getCertificationRevenueSummary(): CertificationRevenueSummary { by_month: byMonth, } } + +export function insertCertificationReviewRequest( + wallet: string, + requestedByWallet: string, + requestedTier: string, + requestedScore: number, + requestedConfidence: number | null, + scoreExpiresAt: string | null, + requestNote: string | null, +): CertificationReviewRequestRow { + const result = stmtInsertCertificationReviewRequest.run( + wallet, + requestedByWallet, + requestedTier, + requestedScore, + requestedConfidence, + scoreExpiresAt, + requestNote, + ) + return stmtGetCertificationReviewRequestById.get(Number(result.lastInsertRowid))! +} + +export function getCertificationReviewRequestById(id: number): CertificationReviewRequestRow | undefined { + return stmtGetCertificationReviewRequestById.get(id) +} + +export function getLatestCertificationReviewRequest(wallet: string): CertificationReviewRequestRow | undefined { + return stmtGetLatestCertificationReviewRequest.get(wallet) +} + +export function getPendingCertificationReviewRequest(wallet: string): CertificationReviewRequestRow | undefined { + return stmtGetPendingCertificationReviewRequest.get(wallet) +} + +export function listCertificationReviewRequests(status: string | null, limit: number): CertificationReviewRequestRow[] { + return stmtListCertificationReviewRequests.all(status, status, limit) +} + +export function updateCertificationReviewRequestDecision( + id: number, + status: string, + reviewedBy: string, + reviewNote: string | null, +): boolean { + return stmtUpdateCertificationReviewRequestDecision.run(status, reviewedBy, reviewNote, id).changes > 0 +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 8817125..14ba350 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -634,6 +634,43 @@ db.exec(` WHERE is_active = 1; `) +db.exec(` + CREATE TABLE IF NOT EXISTS certification_review_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet TEXT NOT NULL, + requested_by_wallet TEXT NOT NULL, + requested_tier TEXT NOT NULL, + requested_score INTEGER NOT NULL, + requested_confidence REAL, + score_expires_at TEXT, + request_note TEXT, + status TEXT NOT NULL DEFAULT 'pending', + requested_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT, + reviewed_by TEXT, + review_note TEXT + ); +`) + +addColumnIfMissing('certification_review_requests', 'requested_by_wallet', 'TEXT NOT NULL DEFAULT ""') +addColumnIfMissing('certification_review_requests', 'requested_tier', 'TEXT NOT NULL DEFAULT "Trusted"') +addColumnIfMissing('certification_review_requests', 'requested_score', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('certification_review_requests', 'requested_confidence', 'REAL') +addColumnIfMissing('certification_review_requests', 'score_expires_at', 'TEXT') +addColumnIfMissing('certification_review_requests', 'request_note', 'TEXT') +addColumnIfMissing('certification_review_requests', 'status', 'TEXT NOT NULL DEFAULT "pending"') +addColumnIfMissing('certification_review_requests', 'requested_at', "TEXT NOT NULL DEFAULT (datetime('now'))") +addColumnIfMissing('certification_review_requests', 'updated_at', "TEXT NOT NULL DEFAULT (datetime('now'))") +addColumnIfMissing('certification_review_requests', 'reviewed_at', 'TEXT') +addColumnIfMissing('certification_review_requests', 'reviewed_by', 'TEXT') +addColumnIfMissing('certification_review_requests', 'review_note', 'TEXT') + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_cert_review_wallet ON certification_review_requests(wallet, requested_at DESC); + CREATE INDEX IF NOT EXISTS idx_cert_review_status ON certification_review_requests(status, requested_at DESC); +`) + // ── ERC-8004 Reputation Publications ──────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS reputation_publications ( diff --git a/src/routes/certification.ts b/src/routes/certification.ts index f4adc5f..2a2715b 100644 --- a/src/routes/certification.ts +++ b/src/routes/certification.ts @@ -3,6 +3,8 @@ * * Public (free): * GET /readiness — check whether a wallet can apply for certification + * GET /review — inspect the latest certification review request for a wallet + * POST /review — submit a certification review request * GET /directory — browse active certifications * GET /:wallet — check certification status * GET /badge/:wallet — SVG badge (green if certified, gray if not) @@ -24,9 +26,14 @@ import { getCertificationRevenue, getCertificationBadgeView, getCertificationDirectoryView, + issueCertificationFromReviewRequest, getCertificationReadinessView, + getCertificationReviewStatusView, getCertificationStatusView, + listCertificationReviewRequestViews, + reviewCertificationRequestDecision, listCertificationRecords, + submitCertificationReviewRequest, revokeCertificationRecord, } from '../services/certificationService.js' import { getPayerWallet } from '../utils/paymentUtils.js' @@ -50,6 +57,8 @@ certification.get('/directory', (c) => { const outcome = getCertificationDirectoryView({ limit: c.req.query('limit'), tier: c.req.query('tier'), + search: c.req.query('search'), + sort: c.req.query('sort'), }) if (!outcome.ok) { return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) @@ -58,6 +67,28 @@ certification.get('/directory', (c) => { return c.json(outcome.data) }) +certification.get('/review', (c) => { + const outcome = getCertificationReviewStatusView(c.req.query('wallet')) + if (!outcome.ok) { + return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) + } + + return c.json(outcome.data) +}) + +certification.post('/review', async (c) => { + const body = await c.req.json<{ wallet?: string; note?: string }>().catch(() => null) + const outcome = submitCertificationReviewRequest({ + wallet: body?.wallet, + note: body?.note, + }) + if (!outcome.ok) { + return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) + } + + return c.json(outcome.data, outcome.status ?? 201) +}) + // ── Public: Check certification status ────────────────────────────────────── certification.get('/:wallet', (c) => { @@ -115,6 +146,48 @@ certification.get('/admin/all', adminAuth, (c) => { return c.json({ certifications, count: certifications.length }) }) +certification.get('/admin/reviews', adminAuth, (c) => { + const outcome = listCertificationReviewRequestViews({ + status: c.req.query('status'), + limit: c.req.query('limit'), + }) + if (!outcome.ok) { + return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) + } + + return c.json(outcome.data) +}) + +certification.post('/admin/reviews/:id/decision', adminAuth, async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req + .json<{ decision?: string; note?: string; reviewed_by?: string }>() + .catch(() => null) + + const outcome = reviewCertificationRequestDecision({ + id, + decision: body?.decision, + note: body?.note, + reviewedBy: body?.reviewed_by, + }) + if (!outcome.ok) { + return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) + } + + return c.json(outcome.data) +}) + +certification.post('/admin/reviews/:id/issue', adminAuth, (c) => { + const outcome = issueCertificationFromReviewRequest({ + id: Number(c.req.param('id')), + }) + if (!outcome.ok) { + return c.json(errorResponse(outcome.code, outcome.message, outcome.details), outcome.status) + } + + return c.json(outcome.data, outcome.status ?? 201) +}) + // ── Admin: Revoke a certification ─────────────────────────────────────────── certification.post('/admin/:id/revoke', adminAuth, async (c) => { diff --git a/src/routes/directory.ts b/src/routes/directory.ts new file mode 100644 index 0000000..4d9f6a1 --- /dev/null +++ b/src/routes/directory.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono' +import { directoryPageHtml } from '../templates/directory.js' + +const directory = new Hono() + +directory.get('/', (c) => { + c.header('Content-Type', 'text/html; charset=utf-8') + c.header('Cache-Control', 'public, max-age=300') + return c.body( + directoryPageHtml({ + limit: c.req.query('limit'), + tier: c.req.query('tier'), + search: c.req.query('search'), + sort: c.req.query('sort'), + }), + ) +}) + +export default directory diff --git a/src/routes/legal.ts b/src/routes/legal.ts index fd0a04a..9cbc34d 100644 --- a/src/routes/legal.ts +++ b/src/routes/legal.ts @@ -36,6 +36,7 @@ legal.get('/robots.txt', (c) => { 'User-agent: *\n' + 'Allow: /\n' + 'Allow: /certify\n' + + 'Allow: /directory\n' + 'Allow: /docs\n' + 'Allow: /methodology\n' + 'Allow: /pricing\n' + @@ -44,6 +45,7 @@ legal.get('/robots.txt', (c) => { 'Disallow: /metrics\n' + 'Disallow: /stripe\n' + 'Disallow: /portal\n' + + 'Disallow: /reviewer\n' + '\n' + `Sitemap: ${buildPublicUrl('/openapi.json')}\n`, ) diff --git a/src/routes/reviewer.ts b/src/routes/reviewer.ts new file mode 100644 index 0000000..5377c3b --- /dev/null +++ b/src/routes/reviewer.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono' +import { reviewerPageHtml } from '../templates/reviewer.js' + +const reviewer = new Hono() + +reviewer.get('/', (c) => { + c.header('Content-Type', 'text/html; charset=utf-8') + c.header('Cache-Control', 'no-store') + return c.body(reviewerPageHtml()) +}) + +export default reviewer diff --git a/src/services/certificationService.ts b/src/services/certificationService.ts index 32c7997..88d039c 100644 --- a/src/services/certificationService.ts +++ b/src/services/certificationService.ts @@ -1,15 +1,26 @@ import { buildPublicUrl } from '../config/public.js' -import type { CertificationDirectoryRow, CertificationRevenueSummary, CertificationRow } from '../db.js' +import type { + CertificationDirectoryRow, + CertificationRevenueSummary, + CertificationReviewRequestRow, + CertificationRow, +} from '../db.js' import { db, getActiveCertification, getCertificationRevenueSummary, + getCertificationReviewRequestById, + getLatestCertificationReviewRequest, + getPendingCertificationReviewRequest, getRegistration, getScore, insertCertification, + insertCertificationReviewRequest, listActiveCertificationDirectory, + listCertificationReviewRequests, listCertifications, revokeCertification, + updateCertificationReviewRequestDecision, } from '../db.js' import { makeBadge } from '../utils/badgeGenerator.js' import { normalizeWallet } from '../utils/walletUtils.js' @@ -24,6 +35,8 @@ export interface CertificationApplyError { | 'cert_not_registered' | 'cert_already_active' | 'cert_not_found' + | 'cert_review_not_found' + | 'cert_review_not_approved' message: string status: 400 | 404 | 409 details?: Record @@ -32,7 +45,7 @@ export interface CertificationApplyError { interface CertificationSuccess { ok: true data: T - status?: 201 + status?: 200 | 201 } export type CertificationResult = CertificationApplyError | CertificationSuccess @@ -103,7 +116,10 @@ export interface CertificationDirectoryView { filters: { limit: number tier: string | null + search: string | null + sort: CertificationDirectorySort } + total: number returned: number certifications: CertificationDirectoryEntryView[] } @@ -111,7 +127,17 @@ export interface CertificationDirectoryView { export interface CertificationReadinessView { wallet: string can_apply: boolean - status: 'eligible' | 'already_certified' | 'not_registered' | 'score_missing' | 'score_expired' | 'score_too_low' + status: + | 'eligible' + | 'already_certified' + | 'not_registered' + | 'score_missing' + | 'score_expired' + | 'score_too_low' + | 'review_pending' + | 'review_approved' + | 'review_needs_info' + | 'review_rejected' requirements: { registration: { met: boolean @@ -131,6 +157,14 @@ export interface CertificationReadinessView { granted_at: string | null expires_at: string | null } + review: { + exists: boolean + status: CertificationReviewStatus | null + requested_at: string | null + reviewed_at: string | null + reviewed_by: string | null + review_note: string | null + } } blockers: Array<{ code: string @@ -151,7 +185,65 @@ export interface CertificationReadinessView { certify_overview: string certified_directory: string apply_endpoint: string + review_status: string + } +} + +export const CERTIFICATION_REVIEW_STATUSES = ['pending', 'approved', 'needs_info', 'rejected'] as const +export type CertificationReviewStatus = (typeof CERTIFICATION_REVIEW_STATUSES)[number] + +interface CertificationReviewLinks { + agent_profile: string + readiness: string + review_status: string + apply_endpoint: string + certified_directory: string +} + +export interface CertificationReviewRequestView { + id: number + wallet: string + requested_by_wallet: string + status: CertificationReviewStatus + requested_at: string + updated_at: string + reviewed_at: string | null + reviewed_by: string | null + requested_score: number + requested_tier: string + requested_confidence: number | null + score_expires_at: string | null + request_note: string | null + review_note: string | null + current_score: { + score: number | null + tier: string | null + confidence: number | null } + profile: { + name: string | null + description: string | null + github_url: string | null + website_url: string | null + github_verified: boolean + } + links: CertificationReviewLinks + message: string +} + +export interface CertificationReviewQueueView { + filters: { + status: CertificationReviewStatus | null + limit: number + } + returned: number + requests: CertificationReviewRequestView[] +} + +export interface CertificationIssuedFromReviewView { + review: CertificationReviewRequestView + certification: CertificationApplyView + message: string } const applyForCertificationTxn = db.transaction((wallet: string): CertificationApplyResult => { @@ -202,6 +294,9 @@ const applyForCertificationTxn = db.transaction((wallet: string): CertificationA } }) +export const CERTIFICATION_DIRECTORY_SORTS = ['score', 'confidence', 'recent', 'name'] as const +export type CertificationDirectorySort = (typeof CERTIFICATION_DIRECTORY_SORTS)[number] + function invalidWalletError(message: string): CertificationApplyError { return { ok: false, @@ -211,6 +306,29 @@ function invalidWalletError(message: string): CertificationApplyError { } } +function normalizeReviewStatus(rawStatus: string | null | undefined): CertificationReviewStatus | null { + const normalized = rawStatus?.trim().toLowerCase() + if (!normalized) return null + if (CERTIFICATION_REVIEW_STATUSES.includes(normalized as CertificationReviewStatus)) { + return normalized as CertificationReviewStatus + } + return null +} + +function normalizeReviewDecision(rawStatus: string | null | undefined): CertificationReviewStatus | null { + const normalized = normalizeReviewStatus(rawStatus) + if (normalized && normalized !== 'pending') { + return normalized + } + return null +} + +function normalizeReviewNote(rawNote: string | null | undefined): string | null { + const note = rawNote?.trim() + if (!note) return null + return note.slice(0, 500) +} + function buildCertificationLinks(wallet: string): CertificationStatusView['links'] { return { certification_badge: buildPublicUrl(`/v1/certification/badge/${wallet}`), @@ -227,8 +345,19 @@ function buildCertificationReadinessLinks(wallet: string): CertificationReadines ...buildCertificationLinks(wallet), certification_status: buildPublicUrl(`/v1/certification/${wallet}`), certify_overview: buildPublicUrl(`/certify?wallet=${wallet}`), - certified_directory: buildPublicUrl('/v1/certification/directory'), + certified_directory: buildPublicUrl('/directory'), + apply_endpoint: buildPublicUrl('/v1/certification/apply'), + review_status: buildPublicUrl(`/v1/certification/review?wallet=${wallet}`), + } +} + +function buildCertificationReviewLinks(wallet: string): CertificationReviewLinks { + return { + agent_profile: buildPublicUrl(`/agent/${wallet}`), + readiness: buildPublicUrl(`/v1/certification/readiness?wallet=${wallet}`), + review_status: buildPublicUrl(`/v1/certification/review?wallet=${wallet}`), apply_endpoint: buildPublicUrl('/v1/certification/apply'), + certified_directory: buildPublicUrl('/directory'), } } @@ -293,6 +422,113 @@ function buildCertificationDirectoryEntryView(row: CertificationDirectoryRow): C } } +function buildCertificationReviewMessage(status: CertificationReviewStatus): string { + if (status === 'approved') { + return 'Review request approved. This wallet is cleared for the next certification step.' + } + if (status === 'needs_info') { + return 'Review request needs more information before approval.' + } + if (status === 'rejected') { + return 'Review request rejected. Resolve the reviewer feedback before submitting again.' + } + return 'Review request submitted and waiting for reviewer action.' +} + +function buildCertificationReviewView(row: CertificationReviewRequestRow): CertificationReviewRequestView { + return { + id: row.id, + wallet: row.wallet, + requested_by_wallet: row.requested_by_wallet, + status: row.status as CertificationReviewStatus, + requested_at: row.requested_at, + updated_at: row.updated_at, + reviewed_at: row.reviewed_at, + reviewed_by: row.reviewed_by, + requested_score: row.requested_score, + requested_tier: row.requested_tier, + requested_confidence: row.requested_confidence, + score_expires_at: row.score_expires_at, + request_note: row.request_note, + review_note: row.review_note, + current_score: { + score: row.current_score, + tier: row.current_tier, + confidence: row.current_confidence, + }, + profile: { + name: row.name, + description: row.description, + github_url: row.github_url, + website_url: row.website_url, + github_verified: row.github_verified === 1, + }, + links: buildCertificationReviewLinks(row.wallet), + message: buildCertificationReviewMessage(row.status as CertificationReviewStatus), + } +} + +function normalizeDirectorySort(rawSort: string | null | undefined): CertificationDirectorySort { + const normalized = rawSort?.trim().toLowerCase() + if (normalized && CERTIFICATION_DIRECTORY_SORTS.includes(normalized as CertificationDirectorySort)) { + return normalized as CertificationDirectorySort + } + return 'score' +} + +function matchesDirectorySearch(entry: CertificationDirectoryEntryView, search: string | null): boolean { + if (!search) return true + + const haystack = [ + entry.wallet, + entry.profile.name, + entry.profile.description, + entry.profile.github_url, + entry.profile.website_url, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase() + + return haystack.includes(search) +} + +function sortDirectoryEntries( + entries: CertificationDirectoryEntryView[], + sort: CertificationDirectorySort, +): CertificationDirectoryEntryView[] { + const scoreValue = (entry: CertificationDirectoryEntryView) => + entry.current_score.score ?? entry.certification.score_at_certification + const confidenceValue = (entry: CertificationDirectoryEntryView) => entry.current_score.confidence ?? -1 + const grantedAtValue = (entry: CertificationDirectoryEntryView) => Date.parse(entry.certification.granted_at) + const nameValue = (entry: CertificationDirectoryEntryView) => + (entry.profile.name?.trim().toLowerCase() || entry.wallet).toLowerCase() + + return [...entries].sort((left, right) => { + if (sort === 'confidence') { + return ( + confidenceValue(right) - confidenceValue(left) || + scoreValue(right) - scoreValue(left) || + grantedAtValue(right) - grantedAtValue(left) + ) + } + + if (sort === 'recent') { + return grantedAtValue(right) - grantedAtValue(left) || scoreValue(right) - scoreValue(left) + } + + if (sort === 'name') { + return nameValue(left).localeCompare(nameValue(right)) || scoreValue(right) - scoreValue(left) + } + + return ( + scoreValue(right) - scoreValue(left) || + confidenceValue(right) - confidenceValue(left) || + grantedAtValue(right) - grantedAtValue(left) + ) + }) +} + export function getCertificationStatus(wallet: string): CertificationRow | null { return getActiveCertification(wallet) ?? null } @@ -308,6 +544,7 @@ export function getCertificationReadinessView( const registration = getRegistration(wallet) const score = getScore(wallet) const certification = getActiveCertification(wallet) + const review = getLatestCertificationReviewRequest(wallet) const nowIso = new Date().toISOString() const links = buildCertificationReadinessLinks(wallet) const scoreIsFresh = !!score && score.expires_at > nowIso @@ -327,7 +564,7 @@ export function getCertificationReadinessView( nextSteps = [ { code: 'view_status', label: 'View certification status', href: links.certification_status }, { code: 'open_profile', label: 'Open agent profile', href: links.agent_profile }, - { code: 'browse_directory', label: 'Browse certified directory', href: links.certified_directory }, + { code: 'browse_directory', label: 'Browse certified directory', href: buildPublicUrl('/directory') }, ] } else if (!registration) { status = 'not_registered' @@ -371,12 +608,54 @@ export function getCertificationReadinessView( nextSteps = [ { code: 'review_profile', label: 'Review agent profile', href: links.agent_profile }, { code: 'review_evaluator', label: 'Open evaluator preview', href: links.evaluator_preview }, - { code: 'browse_directory', label: 'See certified peers', href: links.certified_directory }, + { code: 'browse_directory', label: 'See certified peers', href: buildPublicUrl('/directory') }, + ] + } else if (review?.status === 'pending') { + status = 'review_pending' + blockers.push({ + code: 'cert_review_pending', + message: 'A certification review request is already pending for this wallet.', + }) + nextSteps = [ + { code: 'review_status', label: 'Check review status', href: links.review_status }, + { code: 'open_profile', label: 'Open agent profile', href: links.agent_profile }, + { code: 'review_standards', label: 'Review ERC-8004 document', href: links.standards_document }, + ] + } else if (review?.status === 'needs_info') { + status = 'review_needs_info' + blockers.push({ + code: 'cert_review_needs_info', + message: 'A reviewer asked for more information before certification can proceed.', + }) + nextSteps = [ + { code: 'review_status', label: 'Read reviewer status', href: links.review_status }, + { code: 'open_profile', label: 'Open agent profile', href: links.agent_profile }, + { code: 'submit_review', label: 'Return to Certify', href: links.certify_overview }, + ] + } else if (review?.status === 'rejected') { + status = 'review_rejected' + blockers.push({ + code: 'cert_review_rejected', + message: 'The latest certification review request was rejected. Resolve the reviewer feedback first.', + }) + nextSteps = [ + { code: 'review_status', label: 'Read reviewer status', href: links.review_status }, + { code: 'review_evaluator', label: 'Open evaluator preview', href: links.evaluator_preview }, + { code: 'return_certify', label: 'Return to Certify', href: links.certify_overview }, + ] + } else if (review?.status === 'approved') { + canApply = true + status = 'review_approved' + nextSteps = [ + { code: 'review_status', label: 'Review approved status', href: links.review_status }, + { code: 'apply', label: 'Submit certification purchase', href: links.apply_endpoint }, + { code: 'open_profile', label: 'Open agent profile', href: links.agent_profile }, ] } else { canApply = true status = 'eligible' nextSteps = [ + { code: 'submit_review', label: 'Request a review packet', href: links.certify_overview }, { code: 'apply', label: 'Submit certification purchase', href: links.apply_endpoint }, { code: 'review_standards', label: 'Review ERC-8004 document', href: links.standards_document }, { code: 'open_profile', label: 'Open agent profile', href: links.agent_profile }, @@ -408,6 +687,14 @@ export function getCertificationReadinessView( granted_at: certification?.granted_at ?? null, expires_at: certification?.expires_at ?? null, }, + review: { + exists: !!review, + status: (review?.status as CertificationReviewStatus | undefined) ?? null, + requested_at: review?.requested_at ?? null, + reviewed_at: review?.reviewed_at ?? null, + reviewed_by: review?.reviewed_by ?? null, + review_note: review?.review_note ?? null, + }, }, blockers, next_steps: nextSteps, @@ -421,15 +708,266 @@ export function getCertificationReadinessView( } } +export function getCertificationReviewStatusView( + rawWallet: string | null | undefined, +): CertificationResult { + const wallet = normalizeWallet(rawWallet) + if (!wallet) { + return invalidWalletError('Valid Ethereum wallet address required') + } + + const reviewRequest = getLatestCertificationReviewRequest(wallet) + if (!reviewRequest) { + return { + ok: false, + code: 'cert_review_not_found', + message: 'No certification review request found for this wallet', + status: 404, + } + } + + return { + ok: true, + data: buildCertificationReviewView(reviewRequest), + } +} + +export function submitCertificationReviewRequest(params: { + wallet: string | null | undefined + note?: string | null | undefined +}): CertificationResult { + const wallet = normalizeWallet(params.wallet) + if (!wallet) { + return invalidWalletError('Valid Ethereum wallet address required') + } + + const latestReview = getLatestCertificationReviewRequest(wallet) + if (latestReview?.status === 'approved') { + return { + ok: true, + status: 200, + data: { + ...buildCertificationReviewView(latestReview), + message: 'Review request already approved for this wallet.', + }, + } + } + + const existingPending = getPendingCertificationReviewRequest(wallet) + if (existingPending) { + return { + ok: true, + status: 200, + data: { + ...buildCertificationReviewView(existingPending), + message: 'Review request already pending for this wallet.', + }, + } + } + + const score = getScore(wallet) + if (!score || score.expires_at <= new Date().toISOString()) { + return { + ok: false, + code: 'cert_requirements_not_met', + message: 'Score has expired — request a fresh score before submitting for review', + status: 400, + } + } + + if (score.composite_score < 75) { + return { + ok: false, + code: 'cert_score_too_low', + message: 'Composite score must be >= 75 before a review request can be submitted', + status: 400, + details: { current_score: score.composite_score }, + } + } + + if (!getRegistration(wallet)) { + return { + ok: false, + code: 'cert_not_registered', + message: 'Agent must be registered before submitting a review request', + status: 400, + } + } + + if (getActiveCertification(wallet)) { + return { + ok: false, + code: 'cert_already_active', + message: 'Wallet already has an active certification', + status: 409, + } + } + + const reviewRequest = insertCertificationReviewRequest( + wallet, + wallet, + score.tier, + score.composite_score, + score.confidence ?? null, + score.expires_at ?? null, + normalizeReviewNote(params.note), + ) + + return { + ok: true, + status: 201, + data: buildCertificationReviewView(reviewRequest), + } +} + +export function listCertificationReviewRequestViews(params: { + status?: string | null | undefined + limit?: string | null | undefined +}): CertificationResult { + const parsedLimit = Number.parseInt(params.limit ?? '50', 10) + const limit = Number.isNaN(parsedLimit) ? 50 : Math.min(Math.max(parsedLimit, 1), 200) + const status = normalizeReviewStatus(params.status) + const requests = listCertificationReviewRequests(status, limit).map(buildCertificationReviewView) + + return { + ok: true, + data: { + filters: { + status, + limit, + }, + returned: requests.length, + requests, + }, + } +} + +export function reviewCertificationRequestDecision(params: { + id: number + decision: string | null | undefined + note?: string | null | undefined + reviewedBy?: string | null | undefined +}): CertificationResult { + if (!Number.isInteger(params.id) || params.id <= 0) { + return { + ok: false, + code: 'invalid_request', + message: 'Invalid certification review request ID', + status: 400, + } + } + + const decision = normalizeReviewDecision(params.decision) + if (!decision) { + return { + ok: false, + code: 'invalid_request', + message: 'Decision must be approved, needs_info, or rejected', + status: 400, + } + } + + if ( + !updateCertificationReviewRequestDecision( + params.id, + decision, + params.reviewedBy?.trim() || 'admin', + normalizeReviewNote(params.note), + ) + ) { + return { + ok: false, + code: 'cert_review_not_found', + message: 'Certification review request not found', + status: 404, + } + } + + const updated = getCertificationReviewRequestById(params.id) + if (!updated) { + return { + ok: false, + code: 'cert_review_not_found', + message: 'Certification review request not found', + status: 404, + } + } + + return { + ok: true, + data: buildCertificationReviewView(updated), + } +} + +export function issueCertificationFromReviewRequest(params: { + id: number +}): CertificationResult { + if (!Number.isInteger(params.id) || params.id <= 0) { + return { + ok: false, + code: 'invalid_request', + message: 'Invalid certification review request ID', + status: 400, + } + } + + const review = getCertificationReviewRequestById(params.id) + if (!review) { + return { + ok: false, + code: 'cert_review_not_found', + message: 'Certification review request not found', + status: 404, + } + } + + if (review.status !== 'approved') { + return { + ok: false, + code: 'cert_review_not_approved', + message: 'Certification review must be approved before issuance', + status: 400, + } + } + + const issuance = applyForCertification(review.wallet) + if (!issuance.ok) { + return issuance + } + + return { + ok: true, + status: 201, + data: { + review: buildCertificationReviewView(review), + certification: issuance.data, + message: 'Certification issued from approved review request', + }, + } +} + export function getCertificationDirectoryView(params: { limit: string | null | undefined tier: string | null | undefined + search?: string | null | undefined + sort?: string | null | undefined }): CertificationResult { const parsedLimit = Number.parseInt(params.limit ?? '25', 10) const limit = Number.isNaN(parsedLimit) ? 25 : Math.min(Math.max(parsedLimit, 1), 100) const rawTier = params.tier?.trim() const tier = rawTier && rawTier.length > 0 ? rawTier : null - const certifications = listActiveCertificationDirectory(limit, tier).map(buildCertificationDirectoryEntryView) + const rawSearch = params.search?.trim().toLowerCase() + const search = rawSearch && rawSearch.length > 0 ? rawSearch : null + const sort = normalizeDirectorySort(params.sort) + const matchingCertifications = sortDirectoryEntries( + listActiveCertificationDirectory(tier) + .map(buildCertificationDirectoryEntryView) + .filter((entry) => { + return matchesDirectorySearch(entry, search) + }), + sort, + ) + const certifications = matchingCertifications.slice(0, limit) return { ok: true, @@ -438,7 +976,10 @@ export function getCertificationDirectoryView(params: { filters: { limit, tier, + search, + sort, }, + total: matchingCertifications.length, returned: certifications.length, certifications, }, diff --git a/src/services/discoveryService.ts b/src/services/discoveryService.ts index 80203b0..26fa1f8 100644 --- a/src/services/discoveryService.ts +++ b/src/services/discoveryService.ts @@ -196,16 +196,37 @@ export function getX402DiscoveryView(requestUrl: string, forwardedProto?: string }, }, }, + { + path: '/v1/certification/review', + method: 'GET/POST', + price: 0, + description: + 'Free certification review queue surface for submitting a review request or reading the latest reviewer status for a wallet.', + input: { + query: { wallet: { type: 'string', required: true, description: 'Ethereum wallet address' } }, + body: { wallet: { type: 'string', required: true }, note: { type: 'string', required: false } }, + }, + output: { + example: { + wallet: '0x…', + status: 'pending', + requested_tier: 'Trusted', + requested_score: 82, + }, + }, + }, { path: '/v1/certification/directory', method: 'GET', price: 0, description: - 'Public directory of active DJD certifications with current score context, profile metadata, and links to standards and evaluator views.', + 'Public directory of active DJD certifications with current score context, profile metadata, trust links, and optional search/sort filters.', input: { query: { limit: { type: 'integer' }, tier: { type: 'string' }, + search: { type: 'string' }, + sort: { type: 'string', enum: ['score', 'confidence', 'recent', 'name'] }, }, }, }, diff --git a/src/templates/agentProfile.ts b/src/templates/agentProfile.ts index 2ed3571..a8e8fb2 100644 --- a/src/templates/agentProfile.ts +++ b/src/templates/agentProfile.ts @@ -82,7 +82,7 @@ export function renderAgentPage( const certificationStatusUrl = `${safeOrigin}/v1/certification/${wallet}` const standardsUrl = `${safeOrigin}/v1/score/erc8004?wallet=${wallet}` const evaluatorUrl = `${safeOrigin}/v1/score/evaluator?wallet=${wallet}` - const directoryUrl = `${safeOrigin}/v1/certification/directory` + const directoryUrl = `${safeOrigin}/directory` const certifyUrl = `${safeOrigin}/certify?wallet=${wallet}` const pageUrl = `${safeOrigin}/agent/${wallet}` const s = score?.composite_score ?? 0 diff --git a/src/templates/certify.ts b/src/templates/certify.ts index 4804ce2..9d9b13a 100644 --- a/src/templates/certify.ts +++ b/src/templates/certify.ts @@ -2,8 +2,9 @@ import { buildPublicUrl } from '../config/public.js' export function certifyPageHtml(): string { const certifyUrl = buildPublicUrl('/certify') - const directoryUrl = buildPublicUrl('/v1/certification/directory') + const directoryUrl = buildPublicUrl('/directory') const readinessUrl = buildPublicUrl('/v1/certification/readiness') + const reviewUrl = buildPublicUrl('/v1/certification/review') const pricingUrl = buildPublicUrl('/pricing') const docsUrl = buildPublicUrl('/docs') const explorerUrl = buildPublicUrl('/explorer') @@ -108,6 +109,14 @@ nav{max-width:1080px;margin:0 auto;padding:0 32px;height:64px;display:flex;align .readiness-links{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px} .readiness-link{display:inline-flex;align-items:center;gap:6px;padding:10px 12px;border-radius:10px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;text-decoration:none} .readiness-link:hover{border-color:var(--border-hi);color:var(--accent);text-decoration:none} +.readiness-action-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px} +.readiness-action-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 14px;border-radius:10px;background:var(--accent);border:none;color:var(--bg);font-size:12px;font-weight:700;cursor:pointer} +.readiness-action-btn:disabled{opacity:.45;cursor:not-allowed} +.readiness-review{margin-top:14px;background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px} +.readiness-review-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px} +.readiness-review-title{font-size:14px;font-weight:700} +.readiness-review-note{font-size:12px;color:var(--text-dim);line-height:1.7} +.readiness-review-meta{margin-top:10px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-muted);line-height:1.8} .readiness-code{margin-top:14px;background:#08101d;border:1px solid var(--border);border-radius:12px;padding:16px;overflow:auto} .readiness-code-label{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px} .readiness-code pre{font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.7;color:var(--text-dim);white-space:pre-wrap} @@ -268,6 +277,14 @@ footer{border-top:1px solid var(--border);padding:36px 0 48px;margin-top:72px}
Free
GET
+
+
+
/v1/certification/review
+
Submit a review request or inspect the latest reviewer status for a wallet before the final certification purchase.
+
+
Free
+
GET / POST
+
/v1/certification/:wallet
@@ -336,6 +353,7 @@ footer{border-top:1px solid var(--border);padding:36px 0 48px;margin-top:72px}
+ +` +} diff --git a/src/templates/pricing.ts b/src/templates/pricing.ts index c185cf2..8ae11fb 100644 --- a/src/templates/pricing.ts +++ b/src/templates/pricing.ts @@ -25,7 +25,7 @@ export function pricingPageHtml(plans: PricingPlan[]): string { const growth = plans.find((p) => p.id === 'growth') const scale = plans.find((p) => p.id === 'scale') const pricingUrl = buildPublicUrl('/pricing') - const certifiedDirectoryUrl = buildPublicUrl('/v1/certification/directory') + const certifiedDirectoryUrl = buildPublicUrl('/directory') const explorerUrl = buildPublicUrl('/explorer') return ` diff --git a/src/templates/reviewer.ts b/src/templates/reviewer.ts new file mode 100644 index 0000000..d9bca66 --- /dev/null +++ b/src/templates/reviewer.ts @@ -0,0 +1,374 @@ +export function reviewerPageHtml(): string { + return ` + + + + +Certification Reviewer Dashboard - DJD Agent Score + + + +
+

Certification Reviewer Dashboard

+

Internal operations surface for DJD Certify. Load the review queue with an admin key, inspect score and profile context, then approve, request more information, reject, or issue a certification from an approved review.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
Queue idle
+ Back to Certify +
+ +
+
+ + + +` +} diff --git a/tests/db-schema.test.ts b/tests/db-schema.test.ts index 178e55b..dcae305 100644 --- a/tests/db-schema.test.ts +++ b/tests/db-schema.test.ts @@ -20,11 +20,17 @@ describe('production schema bootstrap', () => { await import('../src/db/schema.js') const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('scores', 'score_outcomes')") + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('scores', 'score_outcomes', 'certification_review_requests')", + ) .all() as Array<{ name: string }> const scoreOutcomeColumns = db.prepare('PRAGMA table_info(score_outcomes)').all() as Array<{ name: string }> - expect(tables.map((table) => table.name).sort()).toEqual(['score_outcomes', 'scores']) + expect(tables.map((table) => table.name).sort()).toEqual([ + 'certification_review_requests', + 'score_outcomes', + 'scores', + ]) expect(scoreOutcomeColumns.map((column) => column.name)).toEqual( expect.arrayContaining([ 'reliability_at_query', diff --git a/tests/routes/certification.test.ts b/tests/routes/certification.test.ts index 60e28e6..5112608 100644 --- a/tests/routes/certification.test.ts +++ b/tests/routes/certification.test.ts @@ -50,6 +50,22 @@ const { testDb } = vi.hoisted(() => { revoked_at TEXT, revocation_reason TEXT ); + CREATE TABLE IF NOT EXISTS certification_review_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet TEXT NOT NULL, + requested_by_wallet TEXT NOT NULL, + requested_tier TEXT NOT NULL, + requested_score INTEGER NOT NULL, + requested_confidence REAL, + score_expires_at TEXT, + request_note TEXT, + status TEXT NOT NULL DEFAULT 'pending', + requested_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT, + reviewed_by TEXT, + review_note TEXT + ); CREATE INDEX IF NOT EXISTS idx_certs_wallet ON certifications(wallet); CREATE INDEX IF NOT EXISTS idx_certs_active ON certifications(is_active, expires_at); `) @@ -79,7 +95,7 @@ vi.mock('../../src/db.js', () => ({ return testDb.prepare('SELECT * FROM certifications WHERE id = ?').get(Number(result.lastInsertRowid)) }, listCertifications: () => testDb.prepare('SELECT * FROM certifications ORDER BY granted_at DESC').all(), - listActiveCertificationDirectory: (limit: number, tier?: string | null) => + listActiveCertificationDirectory: (tier?: string | null) => testDb .prepare( `SELECT @@ -107,10 +123,151 @@ vi.mock('../../src/db.js', () => ({ WHERE c.is_active = 1 AND c.expires_at > datetime('now') AND (? IS NULL OR c.tier = ?) - ORDER BY COALESCE(s.composite_score, c.score_at_certification) DESC, c.granted_at DESC - LIMIT ?`, + ORDER BY COALESCE(s.composite_score, c.score_at_certification) DESC, c.granted_at DESC`, + ) + .all(tier ?? null, tier ?? null), + insertCertificationReviewRequest: ( + wallet: string, + requestedByWallet: string, + requestedTier: string, + requestedScore: number, + requestedConfidence: number | null, + scoreExpiresAt: string | null, + requestNote: string | null, + ) => { + const result = testDb + .prepare( + `INSERT INTO certification_review_requests ( + wallet, + requested_by_wallet, + requested_tier, + requested_score, + requested_confidence, + score_expires_at, + request_note + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(wallet, requestedByWallet, requestedTier, requestedScore, requestedConfidence, scoreExpiresAt, requestNote) + return testDb + .prepare( + `SELECT + r.*, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON s.wallet = r.wallet + WHERE r.id = ? + LIMIT 1`, + ) + .get(Number(result.lastInsertRowid)) + }, + getCertificationReviewRequestById: (id: number) => + testDb + .prepare( + `SELECT + r.*, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON s.wallet = r.wallet + WHERE r.id = ? + LIMIT 1`, + ) + .get(id), + getLatestCertificationReviewRequest: (wallet: string) => + testDb + .prepare( + `SELECT + r.*, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON s.wallet = r.wallet + WHERE r.wallet = ? + ORDER BY r.requested_at DESC, r.id DESC + LIMIT 1`, ) - .all(tier ?? null, tier ?? null, limit), + .get(wallet), + getPendingCertificationReviewRequest: (wallet: string) => + testDb + .prepare( + `SELECT + r.*, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON s.wallet = r.wallet + WHERE r.wallet = ? AND r.status = 'pending' + ORDER BY r.requested_at DESC, r.id DESC + LIMIT 1`, + ) + .get(wallet), + listCertificationReviewRequests: (status: string | null, limit: number) => + testDb + .prepare( + `SELECT + r.*, + reg.name, + reg.description, + reg.github_url, + reg.website_url, + COALESCE(reg.github_verified, 0) AS github_verified, + s.composite_score AS current_score, + s.tier AS current_tier, + s.confidence AS current_confidence + FROM certification_review_requests r + LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet + LEFT JOIN scores s ON s.wallet = r.wallet + WHERE (? IS NULL OR r.status = ?) + ORDER BY r.requested_at DESC, r.id DESC + LIMIT ?`, + ) + .all(status, status, limit), + updateCertificationReviewRequestDecision: ( + id: number, + status: string, + reviewedBy: string, + reviewNote: string | null, + ) => + testDb + .prepare( + `UPDATE certification_review_requests + SET status = ?, + updated_at = datetime('now'), + reviewed_at = datetime('now'), + reviewed_by = ?, + review_note = ? + WHERE id = ?`, + ) + .run(status, reviewedBy, reviewNote, id).changes > 0, revokeCertification: (id: number, reason: string) => testDb .prepare( @@ -210,12 +367,29 @@ function seedExpiredScore(wallet: string) { .run(wallet, pastDate) } -function seedRegistration(wallet: string) { +function seedRegistration( + wallet: string, + overrides: { + name?: string + description?: string | null + githubUrl?: string | null + websiteUrl?: string | null + githubVerified?: number + } = {}, +) { testDb .prepare(` - INSERT INTO agent_registrations (wallet, name) VALUES (?, ?) + INSERT INTO agent_registrations (wallet, name, description, github_url, website_url, github_verified) + VALUES (?, ?, ?, ?, ?, ?) `) - .run(wallet, 'Test Agent') + .run( + wallet, + overrides.name ?? 'Test Agent', + overrides.description ?? null, + overrides.githubUrl ?? null, + overrides.websiteUrl ?? null, + overrides.githubVerified ?? 0, + ) } function seedCertification(wallet: string) { @@ -234,6 +408,7 @@ describe('Certification routes', () => { testDb.exec('DELETE FROM scores') testDb.exec('DELETE FROM agent_registrations') testDb.exec('DELETE FROM certifications') + testDb.exec('DELETE FROM certification_review_requests') process.env.ADMIN_KEY = ADMIN_KEY }) @@ -292,7 +467,7 @@ describe('Certification routes', () => { can_apply: boolean status: string requirements: { certification: { active: boolean } } - links: { certification_status: string } + links: { certification_status: string; certify_overview: string; certified_directory: string } } expect(body.can_apply).toBe(false) @@ -300,6 +475,259 @@ describe('Certification routes', () => { expect(body.requirements.certification.active).toBe(true) expect(body.links.certification_status).toContain(`/v1/certification/${VALID_WALLET_LOWER}`) expect(body.links.certify_overview).toContain(`/certify?wallet=${VALID_WALLET_LOWER}`) + expect(body.links.certified_directory).toContain('/directory') + }) + + it('returns review_pending when an eligible wallet already has a pending review', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + const res = await app.request(`/v1/certification/readiness?wallet=${VALID_WALLET}`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + can_apply: boolean + status: string + requirements: { + review: { + exists: boolean + status: string | null + } + } + links: { review_status: string } + } + + expect(body.can_apply).toBe(false) + expect(body.status).toBe('review_pending') + expect(body.requirements.review.exists).toBe(true) + expect(body.requirements.review.status).toBe('pending') + expect(body.links.review_status).toContain(`/v1/certification/review?wallet=${VALID_WALLET_LOWER}`) + }) + }) + + describe('certification review workflow', () => { + it('creates a pending review request for an eligible wallet', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + const res = await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + wallet: VALID_WALLET, + note: 'Requesting reviewer packet before purchase.', + }), + }) + + expect(res.status).toBe(201) + const body = (await res.json()) as { + wallet: string + status: string + requested_score: number + request_note: string | null + links: { review_status: string; apply_endpoint: string } + } + + expect(body.wallet).toBe(VALID_WALLET_LOWER) + expect(body.status).toBe('pending') + expect(body.requested_score).toBe(82) + expect(body.request_note).toContain('reviewer packet') + expect(body.links.review_status).toContain(`/v1/certification/review?wallet=${VALID_WALLET_LOWER}`) + expect(body.links.apply_endpoint).toContain('/v1/certification/apply') + }) + + it('returns the existing pending review request for duplicate submissions', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + const res = await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { message: string; status: string } + expect(body.status).toBe('pending') + expect(body.message).toContain('already pending') + }) + + it('returns review status for a wallet with a submitted request', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + const res = await app.request(`/v1/certification/review?wallet=${VALID_WALLET}`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + status: string + profile: { name: string | null } + current_score: { score: number | null } + } + expect(body.status).toBe('pending') + expect(body.profile.name).toBe('Test Agent') + expect(body.current_score.score).toBe(82) + }) + + it('rejects review requests for ineligible wallets', async () => { + seedLowScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + const res = await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: { code: string } } + expect(body.error.code).toBe('cert_score_too_low') + }) + + it('supports admin review queue and reviewer decisions', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER, { name: 'Queue Candidate' }) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + const queueRes = await app.request('/v1/certification/admin/reviews?status=pending', { + headers: { 'x-admin-key': ADMIN_KEY }, + }) + expect(queueRes.status).toBe(200) + const queueBody = (await queueRes.json()) as { + returned: number + requests: Array<{ id: number; profile: { name: string | null }; status: string }> + } + + expect(queueBody.returned).toBe(1) + expect(queueBody.requests[0]?.profile.name).toBe('Queue Candidate') + expect(queueBody.requests[0]?.status).toBe('pending') + + const decisionRes = await app.request(`/v1/certification/admin/reviews/${queueBody.requests[0]?.id}/decision`, { + method: 'POST', + headers: { + 'x-admin-key': ADMIN_KEY, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + decision: 'approved', + note: 'Score and profile are sufficient for issuance review.', + reviewed_by: 'ops', + }), + }) + + expect(decisionRes.status).toBe(200) + const decisionBody = (await decisionRes.json()) as { + status: string + reviewed_by: string | null + review_note: string | null + } + expect(decisionBody.status).toBe('approved') + expect(decisionBody.reviewed_by).toBe('ops') + expect(decisionBody.review_note).toContain('issuance review') + }) + + it('issues certification from an approved review request', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER, { name: 'Issue Candidate' }) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + const queueRes = await app.request('/v1/certification/admin/reviews?status=pending', { + headers: { 'x-admin-key': ADMIN_KEY }, + }) + const queueBody = (await queueRes.json()) as { + requests: Array<{ id: number }> + } + const requestId = queueBody.requests[0]?.id + + await app.request(`/v1/certification/admin/reviews/${requestId}/decision`, { + method: 'POST', + headers: { + 'x-admin-key': ADMIN_KEY, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + decision: 'approved', + note: 'Approved for issuance.', + }), + }) + + const issueRes = await app.request(`/v1/certification/admin/reviews/${requestId}/issue`, { + method: 'POST', + headers: { 'x-admin-key': ADMIN_KEY }, + }) + + expect(issueRes.status).toBe(201) + const issueBody = (await issueRes.json()) as { + message: string + review: { status: string } + certification: { wallet: string; tier: string } + } + expect(issueBody.message).toContain('issued from approved review') + expect(issueBody.review.status).toBe('approved') + expect(issueBody.certification.wallet).toBe(VALID_WALLET_LOWER) + expect(issueBody.certification.tier).toBe('Trusted') + }) + + it('rejects issuance when the review request is not approved', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER) + + const app = createApp() + await app.request('/v1/certification/review', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wallet: VALID_WALLET }), + }) + + const queueRes = await app.request('/v1/certification/admin/reviews?status=pending', { + headers: { 'x-admin-key': ADMIN_KEY }, + }) + const queueBody = (await queueRes.json()) as { + requests: Array<{ id: number }> + } + + const issueRes = await app.request(`/v1/certification/admin/reviews/${queueBody.requests[0]?.id}/issue`, { + method: 'POST', + headers: { 'x-admin-key': ADMIN_KEY }, + }) + + expect(issueRes.status).toBe(400) + const issueBody = (await issueRes.json()) as { error: { code: string } } + expect(issueBody.error.code).toBe('cert_review_not_approved') }) }) @@ -446,12 +874,21 @@ describe('Certification routes', () => { describe('GET /v1/certification/directory', () => { it('returns a public directory of active certifications', async () => { seedGoodScore(VALID_WALLET_LOWER) - seedRegistration(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER, { + name: 'Zeta Agent', + description: 'Recovery and routing endpoint', + websiteUrl: 'https://zeta.example.test', + }) seedCertification(VALID_WALLET_LOWER) const secondWallet = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' seedGoodScore(secondWallet) - seedRegistration(secondWallet) + seedRegistration(secondWallet, { + name: 'Alpha Agent', + description: 'Settlement relay for enterprise flows', + githubUrl: 'https://github.com/example/alpha-agent', + githubVerified: 1, + }) testDb .prepare(` INSERT INTO certifications (wallet, tier, score_at_certification, expires_at) @@ -467,6 +904,8 @@ describe('Certification routes', () => { expect(res.status).toBe(200) const body = (await res.json()) as { + total: number + filters: { limit: number; tier: string | null; search: string | null; sort: string } returned: number certifications: Array<{ wallet: string @@ -477,16 +916,56 @@ describe('Certification routes', () => { }> } + expect(body.total).toBe(2) + expect(body.filters.limit).toBe(10) + expect(body.filters.search).toBe(null) + expect(body.filters.sort).toBe('score') expect(body.returned).toBe(2) expect(body.certifications[0]?.wallet).toBe(secondWallet) expect(body.certifications[0]?.certification.tier).toBe('Elite') expect(body.certifications[0]?.current_score.score).toBe(95) - expect(body.certifications[1]?.profile.github_verified).toBe(false) + expect(body.certifications[0]?.profile.github_verified).toBe(true) expect(body.certifications[1]?.links.standards_document).toContain( `/v1/score/erc8004?wallet=${VALID_WALLET_LOWER}`, ) expect(body.certifications[1]?.links.certify_readiness).toContain(`/certify?wallet=${VALID_WALLET_LOWER}`) }) + + it('supports search, sort, and sliced results', async () => { + seedGoodScore(VALID_WALLET_LOWER) + seedRegistration(VALID_WALLET_LOWER, { + name: 'Zeta Agent', + description: 'Agent for routing and orchestration', + }) + seedCertification(VALID_WALLET_LOWER) + + const secondWallet = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' + seedGoodScore(secondWallet) + seedRegistration(secondWallet, { + name: 'Alpha Agent', + description: 'Agent for settlement routing', + }) + seedCertification(secondWallet) + + const app = createApp() + const res = await app.request('/v1/certification/directory?limit=1&search=agent&sort=name') + + expect(res.status).toBe(200) + const body = (await res.json()) as { + total: number + returned: number + filters: { search: string | null; sort: string; limit: number } + certifications: Array<{ wallet: string; profile: { name: string | null } }> + } + + expect(body.total).toBe(2) + expect(body.returned).toBe(1) + expect(body.filters.limit).toBe(1) + expect(body.filters.search).toBe('agent') + expect(body.filters.sort).toBe('name') + expect(body.certifications[0]?.wallet).toBe(secondWallet) + expect(body.certifications[0]?.profile.name).toBe('Alpha Agent') + }) }) // ── GET /badge/:wallet ──────────────────────────────────────────────────── diff --git a/tests/routes/certifyPage.test.ts b/tests/routes/certifyPage.test.ts index ce23521..f324423 100644 --- a/tests/routes/certifyPage.test.ts +++ b/tests/routes/certifyPage.test.ts @@ -16,7 +16,9 @@ describe('GET /certify', () => { expect(body).toContain('DJD Certify') expect(body).toContain('public trust infrastructure') expect(body).toContain('Check certification readiness') + expect(body).toContain('/directory') expect(body).toContain('/v1/certification/readiness') + expect(body).toContain('/v1/certification/review') expect(body).toContain('/v1/certification/directory') expect(body).toContain('POST /v1/certification/apply') expect(body).toContain('/v1/score/evaluator?wallet=') diff --git a/tests/routes/directoryPage.test.ts b/tests/routes/directoryPage.test.ts new file mode 100644 index 0000000..7da1964 --- /dev/null +++ b/tests/routes/directoryPage.test.ts @@ -0,0 +1,25 @@ +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import directoryRoute from '../../src/routes/directory.js' + +describe('GET /directory', () => { + it('renders the trusted endpoint directory page', async () => { + const app = new Hono() + app.route('/directory', directoryRoute) + + const res = await app.request('/directory?search=alpha&sort=name&tier=Trusted&limit=12') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/html') + expect(res.headers.get('cache-control')).toBe('public, max-age=300') + + const body = await res.text() + expect(body).toContain('Trusted Endpoint Directory') + expect(body).toContain('inspectable trust surfaces') + expect(body).toContain('/v1/certification/directory') + expect(body).toContain('Search certified agents, wallets, bios, GitHub, or websites') + expect(body).toContain('value="alpha"') + expect(body).toContain('option value="name" selected') + expect(body).toContain('option value="Trusted" selected') + expect(body).toContain('option value="12" selected') + }) +}) diff --git a/tests/routes/legal.test.ts b/tests/routes/legal.test.ts index 544413d..34c26fa 100644 --- a/tests/routes/legal.test.ts +++ b/tests/routes/legal.test.ts @@ -18,6 +18,7 @@ describe('GET /', () => { expect(body).toContain('DJD is evolving from a score API into trust infrastructure for the agent economy') expect(body).toContain('Certification profile') expect(body).toContain('Certify readiness') + expect(body).toContain('/directory') expect(body).toContain('ERC-8004 document') expect(body).toContain('/v1/certification/directory') expect(body).toContain('/v1/score/erc8004?wallet=') @@ -43,6 +44,7 @@ describe('GET /', () => { const robotsRes = await app.request('/robots.txt') expect(robotsRes.status).toBe(200) const robotsBody = await robotsRes.text() + expect(robotsBody).toContain('Allow: /directory') expect(robotsBody).toContain('Sitemap: https://preview.djdagentscore.test/openapi.json') const agentRes = await app.request('/.well-known/agent.json') diff --git a/tests/routes/openapi.test.ts b/tests/routes/openapi.test.ts index 790fe72..fcfd10b 100644 --- a/tests/routes/openapi.test.ts +++ b/tests/routes/openapi.test.ts @@ -71,6 +71,7 @@ describe('GET /openapi.json', () => { expect(body.paths?.['/v1/score/erc8004']).toBeDefined() expect(body.paths?.['/v1/score/evaluator']).toBeDefined() expect(body.paths?.['/v1/certification/readiness']).toBeDefined() + expect(body.paths?.['/v1/certification/review']).toBeDefined() expect(body.paths?.['/v1/certification/directory']).toBeDefined() expect(body.paths?.['/v1/score/risk']).toBeDefined() expect(body.paths?.['/v1/cluster']).toBeDefined() @@ -89,6 +90,7 @@ describe('GET /openapi.json', () => { expect(body.components?.schemas?.ERC8004CompatibleScoreResponse).toBeDefined() expect(body.components?.schemas?.EvaluatorPreviewResponse).toBeDefined() expect(body.components?.schemas?.CertificationReadinessResponse).toBeDefined() + expect(body.components?.schemas?.CertificationReviewResponse).toBeDefined() expect(body.components?.schemas?.CertificationDirectoryResponse).toBeDefined() expect(body.components?.schemas?.RiskScoreResponse).toBeDefined() expect(body.components?.schemas?.ClusterResponse).toBeDefined() @@ -106,4 +108,59 @@ describe('GET /openapi.json', () => { expect(body.components?.schemas?.WebhookPreset?.properties?.name?.enum).toContain('anomaly_monitoring') expect(body.components?.schemas?.MonitoringPreset?.properties?.policy_type?.enum).toContain('anomaly_monitoring') }) + + it('documents certification directory filters and response metadata', async () => { + const app = new Hono() + app.route('/openapi.json', openapiRoute) + + const res = await app.request('/openapi.json') + const body = JSON.parse(await res.text()) as { + paths?: Record } }> + components?: { + schemas?: Record< + string, + { + properties?: Record< + string, + { + properties?: Record + } + > + } + > + } + } + + const directoryParams = body.paths?.['/v1/certification/directory']?.get?.parameters ?? [] + expect(directoryParams.some((parameter) => parameter.name === 'search')).toBe(true) + expect(directoryParams.some((parameter) => parameter.name === 'sort')).toBe(true) + expect(body.components?.schemas?.CertificationDirectoryResponse?.properties?.total).toBeDefined() + expect( + body.components?.schemas?.CertificationDirectoryResponse?.properties?.filters?.properties?.sort?.enum, + ).toEqual(['score', 'confidence', 'recent', 'name']) + }) + + it('documents certification review request and status surfaces', async () => { + const app = new Hono() + app.route('/openapi.json', openapiRoute) + + const res = await app.request('/openapi.json') + const body = JSON.parse(await res.text()) as { + paths?: Record + components?: { + schemas?: Record }> + } + } + + expect(body.paths?.['/v1/certification/review']?.get).toBeDefined() + expect(body.paths?.['/v1/certification/review']?.post).toBeDefined() + expect(body.components?.schemas?.CertificationReviewRequest).toBeDefined() + expect(body.components?.schemas?.CertificationReviewResponse).toBeDefined() + expect(body.components?.schemas?.CertificationReviewResponse?.properties?.status?.enum).toEqual([ + 'pending', + 'approved', + 'needs_info', + 'rejected', + ]) + }) }) diff --git a/tests/routes/pricing.test.ts b/tests/routes/pricing.test.ts index 2f4571a..5c8113c 100644 --- a/tests/routes/pricing.test.ts +++ b/tests/routes/pricing.test.ts @@ -17,7 +17,7 @@ describe('GET /pricing', () => { expect(body).toContain('Certify workflows') expect(body).toContain('certified directory') expect(body).toContain('ERC-8183 evaluator preview endpoint') - expect(body).toContain('/v1/certification/directory') + expect(body).toContain('/directory') expect(body).toContain('/explorer') }) }) diff --git a/tests/routes/reviewerPage.test.ts b/tests/routes/reviewerPage.test.ts new file mode 100644 index 0000000..85e9eaf --- /dev/null +++ b/tests/routes/reviewerPage.test.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import reviewerRoute from '../../src/routes/reviewer.js' + +describe('GET /reviewer', () => { + it('renders the certification reviewer dashboard page', async () => { + const app = new Hono() + app.route('/reviewer', reviewerRoute) + + const res = await app.request('/reviewer') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/html') + expect(res.headers.get('cache-control')).toBe('no-store') + + const body = await res.text() + expect(body).toContain('Certification Reviewer Dashboard') + expect(body).toContain('/v1/certification/admin/reviews') + expect(body).toContain('Issue Certification') + expect(body).toContain('Enter the admin key') + }) +}) diff --git a/tests/routes/wellKnown.test.ts b/tests/routes/wellKnown.test.ts index 7d56c12..aefaabb 100644 --- a/tests/routes/wellKnown.test.ts +++ b/tests/routes/wellKnown.test.ts @@ -40,6 +40,9 @@ describe('GET /.well-known/x402', () => { expect(body.service.version).toBeTruthy() expect(body.endpoints.some((endpoint) => endpoint.path === '/v1/score/basic' && endpoint.price === 0)).toBe(true) expect(body.endpoints.some((endpoint) => endpoint.path === '/v1/score/erc8004' && endpoint.price === 0)).toBe(true) + expect( + body.endpoints.some((endpoint) => endpoint.path === '/v1/certification/review' && endpoint.price === 0), + ).toBe(true) expect( body.endpoints.some((endpoint) => endpoint.path === '/v1/certification/directory' && endpoint.price === 0), ).toBe(true)