diff --git a/src/views/docs.jsx b/src/views/docs.jsx
index b87f412..528b8d8 100644
--- a/src/views/docs.jsx
+++ b/src/views/docs.jsx
@@ -70,6 +70,10 @@ export function Docs() {
| q |
name or origin contains |
@@ -98,6 +102,25 @@ curl -s '${s}/api/v1/stats'`}
the hub itself once one is running.
+ Host a site on these disks
+
+ Every c0mpute node with the hosting role lists itself
+ here with accepts.hosting: true, its peer id and its hosting price in the{' '}
+ c0mpute block of its descriptor. ?hosting=1 lists only those. To
+ pay some of them to keep your static site:
+
+ {`c0mpute hosting publish ./public --name my-site --disks auto
+c0mpute hosting publish ./public --name my-site --disks auto --country DE,FI --max-price 0.01 --copies 3
+c0mpute hosting disks --country DE # what --disks auto would pick`}
+
+ The publisher asks d1sks once, ranks the hosting disks by price, standing and free space,
+ and signs the chosen peer ids and the agreed price into its site record. Everything after
+ that is peer to peer: the chosen nodes replicate first, the network still keeps at least
+ three copies, the publisher challenges holders with random chunk fetches, and pays the
+ ones that pass per GiB-month. d1sks is discovery only; a site keeps being served if d1sks
+ is down.
+
+
Validating
{`curl -s '${s}/api/v1/validate?url=https://your-disk.example'
curl -s -X POST '${s}/api/v1/validate' -H 'content-type: application/json' --data-binary @opendisk.json`}
diff --git a/src/views/parts.jsx b/src/views/parts.jsx
index e3a6576..7ee4c3f 100644
--- a/src/views/parts.jsx
+++ b/src/views/parts.jsx
@@ -41,6 +41,11 @@ export function DiskRow({ d }) {
{d.name}
{new URL(d.origin).host}
{d.status === 'gone' ? gone : null}
+ {d.c0mpute_hosting ? (
+
+ c0mpute hosting
+
+ ) : null}
{fmtGib(d.free_gib)} free
@@ -61,6 +66,12 @@ export function DiskRow({ d }) {
{d.standing === null ? 'standing unstated' : `standing ${d.standing}`}
+ {d.c0mpute_hosting && d.hosting_per_gib_month !== null ? (
+
+ hosting {fmtPrice(d.hosting_per_gib_month)}
+ /GiBĀ·mo
+
+ ) : null}
);
diff --git a/test/api.test.js b/test/api.test.js
index 4ae99d1..0d22bbc 100644
--- a/test/api.test.js
+++ b/test/api.test.js
@@ -76,6 +76,40 @@ describe('d1sks end to end', () => {
expect(offer.price.amount).toBe(0.07);
});
+ it('filters c0mpute hosting disks and flags them in rows', async () => {
+ if (!dbOk) return;
+ const plain = await (await req('/api/v1/disks?hosting=1&country=PT')).json();
+ expect(plain.disks.some((d) => d.origin === origin)).toBe(false);
+ const keep = doc;
+ doc = {
+ ...doc,
+ accepts: { ...doc.accepts, hosting: true },
+ c0mpute: {
+ peer_id: '12D3KooWSFxwveBjrGy4n1Djg3vpQD5aHPCXYCQHZWruffig7P4J',
+ roles: ['storage', 'hosting'],
+ hosting: { price_per_gib_month: 0.003, max_gib: 5 },
+ },
+ };
+ const r = await req('/api/v1/disks', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ url: origin }),
+ });
+ expect(r.status).toBe(201);
+ const list = await (await req('/api/v1/disks?hosting=1&country=PT')).json();
+ const row = list.disks.find((d) => d.origin === origin);
+ expect(row?.c0mpute_hosting).toBe(true);
+ expect(row.hosting_per_gib_month).toBe(0.003);
+ const html = await (await req('/disks?hosting=1&country=PT')).text();
+ expect(html).toContain('c0mpute hosting');
+ doc = keep;
+ await req('/api/v1/disks', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ url: origin }),
+ });
+ });
+
it('refuses a descriptor that is not valid, with the reason', async () => {
if (!dbOk) return;
const bad = { ...doc };
diff --git a/test/opendisk.test.js b/test/opendisk.test.js
index b5380a9..c2d5d42 100644
--- a/test/opendisk.test.js
+++ b/test/opendisk.test.js
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'bun:test';
-import { descriptorUrl, diskId, generate, toOpenServerOffer, validate } from '../src/opendisk.js';
+import {
+ c0mputeOf,
+ descriptorUrl,
+ diskId,
+ generate,
+ isC0mputeHosting,
+ toOpenServerOffer,
+ validate,
+ wantsHosting,
+} from '../src/opendisk.js';
const FULL = {
name: 'seeder-a41e, Falkenstein',
@@ -34,6 +43,67 @@ const FULL = {
extra: { anything: true },
};
+const PEER = '12D3KooWSFxwveBjrGy4n1Djg3vpQD5aHPCXYCQHZWruffig7P4J';
+
+/** What a c0mpute node with the hosting role serves (node/crates/c0mpute-core/src/opendisk.rs). */
+const c0mputeDoc = (over = {}) => ({
+ opendisk: '0.1',
+ name: 'c0mpute node ffig7P4J',
+ capacity: { free_gib: 900, total_gib: 1000 },
+ price: { currency: 'USD', per_gib_month: 0.004 },
+ accepts: { visibility: ['public', 'private'], encrypted_only: false, hosting: true },
+ c0mpute: {
+ peer_id: PEER,
+ version: '0.2.79',
+ roles: ['storage', 'gateway', 'hosting'],
+ hosting: { price_per_gib_month: 0.003, max_gib: 5 },
+ },
+ ...over,
+});
+
+describe('c0mpute hosting disks', () => {
+ it('reads the c0mpute block of a hosting node', () => {
+ const c = c0mputeOf(c0mputeDoc());
+ expect(c.peer_id).toBe(PEER);
+ expect(c.hosting).toBe(true);
+ expect(c.hosting_price).toBe(0.003);
+ expect(c.hosting_max_gib).toBe(5);
+ expect(isC0mputeHosting(c0mputeDoc())).toBe(true);
+ const v = validate(c0mputeDoc());
+ expect(v.ok).toBe(true);
+ expect(v.disk.accepts.hosting).toBe(true);
+ expect(v.disk.c0mpute.peer_id).toBe(PEER);
+ });
+
+ it('counts the hosting role or accepts.hosting, either one', () => {
+ const roleOnly = c0mputeDoc({ accepts: {} });
+ expect(isC0mputeHosting(roleOnly)).toBe(true);
+ const flagOnly = c0mputeDoc();
+ flagOnly.c0mpute = { ...flagOnly.c0mpute, roles: ['storage'] };
+ expect(isC0mputeHosting(flagOnly)).toBe(true);
+ });
+
+ it('is not a hosting disk without the role, a c0mpute block or a real peer id', () => {
+ const storageOnly = c0mputeDoc({ accepts: { hosting: false } });
+ storageOnly.c0mpute = { peer_id: PEER, roles: ['storage'] };
+ expect(isC0mputeHosting(storageOnly)).toBe(false);
+ expect(c0mputeOf(storageOnly).hosting_price).toBe(null);
+ expect(isC0mputeHosting({ name: 'x', accepts: { hosting: true } })).toBe(false);
+ const badPeer = c0mputeDoc();
+ badPeer.c0mpute = { ...badPeer.c0mpute, peer_id: 'not a peer id' };
+ expect(c0mputeOf(badPeer)).toBe(null);
+ expect(isC0mputeHosting(null)).toBe(false);
+ expect(
+ validate({ name: 'x', capacity: { free_gib: 1 }, price: { per_gib_month: 1 } }).disk.c0mpute,
+ ).toBe(null);
+ });
+
+ it('parses the hosting listing parameter', () => {
+ for (const v of ['1', 'true', 'c0mpute']) expect(wantsHosting(v)).toBe(true);
+ for (const v of [undefined, '', '0', 'false', 'yes']) expect(wantsHosting(v)).toBe(false);
+ });
+});
+
describe('descriptorUrl', () => {
it('normalises a host, an origin and the file itself to the well-known path', () => {
expect(descriptorUrl('example.com')).toBe('https://example.com/.well-known/opendisk.json');