Files
Skald-Circle/crates/skald-core/src/db/users.rs
T
dguiducci c0a779b79e
Nightly Build / build (push) Successful in 7m50s
fix: let an admin use the connectors they implicitly hold
Activating a per-user connector as admin failed with "you are not
authorized to use this connector — ask an admin to enable it for you".

`db::access_defaults` deliberately writes no grant rows for admins, and
says why: "they already hold every plugin and connector implicitly, so a
row for them would be noise". That implicit hold was only ever
implemented for plugins (`plugin_access::effective_access`). The two MCP
grant tables had nothing but the raw junction read, so an admin ended up
with no row *and* no short-circuit — denied their own connectors, and
denied more the more the seeding was trusted to skip them.

The reported symptom was the mildest of four:

  - `activate` refused, while `available` listed the entry (an admin
    holds `mcp.manage_catalog`) — visible but unusable;
  - the login-time startup filter dropped an admin's already-activated
    catalog connectors, so they silently stopped running;
  - `accessible_global` snapshotted an empty set, so an admin's sessions
    were offered no shared MCP tools at all — no error, just absence;
  - the connector report told the agent an admin's own global connector
    was "not granted to you".

`users::is_admin` is now the single predicate behind every "admins hold
it implicitly" short-circuit, and `plugin_access` was moved onto it too:
three tables open-coding the same role lookup is what let one of them be
written without it. Each MCP table grows an `effective_access` beside its
`has_access`, and the distinction is the point — `has_access` stays the
roster question ("what did the admin tick"), which the access-editing
surfaces must keep asking, while the gates ask the authorization one.

Nothing widens for anyone else: deny-by-default is untouched for
non-admins, an unknown user is nobody, a disabled global stays excluded
for admins too, and the `not_granted` report branch survives for a
non-admin who was given the catalog-management capability.
2026-08-07 12:37:23 +01:00

695 lines
24 KiB
Rust

//! `users` — user directory and auth material.
//!
//! This table lives in the system DB, which anyone owning the box can read. So
//! it must never store anything from which a user's key can be derived.
//!
//! For an **encrypted** user it holds the DEK *wrapped* under a key derived from
//! the password: useless without the password, and the wrap's AEAD tag doubles
//! as the password verifier. That is why an encrypted user has no
//! `password_hash` — a second hash of the same password would only hand an
//! offline attacker an easier target than the wrap itself.
//!
//! A **cleartext** user has no DB key to bind a verifier to, so it carries an
//! ordinary Argon2id hash instead (harmless: that DB is readable anyway).
//!
//! [`Credentials`] makes the two shapes mutually exclusive in the type system,
//! mirroring the `CHECK` constraint on the table.
use anyhow::{Result, anyhow, bail};
use serde::Serialize;
use sqlx::SqlitePool;
/// KDF settings as JSON, e.g. `{"algo":"argon2id","m":65536,"t":3,"p":1}`.
/// Not secret — calibrated on the box when the user is created.
pub type KdfParams = String;
/// Argon2id verifier for a user whose database is not encrypted.
#[derive(Clone)]
pub struct ClearVerifier {
pub kdf_params: KdfParams,
pub kdf_salt: Vec<u8>,
pub password_hash: Vec<u8>,
}
/// Auth material for a user. The variants mirror the table's `CHECK`: an
/// encrypted user has a wrapped DEK and no hash; a cleartext user has no
/// wrapped DEK, and may have no verifier at all (a role that cannot log in).
#[derive(Clone)]
pub enum Credentials {
Encrypted {
kdf_params: KdfParams,
kdf_salt: Vec<u8>,
/// DEK sealed with an AEAD under `KDF(password, kdf_salt)`. Changing the
/// password re-wraps this value; the database itself is never re-encrypted.
database_password: Vec<u8>,
},
Cleartext(Option<ClearVerifier>),
}
impl Credentials {
pub fn is_encrypted(&self) -> bool {
matches!(self, Credentials::Encrypted { .. })
}
}
/// A row of `users`.
///
/// Deliberately **not** `Serialize`: it carries the wrapped DEK and the password
/// verifier, and this type must never be handed to an HTTP handler by accident.
/// Use [`User::summary`] for anything that leaves the process.
#[derive(Clone)]
pub struct User {
pub id: String,
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
pub credentials: Credentials,
pub active: bool,
/// UI locale override (NULL = follow the instance default).
pub locale: Option<String>,
/// Directory profile: ISO `YYYY-MM-DD` date of birth (NULL = unknown).
pub birthdate: Option<String>,
/// Directory profile: free-text sex (NULL = not specified).
pub sex: Option<String>,
/// Directory profile: admin-authored notes (NULL = none).
pub notes: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// The public-safe projection of a [`User`] — no key material.
#[derive(Debug, Clone, Serialize)]
pub struct UserSummary {
pub id: String,
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
pub encrypted: bool,
pub active: bool,
pub locale: Option<String>,
pub birthdate: Option<String>,
pub sex: Option<String>,
pub notes: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl User {
pub fn is_encrypted(&self) -> bool {
self.credentials.is_encrypted()
}
pub fn summary(&self) -> UserSummary {
UserSummary {
id: self.id.clone(),
username: self.username.clone(),
display_name: self.display_name.clone(),
role_id: self.role_id.clone(),
encrypted: self.is_encrypted(),
active: self.active,
locale: self.locale.clone(),
birthdate: self.birthdate.clone(),
sex: self.sex.clone(),
notes: self.notes.clone(),
created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(),
}
}
}
// Hand-written so a stray `{:?}` — in a tracing span, an error context, a panic
// message — cannot print key material.
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Credentials::Encrypted { .. } => f.write_str("Encrypted(<redacted>)"),
Credentials::Cleartext(None) => f.write_str("Cleartext(no verifier)"),
Credentials::Cleartext(Some(_)) => f.write_str("Cleartext(<redacted>)"),
}
}
}
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("display_name", &self.display_name)
.field("role_id", &self.role_id)
.field("credentials", &self.credentials)
.field("active", &self.active)
.finish()
}
}
// ── Row mapping ───────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct Row {
id: String,
username: String,
display_name: Option<String>,
role_id: String,
encrypted: bool,
kdf_params: Option<String>,
kdf_salt: Option<Vec<u8>>,
database_password: Option<Vec<u8>>,
password_hash: Option<Vec<u8>>,
active: bool,
locale: Option<String>,
birthdate: Option<String>,
sex: Option<String>,
notes: Option<String>,
created_at: String,
updated_at: String,
}
/// Builds a `&'static str` (sqlx rejects runtime-built SQL) while keeping the
/// column list — which `Row`'s `FromRow` mirrors — in exactly one place.
macro_rules! select {
($tail:literal) => {
concat!(
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ",
"database_password, password_hash, active, locale, birthdate, sex, notes, ",
"created_at, updated_at FROM users ",
$tail
)
};
}
impl TryFrom<Row> for User {
type Error = anyhow::Error;
fn try_from(r: Row) -> Result<Self> {
let broken = |what: &str| anyhow!("users row {}: {what}", r.id);
let credentials = if r.encrypted {
Credentials::Encrypted {
kdf_params: r.kdf_params.ok_or_else(|| broken("encrypted without kdf_params"))?,
kdf_salt: r.kdf_salt.ok_or_else(|| broken("encrypted without kdf_salt"))?,
database_password: r.database_password
.ok_or_else(|| broken("encrypted without database_password"))?,
}
} else {
match r.password_hash {
None => Credentials::Cleartext(None),
Some(password_hash) => Credentials::Cleartext(Some(ClearVerifier {
kdf_params: r.kdf_params.ok_or_else(|| broken("verifier without kdf_params"))?,
kdf_salt: r.kdf_salt.ok_or_else(|| broken("verifier without kdf_salt"))?,
password_hash,
})),
}
};
Ok(User {
id: r.id,
username: r.username,
display_name: r.display_name,
role_id: r.role_id,
credentials,
active: r.active,
locale: r.locale,
birthdate: r.birthdate,
sex: r.sex,
notes: r.notes,
created_at: r.created_at,
updated_at: r.updated_at,
})
}
}
/// The four credential columns, in table order.
type CredColumns<'a> = (bool, Option<&'a str>, Option<&'a [u8]>, Option<&'a [u8]>, Option<&'a [u8]>);
fn columns(c: &Credentials) -> CredColumns<'_> {
match c {
Credentials::Encrypted { kdf_params, kdf_salt, database_password } => (
true,
Some(kdf_params.as_str()),
Some(kdf_salt.as_slice()),
Some(database_password.as_slice()),
None,
),
Credentials::Cleartext(None) => (false, None, None, None, None),
Credentials::Cleartext(Some(v)) => (
false,
Some(v.kdf_params.as_str()),
Some(v.kdf_salt.as_slice()),
None,
Some(v.password_hash.as_slice()),
),
}
}
// ── Reads ─────────────────────────────────────────────────────────────────────
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<User>> {
let row = sqlx::query_as::<_, Row>(select!("WHERE id = ?1"))
.bind(id)
.fetch_optional(pool)
.await?;
row.map(User::try_from).transpose()
}
/// Login entry point: `username` is the handle, `id` is opaque and stable.
pub async fn by_username(pool: &SqlitePool, username: &str) -> Result<Option<User>> {
let row = sqlx::query_as::<_, Row>(select!("WHERE username = ?1"))
.bind(username)
.fetch_optional(pool)
.await?;
row.map(User::try_from).transpose()
}
pub async fn list(pool: &SqlitePool) -> Result<Vec<User>> {
let rows = sqlx::query_as::<_, Row>(select!("ORDER BY username"))
.fetch_all(pool)
.await?;
rows.into_iter().map(User::try_from).collect()
}
pub async fn count(pool: &SqlitePool) -> Result<i64> {
let (n,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users")
.fetch_one(pool)
.await?;
Ok(n)
}
/// Whether this user holds the admin role — the one predicate behind every
/// "admins hold it implicitly" short-circuit (`plugin_access`,
/// `mcp_catalog_access`, `mcp_global_access`).
///
/// It lives here, as one function, because the alternative is what actually
/// happened: each grant table open-coded the role lookup, one of them was written
/// without it, and admins were denied their own connectors while
/// [`super::access_defaults`] skipped seeding them rows on the grounds that the
/// short-circuit existed. An unknown user is not an admin; errors propagate so
/// callers fail closed.
pub async fn is_admin(pool: &SqlitePool, user_id: &str) -> Result<bool> {
let role = sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(matches!(role, Some((r,)) if r == super::roles::ADMIN_ROLE_ID))
}
// ── Writes ────────────────────────────────────────────────────────────────────
/// `id` is supplied by the caller and must be opaque (never the username), so a
/// rename never has to touch `database/{id}.db`.
pub async fn insert(
pool: &SqlitePool,
id: &str,
username: &str,
display_name: Option<&str>,
role_id: &str,
credentials: &Credentials,
) -> Result<()> {
let (encrypted, kdf_params, kdf_salt, database_password, password_hash) = columns(credentials);
sqlx::query(
"INSERT INTO users
(id, username, display_name, role_id, encrypted,
kdf_params, kdf_salt, database_password, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.bind(id)
.bind(username)
.bind(display_name)
.bind(role_id)
.bind(encrypted)
.bind(kdf_params)
.bind(kdf_salt)
.bind(database_password)
.bind(password_hash)
.execute(pool)
.await?;
Ok(())
}
/// Replaces the auth material in one statement.
///
/// This is both "change password" (re-wrap the same DEK under a key derived from
/// the new password — the database is never re-encrypted) and the encrypted ↔
/// cleartext migration, since the variant carries the new shape.
pub async fn set_credentials(pool: &SqlitePool, id: &str, credentials: &Credentials) -> Result<()> {
let (encrypted, kdf_params, kdf_salt, database_password, password_hash) = columns(credentials);
let n = sqlx::query(
"UPDATE users SET
encrypted = ?2,
kdf_params = ?3,
kdf_salt = ?4,
database_password = ?5,
password_hash = ?6,
updated_at = datetime('now')
WHERE id = ?1",
)
.bind(id)
.bind(encrypted)
.bind(kdf_params)
.bind(kdf_salt)
.bind(database_password)
.bind(password_hash)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
pub async fn set_active(pool: &SqlitePool, id: &str, active: bool) -> Result<()> {
let n = sqlx::query(
"UPDATE users SET active = ?2, updated_at = datetime('now') WHERE id = ?1",
)
.bind(id)
.bind(active)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
/// Changes the role assignment and optionally the display name in one statement.
pub async fn update_profile(
pool: &SqlitePool,
id: &str,
username: &str,
display_name: Option<&str>,
role_id: &str,
) -> Result<()> {
let n = sqlx::query(
"UPDATE users
SET username = ?2, display_name = ?3, role_id = ?4, updated_at = datetime('now')
WHERE id = ?1",
)
.bind(id)
.bind(username)
.bind(display_name)
.bind(role_id)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
/// Replaces the admin-managed directory profile fields in one statement:
/// `birthdate` (ISO `YYYY-MM-DD`), free-text `sex`, admin-authored `notes`.
/// Validation is the caller's job — this layer stays dumb.
pub async fn set_directory_fields(
pool: &SqlitePool,
id: &str,
birthdate: Option<&str>,
sex: Option<&str>,
notes: Option<&str>,
) -> Result<()> {
let n = sqlx::query(
"UPDATE users
SET birthdate = ?2, sex = ?3, notes = ?4, updated_at = datetime('now')
WHERE id = ?1",
)
.bind(id)
.bind(birthdate)
.bind(sex)
.bind(notes)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: Option<&str>) -> Result<()> {
let n = sqlx::query(
"UPDATE users SET username = ?2, display_name = ?3, updated_at = datetime('now')
WHERE id = ?1",
)
.bind(id)
.bind(username)
.bind(display_name)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
/// Sets (or clears, with `None`) the user's UI locale override.
pub async fn set_locale(pool: &SqlitePool, id: &str, locale: Option<&str>) -> Result<()> {
let n = sqlx::query(
"UPDATE users SET locale = ?2, updated_at = datetime('now') WHERE id = ?1",
)
.bind(id)
.bind(locale)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
/// Removes the directory row only. The caller still owns `database/{id}.db`:
/// erasing a user means deleting that file too.
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM users WHERE id = ?1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Nested on purpose: also covers `init_system_pool` creating the parent directory.
fn temp_db_path(tag: &str) -> String {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("skald-test-{tag}-{}-{nanos}", std::process::id()));
p.push("database");
p.push("system.db");
p.to_string_lossy().into_owned()
}
fn cleanup(path: &str) {
if let Some(dir) = std::path::Path::new(path).parent().and_then(|p| p.parent()) {
let _ = std::fs::remove_dir_all(dir);
}
}
fn encrypted() -> Credentials {
Credentials::Encrypted {
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
kdf_salt: vec![1, 2, 3, 4],
database_password: vec![0xDE, 0xAD, 0xBE, 0xEF],
}
}
fn cleartext() -> Credentials {
Credentials::Cleartext(Some(ClearVerifier {
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
kdf_salt: vec![5, 6, 7, 8],
password_hash: vec![0xAB, 0xCD],
}))
}
#[tokio::test]
async fn init_system_pool_creates_the_database_directory() {
let path = temp_db_path("users-mkdir");
assert!(!std::path::Path::new(&path).parent().unwrap().exists());
let pool = crate::db::init_system_pool(&path).await.unwrap();
assert!(std::path::Path::new(&path).exists(), "system.db must exist under a fresh database/");
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn encrypted_user_round_trips_and_keeps_the_wrapped_dek() {
let path = temp_db_path("users-enc");
let pool = crate::db::init_system_pool(&path).await.unwrap();
insert(&pool, "u-1", "ada", Some("Ada"), "admin", &encrypted()).await.unwrap();
let u = by_username(&pool, "ada").await.unwrap().expect("user by username");
assert_eq!(u.id, "u-1");
assert!(u.is_encrypted());
assert!(u.active);
match u.credentials {
Credentials::Encrypted { database_password, kdf_salt, .. } => {
assert_eq!(database_password, vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(kdf_salt, vec![1, 2, 3, 4]);
}
other => panic!("expected Encrypted, got {other:?}"),
}
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn cleartext_user_round_trips_with_and_without_a_verifier() {
let path = temp_db_path("users-clear");
let pool = crate::db::init_system_pool(&path).await.unwrap();
crate::db::roles::insert(&pool, "children", "Children", "default", None)
.await.unwrap();
insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap();
insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap();
let with = get(&pool, "u-1").await.unwrap().unwrap();
assert!(!with.is_encrypted());
match with.credentials {
Credentials::Cleartext(Some(v)) => assert_eq!(v.password_hash, vec![0xAB, 0xCD]),
other => panic!("expected a verifier, got {other:?}"),
}
let without = get(&pool, "u-2").await.unwrap().unwrap();
assert!(matches!(without.credentials, Credentials::Cleartext(None)));
assert_eq!(count(&pool).await.unwrap(), 2);
assert_eq!(list(&pool).await.unwrap().len(), 2);
pool.close().await;
cleanup(&path);
}
/// Changing the password re-wraps the DEK; migrating to cleartext must clear it.
#[tokio::test]
async fn set_credentials_rewraps_and_migrates() {
let path = temp_db_path("users-rewrap");
let pool = crate::db::init_system_pool(&path).await.unwrap();
insert(&pool, "u-1", "ada", None, "admin", &encrypted()).await.unwrap();
let rewrapped = Credentials::Encrypted {
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
kdf_salt: vec![9, 9, 9],
database_password: vec![0xFE, 0xED],
};
set_credentials(&pool, "u-1", &rewrapped).await.unwrap();
match get(&pool, "u-1").await.unwrap().unwrap().credentials {
Credentials::Encrypted { database_password, .. } => assert_eq!(database_password, vec![0xFE, 0xED]),
other => panic!("expected Encrypted, got {other:?}"),
}
set_credentials(&pool, "u-1", &cleartext()).await.unwrap();
let u = get(&pool, "u-1").await.unwrap().unwrap();
assert!(!u.is_encrypted(), "migrating must flip `encrypted` and drop the wrapped DEK");
assert!(set_credentials(&pool, "ghost", &cleartext()).await.is_err(), "unknown id must fail");
pool.close().await;
cleanup(&path);
}
/// The SQL `CHECK` is the last line of defence when a row is written without
/// going through [`Credentials`].
#[tokio::test]
async fn check_constraint_rejects_impossible_rows() {
let path = temp_db_path("users-check");
let pool = crate::db::init_system_pool(&path).await.unwrap();
// encrypted without a wrapped DEK
let err = sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted) VALUES ('x', 'x', 'admin', 1)",
)
.execute(&pool)
.await;
assert!(err.is_err(), "encrypted=1 requires database_password");
// encrypted *and* carrying a password hash
let err = sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted, database_password, password_hash)
VALUES ('y', 'y', 'admin', 1, X'00', X'01')",
)
.execute(&pool)
.await;
assert!(err.is_err(), "an encrypted user must not also store a password hash");
// cleartext carrying a wrapped DEK
let err = sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted, database_password)
VALUES ('z', 'z', 'admin', 0, X'00')",
)
.execute(&pool)
.await;
assert!(err.is_err(), "cleartext=0 must not store a wrapped DEK");
assert_eq!(count(&pool).await.unwrap(), 0);
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn set_directory_fields_round_trips() {
let path = temp_db_path("users-profile");
let pool = crate::db::init_system_pool(&path).await.unwrap();
insert(&pool, "u-1", "ada", None, "admin", &encrypted()).await.unwrap();
let u = get(&pool, "u-1").await.unwrap().unwrap();
assert!(u.birthdate.is_none() && u.sex.is_none() && u.notes.is_none());
set_directory_fields(&pool, "u-1", Some("2019-02-10"), Some("female"), Some("loves dinosaurs"))
.await.unwrap();
let u = get(&pool, "u-1").await.unwrap().unwrap();
assert_eq!(u.birthdate.as_deref(), Some("2019-02-10"));
assert_eq!(u.sex.as_deref(), Some("female"));
assert_eq!(u.notes.as_deref(), Some("loves dinosaurs"));
// Clearing the fields writes NULLs back.
set_directory_fields(&pool, "u-1", None, None, None).await.unwrap();
let u = get(&pool, "u-1").await.unwrap().unwrap();
assert!(u.birthdate.is_none() && u.sex.is_none() && u.notes.is_none());
// The summary projection carries the fields too.
set_directory_fields(&pool, "u-1", Some("2019-02-10"), Some("female"), Some("notes"))
.await.unwrap();
let s = get(&pool, "u-1").await.unwrap().unwrap().summary();
assert_eq!(s.birthdate.as_deref(), Some("2019-02-10"));
assert_eq!(s.sex.as_deref(), Some("female"));
assert_eq!(s.notes.as_deref(), Some("notes"));
assert!(set_directory_fields(&pool, "ghost", None, None, None).await.is_err(), "unknown id must fail");
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn debug_never_prints_key_material() {
let u = User {
id: "u-1".into(),
username: "ada".into(),
display_name: None,
role_id: "admin".into(),
credentials: encrypted(),
active: true,
locale: None,
birthdate: None,
sex: None,
notes: None,
created_at: "now".into(),
updated_at: "now".into(),
};
let printed = format!("{u:?}");
assert!(printed.contains("ada"));
assert!(!printed.contains("222"), "no raw DEK bytes");
assert!(!printed.contains("deadbeef") && !printed.contains("DEADBEEF"));
assert!(printed.contains("<redacted>"));
}
}