Apply ZIP update: 150-didactopus-agent-service-account-layer.zip [2026-03-14T13:19:26]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 5c3ba24e90
commit 15d52f66bc
10 changed files with 66 additions and 204 deletions

View File

@ -8,9 +8,11 @@ version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]

View File

@ -5,19 +5,8 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .config import load_settings
from .db import Base, engine
from .models import (
LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
AgentLearnerPlanRequest, AgentLearnerPlanResponse
)
from .repository import (
authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account,
rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs,
store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user,
get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
)
from .models import LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse
from .repository import authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user, get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
from .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret
from .worker import process_job
@ -25,6 +14,19 @@ from .worker import process_job
settings = load_settings()
Base.metadata.create_all(bind=engine)
SERVICE_SCOPE_MAP = {
"packs:read": "packs:read",
"packs:write_personal": "packs:write_personal",
"contributions:submit": "contributions:submit",
"learners:read": "learners:read",
"learners:write": "learners:write",
"recommendations:read": "recommendations:read",
"evaluators:submit": "evaluators:submit",
"evaluators:read": "evaluators:read",
"governance:read": "governance:read",
"governance:write": "governance:write",
}
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
@ -39,37 +41,25 @@ def current_actor(authorization: str = Header(default="")):
raise HTTPException(status_code=401, detail="Unauthorized")
return {"actor_type": "user", "user": user, "scopes": None}
if payload.get("kind") == "service":
return {
"actor_type": "service",
"service_account_id": int(payload["sub"]),
"service_account_name": payload.get("service_account_name"),
"scopes": payload.get("scopes", []),
}
return {"actor_type": "service", "service_account_id": int(payload["sub"]), "service_account_name": payload.get("service_account_name"), "scopes": payload.get("scopes", [])}
raise HTTPException(status_code=401, detail="Unauthorized")
def require_user(actor = Depends(current_actor)):
if actor["actor_type"] != "user":
raise HTTPException(status_code=403, detail="Human user required")
return actor["user"]
def require_admin(actor = Depends(current_actor)):
if actor["actor_type"] != "user" or actor["user"].role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return actor["user"]
def audit_service_action(actor, action: str, target: str, outcome: str = "ok", detail: dict | None = None):
if actor["actor_type"] == "service":
add_agent_audit_log(
actor["service_account_id"],
actor["service_account_name"],
action,
target,
outcome,
detail or {},
)
def require_scope(scope: str):
def inner(actor = Depends(current_actor)):
if actor["actor_type"] == "user":
return actor
scopes = set(actor.get("scopes") or [])
if scope not in scopes:
audit_service_action(actor, f"scope_denied:{scope}", "", "denied", {"scope": scope})
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
return actor
return inner
@ -148,7 +138,6 @@ def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(req
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack)
audit_service_action(actor, "agent_learner_plan", f"{payload.learner_id}:{payload.pack_id}", "ok", {"cards": len(cards)})
return AgentLearnerPlanResponse(learner_id=payload.learner_id, pack_id=payload.pack_id, next_cards=cards, suggested_actions=["Read learner state", "Choose next card", "Submit evidence", "Refresh recommendations"])
@app.post("/api/admin/service-accounts")
@ -161,31 +150,10 @@ def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depe
def api_list_service_accounts(user = Depends(require_admin)):
return list_service_accounts()
@app.post("/api/admin/service-accounts/rotate")
def api_rotate_service_account(payload: ServiceAccountRotateRequest, user = Depends(require_admin)):
secret = new_secret()
sa = rotate_service_account_secret(payload.name, secret)
if sa is None:
raise HTTPException(status_code=404, detail="Service account not found")
return {"name": sa.name, "secret": secret}
@app.post("/api/admin/service-accounts/state")
def api_service_account_state(payload: ServiceAccountStateRequest, name: str, user = Depends(require_admin)):
sa = set_service_account_active(name, payload.is_active)
if sa is None:
raise HTTPException(status_code=404, detail="Service account not found")
return {"name": sa.name, "is_active": sa.is_active}
@app.get("/api/admin/agent-audit-logs")
def api_agent_audit_logs(user = Depends(require_admin)):
return list_agent_audit_logs()
@app.get("/api/packs")
def api_list_packs(actor = Depends(require_scope("packs:read"))):
user_id = actor["user"].id if actor["actor_type"] == "user" else None
packs = [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))]
audit_service_action(actor, "packs_list", "packs", "ok", {"count": len(packs)})
return packs
return [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))]
@app.post("/api/packs")
def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))):
@ -196,6 +164,11 @@ def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require
upsert_pack(payload.pack, submitted_by_user_id=actor["user"].id, policy_lane="personal", is_published=payload.is_published, change_summary=payload.change_summary)
return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"}
@app.post("/api/contributions")
def api_create_contribution(payload: ContributionSubmissionCreate, actor = Depends(require_scope("contributions:submit"))):
contributor_id = actor["user"].id if actor["actor_type"] == "user" else 0
return {"ok": True, "note": "Contribution flow placeholder for this scaffold", "contributor_id": contributor_id}
@app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
if actor["actor_type"] != "user":
@ -206,27 +179,22 @@ def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_sc
@app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id).model_dump()
audit_service_action(actor, "learner_state_read", learner_id, "ok", {"records": len(state.get("records", []))})
return state
return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id)
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
result = save_learner_state(state).model_dump()
audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))})
return result
return save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event)
result = save_learner_state(state).model_dump()
audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id})
return result
save_learner_state(state)
return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
@ -236,9 +204,7 @@ def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(requi
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack)
audit_service_action(actor, "recommendations_read", f"{learner_id}:{pack_id}", "ok", {"cards": len(cards)})
return {"cards": cards}
return {"cards": recommend_next(state, pack)}
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
@ -246,7 +212,6 @@ def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, back
ensure_pack_access(actor, payload.pack_id)
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id)
audit_service_action(actor, "evaluator_job_submit", str(job_id), "ok", {"learner_id": learner_id, "pack_id": payload.pack_id})
return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
@ -254,14 +219,12 @@ def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators
job = get_evaluator_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {})
return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes)
@app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))):
ensure_learner_access(actor, learner_id)
jobs = list_evaluator_jobs_for_learner(learner_id)
audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)})
return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
def main():

View File

@ -31,12 +31,6 @@ class ServiceAccountCreateRequest(BaseModel):
description: str = ""
scopes: list[str] = Field(default_factory=list)
class ServiceAccountRotateRequest(BaseModel):
name: str
class ServiceAccountStateRequest(BaseModel):
is_active: bool
class RefreshRequest(BaseModel):
refresh_token: str
@ -60,8 +54,6 @@ class AgentCapabilityManifest(BaseModel):
supports_governance_endpoints: bool = True
supports_review_queue: bool = True
supports_service_accounts: bool = True
supports_agent_audit_logs: bool = True
supports_service_account_rotation: bool = True
class PackConcept(BaseModel):
id: str
@ -92,6 +84,10 @@ class CreatePackRequest(BaseModel):
is_published: bool = False
change_summary: str = ""
class ContributionSubmissionCreate(BaseModel):
pack: PackData
submission_summary: str = ""
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""

View File

@ -20,17 +20,6 @@ class ServiceAccountORM(Base):
secret_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class AgentAuditLogORM(Base):
__tablename__ = "agent_audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_account_id: Mapped[int] = mapped_column(ForeignKey("service_accounts.id"), index=True)
service_account_name: Mapped[str] = mapped_column(String(120), index=True)
action: Mapped[str] = mapped_column(String(120), index=True)
target: Mapped[str] = mapped_column(String(255), default="")
outcome: Mapped[str] = mapped_column(String(50), default="ok")
detail_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(100), default="")
class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)

View File

@ -3,7 +3,7 @@ import json
from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .orm import UserORM, ServiceAccountORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password, hash_password
from .config import load_settings
@ -58,63 +58,13 @@ def list_service_accounts():
rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all()
return [{"id": r.id, "name": r.name, "owner_user_id": r.owner_user_id, "description": r.description, "scopes": json.loads(r.scopes_json or "[]"), "is_active": r.is_active} for r in rows]
def get_service_account_by_name(name: str):
with SessionLocal() as db:
return db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
def authenticate_service_account(name: str, secret: str):
sa = get_service_account_by_name(name)
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
return None
return sa
def rotate_service_account_secret(name: str, new_secret: str):
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None:
return None
sa.secret_hash = hash_password(new_secret)
db.commit()
db.refresh(sa)
return sa
def set_service_account_active(name: str, is_active: bool):
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None:
return None
sa.is_active = is_active
db.commit()
db.refresh(sa)
return sa
def add_agent_audit_log(service_account_id: int, service_account_name: str, action: str, target: str, outcome: str, detail: dict):
with SessionLocal() as db:
db.add(AgentAuditLogORM(
service_account_id=service_account_id,
service_account_name=service_account_name,
action=action,
target=target,
outcome=outcome,
detail_json=json.dumps(detail),
created_at=now_iso(),
))
db.commit()
def list_agent_audit_logs(limit: int = 200):
with SessionLocal() as db:
rows = db.execute(select(AgentAuditLogORM).order_by(AgentAuditLogORM.id.desc())).scalars().all()[:limit]
return [{
"id": r.id,
"service_account_id": r.service_account_id,
"service_account_name": r.service_account_name,
"action": r.action,
"target": r.target,
"outcome": r.outcome,
"detail": json.loads(r.detail_json or "{}"),
"created_at": r.created_at,
} for r in rows]
def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
@ -197,6 +147,14 @@ def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit()
def list_learners_for_user(user_id: int, is_admin: bool = False):
with SessionLocal() as db:
stmt = select(LearnerORM).order_by(LearnerORM.id)
if not is_admin:
stmt = stmt.where(LearnerORM.owner_user_id == user_id)
rows = db.execute(stmt).scalars().all()
return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows]
def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id)

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Agent Audit and Key Rotation</title>
<title>Didactopus Agent Service Accounts</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{
"name": "didactopus-agent-audit-ui",
"name": "didactopus-agent-service-account-ui",
"private": true,
"version": "0.1.0",
"type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
@ -31,9 +31,7 @@ export default function App() {
const [policy, setPolicy] = useState(null);
const [caps, setCaps] = useState(null);
const [serviceAccounts, setServiceAccounts] = useState([]);
const [auditLogs, setAuditLogs] = useState([]);
const [created, setCreated] = useState(null);
const [rotated, setRotated] = useState(null);
const [form, setForm] = useState({ name: "agent-learner-1", description: "AI learner account", scopes: ["packs:read","learners:read","learners:write","recommendations:read","evaluators:submit","evaluators:read"] });
async function refreshAuthToken() {
@ -59,35 +57,22 @@ export default function App() {
}
}
async function reload() {
useEffect(() => {
if (!auth) return;
async function load() {
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
if (auth.role === "admin") {
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
}
}
useEffect(() => {
if (!auth) return;
reload();
load();
}, [auth]);
async function createNow() {
const result = await guarded((token) => createServiceAccount(token, form));
setCreated(result);
await reload();
}
async function rotateNow(name) {
const result = await guarded((token) => rotateServiceAccount(token, name));
setRotated(result);
await reload();
}
async function toggleState(name, isActive) {
await guarded((token) => setServiceAccountState(token, name, isActive));
await reload();
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
}
if (!auth) return <LoginView onAuth={setAuth} />;
@ -96,8 +81,8 @@ export default function App() {
<div className="page">
<header className="hero">
<div>
<h1>Didactopus agent audit + key rotation</h1>
<p>Scoped machine identities with audit events, rotation, and disable controls.</p>
<h1>Didactopus agent service-account layer</h1>
<p>First-class machine identities with scoped API access for AI learners.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
</div>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
@ -121,37 +106,13 @@ export default function App() {
<button className="primary" onClick={createNow}>Create service account</button>
<h3>Created credential</h3>
<pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<h3>Rotated credential</h3>
<pre className="prebox">{JSON.stringify(rotated, null, 2)}</pre>
<h3>Existing accounts</h3>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<tbody>
{serviceAccounts.map((sa) => (
<tr key={sa.id}>
<td>{sa.name}</td>
<td>{String(sa.is_active)}</td>
<td>{sa.scopes.join(", ")}</td>
<td>
<button onClick={() => rotateNow(sa.name)}>Rotate</button>
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<pre className="prebox">{JSON.stringify(serviceAccounts, null, 2)}</pre>
</>
) : (
<div className="muted">Admin required.</div>
)}
</section>
<section className="card full">
<h2>Agent audit logs</h2>
<pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
</section>
</main>
</div>
);

View File

@ -20,6 +20,3 @@ export async function fetchDeploymentPolicy(token) { const res = await fetch(`${
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); }
export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); }
export async function rotateServiceAccount(token, name) { const res = await fetch(`${API}/admin/service-accounts/rotate`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ name }) }); if (!res.ok) throw new Error("rotateServiceAccount failed"); return await res.json(); }
export async function setServiceAccountState(token, name, is_active) { const res = await fetch(`${API}/admin/service-accounts/state?name=${encodeURIComponent(name)}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ is_active }) }); if (!res.ok) throw new Error("setServiceAccountState failed"); return await res.json(); }
export async function listAgentAuditLogs(token) { const res = await fetch(`${API}/admin/agent-audit-logs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listAgentAuditLogs failed"); return await res.json(); }

View File

@ -8,17 +8,13 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg);
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
label { display:block; font-weight:600; margin-bottom:10px; }
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:8px 12px; cursor:pointer; margin-right:8px; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.primary { background:var(--accent); color:white; border-color:var(--accent); }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
.layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
.full { grid-column:1 / -1; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
.prebox.tall { max-height:420px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:340px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
.table { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } }