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. - 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). - 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). - 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. - 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). - 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 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: New member:
+135 -38
View File
@@ -431,7 +431,7 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
return Ok(()); return Ok(());
} }
for app in &applications { 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); println!(" representative: {}", app.representative);
if !app.address.is_empty() { if !app.address.is_empty() {
println!(" address: {}", app.address); println!(" address: {}", app.address);
@@ -449,6 +449,41 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
Ok(()) 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<()> { pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
mutate_registry(dir, |doc| { mutate_registry(dir, |doc| {
doc.relays = relays.to_vec(); doc.relays = relays.to_vec();
@@ -570,20 +605,36 @@ async fn registry_signup(
State(server): State<std::sync::Arc<RegistryServer>>, State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<SignupRequest>, Json(request): Json<SignupRequest>,
) -> Response { ) -> Response {
let label = sanitize_label(&request.label);
if label.is_empty() {
return (
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 { let Some(expected) = &server.signup_code else {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "signup is not enabled" })), Json(serde_json::json!({ "error": "no pre-approval code is configured; submit without one to queue for review" })),
) )
.into_response(); .into_response();
}; };
if request.code.as_deref() != Some(expected.as_str()) { if code != expected.as_str() {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })), Json(serde_json::json!({ "error": "wrong signup code" })),
) )
.into_response(); .into_response();
} }
}
if !request.attestation { if !request.attestation {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -598,11 +649,13 @@ async fn registry_signup(
) )
.into_response(); .into_response();
} }
let label = sanitize_label(&request.label); if request.org.trim().is_empty()
if label.is_empty() { || request.representative.trim().is_empty()
|| request.email.trim().is_empty()
{
return ( return (
StatusCode::BAD_REQUEST, 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(); .into_response();
} }
@@ -625,8 +678,9 @@ async fn registry_signup(
) )
.into_response(); .into_response();
} }
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) { let applications = match registry::load_applications(&registry::applications_path(&server.dir))
Ok(invite) => invite, {
Ok(applications) => applications,
Err(error) => { Err(error) => {
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -635,26 +689,18 @@ async fn registry_signup(
.into_response(); .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) { let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
CLASS_ENRICHMENT CLASS_ENRICHMENT
} else { } else {
CLASS_SOURCE 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 { let application = registry::Application {
id: id.clone(), id: id.clone(),
org: request.org.clone(), org: request.org.clone(),
@@ -665,8 +711,14 @@ async fn registry_signup(
class: class.to_string(), class: class.to_string(),
payment: request.payment.clone(), payment: request.payment.clone(),
privacy_link: request.privacy_link.clone(), privacy_link: request.privacy_link.clone(),
status: if code.is_some() {
"approved".to_string()
} else {
"pending".to_string()
},
submitted_at: now_ts(), submitted_at: now_ts(),
}; };
if code.is_none() {
if let Err(error) = registry::record_application(&server.dir, application) { if let Err(error) = registry::record_application(&server.dir, application) {
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -674,6 +726,48 @@ async fn registry_signup(
) )
.into_response(); .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,
Json(serde_json::json!({ "error": error.to_string() })),
)
.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 registry_url = server.registry_url.clone().unwrap_or_default();
let ma_key = signed.doc.ma_key.clone(); 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> <h2>1. Register with the membership authority</h2>
<div class="card"> <div class="card">
<p class="muted">Registering here creates your identifier with the membership authority (MA); the <p class="muted">This form requests membership from the membership authority (MA). The MA reviews
onboarding wizard in step 4 then binds your node's keys to it. The public registry publishes only your organization details and issues a credential block
your identifier, class, keys, and the federation's relays. The organization details below are kept (<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
privately by the MA for the membership contract — they are never published and never travel on the binds your node's keys to the identifier. The public registry publishes only your identifier,
wire.</p> 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"> <form id="f">
<label>Short name — this becomes your identifier<br> <label>Short name — this becomes your identifier<br>
<input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label> <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> <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> <label>Signup code (optional)<br>
<input name="code" type="password" placeholder="invite code"></label> <input name="code" type="password" placeholder="pre-approval code"></label>
<p class="muted">The code is the admission gate: members are approved, not anonymous. Ask the MA <p class="muted">Leave empty to queue your application for MA review — the MA will contact you
operator for one. (If you run the MA, it is the <code>--signup-code</code> passed to with your credential block. A pre-approval code, issued by the MA out of band, approves you
<code>frxd registry serve</code>.)</p> immediately and returns the block here.</p>
<label>Legal organization name<br> <label>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label> <input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br> <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> <h2>2. Download</h2>
<div class="card"> <div class="card">
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p> <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 <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.1/frxd-linux-amd64.sha256 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.1/frx-linux-amd64 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.1/frx-linux-amd64.sha256</pre> 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>. <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> Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
</div> </div>
@@ -931,8 +1026,10 @@ f.onsubmit = async (e) => {
}); });
const body = await res.json(); const body = await res.json();
out.style.display = "block"; 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" ? "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"
: 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)); : "Failed: " + (body.error || ("http " + res.status));
}; };
+8
View File
@@ -180,6 +180,11 @@ enum RegistryCommand {
}, },
List, List,
Applications, Applications,
Approve {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
SetRelays { SetRelays {
#[arg(required = true)] #[arg(required = true)]
relays: Vec<String>, relays: Vec<String>,
@@ -356,6 +361,9 @@ async fn main() -> Result<()> {
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?, RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
RegistryCommand::List => commands::registry_list(&dir)?, RegistryCommand::List => commands::registry_list(&dir)?,
RegistryCommand::Applications => commands::registry_applications(&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::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
RegistryCommand::Show => commands::registry_show(&dir)?, RegistryCommand::Show => commands::registry_show(&dir)?,
RegistryCommand::Serve { RegistryCommand::Serve {
+33 -3
View File
@@ -129,9 +129,16 @@ pub struct Application {
pub payment: String, pub payment: String,
#[serde(default)] #[serde(default)]
pub privacy_link: String, pub privacy_link: String,
/// "pending" until the MA approves, then "approved".
#[serde(default = "default_status")]
pub status: String,
pub submitted_at: u64, pub submitted_at: u64,
} }
fn default_status() -> String {
"pending".to_string()
}
pub fn applications_path(dir: &Path) -> PathBuf { pub fn applications_path(dir: &Path) -> PathBuf {
dir.join("applications.json") 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") 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<()> { pub fn record_application(dir: &Path, application: Application) -> Result<()> {
let path = applications_path(dir); let path = applications_path(dir);
let mut applications = load_applications(&path)?; let mut applications = load_applications(&path)?;
applications.push(application); applications.push(application);
fs::write(&path, serde_json::to_string_pretty(&applications)?)?; save_applications(&path, &applications)
crate::config::set_private_permissions(&path)?; }
Ok(())
/// 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)] #[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 let response = http
.post(format!("{base}/v1/signup")) .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() .send()
.await .await
.unwrap(); .unwrap();
@@ -169,6 +169,83 @@ async fn signup_stores_private_application_and_class() {
assert_eq!(app.class, "enrichment"); 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wizard_enrolls_and_writes_config() { async fn wizard_enrolls_and_writes_config() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
@@ -177,7 +254,7 @@ async fn wizard_enrolls_and_writes_config() {
let http = reqwest::Client::new(); let http = reqwest::Client::new();
let body: Value = http let body: Value = http
.post(format!("{base}/v1/signup")) .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() .send()
.await .await
.unwrap() .unwrap()