7 Commits
7 changed files with 789 additions and 151 deletions
+1
View File
@@ -1,3 +1,4 @@
/target /target
/frx-data/ /frx-data/
/comments.txt /comments.txt
/relay.log
+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|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|token|revoke-token|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`. 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 + credential block whose token is the member's reusable account credential, hashed in `<registry dir>/tokens.json` — authorizes key enrollment for every node the member runs); applicants never self-declare a class — the MA assigns it with `approve --class enrichment` (default source; classes are provenance, not roles — every member may query and respond, I5). `frxd registry token <id>` mints another member token, `revoke-token <id>` revokes all of a member's tokens; `frxd registry invite <id>` mints a single-use 24h handoff token (`<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).
+87 -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 a label and the signup code and returns a credential block: `id=... token=... registry=... ma_key=...`. (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, mints its account credential, and prints the credential block to hand over (`--class enrichment` at approval for derived-corpora members, which are metadata-only; the default `source` fits everyone else — membership itself has no roles or tiers). The token is reusable: it authorizes key enrollment for every node the member runs (`registry token <id>` mints an additional one; `registry revoke-token <id>` revokes all after a leak). A single-use 24h invite (`registry invite <id>`) remains for constrained handoffs. 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:
@@ -47,7 +47,7 @@ Run `frxd` on loopback and terminate TLS with Caddy:
caddy reverse-proxy --from relay.federatedsearch.org --to 127.0.0.1:7700 caddy reverse-proxy --from relay.federatedsearch.org --to 127.0.0.1:7700
``` ```
Caddyfile equivalent (apex is the public front door, `ma.` the membership service, `relay.` the relay — all on one host): Caddyfile equivalent (apex is the public front door, `ma.` the membership service, `relay.` the relay, `git.` the code host — all on one host):
``` ```
federatedsearch.org { federatedsearch.org {
@@ -61,6 +61,10 @@ ma.federatedsearch.org {
relay.federatedsearch.org { relay.federatedsearch.org {
reverse_proxy 127.0.0.1:7700 reverse_proxy 127.0.0.1:7700
} }
git.federatedsearch.org {
reverse_proxy 127.0.0.1:3000
}
``` ```
Relay command (peers and registry gated by the MA): Relay command (peers and registry gated by the MA):
@@ -112,6 +116,77 @@ frxd registry --dir /var/lib/frxd/registry serve --listen 127.0.0.1:7800
Put the same Caddy in front, or distribute `registry.json` out of band (it is signed, so the channel does not matter). The snapshot is versioned; nodes reject rollback and fail static during outages. Put the same Caddy in front, or distribute `registry.json` out of band (it is signed, so the channel does not matter). The snapshot is versioned; nodes reject rollback and fail static during outages.
## Code hosting and downloads (Gitea)
The public source and release binaries live at `git.federatedsearch.org` (Gitea), so the
download step on the membership page stays on infrastructure the federation operates.
One-time install on the server (root):
```
VER=1.27.3
curl -fsSLO https://dl.gitea.com/gitea/$VER/gitea-$VER-linux-amd64{,.sha256}
sha256sum -c gitea-$VER-linux-amd64.sha256
install -m 0755 gitea-$VER-linux-amd64 /usr/local/bin/gitea
adduser --system --shell /bin/bash --gecos 'Gitea' --home /home/git --group git
mkdir -p /var/lib/gitea/{custom,data,log} /etc/gitea && chown -R git:git /var/lib/gitea /etc/gitea
```
`/etc/gitea/app.ini` essentials (rest defaults; secrets via `gitea generate secret`):
```ini
WORK_PATH = /var/lib/gitea
[database]
DB_TYPE = sqlite3
PATH = /var/lib/gitea/data/gitea.db
[server]
DOMAIN = git.federatedsearch.org
SSH_DOMAIN = git.federatedsearch.org
ROOT_URL = https://git.federatedsearch.org/
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
[security]
INSTALL_LOCK = true
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
```
systemd unit (`User=git`, `ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini`,
`WorkingDirectory=/var/lib/gitea`), then `gitea migrate --config /etc/gitea/app.ini` as the
`git` user and `systemctl enable --now gitea`. Git-over-SSH uses the host sshd via the `git`
user's Gitea-managed `authorized_keys`; HTTPS pushes can use an access token instead.
Admin bootstrap (as `git` user):
```
gitea admin user create --admin --username <you> --email <you>@federatedsearch.org --random-password --config /etc/gitea/app.ini
gitea admin user generate-access-token -u <you> -t bootstrap --scopes all --config /etc/gitea/app.ini
```
The org is `frx`, the repo `frxd` → clone URL
`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (MA-approved
applications); repo reads are public.
Publishing a release (from the checkout):
```
git tag v0.1.0 && git push gitea v0.1.0
# build static binaries (see next section), then attach via the API:
curl -X POST https://git.federatedsearch.org/api/v1/repos/frx/frxd/releases \
-H "Authorization: token <token>" -H 'content-type: application/json' \
-d '{"tag_name":"v0.1.0","name":"v0.1.0"}'
curl -X POST https://git.federatedsearch.org/api/v1/repos/frx/frxd/releases/<id>/assets?name=frxd-linux-amd64 \
-H "Authorization: token <token>" -F attachment=@frxd-linux-amd64
```
Asset URLs follow `/frx/frxd/releases/download/<tag>/<file>` — the membership page pins
those. Bump the page when a release changes.
## Private networks and custom CAs ## Private networks and custom CAs
- `ca_cert = "/etc/ssl/private-ca.pem"` in `[node]`, or `--ca-cert` on the relay: adds a private/corporate root CA for relay and registry connections. - `ca_cert = "/etc/ssl/private-ca.pem"` in `[node]`, or `--ca-cert` on the relay: adds a private/corporate root CA for relay and registry connections.
@@ -125,7 +200,15 @@ rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl cargo build --release --target x86_64-unknown-linux-musl
``` ```
`[profile.release]` enables LTO and stripping. All dependencies are pure Rust, so the musl build has no system-library requirements. `[profile.release]` enables LTO and stripping. All dependencies are pure Rust, so the musl build has no system-library requirements. `ring` needs a musl C toolchain (`musl-tools`); without host sudo, build in a container instead:
```
docker run --rm -v "$PWD":/src -w /src rust:1-slim-bookworm bash -c \
"apt-get update -qq && apt-get install -y -qq musl-tools && rustup target add x86_64-unknown-linux-musl && cargo build --release --target x86_64-unknown-linux-musl"
```
Binaries land in `target/x86_64-unknown-linux-musl/release/`; rename to
`frxd-linux-amd64` / `frx-linux-amd64` for release assets, with `sha256sum` sidecar files.
## What TLS does and does not cover ## What TLS does and does not cover
+279 -69
View File
@@ -424,6 +424,106 @@ pub fn registry_list(dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
pub fn registry_applications(dir: &Path) -> Result<()> {
let applications = registry::load_applications(&registry::applications_path(dir))?;
if applications.is_empty() {
println!("no applications recorded");
return Ok(());
}
for app in &applications {
println!("{} [{}] {} <{}> — {}", app.id, app.class, app.org, app.email, app.status);
println!(" representative: {}", app.representative);
if !app.address.is_empty() {
println!(" address: {}", app.address);
}
if !app.domain.is_empty() {
println!(" domain: {}", app.domain);
}
if !app.payment.is_empty() {
println!(" payment: {}", app.payment);
}
if !app.privacy_link.is_empty() {
println!(" privacy: {}", app.privacy_link);
}
}
Ok(())
}
/// Approves a pending application: creates the member stub, mints its account
/// credential (member token), and prints the credential block to hand over.
/// `--class enrichment` is for derived corpora (metadata-only, §6); default is source.
pub fn registry_approve(
dir: &Path,
id: &str,
registry_url: Option<&str>,
class: 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 class == Some(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 token = registry::create_token(dir, id)?;
let (_, signed) = open_registry(dir)?;
println!("approved {id} ({class})");
println!("member token — reusable for every node the member runs; keep private:");
print_credential_block(id, &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)?;
println!("single-use handoff invite for {id} (valid 24h):");
print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
Ok(())
}
/// Mints an additional member token (account credential) and prints the block.
/// Returns the raw token for programmatic use.
pub fn registry_token(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<String> {
let (_, signed) = open_registry(dir)?;
if !signed.doc.members.iter().any(|member| member.id == id) {
return Err(anyhow!("no member named {id}"));
}
let token = registry::create_token(dir, id)?;
println!("member token for {id} — reusable for every node they run; keep private:");
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
Ok(token)
}
/// Revokes all of a member's tokens (e.g. after a leak); mint fresh with `registry token`.
pub fn registry_revoke_token(dir: &Path, id: &str) -> Result<()> {
let revoked = registry::revoke_tokens(dir, id)?;
println!("revoked {revoked} token(s) for {id}");
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!("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();
@@ -446,19 +546,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))
@@ -469,13 +561,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?;
@@ -518,27 +605,30 @@ fn sanitize_label(input: &str) -> String {
#[derive(Deserialize)] #[derive(Deserialize)]
struct SignupRequest { struct SignupRequest {
label: String, label: String,
code: Option<String>, #[serde(default)]
org: String,
#[serde(default)]
representative: String,
#[serde(default)]
email: String,
#[serde(default)]
address: String,
#[serde(default)]
domain: String,
#[serde(default)]
payment: String,
#[serde(default)]
privacy_link: String,
#[serde(default)]
attestation: bool,
#[serde(default)]
privacy_ack: bool,
} }
async fn registry_signup( 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 {
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" })),
)
.into_response();
}
let label = sanitize_label(&request.label); let label = sanitize_label(&request.label);
if label.is_empty() { if label.is_empty() {
return ( return (
@@ -547,6 +637,30 @@ async fn registry_signup(
) )
.into_response(); .into_response();
} }
if !request.attestation {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "content authorization must be confirmed" })),
)
.into_response();
}
if !request.privacy_ack {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "the privacy notice must be acknowledged" })),
)
.into_response();
}
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": "organization name, representative, and contact email are required" })),
)
.into_response();
}
let signed = match registry::load_registry(&registry_doc_path(&server.dir)) { let signed = match registry::load_registry(&registry_doc_path(&server.dir)) {
Ok(signed) => signed, Ok(signed) => signed,
Err(error) => { Err(error) => {
@@ -566,8 +680,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,
@@ -576,34 +691,39 @@ async fn registry_signup(
.into_response(); .into_response();
} }
}; };
if let Err(error) = mutate_registry(&server.dir, |doc| { if applications.iter().any(|application| application.id == id) {
doc.members.push(RegistryMember { return (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "an application for this identifier is already on file" })),
)
.into_response();
}
let application = registry::Application {
id: id.clone(), id: id.clone(),
class: crate::config::CLASS_SOURCE.to_string(), org: request.org.clone(),
keys: Vec::new(), representative: request.representative.clone(),
enc_key: None, email: request.email.clone(),
}); address: request.address.clone(),
Ok(()) domain: request.domain.clone(),
}) { class: CLASS_SOURCE.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) {
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })), Json(serde_json::json!({ "error": error.to_string() })),
) )
.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()
@@ -641,13 +761,17 @@ async fn registry_enroll(
.into_response(); .into_response();
} }
}; };
if let Err(error) = registry::redeem_invite(&server.dir, &request.id, &request.token) { if registry::redeem_invite(&server.dir, &request.id, &request.token).is_err() {
let valid = registry::validate_token(&server.dir, &request.id, &request.token)
.unwrap_or(false);
if !valid {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": error.to_string() })), Json(serde_json::json!({ "error": "unknown invite or member token" })),
) )
.into_response(); .into_response();
} }
}
let result = mutate_registry(&server.dir, |doc| { let result = mutate_registry(&server.dir, |doc| {
let Some(member) = doc let Some(member) = doc
.members .members
@@ -707,14 +831,19 @@ const REGISTRY_PAGE: &str = r##"<!doctype html>
.tag { color: #555; margin-top: 0; } .tag { color: #555; margin-top: 0; }
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.1rem 1.25rem; } .card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.1rem 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; } code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
input, button { font: inherit; border: 1px solid #bbb; border-radius: 6px; padding: 0.45rem 0.6rem; } input, select, button { font: inherit; border: 1px solid #bbb; border-radius: 6px; padding: 0.45rem 0.6rem; }
input { width: 100%; margin: 0.2rem 0 0.9rem; } input, select { width: 100%; margin: 0.2rem 0 0.9rem; }
.check { display: block; font-size: 0.92rem; margin: 0.5rem 0; }
.check input { width: auto; margin: 0 0.4rem 0 0; }
button { background: #174ea6; color: #fff; border: none; cursor: pointer; padding: 0.5rem 1rem; border-radius: 6px; } button { background: #174ea6; color: #fff; border: none; cursor: pointer; padding: 0.5rem 1rem; border-radius: 6px; }
button:hover { background: #0f3d91; } button:hover { background: #0f3d91; }
#out { display: none; background: #101418; color: #d6f5d6; padding: 0.85rem 1rem; #out { display: none; background: #101418; color: #d6f5d6; padding: 0.85rem 1rem;
border-radius: 8px; white-space: pre-wrap; word-break: break-all; margin-top: 1rem; } border-radius: 8px; white-space: pre-wrap; word-break: break-all; margin-top: 1rem; }
pre.cmd { background: #101418; color: #d6f5d6; padding: 0.85rem 1rem;
border-radius: 8px; overflow-x: auto; }
.muted { color: #666; font-size: 0.92rem; } .muted { color: #666; font-size: 0.92rem; }
ol { padding-left: 1.3rem; } ol { padding-left: 1.3rem; }
li { margin: 0.35rem 0; }
a { color: #174ea6; } a { color: #174ea6; }
</style> </style>
</head> </head>
@@ -726,24 +855,79 @@ small: signed messages, budgets, honest truncation, aggregate courtesy. No annou
scores on the wire, no in-protocol payment.</p> scores on the wire, no in-protocol payment.</p>
<p>Everything else — matching, ranking, retention, trust — is local.</p> <p>Everything else — matching, ranking, retention, trust — is local.</p>
<h2>Join the federation</h2> <h2>1. Register with the membership authority</h2>
<div class="card"> <div class="card">
<p class="muted">This form requests membership from the membership authority (MA). Membership has no
roles or tiers: every member may broadcast queries and every member may answer them. 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"> <form id="f">
<label>Organization or handle<br> <label>Short name — this becomes your identifier<br>
<input name="label" required pattern="[A-Za-z0-9 -]+" placeholder="acme-docs"></label><br> <input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label>
<label>Signup code (issued by the membership authority)<br> <p class="muted">identifier: <code id="preview">(type a short name)</code> — no domain or DNS of your own is needed.</p>
<input name="code" type="password" placeholder="signup code"></label><br> <label>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br>
<input name="representative" required placeholder="Jane Keswick"></label>
<label>Contact email<br>
<input name="email" type="email" required placeholder="ops@example.org"></label>
<label>Registered address<br>
<input name="address" placeholder="street, city, country"></label>
<label>Organization domain (optional)<br>
<input name="domain" placeholder="example.org"></label>
<label>Payment details (billing / payout — e.g. IBAN or payment handle)<br>
<input name="payment" placeholder="kept private; the protocol itself carries no payment"></label>
<label>Your privacy statement URL (optional)<br>
<input name="privacy_link" placeholder="https://example.org/privacy"></label>
<label class="check"><input type="checkbox" name="attestation" required> I will only index content I own or that users supply, and only collections I explicitly mark shared will answer queries.</label>
<label class="check"><input type="checkbox" name="privacy_ack" required> I acknowledge the privacy notice above.</label>
<button type="submit">Request membership</button> <button type="submit">Request membership</button>
</form> </form>
<pre id="out"></pre> <pre id="out"></pre>
</div> </div>
<h2>After you get credentials</h2> <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.5/frxd-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64.sha256
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frx-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/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>
<h2>3. Install</h2>
<div class="card">
<pre class="cmd">sha256sum -c frxd-linux-amd64.sha256
chmod +x frxd-linux-amd64 frx-linux-amd64
sudo mv frxd-linux-amd64 /usr/local/bin/frxd
sudo mv frx-linux-amd64 /usr/local/bin/frx</pre>
<p class="muted">No sudo? Run them in place — each is a single self-contained binary.</p>
</div>
<h2>4. Onboard</h2>
<div class="card">
<pre class="cmd">frxd --onboarding</pre>
<ol> <ol>
<li>Install the single static binary: <code>frxd</code>.</li> <li>Paste the credential block from step 1.</li>
<li>Run <code>frxd --onboarding</code> and paste the credential block it gives you.</li> <li>The wizard generates your keys, enrolls them with the MA, verifies the signed registry
<li>The wizard binds your keys, verifies the signed registry, and wires your relays — no domains, DNS, or ports needed on your side.</li> against the pinned MA key, and wires the federation relays — no domains, DNS, or open ports
needed on your side.</li>
<li>Index a directory and mark what you share:</li>
</ol> </ol>
<p class="muted">The credential block is reusable: run the wizard on every node you operate —
each node binds its own key to your identifier. If the token leaks, the MA revokes it and
issues a fresh one.</p>
<pre class="cmd">frxd add ~/documents --name docs --shared --exposure metadata
frxd serve</pre>
<p class="muted">Search local-first with <code>frx search "..."</code>; broadcast to the federation with
<code>frx query "..."</code>. Nothing is shared until a collection is explicitly marked
<code>--shared</code>.</p>
</div>
<h2>Who is in</h2> <h2>Who is in</h2>
<p class="muted" id="members">…</p> <p class="muted" id="members">…</p>
@@ -751,18 +935,42 @@ scores on the wire, no in-protocol payment.</p>
<script> <script>
const out = document.getElementById("out"); const out = document.getElementById("out");
document.getElementById("f").onsubmit = async (e) => { const f = document.getElementById("f");
let zone = "frx.federatedsearch.org";
function sanitizeLabel(v) {
let label = "", lastDash = true;
for (const c of v.toLowerCase()) {
if (/[a-z0-9]/.test(c)) { label += c; lastDash = false; }
else if (!lastDash && (/\s/.test(c) || c === "-" || c === "_" || c === ".")) { label += "-"; lastDash = true; }
if (label.length >= 32) break;
}
return label.replace(/^-+|-+$/g, "").slice(0, 32);
}
const preview = document.getElementById("preview");
const updatePreview = () => {
const label = sanitizeLabel(f.label.value);
preview.textContent = label ? label + "." + zone : "(type a short name)";
};
f.label.addEventListener("input", updatePreview);
f.onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const label = e.target.label.value, code = e.target.code.value;
const res = await fetch("/v1/signup", { const res = await fetch("/v1/signup", {
method: "POST", method: "POST",
headers: {"content-type": "application/json"}, headers: {"content-type": "application/json"},
body: JSON.stringify({label, code}) body: JSON.stringify({
label: f.label.value,
org: f.org.value, representative: f.representative.value, email: f.email.value,
address: f.address.value, domain: f.domain.value,
payment: f.payment.value, privacy_link: f.privacy_link.value,
attestation: f.attestation.checked, privacy_ack: f.privacy_ack.checked
})
}); });
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\nRun `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));
}; };
@@ -770,6 +978,8 @@ document.getElementById("f").onsubmit = async (e) => {
try { try {
const res = await fetch("/registry.json"); const res = await fetch("/registry.json");
const doc = await res.json(); const doc = await res.json();
zone = doc.zone || zone;
updatePreview();
const ids = doc.members.map((m) => m.id); const ids = doc.members.map((m) => m.id);
document.getElementById("members").textContent = doc.members.length === 0 document.getElementById("members").textContent = doc.members.length === 0
? "The registry is empty — be the first member." ? "The registry is empty — be the first member."
+36 -10
View File
@@ -11,7 +11,7 @@ use frxd::{commands, node, onboard, relay};
#[command( #[command(
name = "frxd", name = "frxd",
version, version,
about = "FRX member node — querier, responder, and local index (Draft 0.4)" about = "FRX member node — querier, responder, and local index (Draft 0.5)"
)] )]
struct Cli { struct Cli {
#[arg(long, global = true, default_value = "frxd.toml")] #[arg(long, global = true, default_value = "frxd.toml")]
@@ -179,6 +179,27 @@ enum RegistryCommand {
id: String, id: String,
}, },
List, List,
Applications,
Approve {
id: String,
#[arg(long)]
registry_url: Option<String>,
#[arg(long)]
class: Option<String>,
},
Invite {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
Token {
id: String,
#[arg(long)]
registry_url: Option<String>,
},
RevokeToken {
id: String,
},
SetRelays { SetRelays {
#[arg(required = true)] #[arg(required = true)]
relays: Vec<String>, relays: Vec<String>,
@@ -187,10 +208,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>,
}, },
} }
@@ -354,13 +371,22 @@ 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::Approve {
id,
registry_url,
class,
} => commands::registry_approve(&dir, &id, registry_url.as_deref(), class.as_deref())?,
RegistryCommand::Invite { id, registry_url } => {
commands::registry_invite(&dir, &id, registry_url.as_deref())?
}
RegistryCommand::Token { id, registry_url } => {
commands::registry_token(&dir, &id, registry_url.as_deref())?;
}
RegistryCommand::RevokeToken { id } => commands::registry_revoke_token(&dir, &id)?,
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,
+144
View File
@@ -6,6 +6,7 @@ use std::time::SystemTime;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::Digest;
use crate::PROTOCOL; use crate::PROTOCOL;
use crate::crypto::{Keypair, canonical_json, now_ts, verify_signature}; use crate::crypto::{Keypair, canonical_json, now_ts, verify_signature};
@@ -108,6 +109,149 @@ pub fn redeem_invite(dir: &Path, id: &str, token: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Membership application: MA-private contract data (organization, representative,
/// contacts, payment). Never part of the public signed registry snapshot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Application {
pub id: String,
#[serde(default)]
pub org: String,
#[serde(default)]
pub representative: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub address: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub class: String,
#[serde(default)]
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")
}
pub fn load_applications(path: &Path) -> Result<Vec<Application>> {
if !path.exists() {
return Ok(Vec::new());
}
let raw = fs::read_to_string(path).context("reading 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<()> {
let path = applications_path(dir);
let mut applications = load_applications(&path)?;
applications.push(application);
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)
}
/// Member token: the member's account credential at the MA. Authorizes key
/// enrollment for every node the member runs. Stored hashed; MA-private.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberToken {
pub id: String,
pub token_hash: String,
pub created: u64,
#[serde(default)]
pub revoked: bool,
}
pub fn tokens_path(dir: &Path) -> PathBuf {
dir.join("tokens.json")
}
pub fn load_tokens(path: &Path) -> Result<Vec<MemberToken>> {
if !path.exists() {
return Ok(Vec::new());
}
let raw = fs::read_to_string(path).context("reading tokens")?;
serde_json::from_str(&raw).context("parsing tokens")
}
pub fn save_tokens(path: &Path, tokens: &[MemberToken]) -> Result<()> {
fs::write(path, serde_json::to_string_pretty(tokens)?)?;
crate::config::set_private_permissions(path)?;
Ok(())
}
fn hash_token(token: &str) -> String {
hex::encode(sha2::Sha256::digest(token.as_bytes()))
}
/// Mints a fresh member token and returns it (only the hash is stored).
pub fn create_token(dir: &Path, id: &str) -> Result<String> {
let raw = crate::crypto::random_nonce();
let path = tokens_path(dir);
let mut tokens = load_tokens(&path)?;
tokens.push(MemberToken {
id: id.to_string(),
token_hash: hash_token(&raw),
created: now_ts(),
revoked: false,
});
save_tokens(&path, &tokens)?;
Ok(raw)
}
pub fn validate_token(dir: &Path, id: &str, token: &str) -> Result<bool> {
let hash = hash_token(token);
Ok(load_tokens(&tokens_path(dir))?
.iter()
.any(|entry| entry.id == id && !entry.revoked && entry.token_hash == hash))
}
/// Revokes all tokens for a member; returns how many were active.
pub fn revoke_tokens(dir: &Path, id: &str) -> Result<usize> {
let path = tokens_path(dir);
let mut tokens = load_tokens(&path)?;
let mut revoked = 0;
for entry in tokens.iter_mut().filter(|entry| entry.id == id && !entry.revoked) {
entry.revoked = true;
revoked += 1;
}
save_tokens(&path, &tokens)?;
Ok(revoked)
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRegistry { pub struct SignedRegistry {
#[serde(flatten)] #[serde(flatten)]
+231 -57
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,91 +26,269 @@ 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;
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!(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")) .post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "alice", "code": "wrong"})) .json(&full_application("Alice Dev"))
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN); assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
// MA approves: member stub; a member token then authorizes key enrollment
commands::registry_approve(&dir, id, None, 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 enroll = |token: &str, pubkey: String| {
let http = http.clone();
let base = base.clone();
let id = id.to_string();
let token = token.to_string();
async move {
http.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id, "token": token, "pubkey": pubkey,
}))
.send()
.await
.unwrap()
.status()
}
};
// the account credential is reusable: two nodes, two keys, one token
let token = commands::registry_token(&dir, id, None).unwrap();
let key1 = Keypair::generate();
let key2 = Keypair::generate();
assert!(enroll(&token, key1.public_hex()).await.is_success());
assert!(enroll(&token, 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()));
// after revocation the token no longer enrolls; a fresh one does
commands::registry_revoke_token(&dir, id).unwrap();
let key3 = Keypair::generate();
assert_eq!(
enroll(&token, key3.public_hex()).await,
reqwest::StatusCode::FORBIDDEN
);
let fresh = commands::registry_token(&dir, id, None).unwrap();
assert!(enroll(&fresh, key3.public_hex()).await.is_success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
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).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", "org": "Acme", "representative": "A", "email": "a@acme.example"
}))
.send()
.await
.unwrap();
assert_eq!(missing.status(), reqwest::StatusCode::BAD_REQUEST);
let response = http let response = http
.post(format!("{base}/v1/signup")) .post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame"})) .json(&serde_json::json!({
"label": "Keswick Research",
"org": "Keswick Research LLC",
"representative": "J. Keswick",
"email": "ops@keswick.example",
"address": "1 Fell Road, Keswick",
"domain": "keswick.example",
"payment": "IBAN XX00 0000",
"privacy_link": "https://keswick.example/privacy",
"attestation": true,
"privacy_ack": true
}))
.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();
let id = body.get("id").and_then(Value::as_str).unwrap().to_string(); assert_eq!(
assert_eq!(id, "alice-dev.frx.invalid"); body.get("id").and_then(Value::as_str),
let token = body Some("keswick-research.frx.invalid")
.get("token")
.and_then(Value::as_str)
.unwrap()
.to_string();
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"
); );
let key = Keypair::generate(); // private application record holds the contract details; applicants do not
let response = http // self-declare a class — the MA assigns it at approval
.post(format!("{base}/v1/enroll")) let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps.len(), 1);
let app = &apps[0];
assert_eq!(app.id, "keswick-research.frx.invalid");
assert_eq!(app.org, "Keswick Research LLC");
assert_eq!(app.representative, "J. Keswick");
assert_eq!(app.email, "ops@keswick.example");
assert_eq!(app.payment, "IBAN XX00 0000");
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
assert_eq!(app.class, "source");
assert_eq!(app.status, "pending");
// approval with --class enrichment creates the member with that class
commands::registry_approve(
&dir,
"keswick-research.frx.invalid",
None,
Some(frxd::config::CLASS_ENRICHMENT),
)
.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, 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!({ .json(&serde_json::json!({
"id": id, "id": id, "token": token, "pubkey": pubkey,
"token": token,
"pubkey": key.public_hex(),
})) }))
.send() .send()
.await .await
.unwrap(); .unwrap()
assert!(response.status().is_success()); .status()
}
};
// node 1: a single-use handoff invite
commands::registry_invite(&dir, id, None).unwrap();
let key1 = Keypair::generate();
let token1 = invite_token(&dir, id);
assert!(enroll(token1.clone(), key1.public_hex()).await.is_success());
// the invite is single-use: replay is rejected
assert_eq!(
enroll(token1, Keypair::generate().public_hex()).await,
reqwest::StatusCode::FORBIDDEN
);
// 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!(enroll(token2, key2.public_hex()).await.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())); let authorized = registry::authorized_keys(&signed, now_ts());
assert!(authorized.contains_key(&key1.public_hex()));
assert!(authorized.contains_key(&key2.public_hex()));
let replayed = http // an invite for an unknown member fails
.post(format!("{base}/v1/enroll")) assert!(commands::registry_invite(&dir, "ghost.frx.invalid", None).is_err());
.json(&serde_json::json!({
"id": id,
"token": token,
"pubkey": Keypair::generate().public_hex(),
}))
.send()
.await
.unwrap();
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
} }
#[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"})) commands::registry_approve(&dir, id, None, None).unwrap();
.send() let token = commands::registry_token(&dir, id, None).unwrap();
.await let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
.unwrap() let credentials = format!(
.json() "id={id} token={token} registry={base}/registry.json ma_key={}",
.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());
@@ -128,7 +302,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())