2 Commits
6 changed files with 297 additions and 162 deletions
+2 -2
View File
@@ -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 `<data_dir>/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`.
- `<data_dir>/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 <old>` 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 <old>` 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; 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 (`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 `<registry dir>/applications.json` (mode 600, MA contract data — never in the signed snapshot); `frxd registry approve <id>` promotes one (member stub + invite + credential block), and `frxd registry invite <id>` mints another single-use 24h token — one per node the member runs. 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).
+4 -4
View File
@@ -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 <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 `/` 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) and queues it for MA review — `frxd registry --dir <dir> applications` lists applications and `frxd registry --dir <dir> approve <id> --registry-url <url>` creates the member and prints the credential block to hand over. Each node the member runs needs its own token: `frxd registry --dir <dir> invite <id>` 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 `<registry dir>/applications.json` — contract data, never in the public signed snapshot.
New member:
@@ -169,8 +169,8 @@ gitea admin user generate-access-token -u <you> -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):
+90 -75
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,54 @@ 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)?;
println!("approved {id} ({class})");
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("<registry-url>");
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();
@@ -471,19 +519,11 @@ pub fn registry_show(dir: &Path) -> Result<()> {
struct RegistryServer {
dir: PathBuf,
signup_code: Option<String>,
registry_url: Option<String>,
}
pub fn registry_router(
dir: &Path,
signup_code: Option<String>,
registry_url: Option<String>,
) -> 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))
@@ -494,13 +534,8 @@ pub fn registry_router(
.with_state(state)
}
pub async fn registry_serve(
dir: &Path,
listen: &str,
signup_code: Option<String>,
registry_url: Option<String>,
) -> 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?;
@@ -543,7 +578,6 @@ fn sanitize_label(input: &str) -> String {
#[derive(Deserialize)]
struct SignupRequest {
label: String,
code: Option<String>,
#[serde(default)]
org: String,
#[serde(default)]
@@ -570,17 +604,11 @@ 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();
}
@@ -598,11 +626,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 +655,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 +666,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,6 +688,7 @@ async fn registry_signup(
class: class.to_string(),
payment: request.payment.clone(),
privacy_link: request.privacy_link.clone(),
status: "pending".to_string(),
submitted_at: now_ts(),
};
if let Err(error) = registry::record_application(&server.dir, application) {
@@ -674,19 +698,12 @@ async fn registry_signup(
)
.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()
@@ -816,20 +833,16 @@ 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>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br>
@@ -859,10 +872,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.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</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>
@@ -922,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,
@@ -931,9 +944,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 () => {
+17 -9
View File
@@ -180,6 +180,16 @@ enum RegistryCommand {
},
List,
Applications,
Approve {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
Invite {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
SetRelays {
#[arg(required = true)]
relays: Vec<String>,
@@ -188,10 +198,6 @@ enum RegistryCommand {
Serve {
#[arg(long, default_value = "127.0.0.1:7800")]
listen: String,
#[arg(long)]
signup_code: Option<String>,
#[arg(long)]
registry_url: Option<String>,
},
}
@@ -356,13 +362,15 @@ 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::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,
+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)]
+151 -69
View File
@@ -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", "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,24 +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 invite_reissues_token_per_node() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
let base = spawn_registry_server(&dir).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
submit_application(&http, &base, "Multi Node").await;
let id = "multi-node.frx.invalid";
commands::registry_approve(&dir, id, None).unwrap();
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();
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", "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());
@@ -198,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())