Registration page: full organization application, private MA-side storage

This commit is contained in:
George Coles
2026-09-15 11:46:11 -04:00
parent 13bb124dcc
commit 8d74b63b11
6 changed files with 276 additions and 23 deletions
+2 -2
View File
@@ -32,7 +32,7 @@
- Relay backpressure is global: any member's full queue 429s every publisher until drained (visible per §3, but one lagging member can stall the firehose — revisit before scale). - Relay backpressure is global: any member's full queue 429s every publisher until drained (visible per §3, but one lagging member can stall the firehose — revisit before scale).
- Member authority (Draft 0.5 §6): the MA-signed registry snapshot is authoritative when configured (`[node] registry` = file path or URL, `ma_key` pinned; monotonic version — rollback and forgery close the node; file path is mtime-reloaded, URL is fetched at start + every 60s and cached to `<data_dir>/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`. - Member authority (Draft 0.5 §6): the MA-signed registry snapshot is authoritative when configured (`[node] registry` = file path or URL, `ma_key` pinned; monotonic version — rollback and forgery close the node; file path is mtime-reloaded, URL is fetched at start + every 60s and cached to `<data_dir>/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`.
- `<data_dir>/members.toml` (name, pubkey, class, `previous` keys, mtime-reloaded) is a dev/local fallback used only when no registry is configured; empty directory without a registry is open bootstrap only when `dev_bootstrap = true` (RFC §6: explicit dev flag). Receivers drop content-bearing responses from enrichment-class senders (metadata-only, §6). - `<data_dir>/members.toml` (name, pubkey, class, `previous` keys, mtime-reloaded) is a dev/local fallback used only when no registry is configured; empty directory without a registry is open bootstrap only when `dev_bootstrap = true` (RFC §6: explicit dev flag). Receivers drop content-bearing responses from enrichment-class senders (metadata-only, §6).
- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|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|set-relays|show|serve` (signed `registry.json` + `ma-key.hex` in `--dir`); `frxd init --id/--registry/--ma-key`; `frxd key show|rotate`; `member add --previous <old>` for the fallback path. A node with no `[node] relays` discovers them from the registry snapshot (`doc.relays`).
- Aggregate semantics are our implementation choices from a terse spec: requests are `aggregate` envelopes carrying only `period`; replies carry `sent` (broadcasts that month) / `passed` (responses consumed from that member); granularity floor is enforced as YYYY or YYYY-MM only (finer rejected), yearly rolls up months. Revisit with §10 sufficiency review. - Aggregate semantics are our implementation choices from a terse spec: requests are `aggregate` envelopes carrying only `period`; replies carry `sent` (broadcasts that month) / `passed` (responses consumed from that member); granularity floor is enforced as YYYY or YYYY-MM only (finer rejected), yearly rolls up months. Revisit with §10 sufficiency review.
## Known gaps (Phase 2/3, intentional — don't fake them) ## Known gaps (Phase 2/3, intentional — don't fake them)
@@ -50,6 +50,6 @@
- Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role. - Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role.
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 12 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch). - Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 12 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search``EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine). - Engine seam: `src/engine.rs` `SearchEngine` trait (`search``EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine).
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Invites live in `<registry dir>/invites.json`. Prompts accept empty input as the default; scripted stdin works for tests. - Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Invites live in `<registry dir>/invites.json`; the form's organization/representative/contact/payment fields are stored privately in `<registry dir>/applications.json` (mode 600, MA contract data — never in the signed snapshot), and the two acknowledgement checkboxes are required by the endpoint. Prompts accept empty input as the default; scripted stdin works for tests.
- 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).
+1 -1
View File
@@ -29,7 +29,7 @@ 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 --signup-code <code> --registry-url https://ma.federatedsearch.org/registry.json
``` ```
(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 `/` accepts the registration form (label, signup code, organization details) and returns a credential block: `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `<registry dir>/applications.json` — contract data, never in the public signed snapshot; review with `frxd registry applications`.
New member: New member:
+154 -18
View File
@@ -424,6 +424,31 @@ pub fn registry_list(dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
pub fn registry_applications(dir: &Path) -> Result<()> {
let applications = registry::load_applications(&registry::applications_path(dir))?;
if applications.is_empty() {
println!("no applications recorded");
return Ok(());
}
for app in &applications {
println!("{} [{}] {} <{}>", app.id, app.class, app.org, app.email);
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(())
}
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();
@@ -519,6 +544,26 @@ fn sanitize_label(input: &str) -> String {
struct SignupRequest { struct SignupRequest {
label: String, label: String,
code: Option<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(
@@ -539,6 +584,20 @@ 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();
}
let label = sanitize_label(&request.label); let label = sanitize_label(&request.label);
if label.is_empty() { if label.is_empty() {
return ( return (
@@ -576,10 +635,15 @@ async fn registry_signup(
.into_response(); .into_response();
} }
}; };
let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
CLASS_ENRICHMENT
} else {
CLASS_SOURCE
};
if let Err(error) = mutate_registry(&server.dir, |doc| { if let Err(error) = mutate_registry(&server.dir, |doc| {
doc.members.push(RegistryMember { doc.members.push(RegistryMember {
id: id.clone(), id: id.clone(),
class: crate::config::CLASS_SOURCE.to_string(), class: class.to_string(),
keys: Vec::new(), keys: Vec::new(),
enc_key: None, enc_key: None,
}); });
@@ -591,6 +655,25 @@ async fn registry_signup(
) )
.into_response(); .into_response();
} }
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(),
submitted_at: now_ts(),
};
if let Err(error) = registry::record_application(&server.dir, application) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let registry_url = server.registry_url.clone().unwrap_or_default(); let registry_url = server.registry_url.clone().unwrap_or_default();
let ma_key = signed.doc.ma_key.clone(); let ma_key = signed.doc.ma_key.clone();
( (
@@ -707,8 +790,10 @@ 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;
@@ -729,28 +814,55 @@ 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>1. Register</h2> <h2>1. Register with the membership authority</h2>
<div class="card"> <div class="card">
<p class="muted">Registering here creates your identifier with the membership authority (MA); the
onboarding wizard in step 4 then binds your node's keys to it. The public registry publishes only
your identifier, class, keys, and the federation's relays. The organization details below are kept
privately by the MA for the membership contract — they are never published and never travel on the
wire.</p>
<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>Signup code<br>
<input name="code" type="password" placeholder="invite code"></label>
<p class="muted">The code is the admission gate: members are approved, not anonymous. Ask the MA
operator for one. (If you run the MA, it is the <code>--signup-code</code> passed to
<code>frxd registry serve</code>.)</p>
<label>Legal organization name<br>
<input name="org" required placeholder="Keswick Research LLC"></label>
<label>Representative (authorized contact person)<br>
<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>
<p class="muted">Your identifier is <code>&lt;label&gt;.frx.federatedsearch.org</code> — no domain or DNS of
your own is needed. Registration returns a one-time credential block:
<code>id=... token=... registry=... ma_key=...</code>.</p>
</div> </div>
<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.0/frxd-linux-amd64 <pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frxd-linux-amd64.sha256 curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64.sha256
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frx-linux-amd64 curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frx-linux-amd64
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.0/frx-linux-amd64.sha256</pre> curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/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>
@@ -787,13 +899,35 @@ frxd serve</pre>
<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, code: f.code.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";
@@ -806,6 +940,8 @@ document.getElementById("f").onsubmit = async (e) => {
try { try {
const res = await fetch("/registry.json"); const res = await fetch("/registry.json");
const doc = await res.json(); const doc = await res.json();
zone = doc.zone || zone;
updatePreview();
const ids = doc.members.map((m) => m.id); const ids = doc.members.map((m) => m.id);
document.getElementById("members").textContent = doc.members.length === 0 document.getElementById("members").textContent = doc.members.length === 0
? "The registry is empty — be the first member." ? "The registry is empty — be the first member."
+2
View File
@@ -179,6 +179,7 @@ enum RegistryCommand {
id: String, id: String,
}, },
List, List,
Applications,
SetRelays { SetRelays {
#[arg(required = true)] #[arg(required = true)]
relays: Vec<String>, relays: Vec<String>,
@@ -354,6 +355,7 @@ 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::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 {
+45
View File
@@ -108,6 +108,51 @@ 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,
pub submitted_at: u64,
}
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 record_application(dir: &Path, application: Application) -> Result<()> {
let path = applications_path(dir);
let mut applications = load_applications(&path)?;
applications.push(application);
fs::write(&path, serde_json::to_string_pretty(&applications)?)?;
crate::config::set_private_permissions(&path)?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRegistry { pub struct SignedRegistry {
#[serde(flatten)] #[serde(flatten)]
+72 -2
View File
@@ -50,7 +50,7 @@ async fn signup_issues_invite_and_enroll_binds_key() {
let response = http let response = http
.post(format!("{base}/v1/signup")) .post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame"})) .json(&serde_json::json!({"label": "Alice Dev", "code": "sesame", "attestation": true, "privacy_ack": true}))
.send() .send()
.await .await
.unwrap(); .unwrap();
@@ -99,6 +99,76 @@ 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, Some("sesame")).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let missing = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "acme", "code": "sesame"}))
.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",
"code": "sesame",
"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!(response.status().is_success());
let body: Value = response.json().await.unwrap();
assert_eq!(
body.get("id").and_then(Value::as_str),
Some("keswick-research.frx.invalid")
);
// public registry stays minimal: identifier + class only, no org data
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
let member = signed
.doc
.members
.iter()
.find(|m| m.id == "keswick-research.frx.invalid")
.unwrap();
assert_eq!(member.class, frxd::config::CLASS_ENRICHMENT);
let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
assert!(!raw.contains("Keswick Research LLC"));
assert!(!raw.contains("ops@keswick.example"));
// private application record holds the contract details
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
assert_eq!(apps.len(), 1);
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");
}
#[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();
@@ -107,7 +177,7 @@ async fn wizard_enrolls_and_writes_config() {
let http = reqwest::Client::new(); let http = reqwest::Client::new();
let body: Value = http let body: Value = http
.post(format!("{base}/v1/signup")) .post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame"})) .json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "attestation": true, "privacy_ack": true}))
.send() .send()
.await .await
.unwrap() .unwrap()