""" Instance-wide settings stored in the `hub_settings` table. One reader per concern, so callers never touch raw strings or key names. A missing row means the built-in default — an upgrade never changes behaviour on its own, and a downgrade that drops the table just returns to defaults. """ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.db.models import HubSetting # Whether a member may create a group that is listed in the public directory and # open for anyone to join. Off makes the hub private-groups-only. ALLOW_PUBLIC_GROUPS = "allow_public_groups" _DEFAULTS: dict[str, str] = { ALLOW_PUBLIC_GROUPS: "true", } # ── Mail bounds ────────────────────────────────────────────────────────────── # # Stored here rather than read from `hub.toml` at each use, because an operator # has to be able to change them while the hub is serving: the hour the budget # runs out is exactly when nobody wants to edit a file and restart. The TOML # values are the defaults these fall back to, so an instance that never touches # the panel behaves as its configuration file says. MAIL_KEYS = ( "destination_cooldown_seconds", "destination_daily_cap", "hourly_budget", "hourly_reserved_for_recovery", "verification_resend_cooldown", "reset_cooldown", "email_change_cooldown", ) # What a value may be. A cooldown of zero disables it, which is a legitimate # thing for an operator to want; a budget of zero would stop the hub sending # anything at all, which is not, so those start at one. The upper bounds are # there because this is a number typed into a web form. MAIL_BOUNDS: dict[str, tuple[int, int]] = { "destination_cooldown_seconds": (0, 86_400), "destination_daily_cap": (1, 1_000), "hourly_budget": (1, 100_000), "hourly_reserved_for_recovery": (0, 100_000), "verification_resend_cooldown": (0, 86_400), "reset_cooldown": (0, 86_400), "email_change_cooldown": (0, 2_592_000), # 30 days } _mail_defaults: dict[str, int] = {} def set_mail_defaults(mail_cfg) -> None: """Record what `hub.toml` said. Called once, at startup.""" global _mail_defaults _mail_defaults = {k: int(getattr(mail_cfg, k)) for k in MAIL_KEYS} def mail_default(key: str) -> int: return _mail_defaults.get(key, 0) def clamp_mail_value(key: str, value: int) -> int: low, high = MAIL_BOUNDS[key] return max(low, min(high, int(value))) async def get_int(db: AsyncSession, key: str, fallback: int) -> int: raw = await get_raw(db, key) if raw is None: return fallback try: return int(raw) except ValueError: # A row that cannot be read is not a reason to send without a bound. return fallback async def mail_limits(db: AsyncSession) -> dict[str, int]: """Every mail bound, stored value or configured default.""" return {k: await get_int(db, f"mail.{k}", mail_default(k)) for k in MAIL_KEYS} # ── Sign-in lockout ────────────────────────────────────────────────────────── # # After `max_failures` wrong passphrases for one username, sign-in with a # passphrase is refused for `lockout_minutes` (`login_throttle.py`). Zero # failures turns the lockout off; a zero-minute lockout would be the same thing # said less clearly, so the duration starts at one. LOGIN_KEYS = ("max_failures", "lockout_minutes") LOGIN_DEFAULTS: dict[str, int] = { "max_failures": 4, "lockout_minutes": 60, } LOGIN_BOUNDS: dict[str, tuple[int, int]] = { "max_failures": (0, 100), "lockout_minutes": (1, 10_080), # a week } def clamp_login_value(key: str, value: int) -> int: low, high = LOGIN_BOUNDS[key] return max(low, min(high, int(value))) async def login_limits(db: AsyncSession) -> dict[str, int]: """Both lockout numbers, stored value or built-in default.""" return {k: clamp_login_value(k, await get_int(db, f"login.{k}", LOGIN_DEFAULTS[k])) for k in LOGIN_KEYS} # ── Session lifetime ───────────────────────────────────────────────────────── # # `browser_idle_hours`: a browser tab signs itself out after this long with no # input and nothing playing (`static/idle.js`). The hub cannot measure that — it # hears a renewal from any open tab, attended or not — so the page does, and # reads the number from `/v1/hub/info`. The desktop application is exempt: it is # its owner's machine and signs back in with its device key. # # `refresh_idle_hours`: a refresh token unused for this long stops renewing. The # hub's own backstop, for a token that left the browser it was issued to. # # `max_hours`: no session renews past this since its sign-in, used or not. SESSION_KEYS = ("browser_idle_hours", "refresh_idle_hours", "max_hours") SESSION_DEFAULTS: dict[str, int] = { "browser_idle_hours": 1, "refresh_idle_hours": 24, "max_hours": 720, } SESSION_BOUNDS: dict[str, tuple[int, int]] = { "browser_idle_hours": (1, 168), # a week "refresh_idle_hours": (1, 720), # 30 days "max_hours": (1, 8_760), # a year } def clamp_session_value(key: str, value: int) -> int: low, high = SESSION_BOUNDS[key] return max(low, min(high, int(value))) async def session_limits(db: AsyncSession) -> dict[str, int]: """The three session numbers, stored value or built-in default.""" return {k: clamp_session_value( k, await get_int(db, f"session.{k}", SESSION_DEFAULTS[k])) for k in SESSION_KEYS} async def get_raw(db: AsyncSession, key: str) -> str | None: row = await db.get(HubSetting, key) return row.value if row else None async def set_raw(db: AsyncSession, key: str, value: str) -> None: """Upsert. The caller owns the commit.""" row = await db.get(HubSetting, key) if row: row.value = value else: db.add(HubSetting(key=key, value=value)) async def get_bool(db: AsyncSession, key: str) -> bool: raw = await get_raw(db, key) if raw is None: raw = _DEFAULTS.get(key, "false") return raw == "true" async def public_groups_allowed(db: AsyncSession) -> bool: return await get_bool(db, ALLOW_PUBLIC_GROUPS)