Signup without a code queues a pending application; registry approve issues credentials

This commit is contained in:
George Coles
2026-09-15 11:59:30 -04:00
parent 8d74b63b11
commit 3de20ed89d
6 changed files with 266 additions and 54 deletions
+144 -47
View File
@@ -431,7 +431,7 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
return Ok(());
}
for app in &applications {
println!("{} [{}] {} <{}>", app.id, app.class, app.org, app.email);
println!("{} [{}] {} <{}>{}", app.id, app.class, app.org, app.email, app.status);
println!(" representative: {}", app.representative);
if !app.address.is_empty() {
println!(" address: {}", app.address);
@@ -449,6 +449,41 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
Ok(())
}
/// Approves a pending application: creates the member stub (class from the application),
/// issues a 24h invite, and prints the credential block to hand to the member.
pub fn registry_approve(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<()> {
let (_, signed) = open_registry(dir)?;
if signed.doc.members.iter().any(|member| member.id == id) {
return Err(anyhow!("member {id} already listed"));
}
let application = registry::approve_application(dir, id)?;
let class = if application.class == CLASS_ENRICHMENT {
CLASS_ENRICHMENT
} else {
CLASS_SOURCE
}
.to_string();
mutate_registry(dir, |doc| {
doc.members.push(RegistryMember {
id: id.to_string(),
class: class.clone(),
keys: Vec::new(),
enc_key: None,
});
Ok(())
})?;
let invite = registry::create_invite(dir, id, 24 * 3600)?;
let (_, signed) = open_registry(dir)?;
let registry_url = registry_url.unwrap_or("<registry-url>");
println!("approved {id} ({class})");
println!("hand this credential block to the member:");
println!(
"id={id} token={} registry={registry_url} ma_key={}",
invite.token, signed.doc.ma_key
);
Ok(())
}
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
mutate_registry(dir, |doc| {
doc.relays = relays.to_vec();
@@ -570,20 +605,36 @@ async fn registry_signup(
State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<SignupRequest>,
) -> Response {
let Some(expected) = &server.signup_code else {
let label = sanitize_label(&request.label);
if label.is_empty() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "signup is not enabled" })),
)
.into_response();
};
if request.code.as_deref() != Some(expected.as_str()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })),
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
)
.into_response();
}
// A pre-approval code, if supplied, must be valid; an empty code queues for review.
let code = request
.code
.as_deref()
.map(str::trim)
.filter(|code| !code.is_empty());
if let Some(code) = code {
let Some(expected) = &server.signup_code else {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "no pre-approval code is configured; submit without one to queue for review" })),
)
.into_response();
};
if code != expected.as_str() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })),
)
.into_response();
}
}
if !request.attestation {
return (
StatusCode::BAD_REQUEST,
@@ -598,11 +649,13 @@ async fn registry_signup(
)
.into_response();
}
let label = sanitize_label(&request.label);
if label.is_empty() {
if request.org.trim().is_empty()
|| request.representative.trim().is_empty()
|| request.email.trim().is_empty()
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
Json(serde_json::json!({ "error": "organization name, representative, and contact email are required" })),
)
.into_response();
}
@@ -625,8 +678,9 @@ async fn registry_signup(
)
.into_response();
}
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) {
Ok(invite) => invite,
let applications = match registry::load_applications(&registry::applications_path(&server.dir))
{
Ok(applications) => applications,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -635,26 +689,18 @@ async fn registry_signup(
.into_response();
}
};
if applications.iter().any(|application| application.id == id) {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "an application for this identifier is already on file" })),
)
.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: class.to_string(),
keys: Vec::new(),
enc_key: None,
});
Ok(())
}) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let application = registry::Application {
id: id.clone(),
org: request.org.clone(),
@@ -665,8 +711,31 @@ async fn registry_signup(
class: class.to_string(),
payment: request.payment.clone(),
privacy_link: request.privacy_link.clone(),
status: if code.is_some() {
"approved".to_string()
} else {
"pending".to_string()
},
submitted_at: now_ts(),
};
if code.is_none() {
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();
}
return (
StatusCode::ACCEPTED,
Json(serde_json::json!({
"status": "pending",
"id": id,
"message": "application received — the membership authority reviews it and issues your credential block"
})),
)
.into_response();
}
if let Err(error) = registry::record_application(&server.dir, application) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -674,6 +743,31 @@ async fn registry_signup(
)
.into_response();
}
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) {
Ok(invite) => invite,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
if let Err(error) = mutate_registry(&server.dir, |doc| {
doc.members.push(RegistryMember {
id: id.clone(),
class: class.to_string(),
keys: Vec::new(),
enc_key: None,
});
Ok(())
}) {
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();
(
@@ -816,20 +910,21 @@ scores on the wire, no in-protocol payment.</p>
<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>
<p class="muted">This form requests membership from the membership authority (MA). The MA reviews
your organization details and issues a credential block
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
binds your node's keys to the identifier. 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 — never published, never on the wire.</p>
<form id="f">
<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>Signup code (optional)<br>
<input name="code" type="password" placeholder="pre-approval code"></label>
<p class="muted">Leave empty to queue your application for MA review — the MA will contact you
with your credential block. A pre-approval code, issued by the MA out of band, approves you
immediately and returns the block here.</p>
<label>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br>
@@ -859,10 +954,10 @@ operator for one. (If you run the MA, it is the <code>--signup-code</code> passe
<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.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>
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.2/frxd-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.2/frxd-linux-amd64.sha256
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.2/frx-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.2/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>
@@ -931,9 +1026,11 @@ f.onsubmit = async (e) => {
});
const body = await res.json();
out.style.display = "block";
out.textContent = res.ok
out.textContent = body.credentials
? "Membership approved.\n\nNext: download frxd (step 2), install it (step 3), then run `frxd --onboarding` and paste this block:\n\n" + body.credentials + "\n"
: "Failed: " + (body.error || ("http " + res.status));
: res.ok
? "Application received.\n\n" + (body.message || "The membership authority will review it and issue your credential block.")
: "Failed: " + (body.error || ("http " + res.status));
};
(async () => {