From 75ae5bfc1ea98a6985bd43a787d430092415f0c1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 04:18:34 +0000 Subject: [PATCH] feat: list c0mpute hosting nodes (?hosting=1, badge, host-a-site docs) Reads the c0mpute block a c0mpute node adds to its OpenDisk descriptor (peer_id, roles, accepts.hosting, hosting price) and: - ?hosting=1 on /disks and /api/v1/disks lists only c0mpute hosting nodes - rows carry c0mpute_hosting, c0mpute_peer_id, hosting_per_gib_month - a 'c0mpute hosting' badge and hosting price in the list, a block on the disk page, a Kind filter, and a 'Host a site on these disks' docs section pointing at c0mpute hosting publish --disks auto d1sks stays discovery only: placement, proofs and payment are p2p. Co-Authored-By: Claude Opus 5.5 --- src/disks.js | 19 +++++++++++- src/opendisk.js | 40 ++++++++++++++++++++++++ src/views/disks.jsx | 24 +++++++++++++++ src/views/docs.jsx | 23 ++++++++++++++ src/views/parts.jsx | 11 +++++++ test/api.test.js | 34 ++++++++++++++++++++ test/opendisk.test.js | 72 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 221 insertions(+), 2 deletions(-) diff --git a/src/disks.js b/src/disks.js index 51042ed..4056a2e 100644 --- a/src/disks.js +++ b/src/disks.js @@ -1,7 +1,14 @@ import { config } from './config.js'; import { jsonb, sql } from './db.js'; import { safeFetch } from './fetch.js'; -import { descriptorUrl, diskId, toOpenServerOffer, validate } from './opendisk.js'; +import { + c0mputeOf, + descriptorUrl, + diskId, + toOpenServerOffer, + validate, + wantsHosting, +} from './opendisk.js'; export const SORTS = ['price', 'free', 'standing', 'updated', 'name']; @@ -9,6 +16,7 @@ const num = (v) => (v === null || v === undefined ? null : Number(v)); function row(r) { if (!r) return null; + const c0 = c0mputeOf(jsonb(r.data, {})); return { id: r.id, origin: r.origin, @@ -37,6 +45,10 @@ function row(r) { gone_since: r.gone_since, last_error: r.last_error, data: jsonb(r.data, {}), + // A c0mpute node that holds static sites: a host for `c0mpute hosting publish --disks`. + c0mpute_hosting: c0?.hosting === true, + c0mpute_peer_id: c0?.peer_id ?? null, + hosting_per_gib_month: c0?.hosting ? (c0.hosting_price ?? num(r.per_gib_month)) : null, }; } @@ -187,6 +199,11 @@ function whereClause(p) { Number.isFinite(Number(p.min_standing)) ) parts.push(sql`standing >= ${Number(p.min_standing)}`); + if (wantsHosting(p.hosting)) { + // Same rule as c0mputeOf(): a c0mpute peer id, and accepts.hosting or the hosting role. + parts.push(sql`(data->'c0mpute'->>'peer_id' is not null and ( + data->'accepts'->'hosting' = 'true'::jsonb or data->'c0mpute'->'roles' @> ${['hosting']}::jsonb))`); + } if (p.q) { const like = `%${String(p.q).replace(/[%_\\]/g, '\\$&')}%`; parts.push(sql`(name ilike ${like} or origin ilike ${like})`); diff --git a/src/opendisk.js b/src/opendisk.js index a8c3c59..88a9993 100644 --- a/src/opendisk.js +++ b/src/opendisk.js @@ -159,7 +159,9 @@ export function validate(doc) { encrypted_only: typeof accepts.encrypted_only === 'boolean' ? accepts.encrypted_only : null, max_file_gib: isNum(accepts.max_file_gib) ? accepts.max_file_gib : null, feeds: typeof accepts.feeds === 'boolean' ? accepts.feeds : null, + hosting: typeof accepts.hosting === 'boolean' ? accepts.hosting : null, }, + c0mpute: c0mputeOf(doc), proof: { kinds: proofKinds.filter((k) => PROOF_KINDS.includes(k)), every_hours_min: @@ -185,6 +187,44 @@ export function validate(doc) { return { ok: true, errors, warnings, disk }; } +/** + * The c0mpute block a c0mpute node adds to its descriptor (unknown to + * OpenDisk, kept in the raw document): how to reach the disk on the c0mpute + * network and whether it hosts static sites. Null when absent or unusable. + */ +export function c0mputeOf(doc) { + if (!isObj(doc) || !isObj(doc.c0mpute)) return null; + const c = doc.c0mpute; + const peerId = + isStr(c.peer_id) && /^[1-9A-HJ-NP-Za-km-z]{40,64}$/.test(c.peer_id) ? c.peer_id : null; + if (!peerId) return null; + const roles = strList(c.roles); + const accepts = isObj(doc.accepts) ? doc.accepts : {}; + const h = isObj(c.hosting) ? c.hosting : {}; + const hosting = accepts.hosting === true || roles.includes('hosting'); + return { + peer_id: peerId, + version: isStr(c.version) ? c.version : null, + roles, + hosting, + hosting_price: + hosting && isNum(h.price_per_gib_month) && h.price_per_gib_month >= 0 + ? h.price_per_gib_month + : null, + hosting_max_gib: hosting && isNum(h.max_gib) && h.max_gib >= 0 ? h.max_gib : null, + }; +} + +/** True for a c0mpute node that holds static sites (`c0mpute hosting publish --disks`). */ +export function isC0mputeHosting(doc) { + return c0mputeOf(doc)?.hosting === true; +} + +/** The `hosting` listing parameter: true to list only c0mpute hosting disks. */ +export function wantsHosting(v) { + return v === '1' || v === 'true' || v === 'c0mpute' || v === true; +} + /** One seeder identity is one disk, whatever URL it was found at. */ export function diskId(disk, origin) { if (disk.key) return disk.key.replace(':', '-'); diff --git a/src/views/disks.jsx b/src/views/disks.jsx index e74f497..3691939 100644 --- a/src/views/disks.jsx +++ b/src/views/disks.jsx @@ -86,6 +86,15 @@ export function Disks({ result, page, params }) { +