Registration page: full organization application, private MA-side storage

This commit is contained in:
George Coles
2026-09-15 11:46:11 -04:00
parent 13bb124dcc
commit 8d74b63b11
6 changed files with 276 additions and 23 deletions
+154 -18
View File
@@ -424,6 +424,31 @@ pub fn registry_list(dir: &Path) -> Result<()> {
Ok(())
}
pub fn registry_applications(dir: &Path) -> Result<()> {
let applications = registry::load_applications(&registry::applications_path(dir))?;
if applications.is_empty() {
println!("no applications recorded");
return Ok(());
}
for app in &applications {
println!("{} [{}] {} <{}>", app.id, app.class, app.org, app.email);
println!(" representative: {}", app.representative);
if !app.address.is_empty() {
println!(" address: {}", app.address);
}
if !app.domain.is_empty() {
println!(" domain: {}", app.domain);
}
if !app.payment.is_empty() {
println!(" payment: {}", app.payment);
}
if !app.privacy_link.is_empty() {
println!(" privacy: {}", app.privacy_link);
}
}
Ok(())
}
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
mutate_registry(dir, |doc| {
doc.relays = relays.to_vec();
@@ -519,6 +544,26 @@ fn sanitize_label(input: &str) -> String {
struct SignupRequest {
label: String,
code: Option<String>,
#[serde(default)]
org: String,
#[serde(default)]
representative: String,
#[serde(default)]
email: String,
#[serde(default)]
address: String,
#[serde(default)]
domain: String,
#[serde(default)]
class: Option<String>,
#[serde(default)]
payment: String,
#[serde(default)]
privacy_link: String,
#[serde(default)]
attestation: bool,
#[serde(default)]
privacy_ack: bool,
}
async fn registry_signup(
@@ -539,6 +584,20 @@ async fn registry_signup(
)
.into_response();
}
if !request.attestation {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "content authorization must be confirmed" })),
)
.into_response();
}
if !request.privacy_ack {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "the privacy notice must be acknowledged" })),
)
.into_response();
}
let label = sanitize_label(&request.label);
if label.is_empty() {
return (
@@ -576,10 +635,15 @@ async fn registry_signup(
.into_response();
}
};
let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
CLASS_ENRICHMENT
} else {
CLASS_SOURCE
};
if let Err(error) = mutate_registry(&server.dir, |doc| {
doc.members.push(RegistryMember {
id: id.clone(),
class: crate::config::CLASS_SOURCE.to_string(),
class: class.to_string(),
keys: Vec::new(),
enc_key: None,
});
@@ -591,6 +655,25 @@ async fn registry_signup(
)
.into_response();
}
let application = registry::Application {
id: id.clone(),
org: request.org.clone(),
representative: request.representative.clone(),
email: request.email.clone(),
address: request.address.clone(),
domain: request.domain.clone(),
class: class.to_string(),
payment: request.payment.clone(),
privacy_link: request.privacy_link.clone(),
submitted_at: now_ts(),
};
if let Err(error) = registry::record_application(&server.dir, application) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let registry_url = server.registry_url.clone().unwrap_or_default();
let ma_key = signed.doc.ma_key.clone();
(
@@ -707,8 +790,10 @@ const REGISTRY_PAGE: &str = r##"<!doctype html>
.tag { color: #555; margin-top: 0; }
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.1rem 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
input, button { font: inherit; border: 1px solid #bbb; border-radius: 6px; padding: 0.45rem 0.6rem; }
input { width: 100%; margin: 0.2rem 0 0.9rem; }
input, select, button { font: inherit; border: 1px solid #bbb; border-radius: 6px; padding: 0.45rem 0.6rem; }
input, select { width: 100%; margin: 0.2rem 0 0.9rem; }
.check { display: block; font-size: 0.92rem; margin: 0.5rem 0; }
.check input { width: auto; margin: 0 0.4rem 0 0; }
button { background: #174ea6; color: #fff; border: none; cursor: pointer; padding: 0.5rem 1rem; border-radius: 6px; }
button:hover { background: #0f3d91; }
#out { display: none; background: #101418; color: #d6f5d6; padding: 0.85rem 1rem;
@@ -729,28 +814,55 @@ small: signed messages, budgets, honest truncation, aggregate courtesy. No annou
scores on the wire, no in-protocol payment.</p>
<p>Everything else — matching, ranking, retention, trust — is local.</p>
<h2>1. Register</h2>
<h2>1. Register with the membership authority</h2>
<div class="card">
<p class="muted">Registering here creates your identifier with the membership authority (MA); the
onboarding wizard in step 4 then binds your node's keys to it. The public registry publishes only
your identifier, class, keys, and the federation's relays. The organization details below are kept
privately by the MA for the membership contract — they are never published and never travel on the
wire.</p>
<form id="f">
<label>Organization or handle<br>
<input name="label" required pattern="[A-Za-z0-9 -]+" placeholder="acme-docs"></label><br>
<label>Signup code (issued by the membership authority)<br>
<input name="code" type="password" placeholder="signup code"></label><br>
<label>Short name — this becomes your identifier<br>
<input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label>
<p class="muted">identifier: <code id="preview">(type a short name)</code> — no domain or DNS of your own is needed.</p>
<label>Signup code<br>
<input name="code" type="password" placeholder="invite code"></label>
<p class="muted">The code is the admission gate: members are approved, not anonymous. Ask the MA
operator for one. (If you run the MA, it is the <code>--signup-code</code> passed to
<code>frxd registry serve</code>.)</p>
<label>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br>
<input name="representative" required placeholder="Jane Keswick"></label>
<label>Contact email<br>
<input name="email" type="email" required placeholder="ops@example.org"></label>
<label>Registered address<br>
<input name="address" placeholder="street, city, country"></label>
<label>Organization domain (optional)<br>
<input name="domain" placeholder="example.org"></label>
<label>What you will share<br>
<select name="member_class">
<option value="source">Source member — content I own or host</option>
<option value="enrichment">Enrichment member — derived corpora (metadata-only)</option>
</select></label>
<label>Payment details (billing / payout — e.g. IBAN or payment handle)<br>
<input name="payment" placeholder="kept private; the protocol itself carries no payment"></label>
<label>Your privacy statement URL (optional)<br>
<input name="privacy_link" placeholder="https://example.org/privacy"></label>
<label class="check"><input type="checkbox" name="attestation" required> I will only index content I own or that users supply, and only collections I explicitly mark shared will answer queries.</label>
<label class="check"><input type="checkbox" name="privacy_ack" required> I acknowledge the privacy notice above.</label>
<button type="submit">Request membership</button>
</form>
<pre id="out"></pre>
<p class="muted">Your identifier is <code>&lt;label&gt;.frx.federatedsearch.org</code> — no domain or DNS of
your own is needed. Registration returns a one-time credential block:
<code>id=... token=... registry=... ma_key=...</code>.</p>
</div>
<h2>2. Download</h2>
<div class="card">
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frxd-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frxd-linux-amd64.sha256
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frx-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frx-linux-amd64.sha256</pre>
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64.sha256
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frx-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frx-linux-amd64.sha256</pre>
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
</div>
@@ -787,13 +899,35 @@ frxd serve</pre>
<script>
const out = document.getElementById("out");
document.getElementById("f").onsubmit = async (e) => {
const f = document.getElementById("f");
let zone = "frx.federatedsearch.org";
function sanitizeLabel(v) {
let label = "", lastDash = true;
for (const c of v.toLowerCase()) {
if (/[a-z0-9]/.test(c)) { label += c; lastDash = false; }
else if (!lastDash && (/\s/.test(c) || c === "-" || c === "_" || c === ".")) { label += "-"; lastDash = true; }
if (label.length >= 32) break;
}
return label.replace(/^-+|-+$/g, "").slice(0, 32);
}
const preview = document.getElementById("preview");
const updatePreview = () => {
const label = sanitizeLabel(f.label.value);
preview.textContent = label ? label + "." + zone : "(type a short name)";
};
f.label.addEventListener("input", updatePreview);
f.onsubmit = async (e) => {
e.preventDefault();
const label = e.target.label.value, code = e.target.code.value;
const res = await fetch("/v1/signup", {
method: "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify({label, code})
body: JSON.stringify({
label: f.label.value, code: f.code.value,
org: f.org.value, representative: f.representative.value, email: f.email.value,
address: f.address.value, domain: f.domain.value, class: f.member_class.value,
payment: f.payment.value, privacy_link: f.privacy_link.value,
attestation: f.attestation.checked, privacy_ack: f.privacy_ack.checked
})
});
const body = await res.json();
out.style.display = "block";
@@ -806,6 +940,8 @@ document.getElementById("f").onsubmit = async (e) => {
try {
const res = await fetch("/registry.json");
const doc = await res.json();
zone = doc.zone || zone;
updatePreview();
const ids = doc.members.map((m) => m.id);
document.getElementById("members").textContent = doc.members.length === 0
? "The registry is empty — be the first member."