Instance owner/operator role (env-declared via OWNER_EMAIL) #240
@@ -54,6 +54,36 @@ async def get_current_user_or_none(request: Request, session: SessionDep) -> Use
|
||||
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
|
||||
|
||||
|
||||
def is_instance_owner(user: User) -> bool:
|
||||
"""Whether this account is an instance owner/operator — i.e. its email is
|
||||
named in OWNER_EMAIL *and* that email has been verified. Instance ownership
|
||||
is an operational/config role; it does NOT bypass the privacy engine or grant
|
||||
access to others' tree data.
|
||||
|
||||
The verified-email requirement is load-bearing: registration is open and (by
|
||||
default) doesn't require verification, so without it an attacker could claim
|
||||
the owner email by registering it before the operator does — a land-grab to
|
||||
the highest role with no proof of inbox control. Requiring verification ties
|
||||
ownership to actual control of the named inbox regardless of the global
|
||||
REQUIRE_EMAIL_VERIFICATION setting. (Self-hosts without SMTP can verify via
|
||||
the link the console mailer prints to the operator-controlled logs.)"""
|
||||
owners = get_settings().owner_emails()
|
||||
return (
|
||||
bool(owners)
|
||||
and user.email_verified_at is not None
|
||||
and user.email.strip().lower() in owners
|
||||
)
|
||||
|
||||
|
||||
async def require_instance_owner(current: CurrentUser) -> User:
|
||||
if not is_instance_owner(current):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "instance owner only")
|
||||
return current
|
||||
|
||||
|
||||
InstanceOwner = Annotated[User, Depends(require_instance_owner)]
|
||||
|
||||
|
||||
def get_mailer() -> Mailer:
|
||||
settings = get_settings()
|
||||
if settings.mailer == "smtp" and settings.smtp_host:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
admin,
|
||||
ai,
|
||||
auth,
|
||||
citations,
|
||||
@@ -38,3 +39,4 @@ api_router.include_router(public.router)
|
||||
api_router.include_router(members.router)
|
||||
api_router.include_router(proposals.router)
|
||||
api_router.include_router(ai.router)
|
||||
api_router.include_router(admin.router)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Instance-admin surface — owner-only (OWNER_EMAIL). Operational status and
|
||||
instance-wide configuration. Deliberately exposes no tree contents or PII:
|
||||
instance ownership is an operator role, not a privacy bypass."""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import InstanceOwner, SessionDep, configured_llm_providers
|
||||
from app.core.config import get_settings
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import InstanceStatus
|
||||
from app.schemas.ai_policy import ConfiguredProvider
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/instance", response_model=InstanceStatus)
|
||||
async def instance_status(owner: InstanceOwner, session: SessionDep) -> InstanceStatus:
|
||||
"""Operator dashboard data. Requires the caller to be an instance owner."""
|
||||
s = get_settings()
|
||||
user_count = await session.scalar(
|
||||
select(func.count()).select_from(User).where(User.deleted_at.is_(None))
|
||||
)
|
||||
tree_count = await session.scalar(
|
||||
select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None))
|
||||
)
|
||||
return InstanceStatus(
|
||||
version=s.version,
|
||||
env=s.app_env,
|
||||
owner_emails=sorted(s.owner_emails()),
|
||||
require_email_verification=s.require_email_verification,
|
||||
user_count=user_count or 0,
|
||||
tree_count=tree_count or 0,
|
||||
default_llm_provider=s.default_llm_provider,
|
||||
ai_providers=[ConfiguredProvider(**p) for p in configured_llm_providers()],
|
||||
)
|
||||
@@ -1,15 +1,21 @@
|
||||
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep, is_instance_owner
|
||||
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||
from app.services import account_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def _me(user) -> UserRead:
|
||||
out = UserRead.model_validate(user)
|
||||
out.is_instance_owner = is_instance_owner(user)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def read_me(current: CurrentUser) -> UserRead:
|
||||
return UserRead.model_validate(current)
|
||||
return _me(current)
|
||||
|
||||
|
||||
@router.patch("/me/self-person", response_model=UserRead)
|
||||
@@ -20,7 +26,7 @@ async def set_self_person(
|
||||
user = await user_service.set_self_person(
|
||||
session, user=current, person_id=data.self_person_id
|
||||
)
|
||||
return UserRead.model_validate(user)
|
||||
return _me(user)
|
||||
|
||||
|
||||
@router.get("/me/export")
|
||||
|
||||
@@ -22,6 +22,18 @@ class Settings(BaseSettings):
|
||||
version: str = "0.0.0"
|
||||
app_env: str = Field(default="development", description="development | production")
|
||||
|
||||
# --- Instance owner / operator ---
|
||||
# Email(s) of the instance owner(s) — the operator(s) who run this server.
|
||||
# The matching account(s) get instance-admin rights (instance-wide settings;
|
||||
# see /api/v1/admin). Comma-separated for several. Empty = no designated
|
||||
# owner (the instance has no operator account). Derived at request time, so
|
||||
# changing it takes effect immediately with no migration or DB state.
|
||||
owner_email: str = ""
|
||||
|
||||
def owner_emails(self) -> frozenset[str]:
|
||||
"""Normalized (lowercased, trimmed) owner emails; empty if none set."""
|
||||
return frozenset(e.strip().lower() for e in self.owner_email.split(",") if e.strip())
|
||||
|
||||
# SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Instance-admin schemas. Operator-facing, owner-only — operational status and
|
||||
config, never tree data or PII (instance ownership doesn't bypass privacy)."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.ai_policy import ConfiguredProvider
|
||||
|
||||
|
||||
class InstanceStatus(BaseModel):
|
||||
version: str
|
||||
env: str
|
||||
# Operator account(s) — the email(s) named in OWNER_EMAIL.
|
||||
owner_emails: list[str]
|
||||
require_email_verification: bool
|
||||
# Aggregate, non-identifying counts (live rows only).
|
||||
user_count: int
|
||||
tree_count: int
|
||||
# Instance-wide AI configuration (no secrets).
|
||||
default_llm_provider: str
|
||||
ai_providers: list[ConfiguredProvider]
|
||||
@@ -21,6 +21,9 @@ class UserRead(BaseModel):
|
||||
email_verified_at: datetime | None
|
||||
self_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
# Operational role, not a DB column: true when this account's email is named
|
||||
# in OWNER_EMAIL. Set by the API layer (see users.read_me).
|
||||
is_instance_owner: bool = False
|
||||
|
||||
|
||||
class UserSelfPersonUpdate(BaseModel):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Instance owner (OWNER_EMAIL): the operator account + the owner-only /admin
|
||||
surface. Ownership is derived from the env at request time — no DB column — and
|
||||
requires a *verified* email so the owner address can't be land-grabbed by
|
||||
whoever registers it first."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.deps import is_instance_owner
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from tests.conftest import auth, register
|
||||
|
||||
VERIFIED = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_is_instance_owner_matches_case_insensitively(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "owner_email", "Owner@Example.com, second@ex.com")
|
||||
assert is_instance_owner(User(email="owner@example.com", email_verified_at=VERIFIED)) is True
|
||||
assert is_instance_owner(User(email="SECOND@ex.com", email_verified_at=VERIFIED)) is True
|
||||
assert is_instance_owner(User(email="nope@ex.com", email_verified_at=VERIFIED)) is False
|
||||
|
||||
|
||||
def test_unverified_owner_email_is_not_owner(monkeypatch):
|
||||
"""The land-grab guard: a matching email with no verification is NOT owner."""
|
||||
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
|
||||
assert is_instance_owner(User(email="boss@ex.com", email_verified_at=None)) is False
|
||||
assert is_instance_owner(User(email="boss@ex.com", email_verified_at=VERIFIED)) is True
|
||||
|
||||
|
||||
def test_no_owner_when_unset(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "owner_email", "")
|
||||
# An empty OWNER_EMAIL designates no owner — and must never match the (also
|
||||
# empty-string-normalizing) edges.
|
||||
assert is_instance_owner(User(email="anyone@ex.com", email_verified_at=VERIFIED)) is False
|
||||
assert is_instance_owner(User(email="", email_verified_at=VERIFIED)) is False
|
||||
monkeypatch.setattr(get_settings(), "owner_email", " , ")
|
||||
assert is_instance_owner(User(email="", email_verified_at=VERIFIED)) is False
|
||||
|
||||
|
||||
async def _verify(db_session, email: str) -> None:
|
||||
await db_session.execute(
|
||||
text("UPDATE users SET email_verified_at = now() WHERE email = :e"), {"e": email}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_me_reports_instance_owner(client, db_session, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
|
||||
boss = auth(await register(client, "boss@ex.com"))
|
||||
other = auth(await register(client, "peon@ex.com"))
|
||||
await _verify(db_session, "boss@ex.com")
|
||||
assert (await client.get("/api/v1/users/me", headers=boss)).json()["is_instance_owner"] is True
|
||||
assert (await client.get("/api/v1/users/me", headers=other)).json()["is_instance_owner"] is False
|
||||
|
||||
|
||||
async def test_admin_instance_is_owner_only(client, db_session, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "owner_email", "boss@ex.com")
|
||||
boss = auth(await register(client, "boss@ex.com"))
|
||||
other = auth(await register(client, "peon@ex.com"))
|
||||
await _verify(db_session, "boss@ex.com")
|
||||
|
||||
assert (await client.get("/api/v1/admin/instance")).status_code == 401 # anon
|
||||
assert (await client.get("/api/v1/admin/instance", headers=other)).status_code == 403 # non-owner
|
||||
|
||||
r = await client.get("/api/v1/admin/instance", headers=boss)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["owner_emails"] == ["boss@ex.com"]
|
||||
assert body["user_count"] >= 2
|
||||
assert "ai_providers" in body and "default_llm_provider" in body
|
||||
@@ -4,6 +4,18 @@
|
||||
# --- Core ---
|
||||
APP_ENV=development
|
||||
|
||||
# Instance owner / operator. The account(s) whose email is named here get
|
||||
# instance-admin rights (the owner-only /admin surface, instance-wide settings).
|
||||
# Comma-separated for several owners. Leave empty for an instance with no
|
||||
# designated operator. Derived at request time — no migration, takes effect on
|
||||
# restart. Set this to YOUR account email on a real deployment.
|
||||
#
|
||||
# The named account must have a VERIFIED email to be recognized as owner — this
|
||||
# stops someone from claiming the owner address by registering it before you do.
|
||||
# Register this email and verify it (via SMTP, or the link the console mailer
|
||||
# prints to the backend logs) — ideally before exposing registration publicly.
|
||||
OWNER_EMAIL=
|
||||
|
||||
# --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) ---
|
||||
# test-main = current main build; or pin a semver / test-sha-<sha> for rollback.
|
||||
IMAGE_TAG=test-main
|
||||
|
||||
@@ -88,7 +88,8 @@ Core entities and the important relationships. (Illustrative, not final DDL.)
|
||||
### Tenancy & identity
|
||||
- **User** — a person with login. Auth method(s) are attached but identity is internal, so one user can link multiple providers.
|
||||
- **Tree** — the top-level tenant boundary for genealogical data. Owned by a User; may have additional members.
|
||||
- **TreeMembership** — (User, Tree, role) where role ∈ {owner, editor, viewer}. The basis for authorization.
|
||||
- **TreeMembership** — (User, Tree, role) where role ∈ {owner, editor, viewer}. The basis for authorization *within a tree*.
|
||||
- **Instance owner / operator** — orthogonal to tree roles. The account(s) whose email is named in the `OWNER_EMAIL` env var **and whose email is verified** are the instance's operator(s), with access to the owner-only `/api/v1/admin` surface (operational status, instance-wide config). Derived from the env at request time — no DB column, no migration, can't drift, survives DB resets. The verified-email requirement is deliberate: registration is open, so without it whoever registers the owner address first would seize the role — verification ties ownership to proven control of the inbox. Crucially this is **not** a privacy bypass: an instance owner gets operational/config rights, **not** read access to other users' private trees or living-person PII — those still resolve only through the privacy engine. (`is_instance_owner` in `api/deps.py`.)
|
||||
|
||||
### Genealogical core
|
||||
- **Person** — belongs to a Tree. Has many **Name** records (with parts: given, surname, prefix/suffix, and a type such as birth/married/alias) to support variants and changes over time. Carries living/deceased status.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type Instance = components["schemas"]["InstanceStatus"];
|
||||
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs uppercase tracking-wider text-[var(--muted)]">{label}</div>
|
||||
<div className="text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [instance, setInstance] = useState<Instance | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/v1/admin/instance");
|
||||
if (response.status === 403) setForbidden(true);
|
||||
else if (data) setInstance(data);
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (!ready) return <div className="p-6 text-sm text-[var(--muted)]">Loading…</div>;
|
||||
|
||||
// Fail closed on anything that isn't a successful owner load: 403 (not owner),
|
||||
// 401 (not signed in), or any 5xx all land here rather than dereferencing null.
|
||||
if (forbidden || !instance) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="text-xl font-semibold">Instance admin</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
{forbidden
|
||||
? "This area is for the instance owner only. Set OWNER_EMAIL in the server environment to your account email (and verify that email) to claim it."
|
||||
: "Instance status is unavailable right now. Make sure you're signed in as the instance owner."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const i = instance;
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="text-xl font-semibold">Instance admin</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Operational status for this deployment. You see this because your account is
|
||||
named in <code>OWNER_EMAIL</code>. Instance ownership is an operator role — it
|
||||
does not grant access to other people's private tree data.
|
||||
</p>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardContent className="grid grid-cols-2 gap-5 py-6 sm:grid-cols-3">
|
||||
<Stat label="Version" value={i.version} />
|
||||
<Stat label="Environment" value={i.env} />
|
||||
<Stat label="Users" value={i.user_count} />
|
||||
<Stat label="Trees" value={i.tree_count} />
|
||||
<Stat
|
||||
label="Email verification"
|
||||
value={i.require_email_verification ? "required" : "off"}
|
||||
/>
|
||||
<Stat label="Owner(s)" value={i.owner_emails.join(", ") || "—"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="flex flex-col gap-3 py-6">
|
||||
<div className="text-sm font-medium">AI providers (instance-wide)</div>
|
||||
{i.ai_providers.length === 0 ? (
|
||||
<div className="text-sm text-[var(--muted)]">
|
||||
None configured. Set provider credentials (Anthropic, OpenAI, x.AI, or
|
||||
Ollama) in the server environment.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{i.ai_providers.map((p) => (
|
||||
<li key={p.name} className="flex items-center justify-between">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-[var(--muted)]">{p.model}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="text-xs text-[var(--muted)]">
|
||||
Default provider: {i.default_llm_provider}. Per-tree AI policy is set on
|
||||
each tree's AI page.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LogOut,
|
||||
Network,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
Users,
|
||||
@@ -30,7 +31,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
||||
const [treeName, setTreeName] = useState<string | null>(null);
|
||||
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||||
const [me, setMe] = useState<{
|
||||
display_name: string | null;
|
||||
email: string;
|
||||
is_instance_owner?: boolean;
|
||||
} | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -98,6 +103,14 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||
<Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
|
||||
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
|
||||
{me?.is_instance_owner && (
|
||||
<Item
|
||||
href="/admin"
|
||||
label="Admin"
|
||||
icon={ShieldCheck}
|
||||
active={pathname.startsWith("/admin")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{treeId && (
|
||||
<div className="mt-5 flex flex-col gap-1">
|
||||
|
||||
Vendored
+64
@@ -1049,6 +1049,26 @@ export interface paths {
|
||||
patch: operations["update_ai_policy_api_v1_trees__tree_id__ai_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/instance": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Instance Status
|
||||
* @description Operator dashboard data. Requires the caller to be an instance owner.
|
||||
*/
|
||||
get: operations["instance_status_api_v1_admin_instance_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1418,6 +1438,25 @@ export interface components {
|
||||
/** Unmapped Tags */
|
||||
unmapped_tags: string[];
|
||||
};
|
||||
/** InstanceStatus */
|
||||
InstanceStatus: {
|
||||
/** Version */
|
||||
version: string;
|
||||
/** Env */
|
||||
env: string;
|
||||
/** Owner Emails */
|
||||
owner_emails: string[];
|
||||
/** Require Email Verification */
|
||||
require_email_verification: boolean;
|
||||
/** User Count */
|
||||
user_count: number;
|
||||
/** Tree Count */
|
||||
tree_count: number;
|
||||
/** Default Llm Provider */
|
||||
default_llm_provider: string;
|
||||
/** Ai Providers */
|
||||
ai_providers: components["schemas"]["ConfiguredProvider"][];
|
||||
};
|
||||
/** LoginRequest */
|
||||
LoginRequest: {
|
||||
/** Email */
|
||||
@@ -1998,6 +2037,11 @@ export interface components {
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Is Instance Owner
|
||||
* @default false
|
||||
*/
|
||||
is_instance_owner?: boolean;
|
||||
};
|
||||
/** UserSelfPersonUpdate */
|
||||
UserSelfPersonUpdate: {
|
||||
@@ -4760,4 +4804,24 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
instance_status_api_v1_admin_instance_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["InstanceStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4187,6 +4187,28 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/instance": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Instance Status",
|
||||
"description": "Operator dashboard data. Requires the caller to be an instance owner.",
|
||||
"operationId": "instance_status_api_v1_admin_instance_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InstanceStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -5399,6 +5421,60 @@
|
||||
],
|
||||
"title": "ImportReport"
|
||||
},
|
||||
"InstanceStatus": {
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version"
|
||||
},
|
||||
"env": {
|
||||
"type": "string",
|
||||
"title": "Env"
|
||||
},
|
||||
"owner_emails": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Owner Emails"
|
||||
},
|
||||
"require_email_verification": {
|
||||
"type": "boolean",
|
||||
"title": "Require Email Verification"
|
||||
},
|
||||
"user_count": {
|
||||
"type": "integer",
|
||||
"title": "User Count"
|
||||
},
|
||||
"tree_count": {
|
||||
"type": "integer",
|
||||
"title": "Tree Count"
|
||||
},
|
||||
"default_llm_provider": {
|
||||
"type": "string",
|
||||
"title": "Default Llm Provider"
|
||||
},
|
||||
"ai_providers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ConfiguredProvider"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Ai Providers"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"env",
|
||||
"owner_emails",
|
||||
"require_email_verification",
|
||||
"user_count",
|
||||
"tree_count",
|
||||
"default_llm_provider",
|
||||
"ai_providers"
|
||||
],
|
||||
"title": "InstanceStatus"
|
||||
},
|
||||
"LoginRequest": {
|
||||
"properties": {
|
||||
"email": {
|
||||
@@ -7194,6 +7270,11 @@
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At"
|
||||
},
|
||||
"is_instance_owner": {
|
||||
"type": "boolean",
|
||||
"title": "Is Instance Owner",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user