Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions api/_lib/asset_page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { promises as fs } from 'fs';
import path from 'path';

import { assetIdFromRequest, assetDetailPath } from './asset_paths.js';
import { defaultFileNameForAssetType, normalizeAssetType } from './asset_metadata.js';
import { resolveAssetDetails } from './services/assets.js';

const ASSET_TEMPLATE_PATH = path.join(process.cwd(), 'public', 'asset.html');
let assetTemplatePromise = null;

function loadAssetTemplate() {
if (!assetTemplatePromise) {
assetTemplatePromise = fs.readFile(ASSET_TEMPLATE_PATH, 'utf8');
}
return assetTemplatePromise;
}

function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

function shortAddress(value) {
const normalized = String(value || '').trim();
if (!/^0x[a-fA-F0-9]{40}$/.test(normalized)) return '';
return `${normalized.slice(0, 6)}...${normalized.slice(-4)}`;
}

function assetTypeLabel(value) {
const normalized = normalizeAssetType(value);
if (!normalized) return 'ASSET';
return normalized.toUpperCase();
}

function renderTagHtml(tags = []) {
const list = Array.isArray(tags) ? tags.filter(Boolean).slice(0, 6) : [];
if (!list.length) return '<span class="tag">untagged</span>';
return list.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join('');
}

function buildPageModel({ baseUrl, assetId, asset, summary, missing = false }) {
const normalizedSummary = summary && typeof summary === 'object' ? summary : {};
const normalizedAsset = asset && typeof asset === 'object' ? asset : {};
const resolvedAssetType = normalizeAssetType(
normalizedSummary.asset_type || normalizedAsset.asset_type || normalizedAsset.assetType || 'asset'
);
const fileName =
String(normalizedSummary.delivery?.file_name || normalizedSummary.file_name || normalizedAsset.file_name || '')
.trim() || defaultFileNameForAssetType(resolvedAssetType);
const canonicalPath = assetDetailPath(assetId);
const canonicalUrl = `${baseUrl}${canonicalPath}`;
const name = missing
? 'Asset Unavailable'
: String(normalizedSummary.name || normalizedAsset.name || assetId || 'Asset').trim() || 'Asset';
const description = missing
? 'This markdown asset could not be found.'
: String(normalizedSummary.description || normalizedAsset.description || 'Markdown asset listing details.').trim();
const preview = missing
? 'This listing is unavailable.'
: String(normalizedSummary.preview?.excerpt || normalizedAsset.preview?.excerpt || description).trim();
const priceValue = missing
? '$0.00'
: String(normalizedSummary.price?.display || '$0.00').replace(/\s*USDC$/i, '').trim() || '$0.00';
const seller = shortAddress(
normalizedSummary.seller_address || normalizedAsset.seller_address || normalizedSummary.wallet_address || ''
);
const lineage = missing
? 'Published listing unavailable'
: seller
? `Creator ${seller}`
: String(normalizedSummary.provenance?.raised_by || normalizedAsset.provenance?.raised_by || 'Creator listing');
const purchaseNote = missing
? 'This listing is unavailable.'
: seller
? `Paid access via x402. Settlement recipient: ${seller}.`
: 'Paid access via x402. Delivered instantly after settlement.';

return {
title: `${name} — PULL.md`,
description,
canonicalUrl,
socialImageUrl: `${baseUrl}/graphics/pullmd-social-card.png`,
assetType: resolvedAssetType || 'asset',
assetTypeLabel: assetTypeLabel(resolvedAssetType),
lineage,
name,
preview,
priceValue,
fileName,
purchaseNote,
tagsHtml: renderTagHtml(normalizedSummary.tags || normalizedAsset.tags),
assetId: String(assetId || '').trim(),
purchaseButtonLabel: missing ? 'Unavailable' : 'Purchase Asset'
};
}

function renderAssetHtml(template, model) {
return template
.replaceAll('__PULLMD_META_TITLE__', escapeHtml(model.title))
.replaceAll('__PULLMD_META_DESCRIPTION__', escapeHtml(model.description))
.replaceAll('__PULLMD_CANONICAL_URL__', escapeHtml(model.canonicalUrl))
.replaceAll('__PULLMD_SOCIAL_IMAGE_URL__', escapeHtml(model.socialImageUrl))
.replaceAll('__PULLMD_ASSET_TYPE_CLASS__', escapeHtml(model.assetType))
.replaceAll('__PULLMD_ASSET_TYPE_LABEL__', escapeHtml(model.assetTypeLabel))
.replaceAll('__PULLMD_ASSET_LINEAGE__', escapeHtml(model.lineage))
.replaceAll('__PULLMD_ASSET_NAME__', escapeHtml(model.name))
.replaceAll('__PULLMD_ASSET_DESCRIPTION__', escapeHtml(model.description))
.replaceAll('__PULLMD_ASSET_TAGS__', model.tagsHtml)
.replaceAll('__PULLMD_ASSET_FILENAME__', escapeHtml(model.fileName))
.replaceAll('__PULLMD_ASSET_PREVIEW__', escapeHtml(model.preview))
.replaceAll('__PULLMD_ASSET_PRICE__', escapeHtml(model.priceValue))
.replaceAll('__PULLMD_ASSET_PURCHASE_NOTE__', escapeHtml(model.purchaseNote))
.replaceAll('__PULLMD_ASSET_ID__', escapeHtml(model.assetId))
.replaceAll('__PULLMD_ASSET_BUTTON_LABEL__', escapeHtml(model.purchaseButtonLabel));
}

export async function handleAssetPageRequest({ req, res, baseUrl }) {
const method = String(req.method || 'GET').toUpperCase();
if (method === 'OPTIONS') {
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
return res.status(200).end();
}

if (!['GET', 'HEAD'].includes(method)) {
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
return res.status(405).json({ error: 'Method not allowed' });
}

res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=900, stale-while-revalidate=86400');

const assetId = assetIdFromRequest(req);

try {
const template = await loadAssetTemplate();
if (!assetId) {
const html = renderAssetHtml(template, buildPageModel({ baseUrl, assetId: '', missing: true }));
return method === 'HEAD' ? res.status(404).end() : res.status(404).send(html);
}

const details = await resolveAssetDetails(assetId);
const html = renderAssetHtml(
template,
buildPageModel({
baseUrl,
assetId,
asset: details.asset,
summary: details.summary
})
);
return method === 'HEAD' ? res.status(200).end() : res.status(200).send(html);
} catch (error) {
const template = await loadAssetTemplate();
const html = renderAssetHtml(template, buildPageModel({ baseUrl, assetId, missing: true }));
const status = Number(error?.status || 404);
return method === 'HEAD' ? res.status(status).end() : res.status(status).send(html);
}
}
41 changes: 41 additions & 0 deletions api/_lib/asset_paths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export function assetDetailPath(assetId) {
return `/assets/${encodeURIComponent(String(assetId || '').trim())}`;
}

export function legacyAssetDetailPath(assetId) {
return `/asset.html?id=${encodeURIComponent(String(assetId || '').trim())}`;
}

function assetIdFromLegacySharePath(raw) {
const match = String(raw || '').match(/[?&]id=([^&]+)/i);
if (!match?.[1]) return '';
try {
return decodeURIComponent(match[1]);
} catch (_) {
return match[1];
}
}

export function canonicalAssetSharePath(value, assetId) {
const fallback = assetDetailPath(assetId);
const raw = String(value || '').trim();
if (!raw) return fallback;
if (/^\/assets\/[^/?#]+$/i.test(raw)) return raw;
if (/^\/(?:asset|soul)\.html\?/i.test(raw)) {
return assetDetailPath(assetIdFromLegacySharePath(raw) || assetId);
}
return raw.startsWith('/') ? raw : fallback;
}

export function assetIdFromRequest(req = {}) {
const pathId = String(req.query?.id || '').trim();
if (pathId) return pathId;
const rawUrl = String(req.url || '').trim();
const match = rawUrl.match(/[?&]id=([^&]+)/i);
if (!match?.[1]) return '';
try {
return decodeURIComponent(match[1]);
} catch (_) {
return match[1];
}
}
11 changes: 3 additions & 8 deletions api/_lib/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
normalizeAssetType,
normalizeMarkdownFileName
} from './asset_metadata.js';
import { assetDetailPath, canonicalAssetSharePath } from './asset_paths.js';
import { hasPrimaryDatabase } from './db.js';
import { listPublishedCatalogEntries } from './marketplace.js';

Expand Down Expand Up @@ -114,13 +115,7 @@ function bundledCatalogValues() {
}

function canonicalSharePath(value, id) {
const fallback = `/asset.html?id=${encodeURIComponent(String(id || ''))}`;
const raw = String(value || '').trim();
if (!raw) return fallback;
if (/^\/soul\.html\?/i.test(raw)) {
return raw.replace(/^\/soul\.html\?/i, '/asset.html?');
}
return raw;
return canonicalAssetSharePath(value, id);
}

function normalizeCatalogAsset(asset) {
Expand Down Expand Up @@ -199,7 +194,7 @@ function toAssetSummary(asset) {
const sharePath =
typeof asset.sharePath === 'string' && asset.sharePath.trim()
? canonicalSharePath(asset.sharePath, asset.id)
: `/asset.html?id=${encodeURIComponent(asset.id)}`;
: assetDetailPath(asset.id);
const assetType = normalizeAssetType(asset.assetType || asset.asset_type) || 'soul';
const fileName = normalizeMarkdownFileName(
asset.fileName || asset.file_name,
Expand Down
12 changes: 2 additions & 10 deletions api/_lib/discovery.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import { buildDiscoveryLinkEntries, buildDiscoveryUrls } from './public_contract.js';
import { resolveSiteContext } from './site_url.js';

const API_CATALOG_PROFILE_URI = 'https://www.rfc-editor.org/info/rfc9727';
const OPENAPI_CONTENT_TYPE = 'application/vnd.oai.openapi+json;version=3.1';

function forwardedHeaderValue(raw) {
const value = String(raw || '').trim();
if (!value) return '';
const first = value.split(',')[0];
return String(first || '').trim();
}

export function resolveBaseUrl(headers = {}) {
const host = forwardedHeaderValue(headers['x-forwarded-host']) || String(headers.host || 'pull.md').trim();
const proto = forwardedHeaderValue(headers['x-forwarded-proto']) || 'https';
return `${proto}://${host}`;
return resolveSiteContext(headers).baseUrl;
}

function serializeLinkEntry(entry) {
Expand Down
14 changes: 12 additions & 2 deletions api/_lib/homepage.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ function loadHomepageHtml() {
return homepageHtmlPromise;
}

function renderHomepageHtml(baseUrl) {
return loadHomepageHtml().then((template) =>
template
.replaceAll('__PULLMD_BASE_URL__', baseUrl)
.replaceAll('__PULLMD_CANONICAL_URL__', `${baseUrl}/`)
.replaceAll('__PULLMD_SOCIAL_IMAGE_URL__', `${baseUrl}/graphics/pullmd-social-card.png`)
);
}

function renderHomepageMarkdown(baseUrl) {
const discovery = buildDiscoveryUrls(baseUrl);
return [
Expand All @@ -31,6 +40,7 @@ function renderHomepageMarkdown(baseUrl) {
'# PULL.md',
'',
'PULL.md is a markdown-native asset marketplace. Agents and humans share the same catalog, the same MCP discovery surface, and the same canonical x402 download contract.',
'Souls, skills, playbooks, prompts, workflows, guides, policies, and knowledge assets all fit the same portable markdown commerce model.',
'',
'## Quickstart',
'',
Expand Down Expand Up @@ -108,8 +118,8 @@ export async function handleHomepageRequest({ req, res, baseUrl }) {
}

try {
const html = await loadHomepageHtml();
return res.status(200).send(html);
const rendered = await renderHomepageHtml(baseUrl);
return res.status(200).send(rendered);
} catch (error) {
return res.status(500).json({
error: 'Unable to load homepage',
Expand Down
11 changes: 3 additions & 8 deletions api/_lib/marketplace.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeAssetType,
normalizeMarkdownFileName
} from './asset_metadata.js';
import { assetDetailPath, canonicalAssetSharePath } from './asset_paths.js';
import { assertRelationsExist, getPrimaryDatabaseUrl, getSharedDbPool, qualifyPgRelation } from './db.js';
import { scanMarkdownAssetContent } from './services/content_scanner.js';
import { getLatestAssetScan, getLatestAssetScanReport, persistAssetScanReport } from './services/scan_store.js';
Expand Down Expand Up @@ -401,21 +402,15 @@ function derivePreview(markdown) {
}

function sharePathForAsset(assetId) {
return `/asset.html?id=${encodeURIComponent(String(assetId || ''))}`;
return assetDetailPath(assetId);
}

function sharePathForSoul(assetId) {
return sharePathForAsset(assetId);
}

function canonicalSharePath(value, id) {
const fallback = sharePathForAsset(id);
const raw = asString(value);
if (!raw) return fallback;
if (/^\/soul\.html\?/i.test(raw)) {
return raw.replace(/^\/soul\.html\?/i, '/asset.html?');
}
return raw;
return canonicalAssetSharePath(value, id);
}

function normalizeVisibility(value) {
Expand Down
5 changes: 2 additions & 3 deletions api/_lib/mcp_contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import {
} from './services/assets.js';
import fs from 'fs/promises';
import path from 'path';
import { resolveSiteContext } from './site_url.js';

function baseUrlFromHeaders(headers = {}) {
const host = String(headers['x-forwarded-host'] || headers.host || 'www.pull.md').trim();
const proto = String(headers['x-forwarded-proto'] || 'https').trim();
return `${proto}://${host}`;
return resolveSiteContext(headers).baseUrl;
}

function asJson(value) {
Expand Down
19 changes: 13 additions & 6 deletions api/_lib/mcp_tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AppError } from './errors.js';
import { buildCreatorAuthMessage, buildModeratorAuthMessage, getMarketplaceDraftTemplate } from './marketplace.js';
import { buildSiweAuthMessage, resolveSiweIdentity } from './payments.js';
import { buildSiweChallengeFields, parseSiweField } from './siwe.js';
import { canonicalProductionHost, resolveSiteContext } from './site_url.js';

export const MCP_PROTOCOL_VERSION = '2025-06-18';

Expand Down Expand Up @@ -70,8 +71,9 @@ function normalizeModeratorAction(action) {

function buildAuthChallengePayload(args = {}, context = {}) {
const parsed = ensureObject(args);
const host = String(context?.headers?.['x-forwarded-host'] || context?.headers?.host || 'www.pull.md').trim();
const proto = String(context?.headers?.['x-forwarded-proto'] || 'https').trim();
const site = resolveSiteContext(context?.headers || {});
const host = site.host || canonicalProductionHost();
const proto = site.proto || 'https';
const siweIdentity = resolveSiweIdentity({ host, proto });
const siweDomain = siweIdentity.domain;
const siweUri = siweIdentity.uri;
Expand Down Expand Up @@ -261,10 +263,12 @@ function buildAuthChallengePayload(args = {}, context = {}) {
}

function toolCallHeadersFromArgs(context, args, authShape = 'none') {
const site = resolveSiteContext(context?.headers || {});
const host = site.host || canonicalProductionHost();
const baseHeaders = {
host: context?.headers?.host || 'www.pull.md',
'x-forwarded-host': context?.headers?.['x-forwarded-host'] || context?.headers?.host || 'www.pull.md',
'x-forwarded-proto': context?.headers?.['x-forwarded-proto'] || 'https'
host,
'x-forwarded-host': host,
'x-forwarded-proto': site.proto || 'https'
};
const parsedArgs = ensureObject(args);

Expand Down Expand Up @@ -296,7 +300,10 @@ const MCP_TOOL_REGISTRY = [
type: 'object',
properties: {
category: { type: 'string', description: 'Optional category filter' },
asset_type: { type: 'string', description: 'Optional asset type filter (soul, skill)' }
asset_type: {
type: 'string',
description: 'Optional asset type filter (for example soul, skill, playbook, prompt, workflow)'
}
},
additionalProperties: false
},
Expand Down
Loading
Loading