Dev build 2026-06-19 13:51
This commit is contained in:
parent
4b05c48a73
commit
6b6025af16
@ -1871,9 +1871,57 @@ function renderUserBadge(me) {
|
||||
reloadUsersTable().catch(function (err) { console.error('users reload failed', err); });
|
||||
} else if (sub === 'audit') {
|
||||
reloadAuditTable().catch(function (err) { console.error('audit reload failed', err); });
|
||||
} else if (sub === 'general') {
|
||||
wireOnboardingSettingsOnce();
|
||||
loadOnboardingSettings().catch(function (err) { console.error('onboarding settings load failed', err); });
|
||||
}
|
||||
}
|
||||
|
||||
let _onboardingSettingsWired = false;
|
||||
|
||||
function wireOnboardingSettingsOnce() {
|
||||
if (_onboardingSettingsWired) return;
|
||||
const form = document.getElementById('onboardingSettingsForm');
|
||||
if (!form) return;
|
||||
_onboardingSettingsWired = true;
|
||||
form.addEventListener('submit', async function (ev) {
|
||||
ev.preventDefault();
|
||||
const statusEl = document.getElementById('onboardingSettingsStatus');
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
client_id: (fd.get('client_id') || '').trim(),
|
||||
redirect_uri: (fd.get('redirect_uri') || '').trim(),
|
||||
};
|
||||
const secret = (fd.get('client_secret') || '').trim();
|
||||
// Blank secret = keep the stored one (write-only field).
|
||||
if (secret) body.client_secret = secret;
|
||||
try {
|
||||
await requestJson('/api/settings/onboarding', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (statusEl) statusEl.textContent = 'Saved.';
|
||||
await loadOnboardingSettings();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Save failed: ' + err.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOnboardingSettings() {
|
||||
const form = document.getElementById('onboardingSettingsForm');
|
||||
if (!form) return;
|
||||
const user = window.__clearviewUser;
|
||||
if (!user || user.role !== 'admin') return; // endpoint is admin-only
|
||||
const data = await requestJson('/api/settings/onboarding');
|
||||
form.querySelector('[name="client_id"]').value = data.client_id || '';
|
||||
form.querySelector('[name="redirect_uri"]').value = data.redirect_uri || '';
|
||||
const secretInput = form.querySelector('[name="client_secret"]');
|
||||
secretInput.value = '';
|
||||
secretInput.placeholder = data.client_secret_set ? '•••••••• (leave blank to keep)' : 'Not set';
|
||||
}
|
||||
|
||||
function navigateTo(route) {
|
||||
var hash;
|
||||
if (route === 'scan-sharepoint') hash = '#/scan/sharepoint';
|
||||
|
||||
@ -583,7 +583,25 @@
|
||||
</nav>
|
||||
|
||||
<div class="settings-pane" data-settings-pane="general">
|
||||
<p class="setup-hint">Runtime configuration is currently controlled via environment variables in <code>stack/.env</code>. See the <strong>TECHNICAL.md</strong> document for the full list (timeouts, retries, scan caps, onboarding).</p>
|
||||
<div class="card" data-admin-only>
|
||||
<h3>Microsoft onboarding</h3>
|
||||
<p class="setup-hint">Credentials for the Microsoft admin-consent / scan-app onboarding flow. Stored in the database.</p>
|
||||
<form id="onboardingSettingsForm" class="onboarding-form" action="#" method="post">
|
||||
<label class="onboarding-wide">App (client) ID
|
||||
<input name="client_id" autocomplete="off" />
|
||||
</label>
|
||||
<label class="onboarding-wide">Redirect URI
|
||||
<input name="redirect_uri" autocomplete="off" />
|
||||
</label>
|
||||
<label class="onboarding-wide">Client secret
|
||||
<input name="client_secret" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<div class="onboarding-actions">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span id="onboardingSettingsStatus" class="setup-hint"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-pane" data-settings-pane="users" hidden>
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
"""Microsoft onboarding routes (admin-consent connect + scan-app creation)."""
|
||||
"""Microsoft onboarding routes (admin-consent connect + scan-app creation).
|
||||
|
||||
Onboarding credentials live in the database (see settings_service); each route
|
||||
loads them per request and passes them into the onboarding helpers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth.dependencies import get_db
|
||||
from .onboarding import (
|
||||
OnboardingError,
|
||||
consume_callback_state,
|
||||
@ -15,14 +23,18 @@ from .schemas import (
|
||||
CreateScanAppRequest,
|
||||
CreateScanAppResponse,
|
||||
)
|
||||
from .settings_service import get_onboarding_config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.post("/api/onboarding/create-scan-app", response_model=CreateScanAppResponse)
|
||||
def onboarding_create_scan_app(payload: CreateScanAppRequest) -> CreateScanAppResponse:
|
||||
def onboarding_create_scan_app(payload: CreateScanAppRequest, db: DbDep) -> CreateScanAppResponse:
|
||||
try:
|
||||
result = create_scan_app_for_tenant(
|
||||
get_onboarding_config(db),
|
||||
tenant_id=payload.tenant_id,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
@ -42,9 +54,9 @@ def onboarding_create_scan_app(payload: CreateScanAppRequest) -> CreateScanAppRe
|
||||
|
||||
|
||||
@router.get("/api/onboarding/microsoft/connect-url", response_model=ConnectMicrosoftResponse)
|
||||
def onboarding_microsoft_connect_url() -> ConnectMicrosoftResponse:
|
||||
def onboarding_microsoft_connect_url(db: DbDep) -> ConnectMicrosoftResponse:
|
||||
try:
|
||||
return ConnectMicrosoftResponse(connect_url=create_connect_url())
|
||||
return ConnectMicrosoftResponse(connect_url=create_connect_url(get_onboarding_config(db)))
|
||||
except OnboardingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@ -70,7 +82,5 @@ def onboarding_microsoft_callback(
|
||||
|
||||
|
||||
@router.get("/api/onboarding/status")
|
||||
def onboarding_status() -> dict[str, bool]:
|
||||
from . import config
|
||||
automated = bool(config.ONBOARDING_CLIENT_ID and config.ONBOARDING_CLIENT_SECRET and config.ONBOARDING_REDIRECT_URI)
|
||||
return {"automated_available": automated}
|
||||
def onboarding_status(db: DbDep) -> dict[str, bool]:
|
||||
return {"automated_available": get_onboarding_config(db).is_complete}
|
||||
|
||||
43
containers/clearview/src/clearview_app/api_settings.py
Normal file
43
containers/clearview/src/clearview_app/api_settings.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Admin settings routes (database-backed app configuration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth.dependencies import get_db, require_admin
|
||||
from .schemas import OnboardingSettings, OnboardingSettingsUpdate
|
||||
from .settings_service import get_onboarding_config, set_onboarding_config
|
||||
|
||||
# Every route here requires an admin session.
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get("/api/settings/onboarding", response_model=OnboardingSettings)
|
||||
def read_onboarding_settings(db: DbDep) -> OnboardingSettings:
|
||||
cfg = get_onboarding_config(db)
|
||||
# The secret is write-only: report only whether one is stored.
|
||||
return OnboardingSettings(
|
||||
client_id=cfg.client_id,
|
||||
redirect_uri=cfg.redirect_uri,
|
||||
client_secret_set=bool(cfg.client_secret),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/settings/onboarding", response_model=OnboardingSettings)
|
||||
def update_onboarding_settings(payload: OnboardingSettingsUpdate, db: DbDep) -> OnboardingSettings:
|
||||
set_onboarding_config(
|
||||
db,
|
||||
client_id=payload.client_id,
|
||||
redirect_uri=payload.redirect_uri,
|
||||
client_secret=payload.client_secret,
|
||||
)
|
||||
cfg = get_onboarding_config(db)
|
||||
return OnboardingSettings(
|
||||
client_id=cfg.client_id,
|
||||
redirect_uri=cfg.redirect_uri,
|
||||
client_secret_set=bool(cfg.client_secret),
|
||||
)
|
||||
@ -26,9 +26,8 @@ SCAN_JOB_POLL_INTERVAL_SEC = _int_env("SCAN_JOB_POLL_INTERVAL_SEC", 3)
|
||||
# Placeholder mode until Graph/SharePoint auth integration is implemented.
|
||||
SHAREPOINT_SCAN_MODE = os.getenv("SHAREPOINT_SCAN_MODE", "sharepoint_app_only")
|
||||
|
||||
ONBOARDING_CLIENT_ID = os.getenv("ONBOARDING_CLIENT_ID", "")
|
||||
ONBOARDING_CLIENT_SECRET = os.getenv("ONBOARDING_CLIENT_SECRET", "")
|
||||
ONBOARDING_REDIRECT_URI = os.getenv("ONBOARDING_REDIRECT_URI", "")
|
||||
# Onboarding (Microsoft admin-consent / scan-app) credentials are stored in the
|
||||
# database (see settings_service), not in the environment.
|
||||
|
||||
SCAN_HTTP_TIMEOUT_SEC = _int_env("SCAN_HTTP_TIMEOUT_SEC", 30)
|
||||
SCAN_HTTP_MAX_RETRIES = _int_env("SCAN_HTTP_MAX_RETRIES", 3)
|
||||
|
||||
@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api_jobs import router as jobs_router
|
||||
from .api_onboarding import router as onboarding_router
|
||||
from .api_settings import router as settings_router
|
||||
from .api_tenants import router as tenants_router
|
||||
from .auth.dependencies import require_user
|
||||
from .auth.router import router as auth_router
|
||||
@ -55,6 +56,7 @@ app.include_router(auth_router)
|
||||
|
||||
# Admin endpoints — already enforce require_admin internally.
|
||||
app.include_router(users_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
# Existing routers gated by an authenticated session.
|
||||
_protected = [Depends(require_user)]
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
"""Create app_settings table (database-backed runtime configuration).
|
||||
|
||||
Revision ID: 0004_app_settings
|
||||
Revises: 0003_auth_tables
|
||||
Create Date: 2026-06-19
|
||||
|
||||
The baseline (0001) creates every table in ``clearview_app.models.Base`` via
|
||||
``create_all``, so a *fresh* database already has ``app_settings`` once the
|
||||
model exists. Only databases stamped at the baseline before this table was added
|
||||
need it created here — hence the guarded create, which is a no-op on fresh DBs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0004_app_settings"
|
||||
down_revision = "0003_auth_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if "app_settings" not in sa.inspect(bind).get_table_names():
|
||||
op.create_table(
|
||||
"app_settings",
|
||||
sa.Column("key", sa.String(length=64), primary_key=True),
|
||||
sa.Column("value", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if "app_settings" in sa.inspect(bind).get_table_names():
|
||||
op.drop_table("app_settings")
|
||||
@ -15,6 +15,23 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Key-value application configuration, stored in the database.
|
||||
|
||||
Per the project convention, runtime configuration lives in the database (the
|
||||
stack only carries what the app needs to start). Values are stored as plain
|
||||
text, consistent with how tenant secrets/keys are stored on TenantProfile.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
|
||||
class TenantProfile(Base):
|
||||
__tablename__ = "tenant_profiles"
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from .config import ONBOARDING_CLIENT_ID, ONBOARDING_CLIENT_SECRET, ONBOARDING_REDIRECT_URI
|
||||
from .settings_service import OnboardingConfig
|
||||
|
||||
SHAREPOINT_RESOURCE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
|
||||
_STATE_TTL_SEC = 600
|
||||
@ -31,13 +31,13 @@ class OnboardingError(RuntimeError):
|
||||
_state_store: dict[str, float] = {}
|
||||
|
||||
|
||||
def create_connect_url() -> str:
|
||||
_validate_onboarding_config()
|
||||
def create_connect_url(cfg: OnboardingConfig) -> str:
|
||||
_validate_onboarding_config(cfg)
|
||||
state = _issue_state_token()
|
||||
|
||||
query = {
|
||||
"client_id": ONBOARDING_CLIENT_ID,
|
||||
"redirect_uri": ONBOARDING_REDIRECT_URI,
|
||||
"client_id": cfg.client_id,
|
||||
"redirect_uri": cfg.redirect_uri,
|
||||
"state": state,
|
||||
}
|
||||
return f"https://login.microsoftonline.com/organizations/adminconsent?{urlencode(query)}"
|
||||
@ -51,16 +51,16 @@ def consume_callback_state(state: str) -> bool:
|
||||
return (time.time() - created_at) <= _STATE_TTL_SEC
|
||||
|
||||
|
||||
def create_scan_app_for_tenant(tenant_id: str, display_name: str) -> CreatedScanApp:
|
||||
def create_scan_app_for_tenant(cfg: OnboardingConfig, tenant_id: str, display_name: str) -> CreatedScanApp:
|
||||
tenant = (tenant_id or "").strip()
|
||||
app_name = (display_name or "").strip() or f"Clearview Scan App {uuid.uuid4().hex[:8]}"
|
||||
|
||||
if not tenant:
|
||||
raise OnboardingError("tenant_id is required")
|
||||
|
||||
_validate_onboarding_config()
|
||||
_validate_onboarding_config(cfg)
|
||||
|
||||
graph_token = _get_graph_token_for_tenant(tenant)
|
||||
graph_token = _get_graph_token_for_tenant(cfg, tenant)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {graph_token}",
|
||||
"Content-Type": "application/json",
|
||||
@ -145,23 +145,27 @@ def _cleanup_states() -> None:
|
||||
_state_store.pop(key, None)
|
||||
|
||||
|
||||
def _validate_onboarding_config() -> None:
|
||||
def _validate_onboarding_config(cfg: OnboardingConfig) -> None:
|
||||
missing = []
|
||||
if not ONBOARDING_CLIENT_ID:
|
||||
missing.append("ONBOARDING_CLIENT_ID")
|
||||
if not ONBOARDING_CLIENT_SECRET:
|
||||
missing.append("ONBOARDING_CLIENT_SECRET")
|
||||
if not ONBOARDING_REDIRECT_URI:
|
||||
missing.append("ONBOARDING_REDIRECT_URI")
|
||||
if not cfg.client_id:
|
||||
missing.append("client_id")
|
||||
if not cfg.client_secret:
|
||||
missing.append("client_secret")
|
||||
if not cfg.redirect_uri:
|
||||
missing.append("redirect_uri")
|
||||
if missing:
|
||||
raise OnboardingError("Missing onboarding config: " + ", ".join(missing))
|
||||
raise OnboardingError(
|
||||
"Onboarding is not configured yet (missing: "
|
||||
+ ", ".join(missing)
|
||||
+ "). Set it under Settings."
|
||||
)
|
||||
|
||||
|
||||
def _get_graph_token_for_tenant(tenant_id: str) -> str:
|
||||
def _get_graph_token_for_tenant(cfg: OnboardingConfig, tenant_id: str) -> str:
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"client_id": ONBOARDING_CLIENT_ID,
|
||||
"client_secret": ONBOARDING_CLIENT_SECRET,
|
||||
"client_id": cfg.client_id,
|
||||
"client_secret": cfg.client_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "https://graph.microsoft.com/.default",
|
||||
}
|
||||
|
||||
@ -158,3 +158,19 @@ class CreateScanAppResponse(BaseModel):
|
||||
app_object_id: str
|
||||
service_principal_id: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OnboardingSettings(BaseModel):
|
||||
"""Onboarding config as shown to an admin. The secret is never returned;
|
||||
only whether one is stored."""
|
||||
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
client_secret_set: bool
|
||||
|
||||
|
||||
class OnboardingSettingsUpdate(BaseModel):
|
||||
client_id: str = ""
|
||||
redirect_uri: str = ""
|
||||
# None / omitted = keep the stored secret; a string overwrites it.
|
||||
client_secret: str | None = None
|
||||
|
||||
71
containers/clearview/src/clearview_app/settings_service.py
Normal file
71
containers/clearview/src/clearview_app/settings_service.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Database-backed application settings.
|
||||
|
||||
Runtime configuration lives in the database (the stack only carries what the app
|
||||
needs to start). This module reads/writes the ``app_settings`` key-value table.
|
||||
Values are stored as plain text, consistent with how tenant secrets are stored
|
||||
elsewhere in the app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import AppSetting
|
||||
|
||||
_KEY_CLIENT_ID = "onboarding_client_id"
|
||||
_KEY_CLIENT_SECRET = "onboarding_client_secret"
|
||||
_KEY_REDIRECT_URI = "onboarding_redirect_uri"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingConfig:
|
||||
"""Microsoft onboarding (admin-consent / scan-app creation) credentials."""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
redirect_uri: str
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret and self.redirect_uri)
|
||||
|
||||
|
||||
def _get(db: Session, key: str) -> str:
|
||||
row = db.get(AppSetting, key)
|
||||
return (row.value or "") if row is not None else ""
|
||||
|
||||
|
||||
def _set(db: Session, key: str, value: str | None) -> None:
|
||||
row = db.get(AppSetting, key)
|
||||
if row is None:
|
||||
db.add(AppSetting(key=key, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
|
||||
|
||||
def get_onboarding_config(db: Session) -> OnboardingConfig:
|
||||
return OnboardingConfig(
|
||||
client_id=_get(db, _KEY_CLIENT_ID),
|
||||
client_secret=_get(db, _KEY_CLIENT_SECRET),
|
||||
redirect_uri=_get(db, _KEY_REDIRECT_URI),
|
||||
)
|
||||
|
||||
|
||||
def set_onboarding_config(
|
||||
db: Session,
|
||||
*,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
client_secret: str | None = None,
|
||||
) -> None:
|
||||
"""Update onboarding settings and commit.
|
||||
|
||||
``client_secret=None`` keeps the stored secret untouched, so the admin UI can
|
||||
present a write-only field that is left blank to retain the current value.
|
||||
"""
|
||||
_set(db, _KEY_CLIENT_ID, (client_id or "").strip())
|
||||
_set(db, _KEY_REDIRECT_URI, (redirect_uri or "").strip())
|
||||
if client_secret is not None:
|
||||
_set(db, _KEY_CLIENT_SECRET, client_secret.strip())
|
||||
db.commit()
|
||||
@ -7,7 +7,7 @@ history, so operators can see exactly which image build is running.
|
||||
from __future__ import annotations
|
||||
|
||||
VERSION = "v0.2.0"
|
||||
BUILD = 1
|
||||
BUILD = 2
|
||||
|
||||
|
||||
def display_version() -> str:
|
||||
|
||||
@ -2,6 +2,19 @@
|
||||
|
||||
This file documents changes on the develop branch of this project.
|
||||
|
||||
## 2026-06-19 — Onboarding config moved from env to the database
|
||||
|
||||
### Added
|
||||
- Database-backed application settings, per the convention that all runtime config lives in the DB (the stack only carries what the app needs to start). New `app_settings` key-value table (`models.AppSetting`, plain-text values — consistent with how tenant secrets are already stored), Alembic migration `0004_app_settings`, and `settings_service.py` with `get_onboarding_config(db)` / `set_onboarding_config(db, ...)`.
|
||||
- Admin-only settings API (`api_settings.py`, gated by `require_admin`): `GET /api/settings/onboarding` (returns `client_id`, `redirect_uri`, and a `client_secret_set` flag — the secret itself is never returned) and `PUT /api/settings/onboarding` (a blank `client_secret` keeps the stored one). Wired into `main.py`.
|
||||
- Settings → General now has an admin form to manage the Microsoft onboarding credentials (`index.html` + `app.js`: load on tab open, write-only secret field with a "leave blank to keep" placeholder).
|
||||
|
||||
### Changed
|
||||
- `onboarding.py` functions now take an `OnboardingConfig` parameter instead of reading module-level `ONBOARDING_*` constants; `api_onboarding.py` loads the config from the DB per request and passes it in. `GET /api/onboarding/status` now reports `automated_available` from the stored config.
|
||||
- Removed `ONBOARDING_CLIENT_ID/SECRET/REDIRECT_URI` from `config.py` (and earlier from the stack). Background: Clearview originally had no login/settings layer — that was added later — which is why onboarding had been wired through env in the first place.
|
||||
- **Migration note:** the baseline (`0001`) builds the schema via `Base.metadata.create_all`, so a fresh DB already gets `app_settings` from the model; `0004` therefore guards its `create_table` (no-op on fresh DBs, creates it on databases stamped at the baseline before this table existed).
|
||||
- Verified end-to-end against a throwaway Postgres + the built image: migrations reach `0004`, admin setup/login, settings GET/PUT (secret never returned, blank-secret keeps the stored value), `onboarding/status` flips to `automated_available:true` after configuring, values persisted in `app_settings`, and the endpoints 401 without an admin session. `app.js` passes `node --check`.
|
||||
|
||||
## 2026-06-19 — Stack: no silent defaults + per-environment naming
|
||||
|
||||
### Changed
|
||||
|
||||
Reference in New Issue
Block a user