feat: implement equivalent domains functionality

- Added support for equivalent domains in user settings, allowing users to define custom domain groups and exclude specific global groups.
- Introduced new migration script to add necessary database columns and a table for global equivalent domains.
- Created a Python script to generate seed SQL for global domains, which can be executed to populate the database.
- Updated API endpoints to handle equivalent domains, including retrieval and persistence of user-defined settings.
- Refactored existing settings handler to remove stubs and implement full functionality for managing equivalent domains.
This commit is contained in:
qaz741wsd856 2025-12-29 09:11:39 +00:00
parent dfa82a0918
commit f179e4a015
12 changed files with 523 additions and 76 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ public/web-vault/
# reference repos
vaultwarden/
bw_web_builds/
# Generated seed SQL (global equivalent domains)
sql/global_domains_seed.sql

View file

@ -0,0 +1,31 @@
-- Migration: Add equivalent domains (eq_domains) functionality
--
-- Part 1: User settings fields for URI matching
-- - equivalent_domains: JSON string of Vec<Vec<String>> (custom groups)
-- - excluded_globals: JSON string of Vec<i32> (excluded global group IDs)
--
-- We keep defaults as "[]", and allow applying this migration multiple times
-- (duplicate column errors are handled gracefully by CI tooling).
--
-- Part 2: Global equivalent domains table
-- Stores the upstream "global domains" dataset (vaultwarden/Bitwarden compatible),
-- but kept OUT of the Worker bundle for size/perf reasons.
--
-- Data is seeded separately (see scripts) and may be refreshed over time.
-- Columns:
-- - type: the global group id (matches vaultwarden's `GlobalDomain.type`)
-- - sort_order: preserve upstream file order for stable client UX
-- - domains_json: JSON string of Vec<String> (domain list)
ALTER TABLE users ADD COLUMN equivalent_domains TEXT NOT NULL DEFAULT '[]';
ALTER TABLE users ADD COLUMN excluded_globals TEXT NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS global_equivalent_domains (
type INTEGER PRIMARY KEY NOT NULL,
sort_order INTEGER NOT NULL,
domains_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_global_equivalent_domains_sort_order
ON global_equivalent_domains(sort_order);

View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Generate a D1/SQLite seed SQL file for Bitwarden/Vaultwarden global equivalent domains.
Why:
- Avoid bundling `global_domains.json` into the Worker (size + parse CPU).
- Store the dataset in D1 and let SQL generate the final JSON payload.
Default source:
- Downloads from Vaultwarden upstream (GitHub raw).
Usage:
python3 scripts/generate-global-domains-seed.py \
--output sql/global_domains_seed.sql
# Use a pinned URL (recommended for reproducible deploys)
python3 scripts/generate-global-domains-seed.py \
--url https://raw.githubusercontent.com/dani-garcia/vaultwarden/<tag-or-commit>/src/static/global_domains.json \
--output sql/global_domains_seed.sql
# Use a local file
python3 scripts/generate-global-domains-seed.py \
--input /path/to/global_domains.json \
--output sql/global_domains_seed.sql
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import sys
import urllib.request
from pathlib import Path
DEFAULT_URL = (
"https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/src/static/global_domains.json"
)
def _read_source(input_path: str | None, url: str | None) -> tuple[str, str]:
if input_path:
p = Path(input_path)
raw = p.read_text(encoding="utf-8")
return raw, str(p)
if not url:
url = DEFAULT_URL
with urllib.request.urlopen(url) as resp:
raw = resp.read().decode("utf-8")
return raw, url
def _sql_quote_single(s: str) -> str:
# SQLite single-quote escaping
return s.replace("'", "''")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--input", help="Local path to global_domains.json")
ap.add_argument(
"--url",
default=None,
help=f"URL to download global_domains.json (default: {DEFAULT_URL})",
)
ap.add_argument(
"--output",
default="sql/global_domains_seed.sql",
help="Output SQL file path (default: sql/global_domains_seed.sql)",
)
ap.add_argument(
"--table",
default="global_equivalent_domains",
help="Target table name (default: global_equivalent_domains)",
)
args = ap.parse_args(argv)
raw, source = _read_source(args.input, args.url)
data = json.loads(raw)
if not isinstance(data, list):
raise SystemExit("Expected top-level JSON array")
rows: list[tuple[int, int, str]] = []
seen_types: set[int] = set()
for idx, item in enumerate(data):
if not isinstance(item, dict):
continue
t = item.get("type")
domains = item.get("domains")
if not isinstance(t, int):
continue
if not isinstance(domains, list) or not all(isinstance(d, str) for d in domains):
continue
if t in seen_types:
raise SystemExit(f"Duplicate global domain type found: {t}")
seen_types.add(t)
domains_json = json.dumps(domains, ensure_ascii=False, separators=(",", ":"))
rows.append((t, idx, domains_json))
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
header = (
f"-- Generated by scripts/generate-global-domains-seed.py at {now}\n"
f"-- Source: {source}\n"
f"-- Table: {args.table}\n"
"\n"
)
# Chunked multi-row INSERT keeps the SQL readable and efficient.
chunk_size = 200
stmts: list[str] = []
for i in range(0, len(rows), chunk_size):
chunk = rows[i : i + chunk_size]
values_sql = ",\n".join(
f"({t},{sort},'{_sql_quote_single(domains_json)}')" for (t, sort, domains_json) in chunk
)
stmts.append(
# D1 does not allow BEGIN/COMMIT in SQL scripts, so we avoid explicit transactions here.
# Use OR REPLACE to keep the operation idempotent without a destructive full-table DELETE.
f"INSERT OR REPLACE INTO {args.table} (type, sort_order, domains_json)\nVALUES\n{values_sql};"
)
# Optional cleanup: remove types that are no longer present upstream.
# If this statement fails, the table will still contain a superset of valid groups (harmless).
type_list = ",".join(str(t) for (t, _, _) in rows)
stmts.append(f"DELETE FROM {args.table} WHERE type NOT IN ({type_list});")
stmts.append("")
out_path.write_text(header + "\n".join(stmts), encoding="utf-8")
print(f"Wrote {len(rows)} global domain groups to {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Seed Vaultwarden/Bitwarden global equivalent domains into Cloudflare D1.
This:
1) Downloads (or reads local) global_domains.json
2) Generates sql/global_domains_seed.sql
3) Executes it against a D1 database via wrangler
Usage:
./scripts/seed-global-domains.sh --db <d1_name> [--env <wrangler_env>] [--remote] [--url <raw_json_url>]
Examples:
./scripts/seed-global-domains.sh --db vault1 --remote
./scripts/seed-global-domains.sh --db vault1 --env dev --remote
./scripts/seed-global-domains.sh --db vault1 --remote \
--url https://raw.githubusercontent.com/dani-garcia/vaultwarden/<tag-or-commit>/src/static/global_domains.json
EOF
}
DB_NAME=""
ENV_NAME=""
REMOTE=0
URL=""
INPUT=""
OUTPUT="sql/global_domains_seed.sql"
while [[ $# -gt 0 ]]; do
case "$1" in
--db)
DB_NAME="${2:-}"; shift 2;;
--env)
ENV_NAME="${2:-}"; shift 2;;
--remote)
REMOTE=1; shift;;
--url)
URL="${2:-}"; shift 2;;
--input)
INPUT="${2:-}"; shift 2;;
--output)
OUTPUT="${2:-}"; shift 2;;
-h|--help)
usage; exit 0;;
*)
echo "Unknown arg: $1" >&2
usage
exit 2;;
esac
done
if [[ -z "$DB_NAME" ]]; then
echo "Missing required --db <d1_name>" >&2
usage
exit 2
fi
GEN_ARGS=(--output "$OUTPUT")
if [[ -n "$INPUT" ]]; then
GEN_ARGS+=(--input "$INPUT")
elif [[ -n "$URL" ]]; then
GEN_ARGS+=(--url "$URL")
fi
python3 scripts/generate-global-domains-seed.py "${GEN_ARGS[@]}"
WRANGLER_ARGS=(d1 execute "$DB_NAME" --file "$OUTPUT")
if [[ -n "$ENV_NAME" ]]; then
WRANGLER_ARGS+=(--env "$ENV_NAME")
fi
if [[ "$REMOTE" -eq 1 ]]; then
WRANGLER_ARGS+=(--remote)
fi
wrangler "${WRANGLER_ARGS[@]}"

View file

@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS users (
kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2
kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2
security_stamp TEXT,
equivalent_domains TEXT NOT NULL DEFAULT '[]', -- JSON: Vec<Vec<String>>
excluded_globals TEXT NOT NULL DEFAULT '[]', -- JSON: Vec<i32> (reserved for future global groups)
totp_recover TEXT, -- Recovery code for 2FA
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@ -89,3 +91,12 @@ CREATE TABLE IF NOT EXISTS folders (
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Global equivalent domains dataset (seeded separately, not bundled into the Worker)
CREATE TABLE IF NOT EXISTS global_equivalent_domains (
type INTEGER PRIMARY KEY NOT NULL,
sort_order INTEGER NOT NULL,
domains_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_global_equivalent_domains_sort_order
ON global_equivalent_domains(sort_order);

View file

@ -263,6 +263,8 @@ pub async fn register(
kdf_memory,
kdf_parallelism,
security_stamp: Uuid::new_v4().to_string(),
equivalent_domains: "[]".to_string(),
excluded_globals: "[]".to_string(),
totp_recover: None,
created_at: now.clone(),
updated_at: now,
@ -270,8 +272,8 @@ pub async fn register(
query!(
&db,
"INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, password_iterations, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, totp_recover, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
"INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, password_iterations, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, equivalent_domains, excluded_globals, totp_recover, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
user.id,
user.name,
user.email,
@ -287,6 +289,8 @@ pub async fn register(
user.kdf_memory,
user.kdf_parallelism,
user.security_stamp,
user.equivalent_domains,
user.excluded_globals,
user.totp_recover,
user.created_at,
user.updated_at

200
src/handlers/domains.rs Normal file
View file

@ -0,0 +1,200 @@
use axum::{extract::State, Json};
use chrono::Utc;
use log::warn;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use worker::{query, Env};
use crate::handlers::ciphers::RawJson;
use crate::{auth::Claims, db, error::AppError};
/// Build `globalEquivalentDomains` JSON (as a raw JSON string) in SQLite/D1.
///
/// - `include_excluded=true` => returns all groups, each with `excluded` boolean (settings UI).
/// - `include_excluded=false` => returns only non-excluded groups, with `excluded=false` (sync payload).
///
/// This keeps the Worker from parsing the large upstream dataset.
pub(crate) async fn global_equivalent_domains_json(
db: &worker::D1Database,
excluded_globals_json: &str,
include_excluded: bool,
) -> String {
let sql = if include_excluded {
r#"
SELECT COALESCE(
(SELECT json_group_array(json(value))
FROM (
SELECT json_object(
'type', g.type,
'domains', json(g.domains_json),
'excluded', CASE WHEN eg.value IS NULL THEN json('false') ELSE json('true') END
) AS value
FROM global_equivalent_domains g
LEFT JOIN json_each(?1) eg ON eg.value = g.type
ORDER BY g.sort_order
)),
'[]'
) AS globals
"#
} else {
r#"
SELECT COALESCE(
(SELECT json_group_array(json(value))
FROM (
SELECT json_object(
'type', g.type,
'domains', json(g.domains_json),
'excluded', json('false')
) AS value
FROM global_equivalent_domains g
LEFT JOIN json_each(?1) eg ON eg.value = g.type
WHERE eg.value IS NULL
ORDER BY g.sort_order
)),
'[]'
) AS globals
"#
};
async fn run_once(db: &worker::D1Database, sql: &str, excluded: &str) -> Result<String, ()> {
let row: Option<Value> = db
.prepare(sql)
.bind(&[excluded.to_string().into()])
.map_err(|_| ())?
.first(None)
.await
.map_err(|_| ())?;
Ok(row
.and_then(|r| {
r.get("globals")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "[]".to_string()))
}
// If excluded_globals is invalid JSON, json_each() can fail.
// Fallback to treating it as empty list.
match run_once(db, sql, excluded_globals_json).await {
Ok(s) => s,
Err(_) => {
if excluded_globals_json != "[]" {
match run_once(db, sql, "[]").await {
Ok(s) => s,
Err(_) => {
warn!("Failed to build globalEquivalentDomains JSON (falling back to [])");
"[]".to_string()
}
}
} else {
warn!("Failed to build globalEquivalentDomains JSON (falling back to [])");
"[]".to_string()
}
}
}
}
/// GET /api/settings/domains
///
/// Equivalent domains (eq_domains) are used by clients to treat some domains as interchangeable
/// for URI matching (e.g. `google.com` vs `youtube.com` in predefined "global" groups).
///
/// Vaultwarden persists per-user:
/// - `equivalentDomains`: custom groups set by the user
/// - `excludedGlobalEquivalentDomains`: which predefined groups are disabled
///
/// This server persists only the per-user settings in `users`.
/// The optional global dataset can be seeded into D1 (see README), and will then be
/// included in responses without parsing the large JSON in the Worker.
#[worker::send]
pub async fn get_domains(claims: Claims, State(env): State<Arc<Env>>) -> Result<RawJson, AppError> {
let db = db::get_db(&env)?;
let row: Option<Value> = db
.prepare("SELECT equivalent_domains, excluded_globals FROM users WHERE id = ?1")
.bind(&[claims.sub.into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?;
let row = row.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let equivalent_domains = row
.get("equivalent_domains")
.and_then(|v| v.as_str())
.unwrap_or("[]");
let excluded_globals = row
.get("excluded_globals")
.and_then(|v| v.as_str())
.unwrap_or("[]");
// Include ALL global groups and mark `excluded` (settings UI semantics).
// Falls back to [] if the dataset isn't seeded yet.
let global_equivalent_domains =
global_equivalent_domains_json(&db, excluded_globals, true).await;
let response = format!(
r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#,
equivalent_domains, global_equivalent_domains
);
Ok(RawJson(response))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EquivDomainData {
pub excluded_global_equivalent_domains: Option<Vec<i32>>,
pub equivalent_domains: Option<Vec<Vec<String>>>,
}
/// POST /api/settings/domains
///
/// Persist per-user eq_domains settings (no notifications/push).
#[worker::send]
pub async fn post_domains(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
let db = db::get_db(&env)?;
let excluded_globals = payload
.excluded_global_equivalent_domains
.unwrap_or_default();
let equivalent_domains = payload.equivalent_domains.unwrap_or_default();
let excluded_globals_json = serde_json::to_string(&excluded_globals)
.map_err(|_| AppError::BadRequest("Invalid excluded globals".to_string()))?;
let equivalent_domains_json = serde_json::to_string(&equivalent_domains)
.map_err(|_| AppError::BadRequest("Invalid equivalent domains".to_string()))?;
let now = Utc::now().to_rfc3339();
query!(
&db,
"UPDATE users SET equivalent_domains = ?1, excluded_globals = ?2, updated_at = ?3 WHERE id = ?4",
equivalent_domains_json,
excluded_globals_json,
now,
claims.sub
)
.map_err(|_| AppError::Database)?
.run()
.await
.map_err(|_| AppError::Database)?;
Ok(Json(json!({})))
}
/// PUT /api/settings/domains
///
/// Behaves like POST.
#[worker::send]
pub async fn put_domains(
claims: Claims,
State(env): State<Arc<Env>>,
payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
post_domains(claims, State(env), payload).await
}

View file

@ -3,13 +3,13 @@ pub mod attachments;
pub mod ciphers;
pub mod config;
pub mod devices;
pub mod domains;
pub mod emergency_access;
pub mod folders;
pub mod identity;
pub mod import;
pub mod meta;
pub mod purge;
pub mod settings;
pub mod sync;
pub mod twofactor;
pub mod webauth;
@ -79,5 +79,7 @@ pub(crate) async fn two_factor_enabled(
user_id: &str,
) -> Result<bool, crate::error::AppError> {
let twofactors = crate::handlers::twofactor::list_user_twofactors(db, user_id).await?;
Ok(crate::handlers::twofactor::is_twofactor_enabled(&twofactors))
Ok(crate::handlers::twofactor::is_twofactor_enabled(
&twofactors,
))
}

View file

@ -1,61 +0,0 @@
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{auth::Claims, error::AppError};
/// GET /api/settings/domains
///
/// Equivalent domains (eq_domains) are used by clients to treat some domains as interchangeable
/// for URI matching (e.g. `google.com` vs `youtube.com` in predefined "global" groups).
///
/// Vaultwarden persists per-user:
/// - `equivalentDomains`: custom groups set by the user
/// - `excludedGlobalEquivalentDomains`: which predefined groups are disabled
///
/// This minimal server currently does not persist these settings. We return empty data to
/// prevent 404s. In the future, we can implement storage by adding two `users` columns:
/// - `equivalent_domains` (TEXT JSON, default "[]")
/// - `excluded_globals` (TEXT JSON, default "[]")
/// and (optionally) embedding/updating the global domains dataset.
#[worker::send]
pub async fn get_domains(_claims: Claims) -> Result<Json<Value>, AppError> {
Ok(Json(json!({
"equivalentDomains": [],
"globalEquivalentDomains": [],
"object": "domains"
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EquivDomainData {
#[allow(dead_code)] // stub endpoint doesn't persist these yet
pub excluded_global_equivalent_domains: Option<Vec<i32>>,
#[allow(dead_code)] // stub endpoint doesn't persist these yet
pub equivalent_domains: Option<Vec<Vec<String>>>,
}
/// POST /api/settings/domains
///
/// Stub: accept payload but do not persist yet.
#[worker::send]
pub async fn post_domains(
_claims: Claims,
_payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
Ok(Json(json!({})))
}
/// PUT /api/settings/domains
///
/// Stub: behaves like POST.
#[worker::send]
pub async fn put_domains(
claims: Claims,
payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
post_domains(claims, payload).await
}

View file

@ -1,4 +1,4 @@
use axum::extract::State;
use axum::extract::{Query, State};
use std::sync::Arc;
use worker::Env;
@ -6,7 +6,7 @@ use crate::{
auth::Claims,
db,
error::AppError,
handlers::{attachments, ciphers, two_factor_enabled},
handlers::{attachments, ciphers, domains, two_factor_enabled},
models::{
folder::{Folder, FolderResponse},
sync::Profile,
@ -15,12 +15,21 @@ use crate::{
};
use ciphers::RawJson;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct SyncQuery {
/// If true, omit domains data from sync (vaultwarden sets domains to null).
#[serde(rename = "excludeDomains", default)]
pub exclude_domains: bool,
}
#[worker::send]
pub async fn get_sync_data(
claims: Claims,
State(env): State<Arc<Env>>,
Query(query): Query<SyncQuery>,
) -> Result<RawJson, AppError> {
let user_id = claims.sub;
let db = db::get_db(&env)?;
@ -36,6 +45,8 @@ pub async fn get_sync_data(
let two_factor_enabled = two_factor_enabled(&db, &user_id).await?;
let has_master_password = !user.master_password_hash.is_empty();
let equivalent_domains = user.equivalent_domains.clone();
let excluded_globals = user.excluded_globals.clone();
let master_password_unlock = if has_master_password {
// Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase).
// We don't support SSO, so this is always derived from the current user record.
@ -91,10 +102,26 @@ pub async fn get_sync_data(
}))
.map_err(|_| AppError::Internal)?;
let response = format!(
r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{{"equivalentDomains":[],"globalEquivalentDomains":[],"object":"domains"}},"sends":[],"userDecryption":{},"object":"sync"}}"#,
let response = if query.exclude_domains {
format!(
r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"sends":[],"userDecryption":{},"object":"sync"}}"#,
profile_json, folders_json, ciphers_json, user_decryption_json
)
} else {
// Match vaultwarden sync semantics:
// - mark excluded in /api/settings/domains
// - filter excluded out of sync payload
let global_equivalent_domains =
domains::global_equivalent_domains_json(&db, &excluded_globals, false).await;
let domains_json = format!(
r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#,
equivalent_domains, global_equivalent_domains
);
format!(
r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{},"sends":[],"userDecryption":{},"object":"sync"}}"#,
profile_json, folders_json, ciphers_json, domains_json, user_decryption_json
)
};
Ok(RawJson(response))
}

View file

@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize};
use crate::{crypto::verify_password, error::AppError};
fn default_json_array_string() -> String {
"[]".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
@ -23,6 +27,12 @@ pub struct User {
pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB)
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
pub security_stamp: String,
/// JSON string of `Vec<Vec<String>>` storing user-defined equivalent domain groups.
#[serde(default = "default_json_array_string")]
pub equivalent_domains: String,
/// JSON string of `Vec<i32>` storing excluded global group IDs (reserved for future global groups).
#[serde(default = "default_json_array_string")]
pub excluded_globals: String,
pub totp_recover: Option<String>, // Recovery code for 2FA
pub created_at: String,
pub updated_at: String,

View file

@ -6,8 +6,8 @@ use std::sync::Arc;
use worker::Env;
use crate::handlers::{
accounts, attachments, ciphers, config, devices, emergency_access, folders, identity, import,
meta, settings, sync, twofactor, webauth,
accounts, attachments, ciphers, config, devices, domains, emergency_access, folders, identity, import,
meta, sync, twofactor, webauth,
};
pub fn api_router(env: Env) -> Router {
@ -144,9 +144,9 @@ pub fn api_router(env: Env) -> Router {
.route("/api/version", get(meta::version))
.route("/api/hibp/breach", get(meta::hibp_breach))
// Settings (stubbed)
.route("/api/settings/domains", get(settings::get_domains))
.route("/api/settings/domains", post(settings::post_domains))
.route("/api/settings/domains", put(settings::put_domains))
.route("/api/settings/domains", get(domains::get_domains))
.route("/api/settings/domains", post(domains::post_domains))
.route("/api/settings/domains", put(domains::put_domains))
// Emergency access (stub - returns empty lists, feature not supported)
.route(
"/api/emergency-access/trusted",