Compare commits

..

3 Commits

Author SHA1 Message Date
jacobh2 ac17b41a3e install.sh: the NPM proxy host is README step 6
Build Kvitt images / build (push) Failing after 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
jacobh2 e381e371ea contracts-check: OpenAPI header expects the {host} server variable (D-123)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
jacobh2 c9abd24c77 Server: no built-in public host, no trusted proxy by default; OpenAPI server variable host (D-123)
KVITT_PUBLIC_URL is required: every command refuses to start without it (compose
always sets it). KVITT_TRUSTED_PROXY defaults to none, which the rate limiter never
resolves. contracts/openapi.yaml and the utoipa document: servers[].url
https://{host}:8443 with variable host (default kvitt.example.invalid). E-7 and E-12
examples on kvitt.example.invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
15 changed files with 171 additions and 42 deletions
@@ -3,7 +3,7 @@
"kind": "partner",
"target_user_id": null,
"code": "4N8RVT2KW6ZC",
"link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC?from=Jacob",
"link": "https://kvitt.example.invalid:8443/i/4N8RVT2KW6ZC?from=Jacob",
"created_at": "2026-10-17T06:30:02.004Z",
"expires_at": "2026-10-20T06:30:02.004Z"
}
+1 -1
View File
@@ -39,5 +39,5 @@
"server_version": 16
}
],
"public_url": "https://icehack.asuscomm.com:8443"
"public_url": "https://kvitt.example.invalid:8443"
}
+5 -1
View File
@@ -23,7 +23,11 @@ info:
`protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`,
`internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4.
servers:
- url: https://icehack.asuscomm.com:8443
- url: https://{host}:8443
variables:
host:
default: kvitt.example.invalid
description: the owner-chosen Kvitt host name, SPEC §10.5
security:
- device: []
+2 -2
View File
@@ -166,6 +166,6 @@ compose ps
print_health || ok=0
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
if ! print_proxy_health; then
say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 7)."
say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 6)."
fi
say "Kvitt is running. Next: README step 7 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."
say "Kvitt is running. Next: README step 6 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."
+2 -2
View File
@@ -30,10 +30,10 @@ Configuration is environment variables only (SPEC §10.4, exhaustive):
|---|---|---|
| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite` (+ `-wal`, `-shm`), `pictures/`, `apk/`. Created if missing. |
| `KVITT_BIND` | `0.0.0.0:8080` | Listen address. |
| `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Base URL in invite links and the landing page. |
| `KVITT_PUBLIC_URL` | none: required | Base URL in invite links and the landing page, `https://<KVITT_PUBLIC_HOST>:8443` on the Pi (set by compose). Every command refuses to start without it. |
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. |
| `KVITT_TRUSTED_PROXY` | `caddy` | Host allowed to set `X-Forwarded-For`; a name that does not resolve means no proxy. |
| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. |
| `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. |
The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR`
@@ -20,8 +20,9 @@
//! on restart; there is no table.
//!
//! The client IP is the TCP peer (axum `ConnectInfo<SocketAddr>`), or the last `X-Forwarded-For` entry when the peer
//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most
//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without
//! is an address of `KVITT_TRUSTED_PROXY` (Nginx Proxy Manager, SPEC §8.5). The host name is resolved lazily and
//! re-resolved at most once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted
//! (D-022), and so does the default `none` ([`NO_TRUSTED_PROXY`]), which is never looked up. Without
//! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket.
use std::collections::HashMap;
@@ -39,6 +40,7 @@ use tower::{Layer, Service};
use crate::audit::BoxFuture;
use crate::auth::{bearer_token, is_token_shaped, sha256_hex};
use crate::config::NO_TRUSTED_PROXY;
use crate::error::AppError;
use crate::time::Clock;
use crate::{AppState, lock};
@@ -214,7 +216,7 @@ impl Limiter {
return addrs.clone();
}
let name = self.trusted_proxy.trim();
let addrs: Vec<IpAddr> = if name.is_empty() {
let addrs: Vec<IpAddr> = if name.is_empty() || name.eq_ignore_ascii_case(NO_TRUSTED_PROXY) {
Vec::new()
} else if let Ok(ip) = name.parse::<IpAddr>() {
vec![ip]
@@ -463,6 +465,46 @@ mod tests {
);
}
fn limiter_trusting(trusted_proxy: &str) -> Limiter {
let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap();
Limiter::new(Arc::new(FixedClock::new(now)), trusted_proxy.to_owned())
}
#[tokio::test]
async fn the_default_none_trusts_no_peer() {
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
for value in [crate::config::DEFAULT_TRUSTED_PROXY, " NONE ", ""] {
let limiter = limiter_trusting(value);
assert_eq!(
limiter.proxy_addrs().await,
Vec::<IpAddr>::new(),
"{value:?}"
);
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
}
#[tokio::test]
async fn an_unresolvable_name_trusts_no_peer() {
// `.invalid` never resolves (RFC 6761).
let limiter = limiter_trusting("proxy.kvitt.invalid");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
#[tokio::test]
async fn an_ip_literal_is_trusted() {
let limiter = limiter_trusting("192.168.1.20");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(
limiter.client_ip(Some(peer), Some(forwarded)).await,
forwarded
);
}
fn token_key(i: u32) -> Key {
Key::Token {
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
+56 -19
View File
@@ -9,14 +9,16 @@ use crate::error::AppError;
pub const DEFAULT_DATA_DIR: &str = "/data";
/// Default of `KVITT_BIND`.
pub const DEFAULT_BIND: &str = "0.0.0.0:8080";
/// Default of `KVITT_PUBLIC_URL`.
pub const DEFAULT_PUBLIC_URL: &str = "https://icehack.asuscomm.com:8443";
/// Default of `KVITT_FCM_CREDENTIALS`.
pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json";
/// Default of `KVITT_FCM_PROJECT_ID`.
pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337";
/// Default of `KVITT_TRUSTED_PROXY`.
pub const DEFAULT_TRUSTED_PROXY: &str = "caddy";
/// Default of `KVITT_TRUSTED_PROXY`: no peer is trusted to set `X-Forwarded-For` (the rate limiter never resolves
/// this value). `KVITT_PUBLIC_URL` has no default: the Kvitt host name is owner-chosen (SPEC §10.5), so the server
/// refuses to start without it.
pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY;
/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space).
pub const NO_TRUSTED_PROXY: &str = "none";
/// Default of `RUST_LOG`.
pub const DEFAULT_LOG_FILTER: &str = "info";
@@ -33,14 +35,15 @@ pub struct Config {
pub data_dir: PathBuf,
/// `KVITT_BIND`: listen address.
pub bind: SocketAddr,
/// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Stored without a trailing slash.
/// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Required (no default). Stored without a
/// trailing slash.
pub public_url: String,
/// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing.
pub fcm_credentials: PathBuf,
/// `KVITT_FCM_PROJECT_ID`.
pub fcm_project_id: String,
/// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the
/// rate limiter; an unresolvable name means "no proxy".
/// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy".
pub trusted_proxy: String,
}
@@ -51,13 +54,10 @@ impl Config {
}
/// Reads the variables through `lookup` (testable without touching the process environment). Empty values count
/// as unset.
/// as unset. A missing `KVITT_PUBLIC_URL` is an error.
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AppError> {
let get = |key: &str, default: &str| {
lookup(key)
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| default.to_owned())
};
let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty());
let get = |key: &str, default: &str| set(key).unwrap_or_else(|| default.to_owned());
let bind_text = get("KVITT_BIND", DEFAULT_BIND);
let bind: SocketAddr = bind_text.parse().map_err(|e| {
AppError::new(
@@ -65,12 +65,17 @@ impl Config {
format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"),
)
})?;
let public_url = set("KVITT_PUBLIC_URL").ok_or_else(|| {
AppError::new(
crate::error::ErrorCode::Internal,
"KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \
https://<KVITT_PUBLIC_HOST>:8443 (SPEC §10.4)",
)
})?;
Ok(Self {
data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)),
bind,
public_url: get("KVITT_PUBLIC_URL", DEFAULT_PUBLIC_URL)
.trim_end_matches('/')
.to_owned(),
public_url: public_url.trim().trim_end_matches('/').to_owned(),
fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)),
fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID),
trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY),
@@ -97,18 +102,25 @@ impl Config {
mod tests {
use super::*;
const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443";
#[test]
fn defaults_when_unset_or_empty() {
let config = Config::from_lookup(|k| (k == "KVITT_BIND").then(String::new)).unwrap();
let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" | "KVITT_TRUSTED_PROXY" => Some(String::new()),
_ => None,
})
.unwrap();
assert_eq!(config.data_dir, PathBuf::from("/data"));
assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap());
assert_eq!(config.public_url, "https://icehack.asuscomm.com:8443");
assert_eq!(config.public_url, PUBLIC_URL);
assert_eq!(
config.fcm_credentials,
PathBuf::from("/secrets/fcm-service-account.json")
);
assert_eq!(config.fcm_project_id, "kvitt-1337");
assert_eq!(config.trusted_proxy, "caddy");
assert_eq!(config.trusted_proxy, "none");
assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite"));
}
@@ -118,6 +130,7 @@ mod tests {
"KVITT_DATA_DIR" => Some("/tmp/kvitt".into()),
"KVITT_BIND" => Some("127.0.0.1:9000".into()),
"KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()),
"KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()),
_ => None,
})
.unwrap();
@@ -127,10 +140,34 @@ mod tests {
);
assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap());
assert_eq!(config.public_url, "http://192.168.1.10:8080");
assert_eq!(config.trusted_proxy, "192.168.1.20");
}
#[test]
fn public_url_is_required() {
for value in [None, Some(""), Some(" ")] {
let error = Config::from_lookup(|k| {
(k == "KVITT_PUBLIC_URL")
.then_some(value)
.flatten()
.map(str::to_owned)
})
.unwrap_err();
assert!(
error.message.contains("KVITT_PUBLIC_URL"),
"{}",
error.message
);
}
}
#[test]
fn bad_bind_is_an_error() {
assert!(Config::from_lookup(|k| (k == "KVITT_BIND").then(|| "nope".into())).is_err());
let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" => Some("nope".into()),
_ => None,
});
assert!(config.is_err());
}
}
+21 -4
View File
@@ -23,7 +23,7 @@ use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn};
use utoipa::openapi::response::{Response, ResponseBuilder};
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme};
use utoipa::openapi::server::Server;
use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
use utoipa::openapi::{Content, Ref, RefOr, Required};
use utoipa::{Modify, OpenApi};
@@ -35,8 +35,15 @@ const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household
timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \
strings. Common rules: SPEC §14.3.1.";
/// The production base URL (SPEC §7).
pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443";
/// The production base URL (SPEC §7, §14.3): the host is the server variable [`HOST_VARIABLE`], because the Kvitt
/// host name is owner-chosen (SPEC §10.5).
pub const PUBLIC_SERVER_URL: &str = "https://{host}:8443";
/// The server variable in [`PUBLIC_SERVER_URL`].
pub const HOST_VARIABLE: &str = "host";
/// The default of [`HOST_VARIABLE`]: a reserved name that never resolves (RFC 6761), not a real host.
pub const HOST_VARIABLE_DEFAULT: &str = "kvitt.example.invalid";
/// The description of [`HOST_VARIABLE`].
const HOST_VARIABLE_DESCRIPTION: &str = "the owner-chosen Kvitt host name, SPEC §10.5";
const JSON: &str = api::JSON_CONTENT_TYPE;
const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
@@ -191,7 +198,17 @@ impl Modify for CommonRules {
openapi.info.description = Some(INFO_DESCRIPTION.to_owned());
openapi.info.license = None;
openapi.info.contact = None;
openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]);
openapi.servers = Some(vec![
ServerBuilder::new()
.url(PUBLIC_SERVER_URL)
.parameter(
HOST_VARIABLE,
ServerVariableBuilder::new()
.default_value(HOST_VARIABLE_DEFAULT)
.description(Some(HOST_VARIABLE_DESCRIPTION)),
)
.build(),
]);
openapi.security = Some(vec![SecurityRequirement::new(
"device",
Vec::<String>::new(),
@@ -22,6 +22,8 @@ use sqlx::SqliteConnection;
use tower::ServiceExt;
use uuid::Uuid;
/// `KVITT_PUBLIC_URL` of the harness, on the reserved `.invalid` name of the examples (E-7, E-12).
pub const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443";
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001";
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002";
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001";
@@ -151,6 +153,7 @@ impl TestApp {
let path = dir.0.to_string_lossy().into_owned();
let config = Config::from_lookup(|k| match k {
"KVITT_DATA_DIR" => Some(path.clone()),
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.to_owned()),
// No proxy is trusted unless a test says so.
"KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()),
_ => None,
@@ -39,6 +39,30 @@ fn printed_code(output: &Output) -> String {
code.to_owned()
}
#[test]
fn every_command_refuses_to_start_without_a_public_url() {
// SPEC §10.4: KVITT_PUBLIC_URL has no built-in host; compose always sets it.
let dir = TempDir::new();
for args in [&["serve"][..], &["bootstrap"], &["migrate"]] {
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.args(args)
.env("KVITT_DATA_DIR", &dir.0)
.env_remove("KVITT_PUBLIC_URL")
.env("RUST_LOG", "warn")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{args:?}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.starts_with("configuration: KVITT_PUBLIC_URL is not set"),
"{args:?}: {stderr}"
);
assert!(output.stdout.is_empty(), "{args:?}");
}
// Nothing was created: the server stopped before opening the database.
assert!(!dir.0.join("kvitt.sqlite").exists());
}
#[tokio::test]
async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses() {
let dir = TempDir::new();
@@ -219,9 +219,7 @@ async fn e12_partner_invite_matches_the_example() {
assert_eq!(code.len(), 12);
assert_eq!(
reply.json["link"],
json!(format!(
"https://icehack.asuscomm.com:8443/i/{code}?from=Jacob"
))
json!(format!("{PUBLIC_URL}/i/{code}?from=Jacob"))
);
assert_eq!(reply.json["created_at"], NOW);
assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z");
@@ -34,9 +34,9 @@ async fn e4_english_by_default_with_both_links_and_the_grouped_code() {
assert!(html.contains(">Open in Kvitt</a>"));
assert!(html.contains("7K3M-QX9D-2HPA"));
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;code=7K3MQX9D2HPA\""
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&amp;code=7K3MQX9D2HPA\""
));
assert!(html.contains("href=\"https://icehack.asuscomm.com:8443/apk/files/kvitt-412.apk\""));
assert!(html.contains("href=\"https://kvitt.example.invalid:8443/apk/files/kvitt-412.apk\""));
}
#[tokio::test]
@@ -90,7 +90,7 @@ async fn e4_passes_the_admin_name_of_an_e12_link_into_the_app_link_only() {
assert_eq!(reply.status, 200);
let html = reply.text();
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
), "{html}");
// The name is not shown on the page itself.
assert!(!html.contains("Åsa"));
@@ -47,8 +47,10 @@ impl TestApp {
async fn migrated_to(target: Option<i64>) -> Self {
let dir = TempDir::new();
let config = Config::from_lookup(|k| {
(k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned())
let config = Config::from_lookup(|k| match k {
"KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()),
"KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid:8443".to_owned()),
_ => None,
})
.unwrap();
let pool = db::connect(&config.database_path()).await.unwrap();
@@ -79,6 +79,7 @@ async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() {
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.arg("rotate-epoch")
.env("KVITT_DATA_DIR", app.data_dir())
.env("KVITT_PUBLIC_URL", PUBLIC_URL)
.env("RUST_LOG", "warn")
.output()
.unwrap();
@@ -96,6 +97,7 @@ fn binary(dir: &std::path::Path, bind: SocketAddr) -> Command {
command
.env("KVITT_DATA_DIR", dir)
.env("KVITT_BIND", bind.to_string())
.env("KVITT_PUBLIC_URL", PUBLIC_URL)
.env("KVITT_FCM_CREDENTIALS", dir.join("no-such-key.json"))
.env("RUST_LOG", "warn");
command
+1 -1
View File
@@ -1471,7 +1471,7 @@ def check_openapi():
lines = f.read().splitlines()
text = "\n".join(lines)
head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M)
and re.search(r"^ version: '1'$", text, re.M) and "url: https://icehack.asuscomm.com:8443" in text)
and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}:8443\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M))
report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3")
sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None
report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found")