mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
feat(2-A3b): requires_api_key enforcement in supervisor and GUI
- Add set_adapter_last_error method to ConfigStore for setting/clearing adapter error states - Add API key precondition check in supervisor._start_adapter that: - Checks if adapter has requires_api_key attribute - Looks up the key via config_store.get_api_key - Sets last_error and returns early if key is missing - Clears last_error when adapter successfully starts - Update adapters_list handler to compute api_key_missing flag for each adapter and pass to template - Update adapters_edit_form handler to compute api_key_missing and requires_api_key_alias for template context - Update adapters_list.html to show warning badge when api_key_missing - Update adapters_edit.html to show warning article and disable Enable checkbox when api_key_missing - Add tests for new functionality - Fix test mocks to include requires_api_key and last_error fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
43bf973caf
commit
045b8614e8
8 changed files with 222 additions and 19 deletions
|
|
@ -241,6 +241,14 @@ class ConfigStore:
|
|||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
async def set_adapter_last_error(self, name: str, error: str | None) -> None:
|
||||
"""Set or clear the last_error field on an adapter row."""
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE config.adapters SET last_error = $1 WHERE name = $2",
|
||||
error, name,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Change notifications
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1318,27 +1318,45 @@ async def adapters_list(
|
|||
templates = _get_templates()
|
||||
pool = get_pool()
|
||||
operator = request.state.operator
|
||||
adapter_classes = _adapter_classes()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT name, enabled, cadence_s, settings, paused_at, updated_at
|
||||
SELECT name, enabled, cadence_s, settings, paused_at, updated_at, last_error
|
||||
FROM config.adapters
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
|
||||
adapters = []
|
||||
for row in rows:
|
||||
settings = row["settings"] or {}
|
||||
adapters.append({
|
||||
"name": row["name"],
|
||||
"enabled": row["enabled"],
|
||||
"cadence_s": row["cadence_s"],
|
||||
"settings": settings,
|
||||
"paused_at": row["paused_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
adapters = []
|
||||
for row in rows:
|
||||
settings = row["settings"] or {}
|
||||
adapter_cls = adapter_classes.get(row["name"])
|
||||
|
||||
# Check if required API key is missing
|
||||
api_key_missing = False
|
||||
requires_api_key_alias = None
|
||||
if adapter_cls and adapter_cls.requires_api_key is not None:
|
||||
requires_api_key_alias = adapter_cls.requires_api_key
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
requires_api_key_alias,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
|
||||
adapters.append({
|
||||
"name": row["name"],
|
||||
"display_name": getattr(adapter_cls, "display_name", row["name"]) if adapter_cls else row["name"],
|
||||
"enabled": row["enabled"],
|
||||
"cadence_s": row["cadence_s"],
|
||||
"settings": settings,
|
||||
"paused_at": row["paused_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"last_error": row["last_error"],
|
||||
"api_key_missing": api_key_missing,
|
||||
"requires_api_key_alias": requires_api_key_alias,
|
||||
})
|
||||
|
||||
csrf_token = request.state.csrf_token
|
||||
response = templates.TemplateResponse(
|
||||
|
|
@ -1419,6 +1437,18 @@ async def adapters_edit_form(
|
|||
api_key_rows = await conn.fetch("SELECT alias FROM config.api_keys ORDER BY alias")
|
||||
api_keys = [{"alias": r["alias"]} for r in api_key_rows]
|
||||
|
||||
# Check if required API key is missing
|
||||
api_key_missing = False
|
||||
requires_api_key_alias = None
|
||||
if adapter_cls and adapter_cls.requires_api_key is not None:
|
||||
requires_api_key_alias = adapter_cls.requires_api_key
|
||||
async with pool.acquire() as conn:
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
requires_api_key_alias,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
|
||||
csrf_token = request.state.csrf_token
|
||||
response = templates.TemplateResponse(
|
||||
request=request,
|
||||
|
|
@ -1433,6 +1463,8 @@ async def adapters_edit_form(
|
|||
"form_data": None,
|
||||
"tile_url": tile_url,
|
||||
"tile_attribution": tile_attribution,
|
||||
"api_key_missing": api_key_missing,
|
||||
"requires_api_key_alias": requires_api_key_alias,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@
|
|||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if api_key_missing %}
|
||||
<article aria-label="API Key Required" style="background-color: var(--pico-mark-background-color); margin-bottom: 1rem;">
|
||||
<strong>⚠️ API Key Required:</strong> This adapter requires the <code>{{ requires_api_key_alias }}</code> API key to be configured before it can be enabled.
|
||||
<a href="/keys">Configure API Keys</a>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/adapters/{{ adapter.name }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
|
|
@ -32,8 +39,8 @@
|
|||
<legend>Core Settings</legend>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="enabled" {% if form_data %}{% if form_data.enabled %}checked{% endif %}{% elif adapter.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
<input type="checkbox" name="enabled" {% if form_data %}{% if form_data.enabled %}checked{% endif %}{% elif adapter.enabled %}checked{% endif %}{% if api_key_missing %} disabled{% endif %}>
|
||||
Enabled{% if api_key_missing %} <small>(requires API key)</small>{% endif %}
|
||||
</label>
|
||||
|
||||
<label for="cadence_s">Cadence (seconds)</label>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
<tbody>
|
||||
{% for adapter in adapters %}
|
||||
<tr>
|
||||
<td>{{ adapter.name }}</td>
|
||||
<td>
|
||||
{{ adapter.display_name or adapter.name }}
|
||||
{% if adapter.api_key_missing %}
|
||||
<span style="color: var(--pico-color-orange-500); margin-left: 0.5rem;" title="Missing API key: {{ adapter.requires_api_key_alias }}">⚠️ API Key Missing</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if adapter.enabled %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ adapter.cadence_s }}s</td>
|
||||
<td>{{ adapter.updated_at.strftime('%Y-%m-%d %H:%M') if adapter.updated_at else '—' }}</td>
|
||||
|
|
|
|||
|
|
@ -266,6 +266,23 @@ class Supervisor:
|
|||
If the adapter was previously stopped (state exists but task is not running),
|
||||
reuses the existing state to preserve last_completed_poll for rate limiting.
|
||||
"""
|
||||
# API key precondition
|
||||
adapter_cls = self._adapters.get(config.name)
|
||||
if adapter_cls is not None and adapter_cls.requires_api_key is not None:
|
||||
alias = adapter_cls.requires_api_key
|
||||
key_value = await self._config_store.get_api_key(alias)
|
||||
if not key_value:
|
||||
error_msg = f"missing api key: {alias}"
|
||||
logger.warning(
|
||||
"Adapter cannot start - api key missing",
|
||||
extra={"adapter": config.name, "alias": alias},
|
||||
)
|
||||
await self._config_store.set_adapter_last_error(config.name, error_msg)
|
||||
return
|
||||
|
||||
# Clear any stale last_error before proceeding
|
||||
await self._config_store.set_adapter_last_error(config.name, None)
|
||||
|
||||
existing_state = self._adapter_states.get(config.name)
|
||||
|
||||
if existing_state is not None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue