Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa1fb1143 |
@@ -14,7 +14,7 @@
|
|||||||
- Invariants I1–I9 (§2) are normative; proposals contradicting them (scores in responses, topic taxonomy, announce stream, dispute messages, replayable broadcast, in-protocol pricing/settlement) are out of scope by design.
|
- Invariants I1–I9 (§2) are normative; proposals contradicting them (scores in responses, topic taxonomy, announce stream, dispute messages, replayable broadcast, in-protocol pricing/settlement) are out of scope by design.
|
||||||
- Appendix B (Purge Log) is normative: a rejected mechanism may only be re-proposed if the written rationale is addressed.
|
- Appendix B (Purge Log) is normative: a rejected mechanism may only be re-proposed if the written rationale is addressed.
|
||||||
- §10 Open Issues are known gaps, not oversights (e.g., signature canonicalization blocks Phase-1 interop). Check it before "fixing" something.
|
- §10 Open Issues are known gaps, not oversights (e.g., signature canonicalization blocks Phase-1 interop). Check it before "fixing" something.
|
||||||
- Use the spec's vocabulary — member/querier/responder, aggregates, source/enrichment members — not client/server or search-engine terms.
|
- Use the spec's vocabulary — member/querier/responder, aggregates — not client/server or search-engine terms.
|
||||||
- A new `MUST` is only legitimate if it is observable at the boundary, deterministically verifiable by a peer, beneficial to the counterparty, and not derivable from local policy. Ranking/ordering/presentation fails this test and stays local (§5); scores never travel (I6).
|
- A new `MUST` is only legitimate if it is observable at the boundary, deterministically verifiable by a peer, beneficial to the counterparty, and not derivable from local policy. Ranking/ordering/presentation fails this test and stays local (§5); scores never travel (I6).
|
||||||
- I2 is not blanket anti-centralization: shared coordination (identity, admission, contract) is centralized in the MA because common state is cheaper held once; decisions that consume local information (matching, relevance, sharing, retention) stay local. Off-wire conduct (link handling, retention, gating) is contract, not conformance.
|
- I2 is not blanket anti-centralization: shared coordination (identity, admission, contract) is centralized in the MA because common state is cheaper held once; decisions that consume local information (matching, relevance, sharing, retention) stay local. Off-wire conduct (link handling, retention, gating) is contract, not conformance.
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
- `add`/`reindex` reset a collection (delete by manifest `name`) before re-adding, so deleted files don't linger; collection identity is its name, and same-named collections replace each other.
|
- `add`/`reindex` reset a collection (delete by manifest `name`) before re-adding, so deleted files don't linger; collection identity is its name, and same-named collections replace each other.
|
||||||
- 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, `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).
|
||||||
- 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`).
|
- 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.
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@
|
|||||||
|
|
||||||
## Technical plans (deliberately not in the RFC)
|
## Technical plans (deliberately not in the RFC)
|
||||||
- Record plans here — not as spec edits — when they are implementation/demo choices rather than protocol surface.
|
- Record plans here — not as spec edits — when they are implementation/demo choices rather than protocol surface.
|
||||||
- Demo plan: build a useful end-to-end demo on GDELT and Common Crawl (CC-NEWS; sometimes called "OpenCrawl" in discussion) as enrichment members / backfill seeding. RFC §6 and Appendix A already name both as example derived corpora, so no new mechanisms are required; enrichment members are metadata-only exposure.
|
- Demo plan: build a useful end-to-end demo on GDELT and Common Crawl (CC-NEWS; sometimes called "OpenCrawl" in discussion) as ordinary members / backfill seeding; Appendix A names both as example derived corpora. Derived corpora are metadata-only via the member's own `exposure=metadata` collection setting (I9) — there is no registry-level class.
|
||||||
- Phase 1 (two-node query/response) is built and tested; the enrichment demo layers on top of it.
|
- Phase 1 (two-node query/response) is built and tested; the derived-corpora demo layers on top of it.
|
||||||
- Language: Rust (settled, matches §7). Decided by the engine requirement, not preference: Tantivy gives in-process Lucene-class BM25 + incremental indexing; C/C++ embedded alternatives are worse (Xapian GPL-2+, CLucene unmaintained, SQLite FTS5 thin), plus single static musl binaries for the install story and memory safety on the untrusted network/crypto path. Don't re-litigate.
|
- Language: Rust (settled, matches §7). Decided by the engine requirement, not preference: Tantivy gives in-process Lucene-class BM25 + incremental indexing; C/C++ embedded alternatives are worse (Xapian GPL-2+, CLucene unmaintained, SQLite FTS5 thin), plus single static musl binaries for the install story and memory safety on the untrusted network/crypto path. Don't re-litigate.
|
||||||
- frxd modes (one binary, config toggles, no code required of publishers): querier (broadcast/local-first search), responder (match incoming queries against shared collections, sign), local index (watch dirs, extract text, explicit shared marking per I9). Use RFC terms querier/responder, not "subscriber/publisher".
|
- frxd modes (one binary, config toggles, no code required of publishers): querier (broadcast/local-first search), responder (match incoming queries against shared collections, sign), local index (watch dirs, extract text, explicit shared marking per I9). Use RFC terms querier/responder, not "subscriber/publisher".
|
||||||
- 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 (`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.
|
- 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). `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).
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ frxd registry --dir ./ma init --zone frx.federatedsearch.org
|
|||||||
frxd registry --dir ./ma serve --listen 127.0.0.1:7800
|
frxd registry --dir ./ma serve --listen 127.0.0.1:7800
|
||||||
```
|
```
|
||||||
|
|
||||||
(put Caddy in front for a real domain). The page at `/` collects the registration form (short name, organization details) 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.
|
(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. 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:
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Any member may originate queries and answer them; roles are enable flags, never
|
|||||||
|
|
||||||
- **Member node (`frxd serve`)** — owns a keypair and an identifier, indexes local collections, broadcasts queries, answers queries from shared collections, receives responses. Local-first: local results are merged with remote results, provenance-marked.
|
- **Member node (`frxd serve`)** — owns a keypair and an identifier, indexes local collections, broadcasts queries, answers queries from shared collections, receives responses. Local-first: local results are merged with remote results, provenance-marked.
|
||||||
- **Relay (`frxd relay`)** — dumb, interchangeable transport. Holds no history, replays nothing, fans queries out to subscribed members, carries unicast responses/aggregates to member mailboxes. Relays may peer with each other to flood queries.
|
- **Relay (`frxd relay`)** — dumb, interchangeable transport. Holds no history, replays nothing, fans queries out to subscribed members, carries unicast responses/aggregates to member mailboxes. Relays may peer with each other to flood queries.
|
||||||
- **Registry (MA)** — the membership authority: a signed, versioned snapshot listing identifiers, classes, authorized keys with validity windows, optional X25519 encryption keys, and relay endpoints. The registry is the sole authority for key-to-identifier binding.
|
- **Registry (MA)** — the membership authority: a signed, versioned snapshot listing identifiers, authorized keys with validity windows, optional X25519 encryption keys, and relay endpoints. The registry is the sole authority for key-to-identifier binding.
|
||||||
|
|
||||||
## 3. Message flow
|
## 3. Message flow
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Protocol-silent by design (I2): ranking, ordering, presentation, relevance gatin
|
|||||||
|
|
||||||
6. Membership
|
6. Membership
|
||||||
|
|
||||||
The MA governs identity, contract, expulsion — who, never quality. Identifiers are MA-hosted FQDNs (`<label>.frx.<ma-domain>`); no member-controlled DNS is required. Member-hosted identifiers — keys published in the member's own domain and allowlisted by the MA — are planned, not yet normative. The MA maintains a signed, versioned registry snapshot listing identifiers, class, authorized keys with validity windows, an optional X25519 encryption key per member, and the federation's relay endpoints. Members and queriers may discover relays from it; relays MAY verify sender admission against it, rejecting unlisted keys visibly. Nodes pin the MA key; the snapshot is the sole authority for the key→identifier binding. Rotation publishes a successor key before retiring its predecessor; revocation removes a key or shortens its validity. Registry outage is fail-static: the last validated snapshot stays in force, and open bootstrap requires an explicit development flag. Admission cost is the Sybil defense. Expulsion grounds: fabrication, admission fraud, sustained abuse — never low quality. Escalation: local throttle → advisory aggregates → MA warning → delisting → expulsion. Aggregates are inadmissible as sanction evidence (I4). Conduct not observable on the wire — link handling, retention, gating — is governed by contract; the protocol neither observes nor adjudicates it. Membership classes: source members (own content) and enrichment members (derived corpora, e.g. GDELT/CC-NEWS bots — metadata-only exposure, transformation logic open and auditable).
|
The MA governs identity, contract, expulsion — who, never quality. Identifiers are MA-hosted FQDNs (`<label>.frx.<ma-domain>`); no member-controlled DNS is required. Member-hosted identifiers — keys published in the member's own domain and allowlisted by the MA — are planned, not yet normative. The MA maintains a signed, versioned registry snapshot listing identifiers, authorized keys with validity windows, an optional X25519 encryption key per member, and the federation's relay endpoints. Members and queriers may discover relays from it; relays MAY verify sender admission against it, rejecting unlisted keys visibly. Nodes pin the MA key; the snapshot is the sole authority for the key→identifier binding. Rotation publishes a successor key before retiring its predecessor; revocation removes a key or shortens its validity. Registry outage is fail-static: the last validated snapshot stays in force, and open bootstrap requires an explicit development flag. Admission cost is the Sybil defense. Expulsion grounds: fabrication, admission fraud, sustained abuse — never low quality. Escalation: local throttle → advisory aggregates → MA warning → delisting → expulsion. Aggregates are inadmissible as sanction evidence (I4). Conduct not observable on the wire — link handling, retention, gating — is governed by contract; the protocol neither observes nor adjudicates it.
|
||||||
|
|
||||||
7. Reference Implementation — frxd
|
7. Reference Implementation — frxd
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Build order: Phase 1 — envelope, query stream, query, response (demoable betwe
|
|||||||
|
|
||||||
8. Security & Privacy Considerations
|
8. Security & Privacy Considerations
|
||||||
|
|
||||||
Query visibility is total among members; abstraction level and membership are the boundary (I3). Derived queries SHOULD minimize personal data (I3); each member is responsible for the content of its own messages. Response streams are strategic disclosure (corpus mapping, intake intelligence) — unicast, need-to-know. Amplification is bounded by bilateral transport limits and contract, not routing. Publisher self-promotion is the expected adversarial mode; defense is local (gate, pass-rate throttle, local source reputation). Enrichment members' filters are an editorial power — auditable openness is the mitigation.
|
Query visibility is total among members; abstraction level and membership are the boundary (I3). Derived queries SHOULD minimize personal data (I3); each member is responsible for the content of its own messages. Response streams are strategic disclosure (corpus mapping, intake intelligence) — unicast, need-to-know. Amplification is bounded by bilateral transport limits and contract, not routing. Publisher self-promotion is the expected adversarial mode; defense is local (gate, pass-rate throttle, local source reputation). Derived-corpus members' filters are an editorial power — auditable openness is the mitigation.
|
||||||
|
|
||||||
9. Conformance
|
9. Conformance
|
||||||
|
|
||||||
|
|||||||
+14
-45
@@ -10,7 +10,7 @@ use axum::{Json, Router};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Config, Member, load_members, save_members};
|
use crate::config::{Config, Member, load_members, save_members};
|
||||||
use crate::crypto::{Keypair, now_ts};
|
use crate::crypto::{Keypair, now_ts};
|
||||||
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
||||||
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
||||||
@@ -113,7 +113,6 @@ pub fn member_add(
|
|||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
class: &str,
|
|
||||||
previous: &[String],
|
previous: &[String],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config = Config::load(config_path)?;
|
let config = Config::load(config_path)?;
|
||||||
@@ -122,24 +121,15 @@ pub fn member_add(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|key| normalize_key(key))
|
.map(|key| normalize_key(key))
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let class = if class == CLASS_ENRICHMENT {
|
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
let mut members = load_members(&config.members_path())?;
|
let mut members = load_members(&config.members_path())?;
|
||||||
members.retain(|member| member.name != name && member.pubkey != pubkey);
|
members.retain(|member| member.name != name && member.pubkey != pubkey);
|
||||||
members.push(Member {
|
members.push(Member {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
pubkey,
|
pubkey,
|
||||||
class: class.to_string(),
|
|
||||||
previous,
|
previous,
|
||||||
});
|
});
|
||||||
save_members(&config.members_path(), &members)?;
|
save_members(&config.members_path(), &members)?;
|
||||||
println!(
|
println!("listed {name} in {}", config.members_path().display());
|
||||||
"listed {name} ({class}) in {}",
|
|
||||||
config.members_path().display()
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +194,7 @@ pub fn member_list(config_path: &Path) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for member in members {
|
for member in members {
|
||||||
println!("{} [{}] {}", member.name, member.class, member.pubkey);
|
println!("{} {}", member.name, member.pubkey);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -304,25 +294,18 @@ pub fn registry_add(
|
|||||||
dir: &Path,
|
dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
class: &str,
|
|
||||||
not_before: Option<u64>,
|
not_before: Option<u64>,
|
||||||
not_after: Option<u64>,
|
not_after: Option<u64>,
|
||||||
enc_key: Option<String>,
|
enc_key: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let pubkey = normalize_key(pubkey)?;
|
let pubkey = normalize_key(pubkey)?;
|
||||||
let enc_key = enc_key.map(|key| normalize_key(&key)).transpose()?;
|
let enc_key = enc_key.map(|key| normalize_key(&key)).transpose()?;
|
||||||
let class = if class == CLASS_ENRICHMENT {
|
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
mutate_registry(dir, |doc| {
|
mutate_registry(dir, |doc| {
|
||||||
if doc.members.iter().any(|member| member.id == id) {
|
if doc.members.iter().any(|member| member.id == id) {
|
||||||
return Err(anyhow!("member {id} already listed"));
|
return Err(anyhow!("member {id} already listed"));
|
||||||
}
|
}
|
||||||
doc.members.push(RegistryMember {
|
doc.members.push(RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: pubkey.clone(),
|
key: pubkey.clone(),
|
||||||
not_before: not_before.unwrap_or_else(now_ts),
|
not_before: not_before.unwrap_or_else(now_ts),
|
||||||
@@ -332,7 +315,7 @@ pub fn registry_add(
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
println!("added {id} ({class})");
|
println!("added {id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +392,7 @@ pub fn registry_list(dir: &Path) -> Result<()> {
|
|||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
for member in &signed.doc.members {
|
for member in &signed.doc.members {
|
||||||
let keys = member.keys.len();
|
let keys = member.keys.len();
|
||||||
println!("{} [{}] ({} key(s))", member.id, member.class, keys);
|
println!("{} ({} key(s))", member.id, keys);
|
||||||
for entry in &member.keys {
|
for entry in &member.keys {
|
||||||
let window = match entry.not_after {
|
let window = match entry.not_after {
|
||||||
Some(end) => format!("valid {}..{}", entry.not_before, end),
|
Some(end) => format!("valid {}..{}", entry.not_before, end),
|
||||||
@@ -431,7 +414,7 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for app in &applications {
|
for app in &applications {
|
||||||
println!("{} [{}] {} <{}> — {}", app.id, app.class, app.org, app.email, app.status);
|
println!("{} {} <{}> — {}", app.id, app.org, app.email, app.status);
|
||||||
println!(" representative: {}", app.representative);
|
println!(" representative: {}", app.representative);
|
||||||
if !app.address.is_empty() {
|
if !app.address.is_empty() {
|
||||||
println!(" address: {}", app.address);
|
println!(" address: {}", app.address);
|
||||||
@@ -449,30 +432,17 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approves a pending application: creates the member stub, mints its account
|
/// Approves a pending application: creates the member entry, mints its account
|
||||||
/// credential (member token), and prints the credential block to hand over.
|
/// 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>) -> Result<()> {
|
||||||
pub fn registry_approve(
|
|
||||||
dir: &Path,
|
|
||||||
id: &str,
|
|
||||||
registry_url: Option<&str>,
|
|
||||||
class: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
if signed.doc.members.iter().any(|member| member.id == id) {
|
if signed.doc.members.iter().any(|member| member.id == id) {
|
||||||
return Err(anyhow!("member {id} already listed"));
|
return Err(anyhow!("member {id} already listed"));
|
||||||
}
|
}
|
||||||
let _application = registry::approve_application(dir, id)?;
|
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| {
|
mutate_registry(dir, |doc| {
|
||||||
doc.members.push(RegistryMember {
|
doc.members.push(RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.clone(),
|
|
||||||
keys: Vec::new(),
|
keys: Vec::new(),
|
||||||
enc_key: None,
|
enc_key: None,
|
||||||
});
|
});
|
||||||
@@ -480,7 +450,7 @@ pub fn registry_approve(
|
|||||||
})?;
|
})?;
|
||||||
let token = registry::create_token(dir, id)?;
|
let token = registry::create_token(dir, id)?;
|
||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
println!("approved {id} ({class})");
|
println!("approved {id}");
|
||||||
println!("member token — reusable for every node the member runs; keep private:");
|
println!("member token — reusable for every node the member runs; keep private:");
|
||||||
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -705,7 +675,6 @@ async fn registry_signup(
|
|||||||
email: request.email.clone(),
|
email: request.email.clone(),
|
||||||
address: request.address.clone(),
|
address: request.address.clone(),
|
||||||
domain: request.domain.clone(),
|
domain: request.domain.clone(),
|
||||||
class: CLASS_SOURCE.to_string(),
|
|
||||||
payment: request.payment.clone(),
|
payment: request.payment.clone(),
|
||||||
privacy_link: request.privacy_link.clone(),
|
privacy_link: request.privacy_link.clone(),
|
||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
@@ -862,7 +831,7 @@ roles or tiers: every member may broadcast queries and every member may answer t
|
|||||||
reviews your organization details and issues a credential block
|
reviews your organization details and issues a credential block
|
||||||
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
|
(<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,
|
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
|
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>
|
the MA for the membership contract — never published, never on the wire.</p>
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<label>Short name — this becomes your identifier<br>
|
<label>Short name — this becomes your identifier<br>
|
||||||
@@ -892,10 +861,10 @@ the MA for the membership contract — never published, never on the wire.</p>
|
|||||||
<h2>2. Download</h2>
|
<h2>2. Download</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
|
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
|
||||||
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64
|
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/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.6/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.6/frx-linux-amd64
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frx-linux-amd64.sha256</pre>
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/frx-linux-amd64.sha256</pre>
|
||||||
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
||||||
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,15 +56,10 @@ pub struct NodeSection {
|
|||||||
pub responder: bool,
|
pub responder: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CLASS_SOURCE: &str = "source";
|
|
||||||
pub const CLASS_ENRICHMENT: &str = "enrichment";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Member {
|
pub struct Member {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub pubkey: String,
|
pub pubkey: String,
|
||||||
#[serde(default = "default_class")]
|
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub previous: Vec<String>,
|
pub previous: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -130,10 +125,6 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_class() -> String {
|
|
||||||
CLASS_SOURCE.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_max_results() -> usize {
|
fn default_max_results() -> usize {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-15
@@ -123,8 +123,6 @@ enum MemberCommand {
|
|||||||
Add {
|
Add {
|
||||||
name: String,
|
name: String,
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
#[arg(long, default_value = "source")]
|
|
||||||
class: String,
|
|
||||||
#[arg(long = "previous")]
|
#[arg(long = "previous")]
|
||||||
previous: Vec<String>,
|
previous: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -150,8 +148,6 @@ enum RegistryCommand {
|
|||||||
Add {
|
Add {
|
||||||
id: String,
|
id: String,
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
#[arg(long, default_value = "source")]
|
|
||||||
class: String,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
not_before: Option<u64>,
|
not_before: Option<u64>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -184,8 +180,6 @@ enum RegistryCommand {
|
|||||||
id: String,
|
id: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
registry_url: Option<String>,
|
registry_url: Option<String>,
|
||||||
#[arg(long)]
|
|
||||||
class: Option<String>,
|
|
||||||
},
|
},
|
||||||
Invite {
|
Invite {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -334,9 +328,8 @@ async fn main() -> Result<()> {
|
|||||||
MemberCommand::Add {
|
MemberCommand::Add {
|
||||||
name,
|
name,
|
||||||
pubkey,
|
pubkey,
|
||||||
class,
|
|
||||||
previous,
|
previous,
|
||||||
} => commands::member_add(&cli.config, &name, &pubkey, &class, &previous)?,
|
} => commands::member_add(&cli.config, &name, &pubkey, &previous)?,
|
||||||
MemberCommand::Remove { name } => commands::member_remove(&cli.config, &name)?,
|
MemberCommand::Remove { name } => commands::member_remove(&cli.config, &name)?,
|
||||||
MemberCommand::List => commands::member_list(&cli.config)?,
|
MemberCommand::List => commands::member_list(&cli.config)?,
|
||||||
},
|
},
|
||||||
@@ -350,12 +343,11 @@ async fn main() -> Result<()> {
|
|||||||
RegistryCommand::Add {
|
RegistryCommand::Add {
|
||||||
id,
|
id,
|
||||||
pubkey,
|
pubkey,
|
||||||
class,
|
|
||||||
not_before,
|
not_before,
|
||||||
not_after,
|
not_after,
|
||||||
enc_key,
|
enc_key,
|
||||||
} => {
|
} => {
|
||||||
commands::registry_add(&dir, &id, &pubkey, &class, not_before, not_after, enc_key)?
|
commands::registry_add(&dir, &id, &pubkey, not_before, not_after, enc_key)?
|
||||||
}
|
}
|
||||||
RegistryCommand::SetEncKey { id, enc_key } => {
|
RegistryCommand::SetEncKey { id, enc_key } => {
|
||||||
commands::registry_set_enc_key(&dir, &id, &enc_key)?
|
commands::registry_set_enc_key(&dir, &id, &enc_key)?
|
||||||
@@ -372,11 +364,9 @@ async fn main() -> Result<()> {
|
|||||||
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
|
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
|
||||||
RegistryCommand::List => commands::registry_list(&dir)?,
|
RegistryCommand::List => commands::registry_list(&dir)?,
|
||||||
RegistryCommand::Applications => commands::registry_applications(&dir)?,
|
RegistryCommand::Applications => commands::registry_applications(&dir)?,
|
||||||
RegistryCommand::Approve {
|
RegistryCommand::Approve { id, registry_url } => {
|
||||||
id,
|
commands::registry_approve(&dir, &id, registry_url.as_deref())?
|
||||||
registry_url,
|
}
|
||||||
class,
|
|
||||||
} => commands::registry_approve(&dir, &id, registry_url.as_deref(), class.as_deref())?,
|
|
||||||
RegistryCommand::Invite { id, registry_url } => {
|
RegistryCommand::Invite { id, registry_url } => {
|
||||||
commands::registry_invite(&dir, &id, registry_url.as_deref())?
|
commands::registry_invite(&dir, &id, registry_url.as_deref())?
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-19
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::config::{CLASS_ENRICHMENT, Config, Member, load_members};
|
use crate::config::{Config, Member, load_members};
|
||||||
use crate::crypto::{Keypair, now_ts, poll_signing_bytes};
|
use crate::crypto::{Keypair, now_ts, poll_signing_bytes};
|
||||||
use crate::engine::{SearchEngine, TantivyEngine};
|
use crate::engine::{SearchEngine, TantivyEngine};
|
||||||
use crate::index::{LocalIndex, SearchHit, response_items};
|
use crate::index::{LocalIndex, SearchHit, response_items};
|
||||||
@@ -193,13 +193,10 @@ impl Node {
|
|||||||
*self.members_mtime.lock().expect("members mtime lock") = mtime;
|
*self.members_mtime.lock().expect("members mtime lock") = mtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member_class(&self, members: &[Member], pubkey: &str) -> Option<String> {
|
fn member_known(&self, members: &[Member], pubkey: &str) -> bool {
|
||||||
members
|
members
|
||||||
.iter()
|
.iter()
|
||||||
.find(|member| {
|
.any(|member| member.pubkey == pubkey || member.previous.iter().any(|key| key == pubkey))
|
||||||
member.pubkey == pubkey || member.previous.iter().any(|key| key == pubkey)
|
|
||||||
})
|
|
||||||
.map(|member| member.class.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aggregate_for(&self, period: &str, member: Option<&str>) -> AggregateBody {
|
pub fn aggregate_for(&self, period: &str, member: Option<&str>) -> AggregateBody {
|
||||||
@@ -464,21 +461,19 @@ impl Node {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.refresh_members();
|
self.refresh_members();
|
||||||
let (class, listed) = if let Some(watcher) = &self.registry {
|
let listed = if let Some(watcher) = &self.registry {
|
||||||
watcher.refresh_if_changed();
|
watcher.refresh_if_changed();
|
||||||
match watcher.authorized(&envelope.key, now_ts()) {
|
match watcher.authorized(&envelope.key, now_ts()) {
|
||||||
Some((id, class)) if id == envelope.from => (Some(class), true),
|
Some(id) if id == envelope.from => true,
|
||||||
_ => (None, false),
|
_ => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let members = self.members.read().expect("members lock");
|
let members = self.members.read().expect("members lock");
|
||||||
let class = self.member_class(&members, &envelope.key);
|
if members.is_empty() {
|
||||||
let listed = if members.is_empty() {
|
|
||||||
self.config.node.dev_bootstrap
|
self.config.node.dev_bootstrap
|
||||||
} else {
|
} else {
|
||||||
class.is_some() && envelope.from == envelope.key
|
self.member_known(&members, &envelope.key) && envelope.from == envelope.key
|
||||||
};
|
}
|
||||||
(class, listed)
|
|
||||||
};
|
};
|
||||||
if !listed {
|
if !listed {
|
||||||
return;
|
return;
|
||||||
@@ -520,11 +515,6 @@ impl Node {
|
|||||||
let Ok(body) = envelope.parse_body::<ResponseBody>() else {
|
let Ok(body) = envelope.parse_body::<ResponseBody>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if class.as_deref() == Some(CLASS_ENRICHMENT)
|
|
||||||
&& body.results.iter().any(|result| result.content.is_some())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut pending = self.pending.lock().expect("pending lock");
|
let mut pending = self.pending.lock().expect("pending lock");
|
||||||
if let Some(list) = pending.get_mut(&body.qid) {
|
if let Some(list) = pending.get_mut(&body.qid) {
|
||||||
list.push((envelope.from.clone(), body));
|
list.push((envelope.from.clone(), body));
|
||||||
|
|||||||
+4
-10
@@ -24,8 +24,6 @@ pub struct KeyEntry {
|
|||||||
pub struct RegistryMember {
|
pub struct RegistryMember {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub keys: Vec<KeyEntry>,
|
pub keys: Vec<KeyEntry>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enc_key: Option<String>,
|
pub enc_key: Option<String>,
|
||||||
@@ -125,8 +123,6 @@ pub struct Application {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub payment: String,
|
pub payment: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub privacy_link: String,
|
pub privacy_link: String,
|
||||||
@@ -386,7 +382,7 @@ impl Watcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorized(&self, key: &str, now: u64) -> Option<(String, String)> {
|
pub fn authorized(&self, key: &str, now: u64) -> Option<String> {
|
||||||
let current = self.current.lock().expect("registry lock");
|
let current = self.current.lock().expect("registry lock");
|
||||||
current
|
current
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -396,7 +392,7 @@ impl Watcher {
|
|||||||
pub fn enc_key(&self, member_key: &str) -> Option<String> {
|
pub fn enc_key(&self, member_key: &str) -> Option<String> {
|
||||||
let current = self.current.lock().expect("registry lock");
|
let current = self.current.lock().expect("registry lock");
|
||||||
let signed = current.as_ref()?;
|
let signed = current.as_ref()?;
|
||||||
let (id, _) = authorized_keys(signed, now_ts()).remove(member_key)?;
|
let id = authorized_keys(signed, now_ts()).remove(member_key)?;
|
||||||
signed
|
signed
|
||||||
.doc
|
.doc
|
||||||
.members
|
.members
|
||||||
@@ -423,7 +419,7 @@ impl Watcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, (String, String)> {
|
pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, String> {
|
||||||
let mut authorized = HashMap::new();
|
let mut authorized = HashMap::new();
|
||||||
for member in &signed.doc.members {
|
for member in &signed.doc.members {
|
||||||
for key in &member.keys {
|
for key in &member.keys {
|
||||||
@@ -431,7 +427,7 @@ pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, (St
|
|||||||
if valid {
|
if valid {
|
||||||
authorized
|
authorized
|
||||||
.entry(key.key.clone())
|
.entry(key.key.clone())
|
||||||
.or_insert((member.id.clone(), member.class.clone()));
|
.or_insert(member.id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,7 +447,6 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, key_hex, not_before, not_after)| RegistryMember {
|
.map(|(id, key_hex, not_before, not_after)| RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key_hex.to_string(),
|
key: key_hex.to_string(),
|
||||||
not_before,
|
not_before,
|
||||||
@@ -511,7 +506,6 @@ mod tests {
|
|||||||
let mut expired = registry.doc.members.clone();
|
let mut expired = registry.doc.members.clone();
|
||||||
expired.push(RegistryMember {
|
expired.push(RegistryMember {
|
||||||
id: "expired".to_string(),
|
id: "expired".to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: Keypair::generate().public_hex(),
|
key: Keypair::generate().public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
|
|||||||
+2
-73
@@ -4,7 +4,7 @@ use std::fs;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use common::{ask, client, collection, config_for, spawn_relay};
|
use common::{ask, client, collection, config_for, spawn_relay};
|
||||||
use frxd::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Member, save_members};
|
use frxd::config::{Member, save_members};
|
||||||
use frxd::index::LocalIndex;
|
use frxd::index::LocalIndex;
|
||||||
use frxd::message::EXPOSURE_FULL;
|
use frxd::message::EXPOSURE_FULL;
|
||||||
use frxd::node::{self, Node, NodeHandle, current_period};
|
use frxd::node::{self, Node, NodeHandle, current_period};
|
||||||
@@ -163,77 +163,7 @@ async fn aggregate_floor_rejects_finer_than_month() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn enrichment_members_cannot_send_content() {
|
async fn full_exposure_responses_carry_content() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let relay_url = spawn_relay().await;
|
|
||||||
let bob_full = start_node(
|
|
||||||
root.path(),
|
|
||||||
"bob",
|
|
||||||
&relay_url,
|
|
||||||
EXPOSURE_FULL,
|
|
||||||
&[("doc.txt", "enrichment test rust content")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let carol_meta = start_node(
|
|
||||||
root.path(),
|
|
||||||
"carol",
|
|
||||||
&relay_url,
|
|
||||||
"metadata",
|
|
||||||
&[("doc.txt", "enrichment test rust metadata")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let alice = start_node(
|
|
||||||
root.path(),
|
|
||||||
"alice",
|
|
||||||
&relay_url,
|
|
||||||
EXPOSURE_FULL,
|
|
||||||
&[("mine.txt", "alice local")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
save_members(
|
|
||||||
&alice.node.config.members_path(),
|
|
||||||
&[
|
|
||||||
Member {
|
|
||||||
name: "bob".to_string(),
|
|
||||||
pubkey: bob_full.pubkey.clone(),
|
|
||||||
class: CLASS_ENRICHMENT.to_string(),
|
|
||||||
previous: Vec::new(),
|
|
||||||
},
|
|
||||||
Member {
|
|
||||||
name: "carol".to_string(),
|
|
||||||
pubkey: carol_meta.pubkey.clone(),
|
|
||||||
class: CLASS_ENRICHMENT.to_string(),
|
|
||||||
previous: Vec::new(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
|
|
||||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
responses.len(),
|
|
||||||
1,
|
|
||||||
"full-exposure enrichment reply must be dropped"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
responses[0].get("member").and_then(Value::as_str),
|
|
||||||
Some(carol_meta.pubkey.as_str())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
responses[0]
|
|
||||||
.get("results")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.unwrap()[0]
|
|
||||||
.get("content")
|
|
||||||
.unwrap()
|
|
||||||
.is_null()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn source_members_may_send_content() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let relay_url = spawn_relay().await;
|
let relay_url = spawn_relay().await;
|
||||||
let bob = start_node(
|
let bob = start_node(
|
||||||
@@ -257,7 +187,6 @@ async fn source_members_may_send_content() {
|
|||||||
&[Member {
|
&[Member {
|
||||||
name: "bob".to_string(),
|
name: "bob".to_string(),
|
||||||
pubkey: bob.pubkey.clone(),
|
pubkey: bob.pubkey.clone(),
|
||||||
class: CLASS_SOURCE.to_string(),
|
|
||||||
previous: Vec::new(),
|
previous: Vec::new(),
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-4
@@ -132,8 +132,6 @@ fn cli_init_add_search_status() {
|
|||||||
"add".to_string(),
|
"add".to_string(),
|
||||||
"carol".to_string(),
|
"carol".to_string(),
|
||||||
"ab".repeat(32),
|
"ab".repeat(32),
|
||||||
"--class".to_string(),
|
|
||||||
"enrichment".to_string(),
|
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("carol"));
|
assert!(stdout.contains("carol"));
|
||||||
let stdout = run_ok(&[
|
let stdout = run_ok(&[
|
||||||
@@ -142,7 +140,7 @@ fn cli_init_add_search_status() {
|
|||||||
"member".to_string(),
|
"member".to_string(),
|
||||||
"list".to_string(),
|
"list".to_string(),
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("carol [enrichment]"));
|
assert!(stdout.contains("carol"));
|
||||||
|
|
||||||
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
|
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
|
||||||
assert!(stdout.contains("indexed 1 file(s)"));
|
assert!(stdout.contains("indexed 1 file(s)"));
|
||||||
@@ -252,7 +250,7 @@ fn cli_registry_lifecycle() {
|
|||||||
dir_arg.clone(),
|
dir_arg.clone(),
|
||||||
"list".to_string(),
|
"list".to_string(),
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("alice.frx.example [source] (2 key(s))"));
|
assert!(stdout.contains("alice.frx.example (2 key(s))"));
|
||||||
|
|
||||||
let stdout = run_ok(&[
|
let stdout = run_ok(&[
|
||||||
"registry".to_string(),
|
"registry".to_string(),
|
||||||
|
|||||||
@@ -367,7 +367,6 @@ async fn member_directory_filters_senders() {
|
|||||||
&[frxd::config::Member {
|
&[frxd::config::Member {
|
||||||
name: "alice".to_string(),
|
name: "alice".to_string(),
|
||||||
pubkey: alice.public_hex(),
|
pubkey: alice.public_hex(),
|
||||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
|
||||||
previous: Vec::new(),
|
previous: Vec::new(),
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
@@ -409,7 +408,6 @@ async fn rotated_keys_are_accepted_through_previous_listing() {
|
|||||||
vec![frxd::config::Member {
|
vec![frxd::config::Member {
|
||||||
name: "carol".to_string(),
|
name: "carol".to_string(),
|
||||||
pubkey: new_key.public_hex(),
|
pubkey: new_key.public_hex(),
|
||||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
|
||||||
previous,
|
previous,
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use serde_json::Value;
|
|||||||
fn member_with_enc(id: &str, ed: &Keypair, enc_public: &str) -> RegistryMember {
|
fn member_with_enc(id: &str, ed: &Keypair, enc_public: &str) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: ed.public_hex(),
|
key: ed.public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
|
|||||||
+4
-5
@@ -106,10 +106,9 @@ fn registry_doc(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member(id: &str, key: &Keypair, class: &str) -> RegistryMember {
|
fn member(id: &str, key: &Keypair) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key.public_hex(),
|
key: key.public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
@@ -129,7 +128,7 @@ async fn relay_admission_gates_unlisted_keys() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
®istry_doc(
|
®istry_doc(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member("alice.frx.example", &alice, "source")],
|
vec![member("alice.frx.example", &alice)],
|
||||||
vec![],
|
vec![],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
@@ -187,8 +186,8 @@ async fn node_discovers_relays_from_registry() {
|
|||||||
®istry_doc(
|
®istry_doc(
|
||||||
&ma,
|
&ma,
|
||||||
vec![
|
vec![
|
||||||
member("alice.frx.example", &alice, "source"),
|
member("alice.frx.example", &alice),
|
||||||
member("bob.frx.example", &bob_config.load_key().unwrap(), "source"),
|
member("bob.frx.example", &bob_config.load_key().unwrap()),
|
||||||
],
|
],
|
||||||
vec![relay_url.clone()],
|
vec![relay_url.clone()],
|
||||||
1,
|
1,
|
||||||
|
|||||||
+11
-19
@@ -91,7 +91,7 @@ async fn application_pending_then_approve_then_enroll_binds_key() {
|
|||||||
assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
|
assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
|
||||||
// MA approves: member stub; a member token then authorizes key enrollment
|
// MA approves: member stub; a member token then authorizes key enrollment
|
||||||
commands::registry_approve(&dir, id, None, None).unwrap();
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
assert!(signed.doc.members.iter().any(|member| member.id == id));
|
assert!(signed.doc.members.iter().any(|member| member.id == id));
|
||||||
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
@@ -137,7 +137,7 @@ async fn application_pending_then_approve_then_enroll_binds_key() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn signup_stores_private_application_and_class() {
|
async fn signup_stores_private_application() {
|
||||||
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).await;
|
let base = spawn_registry_server(&dir).await;
|
||||||
@@ -181,8 +181,7 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
Some("keswick-research.frx.invalid")
|
Some("keswick-research.frx.invalid")
|
||||||
);
|
);
|
||||||
|
|
||||||
// private application record holds the contract details; applicants do not
|
// private application record holds the contract details
|
||||||
// self-declare a class — the MA assigns it at approval
|
|
||||||
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
assert_eq!(apps.len(), 1);
|
assert_eq!(apps.len(), 1);
|
||||||
let app = &apps[0];
|
let app = &apps[0];
|
||||||
@@ -192,25 +191,18 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
assert_eq!(app.email, "ops@keswick.example");
|
assert_eq!(app.email, "ops@keswick.example");
|
||||||
assert_eq!(app.payment, "IBAN XX00 0000");
|
assert_eq!(app.payment, "IBAN XX00 0000");
|
||||||
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
|
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
|
||||||
assert_eq!(app.class, "source");
|
|
||||||
assert_eq!(app.status, "pending");
|
assert_eq!(app.status, "pending");
|
||||||
|
|
||||||
// approval with --class enrichment creates the member with that class
|
// approval creates the member entry
|
||||||
commands::registry_approve(
|
commands::registry_approve(&dir, "keswick-research.frx.invalid", None).unwrap();
|
||||||
&dir,
|
|
||||||
"keswick-research.frx.invalid",
|
|
||||||
None,
|
|
||||||
Some(frxd::config::CLASS_ENRICHMENT),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
let member = signed
|
assert!(
|
||||||
|
signed
|
||||||
.doc
|
.doc
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.id == "keswick-research.frx.invalid")
|
.any(|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
|
// public registry stays minimal: no org data in the signed snapshot
|
||||||
let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
|
let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
|
||||||
@@ -229,7 +221,7 @@ async fn invite_reissues_token_per_node() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
submit_application(&http, &base, "Multi Node").await;
|
submit_application(&http, &base, "Multi Node").await;
|
||||||
let id = "multi-node.frx.invalid";
|
let id = "multi-node.frx.invalid";
|
||||||
commands::registry_approve(&dir, id, None, None).unwrap();
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
|
|
||||||
let enroll = |token: String, pubkey: String| {
|
let enroll = |token: String, pubkey: String| {
|
||||||
let http = http.clone();
|
let http = http.clone();
|
||||||
@@ -282,7 +274,7 @@ async fn wizard_enrolls_and_writes_config() {
|
|||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
submit_application(&http, &base, "Wizard Test").await;
|
submit_application(&http, &base, "Wizard Test").await;
|
||||||
let id = "wizard-test.frx.invalid";
|
let id = "wizard-test.frx.invalid";
|
||||||
commands::registry_approve(&dir, id, None, None).unwrap();
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
let token = commands::registry_token(&dir, id, None).unwrap();
|
let token = commands::registry_token(&dir, id, None).unwrap();
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
let credentials = format!(
|
let credentials = format!(
|
||||||
|
|||||||
+5
-8
@@ -19,13 +19,11 @@ use serde_json::Value;
|
|||||||
fn member_entry(
|
fn member_entry(
|
||||||
id: &str,
|
id: &str,
|
||||||
key: &Keypair,
|
key: &Keypair,
|
||||||
class: &str,
|
|
||||||
not_before: u64,
|
not_before: u64,
|
||||||
not_after: Option<u64>,
|
not_after: Option<u64>,
|
||||||
) -> RegistryMember {
|
) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key.public_hex(),
|
key: key.public_hex(),
|
||||||
not_before,
|
not_before,
|
||||||
@@ -122,7 +120,7 @@ async fn registry_gates_membership_and_revocation_propagates() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -162,7 +160,7 @@ async fn registry_gates_membership_and_revocation_propagates() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -187,7 +185,7 @@ async fn forged_or_wrong_key_snapshot_closes_the_registry() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&attacker,
|
&attacker,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -222,7 +220,6 @@ async fn expired_key_is_not_authorized() {
|
|||||||
vec![member_entry(
|
vec![member_entry(
|
||||||
"alice.frx.example",
|
"alice.frx.example",
|
||||||
&alice,
|
&alice,
|
||||||
"source",
|
|
||||||
0,
|
0,
|
||||||
Some(now_ts().saturating_sub(1)),
|
Some(now_ts().saturating_sub(1)),
|
||||||
)],
|
)],
|
||||||
@@ -251,7 +248,7 @@ async fn fail_static_uses_last_validated_snapshot() {
|
|||||||
let alice = Keypair::generate();
|
let alice = Keypair::generate();
|
||||||
let snapshot = ma_registry(
|
let snapshot = ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +316,7 @@ async fn registry_can_be_served_over_http() {
|
|||||||
®istry_dir.join("registry.json"),
|
®istry_dir.join("registry.json"),
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user