From 14673b1da43a3ff13898cc3d646aaee0fa7cbf2d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 03:23:46 +0000 Subject: [PATCH] The pay2seed hub: consent, escrowed offers, leases and the 1 percent bittorrented.com is now the reference hub for the OpenSwarm payment family (logicsrc.com/openswarm). The specs were merged this morning; this is the implementation. A requester signs an attestation saying what they are putting on a swarm and why they may share it, with a README that becomes the swarm's page. Nothing is listed without one. A public claim waits out a window in the open first, so a notice can void it before anyone is paid to seed something the requester had no right to. A private swarm is ciphertext a seeder holds without ever reading. An offer escrows a budget for the swarm to be kept for N days by M seeders at a price per GiB-month. It stays unpaid until the money settles, because a budget that is not escrowed is not a promise anyone should seed against. Seeders take slots by signing the offer id, prove each period, and are paid per proven period; the receipt's unique (lease, period) is the whole of the idempotency, so a verifier that reports twice pays once. Two consecutive failures reopen the slot. Every party is a key, and a key is a human or a bit: an autonomous agent that holds its own key and earns its own money under the same consent rules. Because both sides can be either, the lane is stamped on every lease and receipt at the moment the money is agreed. h2h, h2b, b2h, b2b. We carry all four. The hub takes 1 percent of what crosses it, charged to whoever is paying and never taken out of a seeder's floor. Money is exact six-decimal strings over integer micros the whole way and only becomes a number at the Postgres boundary, so a hub and a seeder computing a period apart always agree. Ten tables, all RLS-enabled and service-role only, applied to the live database. The API is at /api/openswarm/pay2seed with the hub record at /.well-known/openswarm-hub.json, and the market is at /swarm, where a swarm's README is rendered through a subset renderer that emits no HTML from the source and is sanitised again after. The generated Supabase types were regenerating 129 unrelated breakages across the app, so only the ten new tables were added rather than the whole file. Bringing the rest current is its own piece of work. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SKAohrRkqLKVQL2cGCAkR5 --- .../.well-known/openswarm-hub.json/route.ts | 10 + src/app/api/openswarm/hub/route.ts | 16 + .../pay2seed/attestations/[id]/route.ts | 17 + .../openswarm/pay2seed/attestations/route.ts | 23 + .../openswarm/pay2seed/leases/[id]/route.ts | 17 + .../api/openswarm/pay2seed/leases/route.ts | 26 + .../api/openswarm/pay2seed/notices/route.ts | 47 + .../openswarm/pay2seed/offers/[id]/route.ts | 32 + .../api/openswarm/pay2seed/offers/route.ts | 48 + .../openswarm/pay2seed/parties/[key]/route.ts | 17 + .../api/openswarm/pay2seed/parties/route.ts | 32 + .../api/openswarm/pay2seed/proofs/route.ts | 56 ++ .../openswarm/pay2seed/seeders/[key]/route.ts | 28 + src/app/swarm/[id]/page.tsx | 139 +++ src/app/swarm/page.tsx | 138 +++ src/components/layout/sidebar.test.tsx | 4 +- src/components/layout/sidebar.tsx | 1 + src/lib/openswarm/index.ts | 6 + src/lib/openswarm/lanes.ts | 109 +++ src/lib/openswarm/markdown.ts | Bin 0 -> 7665 bytes src/lib/openswarm/openswarm.test.ts | 248 +++++ src/lib/openswarm/records.ts | 188 ++++ src/lib/openswarm/route-helpers.ts | 22 + src/lib/openswarm/service.ts | 921 ++++++++++++++++++ src/lib/openswarm/types.ts | 103 ++ src/lib/supabase/types.ts | 605 ++++++++++++ .../20260906030000_pay2seed_hub.sql | 407 ++++++++ 27 files changed, 3258 insertions(+), 2 deletions(-) create mode 100644 src/app/.well-known/openswarm-hub.json/route.ts create mode 100644 src/app/api/openswarm/hub/route.ts create mode 100644 src/app/api/openswarm/pay2seed/attestations/[id]/route.ts create mode 100644 src/app/api/openswarm/pay2seed/attestations/route.ts create mode 100644 src/app/api/openswarm/pay2seed/leases/[id]/route.ts create mode 100644 src/app/api/openswarm/pay2seed/leases/route.ts create mode 100644 src/app/api/openswarm/pay2seed/notices/route.ts create mode 100644 src/app/api/openswarm/pay2seed/offers/[id]/route.ts create mode 100644 src/app/api/openswarm/pay2seed/offers/route.ts create mode 100644 src/app/api/openswarm/pay2seed/parties/[key]/route.ts create mode 100644 src/app/api/openswarm/pay2seed/parties/route.ts create mode 100644 src/app/api/openswarm/pay2seed/proofs/route.ts create mode 100644 src/app/api/openswarm/pay2seed/seeders/[key]/route.ts create mode 100644 src/app/swarm/[id]/page.tsx create mode 100644 src/app/swarm/page.tsx create mode 100644 src/lib/openswarm/index.ts create mode 100644 src/lib/openswarm/lanes.ts create mode 100644 src/lib/openswarm/markdown.ts create mode 100644 src/lib/openswarm/openswarm.test.ts create mode 100644 src/lib/openswarm/records.ts create mode 100644 src/lib/openswarm/route-helpers.ts create mode 100644 src/lib/openswarm/service.ts create mode 100644 src/lib/openswarm/types.ts create mode 100644 supabase/migrations/20260906030000_pay2seed_hub.sql diff --git a/src/app/.well-known/openswarm-hub.json/route.ts b/src/app/.well-known/openswarm-hub.json/route.ts new file mode 100644 index 00000000..c9774c85 --- /dev/null +++ b/src/app/.well-known/openswarm-hub.json/route.ts @@ -0,0 +1,10 @@ +/** @route GET /.well-known/openswarm-hub.json — the hub record, where clients look first. */ +import { NextRequest } from 'next/server'; +import { hubRecord } from '@/lib/openswarm/service'; +import { json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + return json(hubRecord(new URL(request.url).origin)); +} diff --git a/src/app/api/openswarm/hub/route.ts b/src/app/api/openswarm/hub/route.ts new file mode 100644 index 00000000..601be0c7 --- /dev/null +++ b/src/app/api/openswarm/hub/route.ts @@ -0,0 +1,16 @@ +/** + * @route GET /api/openswarm/hub — the hub record. + * + * Also served at /.well-known/openswarm-hub.json, which is where the spec says + * a client looks. It says who this hub is, what it charges, which consent + * bases it will list, and that it carries all four lanes. + */ +import { NextRequest } from 'next/server'; +import { hubRecord } from '@/lib/openswarm/service'; +import { json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + return json(hubRecord(new URL(request.url).origin)); +} diff --git a/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts b/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts new file mode 100644 index 00000000..a711e628 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/attestations/[id] — the record and where it stands. */ +import { NextRequest } from 'next/server'; +import { getAttestation } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const attestation = await getAttestation(decodeURIComponent(id)); + if (!attestation) return json({ error: 'no such attestation' }, 404); + return json({ attestation }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/attestations/route.ts b/src/app/api/openswarm/pay2seed/attestations/route.ts new file mode 100644 index 00000000..b0277f45 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/attestations/route.ts @@ -0,0 +1,23 @@ +/** + * @route POST /api/openswarm/pay2seed/attestations — register consent. + * + * Nothing is listed on this hub without one. The record is signed by the key + * that claims it, carries a README, and says on what basis it may be shared. + * A public claim is visible for a window first, so a notice can void it before + * anyone is paid to seed something the requester had no right to. + */ +import { NextRequest } from 'next/server'; +import { registerAttestation } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const record = await body(request); + const { attestation, id } = await registerAttestation(record); + return json({ id, attestation }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/leases/[id]/route.ts b/src/app/api/openswarm/pay2seed/leases/[id]/route.ts new file mode 100644 index 00000000..9350e54b --- /dev/null +++ b/src/app/api/openswarm/pay2seed/leases/[id]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/leases/[id] — a lease, what it has earned, where it stands. */ +import { NextRequest } from 'next/server'; +import { getLease } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const lease = await getLease(decodeURIComponent(id)); + if (!lease) return json({ error: 'no such lease' }, 404); + return json({ lease }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/leases/route.ts b/src/app/api/openswarm/pay2seed/leases/route.ts new file mode 100644 index 00000000..77a480f1 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/leases/route.ts @@ -0,0 +1,26 @@ +/** + * @route POST /api/openswarm/pay2seed/leases — take a slot on an offer. + * + * The seeder proves it holds the key by signing the offer id. It must already + * be a payee, and clear the hub's standing floor. The lane is stamped here, at + * the moment the money is agreed, so it survives either party later changing + * kind. + */ +import { NextRequest } from 'next/server'; +import { takeLease } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ offer?: string; seeder?: string; sig?: string }>(request); + if (!input.offer || !input.seeder || !input.sig) { + return json({ error: 'offer, seeder and sig are required' }, 400); + } + const lease = await takeLease({ offerId: input.offer, seederKey: input.seeder, sig: input.sig }); + return json({ lease }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/notices/route.ts b/src/app/api/openswarm/pay2seed/notices/route.ts new file mode 100644 index 00000000..0ef9d85d --- /dev/null +++ b/src/app/api/openswarm/pay2seed/notices/route.ts @@ -0,0 +1,47 @@ +/** + * @route POST /api/openswarm/pay2seed/notices — a claim against an attestation. + * + * Anyone may file one. The hub records it and forwards it to the requester's + * notice endpoint; whether the attestation is voided or stands is reviewed, + * and a hub that voided on nothing at all would not be conformant either way. + */ +import { NextRequest } from 'next/server'; +import { fileNotice } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ + attestation?: string; + kind?: 'rights' | 'illegal' | 'personal-data' | 'other'; + statement?: string; + claimant?: { name?: string; contact?: string }; + record?: unknown; + }>(request); + if (!input.attestation || !input.statement) { + return json({ error: 'attestation and statement are required' }, 400); + } + const result = await fileNotice({ + attestationId: input.attestation, + kind: input.kind ?? 'other', + statement: input.statement, + claimantName: input.claimant?.name ?? null, + claimantContact: input.claimant?.contact ?? null, + record: input.record, + }); + return json( + { + notice: result.noticeId, + attestation: result.attestation.id, + // Say plainly what happens next rather than implying it is already done. + outcome: 'received', + noticeEndpoint: result.attestation.noticeEndpoint, + }, + 201 + ); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/offers/[id]/route.ts b/src/app/api/openswarm/pay2seed/offers/[id]/route.ts new file mode 100644 index 00000000..d2c253d8 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/offers/[id]/route.ts @@ -0,0 +1,32 @@ +/** @route GET /api/openswarm/pay2seed/offers/[id] — an offer, its attestation and its leases. */ +import { NextRequest } from 'next/server'; +import { getAttestation, getOffer } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const offer = await getOffer(decodeURIComponent(id)); + if (!offer) return json({ error: 'no such offer' }, 404); + const attestation = await getAttestation(offer.attestationId); + return json({ + offer, + attestation: attestation + ? { + id: attestation.id, + basis: attestation.basis, + visibility: attestation.visibility, + license: attestation.license, + description: attestation.description, + subject: attestation.subject, + status: attestation.status, + readme: attestation.readme, + } + : null, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/offers/route.ts b/src/app/api/openswarm/pay2seed/offers/route.ts new file mode 100644 index 00000000..68769661 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/offers/route.ts @@ -0,0 +1,48 @@ +/** + * @route GET /api/openswarm/pay2seed/offers — the market a seeder picks work from. + * @route POST /api/openswarm/pay2seed/offers — post an offer against an attestation. + * + * The market only shows offers whose attestation is honoured, which is what + * the claim window is for. Every row carries the basis and the visibility, so a + * seeder knows before accepting whether it would be holding a stranger's + * ciphertext or seeding an openly licensed dataset in the clear. + */ +import { NextRequest } from 'next/server'; +import { createOffer, listMarket, quoteOffer, type OfferDraft } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; +import type { Basis, Visibility } from '@/lib/openswarm/types'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const rows = await listMarket({ + visibility: (url.searchParams.get('visibility') as Visibility) ?? undefined, + basis: (url.searchParams.get('basis') as Basis) ?? undefined, + limit: Number(url.searchParams.get('limit') ?? 50), + }); + return json({ offers: rows }); + } catch (error) { + return failed(error); + } +} + +export async function POST(request: NextRequest) { + try { + const input = await body(request); + if (!input.requester) return json({ error: 'requester is required' }, 400); + if (!input.attestation) return json({ error: 'attestation is required' }, 400); + + // A quote is free and pure, so a page can show the price without + // committing anyone to it. + if (input.quoteOnly) return json({ quote: quoteOffer(input) }); + + const { offer, quote } = await createOffer(input, input.requester); + // Unpaid until the money settles: a budget that is not escrowed is not a + // promise anybody should seed against. + return json({ offer, quote, status: 'unpaid' }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/parties/[key]/route.ts b/src/app/api/openswarm/pay2seed/parties/[key]/route.ts new file mode 100644 index 00000000..0d0c4439 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/parties/[key]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/parties/[key] — a key's standing and balance. */ +import { NextRequest } from 'next/server'; +import { getParty } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ key: string }> }) { + try { + const { key } = await params; + const party = await getParty(decodeURIComponent(key)); + if (!party) return json({ error: 'no such key here' }, 404); + return json({ party }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/parties/route.ts b/src/app/api/openswarm/pay2seed/parties/route.ts new file mode 100644 index 00000000..9d3fc7ef --- /dev/null +++ b/src/app/api/openswarm/pay2seed/parties/route.ts @@ -0,0 +1,32 @@ +/** + * @route POST /api/openswarm/pay2seed/parties — register a key. + * + * A party says what it is: a human, or a bit (an autonomous agent). An agent + * that wants to sell in public names the operator answerable for it. A payout + * address is what makes a key a payee, and without one it cannot take a lease, + * because there would be nowhere to pay. + */ +import { NextRequest } from 'next/server'; +import { registerParty } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; +import type { PartyKind } from '@/lib/openswarm/lanes'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ + key?: string; + kind?: PartyKind; + operatorKey?: string | null; + label?: string | null; + payoutAddress?: string | null; + payoutNetwork?: string | null; + }>(request); + if (!input.key) return json({ error: 'key is required' }, 400); + const party = await registerParty({ ...input, key: input.key }); + return json({ party }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/proofs/route.ts b/src/app/api/openswarm/pay2seed/proofs/route.ts new file mode 100644 index 00000000..a912076a --- /dev/null +++ b/src/app/api/openswarm/pay2seed/proofs/route.ts @@ -0,0 +1,56 @@ +/** + * @route POST /api/openswarm/pay2seed/proofs — report a period's verdict. + * + * A passed period is paid at once; the receipt's unique (lease, period) is the + * whole of the idempotency, so a verifier that reports twice pays once. Two + * consecutive failures end the lease and reopen the slot. + * + * Only the hub's own verifier may report today. A seeder cannot mark its own + * period proven, which is the point of a proof. + */ +import { NextRequest } from 'next/server'; +import { reportProof } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +function authorised(request: NextRequest): boolean { + const expected = process.env.OPENSWARM_VERIFIER_TOKEN; + if (!expected) return false; + const header = request.headers.get('authorization') ?? ''; + return header === `Bearer ${expected}`; +} + +export async function POST(request: NextRequest) { + try { + if (!authorised(request)) return json({ error: 'a verifier token is required to report a proof' }, 401); + const input = await body<{ + lease?: string; + period?: number; + kind?: 'challenge' | 'probe'; + passed?: boolean; + verifier?: string | null; + detail?: string | null; + record?: unknown; + }>(request); + if (!input.lease || typeof input.period !== 'number' || typeof input.passed !== 'boolean') { + return json({ error: 'lease, period and passed are required' }, 400); + } + const result = await reportProof({ + leaseId: input.lease, + period: input.period, + kind: input.kind ?? 'probe', + passed: input.passed, + verifierKey: input.verifier ?? null, + detail: input.detail ?? null, + record: input.record, + }); + return json({ + lease: result.lease, + earnedUsd: result.earnedUsd, + alreadyRecorded: result.alreadyRecorded, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts b/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts new file mode 100644 index 00000000..6a61e46f --- /dev/null +++ b/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts @@ -0,0 +1,28 @@ +/** @route GET /api/openswarm/pay2seed/seeders/[key] — a seeder's standing, balance and leases. */ +import { NextRequest } from 'next/server'; +import { getParty, listLeasesFor } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ key: string }> }) { + try { + const { key } = await params; + const seeder = decodeURIComponent(key); + const party = await getParty(seeder); + if (!party) return json({ error: 'no such key here' }, 404); + const leases = await listLeasesFor(seeder); + return json({ + key: party.key, + kind: party.kind, + standing: party.seederStanding, + proven: party.proven, + failed: party.failed, + abandoned: party.abandoned, + balanceUsd: party.balanceUsd, + leases, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/swarm/[id]/page.tsx b/src/app/swarm/[id]/page.tsx new file mode 100644 index 00000000..bc5ca1ef --- /dev/null +++ b/src/app/swarm/[id]/page.tsx @@ -0,0 +1,139 @@ +/** + * /swarm/[id] — one swarm's page, built from its README. + * + * This is what a consented swarm looks like beside a bare infohash. The README + * is rendered from the attestation, so the page works without a key even when + * the swarm itself is ciphertext nobody here can read. + */ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import DOMPurify from 'isomorphic-dompurify'; +import { MainLayout } from '@/components/layout'; +import { getAttestation, getOffer } from '@/lib/openswarm/service'; +import { readmeSummary, renderReadme } from '@/lib/openswarm/markdown'; + +export const dynamic = 'force-dynamic'; + +const BASIS_WORDS: Record = { + own: 'Their own work', + licensed: 'Licensed for redistribution', + 'open-license': 'Open licence', + 'public-domain': 'Public domain', + personal: 'Personal backup', +}; + +async function load(id: string) { + const offer = await getOffer(id); + if (!offer) return null; + const attestation = await getAttestation(offer.attestationId); + return attestation ? { offer, attestation } : null; +} + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const found = await load(decodeURIComponent(id)).catch(() => null); + if (!found) return { title: 'Swarm | BitTorrented' }; + const title = found.attestation.description || readmeSummary(found.attestation.readme, 60) || 'A swarm'; + return { + title: `${title} | BitTorrented`, + description: readmeSummary(found.attestation.readme), + alternates: { canonical: `/swarm/${id}` }, + }; +} + +export default async function SwarmPage({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const found = await load(decodeURIComponent(id)).catch(() => null); + if (!found) notFound(); + const { offer, attestation } = found; + + // The renderer emits no raw HTML from the source; sanitising as well is the + // second cheap defence rather than the only one. + const html = DOMPurify.sanitize(renderReadme(attestation.readme), { USE_PROFILES: { html: true } }); + const subject = + attestation.subject.infohashV1 || attestation.subject.infohashV2 || attestation.subject.file || attestation.subject.channel; + + return ( + +
+ + +
+

+ {attestation.description || readmeSummary(attestation.readme, 70) || 'A swarm'} +

+
+
+
Basis:
+
+ {BASIS_WORDS[attestation.basis] ?? attestation.basis} + {attestation.license ? ` (${attestation.license})` : ''} +
+
+
+
Visibility:
+
+ {attestation.visibility === 'public' ? 'public' : 'private, ciphertext only'} +
+
+
+
Status:
+
{attestation.status}
+
+
+
+ +
+ +
+

What is on offer

+
+
+
Kept for
+
{offer.days} days
+
+
+
Seeders wanted
+
+ {offer.seedersMin} to {offer.seedersMax} +
+
+
+
Per GiB-month
+
${offer.priceUsdPerGibMonth}
+
+
+
Budget
+
${offer.budgetUsd}
+
+
+
Paid out so far
+
${offer.spentUsd}
+
+
+
Offer status
+
{offer.status}
+
+
+ {subject ?

Subject: {subject}

: null} +
+ +

+ Something wrong with this listing?{' '} + + File a notice + + . An attestation that is voided takes every offer and lease on it with it. +

+
+
+ ); +} diff --git a/src/app/swarm/page.tsx b/src/app/swarm/page.tsx new file mode 100644 index 00000000..98ac52a4 --- /dev/null +++ b/src/app/swarm/page.tsx @@ -0,0 +1,138 @@ +/** + * /swarm — the market. + * + * What a seeder picks work from, and what a reader sees instead of a bare + * infohash. Every row carries the basis and the visibility in words, because + * that is the difference between being asked to hold a stranger's ciphertext + * and being asked to seed an openly licensed dataset in the clear. + */ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { MainLayout } from '@/components/layout'; +import { listMarket } from '@/lib/openswarm/service'; +import { describeLane } from '@/lib/openswarm/lanes'; +import type { MarketRow } from '@/lib/openswarm/types'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'The swarm market | BitTorrented', + description: + 'Data people are paying to keep alive, and what a seeder earns for holding it. Consent is signed at upload; public swarms are free to fetch.', + alternates: { canonical: '/swarm' }, +}; + +const BASIS_WORDS: Record = { + own: 'Their own work', + licensed: 'Licensed for redistribution', + 'open-license': 'Open licence', + 'public-domain': 'Public domain', + personal: 'Personal backup', +}; + +function gib(bytes: number): string { + const value = bytes / 1024 ** 3; + return value >= 10 ? `${Math.round(value)} GB` : `${value.toFixed(1)} GB`; +} + +function Row({ row }: { row: MarketRow }): React.ReactElement { + const { offer } = row; + return ( +
  • +
    + + {row.description || row.summary || 'An unnamed swarm'} + + + ${row.projectedUsd} for {offer.days} days + +
    +

    {row.summary}

    +
    +
    +
    Basis:
    +
    + {BASIS_WORDS[row.basis] ?? row.basis} + {row.license ? ` (${row.license})` : ''} +
    +
    +
    +
    Visibility:
    +
    + {row.visibility === 'public' ? 'public, readable by anyone' : 'private, ciphertext only'} +
    +
    +
    +
    Size:
    +
    {gib(offer.sizeBytes)}
    +
    +
    +
    Slots:
    +
    + {row.slotsFree} free of {offer.seedersMax} +
    +
    +
    +
    Posted by:
    +
    {row.requesterKind === 'bit' ? 'an agent' : 'a person'}
    +
    +
    +
  • + ); +} + +export default async function SwarmMarketPage(): Promise { + let rows: MarketRow[] = []; + let error: string | null = null; + try { + rows = await listMarket({ limit: 50 }); + } catch { + error = 'The market could not be read just now.'; + } + + return ( + +
    +
    +

    The swarm market

    +

    + Data somebody is paying to keep alive, and what a seeder earns for holding it. Consent is signed at + upload, so every swarm here says who put it there and on what basis. Public swarms are free to fetch; + private ones are ciphertext a seeder holds without ever reading. +

    +
    + +
    +

    + Both sides earn. Rent out disk you already have, or pay to keep something online. The hub takes 1 + percent of what crosses it, charged to whoever is paying and never taken out of a seeder's floor. + It carries every lane: {describeLane('h2h')}, {describeLane('h2b')}, {describeLane('b2h')} and{' '} + {describeLane('b2b')}. +

    +

    + The protocol is open:{' '} + + pay2seed, paid2seed, pay2stream and paid2stream + + . +

    +
    + + {error ?

    {error}

    : null} + + {rows.length ? ( +
      + {rows.map((row) => ( + + ))} +
    + ) : ( +

    + Nothing is listed yet. A public claim waits out its window before it appears here, so that a notice can + void it before anyone is paid to seed it. +

    + )} +
    +
    + ); +} diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index ac34d237..c94d532d 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -349,7 +349,7 @@ describe('Sidebar Navigation', () => { // Pricing, Settings = 2 account // Logo = 1 // External: The Pirate Bay, LimeTorrents, 1337x, IMDB = 4 - expect(links.length).toBe(23); + expect(links.length).toBe(24); }); it('should show all nav items when logged in', () => { @@ -357,7 +357,7 @@ describe('Sidebar Navigation', () => { const links = screen.getAllByRole('link'); // Logo link + 14 main nav items + 2 account items + 4 external sites = 21 - expect(links.length).toBe(23); + expect(links.length).toBe(24); }); }); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 462d2f0a..fbd8a3e9 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -61,6 +61,7 @@ const mainNavItems: NavItem[] = [ { href: '/upcoming', label: 'Upcoming', icon: MovieIcon, requiresPaid: true }, { href: '/torrents', label: 'Torrents', icon: MagnetIcon }, { href: '/seedboxes', label: 'Seedboxes', icon: DownloadIcon, requiresAuth: true }, + { href: '/swarm', label: 'Swarm Market', icon: MagnetIcon }, { href: '/vod/manage', label: 'VOD Providers', icon: MovieIcon, requiresAuth: true }, { href: '/news', label: 'News', icon: NewsIcon, requiresPaid: true }, { href: '/finance', label: 'Finance', icon: FinanceIcon, requiresPaid: true }, diff --git a/src/lib/openswarm/index.ts b/src/lib/openswarm/index.ts new file mode 100644 index 00000000..fb4ae74b --- /dev/null +++ b/src/lib/openswarm/index.ts @@ -0,0 +1,6 @@ +/** The pay2seed / paid2seed hub: bittorrented.com's OpenSwarm surface. */ +export * from './lanes'; +export * from './markdown'; +export * from './records'; +export * from './service'; +export * from './types'; diff --git a/src/lib/openswarm/lanes.ts b/src/lib/openswarm/lanes.ts new file mode 100644 index 00000000..f702744d --- /dev/null +++ b/src/lib/openswarm/lanes.ts @@ -0,0 +1,109 @@ +/** + * Lanes, and the hub's cut. + * + * Every party on this layer is a key, and a key is either a **human** or a + * **bit**: an autonomous agent that holds its own key, earns its own money and + * answers for its own consent. Because both sides of a payment can be either, + * there are exactly four lanes: + * + * h2h a person pays a person someone rents a stranger's disk + * h2b a person pays an agent you buy access to what an agent made + * b2h an agent pays a person an agent keeps its archive on your box + * b2b an agent pays an agent the one nobody else is serving + * + * The lane is stamped on a lease and on every receipt at the moment the money + * is agreed, so it survives a party later changing kind, and so we can see + * which lane is actually growing rather than guessing. + * + * The hub takes **1 percent** of what crosses it. It is charged to whoever is + * paying, on top of what they quoted, and is never taken out of a seeder's or + * a relay's earnings: a promised floor is what the seeder is paid. + */ +import { fromMicros, toMicros } from './records'; + +export type PartyKind = 'human' | 'bit'; +export type Lane = 'h2h' | 'h2b' | 'b2h' | 'b2b'; + +export const LANES: readonly Lane[] = ['h2h', 'h2b', 'b2h', 'b2b']; + +/** The reference hub's cut, in basis points. 100 bps is 1 percent. */ +export const HUB_FEE_BPS = 100; + +export function isPartyKind(value: unknown): value is PartyKind { + return value === 'human' || value === 'bit'; +} + +/** The lane a payment runs on: who pays, then who is paid. */ +export function laneFor(payer: PartyKind, payee: PartyKind): Lane { + return `${payer === 'bit' ? 'b' : 'h'}2${payee === 'bit' ? 'b' : 'h'}` as Lane; +} + +export function describeLane(lane: Lane): string { + switch (lane) { + case 'h2h': + return 'person to person'; + case 'h2b': + return 'person to agent'; + case 'b2h': + return 'agent to person'; + case 'b2b': + return 'agent to agent'; + } +} + +/** + * The hub's fee on an amount, rounded UP to the micro so a fraction of a cent + * is never quietly the hub's loss, and never more than a micro of anyone's. + */ +export function feeOn(amount: string, bps = HUB_FEE_BPS): string { + const micros = toMicros(amount); + const fee = (micros * BigInt(bps) + 9_999n) / 10_000n; + return fromMicros(fee); +} + +/** What a requester actually pays: the budget they set, plus the hub's cut. */ +export function totalWithFee(budget: string, bps = HUB_FEE_BPS): string { + return fromMicros(toMicros(budget) + toMicros(feeOn(budget, bps))); +} + +/** + * What one proven period earns (paid2seed §5.1): + * + * price per GiB-month × (bytes / 2^30) × (hours / 720) + * + * All of it in integer micro-USD, rounding down, so a hub and a seeder + * computing it separately always agree and the hub never over-pays itself out + * of a rounding difference. + */ +export function periodEarnings(args: { + priceUsdPerGibMonth: string; + sizeBytes: number | bigint; + everyHours: number; +}): string { + const price = toMicros(args.priceUsdPerGibMonth); + const bytes = BigInt(args.sizeBytes); + const hours = BigInt(Math.max(0, Math.floor(args.everyHours))); + const GIB = 1_073_741_824n; + return fromMicros((price * bytes * hours) / (GIB * 720n)); +} + +/** + * The budget an offer must escrow to cover every slot for its whole term. + * Rounded up, so an offer can never run out mid-period and leave a proven + * seeder unpaid. + */ +export function requiredBudget(args: { + priceUsdPerGibMonth: string; + sizeBytes: number | bigint; + days: number; + seedersMax: number; +}): string { + const price = toMicros(args.priceUsdPerGibMonth); + const bytes = BigInt(args.sizeBytes); + const days = BigInt(Math.max(1, Math.floor(args.days))); + const seats = BigInt(Math.max(1, Math.floor(args.seedersMax))); + const GIB = 1_073_741_824n; + const denominator = GIB * 30n; + const numerator = price * bytes * days * seats; + return fromMicros((numerator + denominator - 1n) / denominator); +} diff --git a/src/lib/openswarm/markdown.ts b/src/lib/openswarm/markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..09312fcf2eacc738b43112d9774c4b0bf0c671f6 GIT binary patch literal 7665 zcmb7J-EP~;5$?5LqH7%^B4wMhQ#9yNY#G5hIZc5EEs_AekmP7Ztt1}vuUyiKk7b}w z)4oR^rBBjtW_L--QXD52vB=$>nVsKnhW%deK=j0?l@cG`zWMq6TT!TVt_oEIcuz}j zZ&h(8^ja2)$Wl>aXCjLmRf0Csm7P(w15os~2qPP@`B1>>imPF3W z<=soI)Li`cKYt8f`2q9RxeDn(3X<8H_~ccQXIc%!Pg#;=>3gsg>!@6bKmYPxlyVj; zP2`B{#6QMJhh3GA1i394349obA2-BuT6qB_E;F4FfI10_NF4#j1Qp!AA zr*S6d8YUKb5#54Jy-DgDEsQ2|sdXUUq(aUhTZK*TB79-RO36~N&Y)hgD$Cr{zJL}J zxlC2bl1_h>x6)W_Fcm2o0yZjcp%+c6>K`108SW_Fe*Edp-`{>5hz}~viur{u3#b${ z`0r3$iu(g0x-Yr|{3nzAb@zmxFZekw?cPP`=ZnB(D5Js@FVSicztl7 zzT{Z}uddRtjNqxpW8RfX?3L9&88-mB%tvUVrrBV%X_U`jFWR zSF-TM<w|}bgMP0k{zh8U;YuZNP6{T3Uz%nmjHyCsn$+I**BNv|Tfr)_ zd`We+)LJgoJGhcZ{^Ue$WZsCByqXkflntFG34G^L(1Cy+JxC`+v=H8*Tit9#fAqW4 z-%t8MsdVYt_r4ErWd#sbcdCEwkq79j!N!<>;P<1ioU?@JXumIZA|*wT)&3s9Ar=*4 zRyvIseZ^5S(6CX2Vy8FGQ~+qv<{E#HWk?i1c@TmrewInHPT#~4k> zJTY=9Qd5Z+sW-ZwPLBO)KcMhn#_X)dl(f$=7vvB@Ug?!Ly`Y8R(S0kT z>CNbT;y+yUd2wpV2zN)mlZK9!$9vBv_J;LFD65RpN|ADecxcJXrec8)$0LY5o=kfF zcry0z+aGIOVC?U|0iSHHVC+GG32Jp@^QO^DN+QI$byNE+IF{=NA+E&qB1)D*7vW`R zOAkcbf9N12%10}P3#q7spptW>;LA=df4l1pr!CThcAhk6&L+OyP)leXr=?W4_4!)m zl0#nLbP6>-N2he|TPmeJ->p%}YJQ|Pr%=@O%TAg>y{Z7}Lf%EETNrc^1p`rqdOuQ{4x+uMJ<8)wr(wx+!?qvn*kEKHm_i!xQPvkgqU_*7`x{ofOuDzTE+a_^T~C3_`SNh7UFtfYAP zl$6-_JPB*fv@#XN437IYmUbiQoL2xRD0gu&CNjTfQR;QOSi_3RBa!|@`1eDnS6Dc*Fo4ec;>8PsgDPl> z-6`=kOhXmNdJiwdlYOUd*UTma)*zf)MnGL!rcoJbuHq_`1ZB!~MznzcWG?1N7HE0UUn$_gEL9?m z87AYWLCB9Ws_Aw4ERePdQD;(|<+HUUG5r$f<&R?VdFqsBjH{RhDg4=X(tCy5I zsJlVJAu}LL$U9A0f})-OS=Ay5#E|SgW)(q#y#DG$SnklBjVp01OeLU$ZS}37HX_i1 z5bp5HiE|F%;AUT=uw75wt3px-R}61-+WS1Ah!~&qf2bQ{dbM3L8l5$Qb}v@N&?FnW z!818S=k?%-1|4)WC z+xei8*5wa}JXmo%sn?~2#*o%^1$qvi=M68QeL2UU0#QppgY8<4eX9mkaS4Yz;@#A* zVGO+}hV<5uB2D28{i*p0VF<&{i~&HGIO1`a;d~Fn`7Xn$>tyKiTf(pakcSXejlydK z?~M{`V@yiWN;7)aJ;clL)~+-p_c}+N zLWg60)rQxNB8@Z92vg0!-yoF1Sd)(!)aq*(31OJ*B7_DmQh&gd7?RhH|6Fy=;w-#j zE)<~*%PoqvthcLqYx_6!h=TBjhW2t<$b2P=3U#;wI*d^cxS?jONuz11yga+(!9C`J z^c{d1wc%b(YOlth2msReo~%o+0oN?V80!sC8zQJ-=mUHYK~Iw8?d~P_(VkNqt76i1Jc(C`UZtBJ>SznxyUjE_=cB6$AT|o zA2{yEn<#f*z1R^oGAvs1foUaa3TitS>J8Do@Gu)6QCKyQic28~xdAya5PwiI&=xla z(iCgx6Pgtrnrn6lj>?4Q5qz zm+@}+o89J9TiMz`u6W4mLD{0eTeI~a%xHb*JwPLk+;`ps`8;M|3}8N;n5NTSp~-89 zS8lv$Mz)yPV(Q^@V|;XOhYi32^=x9)`#j}OJ#I3AMH6^N3mjkcv8Mi3!u%a-A6j2T nt8{d4R?O7-%z5c4aQ(yo{`GHakaz>prP#I8o-PXZ!@>Un7Kkh| literal 0 HcmV?d00001 diff --git a/src/lib/openswarm/openswarm.test.ts b/src/lib/openswarm/openswarm.test.ts new file mode 100644 index 00000000..b85645be --- /dev/null +++ b/src/lib/openswarm/openswarm.test.ts @@ -0,0 +1,248 @@ +/** + * The hub's arithmetic and its record rules. + * + * These are the parts that carry money and consent, so they are tested without + * a database: canonical bytes and signatures, the four lanes, the 1 percent, + * what a proven period earns, and a README renderer that must never emit HTML + * somebody put in their own README. + */ +import { describe, expect, it } from 'vitest'; +import { generateKeyPairSync, sign as signBytes } from 'node:crypto'; +import { + HUB_FEE_BPS, + LANES, + feeOn, + laneFor, + periodEarnings, + requiredBudget, + totalWithFee, +} from './lanes'; +import { renderReadme, readmeSummary, escapeHtml } from './markdown'; +import { + canonicalize, + canonicalBytes, + fromMicros, + isKey, + recordId, + signingMessage, + signers, + toMicros, + usd, + verifiedBy, + type SignedRecord, +} from './records'; + +/* ------------------------------------------------------------- key fixtures */ + +function keypair(): { key: string; sign: (record: SignedRecord) => string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + const key = `ed25519:${raw.toString('hex')}`; + return { + key, + sign: (record) => signBytes(null, signingMessage(record.type, record), privateKey).toString('base64'), + }; +} + +const record = (type: string, fields: Record): SignedRecord => + ({ openswarm: '0.1', type, createdAt: '2026-09-06T00:00:00.000Z', ...fields, sigs: [] }) as SignedRecord; + +/* ----------------------------------------------------------------- records */ + +describe('canonical form', () => { + it('sorts keys and drops insignificant whitespace', () => { + expect(canonicalize({ b: 1, a: 'x' })).toBe('{"a":"x","b":1}'); + expect(canonicalize([1, { z: true, y: null }])).toBe('[1,{"y":null,"z":true}]'); + }); + + it('refuses a fractional number, because money is a string here', () => { + // A float in the canonical bytes is exactly the ambiguity six-decimal + // strings exist to avoid. + expect(() => canonicalize({ amount: 0.1 })).toThrow(/fractional/); + }); + + it('computes the id over everything but the signatures', () => { + const unsigned = record('pay2seed.attestation', { basis: 'own' }); + const signed = { ...unsigned, sigs: [{ alg: 'ed25519', key: 'ed25519:' + 'a'.repeat(64), sig: 'x' }] }; + expect(canonicalBytes(unsigned)).toBe(canonicalBytes(signed)); + expect(recordId(unsigned)).toBe(recordId(signed)); + expect(recordId(unsigned)).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); + +describe('signatures', () => { + it('verifies a record signed by its key', () => { + const alice = keypair(); + const draft = record('pay2seed.attestation', { requester: alice.key, basis: 'own' }); + const signed = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }] }; + expect(verifiedBy(signed, alice.key)).toBe(true); + expect(signers(signed)).toEqual([alice.key]); + }); + + it('refuses a signature from another key, and one over a changed body', () => { + const alice = keypair(); + const mallory = keypair(); + const draft = record('pay2seed.attestation', { requester: alice.key, basis: 'own' }); + const signed = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }] }; + + expect(verifiedBy(signed, mallory.key)).toBe(false); + // Editing the body after signing must not still verify. + expect(verifiedBy({ ...signed, basis: 'licensed' } as SignedRecord, alice.key)).toBe(false); + }); + + it('will not let a signature be replayed as another record type', () => { + // The domain prefix is the whole defence here: same body, different type. + const alice = keypair(); + const draft = record('pay2seed.attestation', { amount: '1.000000' }); + const signature = alice.sign(draft); + const asAttestation = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: signature }] }; + const asReceipt = { ...draft, type: 'paid2seed.receipt', sigs: [{ alg: 'ed25519', key: alice.key, sig: signature }] }; + expect(verifiedBy(asAttestation, alice.key)).toBe(true); + expect(verifiedBy(asReceipt as SignedRecord, alice.key)).toBe(false); + }); + + it('ignores an algorithm it does not implement rather than failing', () => { + const alice = keypair(); + const draft = record('pay2seed.attestation', { basis: 'own' }); + const signed = { + ...draft, + sigs: [ + { alg: 'mldsa65', key: 'mldsa65:beef', sig: 'whatever' }, + { alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }, + ], + }; + expect(verifiedBy(signed, alice.key)).toBe(true); + }); + + it('knows a key when it sees one', () => { + expect(isKey(`ed25519:${'a'.repeat(64)}`)).toBe(true); + expect(isKey(`ed25519:${'A'.repeat(64)}`)).toBe(false); + expect(isKey('ed25519:tooshort')).toBe(false); + expect(isKey(42)).toBe(false); + }); +}); + +/* ------------------------------------------------------------------- money */ + +describe('money', () => { + it('round-trips six-decimal strings through integer micros', () => { + expect(toMicros('1.500000')).toBe(1_500_000n); + expect(fromMicros(1_500_000n)).toBe('1.500000'); + expect(fromMicros(1n)).toBe('0.000001'); + expect(usd(0.1 + 0.2)).toBe('0.300000'); + }); + + it('refuses an amount that is not exactly six decimals', () => { + expect(() => toMicros('1.5')).toThrow(); + expect(() => toMicros('1')).toThrow(); + }); +}); + +describe('the hub takes one percent', () => { + it('is 100 basis points, rounded up to the micro', () => { + expect(HUB_FEE_BPS).toBe(100); + expect(feeOn('100.000000')).toBe('1.000000'); + expect(totalWithFee('100.000000')).toBe('101.000000'); + // Rounded up, so a fraction of a micro is never quietly the hub's loss. + expect(feeOn('0.000050')).toBe('0.000001'); + }); + + it('is charged on top, so a quoted budget is what reaches the seeders', () => { + const budget = '12.000000'; + expect(totalWithFee(budget)).toBe('12.120000'); + // The budget itself is untouched: the fee is the payer's, not the seeder's. + expect(budget).toBe('12.000000'); + }); +}); + +describe('lanes', () => { + it('carries all four, and names who pays whom', () => { + expect(LANES).toEqual(['h2h', 'h2b', 'b2h', 'b2b']); + expect(laneFor('human', 'human')).toBe('h2h'); + expect(laneFor('human', 'bit')).toBe('h2b'); + expect(laneFor('bit', 'human')).toBe('b2h'); + expect(laneFor('bit', 'bit')).toBe('b2b'); + }); +}); + +describe('what a period earns', () => { + const GIB = 1024 ** 3; + + it('is price × size × time, and a whole GiB-month is the price', () => { + // One GiB, one month of six-hour periods: 120 periods make the full price. + const perPeriod = periodEarnings({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, everyHours: 6 }); + expect(perPeriod).toBe('0.001250'); + expect(toMicros(perPeriod) * 120n).toBe(toMicros('0.150000')); + }); + + it('rounds down, so a hub and a seeder computing it apart always agree', () => { + const earned = periodEarnings({ priceUsdPerGibMonth: '0.000001', sizeBytes: 1, everyHours: 1 }); + expect(earned).toBe('0.000000'); + }); + + it('escrows enough for every slot for the whole term, rounded up', () => { + const budget = requiredBudget({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, days: 30, seedersMax: 3 }); + expect(budget).toBe('0.450000'); + // A proven period can never exceed what was escrowed for it. + const perPeriod = periodEarnings({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, everyHours: 6 }); + expect(toMicros(perPeriod) * 120n * 3n).toBeLessThanOrEqual(toMicros(budget)); + }); +}); + +/* ---------------------------------------------------------------- markdown */ + +describe('the README renderer', () => { + it('renders the subset a listing needs', () => { + const html = renderReadme('# Title\n\nA line with **bold** and `code`.\n\n- one\n- two\n'); + // A README's own h1 steps down, so the page keeps one h1 of its own. + expect(html).toContain('

    Title

    '); + expect(html).toContain('bold'); + expect(html).toContain('code'); + expect(html).toContain('
  • one
  • '); + }); + + it('renders GFM tables and fenced code', () => { + const html = renderReadme('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```sh\necho hi\n```'); + expect(html).toContain('a'); + expect(html).toContain('1'); + expect(html).toContain('
    echo hi
    '); + }); + + it('never emits HTML somebody put in their own README', () => { + const html = renderReadme('\n\n'); + // The words survive as text; what must not survive is a tag or an + // attribute, so assert on the markup rather than on the string. + expect(html).not.toContain(' { + const bad = renderReadme('[click](javascript:alert(1))'); + expect(bad).not.toContain('href="javascript'); + + // A relative link resolves into the swarm and is gated as the file is. + const relative = renderReadme('[the data](data/set.csv)'); + expect(relative).toContain('href="data/set.csv"'); + expect(relative).not.toContain('target="_blank"'); + + // An absolute one leaves the site, so it is marked up as such. + const absolute = renderReadme('[home](https://example.com)'); + expect(absolute).toContain('rel="noreferrer nofollow ugc"'); + expect(absolute).toContain('target="_blank"'); + }); + + it('will not let a link climb out of the swarm', () => { + expect(renderReadme('[up](../../etc/passwd)')).not.toContain('href="../'); + }); + + it('summarises the first real line, skipping headings and fences', () => { + expect(readmeSummary('# Heading\n\n```\ncode\n```\n\nThe actual summary.')).toBe('The actual summary.'); + expect(readmeSummary('# Only a heading')).toBe(''); + }); + + it('escapes the five characters that matter', () => { + expect(escapeHtml(`<&>"'`)).toBe('<&>"''); + }); +}); diff --git a/src/lib/openswarm/records.ts b/src/lib/openswarm/records.ts new file mode 100644 index 00000000..acb0d7e6 --- /dev/null +++ b/src/lib/openswarm/records.ts @@ -0,0 +1,188 @@ +/** + * OpenSwarm records: canonical form, ids and signatures. + * + * Every object in the family is a signed JSON record (core §3). The rules are + * short and none of them are ours to reinterpret: + * + * canonical bytes = RFC 8785 JCS of the record with `sigs` removed + * id = "sha256:" + lowercase hex of SHA-256 over those bytes + * signed message = "openswarm:sig:v1:" + type + "\n" + canonical bytes + * + * The domain prefix is what stops a signature over an attestation being + * replayed as a signature over a receipt with the same body. + * + * Money is a decimal string with exactly six fractional digits, never a float, + * so JCS never has to serialise one and rounding is always ours to decide. + */ +import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto'; + +export const OPENSWARM_VERSION = '0.1'; + +/** `:`; ed25519 public keys are 32 bytes, so 64 hex characters. */ +const KEY_PATTERN = /^ed25519:[0-9a-f]{64}$/; +/** SPKI DER prefix for an Ed25519 public key, so node can import raw bytes. */ +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +export interface Signature { + alg: string; + key: string; + sig: string; +} + +export interface SignedRecord { + openswarm: string; + type: string; + createdAt: string; + sigs: Signature[]; + [field: string]: unknown; +} + +export function isKey(value: unknown): value is string { + return typeof value === 'string' && KEY_PATTERN.test(value); +} + +/** + * RFC 8785 canonical JSON: object keys sorted by their UTF-16 code units, no + * insignificant whitespace. Numbers are rejected outright rather than + * serialised — this family puts every amount in a string precisely so the + * float-formatting half of JCS never runs. + */ +export function canonicalize(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new OpenSwarmRecordError('a record may not carry a fractional number; amounts are strings'); + } + if (!Number.isSafeInteger(value)) { + throw new OpenSwarmRecordError('a record may not carry an integer beyond 2^53'); + } + return String(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`; + } + throw new OpenSwarmRecordError(`a record may not carry a ${typeof value}`); +} + +export class OpenSwarmRecordError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = 'OpenSwarmRecordError'; + } +} + +/** The bytes a record's id and signatures are computed over: everything but `sigs`. */ +export function canonicalBytes(record: Record): string { + const { sigs: _sigs, ...rest } = record; + return canonicalize(rest); +} + +export function recordId(record: Record): string { + return `sha256:${createHash('sha256').update(canonicalBytes(record), 'utf8').digest('hex')}`; +} + +export function sha256Hex(input: string | Buffer): string { + return createHash('sha256').update(input).digest('hex'); +} + +function publicKeyFrom(key: string) { + if (!isKey(key)) throw new OpenSwarmRecordError(`not an ed25519 key: ${key}`); + const raw = Buffer.from(key.slice('ed25519:'.length), 'hex'); + return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' }); +} + +/** The exact bytes a signer signs for this record type. */ +export function signingMessage(type: string, record: Record): Buffer { + return Buffer.from(`openswarm:sig:v1:${type}\n${canonicalBytes(record)}`, 'utf8'); +} + +/** + * Is this record signed by `key`? + * + * A verifier that does not implement an algorithm MUST ignore those entries + * rather than fail, so an mldsa65 signature alongside ed25519 is not an error + * here; it simply is not what satisfies the check. + */ +export function verifiedBy(record: SignedRecord, key: string): boolean { + if (!Array.isArray(record.sigs)) return false; + const message = signingMessage(record.type, record); + return record.sigs.some((entry) => { + if (!entry || entry.alg !== 'ed25519' || entry.key !== key) return false; + try { + return verifySignature(null, message, publicKeyFrom(entry.key), Buffer.from(entry.sig, 'base64')); + } catch { + return false; + } + }); +} + +/** Every key that has a good ed25519 signature over this record. */ +export function signers(record: SignedRecord): string[] { + if (!Array.isArray(record.sigs)) return []; + const seen = new Set(); + for (const entry of record.sigs) { + if (entry?.alg === 'ed25519' && isKey(entry.key) && !seen.has(entry.key) && verifiedBy(record, entry.key)) { + seen.add(entry.key); + } + } + return [...seen]; +} + +/** Shape and envelope checks every record must pass before anything else looks at it. */ +export function assertEnvelope(value: unknown, type: string): SignedRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OpenSwarmRecordError('a record must be a JSON object'); + } + const record = value as SignedRecord; + if (record.openswarm !== OPENSWARM_VERSION) { + throw new OpenSwarmRecordError(`this hub speaks openswarm ${OPENSWARM_VERSION}`); + } + if (record.type !== type) { + throw new OpenSwarmRecordError(`expected a ${type} record, got ${String(record.type)}`); + } + if (typeof record.createdAt !== 'string' || Number.isNaN(Date.parse(record.createdAt))) { + throw new OpenSwarmRecordError('createdAt must be an RFC 3339 timestamp'); + } + if (!Array.isArray(record.sigs) || record.sigs.length === 0) { + throw new OpenSwarmRecordError('a record must carry at least one signature'); + } + return record; +} + +/* -------------------------------------------------------------- money ---- */ + +const SCALE = 1_000_000n; + +/** Parse a six-decimal money string into integer micro-USD. */ +export function toMicros(amount: string): bigint { + if (!/^\d{1,12}\.\d{6}$/.test(amount)) { + throw new OpenSwarmRecordError(`an amount is a decimal string with six fractional digits, not "${amount}"`); + } + const [whole, fraction] = amount.split('.'); + return BigInt(whole) * SCALE + BigInt(fraction); +} + +/** Format integer micro-USD back into the wire's six-decimal string. */ +export function fromMicros(micros: bigint): string { + const negative = micros < 0n; + const value = negative ? -micros : micros; + const whole = value / SCALE; + const fraction = (value % SCALE).toString().padStart(6, '0'); + return `${negative ? '-' : ''}${whole}.${fraction}`; +} + +/** A number of USD (a DB numeric, say) as the wire's money string. */ +export function usd(amount: number | string): string { + if (typeof amount === 'string') return toMicros(amount) === 0n ? '0.000000' : amount; + if (!Number.isFinite(amount)) throw new OpenSwarmRecordError('an amount must be finite'); + return (Math.round(amount * 1e6) / 1e6).toFixed(6); +} diff --git a/src/lib/openswarm/route-helpers.ts b/src/lib/openswarm/route-helpers.ts new file mode 100644 index 00000000..d0d494ff --- /dev/null +++ b/src/lib/openswarm/route-helpers.ts @@ -0,0 +1,22 @@ +/** Turning a hub decision into an HTTP answer, the same way on every route. */ +import { NextResponse } from 'next/server'; +import { HubError } from './service'; +import { OpenSwarmRecordError } from './records'; + +export const json = (body: unknown, status = 200): NextResponse => + NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } }); + +/** + * A refusal is information, not a stack trace: the caller is a program signing + * records, and the message is what tells it which rule it broke. + */ +export function failed(error: unknown): NextResponse { + if (error instanceof HubError) return json({ error: error.message }, error.status); + if (error instanceof OpenSwarmRecordError) return json({ error: error.message }, error.status); + console.error('[openswarm]', error); + return json({ error: 'the hub could not complete that' }, 500); +} + +export async function body(request: Request): Promise { + return (await request.json().catch(() => ({}))) as T; +} diff --git a/src/lib/openswarm/service.ts b/src/lib/openswarm/service.ts new file mode 100644 index 00000000..15e23159 --- /dev/null +++ b/src/lib/openswarm/service.ts @@ -0,0 +1,921 @@ +/** + * The pay2seed / paid2seed hub. + * + * bittorrented.com is the reference hub for the OpenSwarm payment family. This + * file is the whole of the hub's decision-making: what it will list, what it + * charges, who may take a lease, what a proven period earns, and what a notice + * does. The API routes are thin over it and the tests drive it directly. + * + * Two rules run through everything: + * + * Nothing is listed without consent. An attestation says who put the data + * there and on what basis, it is signed by the key that claims it, and a + * public claim waits out a window in which a notice can void it. + * + * A promised floor is what the seeder is paid. The hub's 1 percent is + * charged to whoever is paying, on top of what they quoted, and never comes + * out of a seeder's earnings. + */ +import { createServerClient } from '@/lib/supabase'; +import type { Database, Json } from '@/lib/supabase/types'; +import { + HUB_FEE_BPS, + feeOn, + isPartyKind, + laneFor, + periodEarnings, + requiredBudget, + totalWithFee, + type Lane, + type PartyKind, +} from './lanes'; +import { readmeSummary } from './markdown'; +import { + OPENSWARM_VERSION, + OpenSwarmRecordError, + assertEnvelope, + fromMicros, + isKey, + recordId, + sha256Hex, + signers, + toMicros, + usd, + verifiedBy, + type SignedRecord, +} from './records'; +import { BASES, type Attestation, type Basis, type Lease, type MarketRow, type Offer, type Party, type Subject, type Visibility } from './types'; + +export class HubError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = 'HubError'; + } +} + +/** Hours a public claim is visible before it can be listed (pay2seed §3.3). */ +export const CLAIM_WINDOW_HOURS = Number(process.env.OPENSWARM_CLAIM_HOURS ?? 24); +/** A seeder needs at least this standing to take a lease. */ +export const MIN_SEEDER_STANDING = Number(process.env.OPENSWARM_MIN_STANDING ?? 0); +export const MAX_OFFER_DAYS = 365; +const READMES_MAX = 65_536; + +const db = () => createServerClient(); + +type Tables = Database['public']['Tables']; +type PartyInsert = Tables['openswarm_parties']['Insert']; +type PartyUpdate = Tables['openswarm_parties']['Update']; + +/** + * Money crosses into Postgres as NUMERIC(14,6). Everything above this line is + * an exact six-decimal string over integer micros; a double holds those + * magnitudes exactly (a micro of 10^14 is well inside 2^53), so the conversion + * is lossless and only happens at the boundary. + */ +const num = (money: string): number => Number(money); + +/** A signed record on its way into a JSONB column. */ +const asJson = (record: unknown): Json => record as unknown as Json; + +/* --------------------------------------------------------------- parties -- */ + +function standing(row: Record): { seeder: number; requester: number } { + return { + seeder: Math.max(0, (row.proven ?? 0) - 2 * (row.failed ?? 0) - 3 * (row.abandoned ?? 0)), + requester: Math.max(0, (row.honoured ?? 0) - 3 * (row.voided ?? 0)), + }; +} + +function toParty(row: Record): Party { + const counters = row as unknown as Record; + const marks = standing(counters); + return { + key: String(row.key), + kind: (row.kind as PartyKind) ?? 'human', + operatorKey: (row.operator_key as string) ?? null, + label: (row.label as string) ?? null, + payoutAddress: (row.payout_address as string) ?? null, + payoutNetwork: (row.payout_network as string) ?? null, + balanceUsd: usd(Number(row.balance_usd ?? 0)), + paidOutUsd: usd(Number(row.paid_out_usd ?? 0)), + proven: counters.proven ?? 0, + failed: counters.failed ?? 0, + abandoned: counters.abandoned ?? 0, + honoured: counters.honoured ?? 0, + voided: counters.voided ?? 0, + seederStanding: marks.seeder, + requesterStanding: marks.requester, + }; +} + +export async function getParty(key: string): Promise { + const { data } = await db().from('openswarm_parties').select('*').eq('key', key).maybeSingle(); + return data ? toParty(data) : null; +} + +/** + * Register or update a key. A party declares what it is; a bit (an agent) may + * name the human answerable for it, which its public attestations require. + */ +export async function registerParty(input: { + key: string; + kind?: PartyKind; + operatorKey?: string | null; + label?: string | null; + payoutAddress?: string | null; + payoutNetwork?: string | null; + accountId?: string | null; +}): Promise { + if (!isKey(input.key)) throw new HubError('a party key is ed25519:<64 hex>'); + if (input.kind !== undefined && !isPartyKind(input.kind)) throw new HubError('kind is "human" or "bit"'); + // An address without a network is not a payee. + if (Boolean(input.payoutAddress) !== Boolean(input.payoutNetwork)) { + throw new HubError('a payout address needs its network, and a network needs its address'); + } + if (input.operatorKey && !isKey(input.operatorKey)) throw new HubError('operatorKey is ed25519:<64 hex>'); + if (input.operatorKey) { + const operator = await getParty(input.operatorKey); + if (!operator) throw new HubError('that operator key is not registered here', 404); + } + + const existing = await getParty(input.key); + const patch: PartyInsert = { key: input.key }; + if (input.kind !== undefined) patch.kind = input.kind; + if (input.operatorKey !== undefined) patch.operator_key = input.operatorKey; + if (input.label !== undefined) patch.label = input.label; + if (input.accountId !== undefined) patch.account_id = input.accountId; + if (input.payoutAddress !== undefined) { + patch.payout_address = input.payoutAddress; + patch.payout_network = input.payoutNetwork; + } + + const { data, error } = await db() + .from('openswarm_parties') + .upsert(existing ? patch : { kind: 'human', ...patch }, { onConflict: 'key' }) + .select('*') + .single(); + if (error) throw new HubError(`could not register the key: ${error.message}`, 500); + return toParty(data); +} + +/** A party row, created as a plain human if this key has never been seen. */ +async function ensureParty(key: string): Promise { + return (await getParty(key)) ?? (await registerParty({ key })); +} + +/* ---------------------------------------------------------- attestations -- */ + +function subjectOf(record: SignedRecord): Subject { + const raw = (record.subject ?? {}) as Record; + const subject: Subject = { + infohashV1: typeof raw.infohashV1 === 'string' ? raw.infohashV1.toLowerCase() : null, + infohashV2: typeof raw.infohashV2 === 'string' ? raw.infohashV2.toLowerCase() : null, + file: typeof raw.file === 'string' ? raw.file : null, + channel: typeof raw.channel === 'string' ? raw.channel : null, + }; + const named = [subject.infohashV1 || subject.infohashV2, subject.file, subject.channel].filter(Boolean).length; + if (named !== 1) throw new HubError('an attestation names exactly one subject: an infohash, a file key, or a channel key'); + if (subject.infohashV1 && !/^[0-9a-f]{40}$/.test(subject.infohashV1)) throw new HubError('infohashV1 is 40 hex characters'); + if (subject.infohashV2 && !/^[0-9a-f]{64}$/.test(subject.infohashV2)) throw new HubError('infohashV2 is 64 hex characters'); + if (subject.file && !isKey(subject.file)) throw new HubError('a file key is ed25519:<64 hex>'); + if (subject.channel && !isKey(subject.channel)) throw new HubError('a channel key is ed25519:<64 hex>'); + return subject; +} + +function toAttestation(row: Record): Attestation { + return { + id: String(row.id), + requesterKey: String(row.requester_key), + visibility: row.visibility as Visibility, + basis: row.basis as Basis, + license: (row.license as string) ?? null, + description: (row.description as string) ?? null, + noticeEndpoint: (row.notice_endpoint as string) ?? null, + subject: { + infohashV1: (row.infohash_v1 as string) ?? null, + infohashV2: (row.infohash_v2 as string) ?? null, + file: (row.file_key as string) ?? null, + channel: (row.channel_key as string) ?? null, + }, + readme: String(row.readme), + readmeSha256: String(row.readme_sha256), + status: row.status as Attestation['status'], + claimWindowEndsAt: (row.claim_window_ends_at as string) ?? null, + createdAt: String(row.created_at), + }; +} + +/** + * Register consent. + * + * The record must be signed by the requester; a private `ipfile` subject must + * ALSO be signed by the file's publisher key, which is what stops a stranger + * attesting somebody else's swarm. A public infohash has no such proof, so it + * gets the claim window instead. + */ +export async function registerAttestation(body: unknown): Promise<{ attestation: Attestation; id: string }> { + const record = assertEnvelope(body, 'pay2seed.attestation'); + + const requester = typeof record.requester === 'string' ? record.requester : signers(record)[0]; + if (!requester || !isKey(requester)) throw new HubError('the attestation must be signed by an ed25519 key'); + if (!verifiedBy(record, requester)) throw new HubError('the signature does not verify against the requester key', 403); + + const visibility = record.visibility as Visibility; + if (visibility !== 'public' && visibility !== 'private') throw new HubError('visibility is "public" or "private"'); + + const basis = record.basis as Basis; + if (!BASES.includes(basis)) throw new HubError(`basis is one of ${BASES.join(', ')}`); + if (basis === 'personal' && visibility !== 'private') throw new HubError('a personal basis is for a private swarm'); + if (basis === 'open-license' && typeof record.license !== 'string') throw new HubError('an open-license basis names its SPDX licence'); + if (record.acceptsTakedown !== true) throw new HubError('acceptsTakedown must be true, and must be in the signed bytes'); + + const subject = subjectOf(record); + if ((subject.infohashV1 || subject.infohashV2) && visibility !== 'public') throw new HubError('an infohash subject is public'); + if (subject.file && visibility !== 'private') throw new HubError('a file-key subject is private'); + + const readme = typeof record.readme === 'string' ? record.readme : ''; + if (!readme.trim()) throw new HubError('every swarm on this market carries a README; there is no exception'); + if (readme.length > READMES_MAX) throw new HubError('a README is at most 64 KiB'); + const readmeSha256 = typeof record.readmeSha256 === 'string' ? record.readmeSha256.toLowerCase() : ''; + if (!/^[0-9a-f]{64}$/.test(readmeSha256)) throw new HubError('readmeSha256 is the SHA-256 of the copy inside the swarm'); + + const noticeEndpoint = typeof record.notice === 'string' ? record.notice : null; + if (visibility === 'public' && !noticeEndpoint) throw new HubError('a public swarm says where a notice goes'); + if (noticeEndpoint && !/^(https:\/\/|mailto:)/i.test(noticeEndpoint)) { + throw new HubError('a notice endpoint is an https URL or a mailto: address'); + } + + // A private swarm's publisher must have signed too. + if (subject.file && !verifiedBy(record, subject.file)) { + throw new HubError("a private swarm's attestation must also be signed by the file's publisher key", 403); + } + + const party = await ensureParty(requester); + + // An agent selling in public names the human answerable for it. + if (party.kind === 'bit' && visibility === 'public' && !party.operatorKey) { + throw new HubError('an agent\'s public attestation names a responsible operator key; register one first', 403); + } + + // Standing: a requester who keeps having claims voided stops being listed. + if (party.voided >= 3) throw new HubError('this key has three voided attestations in the last year', 403); + if (visibility === 'public' && party.voided > party.honoured) { + throw new HubError('this key has more voided claims than honoured ones', 403); + } + + // Somebody else already holds this public infohash, unless the new basis is + // one that does not depend on who is asking. + if (subject.infohashV1 && basis !== 'open-license' && basis !== 'public-domain') { + const { data: held } = await db() + .from('openswarm_attestations') + .select('requester_key') + .eq('infohash_v1', subject.infohashV1) + .eq('status', 'honoured') + .maybeSingle(); + if (held && held.requester_key !== requester) { + throw new HubError('another key already holds an honoured claim on this infohash', 409); + } + } + + const id = recordId(record); + const now = Date.now(); + // A private swarm is the requester's own business and is honoured at once. + // A public claim waits, in the open, where a notice can void it. + const isPublic = visibility === 'public'; + const row = { + id, + requester_key: requester, + visibility, + basis, + license: typeof record.license === 'string' ? record.license : null, + description: typeof record.description === 'string' ? record.description.slice(0, 280) : null, + notice_endpoint: noticeEndpoint, + infohash_v1: subject.infohashV1, + infohash_v2: subject.infohashV2, + file_key: subject.file, + channel_key: subject.channel, + readme, + readme_sha256: readmeSha256, + record: asJson(record), + status: isPublic ? 'claimed' : 'honoured', + claim_window_ends_at: isPublic ? new Date(now + CLAIM_WINDOW_HOURS * 3_600_000).toISOString() : null, + honoured_at: isPublic ? null : new Date(now).toISOString(), + }; + + const { data, error } = await db().from('openswarm_attestations').upsert(row, { onConflict: 'id' }).select('*').single(); + if (error) throw new HubError(`could not register the attestation: ${error.message}`, 500); + if (!isPublic) await bumpParty(requester, { honoured: 1 }); + return { attestation: toAttestation(data), id }; +} + +export async function getAttestation(id: string): Promise { + const { data } = await db().from('openswarm_attestations').select('*').eq('id', id).maybeSingle(); + return data ? toAttestation(data) : null; +} + +/** Move any public claim whose window has run out to honoured. */ +export async function honourDueClaims(now = new Date()): Promise { + const { data, error } = await db() + .from('openswarm_attestations') + .update({ status: 'honoured', honoured_at: now.toISOString() }) + .eq('status', 'claimed') + .lt('claim_window_ends_at', now.toISOString()) + .select('requester_key'); + if (error) return 0; + for (const row of data ?? []) await bumpParty(String(row.requester_key), { honoured: 1 }); + return (data ?? []).length; +} + +async function bumpParty(key: string, deltas: Partial>): Promise { + const party = await getParty(key); + if (!party) return; + const patch: PartyUpdate = {}; + for (const [field, delta] of Object.entries(deltas)) { + patch[field as keyof typeof deltas] = (party[field as 'proven'] ?? 0) + (delta ?? 0); + } + await db().from('openswarm_parties').update(patch).eq('key', key); +} + +/* ----------------------------------------------------------------- offers -- */ + +function toOffer(row: Record): Offer { + return { + id: String(row.id), + attestationId: String(row.attestation_id), + requesterKey: String(row.requester_key), + visibility: row.visibility as Visibility, + sizeBytes: Number(row.size_bytes), + days: Number(row.days), + seedersMin: Number(row.seeders_min), + seedersMax: Number(row.seeders_max), + priceUsdPerGibMonth: usd(Number(row.price_usd_per_gib_month)), + budgetUsd: usd(Number(row.budget_usd)), + spentUsd: usd(Number(row.spent_usd)), + feeUsd: usd(Number(row.fee_usd)), + proofEveryHours: Number(row.proof_every_hours), + trackers: Array.isArray(row.trackers) ? (row.trackers as string[]) : [], + status: row.status as Offer['status'], + startsAt: String(row.starts_at), + expiresAt: String(row.expires_at), + createdAt: String(row.created_at), + }; +} + +export interface OfferDraft { + attestation: string; + sizeBytes: number; + days: number; + seeders?: { min?: number; max?: number }; + priceUsdPerGibMonth: string; + proof?: { everyHours?: number }; + trackers?: string[]; + pass?: unknown; +} + +/** + * Quote an offer: what it will cost, and what the hub takes. Pure, so the page + * and the checkout can never disagree about the number. + */ +export function quoteOffer(draft: OfferDraft): { budgetUsd: string; feeUsd: string; totalUsd: string; projectedPerSeederUsd: string } { + const seedersMax = Math.max(1, Math.floor(draft.seeders?.max ?? draft.seeders?.min ?? 1)); + const budget = requiredBudget({ + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + sizeBytes: draft.sizeBytes, + days: draft.days, + seedersMax, + }); + return { + budgetUsd: budget, + feeUsd: feeOn(budget), + totalUsd: totalWithFee(budget), + projectedPerSeederUsd: requiredBudget({ + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + sizeBytes: draft.sizeBytes, + days: draft.days, + seedersMax: 1, + }), + }; +} + +/** + * Create an offer against an honoured attestation. It is `unpaid` until the + * money settles: a budget that is not escrowed is not a promise anyone should + * seed against. + */ +export async function createOffer(draft: OfferDraft, requesterKey: string): Promise<{ offer: Offer; quote: ReturnType }> { + if (!isKey(requesterKey)) throw new HubError('a requester key is ed25519:<64 hex>'); + const attestation = await getAttestation(draft.attestation); + if (!attestation) throw new HubError('no such attestation', 404); + if (attestation.requesterKey !== requesterKey) throw new HubError('that attestation belongs to another key', 403); + if (attestation.status === 'voided') throw new HubError('that attestation was voided', 409); + + const days = Math.floor(draft.days); + if (!Number.isFinite(days) || days < 1 || days > MAX_OFFER_DAYS) throw new HubError(`days is 1 to ${MAX_OFFER_DAYS}`); + const sizeBytes = Math.floor(draft.sizeBytes); + if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) throw new HubError('sizeBytes must be a positive integer'); + const seedersMin = Math.max(1, Math.floor(draft.seeders?.min ?? 1)); + const seedersMax = Math.max(seedersMin, Math.floor(draft.seeders?.max ?? seedersMin)); + const everyHours = Math.min(168, Math.max(1, Math.floor(draft.proof?.everyHours ?? 6))); + // Throws when the price is not a six-decimal string, which is the point. + toMicros(draft.priceUsdPerGibMonth); + + const quote = quoteOffer({ ...draft, days, sizeBytes, seeders: { min: seedersMin, max: seedersMax } }); + const startsAt = new Date(); + const expiresAt = new Date(startsAt.getTime() + days * 86_400_000); + + const record = { + openswarm: OPENSWARM_VERSION, + type: 'pay2seed.offer', + hub: hubKey(), + requester: requesterKey, + attestation: attestation.id, + subject: attestation.subject, + visibility: attestation.visibility, + sizeBytes, + days, + seeders: { min: seedersMin, max: seedersMax }, + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + budgetUsd: quote.budgetUsd, + proof: { everyHours, verifiers: ['hub'] }, + trackers: draft.trackers ?? [], + startsAt: startsAt.toISOString(), + expiresAt: expiresAt.toISOString(), + createdAt: startsAt.toISOString(), + sigs: [] as unknown[], + }; + const id = recordId(record); + + const { data, error } = await db() + .from('openswarm_offers') + .insert({ + id, + attestation_id: attestation.id, + requester_key: requesterKey, + visibility: attestation.visibility, + size_bytes: sizeBytes, + days, + seeders_min: seedersMin, + seeders_max: seedersMax, + price_usd_per_gib_month: num(draft.priceUsdPerGibMonth), + budget_usd: num(quote.budgetUsd), + fee_usd: num(quote.feeUsd), + proof_every_hours: everyHours, + trackers: draft.trackers ?? [], + pass: asJson(draft.pass ?? null), + record: asJson(record), + status: 'unpaid', + starts_at: startsAt.toISOString(), + expires_at: expiresAt.toISOString(), + }) + .select('*') + .single(); + if (error) throw new HubError(`could not create the offer: ${error.message}`, 500); + return { offer: toOffer(data), quote }; +} + +/** The money settled. An offer becomes listable, and seeders can take slots. */ +export async function markOfferPaid(offerId: string, paymentId: string): Promise { + const { data, error } = await db() + .from('openswarm_offers') + .update({ status: 'pending', payment_id: paymentId, paid_at: new Date().toISOString() }) + .eq('id', offerId) + .eq('status', 'unpaid') + .select('*') + .maybeSingle(); + // A retried webhook finds nothing to update, which is the idempotency. + if (error || !data) return null; + return toOffer(data); +} + +export async function getOffer(id: string): Promise { + const { data } = await db().from('openswarm_offers').select('*').eq('id', id).maybeSingle(); + return data ? toOffer(data) : null; +} + +/** + * The market: offers a seeder can actually take work on. An offer whose + * attestation is not honoured yet is not here, which is what the claim window + * is for. + */ +export async function listMarket(filter: { visibility?: Visibility; basis?: Basis; limit?: number } = {}): Promise { + const limit = Math.min(100, Math.max(1, filter.limit ?? 50)); + let query = db() + .from('openswarm_offers') + .select('*, openswarm_attestations!inner(*), openswarm_parties!openswarm_offers_requester_key_fkey(kind)') + .in('status', ['pending', 'active']) + .eq('openswarm_attestations.status', 'honoured') + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(limit); + if (filter.visibility) query = query.eq('visibility', filter.visibility); + if (filter.basis) query = query.eq('openswarm_attestations.basis', filter.basis); + + const { data, error } = await query; + if (error) throw new HubError(`could not read the market: ${error.message}`, 500); + + const rows = (data ?? []) as unknown as Array>; + const offerIds = rows.map((row) => String(row.id)); + const taken = await slotsTaken(offerIds); + + return rows.map((row) => { + const attestation = toAttestation(row.openswarm_attestations as Record); + const offer = toOffer(row); + const party = (row.openswarm_parties ?? {}) as { kind?: PartyKind }; + return { + offer, + basis: attestation.basis, + visibility: attestation.visibility, + description: attestation.description, + license: attestation.license, + summary: readmeSummary(attestation.readme), + subject: attestation.subject, + slotsFree: Math.max(0, offer.seedersMax - (taken.get(offer.id) ?? 0)), + projectedUsd: requiredBudget({ + priceUsdPerGibMonth: offer.priceUsdPerGibMonth, + sizeBytes: offer.sizeBytes, + days: offer.days, + seedersMax: 1, + }), + requesterKind: party.kind ?? 'human', + }; + }); +} + +async function slotsTaken(offerIds: string[]): Promise> { + const taken = new Map(); + if (!offerIds.length) return taken; + const { data } = await db() + .from('openswarm_leases') + .select('offer_id') + .in('offer_id', offerIds) + .not('status', 'in', '("abandoned","voided","ended")'); + for (const row of data ?? []) { + const id = String(row.offer_id); + taken.set(id, (taken.get(id) ?? 0) + 1); + } + return taken; +} + +/* ----------------------------------------------------------------- leases -- */ + +function toLease(row: Record): Lease { + return { + id: String(row.id), + offerId: String(row.offer_id), + seederKey: String(row.seeder_key), + slot: Number(row.slot), + priceUsdPerGibMonth: usd(Number(row.price_usd_per_gib_month)), + lane: row.lane as Lane, + earnedUsd: usd(Number(row.earned_usd ?? 0)), + periodsProven: Number(row.periods_proven ?? 0), + periodsFailed: Number(row.periods_failed ?? 0), + status: row.status as Lease['status'], + startsAt: String(row.starts_at), + endsAt: String(row.ends_at), + }; +} + +/** + * Take a slot on an offer. + * + * The seeder proves it holds the key by signing the offer id, must already be + * a payee (there would otherwise be nowhere to pay), and must clear the + * standing floor. + */ +export async function takeLease(input: { offerId: string; seederKey: string; sig: string }): Promise { + if (!isKey(input.seederKey)) throw new HubError('a seeder key is ed25519:<64 hex>'); + + const offer = await getOffer(input.offerId); + if (!offer) throw new HubError('no such offer', 404); + if (offer.status === 'unpaid') throw new HubError('that offer is not funded yet', 409); + if (offer.status === 'voided' || offer.status === 'settled') throw new HubError('that offer is closed', 409); + if (new Date(offer.expiresAt).getTime() <= Date.now()) throw new HubError('that offer has expired', 409); + + // The signature is over a record shaped like the wire's, so one verifier serves both. + const proof = { + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.lease.request', + offer: offer.id, + seeder: input.seederKey, + createdAt: new Date().toISOString(), + sigs: [{ alg: 'ed25519', key: input.seederKey, sig: input.sig }], + } as SignedRecord; + // The signed bytes must not include a timestamp the caller did not sign, so + // verify over the stable part only. + const stable = { openswarm: OPENSWARM_VERSION, type: 'paid2seed.lease.request', offer: offer.id, seeder: input.seederKey }; + const signable = { ...stable, sigs: proof.sigs } as unknown as SignedRecord; + if (!verifiedBy(signable, input.seederKey)) { + throw new HubError('sign the offer id with the seeder key to take a lease', 403); + } + + const seeder = await getParty(input.seederKey); + if (!seeder) throw new HubError('register the seeder key first', 404); + if (!seeder.payoutAddress) throw new HubError('register a payout address first; there would be nowhere to pay', 409); + if (seeder.seederStanding < MIN_SEEDER_STANDING) throw new HubError('this key is below the hub\'s standing floor', 403); + + const { data: mine } = await db() + .from('openswarm_leases') + .select('*') + .eq('offer_id', offer.id) + .eq('seeder_key', input.seederKey) + .maybeSingle(); + if (mine) throw new HubError('this key already holds a lease on that offer', 409); + + const taken = await slotsTaken([offer.id]); + const used = taken.get(offer.id) ?? 0; + if (used >= offer.seedersMax) throw new HubError('that offer has no free slot', 409); + + const requester = await getParty(offer.requesterKey); + const lane = laneFor(requester?.kind ?? 'human', seeder.kind); + + const startsAt = new Date(); + const record = { + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.lease', + hub: hubKey(), + offer: offer.id, + seeder: input.seederKey, + slot: used + 1, + priceUsdPerGibMonth: offer.priceUsdPerGibMonth, + startsAt: startsAt.toISOString(), + endsAt: offer.expiresAt, + graceHours: 24, + createdAt: startsAt.toISOString(), + sigs: [] as unknown[], + }; + + const { data, error } = await db() + .from('openswarm_leases') + .insert({ + id: recordId(record), + offer_id: offer.id, + seeder_key: input.seederKey, + slot: used + 1, + price_usd_per_gib_month: num(offer.priceUsdPerGibMonth), + lane, + status: 'fetching', + grace_hours: 24, + starts_at: startsAt.toISOString(), + ends_at: offer.expiresAt, + record: asJson(record), + }) + .select('*') + .single(); + if (error) throw new HubError(`could not take the lease: ${error.message}`, 500); + + // Enough seeders now hold slots for the offer to be live. + if (used + 1 >= offer.seedersMin && offer.status === 'pending') { + await db().from('openswarm_offers').update({ status: 'active' }).eq('id', offer.id); + } + return toLease(data); +} + +export async function getLease(id: string): Promise { + const { data } = await db().from('openswarm_leases').select('*').eq('id', id).maybeSingle(); + return data ? toLease(data) : null; +} + +export async function listLeasesFor(seederKey: string): Promise { + const { data } = await db().from('openswarm_leases').select('*').eq('seeder_key', seederKey).order('created_at', { ascending: false }); + return (data ?? []).map((row) => toLease(row as Record)); +} + +/* ------------------------------------------------------ proofs & receipts -- */ + +/** + * Record a period's verdict and pay for it. + * + * The receipt's unique `(lease, period)` is the whole of the idempotency: a + * verifier that reports twice pays once. Two consecutive failures end the + * lease and reopen the slot. + */ +export async function reportProof(input: { + leaseId: string; + period: number; + kind: 'challenge' | 'probe'; + passed: boolean; + verifierKey?: string | null; + detail?: string | null; + record?: unknown; +}): Promise<{ lease: Lease; earnedUsd: string; alreadyRecorded: boolean }> { + const lease = await getLease(input.leaseId); + if (!lease) throw new HubError('no such lease', 404); + if (lease.status === 'voided') throw new HubError('that lease was voided', 409); + + const offer = await getOffer(lease.offerId); + if (!offer) throw new HubError('that lease has no offer', 404); + + const period = Math.floor(input.period); + if (!Number.isFinite(period) || period < 0) throw new HubError('period is a non-negative integer'); + + const proofId = recordId({ + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.proof', + lease: lease.id, + period, + kind: input.kind, + passed: input.passed, + }); + + const { error: proofError } = await db().from('openswarm_proofs').insert({ + id: proofId, + lease_id: lease.id, + period, + kind: input.kind, + verifier_key: input.verifierKey ?? null, + passed: input.passed, + detail: input.detail ?? null, + record: asJson(input.record ?? {}), + }); + // A duplicate period is not an error; it simply pays nothing more. + const duplicate = Boolean(proofError && /duplicate|unique/i.test(proofError.message)); + if (proofError && !duplicate) throw new HubError(`could not record the proof: ${proofError.message}`, 500); + if (duplicate) return { lease, earnedUsd: '0.000000', alreadyRecorded: true }; + + if (!input.passed) { + const consecutive = await failLease(lease, offer); + return { lease: consecutive, earnedUsd: '0.000000', alreadyRecorded: false }; + } + + const earned = periodEarnings({ + priceUsdPerGibMonth: lease.priceUsdPerGibMonth, + sizeBytes: offer.sizeBytes, + everyHours: offer.proofEveryHours, + }); + + // Never pay past what was escrowed: the last period of a rounded-down budget + // pays what is left rather than going negative. + const remaining = toMicros(offer.budgetUsd) - toMicros(offer.spentUsd); + const payable = fromMicros(remaining <= 0n ? 0n : remaining < toMicros(earned) ? remaining : toMicros(earned)); + + const seeder = await getParty(lease.seederKey); + const balance = fromMicros(toMicros(seeder?.balanceUsd ?? '0.000000') + toMicros(payable)); + + await db().from('openswarm_receipts').insert({ + id: recordId({ openswarm: OPENSWARM_VERSION, type: 'paid2seed.receipt', lease: lease.id, period }), + lease_id: lease.id, + proof_id: proofId, + period, + earned_usd: num(payable), + balance_usd: num(balance), + lane: lease.lane, + record: asJson({ + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.receipt', + lease: lease.id, + period, + earnedUsd: payable, + balanceUsd: balance, + createdAt: new Date().toISOString(), + sigs: [], + }), + }); + + await db() + .from('openswarm_leases') + .update({ + earned_usd: num(fromMicros(toMicros(lease.earnedUsd) + toMicros(payable))), + periods_proven: lease.periodsProven + 1, + consecutive_failures: 0, + status: 'proven', + last_proof_at: new Date().toISOString(), + }) + .eq('id', lease.id); + + await db() + .from('openswarm_offers') + .update({ spent_usd: num(fromMicros(toMicros(offer.spentUsd) + toMicros(payable))) }) + .eq('id', offer.id); + + if (seeder) { + await db() + .from('openswarm_parties') + .update({ balance_usd: num(balance), proven: seeder.proven + 1 }) + .eq('key', lease.seederKey); + } + + const updated = await getLease(lease.id); + return { lease: updated ?? lease, earnedUsd: payable, alreadyRecorded: false }; +} + +async function failLease(lease: Lease, _offer: Offer): Promise { + const { data } = await db().from('openswarm_leases').select('consecutive_failures').eq('id', lease.id).maybeSingle(); + const consecutive = Number(data?.consecutive_failures ?? 0) + 1; + // Two in a row ends it and reopens the slot for somebody who will hold it. + const status = consecutive >= 2 ? 'ended' : 'lapsed'; + await db() + .from('openswarm_leases') + .update({ periods_failed: lease.periodsFailed + 1, consecutive_failures: consecutive, status }) + .eq('id', lease.id); + await bumpParty(lease.seederKey, { failed: 1 }); + return { ...lease, periodsFailed: lease.periodsFailed + 1, status }; +} + +/* ---------------------------------------------------------------- notices -- */ + +/** + * A claim against an attestation. The hub forwards it and then either voids + * the attestation or records that it was reviewed and stands. Voiding takes + * every offer and lease with it and refunds what has not been earned. + */ +export async function fileNotice(input: { + attestationId: string; + kind: 'rights' | 'illegal' | 'personal-data' | 'other'; + statement: string; + claimantName?: string | null; + claimantContact?: string | null; + record?: unknown; +}): Promise<{ noticeId: string; attestation: Attestation }> { + const attestation = await getAttestation(input.attestationId); + if (!attestation) throw new HubError('no such attestation', 404); + if (!input.statement?.trim()) throw new HubError('a notice says what the claim is'); + + const noticeId = recordId({ + openswarm: OPENSWARM_VERSION, + type: 'pay2seed.notice', + attestation: attestation.id, + kind: input.kind, + statement: input.statement, + createdAt: new Date().toISOString(), + }); + + await db().from('openswarm_notices').insert({ + id: noticeId, + attestation_id: attestation.id, + kind: input.kind, + claimant_name: input.claimantName ?? null, + claimant_contact: input.claimantContact ?? null, + statement: input.statement.slice(0, 4000), + record: asJson(input.record ?? {}), + outcome: 'received', + }); + + return { noticeId, attestation }; +} + +/** Void an attestation: every offer on it closes and every lease ends. */ +export async function voidAttestation(attestationId: string, reason: string): Promise<{ offers: number; leases: number }> { + const attestation = await getAttestation(attestationId); + if (!attestation) throw new HubError('no such attestation', 404); + + await db() + .from('openswarm_attestations') + .update({ status: 'voided', voided_at: new Date().toISOString(), void_reason: reason }) + .eq('id', attestationId); + + const { data: offers } = await db().from('openswarm_offers').select('id').eq('attestation_id', attestationId); + const offerIds = (offers ?? []).map((row) => String(row.id)); + if (offerIds.length) { + await db().from('openswarm_offers').update({ status: 'voided' }).in('id', offerIds); + await db().from('openswarm_leases').update({ status: 'voided' }).in('offer_id', offerIds); + } + await db().from('openswarm_notices').update({ outcome: 'voided', resolved_at: new Date().toISOString() }).eq('attestation_id', attestationId); + await bumpParty(attestation.requesterKey, { voided: 1 }); + + const { count } = await db() + .from('openswarm_leases') + .select('id', { count: 'exact', head: true }) + .in('offer_id', offerIds.length ? offerIds : ['none']); + return { offers: offerIds.length, leases: count ?? 0 }; +} + +/* ------------------------------------------------------------- hub record -- */ + +/** The hub's own signing key, when one is configured. */ +export function hubKey(): string { + const key = process.env.OPENSWARM_HUB_KEY ?? ''; + return isKey(key) ? key : 'ed25519:0000000000000000000000000000000000000000000000000000000000000000'; +} + +/** `GET /.well-known/openswarm-hub.json` (ippay §5.1, pay2seed §6.1). */ +export function hubRecord(origin: string): Record { + return { + openswarm: OPENSWARM_VERSION, + type: 'ippay.hub', + key: hubKey(), + name: 'BitTorrented', + base: `${origin}/api/openswarm`, + networks: ['eip155:8453', 'eip155:137', 'eip155:1'], + // 1 percent, and it is charged to whoever is paying. + minHubBps: HUB_FEE_BPS, + payout: { minUsd: '1.000000', schedule: 'daily' }, + passDays: 30, + pay2seed: { + base: `${origin}/api/openswarm/pay2seed`, + bases: BASES, + claimHours: CLAIM_WINDOW_HOURS, + minStanding: MIN_SEEDER_STANDING, + verifiers: ['hub'], + maxDays: MAX_OFFER_DAYS, + // Every party here is a human or a bit, and all four lanes are carried. + lanes: ['h2h', 'h2b', 'b2h', 'b2b'], + }, + createdAt: '2026-09-06T00:00:00.000Z', + sigs: [], + }; +} + +export { sha256Hex, OpenSwarmRecordError }; diff --git a/src/lib/openswarm/types.ts b/src/lib/openswarm/types.ts new file mode 100644 index 00000000..4ce247e8 --- /dev/null +++ b/src/lib/openswarm/types.ts @@ -0,0 +1,103 @@ +/** Shapes the hub stores and returns. The wire records live in `records.ts`. */ +import type { Lane, PartyKind } from './lanes'; + +export type Visibility = 'public' | 'private'; +export type Basis = 'own' | 'licensed' | 'open-license' | 'public-domain' | 'personal'; +export type AttestationStatus = 'claimed' | 'honoured' | 'voided'; +export type OfferStatus = 'unpaid' | 'pending' | 'active' | 'settled' | 'voided'; +export type LeaseStatus = 'fetching' | 'proven' | 'lapsed' | 'abandoned' | 'ended' | 'voided'; + +export const BASES: readonly Basis[] = ['own', 'licensed', 'open-license', 'public-domain', 'personal']; + +export interface Party { + key: string; + kind: PartyKind; + operatorKey: string | null; + label: string | null; + payoutAddress: string | null; + payoutNetwork: string | null; + balanceUsd: string; + paidOutUsd: string; + proven: number; + failed: number; + abandoned: number; + honoured: number; + voided: number; + /** paid2seed §6.3: proven - 2·failed - 3·abandoned, floored at zero. */ + seederStanding: number; + /** honoured - 3·voided, floored at zero. */ + requesterStanding: number; +} + +export interface Subject { + infohashV1?: string | null; + infohashV2?: string | null; + file?: string | null; + channel?: string | null; +} + +export interface Attestation { + id: string; + requesterKey: string; + visibility: Visibility; + basis: Basis; + license: string | null; + description: string | null; + noticeEndpoint: string | null; + subject: Subject; + readme: string; + readmeSha256: string; + status: AttestationStatus; + claimWindowEndsAt: string | null; + createdAt: string; +} + +export interface Offer { + id: string; + attestationId: string; + requesterKey: string; + visibility: Visibility; + sizeBytes: number; + days: number; + seedersMin: number; + seedersMax: number; + priceUsdPerGibMonth: string; + budgetUsd: string; + spentUsd: string; + feeUsd: string; + proofEveryHours: number; + trackers: string[]; + status: OfferStatus; + startsAt: string; + expiresAt: string; + createdAt: string; +} + +export interface Lease { + id: string; + offerId: string; + seederKey: string; + slot: number; + priceUsdPerGibMonth: string; + lane: Lane; + earnedUsd: string; + periodsProven: number; + periodsFailed: number; + status: LeaseStatus; + startsAt: string; + endsAt: string; +} + +export interface MarketRow { + offer: Offer; + basis: Basis; + visibility: Visibility; + description: string | null; + license: string | null; + summary: string; + subject: Subject; + slotsFree: number; + /** What a seeder would earn holding this for the whole term. */ + projectedUsd: string; + requesterKind: PartyKind; +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 4cfa804b..e6bdf4e4 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -15,6 +15,611 @@ export type Json = export type Database = { public: { Tables: { + openswarm_attestations: { + Row: { + basis: string + channel_key: string | null + claim_window_ends_at: string | null + created_at: string + description: string | null + file_key: string | null + honoured_at: string | null + id: string + infohash_v1: string | null + infohash_v2: string | null + license: string | null + notice_endpoint: string | null + readme: string + readme_sha256: string + record: Json + requester_key: string + status: string + updated_at: string + visibility: string + void_reason: string | null + voided_at: string | null + } + Insert: { + basis: string + channel_key?: string | null + claim_window_ends_at?: string | null + created_at?: string + description?: string | null + file_key?: string | null + honoured_at?: string | null + id: string + infohash_v1?: string | null + infohash_v2?: string | null + license?: string | null + notice_endpoint?: string | null + readme: string + readme_sha256: string + record: Json + requester_key: string + status?: string + updated_at?: string + visibility: string + void_reason?: string | null + voided_at?: string | null + } + Update: { + basis?: string + channel_key?: string | null + claim_window_ends_at?: string | null + created_at?: string + description?: string | null + file_key?: string | null + honoured_at?: string | null + id?: string + infohash_v1?: string | null + infohash_v2?: string | null + license?: string | null + notice_endpoint?: string | null + readme?: string + readme_sha256?: string + record?: Json + requester_key?: string + status?: string + updated_at?: string + visibility?: string + void_reason?: string | null + voided_at?: string | null + } + Relationships: [ + { + foreignKeyName: "openswarm_attestations_requester_key_fkey" + columns: ["requester_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_leases: { + Row: { + consecutive_failures: number + created_at: string + earned_usd: number + ends_at: string + grace_hours: number + id: string + lane: string + last_proof_at: string | null + offer_id: string + periods_failed: number + periods_proven: number + price_usd_per_gib_month: number + record: Json + seeder_key: string + slot: number + starts_at: string + status: string + updated_at: string + } + Insert: { + consecutive_failures?: number + created_at?: string + earned_usd?: number + ends_at: string + grace_hours?: number + id: string + lane: string + last_proof_at?: string | null + offer_id: string + periods_failed?: number + periods_proven?: number + price_usd_per_gib_month: number + record: Json + seeder_key: string + slot: number + starts_at?: string + status?: string + updated_at?: string + } + Update: { + consecutive_failures?: number + created_at?: string + earned_usd?: number + ends_at?: string + grace_hours?: number + id?: string + lane?: string + last_proof_at?: string | null + offer_id?: string + periods_failed?: number + periods_proven?: number + price_usd_per_gib_month?: number + record?: Json + seeder_key?: string + slot?: number + starts_at?: string + status?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_leases_offer_id_fkey" + columns: ["offer_id"] + isOneToOne: false + referencedRelation: "openswarm_offers" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_leases_seeder_key_fkey" + columns: ["seeder_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_notices: { + Row: { + attestation_id: string + claimant_contact: string | null + claimant_name: string | null + created_at: string + id: string + kind: string + outcome: string + record: Json + resolved_at: string | null + statement: string + } + Insert: { + attestation_id: string + claimant_contact?: string | null + claimant_name?: string | null + created_at?: string + id: string + kind: string + outcome?: string + record: Json + resolved_at?: string | null + statement: string + } + Update: { + attestation_id?: string + claimant_contact?: string | null + claimant_name?: string | null + created_at?: string + id?: string + kind?: string + outcome?: string + record?: Json + resolved_at?: string | null + statement?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_notices_attestation_id_fkey" + columns: ["attestation_id"] + isOneToOne: false + referencedRelation: "openswarm_attestations" + referencedColumns: ["id"] + }, + ] + } + openswarm_offers: { + Row: { + attestation_id: string + budget_usd: number + created_at: string + days: number + expires_at: string + fee_usd: number + id: string + paid_at: string | null + pass: Json | null + payment_id: string | null + price_usd_per_gib_month: number + proof_every_hours: number + record: Json + requester_key: string + seeders_max: number + seeders_min: number + size_bytes: number + spent_usd: number + starts_at: string + status: string + trackers: Json + updated_at: string + visibility: string + } + Insert: { + attestation_id: string + budget_usd: number + created_at?: string + days: number + expires_at: string + fee_usd?: number + id: string + paid_at?: string | null + pass?: Json | null + payment_id?: string | null + price_usd_per_gib_month: number + proof_every_hours?: number + record: Json + requester_key: string + seeders_max?: number + seeders_min?: number + size_bytes: number + spent_usd?: number + starts_at?: string + status?: string + trackers?: Json + updated_at?: string + visibility: string + } + Update: { + attestation_id?: string + budget_usd?: number + created_at?: string + days?: number + expires_at?: string + fee_usd?: number + id?: string + paid_at?: string | null + pass?: Json | null + payment_id?: string | null + price_usd_per_gib_month?: number + proof_every_hours?: number + record?: Json + requester_key?: string + seeders_max?: number + seeders_min?: number + size_bytes?: number + spent_usd?: number + starts_at?: string + status?: string + trackers?: Json + updated_at?: string + visibility?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_offers_attestation_id_fkey" + columns: ["attestation_id"] + isOneToOne: false + referencedRelation: "openswarm_attestations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_offers_requester_key_fkey" + columns: ["requester_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_parties: { + Row: { + abandoned: number + account_id: string | null + balance_usd: number + created_at: string + failed: number + honoured: number + key: string + kind: string + label: string | null + operator_key: string | null + paid_out_usd: number + payout_address: string | null + payout_network: string | null + proven: number + updated_at: string + voided: number + } + Insert: { + abandoned?: number + account_id?: string | null + balance_usd?: number + created_at?: string + failed?: number + honoured?: number + key: string + kind?: string + label?: string | null + operator_key?: string | null + paid_out_usd?: number + payout_address?: string | null + payout_network?: string | null + proven?: number + updated_at?: string + voided?: number + } + Update: { + abandoned?: number + account_id?: string | null + balance_usd?: number + created_at?: string + failed?: number + honoured?: number + key?: string + kind?: string + label?: string | null + operator_key?: string | null + paid_out_usd?: number + payout_address?: string | null + payout_network?: string | null + proven?: number + updated_at?: string + voided?: number + } + Relationships: [ + { + foreignKeyName: "openswarm_parties_operator_key_fkey" + columns: ["operator_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_proofs: { + Row: { + created_at: string + detail: string | null + id: string + kind: string + lease_id: string + passed: boolean + period: number + record: Json + verifier_key: string | null + } + Insert: { + created_at?: string + detail?: string | null + id: string + kind: string + lease_id: string + passed: boolean + period: number + record: Json + verifier_key?: string | null + } + Update: { + created_at?: string + detail?: string | null + id?: string + kind?: string + lease_id?: string + passed?: boolean + period?: number + record?: Json + verifier_key?: string | null + } + Relationships: [ + { + foreignKeyName: "openswarm_proofs_lease_id_fkey" + columns: ["lease_id"] + isOneToOne: false + referencedRelation: "openswarm_leases" + referencedColumns: ["id"] + }, + ] + } + openswarm_receipts: { + Row: { + balance_usd: number + created_at: string + earned_usd: number + id: string + lane: string + lease_id: string + period: number + proof_id: string | null + record: Json + } + Insert: { + balance_usd: number + created_at?: string + earned_usd: number + id: string + lane: string + lease_id: string + period: number + proof_id?: string | null + record: Json + } + Update: { + balance_usd?: number + created_at?: string + earned_usd?: number + id?: string + lane?: string + lease_id?: string + period?: number + proof_id?: string | null + record?: Json + } + Relationships: [ + { + foreignKeyName: "openswarm_receipts_lease_id_fkey" + columns: ["lease_id"] + isOneToOne: false + referencedRelation: "openswarm_leases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_receipts_proof_id_fkey" + columns: ["proof_id"] + isOneToOne: false + referencedRelation: "openswarm_proofs" + referencedColumns: ["id"] + }, + ] + } + openswarm_team_invites: { + Row: { + created_at: string + expires_at: string + id: string + invited_by: string + invitee: string + redeemed_at: string | null + redeemed_by: string | null + role: string + team_id: string + token_hash: string + } + Insert: { + created_at?: string + expires_at: string + id?: string + invited_by: string + invitee: string + redeemed_at?: string | null + redeemed_by?: string | null + role?: string + team_id: string + token_hash: string + } + Update: { + created_at?: string + expires_at?: string + id?: string + invited_by?: string + invitee?: string + redeemed_at?: string | null + redeemed_by?: string | null + role?: string + team_id?: string + token_hash?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_team_invites_invited_by_fkey" + columns: ["invited_by"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_invites_redeemed_by_fkey" + columns: ["redeemed_by"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_invites_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "openswarm_teams" + referencedColumns: ["id"] + }, + ] + } + openswarm_team_members: { + Row: { + box_key: string | null + created_at: string + id: string + member_key: string + removed_at: string | null + role: string + team_id: string + } + Insert: { + box_key?: string | null + created_at?: string + id?: string + member_key: string + removed_at?: string | null + role?: string + team_id: string + } + Update: { + box_key?: string | null + created_at?: string + id?: string + member_key?: string + removed_at?: string | null + role?: string + team_id?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_team_members_member_key_fkey" + columns: ["member_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_members_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "openswarm_teams" + referencedColumns: ["id"] + }, + ] + } + openswarm_teams: { + Row: { + created_at: string + id: string + name: string + owner_key: string + rotate_on_remove: boolean + scope: Json + seats_paid: number + updated_at: string + } + Insert: { + created_at?: string + id?: string + name: string + owner_key: string + rotate_on_remove?: boolean + scope?: Json + seats_paid?: number + updated_at?: string + } + Update: { + created_at?: string + id?: string + name?: string + owner_key?: string + rotate_on_remove?: boolean + scope?: Json + seats_paid?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_teams_owner_key_fkey" + columns: ["owner_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } email_accounts: { Row: { id: string; diff --git a/supabase/migrations/20260906030000_pay2seed_hub.sql b/supabase/migrations/20260906030000_pay2seed_hub.sql new file mode 100644 index 00000000..8a2d75e0 --- /dev/null +++ b/supabase/migrations/20260906030000_pay2seed_hub.sql @@ -0,0 +1,407 @@ +-- The pay2seed / paid2seed hub. +-- +-- bittorrented.com is the reference hub for the OpenSwarm payment family +-- (logicsrc.com/openswarm). A requester attests what they are putting on a +-- swarm and why they may, escrows a budget for it to be kept alive, and +-- seeders take leases, prove every period that they hold and serve it, and are +-- paid per GiB-month. The hub takes 1 percent of what crosses it and never +-- touches a seeder's floor. +-- +-- Every party here is a KEY, and a key is either a human or a bit (an +-- autonomous agent). That is the whole point of the lane column: this layer +-- carries h2h, h2b, b2h and b2b alike, and we want to see which is growing. +-- +-- openswarm_parties — a key, its kind, its payout address, its standing +-- openswarm_attestations — consent: what this is and why you may share it +-- openswarm_offers — money escrowed for a swarm to be kept +-- openswarm_leases — one seeder's slot on an offer +-- openswarm_receipts — one proven period's earnings +-- openswarm_proofs — the challenge answer or probe behind a receipt +-- openswarm_notices — a claim against an attestation +-- openswarm_teams — who may decrypt a private swarm +-- openswarm_team_members — the member keys, with roles +-- openswarm_team_invites — an invitation, until it is redeemed or expires + +CREATE OR REPLACE FUNCTION update_openswarm_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SET search_path = ''; + +-- --------------------------------------------------------------------------- +-- Parties: every key the hub knows, human or bit. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_parties ( + key TEXT PRIMARY KEY, + -- 'human' or 'bit'. A bit is an autonomous agent; it sells and earns on the + -- same terms and under the same consent rules. + kind TEXT NOT NULL DEFAULT 'human', + -- An agent names the human answerable for it. Required for a bit's PUBLIC + -- attestations, so a notice always reaches somebody. + operator_key TEXT REFERENCES openswarm_parties(key) ON DELETE SET NULL, + label TEXT, + -- Linked to a site account when the key was minted here; null for a stranger. + account_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + payout_address TEXT, + payout_network TEXT, + balance_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + paid_out_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + -- Standing counters (paid2seed §6.3). Read, never trusted from the client. + proven INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + abandoned INTEGER NOT NULL DEFAULT 0, + honoured INTEGER NOT NULL DEFAULT 0, + voided INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_parties_kind_check CHECK (kind IN ('human', 'bit')), + CONSTRAINT openswarm_parties_key_check CHECK (key ~ '^ed25519:[0-9a-f]{64}$'), + -- An address without a network is not a payee (coinpay-payee-always-required). + CONSTRAINT openswarm_parties_payout_check + CHECK ((payout_address IS NULL) = (payout_network IS NULL)) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_account ON openswarm_parties(account_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_kind ON openswarm_parties(kind); +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_payout ON openswarm_parties(payout_address); + +-- --------------------------------------------------------------------------- +-- Attestations: consent, signed, before anything is listed. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_attestations ( + id TEXT PRIMARY KEY, + requester_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + visibility TEXT NOT NULL, + basis TEXT NOT NULL, + license TEXT, + description TEXT, + notice_endpoint TEXT, + -- Exactly one subject: a v1/v2 infohash (public), a file key (private) or a + -- channel key (a live stream). Enforced below. + infohash_v1 TEXT, + infohash_v2 TEXT, + file_key TEXT, + channel_key TEXT, + -- Every swarm on the market carries a README. No README, no listing. + readme TEXT NOT NULL, + readme_sha256 TEXT NOT NULL, + -- The signed record exactly as received, so anyone can re-verify it. + record JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'claimed', + -- A public claim is only listed once this passes (pay2seed §3.3). + claim_window_ends_at TIMESTAMPTZ, + honoured_at TIMESTAMPTZ, + voided_at TIMESTAMPTZ, + void_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_attestations_visibility_check CHECK (visibility IN ('public', 'private')), + CONSTRAINT openswarm_attestations_basis_check + CHECK (basis IN ('own', 'licensed', 'open-license', 'public-domain', 'personal')), + CONSTRAINT openswarm_attestations_status_check + CHECK (status IN ('claimed', 'honoured', 'voided')), + -- personal is for a private backup nobody else is meant to read. + CONSTRAINT openswarm_attestations_personal_check + CHECK (basis <> 'personal' OR visibility = 'private'), + CONSTRAINT openswarm_attestations_license_check + CHECK (basis <> 'open-license' OR license IS NOT NULL), + -- A public swarm must say where a notice goes. + CONSTRAINT openswarm_attestations_notice_check + CHECK (visibility = 'private' OR notice_endpoint IS NOT NULL), + CONSTRAINT openswarm_attestations_readme_check CHECK (length(readme) BETWEEN 1 AND 65536), + CONSTRAINT openswarm_attestations_subject_check CHECK ( + (CASE WHEN infohash_v1 IS NOT NULL OR infohash_v2 IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN file_key IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN channel_key IS NOT NULL THEN 1 ELSE 0 END) = 1 + ), + -- An infohash subject is public; a file key is private. + CONSTRAINT openswarm_attestations_public_check + CHECK (NOT (infohash_v1 IS NOT NULL OR infohash_v2 IS NOT NULL) OR visibility = 'public'), + CONSTRAINT openswarm_attestations_private_check + CHECK (file_key IS NULL OR visibility = 'private') +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_requester ON openswarm_attestations(requester_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_status ON openswarm_attestations(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_v1 ON openswarm_attestations(infohash_v1); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_file ON openswarm_attestations(file_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_channel ON openswarm_attestations(channel_key); +-- One honoured claim per public infohash: the second requester is refused +-- unless their basis does not depend on who is asking (pay2seed §3.3). +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_attestations_one_honoured_v1 + ON openswarm_attestations(infohash_v1) + WHERE infohash_v1 IS NOT NULL AND status = 'honoured'; + +DROP TRIGGER IF EXISTS trigger_openswarm_attestations_updated_at ON openswarm_attestations; +CREATE TRIGGER trigger_openswarm_attestations_updated_at + BEFORE UPDATE ON openswarm_attestations + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Offers: a budget escrowed for a swarm to be held. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_offers ( + id TEXT PRIMARY KEY, + attestation_id TEXT NOT NULL REFERENCES openswarm_attestations(id) ON DELETE CASCADE, + requester_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + visibility TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + days INTEGER NOT NULL, + seeders_min INTEGER NOT NULL DEFAULT 1, + seeders_max INTEGER NOT NULL DEFAULT 1, + price_usd_per_gib_month NUMERIC(12, 6) NOT NULL, + budget_usd NUMERIC(14, 6) NOT NULL, + -- What leases have earned so far; the rest is refundable. + spent_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + -- The hub's 1 percent, charged to the requester on top of the budget. + fee_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + proof_every_hours INTEGER NOT NULL DEFAULT 6, + trackers JSONB NOT NULL DEFAULT '[]'::JSONB, + -- A pass so a leased seeder can pull a paid private swarm as a paying peer. + pass JSONB, + record JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + payment_id TEXT, + paid_at TIMESTAMPTZ, + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_offers_status_check + CHECK (status IN ('unpaid', 'pending', 'active', 'settled', 'voided')), + CONSTRAINT openswarm_offers_visibility_check CHECK (visibility IN ('public', 'private')), + CONSTRAINT openswarm_offers_seeders_check CHECK (seeders_min >= 1 AND seeders_max >= seeders_min), + CONSTRAINT openswarm_offers_days_check CHECK (days > 0), + CONSTRAINT openswarm_offers_size_check CHECK (size_bytes > 0), + CONSTRAINT openswarm_offers_price_check CHECK (price_usd_per_gib_month >= 0), + CONSTRAINT openswarm_offers_budget_check CHECK (budget_usd >= 0 AND spent_usd >= 0), + CONSTRAINT openswarm_offers_proof_check CHECK (proof_every_hours BETWEEN 1 AND 168) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_status ON openswarm_offers(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_requester ON openswarm_offers(requester_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_attestation ON openswarm_offers(attestation_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_expires ON openswarm_offers(expires_at); +-- Unique so a retried webhook cannot pay the same offer twice. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_offers_payment + ON openswarm_offers(payment_id) WHERE payment_id IS NOT NULL; + +DROP TRIGGER IF EXISTS trigger_openswarm_offers_updated_at ON openswarm_offers; +CREATE TRIGGER trigger_openswarm_offers_updated_at + BEFORE UPDATE ON openswarm_offers + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Leases: one seeder's slot on an offer. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_leases ( + id TEXT PRIMARY KEY, + offer_id TEXT NOT NULL REFERENCES openswarm_offers(id) ON DELETE CASCADE, + seeder_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + slot INTEGER NOT NULL, + price_usd_per_gib_month NUMERIC(12, 6) NOT NULL, + -- The payer and payee kinds at lease time, so the lane survives a party + -- later changing kind. h2h, h2b, b2h or b2b. + lane TEXT NOT NULL, + earned_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + periods_proven INTEGER NOT NULL DEFAULT 0, + periods_failed INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'fetching', + grace_hours INTEGER NOT NULL DEFAULT 24, + last_proof_at TIMESTAMPTZ, + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ NOT NULL, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_leases_status_check + CHECK (status IN ('fetching', 'proven', 'lapsed', 'abandoned', 'ended', 'voided')), + CONSTRAINT openswarm_leases_lane_check CHECK (lane IN ('h2h', 'h2b', 'b2h', 'b2b')), + CONSTRAINT openswarm_leases_slot_check CHECK (slot >= 1) +); + +-- One lease per seeder per offer, and one seeder per slot. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_leases_one_per_seeder + ON openswarm_leases(offer_id, seeder_key); +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_leases_slot + ON openswarm_leases(offer_id, slot) + WHERE status <> 'abandoned' AND status <> 'voided'; +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_seeder ON openswarm_leases(seeder_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_status ON openswarm_leases(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_lane ON openswarm_leases(lane); + +DROP TRIGGER IF EXISTS trigger_openswarm_leases_updated_at ON openswarm_leases; +CREATE TRIGGER trigger_openswarm_leases_updated_at + BEFORE UPDATE ON openswarm_leases + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Proofs and receipts: what a period was paid for. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_proofs ( + id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL REFERENCES openswarm_leases(id) ON DELETE CASCADE, + period INTEGER NOT NULL, + kind TEXT NOT NULL, + verifier_key TEXT, + passed BOOLEAN NOT NULL, + detail TEXT, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_proofs_kind_check CHECK (kind IN ('challenge', 'probe')), + CONSTRAINT openswarm_proofs_period_check CHECK (period >= 0) +); + +-- One verdict per period per lease: a retried report updates, never doubles. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_proofs_period + ON openswarm_proofs(lease_id, period); + +CREATE TABLE IF NOT EXISTS openswarm_receipts ( + id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL REFERENCES openswarm_leases(id) ON DELETE CASCADE, + proof_id TEXT REFERENCES openswarm_proofs(id) ON DELETE SET NULL, + period INTEGER NOT NULL, + earned_usd NUMERIC(14, 6) NOT NULL, + balance_usd NUMERIC(14, 6) NOT NULL, + lane TEXT NOT NULL, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_receipts_lane_check CHECK (lane IN ('h2h', 'h2b', 'b2h', 'b2b')), + CONSTRAINT openswarm_receipts_earned_check CHECK (earned_usd >= 0) +); + +-- A period is paid once. This unique index is the whole of the idempotency. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_receipts_period + ON openswarm_receipts(lease_id, period); +CREATE INDEX IF NOT EXISTS idx_openswarm_receipts_lane ON openswarm_receipts(lane); + +-- --------------------------------------------------------------------------- +-- Notices: anyone may claim against an attestation. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_notices ( + id TEXT PRIMARY KEY, + attestation_id TEXT NOT NULL REFERENCES openswarm_attestations(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + claimant_name TEXT, + claimant_contact TEXT, + statement TEXT NOT NULL, + record JSONB NOT NULL, + outcome TEXT NOT NULL DEFAULT 'received', + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_notices_kind_check + CHECK (kind IN ('rights', 'illegal', 'personal-data', 'other')), + CONSTRAINT openswarm_notices_outcome_check + CHECK (outcome IN ('received', 'voided', 'stands')) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_notices_attestation ON openswarm_notices(attestation_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_notices_outcome ON openswarm_notices(outcome); + +-- --------------------------------------------------------------------------- +-- Teams: who may decrypt a private swarm. This is what the hub sells. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + name TEXT NOT NULL, + -- File keys or publisher keys members may be granted; ["*"] under the owner. + scope JSONB NOT NULL DEFAULT '{"files":["*"],"publishers":[]}'::JSONB, + rotate_on_remove BOOLEAN NOT NULL DEFAULT TRUE, + seats_paid INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_teams_name_check CHECK (length(name) BETWEEN 1 AND 120) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_teams_owner_name ON openswarm_teams(owner_key, name); + +DROP TRIGGER IF EXISTS trigger_openswarm_teams_updated_at ON openswarm_teams; +CREATE TRIGGER trigger_openswarm_teams_updated_at + BEFORE UPDATE ON openswarm_teams + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +CREATE TABLE IF NOT EXISTS openswarm_team_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES openswarm_teams(id) ON DELETE CASCADE, + member_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + box_key TEXT, + role TEXT NOT NULL DEFAULT 'member', + removed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_team_members_role_check CHECK (role IN ('admin', 'member', 'readonly')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_team_members_unique + ON openswarm_team_members(team_id, member_key) WHERE removed_at IS NULL; + +CREATE TABLE IF NOT EXISTS openswarm_team_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES openswarm_teams(id) ON DELETE CASCADE, + invited_by TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + -- An email, a handle or a key. The invitee brings a key when they redeem. + invitee TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + redeemed_at TIMESTAMPTZ, + redeemed_by TEXT REFERENCES openswarm_parties(key) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_team_invites_role_check CHECK (role IN ('admin', 'member', 'readonly')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_team_invites_token ON openswarm_team_invites(token_hash); +CREATE INDEX IF NOT EXISTS idx_openswarm_team_invites_team ON openswarm_team_invites(team_id); + +-- --------------------------------------------------------------------------- +-- Row level security. Everything here is written by the service role: the +-- records are signed by keys, not by site sessions, so a browser session is +-- never the authority. Reads of the public market go through the API, which +-- decides what a stranger may see. +-- --------------------------------------------------------------------------- +ALTER TABLE openswarm_parties ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_attestations ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_offers ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_leases ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_proofs ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_receipts ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_notices ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_team_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_team_invites ENABLE ROW LEVEL SECURITY; + +DO $$ +DECLARE t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'openswarm_parties', 'openswarm_attestations', 'openswarm_offers', 'openswarm_leases', + 'openswarm_proofs', 'openswarm_receipts', 'openswarm_notices', 'openswarm_teams', + 'openswarm_team_members', 'openswarm_team_invites' + ] LOOP + EXECUTE format('DROP POLICY IF EXISTS "Service role manages %1$s" ON %1$s', t); + EXECUTE format( + 'CREATE POLICY "Service role manages %1$s" ON %1$s FOR ALL USING (auth.jwt() ->> ''role'' = ''service_role'')', + t + ); + END LOOP; +END $$; + +-- A signed-in account can read the parties it owns, so the site can show a +-- person their own keys, balance and standing without the service role. +DROP POLICY IF EXISTS "Owners read own parties" ON openswarm_parties; +CREATE POLICY "Owners read own parties" + ON openswarm_parties FOR SELECT + USING (auth.uid() = account_id);