diff --git a/AGENTS.md b/AGENTS.md
index 38592fd..96c9930 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,7 @@
- Relay backpressure is global: any member's full queue 429s every publisher until drained (visible per §3, but one lagging member can stall the firehose — revisit before scale).
- Member authority (Draft 0.5 §6): the MA-signed registry snapshot is authoritative when configured (`[node] registry` = file path or URL, `ma_key` pinned; monotonic version — rollback and forgery close the node; file path is mtime-reloaded, URL is fetched at start + every 60s and cached to `/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`.
- `/members.toml` (name, pubkey, class, `previous` keys, mtime-reloaded) is a dev/local fallback used only when no registry is configured; empty directory without a registry is open bootstrap only when `dev_bootstrap = true` (RFC §6: explicit dev flag). Receivers drop content-bearing responses from enrichment-class senders (metadata-only, §6).
-- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|applications|set-relays|show|serve` (signed `registry.json` + `ma-key.hex` in `--dir`); `frxd init --id/--registry/--ma-key`; `frxd key show|rotate`; `member add --previous ` for the fallback path. A node with no `[node] relays` discovers them from the registry snapshot (`doc.relays`).
+- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|applications|approve|invite|set-relays|show|serve` (signed `registry.json` + `ma-key.hex` in `--dir`); `frxd init --id/--registry/--ma-key`; `frxd key show|rotate`; `member add --previous ` for the fallback path. A node with no `[node] relays` discovers them from the registry snapshot (`doc.relays`).
- Aggregate semantics are our implementation choices from a terse spec: requests are `aggregate` envelopes carrying only `period`; replies carry `sent` (broadcasts that month) / `passed` (responses consumed from that member); granularity floor is enforced as YYYY or YYYY-MM only (finer rejected), yearly rolls up months. Revisit with §10 sufficiency review.
## Known gaps (Phase 2/3, intentional — don't fake them)
@@ -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; 1–2 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 }`, `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. Signup without a code queues a pending application (`/applications.json`, mode 600, MA contract data — never in the signed snapshot); `frxd registry approve ` promotes it (member stub + invite + credential block). A valid `--signup-code` approves immediately. Invites live in `/invites.json`. 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 (`registry serve`; HTML page at `/`, `POST /v1/signup` queues a pending application, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Applications live in `/applications.json` (mode 600, MA contract data — never in the signed snapshot); `frxd registry approve ` promotes one (member stub + invite + credential block), and `frxd registry invite ` mints another single-use 24h token — one per node the member runs. Invites live in `/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 (`.frx.`, 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).
diff --git a/DEPLOY.md b/DEPLOY.md
index 89de3ef..39ff9b8 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -26,10 +26,10 @@ MA operator — run the signup site:
```
frxd registry --dir ./ma init --zone frx.federatedsearch.org
-frxd registry --dir ./ma serve --listen 127.0.0.1:7800 --signup-code --registry-url https://ma.federatedsearch.org/registry.json
+frxd registry --dir ./ma serve --listen 127.0.0.1:7800
```
-(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 applications` lists it and `frxd registry --dir approve --registry-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 `/applications.json` — contract data, never in the public signed snapshot.
+(put Caddy in front for a real domain). The page at `/` collects the registration form (short name, organization details) and queues it for MA review — `frxd registry --dir applications` lists applications and `frxd registry --dir approve --registry-url ` creates the member and prints the credential block to hand over. Each node the member runs needs its own token: `frxd registry --dir invite ` mints another single-use 24h invite for the same identifier. The block is `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `/applications.json` — contract data, never in the public signed snapshot.
New member:
@@ -169,8 +169,8 @@ gitea admin user generate-access-token -u -t bootstrap --scopes all --conf
```
The org is `frx`, the repo `frxd` → clone URL
-`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (signup code);
-repo reads are public.
+`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (MA-approved
+applications); repo reads are public.
Publishing a release (from the checkout):
diff --git a/src/commands.rs b/src/commands.rs
index 1be794c..a78cbe3 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -474,16 +474,29 @@ pub fn registry_approve(dir: &Path, id: &str, registry_url: Option<&str>) -> Res
})?;
let invite = registry::create_invite(dir, id, 24 * 3600)?;
let (_, signed) = open_registry(dir)?;
- let registry_url = registry_url.unwrap_or("");
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
- );
+ print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
Ok(())
}
+/// Issues a fresh invite for an existing member — one single-use token per node
+/// the member runs (each node binds its own key at enrollment).
+pub fn registry_invite(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!("no member named {id}"));
+ }
+ let invite = registry::create_invite(dir, id, 24 * 3600)?;
+ print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
+ Ok(())
+}
+
+fn print_credential_block(id: &str, token: &str, registry_url: Option<&str>, ma_key: &str) {
+ let registry_url = registry_url.unwrap_or("");
+ println!("hand this credential block to the member (single use, valid 24h):");
+ println!("id={id} token={token} registry={registry_url} ma_key={ma_key}");
+}
+
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
mutate_registry(dir, |doc| {
doc.relays = relays.to_vec();
@@ -506,19 +519,11 @@ pub fn registry_show(dir: &Path) -> Result<()> {
struct RegistryServer {
dir: PathBuf,
- signup_code: Option,
- registry_url: Option,
}
-pub fn registry_router(
- dir: &Path,
- signup_code: Option,
- registry_url: Option,
-) -> Router {
+pub fn registry_router(dir: &Path) -> Router {
let state = std::sync::Arc::new(RegistryServer {
dir: dir.to_path_buf(),
- signup_code,
- registry_url,
});
Router::new()
.route("/health", get(registry_health))
@@ -529,13 +534,8 @@ pub fn registry_router(
.with_state(state)
}
-pub async fn registry_serve(
- dir: &Path,
- listen: &str,
- signup_code: Option,
- registry_url: Option,
-) -> Result<()> {
- let app = registry_router(dir, signup_code, registry_url);
+pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> {
+ let app = registry_router(dir);
let listener = TcpListener::bind(listen).await?;
println!("registry serving on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
@@ -578,7 +578,6 @@ fn sanitize_label(input: &str) -> String {
#[derive(Deserialize)]
struct SignupRequest {
label: String,
- code: Option,
#[serde(default)]
org: String,
#[serde(default)]
@@ -613,28 +612,6 @@ async fn registry_signup(
)
.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,
@@ -711,31 +688,9 @@ 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()
- },
+ status: "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,
@@ -743,44 +698,12 @@ 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();
(
- StatusCode::OK,
+ StatusCode::ACCEPTED,
Json(serde_json::json!({
+ "status": "pending",
"id": id,
- "token": invite.token,
- "registry": registry_url,
- "ma_key": ma_key,
- "credentials": format!(
- "id={id} token={} registry={registry_url} ma_key={ma_key}",
- invite.token
- ),
+ "message": "application received — the membership authority reviews it and issues your credential block"
})),
)
.into_response()
@@ -920,11 +843,6 @@ the MA for the membership contract — never published, never on the wire.
Short name — this becomes your identifier
identifier: (type a short name) — no domain or DNS of your own is needed.
-Signup code (optional)
-
-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.
Legal organization name
Representative (authorized contact person)
@@ -954,10 +872,10 @@ immediately and returns the block here.
2. Download
Static Linux x86_64 binaries (musl — no runtime dependencies):
-
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
+
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frxd-linux-amd64
+curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frxd-linux-amd64.sha256
+curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64
+curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64.sha256
All releases: git.federatedsearch.org/frx/frxd/releases .
Source and spec (rfc.txt): git.federatedsearch.org/frx/frxd .
@@ -1017,7 +935,7 @@ f.onsubmit = async (e) => {
method: "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify({
- label: f.label.value, code: f.code.value,
+ label: f.label.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,
diff --git a/src/main.rs b/src/main.rs
index fb81869..1f54e5d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -185,6 +185,11 @@ enum RegistryCommand {
#[arg(long)]
registry_url: Option,
},
+ Invite {
+ id: String,
+ #[arg(long)]
+ registry_url: Option,
+ },
SetRelays {
#[arg(required = true)]
relays: Vec,
@@ -193,10 +198,6 @@ enum RegistryCommand {
Serve {
#[arg(long, default_value = "127.0.0.1:7800")]
listen: String,
- #[arg(long)]
- signup_code: Option,
- #[arg(long)]
- registry_url: Option,
},
}
@@ -364,13 +365,12 @@ async fn main() -> Result<()> {
RegistryCommand::Approve { id, registry_url } => {
commands::registry_approve(&dir, &id, registry_url.as_deref())?
}
+ RegistryCommand::Invite { id, registry_url } => {
+ commands::registry_invite(&dir, &id, registry_url.as_deref())?
+ }
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
RegistryCommand::Show => commands::registry_show(&dir)?,
- RegistryCommand::Serve {
- listen,
- signup_code,
- registry_url,
- } => commands::registry_serve(&dir, &listen, signup_code, registry_url).await?,
+ RegistryCommand::Serve { listen } => commands::registry_serve(&dir, &listen).await?,
},
Command::Aggregates {
from,
diff --git a/tests/onboarding.rs b/tests/onboarding.rs
index aabea6c..0669d67 100644
--- a/tests/onboarding.rs
+++ b/tests/onboarding.rs
@@ -9,15 +9,11 @@ use frxd::registry::{self};
use serde_json::Value;
use tokio::net::TcpListener;
-async fn spawn_registry_server(dir: &Path, signup_code: Option<&str>) -> String {
+async fn spawn_registry_server(dir: &Path) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}");
- let router = commands::registry_router(
- dir,
- signup_code.map(str::to_string),
- Some(format!("{base}/registry.json")),
- );
+ let router = commands::registry_router(dir);
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
@@ -30,67 +26,98 @@ fn setup_ma(root: &Path) -> std::path::PathBuf {
dir
}
+fn full_application(label: &str) -> Value {
+ serde_json::json!({
+ "label": label,
+ "org": format!("{label} Org"),
+ "representative": "R. Ep",
+ "email": "ops@example.org",
+ "attestation": true,
+ "privacy_ack": true
+ })
+}
+
+async fn submit_application(http: &reqwest::Client, base: &str, label: &str) -> Value {
+ let response = http
+ .post(format!("{base}/v1/signup"))
+ .json(&full_application(label))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
+ response.json().await.unwrap()
+}
+
+fn invite_token(dir: &Path, id: &str) -> String {
+ registry::load_invites(&dir.join("invites.json"))
+ .unwrap()
+ .into_iter()
+ .rev()
+ .find(|invite| invite.id == id)
+ .unwrap()
+ .token
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn signup_issues_invite_and_enroll_binds_key() {
+async fn application_pending_then_approve_then_enroll_binds_key() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
- let base = spawn_registry_server(&dir, Some("sesame")).await;
+ let base = spawn_registry_server(&dir).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
- let rejected = http
- .post(format!("{base}/v1/signup"))
- .json(&serde_json::json!({"label": "alice", "code": "wrong"}))
- .send()
- .await
- .unwrap();
- assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
-
- let response = http
- .post(format!("{base}/v1/signup"))
- .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();
- assert!(response.status().is_success());
- let body: Value = response.json().await.unwrap();
- let id = body.get("id").and_then(Value::as_str).unwrap().to_string();
- assert_eq!(id, "alice-dev.frx.invalid");
- let token = body
- .get("token")
- .and_then(Value::as_str)
- .unwrap()
- .to_string();
+ let body = submit_application(&http, &base, "Alice Dev").await;
+ let id = "alice-dev.frx.invalid";
+ assert_eq!(body.get("status").and_then(Value::as_str), Some("pending"));
+ assert_eq!(body.get("id").and_then(Value::as_str), Some(id));
+ assert!(body.get("credentials").is_none());
+ // pending: no member entry yet, application on file
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
- assert!(
- registry::authorized_keys(&signed, now_ts()).is_empty(),
- "signup must not authorize a key before enrollment"
- );
+ assert!(signed.doc.members.iter().all(|member| member.id != id));
+ let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
+ assert_eq!(apps.len(), 1);
+ assert_eq!(apps[0].status, "pending");
+
+ // a duplicate application for the same identifier is rejected
+ let dup = http
+ .post(format!("{base}/v1/signup"))
+ .json(&full_application("Alice Dev"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
+
+ // 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 key = Keypair::generate();
- let response = http
+ let enrolled = http
.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id,
- "token": token,
+ "token": invite_token(&dir, id),
"pubkey": key.public_hex(),
}))
.send()
.await
.unwrap();
- assert!(response.status().is_success());
-
+ 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()));
+ // the token is single-use
let replayed = http
.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id,
- "token": token,
+ "token": invite_token(&dir, id),
"pubkey": Keypair::generate().public_hex(),
}))
.send()
@@ -103,15 +130,18 @@ async fn signup_issues_invite_and_enroll_binds_key() {
async fn signup_stores_private_application_and_class() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
- let base = spawn_registry_server(&dir, Some("sesame")).await;
+ let base = spawn_registry_server(&dir).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
+ // missing acknowledgements are rejected
let missing = http
.post(format!("{base}/v1/signup"))
- .json(&serde_json::json!({"label": "acme", "code": "sesame"}))
+ .json(&serde_json::json!({
+ "label": "acme", "org": "Acme", "representative": "A", "email": "a@acme.example"
+ }))
.send()
.await
.unwrap();
@@ -121,7 +151,6 @@ async fn signup_stores_private_application_and_class() {
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({
"label": "Keswick Research",
- "code": "sesame",
"org": "Keswick Research LLC",
"representative": "J. Keswick",
"email": "ops@keswick.example",
@@ -136,26 +165,13 @@ async fn signup_stores_private_application_and_class() {
.send()
.await
.unwrap();
- assert!(response.status().is_success());
+ assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
let body: Value = response.json().await.unwrap();
assert_eq!(
body.get("id").and_then(Value::as_str),
Some("keswick-research.frx.invalid")
);
- // public registry stays minimal: identifier + class only, no org data
- let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
- let member = signed
- .doc
- .members
- .iter()
- .find(|m| m.id == "keswick-research.frx.invalid")
- .unwrap();
- assert_eq!(member.class, frxd::config::CLASS_ENRICHMENT);
- let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
- assert!(!raw.contains("Keswick Research LLC"));
- assert!(!raw.contains("ops@keswick.example"));
-
// private application record holds the contract details
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps.len(), 1);
@@ -167,101 +183,90 @@ async fn signup_stores_private_application_and_class() {
assert_eq!(app.payment, "IBAN XX00 0000");
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
assert_eq!(app.class, "enrichment");
+ assert_eq!(app.status, "pending");
+
+ // approval creates the member entry with the declared class
+ commands::registry_approve(&dir, "keswick-research.frx.invalid", None).unwrap();
+ let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
+ let member = signed
+ .doc
+ .members
+ .iter()
+ .find(|m| m.id == "keswick-research.frx.invalid")
+ .unwrap();
+ assert_eq!(member.class, frxd::config::CLASS_ENRICHMENT);
+
+ // public registry stays minimal: no org data in the signed snapshot
+ let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
+ assert!(!raw.contains("Keswick Research LLC"));
+ assert!(!raw.contains("ops@keswick.example"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn signup_without_code_queues_application_for_approval() {
+async fn invite_reissues_token_per_node() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
- let base = spawn_registry_server(&dir, Some("sesame")).await;
+ let base = spawn_registry_server(&dir).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
+ submit_application(&http, &base, "Multi Node").await;
+ let id = "multi-node.frx.invalid";
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 enroll = |token: String, pubkey: String| {
+ let http = http.clone();
+ let base = base.clone();
+ let id = id.to_string();
+ async move {
+ http.post(format!("{base}/v1/enroll"))
+ .json(&serde_json::json!({
+ "id": id, "token": token, "pubkey": pubkey,
+ }))
+ .send()
+ .await
+ .unwrap()
+ .status()
+ }
+ };
+
+ // node 1: the invite from approval
+ let key1 = Keypair::generate();
+ let token1 = invite_token(&dir, id);
+ assert!(enroll(token1.clone(), key1.public_hex()).await.is_success());
+
+ // node 2: a fresh token from `registry invite`
+ commands::registry_invite(&dir, id, None).unwrap();
+ let key2 = Keypair::generate();
+ let token2 = invite_token(&dir, id);
+ assert_ne!(token1, token2);
+ assert!(enroll(token2, key2.public_hex()).await.is_success());
+
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
- assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
+ let authorized = registry::authorized_keys(&signed, now_ts());
+ assert!(authorized.contains_key(&key1.public_hex()));
+ assert!(authorized.contains_key(&key2.public_hex()));
+
+ // an invite for an unknown member fails
+ assert!(commands::registry_invite(&dir, "ghost.frx.invalid", None).is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wizard_enrolls_and_writes_config() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
- let base = spawn_registry_server(&dir, Some("sesame")).await;
+ let base = spawn_registry_server(&dir).await;
let http = reqwest::Client::new();
- let body: Value = http
- .post(format!("{base}/v1/signup"))
- .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()
- .json()
- .await
- .unwrap();
- let credentials = body.get("credentials").and_then(Value::as_str).unwrap();
+ submit_application(&http, &base, "Wizard Test").await;
+ let id = "wizard-test.frx.invalid";
+ commands::registry_approve(&dir, id, None).unwrap();
+ let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
+ let credentials = format!(
+ "id={id} token={} registry={base}/registry.json ma_key={}",
+ invite_token(&dir, id),
+ signed.doc.ma_key
+ );
let config_path = root.path().join("wizard.toml");
let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display());
@@ -275,7 +280,7 @@ async fn wizard_enrolls_and_writes_config() {
assert!(text.contains("enrolled"), "{text}");
let config = Config::load(&config_path).unwrap();
- assert_eq!(config.node.id.as_deref(), Some("wizard-test.frx.invalid"));
+ assert_eq!(config.node.id.as_deref(), Some(id));
assert_eq!(
config.node.registry.as_deref(),
Some(format!("{base}/registry.json").as_str())