Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: mx.client
Type: Package
Title: Stateful Matrix Client Helpers
Version: 0.1.1
Version: 0.1.1.1
Date: 2026-06-13
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ export(mx_send_media)
export(mx_send_text)
export(mx_sync_update)
export(mx_with_relogin)

S3method(print,mx_client_config)
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# mx.client 0.1.1.1

* `print()` method for `mx_client_config`: masks `token` and `password`
as `<hidden>` (or `<unset>` when empty) instead of falling through to
the default list print, which showed credentials verbatim. `sync_token`
and every other field stay visible. `unclass()` still exposes the raw
values for the cases that need them.

# mx.client 0.1.1

* First release. Stateful client layer over 'mx.api': configuration
Expand Down
64 changes: 64 additions & 0 deletions R/config.R
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,70 @@ mx_client_plain_list <- function(client) {
out
}

# Config fields holding credentials. sync_token is deliberately absent:
# it is a cursor, not a secret, and seeing it is useful when debugging.
mx_client_secret_fields <- c("token", "access_token", "refresh_token",
"password")

mx_client_field_text <- function(name, value) {
set <- length(value) > 0L &&
!all(is.na(value)) &&
(!is.character(value) || any(nzchar(value)))
if (name %in% mx_client_secret_fields) {
return(if (set) {
"<hidden>"
} else {
"<unset>"
})
}
if (!set) {
return("<unset>")
}
if (is.list(value) || length(value) > 1L) {
return(sprintf("<%s[%d]>", class(value)[1L], length(value)))
}
as.character(value)
}

#' Print a Matrix client config
#'
#' Prints the config field by field with credentials masked, so that an
#' interactive session, a screenshot, or a pasted bug report does not
#' leak the access token or password. Secret fields show whether they
#' are set (\code{<hidden>}) or empty (\code{<unset>}) without showing
#' the value. Use \code{unclass(x)} or \code{str(unclass(x))} when the
#' raw credentials are genuinely needed.
#'
#' @param x An \code{mx_client_config}, as returned by
#' \code{mx_client_load()} or \code{mx_client_from_config()}.
#' @param ... Ignored.
#' @return \code{x}, invisibly.
#' @examples
#' cfg <- mx_client_from_config(list(server = "https://matrix.example.org",
#' token = "syt_secret_value",
#' user_id = "@bot:example.org",
#' device_id = "DEVICEID"))
#' cfg
#' @export
print.mx_client_config <- function(x, ...) {
cat("<mx_client_config>\n")
fields <- names(x)
labels <- c(paste0(fields, ":"), if (!is.null(attr(x, "path"))) "path:")
if (!length(labels)) {
cat(" (no fields)\n")
return(invisible(x))
}
width <- max(nchar(labels))
for (f in fields) {
cat(sprintf(" %-*s %s\n", width, paste0(f, ":"),
mx_client_field_text(f, x[[f]])))
}
if (!is.null(attr(x, "path"))) {
cat(sprintf(" %-*s %s\n", width, "path:", attr(x, "path")))
}
invisible(x)
}

#' Load a Matrix client config
#'
#' Reads a JSON config. If \code{path} or the derived environment variable
Expand Down
10 changes: 4 additions & 6 deletions R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -213,19 +213,17 @@ mx_accept_invites <- function(client, invites) {
#' target_event_id = "$msg")
#' @export
mx_extract_reaction_verdict <- function(sync_resp, room_id, self_id,
target_event_id,
approve_keys = NULL,
target_event_id, approve_keys = NULL,
deny_keys = NULL) {
# Emoji defaults are built here, not in the signature, so they don't
# land as raw astral-plane glyphs in the .Rd \usage block -- LaTeX
# can't typeset them and the PDF manual fails R CMD check --as-cran.
if (is.null(approve_keys)) {
approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705),
"y", "yes", "ok")
approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), "y", "yes",
"ok")
}
if (is.null(deny_keys)) {
deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C),
"n", "no", "nope")
deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), "n", "no", "nope")
}
room <- sync_resp$rooms$join[[room_id]]
if (is.null(room)) {
Expand Down
30 changes: 30 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,33 @@ unlink(tmp, recursive = TRUE)
expect_true(is.function(mx.client::mx_room_encrypted))
expect_equal(names(formals(mx.client::mx_room_encrypted)),
c("client", "room", "room_cache"))

# --- print.mx_client_config masks credentials ---

secret_cfg <- mx.client::mx_client_from_config(
list(server = "https://matrix.example", token = "syt_supersecret",
password = "hunter2", user_id = "@bot:example", device_id = "DEV",
room_id = "!room:ex", sync_token = "s99_cursor",
tools_filter = list()),
path = "/tmp/matrix.json"
)
out <- paste(capture.output(print(secret_cfg)), collapse = "\n")
# credentials never appear, in any form
expect_false(grepl("syt_supersecret", out, fixed = TRUE))
expect_false(grepl("hunter2", out, fixed = TRUE))
expect_true(grepl("token:\\s+<hidden>", out))
expect_true(grepl("password:\\s+<hidden>", out))
# non-secret fields stay visible, including the sync cursor
expect_true(grepl("@bot:example", out, fixed = TRUE))
expect_true(grepl("s99_cursor", out, fixed = TRUE))
expect_true(grepl("/tmp/matrix.json", out, fixed = TRUE))
# an empty credential reads as unset rather than hidden
empty_cfg <- mx.client::mx_client_from_config(
list(server = "https://matrix.example", token = "", user_id = "@b:e")
)
out_empty <- paste(capture.output(print(empty_cfg)), collapse = "\n")
expect_true(grepl("token:\\s+<unset>", out_empty))
# printing returns the object unchanged, and invisibly
capture.output(vis <- withVisible(print(secret_cfg)))
expect_false(vis$visible)
expect_identical(vis$value, secret_cfg)
31 changes: 31 additions & 0 deletions man/print.mx_client_config.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{print.mx_client_config}
\alias{print.mx_client_config}
\title{Print a Matrix client config}
\usage{
\method{print}{mx_client_config}(x, ...)
}
\arguments{
\item{x}{An \code{mx_client_config}, as returned by
\code{mx_client_load()} or \code{mx_client_from_config()}.}

\item{...}{Ignored.}
}
\value{
\code{x}, invisibly.
}
\description{
Prints the config field by field with credentials masked, so that an
interactive session, a screenshot, or a pasted bug report does not
leak the access token or password. Secret fields show whether they
are set (\code{<hidden>}) or empty (\code{<unset>}) without showing
the value. Use \code{unclass(x)} or \code{str(unclass(x))} when the
raw credentials are genuinely needed.
}
\examples{
cfg <- mx_client_from_config(list(server = "https://matrix.example.org",
token = "syt_secret_value",
user_id = "@bot:example.org",
device_id = "DEVICEID"))
cfg
}