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). - 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`. - 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). - `<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. - 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) ## 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. - 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 (`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. - 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).
+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 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: 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 The org is `frx`, the repo `frxd` → clone URL
`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (signup code); `https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (MA-approved
repo reads are public. applications); repo reads are public.
Publishing a release (from the checkout): Publishing a release (from the checkout):
+89 -74
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,54 @@ 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)?;
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<()> { 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();
@@ -471,19 +519,11 @@ pub fn registry_show(dir: &Path) -> Result<()> {
struct RegistryServer { struct RegistryServer {
dir: PathBuf, dir: PathBuf,
signup_code: Option<String>,
registry_url: Option<String>,
} }
pub fn registry_router( pub fn registry_router(dir: &Path) -> Router {
dir: &Path,
signup_code: Option<String>,
registry_url: Option<String>,
) -> Router {
let state = std::sync::Arc::new(RegistryServer { let state = std::sync::Arc::new(RegistryServer {
dir: dir.to_path_buf(), dir: dir.to_path_buf(),
signup_code,
registry_url,
}); });
Router::new() Router::new()
.route("/health", get(registry_health)) .route("/health", get(registry_health))
@@ -494,13 +534,8 @@ pub fn registry_router(
.with_state(state) .with_state(state)
} }
pub async fn registry_serve( pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> {
dir: &Path, let app = registry_router(dir);
listen: &str,
signup_code: Option<String>,
registry_url: Option<String>,
) -> Result<()> {
let app = registry_router(dir, signup_code, registry_url);
let listener = TcpListener::bind(listen).await?; let listener = TcpListener::bind(listen).await?;
println!("registry serving on http://{}", listener.local_addr()?); println!("registry serving on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
@@ -543,7 +578,6 @@ fn sanitize_label(input: &str) -> String {
#[derive(Deserialize)] #[derive(Deserialize)]
struct SignupRequest { struct SignupRequest {
label: String, label: String,
code: Option<String>,
#[serde(default)] #[serde(default)]
org: String, org: String,
#[serde(default)] #[serde(default)]
@@ -570,17 +604,11 @@ 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 Some(expected) = &server.signup_code else { let label = sanitize_label(&request.label);
if label.is_empty() {
return ( return (
StatusCode::FORBIDDEN, StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "signup is not enabled" })), Json(serde_json::json!({ "error": "label must be alphanumeric" })),
)
.into_response();
};
if request.code.as_deref() != Some(expected.as_str()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })),
) )
.into_response(); .into_response();
} }
@@ -598,11 +626,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 +655,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 +666,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,6 +688,7 @@ 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: "pending".to_string(),
submitted_at: now_ts(), submitted_at: now_ts(),
}; };
if let Err(error) = registry::record_application(&server.dir, application) { if let Err(error) = registry::record_application(&server.dir, application) {
@@ -674,19 +698,12 @@ async fn registry_signup(
) )
.into_response(); .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!({ Json(serde_json::json!({
"status": "pending",
"id": id, "id": id,
"token": invite.token, "message": "application received — the membership authority reviews it and issues your credential block"
"registry": registry_url,
"ma_key": ma_key,
"credentials": format!(
"id={id} token={} registry={registry_url} ma_key={ma_key}",
invite.token
),
})), })),
) )
.into_response() .into_response()
@@ -816,20 +833,16 @@ 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>
<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> <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 +872,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.3/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.3/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.3/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.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>. <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>
@@ -922,7 +935,7 @@ f.onsubmit = async (e) => {
method: "POST", method: "POST",
headers: {"content-type": "application/json"}, headers: {"content-type": "application/json"},
body: JSON.stringify({ 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, 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, address: f.address.value, domain: f.domain.value, class: f.member_class.value,
payment: f.payment.value, privacy_link: f.privacy_link.value, payment: f.payment.value, privacy_link: f.privacy_link.value,
@@ -931,8 +944,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));
}; };
+17 -9
View File
@@ -180,6 +180,16 @@ enum RegistryCommand {
}, },
List, List,
Applications, Applications,
Approve {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
Invite {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
SetRelays { SetRelays {
#[arg(required = true)] #[arg(required = true)]
relays: Vec<String>, relays: Vec<String>,
@@ -188,10 +198,6 @@ enum RegistryCommand {
Serve { Serve {
#[arg(long, default_value = "127.0.0.1:7800")] #[arg(long, default_value = "127.0.0.1:7800")]
listen: String, 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::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::Invite { id, registry_url } => {
commands::registry_invite(&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 { listen } => commands::registry_serve(&dir, &listen).await?,
listen,
signup_code,
registry_url,
} => commands::registry_serve(&dir, &listen, signup_code, registry_url).await?,
}, },
Command::Aggregates { Command::Aggregates {
from, from,
+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)]
+151 -69
View File
@@ -9,15 +9,11 @@ use frxd::registry::{self};
use serde_json::Value; use serde_json::Value;
use tokio::net::TcpListener; 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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}"); let base = format!("http://{addr}");
let router = commands::registry_router( let router = commands::registry_router(dir);
dir,
signup_code.map(str::to_string),
Some(format!("{base}/registry.json")),
);
tokio::spawn(async move { tokio::spawn(async move {
let _ = axum::serve(listener, router).await; let _ = axum::serve(listener, router).await;
}); });
@@ -30,67 +26,98 @@ fn setup_ma(root: &Path) -> std::path::PathBuf {
dir 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)] #[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 root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path()); 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() let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.build() .build()
.unwrap(); .unwrap();
let rejected = http let body = submit_application(&http, &base, "Alice Dev").await;
.post(format!("{base}/v1/signup")) let id = "alice-dev.frx.invalid";
.json(&serde_json::json!({"label": "alice", "code": "wrong"})) assert_eq!(body.get("status").and_then(Value::as_str), Some("pending"));
.send() assert_eq!(body.get("id").and_then(Value::as_str), Some(id));
.await assert!(body.get("credentials").is_none());
.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();
// pending: no member entry yet, application on file
let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!( assert!(signed.doc.members.iter().all(|member| member.id != id));
registry::authorized_keys(&signed, now_ts()).is_empty(), let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
"signup must not authorize a key before enrollment" 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 key = Keypair::generate();
let response = http let enrolled = http
.post(format!("{base}/v1/enroll")) .post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"id": id, "id": id,
"token": token, "token": invite_token(&dir, id),
"pubkey": key.public_hex(), "pubkey": key.public_hex(),
})) }))
.send() .send()
.await .await
.unwrap(); .unwrap();
assert!(response.status().is_success()); assert!(enrolled.status().is_success());
let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex())); assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
// the token is single-use
let replayed = http let replayed = http
.post(format!("{base}/v1/enroll")) .post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"id": id, "id": id,
"token": token, "token": invite_token(&dir, id),
"pubkey": Keypair::generate().public_hex(), "pubkey": Keypair::generate().public_hex(),
})) }))
.send() .send()
@@ -103,15 +130,18 @@ async fn signup_issues_invite_and_enroll_binds_key() {
async fn signup_stores_private_application_and_class() { async fn signup_stores_private_application_and_class() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path()); 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() let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.build() .build()
.unwrap(); .unwrap();
// missing acknowledgements are rejected
let missing = http let missing = http
.post(format!("{base}/v1/signup")) .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() .send()
.await .await
.unwrap(); .unwrap();
@@ -121,7 +151,6 @@ async fn signup_stores_private_application_and_class() {
.post(format!("{base}/v1/signup")) .post(format!("{base}/v1/signup"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"label": "Keswick Research", "label": "Keswick Research",
"code": "sesame",
"org": "Keswick Research LLC", "org": "Keswick Research LLC",
"representative": "J. Keswick", "representative": "J. Keswick",
"email": "ops@keswick.example", "email": "ops@keswick.example",
@@ -136,26 +165,13 @@ async fn signup_stores_private_application_and_class() {
.send() .send()
.await .await
.unwrap(); .unwrap();
assert!(response.status().is_success()); assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
let body: Value = response.json().await.unwrap(); let body: Value = response.json().await.unwrap();
assert_eq!( assert_eq!(
body.get("id").and_then(Value::as_str), body.get("id").and_then(Value::as_str),
Some("keswick-research.frx.invalid") 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 // private application record holds the contract details
let apps = registry::load_applications(&dir.join("applications.json")).unwrap(); let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps.len(), 1); 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.payment, "IBAN XX00 0000");
assert_eq!(app.privacy_link, "https://keswick.example/privacy"); assert_eq!(app.privacy_link, "https://keswick.example/privacy");
assert_eq!(app.class, "enrichment"); 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)] #[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();
let dir = setup_ma(root.path()); 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 http = reqwest::Client::new();
let body: Value = http submit_application(&http, &base, "Wizard Test").await;
.post(format!("{base}/v1/signup")) let id = "wizard-test.frx.invalid";
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "attestation": true, "privacy_ack": true})) commands::registry_approve(&dir, id, None).unwrap();
.send() let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
.await let credentials = format!(
.unwrap() "id={id} token={} registry={base}/registry.json ma_key={}",
.json() invite_token(&dir, id),
.await signed.doc.ma_key
.unwrap(); );
let credentials = body.get("credentials").and_then(Value::as_str).unwrap();
let config_path = root.path().join("wizard.toml"); let config_path = root.path().join("wizard.toml");
let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display()); 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}"); assert!(text.contains("enrolled"), "{text}");
let config = Config::load(&config_path).unwrap(); 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!( assert_eq!(
config.node.registry.as_deref(), config.node.registry.as_deref(),
Some(format!("{base}/registry.json").as_str()) Some(format!("{base}/registry.json").as_str())