feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the website can create game accounts and unlink them, gated by a shard-wide signup mode. The existing [link flow is unchanged. Overlay: - BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized falls back to game), AccountCreateEnabled (mode-following default), RequireIpForCreate, name/password caps, and a boot warning when the core Accounts.AutoCreateAccounts setting contradicts the mode. - BridgeAccounts (new): account.create (mode gate, actor required, char-safety mirrored from AccountHandler, collision check, per-IP cap via CanCreate/ LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link, account.audit; password never logged or echoed) and account.unlink (Owner floor via BridgeAdmin.Protected, clears the tag). - BridgeAccountLink: in-game [unlink command, emits account.unlinked. - BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse. Sidecar: - POST /accounts/create, DELETE /link/:account, respond_account status mapping (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400). - store.record_unlink drops the mirrored link row. - PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2). Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429; PROTOCOL_2.md Part A marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/vendors/:account", get(vendors))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.route("/link/confirm", post(link_confirm))
|
||||
.route("/link/:account", get(link_lookup))
|
||||
// Account provisioning (Protocol 2.0). Create is correlated by reqId; the DELETE unlinks.
|
||||
.route("/accounts/create", post(account_create))
|
||||
.route("/link/:account", get(link_lookup).delete(link_delete))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
|
||||
@@ -288,6 +290,138 @@ fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
|
||||
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
|
||||
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
|
||||
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||
if kind == "account.error" {
|
||||
let reason = value
|
||||
.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("request rejected");
|
||||
let code = if reason.contains("already exists") {
|
||||
StatusCode::CONFLICT
|
||||
} else if reason.contains("ip account limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else if reason.contains("disabled")
|
||||
|| reason.contains("protected")
|
||||
|| reason.contains("refused")
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
} else if reason.contains("unknown") || reason.contains("not linked") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, Json(value))
|
||||
} else {
|
||||
(StatusCode::OK, Json(value))
|
||||
}
|
||||
}
|
||||
Err(RpcError::NoShard) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "shard not connected"})),
|
||||
),
|
||||
Err(RpcError::Timeout) => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(json!({"error": "shard did not reply in time"})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- account-provisioning handlers ----
|
||||
|
||||
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
|
||||
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
|
||||
/// here and never appears in the reply; a successful create mirrors the link into the store.
|
||||
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let mut obj = match body {
|
||||
Value::Object(m) => m,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "body must be a JSON object"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
|
||||
for field in ["actor", "account", "password", "websiteUserId"] {
|
||||
let present = obj
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": format!("{field} is required") })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
obj.insert("kind".to_string(), json!("account.create"));
|
||||
obj.insert("reqId".to_string(), json!(req_id));
|
||||
|
||||
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
|
||||
|
||||
// Mirror a successful create's link into the store, so events are attributable without the
|
||||
// shard (same as link.confirm does).
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
if let (Some(account), Some(web_id)) = (
|
||||
value.get("account").and_then(|a| a.as_str()),
|
||||
value.get("websiteUserId").and_then(|w| w.as_str()),
|
||||
) {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st.store.record_link(account, web_id, t).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
|
||||
/// also clears the sidecar's mirrored link row so attribution stops immediately.
|
||||
async fn link_delete(
|
||||
State(st): State<AppState>,
|
||||
Path(account): Path<String>,
|
||||
body: Option<Json<Value>>,
|
||||
) -> impl IntoResponse {
|
||||
let actor = body
|
||||
.as_ref()
|
||||
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if actor.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "actor is required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({
|
||||
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
|
||||
});
|
||||
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
let _ = st.store.record_unlink(&account).await;
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
// ---- admin write-plane handlers ----
|
||||
|
||||
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
|
||||
|
||||
Reference in New Issue
Block a user