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
+1 -1
View File
@@ -50,6 +50,6 @@
- Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role.
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 12 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search``EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine).
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Invites live in `<registry dir>/invites.json`; the form's organization/representative/contact/payment fields are stored privately in `<registry dir>/applications.json` (mode 600, MA contract data — never in the signed snapshot), and the two acknowledgement checkboxes are required by the endpoint. Prompts accept empty input as the default; scripted stdin works for tests.
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Signup without a code queues a pending application (`<registry dir>/applications.json`, mode 600, MA contract data — never in the signed snapshot); `frxd registry approve <id>` promotes it (member stub + invite + credential block). A valid `--signup-code` approves immediately. Invites live in `<registry dir>/invites.json`. Prompts accept empty input as the default; scripted stdin works for tests.
- Next matching steps: eval harness with a small golden set (precision@k + false-silence rate), then a dense recall leg (model2vec-rs 0.2.1 exists but needs `default-features = false, features = ["fancy-regex", "local-only"]` for musl/airgapped; verify crate + model licenses before bundling), then an optional cross-encoder reranker. Embeddings are for recall; reranking is the precision tier.
- Identity/registry (RFC Draft 0.5 §4/§6): MA-hosted FQDN identifiers first (`<label>.frx.<ma-domain>`, no DNS needed by users), signed versioned registry snapshot with the MA key pinned; envelope `from` = identifier, `key` = pubkey; registry outage fails static. Member-hosted identities, MA anchor rollover, and unicast confidentiality are §10 open. Implementation phases: A (signed registry snapshot) and B (identifier + `key` + JCS on the wire) are built and tested. Prioritize frictionless onboarding (users may be department-level and cannot create DNS).
+1 -1
View File
@@ -29,7 +29,7 @@ frxd registry --dir ./ma init --zone frx.federatedsearch.org
frxd registry --dir ./ma serve --listen 127.0.0.1:7800 --signup-code <code> --registry-url https://ma.federatedsearch.org/registry.json
```
(put Caddy in front for a real domain). The page at `/` accepts the registration form (label, signup code, organization details) and returns a credential block: `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `<registry dir>/applications.json` — contract data, never in the public signed snapshot; review with `frxd registry applications`.
(put Caddy in front for a real domain). The page at `/` collects the registration form (short name, organization details, optional pre-approval code). Without a code the application queues for MA review — `frxd registry --dir <dir> applications` lists it and `frxd registry --dir <dir> approve <id> --registry-url <url>` issues the member, the invite, and the credential block to hand over; a valid code approves immediately and shows the block on the page. The block is `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `<registry dir>/applications.json` — contract data, never in the public signed snapshot.
New member:
+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 () => {
+8
View File
@@ -180,6 +180,11 @@ enum RegistryCommand {
},
List,
Applications,
Approve {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
SetRelays {
#[arg(required = true)]
relays: Vec<String>,
@@ -356,6 +361,9 @@ async fn main() -> Result<()> {
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
RegistryCommand::List => commands::registry_list(&dir)?,
RegistryCommand::Applications => commands::registry_applications(&dir)?,
RegistryCommand::Approve { id, registry_url } => {
commands::registry_approve(&dir, &id, registry_url.as_deref())?
}
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
RegistryCommand::Show => commands::registry_show(&dir)?,
RegistryCommand::Serve {
+33 -3
View File
@@ -129,9 +129,16 @@ pub struct Application {
pub payment: String,
#[serde(default)]
pub privacy_link: String,
/// "pending" until the MA approves, then "approved".
#[serde(default = "default_status")]
pub status: String,
pub submitted_at: u64,
}
fn default_status() -> String {
"pending".to_string()
}
pub fn applications_path(dir: &Path) -> PathBuf {
dir.join("applications.json")
}
@@ -144,13 +151,36 @@ pub fn load_applications(path: &Path) -> Result<Vec<Application>> {
serde_json::from_str(&raw).context("parsing applications")
}
pub fn save_applications(path: &Path, applications: &[Application]) -> Result<()> {
fs::write(path, serde_json::to_string_pretty(applications)?)?;
crate::config::set_private_permissions(path)?;
Ok(())
}
pub fn record_application(dir: &Path, application: Application) -> Result<()> {
let path = applications_path(dir);
let mut applications = load_applications(&path)?;
applications.push(application);
fs::write(&path, serde_json::to_string_pretty(&applications)?)?;
crate::config::set_private_permissions(&path)?;
Ok(())
save_applications(&path, &applications)
}
/// Marks a pending application approved and returns it. Errors if unknown or not pending.
pub fn approve_application(dir: &Path, id: &str) -> Result<Application> {
let path = applications_path(dir);
let mut applications = load_applications(&path)?;
let Some(application) = applications.iter_mut().find(|app| app.id == id) else {
return Err(anyhow!("no application for {id}"));
};
if application.status != "pending" {
return Err(anyhow!(
"application for {id} is not pending ({})",
application.status
));
}
application.status = "approved".to_string();
let approved = application.clone();
save_applications(&path, &applications)?;
Ok(approved)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+79 -2
View File
@@ -50,7 +50,7 @@ async fn signup_issues_invite_and_enroll_binds_key() {
let response = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame", "attestation": true, "privacy_ack": true}))
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame", "org": "Alice Dev Org", "representative": "Alice", "email": "alice@example.org", "attestation": true, "privacy_ack": true}))
.send()
.await
.unwrap();
@@ -169,6 +169,83 @@ async fn signup_stores_private_application_and_class() {
assert_eq!(app.class, "enrichment");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn signup_without_code_queues_application_for_approval() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
let base = spawn_registry_server(&dir, Some("sesame")).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
// no code: application queues as pending; no credentials, no member entry
let response = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({
"label": "Pending Co",
"org": "Pending Co Ltd",
"representative": "P. Pending",
"email": "ops@pending.example",
"attestation": true,
"privacy_ack": true
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
let body: Value = response.json().await.unwrap();
assert_eq!(body.get("status").and_then(Value::as_str), Some("pending"));
assert!(body.get("credentials").is_none());
let id = "pending-co.frx.invalid";
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(
signed.doc.members.iter().all(|member| member.id != id),
"a pending application must not create a member entry"
);
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, id);
assert_eq!(apps[0].status, "pending");
// a wrong code is still rejected, not queued
let rejected = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({
"label": "other", "code": "wrong", "org": "O", "representative": "R",
"email": "r@o.example", "attestation": true, "privacy_ack": true
}))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
// MA approves: member stub + invite; enrollment binds the key
commands::registry_approve(&dir, id, None).unwrap();
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(signed.doc.members.iter().any(|member| member.id == id));
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps[0].status, "approved");
let invites = registry::load_invites(&dir.join("invites.json")).unwrap();
let invite = invites.iter().find(|invite| invite.id == id).unwrap();
let key = Keypair::generate();
let enrolled = http
.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id,
"token": invite.token,
"pubkey": key.public_hex(),
}))
.send()
.await
.unwrap();
assert!(enrolled.status().is_success());
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wizard_enrolls_and_writes_config() {
let root = tempfile::tempdir().unwrap();
@@ -177,7 +254,7 @@ async fn wizard_enrolls_and_writes_config() {
let http = reqwest::Client::new();
let body: Value = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "attestation": true, "privacy_ack": true}))
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "org": "Wizard Test Org", "representative": "Wiz", "email": "wiz@example.org", "attestation": true, "privacy_ack": true}))
.send()
.await
.unwrap()