diff --git a/api/_lib/asset_page.js b/api/_lib/asset_page.js new file mode 100644 index 0000000..a874d0e --- /dev/null +++ b/api/_lib/asset_page.js @@ -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('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +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 'untagged'; + return list.map((tag) => `${escapeHtml(tag)}`).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); + } +} diff --git a/api/_lib/asset_paths.js b/api/_lib/asset_paths.js new file mode 100644 index 0000000..1be0338 --- /dev/null +++ b/api/_lib/asset_paths.js @@ -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]; + } +} diff --git a/api/_lib/catalog.js b/api/_lib/catalog.js index cb082e3..63b686a 100644 --- a/api/_lib/catalog.js +++ b/api/_lib/catalog.js @@ -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'; @@ -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) { @@ -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, diff --git a/api/_lib/discovery.js b/api/_lib/discovery.js index 4886162..9030a8a 100644 --- a/api/_lib/discovery.js +++ b/api/_lib/discovery.js @@ -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) { diff --git a/api/_lib/homepage.js b/api/_lib/homepage.js index 1332563..15d9eb8 100644 --- a/api/_lib/homepage.js +++ b/api/_lib/homepage.js @@ -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 [ @@ -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', '', @@ -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', diff --git a/api/_lib/marketplace.js b/api/_lib/marketplace.js index 9af3bbb..52dd328 100644 --- a/api/_lib/marketplace.js +++ b/api/_lib/marketplace.js @@ -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'; @@ -401,7 +402,7 @@ function derivePreview(markdown) { } function sharePathForAsset(assetId) { - return `/asset.html?id=${encodeURIComponent(String(assetId || ''))}`; + return assetDetailPath(assetId); } function sharePathForSoul(assetId) { @@ -409,13 +410,7 @@ function sharePathForSoul(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) { diff --git a/api/_lib/mcp_contract.js b/api/_lib/mcp_contract.js index b29b6d4..5d21a98 100644 --- a/api/_lib/mcp_contract.js +++ b/api/_lib/mcp_contract.js @@ -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) { diff --git a/api/_lib/mcp_tools.js b/api/_lib/mcp_tools.js index 53c5808..afea67f 100644 --- a/api/_lib/mcp_tools.js +++ b/api/_lib/mcp_tools.js @@ -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'; @@ -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; @@ -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); @@ -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 }, diff --git a/api/_lib/services/assets.js b/api/_lib/services/assets.js index 7522d29..72ce07c 100644 --- a/api/_lib/services/assets.js +++ b/api/_lib/services/assets.js @@ -8,6 +8,7 @@ import { enabledAssetTypes, isEnabledAssetType } from '../asset_metadata.js'; +import { canonicalProductionBaseUrl, canonicalProductionHost } from '../site_url.js'; import { buildPublicAssetsMeta } from '../public_contract.js'; import { buildSiweAuthMessage, getSellerAddress, verifyPurchaseReceipt } from '../payments.js'; import { AppError } from '../errors.js'; @@ -215,16 +216,16 @@ export function buildMcpAssetDetailsResponse({ assetId, asset, summary, sellerAd assetId: id, action: 'redownload', timestamp: Date.now(), - domain: 'www.pull.md', - uri: 'https://www.pull.md' + domain: canonicalProductionHost(), + uri: canonicalProductionBaseUrl() }), session: buildSiweAuthMessage({ wallet: '0x', assetId: '*', action: 'session', timestamp: Date.now(), - domain: 'www.pull.md', - uri: 'https://www.pull.md' + domain: canonicalProductionHost(), + uri: canonicalProductionBaseUrl() }) }, auth_timestamp_note: diff --git a/api/_lib/services/creator_marketplace.js b/api/_lib/services/creator_marketplace.js index 05dca65..ca58799 100644 --- a/api/_lib/services/creator_marketplace.js +++ b/api/_lib/services/creator_marketplace.js @@ -19,6 +19,7 @@ import { AppError } from '../errors.js'; import { getTelemetryDashboard, normalizeTelemetryWindowHours, recordTelemetryEvent } from '../telemetry.js'; import { resolveSiweIdentity, verifyRedownloadSessionToken } from '../payments.js'; import { getContentScannerConfig } from './content_scanner.js'; +import { resolveSiteContext } from '../site_url.js'; const ETH_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; @@ -33,9 +34,7 @@ const DEPRECATED_ACTIONS = new Set([ ]); function resolveBaseUrl(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 resolveSiweContext(headers = {}) { diff --git a/api/_lib/site_url.js b/api/_lib/site_url.js new file mode 100644 index 0000000..58a0d37 --- /dev/null +++ b/api/_lib/site_url.js @@ -0,0 +1,69 @@ +const DEFAULT_CANONICAL_HOST = 'pull.md'; + +function forwardedHeaderValue(raw) { + const value = String(raw || '').trim(); + if (!value) return ''; + const first = value.split(',')[0]; + return String(first || '').trim(); +} + +function stripPort(host) { + return String(host || '') + .trim() + .replace(/:\d+$/, '') + .toLowerCase(); +} + +export function canonicalProductionHost() { + const configured = stripPort(process.env.CANONICAL_HOST || ''); + return configured || DEFAULT_CANONICAL_HOST; +} + +export function canonicalProductionBaseUrl() { + return `https://${canonicalProductionHost()}`; +} + +export function isLocalHost(host) { + const value = stripPort(host); + return ( + value.includes('localhost') || + value.startsWith('127.0.0.1') || + value.startsWith('0.0.0.0') || + value.endsWith('.local') + ); +} + +export function isProductionPullMdHost(host) { + const value = stripPort(host); + if (!value) return false; + const canonical = canonicalProductionHost(); + return value === canonical || value === `www.${canonical}`; +} + +export function resolveSiteContext(headers = {}) { + const forwardedHost = forwardedHeaderValue(headers['x-forwarded-host']); + const host = stripPort(forwardedHost || headers.host || canonicalProductionHost()); + const proto = forwardedHeaderValue(headers['x-forwarded-proto']) || (isLocalHost(host) ? 'http' : 'https'); + const requestBaseUrl = `${proto}://${host || canonicalProductionHost()}`; + const baseUrl = isProductionPullMdHost(host) || !host + ? canonicalProductionBaseUrl() + : requestBaseUrl; + + return { + host, + proto, + requestBaseUrl, + canonicalHost: canonicalProductionHost(), + canonicalBaseUrl: canonicalProductionBaseUrl(), + baseUrl + }; +} + +export function canonicalizePullMdUrl(value = '') { + const raw = String(value || '').trim(); + if (!raw) return raw; + const canonicalBase = canonicalProductionBaseUrl(); + return raw + .replace(/^https:\/\/www\.pull\.md\b/i, canonicalBase) + .replace(/^https:\/\/pull\.md\b/i, canonicalBase); +} diff --git a/api/_lib/sitemap.js b/api/_lib/sitemap.js new file mode 100644 index 0000000..61e083f --- /dev/null +++ b/api/_lib/sitemap.js @@ -0,0 +1,50 @@ +import { assetDetailPath } from './asset_paths.js'; +import { listAssetsCatalog } from './services/assets.js'; + +function escapeXml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderSitemap(urls = []) { + const rows = urls + .map((url) => ` \n ${escapeXml(url)}\n `) + .join('\n'); + return `\n\n${rows}\n\n`; +} + +export async function handleSitemapRequest({ 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' }); + } + + const assets = await listAssetsCatalog({}); + const urls = [ + `${baseUrl}/`, + `${baseUrl}/security.html`, + `${baseUrl}/WEBMCP.md`, + `${baseUrl}/.well-known/api-catalog`, + `${baseUrl}/.well-known/mcp/server-card.json`, + `${baseUrl}/.well-known/agent-skills/index.json`, + `${baseUrl}/api/openapi.json`, + `${baseUrl}/api/mcp/manifest`, + `${baseUrl}/api/assets`, + ...assets.map((asset) => `${baseUrl}${assetDetailPath(asset.id)}`) + ]; + const xml = renderSitemap([...new Set(urls)]); + + res.setHeader('Content-Type', 'application/xml; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=900, stale-while-revalidate=86400'); + return method === 'HEAD' ? res.status(200).end() : res.status(200).send(xml); +} diff --git a/api/_lib/siwe.js b/api/_lib/siwe.js index 9e027a4..a714974 100644 --- a/api/_lib/siwe.js +++ b/api/_lib/siwe.js @@ -1,13 +1,14 @@ import crypto from 'crypto'; import { ethers } from 'ethers'; +import { canonicalProductionHost } from './site_url.js'; const SIWE_STATEMENT = 'Authenticate wallet ownership for PULL.md. No token transfer or approval.'; const SIWE_CHAIN_ID = Number(process.env.SIWE_CHAIN_ID || '8453'); function configuredSiweDomain() { const configured = String(process.env.SIWE_DOMAIN || '').trim(); - if (!configured) return 'www.pull.md'; - if (configured.toLowerCase().includes('pullmd')) return 'www.pull.md'; + if (!configured) return canonicalProductionHost(); + if (configured.toLowerCase().includes('pullmd')) return canonicalProductionHost(); return configured; } diff --git a/api/mcp/manifest.js b/api/mcp/manifest.js index d647e51..8084023 100644 --- a/api/mcp/manifest.js +++ b/api/mcp/manifest.js @@ -1,7 +1,9 @@ import { getMcpToolsForManifest } from '../_lib/mcp_tools.js'; import { getMcpServerMetadata } from '../_lib/mcp_sdk.js'; import { setDiscoveryHeaders } from '../_lib/discovery.js'; +import { handleAssetPageRequest } from '../_lib/asset_page.js'; import { handleHomepageRequest } from '../_lib/homepage.js'; +import { handleSitemapRequest } from '../_lib/sitemap.js'; import { buildAuthContract, buildCommerceContract, @@ -10,15 +12,21 @@ import { buildFacilitatorCapabilities, buildMarketplaceProfile } from '../_lib/public_contract.js'; +import { resolveSiteContext } from '../_lib/site_url.js'; export default function handler(req, res) { - const host = String(req.headers['x-forwarded-host'] || req.headers.host || 'www.pull.md').trim(); - const proto = String(req.headers['x-forwarded-proto'] || 'https').trim(); - const baseUrl = `${proto}://${host}`; + const { baseUrl } = resolveSiteContext(req.headers || {}); - if (String(req.query?.view || '').trim().toLowerCase() === 'home') { + const view = String(req.query?.view || '').trim().toLowerCase(); + if (view === 'home') { return handleHomepageRequest({ req, res, baseUrl }); } + if (view === 'asset') { + return handleAssetPageRequest({ req, res, baseUrl }); + } + if (view === 'sitemap') { + return handleSitemapRequest({ req, res, baseUrl }); + } const allowedOrigins = [ 'https://pullmd.vercel.app', diff --git a/public/asset.html b/public/asset.html index 04f5f21..8842efe 100644 --- a/public/asset.html +++ b/public/asset.html @@ -3,11 +3,21 @@ - Asset — PULL.md - + __PULLMD_META_TITLE__ + + + + + + + + + + + @@ -53,16 +63,16 @@
- Hybrid - Creator listing + __PULLMD_ASSET_TYPE_LABEL__ + __PULLMD_ASSET_LINEAGE__
# -

Loading asset...

+

__PULLMD_ASSET_NAME__

-

Fetching listing details.

+

__PULLMD_ASSET_DESCRIPTION__

- loading + __PULLMD_ASSET_TAGS__
@@ -74,7 +84,7 @@

Included File

MD
- ASSET.md + __PULLMD_ASSET_FILENAME__ Delivered only after successful payment or entitlement verification
@@ -82,7 +92,7 @@

Included File

Preview

-
Preview unavailable.
+
__PULLMD_ASSET_PREVIEW__
@@ -98,17 +108,17 @@

Preview

style="display: none;" >
- $0.00 + __PULLMD_ASSET_PRICE__ USDC
Base Network
- -

Delivered instantly through canonical x402 settlement.

+

__PULLMD_ASSET_PURCHASE_NOTE__