Files
recap/startos-registry/server.js
T
Keysat 574a16d9fa Save in-progress keysat integration and StartOS 0.4 work
Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
  server/index.js, activation modal in public/index.html, embedded
  Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
  through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
  yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
  retained here so they remain recoverable when removed in the
  follow-up cleanup commit.
2026-05-08 09:39:17 -05:00

421 lines
12 KiB
JavaScript

/**
* Minimal StartOS Package Registry Server
*
* Implements the Start9 Marketplace Protocol (Package API)
* Reference: https://github.com/Start9Labs/registry/blob/master/marketplace_protocol.md
*
* Endpoints:
* GET /package/v0/info - Registry metadata (name + categories)
* GET /package/v0/index - Filtered package listing
* GET /package/v0/latest - Latest version per package
* GET /package/v0/:s9pk - Download .s9pk binary
* GET /package/v0/manifest/:id - Package manifest JSON
* GET /package/v0/release-notes/:id
* GET /package/v0/icon/:id
* GET /package/v0/license/:id
* GET /package/v0/instructions/:id
* GET /package/v0/version/:id
*/
const express = require('express')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const app = express()
const PORT = process.env.PORT || 3030
// ---------------------------------------------------------------------------
// Load configuration
// ---------------------------------------------------------------------------
const REGISTRY_CONFIG = JSON.parse(
fs.readFileSync(path.join(__dirname, 'registry.json'), 'utf8'),
)
/**
* Load all package configs from packages/<id>/package.json
* Returns a Map of pkgId -> config
*/
function loadPackages() {
const pkgDir = path.join(__dirname, 'packages')
const packages = new Map()
if (!fs.existsSync(pkgDir)) return packages
for (const dir of fs.readdirSync(pkgDir)) {
const configPath = path.join(pkgDir, dir, 'package.json')
if (!fs.existsSync(configPath)) continue
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
// Validate required fields
if (!config.id || !config.version || !config.title) {
console.warn(`[WARN] Skipping ${dir}: missing required fields (id, version, title)`)
continue
}
// Resolve file paths relative to the package directory
config._dir = path.join(pkgDir, dir)
packages.set(config.id, config)
console.log(` Loaded: ${config.id} v${config.version}`)
} catch (err) {
console.warn(`[WARN] Failed to load ${configPath}: ${err.message}`)
}
}
return packages
}
let PACKAGES = loadPackages()
// Reload packages on SIGHUP (allows updating without restart)
process.on('SIGHUP', () => {
console.log('[INFO] Received SIGHUP, reloading packages...')
PACKAGES = loadPackages()
console.log(`[INFO] Loaded ${PACKAGES.size} package(s)`)
})
// ---------------------------------------------------------------------------
// Helper: Read a file from a package directory, with fallback
// ---------------------------------------------------------------------------
function readPackageFile(pkg, filename, fallback) {
const filepath = path.join(pkg._dir, filename)
if (fs.existsSync(filepath)) {
return fs.readFileSync(filepath)
}
return fallback !== undefined ? Buffer.from(fallback) : null
}
function readPackageFileText(pkg, filename, fallback) {
const buf = readPackageFile(pkg, filename, fallback)
return buf ? buf.toString('utf8') : fallback || ''
}
/**
* Get the icon as a data URL (base64-encoded PNG).
* The /index endpoint requires this format.
*/
function getIconDataUrl(pkg) {
const iconPath = path.join(pkg._dir, 'icon.png')
if (!fs.existsSync(iconPath)) return null
const data = fs.readFileSync(iconPath)
return `data:image/png;base64,${data.toString('base64')}`
}
/**
* Find the .s9pk file for a package. Supports multiple naming conventions:
* - <id>.s9pk
* - <id>_<arch>.s9pk
* - Any .s9pk file in the directory
*
* If spec/version-priority query params are provided, we ignore them for now
* (single-version-per-package simplification).
*/
function findS9pk(pkg, preferredArch) {
const dir = pkg._dir
const candidates = fs.readdirSync(dir).filter((f) => f.endsWith('.s9pk'))
if (candidates.length === 0) return null
// Prefer arch-specific if requested
if (preferredArch) {
const archSpecific = candidates.find((f) => f.includes(`_${preferredArch}`))
if (archSpecific) return path.join(dir, archSpecific)
}
// Prefer universal (no arch suffix), then first available
const universal = candidates.find((f) => f === `${pkg.id}.s9pk`)
if (universal) return path.join(dir, universal)
return path.join(dir, candidates[0])
}
/**
* Build the manifest object for a package.
* This is what the StartOS client expects from /manifest/:id
*/
function buildManifest(pkg) {
return {
id: pkg.id,
title: pkg.title,
version: pkg.version,
description: {
short: pkg.descriptionShort || '',
long: pkg.descriptionLong || '',
},
license: pkg.license || 'MIT',
'release-notes': pkg.releaseNotes || {},
'upstream-repo': pkg.upstreamRepo || '',
'package-repo': pkg.packageRepo || '',
'marketing-url': pkg.marketingUrl || '',
'donation-url': pkg.donationUrl || null,
images: pkg.images || {},
dependencies: pkg.dependencies || {},
alerts: pkg.alerts || {},
}
}
// ---------------------------------------------------------------------------
// Routes: Package API v0
// ---------------------------------------------------------------------------
const router = express.Router()
/**
* GET /info
* Returns: { name: string, categories: string[] }
*/
router.get('/info', (req, res) => {
res.json({
name: REGISTRY_CONFIG.name,
categories: REGISTRY_CONFIG.categories,
})
})
/**
* GET /index
* Query params (all optional for our purposes):
* ids[] - filter to specific package IDs
* category - filter by category
* page - pagination (default 1)
* per-page - items per page (default 20)
* os.compat - OS version compatibility
* hardware.* - hardware filters
*
* Returns: Array of package objects
*/
router.get('/index', (req, res) => {
let packages = Array.from(PACKAGES.values())
// Filter by IDs if specified
const ids = req.query['ids[]'] || req.query.ids
if (ids) {
const idList = Array.isArray(ids) ? ids : [ids]
packages = packages.filter((p) => idList.includes(p.id))
}
// Filter by category if specified
const category = req.query.category
if (category) {
packages = packages.filter(
(p) => p.categories && p.categories.includes(category),
)
}
// Pagination
const page = parseInt(req.query.page) || 1
const perPage = parseInt(req.query['per-page']) || 20
const start = (page - 1) * perPage
const paged = packages.slice(start, start + perPage)
// Build response per marketplace protocol
const result = paged.map((pkg) => ({
icon: getIconDataUrl(pkg),
license: readPackageFileText(pkg, 'LICENSE', pkg.license || 'MIT'),
instructions: readPackageFileText(pkg, 'INSTRUCTIONS.md', ''),
manifest: buildManifest(pkg),
categories: pkg.categories || [],
versions: [pkg.version],
'dependency-metadata': pkg.dependencyMetadata || {},
}))
res.json(result)
})
/**
* GET /latest
* Query params:
* ids[] - package IDs to check
*
* Returns: { "<pkgId>": "<version>" | null }
*/
router.get('/latest', (req, res) => {
const ids = req.query['ids[]'] || req.query.ids
if (!ids) return res.json({})
const idList = Array.isArray(ids) ? ids : [ids]
const result = {}
for (const id of idList) {
const pkg = PACKAGES.get(id)
result[id] = pkg ? pkg.version : null
}
res.json(result)
})
/**
* GET /:s9pk
* Downloads the .s9pk binary file.
* The :s9pk param is the filename (e.g., "youtube-summarizer.s9pk")
*
* Optional query params:
* spec - version range (ignored for single-version packages)
* version-priority - "min" or "max" (ignored for single-version packages)
*/
router.get('/:s9pk', (req, res, next) => {
const filename = req.params.s9pk
if (!filename.endsWith('.s9pk')) return next()
// Extract package ID from filename (remove .s9pk and optional _arch suffix)
const base = filename.replace('.s9pk', '')
const pkgId = base.replace(/_(?:x86_64|aarch64|riscv64)$/, '')
const pkg = PACKAGES.get(pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
// Find the actual .s9pk file
const arch = base.includes('_') ? base.split('_').pop() : null
const s9pkPath = findS9pk(pkg, arch)
if (!s9pkPath || !fs.existsSync(s9pkPath)) {
return res.status(404).json({ error: 's9pk file not found' })
}
const stat = fs.statSync(s9pkPath)
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Length', stat.size)
res.setHeader(
'Content-Disposition',
`attachment; filename="${path.basename(s9pkPath)}"`,
)
const stream = fs.createReadStream(s9pkPath)
stream.pipe(res)
})
/**
* GET /manifest/:pkgId
* Returns the package manifest as JSON.
*/
router.get('/manifest/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
res.json(buildManifest(pkg))
})
/**
* GET /release-notes/:pkgId
* Returns: { "<version>": "<notes>" }
*/
router.get('/release-notes/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
res.json(pkg.releaseNotes || {})
})
/**
* GET /icon/:pkgId
* Returns the package icon as a binary PNG.
*/
router.get('/icon/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
const iconData = readPackageFile(pkg, 'icon.png')
if (!iconData) return res.status(404).json({ error: 'Icon not found' })
res.setHeader('Content-Type', 'image/png')
res.setHeader('Content-Length', iconData.length)
res.send(iconData)
})
/**
* GET /license/:pkgId
* Returns the license text.
*/
router.get('/license/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
const text = readPackageFileText(pkg, 'LICENSE', pkg.license || 'MIT')
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Length', Buffer.byteLength(text))
res.send(text)
})
/**
* GET /instructions/:pkgId
* Returns the instructions markdown.
*/
router.get('/instructions/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
const text = readPackageFileText(pkg, 'INSTRUCTIONS.md', '')
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Length', Buffer.byteLength(text))
res.send(text)
})
/**
* GET /version/:pkgId
* Returns: { version: "<version>" }
*/
router.get('/version/:pkgId', (req, res) => {
const pkg = PACKAGES.get(req.params.pkgId)
if (!pkg) return res.status(404).json({ error: 'Package not found' })
res.json({ version: pkg.version })
})
// Mount at /package/v0 (the marketplace protocol path)
app.use('/package/v0', router)
// Also mount at /package/v1 for forward compatibility
// (StartOS 0.4.0 may try v1 first, then fall back to v0)
app.use('/package/v1', router)
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
packages: PACKAGES.size,
uptime: process.uptime(),
})
})
// Root - basic info
app.get('/', (req, res) => {
res.json({
name: REGISTRY_CONFIG.name,
packages: PACKAGES.size,
protocol: 'Start9 Marketplace Protocol',
endpoints: {
info: '/package/v0/info',
index: '/package/v0/index',
latest: '/package/v0/latest',
health: '/health',
},
})
})
// ---------------------------------------------------------------------------
// Start server
// ---------------------------------------------------------------------------
app.listen(PORT, () => {
console.log(`\n StartOS Registry Server`)
console.log(` =======================`)
console.log(` Name: ${REGISTRY_CONFIG.name}`)
console.log(` Port: ${PORT}`)
console.log(` Packages: ${PACKAGES.size}`)
PACKAGES.forEach((pkg) => {
console.log(` - ${pkg.id} v${pkg.version}`)
})
console.log(`\n Endpoints:`)
console.log(` GET /package/v0/info`)
console.log(` GET /package/v0/index`)
console.log(` GET /package/v0/latest?ids[]=<id>`)
console.log(` GET /package/v0/<id>.s9pk`)
console.log(` GET /package/v0/manifest/<id>`)
console.log(` GET /package/v0/release-notes/<id>`)
console.log(` GET /package/v0/icon/<id>`)
console.log(` GET /package/v0/license/<id>`)
console.log(` GET /package/v0/instructions/<id>`)
console.log(` GET /package/v0/version/<id>`)
console.log(`\n Ready.\n`)
})