Compare commits
5
Commits
744f949d19
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aa0117af4 | ||
|
|
3de20ed89d | ||
|
|
8d74b63b11 | ||
|
|
13bb124dcc | ||
|
|
c44acbecd3 |
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/frx-data/
|
/frx-data/
|
||||||
/comments.txt
|
/comments.txt
|
||||||
|
/relay.log
|
||||||
|
|||||||
@@ -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|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; 1–2 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
|
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 1–2 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
|
||||||
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search` → `EngineOutput { hits, total: Option<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 + 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).
|
||||||
|
|||||||
@@ -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 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:
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+256
-69
@@ -424,6 +424,79 @@ pub fn registry_list(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn registry_applications(dir: &Path) -> Result<()> {
|
||||||
|
let applications = registry::load_applications(®istry::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 (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();
|
||||||
@@ -446,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))
|
||||||
@@ -469,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?;
|
||||||
@@ -518,27 +578,32 @@ 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)]
|
||||||
|
class: Option<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 +612,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(®istry_doc_path(&server.dir)) {
|
let signed = match registry::load_registry(®istry_doc_path(&server.dir)) {
|
||||||
Ok(signed) => signed,
|
Ok(signed) => signed,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -566,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(®istry::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 +666,44 @@ 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 (
|
||||||
id: id.clone(),
|
StatusCode::CONFLICT,
|
||||||
class: crate::config::CLASS_SOURCE.to_string(),
|
Json(serde_json::json!({ "error": "an application for this identifier is already on file" })),
|
||||||
keys: Vec::new(),
|
)
|
||||||
enc_key: None,
|
.into_response();
|
||||||
});
|
}
|
||||||
Ok(())
|
let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
|
||||||
}) {
|
CLASS_ENRICHMENT
|
||||||
|
} else {
|
||||||
|
CLASS_SOURCE
|
||||||
|
};
|
||||||
|
let application = registry::Application {
|
||||||
|
id: id.clone(),
|
||||||
|
org: request.org.clone(),
|
||||||
|
representative: request.representative.clone(),
|
||||||
|
email: request.email.clone(),
|
||||||
|
address: request.address.clone(),
|
||||||
|
domain: request.domain.clone(),
|
||||||
|
class: class.to_string(),
|
||||||
|
payment: request.payment.clone(),
|
||||||
|
privacy_link: request.privacy_link.clone(),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
submitted_at: now_ts(),
|
||||||
|
};
|
||||||
|
if let Err(error) = registry::record_application(&server.dir, application) {
|
||||||
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()
|
||||||
@@ -707,14 +807,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 +831,80 @@ 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). 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>What you will share<br>
|
||||||
|
<select name="member_class">
|
||||||
|
<option value="source">Source member — content I own or host</option>
|
||||||
|
<option value="enrichment">Enrichment member — derived corpora (metadata-only)</option>
|
||||||
|
</select></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.3/frxd-linux-amd64
|
||||||
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frxd-linux-amd64.sha256
|
||||||
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64
|
||||||
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64.sha256</pre>
|
||||||
|
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
||||||
|
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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,25 +912,51 @@ 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, class: f.member_class.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"
|
||||||
: "Failed: " + (body.error || ("http " + res.status));
|
: res.ok
|
||||||
|
? "Application received.\n\n" + (body.message || "The membership authority will review it and issue your credential block.")
|
||||||
|
: "Failed: " + (body.error || ("http " + res.status));
|
||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
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."
|
||||||
|
|||||||
+20
-10
@@ -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,17 @@ enum RegistryCommand {
|
|||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
List,
|
List,
|
||||||
|
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>,
|
||||||
@@ -187,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>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,13 +361,16 @@ 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 } => {
|
||||||
|
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,
|
||||||
|
|||||||
@@ -108,6 +108,81 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SignedRegistry {
|
pub struct SignedRegistry {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
+204
-52
@@ -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"}))
|
|
||||||
.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()
|
||||||
@@ -99,22 +126,147 @@ async fn signup_issues_invite_and_enroll_binds_key() {
|
|||||||
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
|
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
.post(format!("{base}/v1/signup"))
|
||||||
|
.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",
|
||||||
|
"class": "enrichment",
|
||||||
|
"payment": "IBAN XX00 0000",
|
||||||
|
"privacy_link": "https://keswick.example/privacy",
|
||||||
|
"attestation": true,
|
||||||
|
"privacy_ack": true
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
|
let body: Value = response.json().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body.get("id").and_then(Value::as_str),
|
||||||
|
Some("keswick-research.frx.invalid")
|
||||||
|
);
|
||||||
|
|
||||||
|
// private application record holds the contract details
|
||||||
|
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, "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"}))
|
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());
|
||||||
@@ -128,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())
|
||||||
|
|||||||
Reference in New Issue
Block a user