Compare commits

..
Author SHA1 Message Date
Brandon Thomas 6e95be9bdb endpoints 2026-06-15 11:08:24 -04:00
Brandon Thomas b7667976df token and generator 2026-06-15 09:12:07 -04:00
Brandon Thomas e07d302df1 schema 2026-06-15 08:55:47 -04:00
50 changed files with 70 additions and 1307 deletions
@@ -120,7 +120,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
}
],
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -270,7 +270,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
Generated
-9
View File
@@ -712,13 +712,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_api_keys"
version = "0.0.1"
dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_client"
version = "0.0.1"
@@ -7304,7 +7297,6 @@ name = "mysql_queries"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_keys",
"bucket_paths",
"chrono",
"composite_identifiers",
@@ -11564,7 +11556,6 @@ dependencies = [
"actix_helpers",
"anyhow",
"artcraft_api_defs",
"artcraft_api_keys",
"artcraft_router",
"async-stripe 0.26.0",
"async-trait",
-2
View File
@@ -7,7 +7,6 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_api_keys",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/cloud_storage",
@@ -233,7 +232,6 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
@@ -1,20 +0,0 @@
[package]
name = "artcraft_api_keys"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_api_keys"
path = "src/lib.rs"
[dependencies]
# Hackari workspace (faster compile times)
workspace-build-acceleration.workspace = true
@@ -1,104 +0,0 @@
use std::fmt;
use crate::parse_from_header_value::parse_from_header_value;
pub mod parse_from_header_value;
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
/// the secret is redacted so the full key never lands in logs or error messages.
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
///
/// `Debug` and `Display` both redact the value, printing only the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
/// accidentally logged.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ArtcraftApiKey(pub String);
impl ArtcraftApiKey {
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
ArtcraftApiKey(value.to_string())
}
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
/// full parsing rules.
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
parse_from_header_value(header_value)
}
/// The full, unredacted key value as a borrowed `&str`.
///
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
/// (storage, the create-key response, tests), never in logs or error messages.
pub fn as_str_be_careful(&self) -> &str {
&self.0
}
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
/// caveat behind the `_be_careful` suffix.
pub fn to_string_be_careful(&self) -> String {
self.0.clone()
}
/// The redacted form used by both `Debug` and `Display`: the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
fn redacted(&self) -> String {
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
format!("{prefix}…")
}
}
impl fmt::Display for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.redacted())
}
}
impl fmt::Debug for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ArtcraftApiKey({:?})", self.redacted())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn new_from_str_and_as_str_round_trip() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
assert_eq!(key.0, SAMPLE_KEY);
}
#[test]
fn display_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
}
#[test]
fn debug_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
}
#[test]
fn formatting_does_not_leak_the_full_secret() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
assert!(!format!("{key}").contains(suffix));
assert!(!format!("{key:?}").contains(suffix));
}
#[test]
fn shorter_than_prefix_is_printed_in_full() {
let key = ArtcraftApiKey::new_from_str("abc");
assert_eq!(format!("{key}"), "abc…");
}
}
@@ -1,125 +0,0 @@
use crate::ArtcraftApiKey;
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
const BEARER_SCHEME: &str = "bearer";
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
const KEY_SCHEME: &str = "key";
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
///
/// The value is accepted in three forms:
///
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
/// - `<api_key>` (AWS-style — the bare key, no scheme)
///
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
/// a scheme with no credential.
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
let trimmed = header_value.trim();
if trimmed.is_empty() {
return None;
}
let key = match trimmed.split_once(char::is_whitespace) {
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
Some((scheme, rest)) => {
if is_supported_scheme(scheme) {
let key = rest.trim();
if key.is_empty() {
// A supported scheme with no credential.
return None;
}
key
} else {
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
// contains whitespace, so a multi-token value we don't recognize is
// not a usable key.
return None;
}
}
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
// just a bare scheme keyword (a malformed header carrying no credential).
None => {
if is_supported_scheme(trimmed) {
return None;
}
trimmed
}
};
Some(ArtcraftApiKey::new_from_str(key))
}
fn is_supported_scheme(word: &str) -> bool {
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
fn parsed(header_value: &str) -> Option<String> {
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
assert_eq!(parsed("key abc"), Some("abc".to_string()));
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parsed("abc"), Some("abc".to_string()));
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parsed(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parsed(""), None);
assert_eq!(parsed(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parsed("Bearer"), None);
assert_eq!(parsed("Bearer "), None);
assert_eq!(parsed("Key"), None);
assert_eq!(parsed("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
assert_eq!(parsed("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parsed("abc def"), None);
}
#[test]
fn returns_an_artcraft_api_key() {
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
}
}
@@ -20,7 +20,6 @@ path = "src/lib.rs"
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_keys.workspace = true
bucket_paths.workspace = true
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
enums = { workspace = true, features = ["database"] }
@@ -2,7 +2,6 @@ use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_keys::ArtcraftApiKey;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
@@ -14,7 +13,7 @@ where
pub ip_address: &'e str,
pub name: &'e str,
pub maybe_description: Option<&'e str>,
pub api_key: &'e ArtcraftApiKey,
pub api_key: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
@@ -45,7 +44,7 @@ SET
created_at = NOW()
"#,
token.as_str(),
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
args.api_key,
args.name,
args.maybe_description,
args.owner_user_token.as_str(),
@@ -36,7 +36,6 @@ actix_artcraft.workspace = true
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
actix_helpers.workspace = true
artcraft_api_defs.workspace = true
artcraft_api_keys.workspace = true
artcraft_router.workspace = true
bootstrap.workspace = true
bucket_paths.workspace = true
@@ -86,8 +86,6 @@ pub async fn create_api_key_handler(
Ok(Json(CreateApiKeySuccessResponse {
success: true,
api_key_token: token,
// Return the full secret value (never the redacted Debug/Display form). This is the only
// time it is ever exposed.
api_key: api_key.to_string_be_careful(), // NB: This is okay in this case
api_key,
}))
}
@@ -1,144 +0,0 @@
use actix_http::header::{HeaderMap, HeaderName};
use actix_web::HttpRequest;
use artcraft_api_keys::ArtcraftApiKey;
const AUTHORIZATION_HEADER_NAME: HeaderName = HeaderName::from_static("authorization");
/// Extract an [`ArtcraftApiKey`] from a request's `Authorization` header.
///
/// The header name is matched case-insensitively (HTTP header names always are). The value parsing
/// — the `Bearer <api_key>`, `Key <api_key>`, and bare `<api_key>` forms — lives in the
/// [`artcraft_api_keys`] crate. Returns `None` if the header is absent, unreadable (non-ASCII
/// bytes), or does not contain a usable key.
pub fn get_authorization_header_api_key(http_request: &HttpRequest) -> Option<ArtcraftApiKey> {
let header_map: &HeaderMap = http_request.headers();
let header_value = header_map.get(AUTHORIZATION_HEADER_NAME)?
.to_str()
.ok()?;
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
}
#[cfg(test)]
mod tests {
use actix_web::test::TestRequest;
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
mod request_header_tests {
use super::*;
#[test]
fn bearer_scheme() {
assert_eq!(
api_key_for_authorization_header(&format!("Bearer {SAMPLE_KEY}")),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn key_scheme() {
assert_eq!(
api_key_for_authorization_header(&format!("Key {SAMPLE_KEY}")),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(
api_key_for_authorization_header(SAMPLE_KEY),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn header_name_is_case_insensitive() {
let value = format!("Bearer {SAMPLE_KEY}");
for header_name in ["authorization", "Authorization", "AUTHORIZATION"] {
let http_request = TestRequest::default()
.insert_header((header_name, value.as_str()))
.to_http_request();
assert_eq!(
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful()),
Some(SAMPLE_KEY.to_string()),
"failed for header name {header_name:?}");
}
}
#[test]
fn missing_header_returns_none() {
let http_request = TestRequest::default().to_http_request();
assert_eq!(get_authorization_header_api_key(&http_request), None);
}
}
mod parsing_tests {
use super::*;
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parse_authorization_header_api_key("Bearer abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("bearer abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("BEARER abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parse_authorization_header_api_key("Key abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("key abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parse_authorization_header_api_key("abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parse_authorization_header_api_key(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parse_authorization_header_api_key(""), None);
assert_eq!(parse_authorization_header_api_key(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parse_authorization_header_api_key("Bearer"), None);
assert_eq!(parse_authorization_header_api_key("Bearer "), None);
assert_eq!(parse_authorization_header_api_key("Key"), None);
assert_eq!(parse_authorization_header_api_key("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parse_authorization_header_api_key("Basic dXNlcjpwYXNz"), None);
assert_eq!(parse_authorization_header_api_key("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parse_authorization_header_api_key("abc def"), None);
}
}
// Thin string-returning adapters over the real implementation so the original test cases above
// can stay unchanged after the parser moved into the `artcraft_api_keys` crate.
fn parse_authorization_header_api_key(header_value: &str) -> Option<String> {
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
.map(|key| key.to_string_be_careful())
}
fn api_key_for_authorization_header(value: &str) -> Option<String> {
let http_request = TestRequest::default()
.insert_header(("Authorization", value))
.to_http_request();
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful())
}
}
@@ -1,6 +1,5 @@
pub mod bucket_urls;
pub mod filter_model_name;
pub mod get_authorization_header_api_key;
pub mod get_host_header;
pub mod get_request_platform_type;
pub mod handle_multipart_error;
@@ -1,7 +1,5 @@
use rand::Rng;
use artcraft_api_keys::ArtcraftApiKey;
/// All Artcraft API keys begin with this prefix. It counts toward the total key length.
///
/// The underscores in this literal are the ONLY underscores permitted in a key — the random
@@ -29,10 +27,7 @@ const ENTROPY_CHAR_COUNT: usize = 40;
///
/// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded
/// generator), which never fails.
///
/// The full secret lives inside the returned [`ArtcraftApiKey`]; use `as_str()` to obtain it for
/// storage (`Debug`/`Display` deliberately redact it).
pub fn generate_api_key() -> ArtcraftApiKey {
pub fn generate_api_key() -> String {
let mut rng = rand::rng();
let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
@@ -43,7 +38,7 @@ pub fn generate_api_key() -> ArtcraftApiKey {
key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char);
}
ArtcraftApiKey(key)
key
}
#[cfg(test)]
@@ -54,19 +49,19 @@ mod tests {
#[test]
fn starts_with_prefix() {
assert!(generate_api_key().as_str_be_careful().starts_with("artcraft_api_"));
assert!(generate_api_key().as_str_be_careful().starts_with(API_KEY_PREFIX));
assert!(generate_api_key().starts_with("artcraft_api_"));
assert!(generate_api_key().starts_with(API_KEY_PREFIX));
}
#[test]
fn has_expected_total_length() {
assert_eq!(generate_api_key().as_str_be_careful().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
}
#[test]
fn entropy_suffix_is_crockford_lowercase() {
let key = generate_api_key();
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT);
for byte in suffix.bytes() {
@@ -84,21 +79,21 @@ mod tests {
fn forbidden_characters_are_absent() {
let key = generate_api_key();
for forbidden in ['.', '+', '/'] {
assert!(!key.as_str_be_careful().contains(forbidden), "key must not contain {:?}", forbidden);
assert!(!key.contains(forbidden), "key must not contain {:?}", forbidden);
}
}
#[test]
fn underscores_only_appear_in_the_prefix() {
let key = generate_api_key();
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
assert!(!suffix.contains('_'), "the random body must not contain underscores");
}
#[test]
fn entropy_suffix_is_alphanumeric() {
let key = generate_api_key();
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
}
@@ -1,567 +0,0 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSpinnerThird,
faCopy,
faCheck,
faKey,
faTrash,
} from "@fortawesome/pro-solid-svg-icons";
import {
UserApiKeysApi,
type ApiKeyInfo,
type CreatedApiKey,
type UserInfo,
} from "@storyteller/api";
import { toast } from "../toast/toast";
const NAME_MAX = 255;
const DESCRIPTION_MAX = 512;
const PAGE_SIZE = 20;
const INPUT_CLASS =
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
interface ApiKeySectionProps {
user: UserInfo;
}
export function ApiKeySection(_props: ApiKeySectionProps) {
const [api] = useState(() => new UserApiKeysApi());
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
// Rows fetched so far (including deleted ones we hide), drives the next
// offset. The backend returns no total count, so we offer "Load more" while
// the last page came back full.
const [fetchedCount, setFetchedCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [creating, setCreating] = useState(false);
const [editingToken, setEditingToken] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<ApiKeyInfo | null>(null);
// The full secret, shown exactly once right after a key is created.
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
// `reset` reloads from offset 0; otherwise appends the next page.
const load = async (reset: boolean) => {
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
const offset = reset ? 0 : fetchedCount;
const response = await api.ListApiKeys({ limit: PAGE_SIZE, offset });
setLoading(false);
setLoadingMore(false);
if (response.success && response.data) {
const fetched = response.data.api_keys;
// The list includes soft-deleted keys; only show live ones.
const live = fetched.filter((k) => k.maybe_deleted_at === null);
setKeys((prev) => (reset ? live : [...prev, ...live]));
setFetchedCount((prev) => (reset ? fetched.length : prev + fetched.length));
setHasMore(fetched.length === PAGE_SIZE);
} else {
toast.error(response.errorMessage ?? "Could not load API keys.");
}
};
useEffect(() => {
load(true);
// Load once on mount; pagination drives subsequent loads explicitly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCreated = (created: CreatedApiKey) => {
setCreating(false);
// Reveal the full secret once; the truncated key then appears in the list.
setCreatedKey(created);
load(true);
};
const handleDeleteConfirmed = async () => {
if (!pendingDelete) return;
const target = pendingDelete;
setPendingDelete(null);
const response = await api.DeleteApiKey({ token: target.token });
if (response.success) {
toast.success("API key deleted.");
// Drop it locally so the list updates without a full reload.
setKeys((prev) => prev.filter((k) => k.token !== target.token));
} else {
toast.error(response.errorMessage ?? "Could not delete API key.");
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">API keys</p>
<p className="text-xs opacity-70">
Create and manage keys to access the API programmatically.
</p>
</div>
{!creating && (
<Button
type="button"
variant="primary"
className="h-9 px-3 shrink-0"
onClick={() => setCreating(true)}
>
Create key
</Button>
)}
</div>
{createdKey && (
<NewKeyReveal
created={createdKey}
onDismiss={() => setCreatedKey(null)}
/>
)}
{creating && (
<CreateKeyForm
api={api}
onCreated={handleCreated}
onCancel={() => setCreating(false)}
/>
)}
<hr className="border-ui-panel-border" />
{loading ? (
<div className="py-6 text-center text-xs opacity-60">
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
</div>
) : keys.length === 0 ? (
<div className="py-6 text-center text-xs opacity-60">
You don't have any API keys yet.
</div>
) : (
<div className="flex flex-col gap-3">
{keys.map((item) => (
<ApiKeyRow
key={item.token}
api={api}
item={item}
isEditing={editingToken === item.token}
onOpenEdit={() => setEditingToken(item.token)}
onCloseEdit={() => setEditingToken(null)}
onUpdated={(updated) => {
setEditingToken(null);
setKeys((prev) =>
prev.map((k) => (k.token === updated.token ? updated : k)),
);
}}
onRequestDelete={() => setPendingDelete(item)}
/>
))}
</div>
)}
{hasMore && (
<div className="flex justify-center pt-1">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
disabled={loadingMore}
onClick={() => load(false)}
>
{loadingMore ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Load more"
)}
</Button>
</div>
)}
<DeleteConfirmModal
item={pendingDelete}
onCancel={() => setPendingDelete(null)}
onConfirm={handleDeleteConfirmed}
/>
</div>
);
}
function CreateKeyForm({
api,
onCreated,
onCancel,
}: {
api: UserApiKeysApi;
onCreated: (created: CreatedApiKey) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const trimmedName = name.trim();
const canSubmit = trimmedName.length > 0;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit) {
setError("Enter a name for the key.");
return;
}
setSubmitting(true);
const trimmedDescription = description.trim();
const response = await api.CreateApiKey({
name: trimmedName,
maybeDescription:
trimmedDescription.length > 0 ? trimmedDescription : undefined,
});
setSubmitting(false);
if (response.success && response.data) {
toast.success("API key created.");
onCreated(response.data);
} else {
setError(response.errorMessage ?? "Could not create API key.");
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (required)"
maxLength={NAME_MAX}
autoFocus
inputClassName={INPUT_CLASS}
/>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
maxLength={DESCRIPTION_MAX}
inputClassName={INPUT_CLASS}
/>
{error && <FormError message={error} />}
<FormActions
onCancel={onCancel}
submitting={submitting}
disabled={!canSubmit}
submitLabel="Create"
/>
</form>
);
}
function NewKeyReveal({
created,
onDismiss,
}: {
created: CreatedApiKey;
onDismiss: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(created.api_key);
setCopied(true);
toast.success("Copied to clipboard.");
} catch {
toast.error("Could not copy. Select and copy it manually.");
}
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<FontAwesomeIcon icon={faKey} className="opacity-80" />
<span>API key created</span>
</div>
<p className="text-xs opacity-80">
Copy your key now. For security, you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
{created.api_key}
</code>
<Button
type="button"
variant="secondary"
className="h-9 px-3 shrink-0"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1.5" />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<div className="flex justify-end pt-0.5">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
onClick={onDismiss}
>
Done
</Button>
</div>
</div>
);
}
function ApiKeyRow({
api,
item,
isEditing,
onOpenEdit,
onCloseEdit,
onUpdated,
onRequestDelete,
}: {
api: UserApiKeysApi;
item: ApiKeyInfo;
isEditing: boolean;
onOpenEdit: () => void;
onCloseEdit: () => void;
onUpdated: (updated: ApiKeyInfo) => void;
onRequestDelete: () => void;
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-ui-panel-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{item.name}</p>
{item.maybe_description && (
<p className="truncate text-xs opacity-70">
{item.maybe_description}
</p>
)}
<div className="flex items-center gap-2 pt-1">
{/* Truncated, display-only — the full secret is only shown on create. */}
<code className="min-w-0 truncate rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-white/70">
{item.truncated_api_key}
</code>
<span className="text-[10px] uppercase tracking-wider text-white/40">
{formatDate(item.created_at)}
</span>
</div>
</div>
{!isEditing && (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
onClick={onOpenEdit}
>
Edit
</Button>
<Button
type="button"
variant="secondary"
className="h-8 px-2.5 text-red-400"
onClick={onRequestDelete}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</div>
)}
</div>
{isEditing && (
<EditDescriptionForm
api={api}
item={item}
onUpdated={onUpdated}
onCancel={onCloseEdit}
/>
)}
</div>
);
}
function EditDescriptionForm({
api,
item,
onUpdated,
onCancel,
}: {
api: UserApiKeysApi;
item: ApiKeyInfo;
onUpdated: (updated: ApiKeyInfo) => void;
onCancel: () => void;
}) {
const initial = item.maybe_description ?? "";
const [value, setValue] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const trimmed = value.trim();
const isDirty = trimmed !== initial.trim();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!isDirty) {
onCancel();
return;
}
setSubmitting(true);
const nextDescription = trimmed.length > 0 ? trimmed : null;
const response = await api.UpdateApiKey({
token: item.token,
maybeDescription: nextDescription,
});
setSubmitting(false);
if (response.success) {
toast.success("API key updated.");
// Update endpoint returns only { success }; merge the new description in.
onUpdated({ ...item, maybe_description: nextDescription });
} else {
setError(response.errorMessage ?? "Could not update API key.");
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 pt-1">
<Input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Description (optional)"
maxLength={DESCRIPTION_MAX}
autoFocus
inputClassName={INPUT_CLASS}
/>
{error && <FormError message={error} />}
<FormActions
onCancel={onCancel}
submitting={submitting}
disabled={!isDirty}
submitLabel="Save"
/>
</form>
);
}
function DeleteConfirmModal({
item,
onCancel,
onConfirm,
}: {
item: ApiKeyInfo | null;
onCancel: () => void;
onConfirm: () => void;
}) {
const [deleting, setDeleting] = useState(false);
const handleConfirm = async () => {
setDeleting(true);
await onConfirm();
setDeleting(false);
};
return (
<Modal isOpen={item !== null} onClose={onCancel} className="max-w-sm">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">Delete API key</h3>
<p className="text-sm opacity-70">
Delete "{item?.name}"? Any application using this key will stop
working. This can't be undone.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
className="h-9 px-3"
onClick={onCancel}
disabled={deleting}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
className="h-9 px-4 bg-red-500 hover:bg-red-600"
onClick={handleConfirm}
disabled={deleting}
>
{deleting ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Delete"
)}
</Button>
</div>
</div>
</Modal>
);
}
function FormActions({
onCancel,
submitting,
disabled,
submitLabel,
}: {
onCancel: () => void;
submitting: boolean;
disabled: boolean;
submitLabel: string;
}) {
return (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
variant="secondary"
className="h-9 px-3"
onClick={onCancel}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
className="h-9 px-4"
disabled={submitting || disabled}
>
{submitting ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
submitLabel
)}
</Button>
</div>
);
}
function FormError({ message }: { message: string }) {
return <p className="text-xs text-red-400 leading-tight">{message}</p>;
}
function formatDate(raw: string): string {
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
@@ -1,65 +1,36 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons";
import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { USER_FEATURE_FLAGS } from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
type Tab = "general" | "account" | "apiKeys";
type Tab = "general" | "account";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
{ id: "general", label: "General", icon: faCog },
{ id: "account", label: "Account", icon: faUser },
];
const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = {
id: "apiKeys",
label: "API Keys",
icon: faKey,
};
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { user } = useSession();
const [tab, setTab] = useState<Tab>("general");
const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.API_KEY,
);
const tabs = useMemo(
() => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS),
[hasApiKeyFlag],
);
useEffect(() => {
if (isOpen) setTab("general");
}, [isOpen]);
// If the only-conditional tab disappears (e.g. flag revoked while open),
// fall back to a visible tab.
useEffect(() => {
if (!tabs.some((t) => t.id === tab)) setTab("general");
}, [tabs, tab]);
const activeLabel = tabs.find((t) => t.id === tab)?.label ?? "";
const activeLabel = TABS.find((t) => t.id === tab)?.label ?? "";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl" childPadding={false}>
<div className="h-[100dvh] sm:h-[560px]">
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
@@ -68,7 +39,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
{tabs.map((t) => (
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
@@ -93,7 +64,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
{tab === "general" && <GeneralPanel />}
{tab === "account" && <AccountPanel />}
{tab === "apiKeys" && <ApiKeysPanel />}
</div>
</div>
</div>
@@ -112,16 +82,12 @@ function GeneralPanel() {
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter adds a
new line. When off (default), both Enter and Shift+Enter add a new
line, use the button to submit.
When on, pressing Enter submits the prompt and Shift+Enter
adds a new line. When off (default), both Enter and
Shift+Enter add a new line, use the button to submit.
</p>
</div>
<Switch
enabled={enterToGenerate}
setEnabled={setEnterToGenerate}
offClassName="bg-white/20"
/>
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
</div>
</div>
);
@@ -150,25 +116,3 @@ function AccountPanel() {
</div>
);
}
function ApiKeysPanel() {
const { user, authChecked } = useSession();
if (!authChecked) {
return <div className="pt-3 text-xs opacity-60">Loading API keys...</div>;
}
if (!user) {
return (
<div className="pt-3 text-xs opacity-60">
You need to be signed in to manage API keys.
</div>
);
}
return (
<div className="pt-3">
<ApiKeySection user={user} />
</div>
);
}
-1
View File
@@ -17,7 +17,6 @@ export * from "./lib/PromptsApi.js";
export * from "./lib/TtsApi.js";
export * from "./lib/UserBookmarksApi.js";
export * from "./lib/PasswordResetApi.js";
export * from "./lib/UserApiKeysApi.js";
export * from "./lib/UserReferralCodesApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/VideoApi.js";
-197
View File
@@ -1,197 +0,0 @@
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js";
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
// Canonical wire shape for an API key (list rows + single-key GET). Mirrors the
// backend `ApiKeyInfo`. The full secret is NEVER here — only
// `truncated_api_key` (first 20 chars). The full value is returned exactly once
// at creation time (see CreatedApiKey).
export interface ApiKeyInfo {
token: string;
truncated_api_key: string;
name: string;
maybe_description: string | null;
owner_user_token: string;
ip_address_creation: string;
ip_address_update: string;
created_at: string;
updated_at: string;
// Soft-delete timestamp; null means live. The list includes deleted keys, so
// the UI filters these out.
maybe_deleted_at: string | null;
}
// Create response: the `api_key_token` (used for all subsequent management) plus
// the full secret `api_key`, returned exactly once.
export interface CreatedApiKey {
api_key_token: string;
api_key: string;
}
interface ErrorBody {
success: boolean;
error_code?: number;
error_code_str?: string;
message?: string;
}
export class UserApiKeysApi extends ApiManager {
public ListApiKeys({
limit,
offset,
}: {
limit?: number;
offset?: number;
} = {}): Promise<ApiResponse<{ api_keys: ApiKeyInfo[] }>> {
const query = new URLSearchParams();
if (limit !== undefined) query.set("limit", String(limit));
if (offset !== undefined) query.set("offset", String(offset));
const suffix = query.toString() ? `?${query.toString()}` : "";
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/list${suffix}`;
return this.jsonFetch<{
success: boolean;
api_keys?: ApiKeyInfo[];
} & ErrorBody>(endpoint, { method: "GET" })
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return {
success: true,
data: { api_keys: response.api_keys ?? [] },
};
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Single-key lookup. Not currently used by the settings UI (the list already
// carries everything), but provided for parity with the backend.
public GetApiKey({
token,
}: {
token: string;
}): Promise<ApiResponse<ApiKeyInfo>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<
{ success: boolean; api_key?: ApiKeyInfo } & ErrorBody
>(endpoint, { method: "GET" })
.then((response) => {
if (!response.success || !response.api_key) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true, data: response.api_key };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public CreateApiKey({
name,
maybeDescription,
}: {
name: string;
maybeDescription?: string;
}): Promise<ApiResponse<CreatedApiKey>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/create`;
return this.jsonFetch<
{
success: boolean;
api_key_token?: string;
api_key?: string;
} & ErrorBody
>(endpoint, {
method: "POST",
body: { name, maybe_description: maybeDescription ?? null },
})
.then((response) => {
if (!response.success || !response.api_key_token || !response.api_key) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return {
success: true,
data: {
api_key_token: response.api_key_token,
api_key: response.api_key,
},
};
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public UpdateApiKey({
token,
maybeDescription,
}: {
token: string;
maybeDescription: string | null;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
method: "PUT",
body: { maybe_description: maybeDescription },
})
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public DeleteApiKey({ token }: { token: string }): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
method: "DELETE",
})
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Parses JSON for both 2xx and 4xx so `BadInputWithSimpleMessage` text
// reaches the caller. The base ApiManager.fetch throws on non-2xx.
private async jsonFetch<T>(
endpoint: string,
{ method, body }: { method: string; body?: unknown },
): Promise<T> {
const response = await fetch(endpoint, {
method,
headers: buildSessionHeaders({
Accept: "application/json",
"Content-Type": "application/json",
}),
credentials: "include",
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
throw new Error(text || `Request failed with status ${response.status}`);
}
}
private statusFallback(response: ErrorBody): string {
if (response.error_code_str) return response.error_code_str;
if (response.error_code) return `Request failed (${response.error_code})`;
return "Request failed";
}
}
@@ -1,5 +1,4 @@
export enum USER_FEATURE_FLAGS {
API_KEY = "api_key",
EXPLORE_MEDIA = "explore_media",
REFERRALS = "referrals",
STUDIO = "studio",