) => {
+ const id = String(record[idKey] ?? record.id);
+ return (
+
+ {d.paths?.one && (
+
+ )}
+ {d.paths?.update && (
+
+ )}
+ {d.paths?.remove && (
+ remove({ resource: key, id })}
+ >
+
+
+ )}
+
+
+ );
+ },
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/apps/admin-panel/src/pages/ResourceShow.tsx b/apps/admin-panel/src/pages/ResourceShow.tsx
new file mode 100644
index 000000000..29575b773
--- /dev/null
+++ b/apps/admin-panel/src/pages/ResourceShow.tsx
@@ -0,0 +1,51 @@
+import { useOne, useResource, useNavigation } from "@refinedev/core";
+import { Card, Descriptions, Button, Space, Alert, Spin } from "antd";
+
+import { getDescriptor, detailFields } from "../resources";
+import { fieldLabel, renderValue } from "../resources/fields";
+import { ResourceActions } from "./ResourceActions";
+
+/// Generated detail screen from the resource descriptor.
+export const ResourceShow = () => {
+ const { resource, id } = useResource();
+ const key = resource?.name ?? "";
+ const d = getDescriptor(key);
+ const { edit } = useNavigation();
+
+ const { data, isLoading, isError, error } = useOne({
+ resource: key,
+ id: String(id ?? ""),
+ });
+
+ if (isError) {
+ const message = (error as { message?: string })?.message ?? "Failed to load";
+ return ;
+ }
+ if (isLoading || !data?.data) return ;
+
+ const record = data.data as Record;
+
+ return (
+
+
+ {d.paths?.update && (
+
+ )}
+
+ }
+ >
+
+ {detailFields(d).map((f) => (
+
+ {renderValue(f, record[f.name])}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/admin-panel/src/pages/TenantTree.tsx b/apps/admin-panel/src/pages/TenantTree.tsx
new file mode 100644
index 000000000..d611d0341
--- /dev/null
+++ b/apps/admin-panel/src/pages/TenantTree.tsx
@@ -0,0 +1,161 @@
+import { useCallback, useEffect, useState } from "react";
+import { useNavigation, useDelete } from "@refinedev/core";
+import { Card, Table, Button, Space, Popconfirm, Alert } from "antd";
+
+import { apiFetch } from "../httpClient";
+import { cachedAdminContext } from "../adminContext";
+import { getDescriptor, idField, listFields } from "../resources";
+import { fieldLabel, renderValue } from "../resources/fields";
+import { ResourceActions } from "./ResourceActions";
+
+const AM = "/account-management/v1";
+
+// One tenant row, with lazily-loaded children. A node with children > 0 that
+// has not been expanded yet carries a single placeholder child so Ant Table
+// renders the expander; the placeholder is swapped for the real rows on
+// expand.
+type Row = Record & { children?: Row[] };
+
+const PLACEHOLDER = "__loading__";
+
+function withPlaceholder(rows: Row[]): Row[] {
+ return rows.map((r) => {
+ const count = Number(r.child_count ?? 0);
+ return count > 0 ? { ...r, children: [{ id: `${String(r.id)}:${PLACEHOLDER}` }] } : r;
+ });
+}
+
+function isPlaceholder(r: Row): boolean {
+ return String(r.id).endsWith(`:${PLACEHOLDER}`);
+}
+
+/** Recursively replace the children of the node with `id`. */
+function injectChildren(rows: Row[], id: string, children: Row[]): Row[] {
+ return rows.map((r) => {
+ if (String(r.id) === id) return { ...r, children: withPlaceholder(children) };
+ if (r.children) return { ...r, children: injectChildren(r.children, id, children) };
+ return r;
+ });
+}
+
+/// Tenants hierarchy view: the home tenant as the root, children loaded lazily
+/// per node via `/tenants/{id}/children`. Reuses the tenants descriptor for
+/// columns, row actions, and navigation, so it stays in sync with the registry.
+export const TenantTree = () => {
+ const d = getDescriptor("tenants");
+ const idKey = idField(d);
+ const { show, edit, create } = useNavigation();
+ const { mutate: remove } = useDelete();
+
+ const [rows, setRows] = useState([]);
+ const [expandedKeys, setExpandedKeys] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchChildren = useCallback(async (id: string): Promise => {
+ const payload = await apiFetch<{ items?: Row[] }>(`${AM}/tenants/${id}/children`);
+ return payload.items ?? [];
+ }, []);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const ctx = cachedAdminContext();
+ if (!ctx) throw new Error("No admin context — sign in first");
+ const home = ctx.subject_tenant_id;
+ const root = await apiFetch(`${AM}/tenants/${home}`);
+ root.children = withPlaceholder(await fetchChildren(home));
+ setRows([root]);
+ // Expand the root by default so its children are visible immediately.
+ setExpandedKeys([String(root[idKey] ?? root.id)]);
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchChildren]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const onExpand = async (expanded: boolean, record: Row) => {
+ const id = String(record[idKey] ?? record.id);
+ setExpandedKeys((prev) =>
+ expanded ? [...new Set([...prev, id])] : prev.filter((k) => k !== id),
+ );
+ if (!expanded) return;
+ const loaded = record.children?.some((c) => !isPlaceholder(c));
+ if (loaded) return;
+ try {
+ const kids = await fetchChildren(id);
+ setRows((prev) => injectChildren(prev, id, kids));
+ } catch (e) {
+ // Surface the failure instead of swallowing it: collapse the node again
+ // and show the error so the expander doesn't sit on an empty placeholder.
+ setExpandedKeys((prev) => prev.filter((k) => k !== id));
+ setError((e as Error).message);
+ }
+ };
+
+ if (error) {
+ return ;
+ }
+
+ const columns = [
+ ...listFields(d).map((f) => ({
+ title: fieldLabel(f),
+ dataIndex: f.name,
+ key: f.name,
+ render: (v: unknown, record: Row) => (isPlaceholder(record) ? "" : renderValue(f, v)),
+ })),
+ {
+ title: "Actions",
+ key: "__actions",
+ render: (_: unknown, record: Row) => {
+ if (isPlaceholder(record)) return "…";
+ const id = String(record[idKey] ?? record.id);
+ return (
+
+ {d.paths?.one && }
+ {d.paths?.update && }
+ {d.paths?.remove && (
+ remove({ resource: "tenants", id }, { onSuccess: load })}
+ >
+
+
+ )}
+
+
+ );
+ },
+ },
+ ];
+
+ return (
+ create("tenants")}>Create}
+ >
+
Boolean(r.children?.length),
+ }}
+ />
+
+ );
+};
diff --git a/apps/admin-panel/src/resources/admin.config.json b/apps/admin-panel/src/resources/admin.config.json
new file mode 100644
index 000000000..15ba097a9
--- /dev/null
+++ b/apps/admin-panel/src/resources/admin.config.json
@@ -0,0 +1,174 @@
+{
+ "$comment": "Declarative admin resource registration (ADR-0003 concern-split). This is DATA, not code: another gears-rust project registers its resources by editing this file — no TypeScript. API-intrinsic facts (field types, CRUD verbs, tenant-scope) are derived at runtime from the aggregated OpenAPI spec; this file carries only what the spec can't express (presentation + policy). Templates: {tenant} = caller home tenant, {id} = record id. optionsSource names a generic option loader; visibleWhen gates an action by a record field value.",
+ "resources": [
+ {
+ "key": "tenants",
+ "label": "Tenants",
+ "owningGear": "account-management",
+ "tenantScope": "tenant",
+ "safety": "destructive",
+ "schema": "TenantDto",
+ "basePath": "/account-management/v1/tenants",
+ "listPathTemplate": "/account-management/v1/tenants/{tenant}/children",
+ "capabilities": { "read": "tenants:read", "write": "tenants:write", "delete": "tenants:write" },
+ "formDefaults": {
+ "parent_id": "{tenant}",
+ "tenant_type": "gts.cf.core.am.tenant_type.v1~cf.core.am.customer.v1~",
+ "self_managed": false
+ },
+ "fields": [
+ { "name": "id", "inList": true },
+ { "name": "name", "inList": true, "inForm": true },
+ { "name": "status", "type": "tag", "inList": true },
+ { "name": "tenant_type", "label": "Type", "inList": true, "createOnly": true, "required": true, "optionsSource": "tenant-types" },
+ { "name": "parent_id", "label": "Parent", "relation": "tenants", "createOnly": true },
+ { "name": "self_managed", "createOnly": true },
+ { "name": "child_count", "label": "Children", "inList": true }
+ ],
+ "actions": [
+ { "name": "suspend", "label": "Suspend", "method": "POST", "pathTemplate": "/account-management/v1/tenants/{id}/suspend", "capability": "tenants:suspend", "safety": "destructive", "visibleWhen": { "field": "status", "equals": "active" } },
+ { "name": "unsuspend", "label": "Unsuspend", "method": "POST", "pathTemplate": "/account-management/v1/tenants/{id}/unsuspend", "capability": "tenants:suspend", "safety": "normal", "visibleWhen": { "field": "status", "equals": "suspended" } }
+ ]
+ },
+ {
+ "key": "conversions",
+ "label": "Conversions",
+ "owningGear": "account-management",
+ "tenantScope": "tenant",
+ "safety": "normal",
+ "schema": "OwnConversionRequestDto",
+ "basePath": "/account-management/v1/tenants/{tenant}/conversions",
+ "suppressVerbs": ["create", "update"],
+ "capabilities": { "read": "conversions:read", "write": "conversions:write" },
+ "fields": [
+ { "name": "id", "inList": true, "readOnly": true },
+ { "name": "status", "type": "tag", "inList": true },
+ { "name": "target_mode", "label": "Target mode", "type": "string", "inList": true },
+ { "name": "side", "inList": true },
+ { "name": "comment" },
+ { "name": "created_at", "inList": true }
+ ],
+ "actions": [
+ { "name": "approve", "label": "Approve", "method": "PATCH", "pathTemplate": "/account-management/v1/tenants/{tenant}/conversions/{id}", "body": { "status": "approved" }, "capability": "conversions:write", "safety": "normal", "visibleWhen": { "field": "status", "equals": "pending" } },
+ { "name": "reject", "label": "Reject", "method": "PATCH", "pathTemplate": "/account-management/v1/tenants/{tenant}/conversions/{id}", "body": { "status": "rejected" }, "capability": "conversions:write", "safety": "destructive", "visibleWhen": { "field": "status", "equals": "pending" } },
+ { "name": "cancel", "label": "Cancel", "method": "PATCH", "pathTemplate": "/account-management/v1/tenants/{tenant}/conversions/{id}", "body": { "status": "cancelled" }, "capability": "conversions:write", "safety": "destructive", "visibleWhen": { "field": "status", "equals": "pending" } }
+ ]
+ },
+ {
+ "key": "users",
+ "label": "Users",
+ "owningGear": "account-management",
+ "tenantScope": "tenant",
+ "safety": "destructive",
+ "schema": "UserDto",
+ "basePath": "/account-management/v1/tenants/{tenant}/users",
+ "capabilities": { "read": "users:read", "write": "users:write", "delete": "users:write" },
+ "fields": [
+ { "name": "id", "inList": true, "readOnly": true },
+ { "name": "username", "inList": true, "inForm": true, "required": true },
+ { "name": "email", "inList": true, "inForm": true },
+ { "name": "display_name", "label": "Display name", "inList": true, "inForm": true },
+ { "name": "first_name", "label": "First name", "inForm": true },
+ { "name": "last_name", "label": "Last name", "inForm": true }
+ ]
+ },
+ {
+ "key": "tenant-metadata",
+ "label": "Tenant metadata",
+ "owningGear": "account-management",
+ "tenantScope": "tenant",
+ "safety": "destructive",
+ "schema": "TenantMetadataEntryDto",
+ "basePath": "/account-management/v1/tenants/{tenant}/metadata",
+ "idField": "type_id",
+ "createKeyField": "type_id",
+ "bodyField": "value",
+ "capabilities": { "read": "tenant-metadata:read", "write": "tenant-metadata:write", "delete": "tenant-metadata:write" },
+ "fields": [
+ { "name": "type_id", "label": "Type id", "inList": true, "createOnly": true },
+ { "name": "value", "inList": true, "inForm": true },
+ { "name": "updated_at", "label": "Updated", "inList": true }
+ ]
+ },
+ {
+ "key": "resource-groups",
+ "label": "Resource groups",
+ "owningGear": "resource-group",
+ "tenantScope": "tenant",
+ "safety": "destructive",
+ "schema": "GroupDto",
+ "basePath": "/resource-group/v1/groups",
+ "capabilities": { "read": "resource-groups:read", "write": "resource-groups:write", "delete": "resource-groups:write" },
+ "fields": [
+ { "name": "id", "inList": true },
+ { "name": "name", "inList": true, "inForm": true },
+ { "name": "type", "label": "Type", "inList": true, "createOnly": true },
+ { "name": "parent_id", "label": "Parent", "relation": "resource-groups", "inForm": true },
+ { "name": "metadata", "inForm": true }
+ ]
+ },
+ {
+ "key": "types",
+ "label": "Types / GTS",
+ "owningGear": "types-registry",
+ "tenantScope": "global",
+ "safety": "read-only",
+ "schema": "GtsEntityDto",
+ "basePath": "/types-registry/v1/entities",
+ "idField": "gts_id",
+ "capabilities": { "read": "types:read" },
+ "fields": [
+ { "name": "gts_id", "label": "GTS id", "inList": true, "readOnly": true },
+ { "name": "is_schema", "label": "Schema?", "inList": true }
+ ]
+ },
+ {
+ "key": "gears",
+ "label": "Gears",
+ "owningGear": "gear-orchestrator",
+ "tenantScope": "global",
+ "safety": "read-only",
+ "basePath": "/gear-orchestrator/v1/gears",
+ "idField": "name",
+ "capabilities": { "read": "gears:read" },
+ "fields": [
+ { "name": "name", "inList": true, "readOnly": true },
+ { "name": "capabilities", "type": "json", "inList": true },
+ { "name": "dependencies", "type": "json" },
+ { "name": "deployment_mode", "label": "Mode", "inList": true }
+ ]
+ },
+ {
+ "key": "upstreams",
+ "label": "Egress upstreams",
+ "owningGear": "oagw",
+ "tenantScope": "tenant",
+ "safety": "read-only",
+ "schema": "UpstreamResponse",
+ "basePath": "/oagw/v1/upstreams",
+ "fields": [
+ { "name": "id", "inList": true, "readOnly": true },
+ { "name": "alias", "inList": true },
+ { "name": "protocol", "type": "tag", "inList": true },
+ { "name": "enabled", "inList": true },
+ { "name": "rate_limit", "label": "Rate limit" }
+ ]
+ },
+ {
+ "key": "routes",
+ "label": "Egress routes",
+ "owningGear": "oagw",
+ "tenantScope": "tenant",
+ "safety": "read-only",
+ "schema": "RouteResponse",
+ "basePath": "/oagw/v1/routes",
+ "fields": [
+ { "name": "id", "inList": true, "readOnly": true },
+ { "name": "upstream_id", "label": "Upstream", "inList": true },
+ { "name": "priority", "inList": true },
+ { "name": "enabled", "inList": true },
+ { "name": "rate_limit", "label": "Rate limit" }
+ ]
+ }
+ ]
+}
diff --git a/apps/admin-panel/src/resources/configLoader.ts b/apps/admin-panel/src/resources/configLoader.ts
new file mode 100644
index 000000000..2f728931d
--- /dev/null
+++ b/apps/admin-panel/src/resources/configLoader.ts
@@ -0,0 +1,174 @@
+// Interpret the declarative admin resource config (admin.config.json) into the
+// runtime ResourceDescriptor model.
+//
+// ADR-0003 concern-split: per-resource registration is DATA (JSON), not code.
+// This module is the generic interpreter that turns that data into the
+// descriptors the app consumes — building the few behavioral bits (path
+// templates, form defaults, action wiring, option loaders, visibility
+// predicates) declaratively. It holds no per-resource knowledge, so another
+// gears-rust project registers resources by shipping its own JSON with no
+// TypeScript changes.
+
+import { apiFetch } from "../httpClient";
+import type {
+ ActionDef,
+ FieldDef,
+ FieldOption,
+ ResourceDescriptor,
+ SafetyLevel,
+ TenantScope,
+ Verb,
+} from "./types";
+
+// --- Generic, reusable option loaders (named infra, not per-resource code) ---
+
+const TENANT_TYPE_PREFIX = "gts.cf.core.am.tenant_type.v1~";
+
+/** Load registered tenant types for a create-form select (skips the base). */
+async function loadTenantTypes(): Promise {
+ const res = await apiFetch<{ entities?: { gts_id: string }[] }>(
+ "/types-registry/v1/entities",
+ );
+ return (res.entities ?? [])
+ .map((e) => e.gts_id)
+ .filter((id) => id.startsWith(TENANT_TYPE_PREFIX) && id !== TENANT_TYPE_PREFIX)
+ .map((id) => {
+ const seg = id.split("~").filter(Boolean).pop() ?? id;
+ const name = seg.split(".").slice(-2, -1)[0] ?? seg;
+ return { value: id, label: name };
+ });
+}
+
+/** Named option loaders referenced by `field.optionsSource` in the config. */
+const OPTION_LOADERS: Record Promise> = {
+ "tenant-types": loadTenantTypes,
+};
+
+// --- JSON config shape ---
+
+interface FieldConfig {
+ name: string;
+ label?: string;
+ type?: string;
+ inList?: boolean;
+ inDetail?: boolean;
+ inForm?: boolean;
+ createOnly?: boolean;
+ required?: boolean;
+ readOnly?: boolean;
+ relation?: string;
+ /** Name of a generic option loader in OPTION_LOADERS. */
+ optionsSource?: string;
+}
+
+interface ActionConfig {
+ name: string;
+ label: string;
+ method?: "POST" | "PATCH" | "PUT" | "DELETE";
+ /** Path template with `{tenant}` / `{id}` placeholders. */
+ pathTemplate: string;
+ /** Static request body (e.g. a status transition). */
+ body?: unknown;
+ capability?: string;
+ safety?: SafetyLevel;
+ /** Show the action only when the record field equals this value. */
+ visibleWhen?: { field: string; equals: unknown };
+}
+
+interface ResourceConfig {
+ key: string;
+ label: string;
+ owningGear: string;
+ tenantScope: string;
+ safety: string;
+ schema?: string;
+ basePath: string;
+ /** Template for an irregular list path (`{tenant}` placeholder). */
+ listPathTemplate?: string;
+ suppressVerbs?: string[];
+ idField?: string;
+ createKeyField?: string;
+ bodyField?: string;
+ capabilities?: { read?: string; write?: string; delete?: string };
+ /** Create-form defaults; string values may embed `{tenant}`. */
+ formDefaults?: Record;
+ fields: FieldConfig[];
+ actions?: ActionConfig[];
+}
+
+export interface AdminConfig {
+ resources: ResourceConfig[];
+}
+
+// --- Template + predicate helpers ---
+
+/** Substitute `{tenant}` (home tenant) and `{id}` (record id) in a template. */
+function fill(tpl: string, tenant: string, id?: string): string {
+ return tpl
+ .replace(/\{tenant\}/g, encodeURIComponent(tenant))
+ .replace(/\{id\}/g, id !== undefined ? encodeURIComponent(id) : "");
+}
+
+function toField(f: FieldConfig): FieldDef {
+ const { optionsSource, type, ...rest } = f;
+ const field: FieldDef = { ...rest, type: type as FieldDef["type"] };
+ if (optionsSource) {
+ const loader = OPTION_LOADERS[optionsSource];
+ if (!loader) {
+ throw new Error(
+ `Unknown optionsSource "${optionsSource}" for field "${f.name}"`,
+ );
+ }
+ field.options = loader;
+ }
+ return field;
+}
+
+function toAction(a: ActionConfig): ActionDef {
+ const when = a.visibleWhen;
+ return {
+ name: a.name,
+ label: a.label,
+ method: a.method,
+ path: (ctx, id) => fill(a.pathTemplate, ctx.subject_tenant_id, id),
+ body: a.body !== undefined ? () => a.body : undefined,
+ capability: a.capability,
+ safety: a.safety,
+ visible: when ? (r) => r[when.field] === when.equals : undefined,
+ };
+}
+
+/** Interpret the JSON config into runtime resource descriptors. */
+export function buildDescriptors(config: AdminConfig): ResourceDescriptor[] {
+ return config.resources.map((r) => ({
+ key: r.key,
+ label: r.label,
+ owningGear: r.owningGear,
+ tenantScope: r.tenantScope as TenantScope,
+ safety: r.safety as SafetyLevel,
+ schema: r.schema,
+ basePath: r.basePath,
+ listPath: r.listPathTemplate
+ ? (ctx) => fill(r.listPathTemplate!, ctx.subject_tenant_id)
+ : undefined,
+ suppressVerbs: r.suppressVerbs as Verb[] | undefined,
+ idField: r.idField,
+ createKeyField: r.createKeyField,
+ bodyField: r.bodyField,
+ capabilities: r.capabilities,
+ formDefaults: r.formDefaults
+ ? (ctx) => {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(r.formDefaults!)) {
+ out[k] =
+ typeof v === "string"
+ ? v.replace(/\{tenant\}/g, ctx.subject_tenant_id)
+ : v;
+ }
+ return out;
+ }
+ : undefined,
+ fields: r.fields.map(toField),
+ actions: r.actions?.map(toAction),
+ }));
+}
diff --git a/apps/admin-panel/src/resources/fields.tsx b/apps/admin-panel/src/resources/fields.tsx
new file mode 100644
index 000000000..5dbc179b3
--- /dev/null
+++ b/apps/admin-panel/src/resources/fields.tsx
@@ -0,0 +1,125 @@
+import { Tag, Input, InputNumber, Switch, Select } from "antd";
+import { useEffect, useState, type ReactNode } from "react";
+
+import type { FieldDef, FieldOption } from "./types";
+
+const { TextArea } = Input;
+
+/// Searchable select whose options load lazily from the backend. Ant Design's
+/// Form.Item injects `value`/`onChange`, so they are accepted and forwarded.
+function AsyncSelect({
+ load,
+ disabled,
+ value,
+ onChange,
+}: {
+ load: () => Promise;
+ disabled?: boolean;
+ value?: string;
+ onChange?: (v: string) => void;
+}) {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ let live = true;
+ setLoading(true);
+ load()
+ .then((o) => live && setOptions(o))
+ .catch(() => live && setOptions([]))
+ .finally(() => live && setLoading(false));
+ return () => {
+ live = false;
+ };
+ }, [load]);
+
+ return (
+
+ );
+}
+
+/** Human label for a field (explicit `label` or a Title-cased name). */
+export function fieldLabel(f: FieldDef): string {
+ if (f.label) return f.label;
+ return f.name
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/** Render a stored value for list/detail display, by field type. */
+export function renderValue(f: FieldDef, value: unknown): ReactNode {
+ if (value === null || value === undefined || value === "") return "—";
+ switch (f.type) {
+ case "tag":
+ return {String(value)};
+ case "boolean":
+ return value ? "Yes" : "No";
+ case "json":
+ return Array.isArray(value)
+ ? value.map((v) => String(v)).join(", ") || "—"
+ : JSON.stringify(value);
+ default:
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
+ }
+}
+
+/**
+ * Antd form control for a field. JSON fields edit as text and are parsed on
+ * submit (see `parseFormValues`). Read-only fields render disabled.
+ */
+export function fieldInput(f: FieldDef): ReactNode {
+ const disabled = f.readOnly === true;
+ if (f.options) {
+ return ;
+ }
+ switch (f.type) {
+ case "boolean":
+ return ;
+ case "number":
+ return ;
+ case "json":
+ return ;
+ default:
+ return ;
+ }
+}
+
+/** valuePropName differs for the Switch control. */
+export function valueProp(f: FieldDef): string {
+ return f.type === "boolean" ? "checked" : "value";
+}
+
+/**
+ * Coerce raw form values into the API payload: JSON text fields are parsed,
+ * empty optional fields are dropped so the backend keeps its defaults.
+ */
+export function parseFormValues(
+ fields: FieldDef[],
+ values: Record,
+): Record {
+ const out: Record = {};
+ for (const f of fields) {
+ const v = values[f.name];
+ if (v === undefined || v === "") continue;
+ if (f.type === "json" && typeof v === "string") {
+ try {
+ out[f.name] = JSON.parse(v);
+ } catch {
+ throw new Error(`Field "${fieldLabel(f)}" must be valid JSON`);
+ }
+ } else {
+ out[f.name] = v;
+ }
+ }
+ return out;
+}
diff --git a/apps/admin-panel/src/resources/index.ts b/apps/admin-panel/src/resources/index.ts
new file mode 100644
index 000000000..7a322d859
--- /dev/null
+++ b/apps/admin-panel/src/resources/index.ts
@@ -0,0 +1,80 @@
+import type { IResourceItem } from "@refinedev/core";
+
+import { RESOURCE_REGISTRY, resourceIcon } from "./registry";
+import type { ResourceDescriptor, FieldDef, ResourcePaths } from "./types";
+
+export type { ResourceDescriptor, FieldDef, ActionDef } from "./types";
+export { RESOURCE_REGISTRY, resourceIcon } from "./registry";
+export { ensureRegistryResolved } from "./openapi";
+
+/**
+ * The resource's resolved CRUD paths. Built at boot from the OpenAPI spec by
+ * `ensureRegistryResolved()`; throws if that has not run (or the spec was
+ * unreachable), which surfaces as a clear error rather than a silent no-op.
+ */
+export function resourcePaths(d: ResourceDescriptor): ResourcePaths {
+ if (!d.paths) {
+ throw new Error(
+ `Resource "${d.key}" has no resolved paths — the OpenAPI spec was not loaded`,
+ );
+ }
+ return d.paths;
+}
+
+const BY_KEY: Record = Object.fromEntries(
+ RESOURCE_REGISTRY.map((d) => [d.key, d]),
+);
+
+/** Look up a descriptor by Refine resource key, or throw if unknown. */
+export function getDescriptor(key: string): ResourceDescriptor {
+ const d = BY_KEY[key];
+ if (!d) {
+ throw new Error(`Unknown admin resource: ${key}`);
+ }
+ return d;
+}
+
+/** Record identity field for a resource ("id" unless overridden). */
+export function idField(d: ResourceDescriptor): string {
+ return d.idField ?? "id";
+}
+
+/** Fields shown as list columns. */
+export function listFields(d: ResourceDescriptor): FieldDef[] {
+ return d.fields.filter((f) => f.inList);
+}
+
+/** Fields shown in the detail/show panel (all unless explicitly hidden). */
+export function detailFields(d: ResourceDescriptor): FieldDef[] {
+ return d.fields.filter((f) => f.inDetail !== false);
+}
+
+/** Fields editable in the create form. */
+export function createFields(d: ResourceDescriptor): FieldDef[] {
+ return d.fields.filter((f) => f.inForm || f.createOnly);
+}
+
+/** Fields editable in the edit form (create-only fields are immutable). */
+export function editFields(d: ResourceDescriptor): FieldDef[] {
+ return d.fields.filter((f) => f.inForm && !f.createOnly);
+}
+
+/**
+ * Build the Refine `resources` array (navigation + route metadata) from the
+ * registry. A verb route is advertised only when its path builder exists, so
+ * resources without create/edit/delete degrade gracefully.
+ */
+export function refineResources(): IResourceItem[] {
+ return RESOURCE_REGISTRY.map((d) => ({
+ name: d.key,
+ list: `/${d.key}`,
+ ...(d.paths?.one ? { show: `/${d.key}/show/:id` } : {}),
+ ...(d.paths?.create ? { create: `/${d.key}/create` } : {}),
+ ...(d.paths?.update ? { edit: `/${d.key}/edit/:id` } : {}),
+ meta: {
+ label: d.label,
+ icon: resourceIcon(d.key),
+ canDelete: Boolean(d.paths?.remove),
+ },
+ }));
+}
diff --git a/apps/admin-panel/src/resources/openapi.ts b/apps/admin-panel/src/resources/openapi.ts
new file mode 100644
index 000000000..6dee13d0d
--- /dev/null
+++ b/apps/admin-panel/src/resources/openapi.ts
@@ -0,0 +1,169 @@
+// Runtime OpenAPI-driven field discovery.
+//
+// ADR-0003 (revised 2026-06-30): discovery moves from a fully hand-curated
+// registry toward reading the gateway-aggregated `/openapi.json` at runtime.
+// This module derives a resource's field set (name / type / required /
+// read-only) from its OpenAPI component schema, so descriptors no longer
+// duplicate the API's own type truth. What OpenAPI cannot express
+// (per-view visibility, labels, relations, custom-action wiring, safety,
+// tenant scope) stays curated in the descriptor and overrides the derived
+// defaults. The transport for that curated overlay (config / `x-cf-admin-*`
+// extensions / descriptor endpoint) is still pending; this engine is
+// independent of that choice.
+
+import { apiFetch } from "../httpClient";
+import { RESOURCE_REGISTRY } from "./registry";
+import { buildPaths, deriveOps } from "./openapiOps";
+import type { FieldDef, FieldType, Verb } from "./types";
+
+/** Minimal slice of an OpenAPI 3.1 document — only what discovery reads. */
+interface OpenApiSpec {
+ components?: { schemas?: Record };
+ paths?: Record>;
+}
+
+/** Minimal JSON Schema node (utoipa output). */
+interface JsonSchema {
+ // utoipa emits either a single type or `["string", "null"]` for nullable.
+ type?: string | string[];
+ format?: string;
+ readOnly?: boolean;
+ required?: string[];
+ properties?: Record;
+ allOf?: JsonSchema[];
+ $ref?: string;
+}
+
+/** Pick the non-null member of a possibly-nullable `type`. */
+function baseType(t: JsonSchema["type"]): string | undefined {
+ if (Array.isArray(t)) return t.find((x) => x !== "null");
+ return t;
+}
+
+/** Map a JSON Schema property to the admin field render/parse hint. */
+function fieldType(prop: JsonSchema): FieldType {
+ if (prop.$ref) return "json"; // nested entity / relation — rendered as JSON
+ if (prop.format === "uuid") return "uuid";
+ if (prop.format === "date-time") return "datetime";
+ switch (baseType(prop.type)) {
+ case "integer":
+ case "number":
+ return "number";
+ case "boolean":
+ return "boolean";
+ case "string":
+ return "string";
+ case "object":
+ case "array":
+ return "json";
+ default:
+ return "json";
+ }
+}
+
+/**
+ * Derive field descriptors from a component schema's properties. Only the
+ * facts OpenAPI states authoritatively are emitted: name, type, `required`,
+ * and `readOnly`. Presentation (visibility, labels) is left to the curated
+ * overlay.
+ */
+export function schemaToFields(schema: JsonSchema): FieldDef[] {
+ // Flatten a top-level allOf (utoipa uses it to compose/extend schemas).
+ const merged: JsonSchema = schema.allOf
+ ? schema.allOf.reduce(
+ (acc, part) => ({
+ properties: { ...acc.properties, ...part.properties },
+ required: [...(acc.required ?? []), ...(part.required ?? [])],
+ }),
+ { properties: { ...schema.properties }, required: [...(schema.required ?? [])] },
+ )
+ : schema;
+
+ const required = new Set(merged.required ?? []);
+ return Object.entries(merged.properties ?? {}).map(([name, prop]) => ({
+ name,
+ type: fieldType(prop),
+ required: required.has(name),
+ readOnly: prop.readOnly === true,
+ }));
+}
+
+/** Resolve a named component schema's fields, or `[]` if absent. */
+export function deriveFields(spec: OpenApiSpec, schemaName: string): FieldDef[] {
+ const schema = spec.components?.schemas?.[schemaName];
+ return schema ? schemaToFields(schema) : [];
+}
+
+/**
+ * Merge OpenAPI-derived fields with the curated overlay. The curated entry
+ * wins on every property it sets (label, type override, visibility, relation,
+ * options, …); derived facts (type / required / readOnly) fill the gaps.
+ * Derived-only fields are appended so the descriptor need not list them.
+ * Curated field order is preserved; appended fields keep schema order.
+ */
+export function mergeFields(curated: FieldDef[], derived: FieldDef[]): FieldDef[] {
+ const byName = new Map(derived.map((f) => [f.name, f]));
+ const out: FieldDef[] = curated.map((c) => {
+ const d = byName.get(c.name);
+ byName.delete(c.name);
+ return d ? { ...d, ...c } : c;
+ });
+ for (const d of byName.values()) out.push(d);
+ return out;
+}
+
+// The aggregated spec is fetched once and cached for the session.
+let specPromise: Promise | null = null;
+
+/** Fetch and cache the gateway-aggregated OpenAPI document. */
+export function loadOpenApiSpec(): Promise {
+ if (!specPromise) {
+ specPromise = apiFetch("/openapi.json").catch((err) => {
+ specPromise = null; // allow a later retry
+ throw err;
+ });
+ }
+ return specPromise;
+}
+
+// Resolve each descriptor (fields + CRUD paths) in place, exactly once.
+let resolved = false;
+
+/**
+ * Resolve the in-memory registry against the aggregated OpenAPI spec, mutating
+ * each descriptor before any screen renders. For every resource this builds the
+ * CRUD `paths` from its `basePath` (the API is the source of truth for which
+ * verbs exist); for resources that also declare a `schema` it enriches
+ * `fields` (derived type / `required` / `readOnly` fill gaps, curated entries
+ * override presentation).
+ *
+ * Runs once per session (idempotent). Best-effort: if the spec cannot be
+ * fetched, descriptors keep their curated fields and gain no `paths` — the auth
+ * gate retries on the next check, and the spec is public so it normally
+ * succeeds whenever the backend is reachable.
+ */
+export async function ensureRegistryResolved(): Promise {
+ if (resolved) return;
+ let spec: OpenApiSpec;
+ try {
+ spec = await loadOpenApiSpec();
+ } catch {
+ return; // retry on the next auth check
+ }
+ for (const d of RESOURCE_REGISTRY) {
+ if (d.schema) {
+ const derived = deriveFields(spec, d.schema);
+ if (derived.length > 0) d.fields = mergeFields(d.fields, derived);
+ }
+ const ops = deriveOps(spec, d.basePath);
+ // A read-only resource offers no writes even where the API exposes them.
+ const readOnlyVerbs: Verb[] =
+ d.safety === "read-only" ? ["create", "update", "remove"] : [];
+ d.paths = buildPaths(ops, {
+ listPath: d.listPath,
+ suppress: [...(d.suppressVerbs ?? []), ...readOnlyVerbs],
+ });
+ if (ops.updateMethod && !d.updateMethod) d.updateMethod = ops.updateMethod;
+ }
+ resolved = true;
+}
diff --git a/apps/admin-panel/src/resources/openapiOps.ts b/apps/admin-panel/src/resources/openapiOps.ts
new file mode 100644
index 000000000..5d2bb43d5
--- /dev/null
+++ b/apps/admin-panel/src/resources/openapiOps.ts
@@ -0,0 +1,159 @@
+// Runtime OpenAPI operation/path discovery.
+//
+// ADR-0003 (revised 2026-07-02): resource routes are API-intrinsic, so they
+// are derived from the gateway-aggregated `/openapi.json` rather than
+// hand-written per resource. Given a resource's `basePath` (its collection
+// path), this module finds the standard CRUD operations the spec actually
+// exposes and returns path *templates*; a resolver fills the tenant and
+// record-id parameters at call time. Presentation and policy (irregular list
+// paths, verb suppression, custom actions, labels) stay in the descriptor —
+// see `resources/types.ts` and `registry.ts`.
+
+import type { AdminContext } from "../adminContext";
+import type { ResourcePaths, Verb } from "./types";
+
+/** Minimal slice of an OpenAPI document — only the path map is read here. */
+export interface OpenApiPaths {
+ paths?: Record>;
+}
+
+/** A custom (non-CRUD) action discovered as a POST-only leaf under the item. */
+export interface DerivedAction {
+ name: string;
+ method: "POST";
+ template: string;
+}
+
+/** Operations derived for one resource from the spec. */
+export interface DerivedOps {
+ /** Raw OpenAPI path template per CRUD verb (absent verb => unsupported). */
+ templates: Partial>;
+ /** HTTP verb the spec uses for the item update (PUT preferred over PATCH). */
+ updateMethod?: "PUT" | "PATCH";
+ /** POST-only leaf operations under the item path (e.g. suspend/unsuspend). */
+ actions: DerivedAction[];
+}
+
+function escapeRe(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * Derive the standard CRUD operations for a resource from the aggregated spec.
+ * `basePath` is the collection path (e.g. `/resource-group/v1/groups` or
+ * `/account-management/v1/tenants/{tenant_id}/conversions`). The item path is
+ * `basePath/{param}`; list/create sit on the collection, one/update/remove on
+ * the item. Only operations the spec declares are returned.
+ */
+export function deriveOps(spec: OpenApiPaths, basePath: string): DerivedOps {
+ const paths = spec.paths ?? {};
+ const has = (k: string, m: string): boolean =>
+ Boolean(paths[k]) && m in paths[k];
+
+ // Match spec paths *structurally*: a descriptor's `basePath` uses the config's
+ // own placeholder convention (`{tenant}`), which need not equal the spec's
+ // parameter names (`{tenant_id}`). Collapse every path parameter to a single
+ // wildcard before comparing so `{tenant}` lines up with `{tenant_id}`. The
+ // templates returned are the spec's *real* keys, so `resolvePath` fills the
+ // real parameters (it keys off `/tenant/i`, not an exact name).
+ const norm = (s: string): string => s.replace(/\{[^/]+\}/g, "{}");
+ const bp = norm(basePath);
+ const specKeys = Object.keys(paths);
+
+ const collection = specKeys.find((k) => norm(k) === bp);
+ // Item path = collection + a single path parameter segment.
+ const item = specKeys.find((k) => norm(k) === `${bp}/{}`);
+
+ const templates: Partial> = {};
+ if (collection && has(collection, "get")) templates.list = collection;
+ if (collection && has(collection, "post")) templates.create = collection;
+
+ let updateMethod: "PUT" | "PATCH" | undefined;
+ if (item) {
+ if (has(item, "get")) templates.one = item;
+ if (has(item, "put")) {
+ templates.update = item;
+ updateMethod = "PUT";
+ } else if (has(item, "patch")) {
+ templates.update = item;
+ updateMethod = "PATCH";
+ }
+ if (has(item, "delete")) templates.remove = item;
+ }
+
+ // Actions are POST-only literal leaves under the item path. A leaf that also
+ // has a GET is a sub-collection (e.g. `/tenants/{id}/users`), not an action;
+ // a leaf that is itself a parameter (`{...}`) is a nested record, not a verb.
+ const actions: DerivedAction[] = [];
+ if (item) {
+ const actRe = new RegExp(`^${escapeRe(norm(item))}/([a-z0-9-]+)$`);
+ for (const k of specKeys) {
+ const m = actRe.exec(norm(k));
+ if (m && has(k, "post") && !has(k, "get")) {
+ actions.push({ name: m[1], method: "POST", template: k });
+ }
+ }
+ }
+
+ return { templates, updateMethod, actions };
+}
+
+/** Path parameter names in a template, in order. */
+function paramsOf(template: string): string[] {
+ return [...template.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
+}
+
+/**
+ * Fill a path template's parameters. The **last** parameter is the record id
+ * (only for item operations, where `id` is passed); any earlier tenant-named
+ * parameter is the caller's home tenant. This resolves both `/tenants/{id}`
+ * (the sole param is the record id) and
+ * `/tenants/{tenant_id}/conversions/{request_id}` (tenant from context, the
+ * trailing id from the row).
+ */
+export function resolvePath(
+ template: string,
+ ctx: AdminContext,
+ id?: string,
+): string {
+ const params = paramsOf(template);
+ const last = params[params.length - 1];
+ return template.replace(/\{([^}]+)\}/g, (_full, name: string) => {
+ if (id !== undefined && name === last) return encodeURIComponent(id);
+ if (/tenant/i.test(name)) return encodeURIComponent(ctx.subject_tenant_id);
+ // A non-tenant, non-id param should not occur for our resources; fall back
+ // to the id so the URL is at least well-formed.
+ return id !== undefined ? encodeURIComponent(id) : "";
+ });
+}
+
+/**
+ * Build the descriptor's runtime `paths` from derived templates, applying an
+ * optional irregular-list override and a verb-suppression set (policy that
+ * intentionally offers less than the API exposes, e.g. read-only resources).
+ */
+export function buildPaths(
+ ops: DerivedOps,
+ opts: {
+ listPath?: (ctx: AdminContext) => string;
+ suppress?: readonly Verb[];
+ } = {},
+): ResourcePaths {
+ const suppressed = new Set(opts.suppress ?? []);
+ const t = ops.templates;
+ const enabled = (v: Verb): boolean => !suppressed.has(v) && Boolean(t[v]);
+
+ return {
+ list: opts.listPath
+ ? opts.listPath
+ : (ctx) => resolvePath(t.list ?? "", ctx),
+ one: enabled("one") ? (ctx, id) => resolvePath(t.one!, ctx, id) : undefined,
+ create: enabled("create") ? (ctx) => resolvePath(t.create!, ctx) : undefined,
+ update: enabled("update")
+ ? (ctx, id) => resolvePath(t.update!, ctx, id)
+ : undefined,
+ remove: enabled("remove")
+ ? (ctx, id) => resolvePath(t.remove!, ctx, id)
+ : undefined,
+ };
+}
diff --git a/apps/admin-panel/src/resources/registry.ts b/apps/admin-panel/src/resources/registry.ts
new file mode 100644
index 000000000..069e63ae9
--- /dev/null
+++ b/apps/admin-panel/src/resources/registry.ts
@@ -0,0 +1,46 @@
+import {
+ TeamOutlined,
+ ClusterOutlined,
+ ApiOutlined,
+ AppstoreOutlined,
+ SwapOutlined,
+ TagsOutlined,
+ UserOutlined,
+ ProfileOutlined,
+ CloudServerOutlined,
+ NodeIndexOutlined,
+} from "@ant-design/icons";
+import { createElement, type ReactNode } from "react";
+
+import adminConfig from "./admin.config.json";
+import { buildDescriptors, type AdminConfig } from "./configLoader";
+import type { ResourceDescriptor } from "./types";
+
+/**
+ * The admin resource registry, built from the declarative `admin.config.json`
+ * (ADR-0003 concern-split). The config is data, not code: registering a
+ * resource — here or in another gears-rust project — is a JSON edit, and the
+ * API-intrinsic parts (field types, CRUD verbs, tenant-scope) are filled at
+ * boot from the aggregated OpenAPI spec (see `openapi.ts`). Adding a resource
+ * requires no changes to this file or any TypeScript.
+ */
+export const RESOURCE_REGISTRY: ResourceDescriptor[] = buildDescriptors(
+ adminConfig as AdminConfig,
+);
+
+/** Sidebar icon per resource key (presentation only). */
+const ICONS: Record ReactNode> = {
+ tenants: () => createElement(TeamOutlined),
+ "tenant-metadata": () => createElement(ProfileOutlined),
+ users: () => createElement(UserOutlined),
+ conversions: () => createElement(SwapOutlined),
+ "resource-groups": () => createElement(ClusterOutlined),
+ types: () => createElement(TagsOutlined),
+ gears: () => createElement(ApiOutlined),
+ upstreams: () => createElement(CloudServerOutlined),
+ routes: () => createElement(NodeIndexOutlined),
+};
+
+export function resourceIcon(key: string): ReactNode {
+ return (ICONS[key] ?? (() => createElement(AppstoreOutlined)))();
+}
diff --git a/apps/admin-panel/src/resources/types.ts b/apps/admin-panel/src/resources/types.ts
new file mode 100644
index 000000000..81496c8bc
--- /dev/null
+++ b/apps/admin-panel/src/resources/types.ts
@@ -0,0 +1,161 @@
+import type { AdminContext } from "../adminContext";
+
+// Admin resource metadata model (DESIGN: admin resource descriptors).
+//
+// Each manageable object is described once, declaratively, and the data
+// provider + generated screens are driven entirely off these descriptors.
+// New objects/fields are added by registering a descriptor here — the core
+// admin app does not change. This is the additive registry the issue calls
+// for; a later increment augments it from OpenAPI and gear-contributed
+// descriptors without changing this shape.
+
+/** Per-issue safety classification for a resource or action. */
+export type SafetyLevel = "normal" | "destructive" | "operator-only" | "read-only";
+
+/** Field render/parse hint. Defaults to "string". */
+export type FieldType =
+ | "string"
+ | "number"
+ | "boolean"
+ | "datetime"
+ | "uuid"
+ | "json"
+ | "tag";
+
+/** How the resource is scoped against the caller's tenant. */
+export type TenantScope = "platform-only" | "tenant" | "global";
+
+/** One field of a resource, with per-view visibility. */
+export interface FieldDef {
+ name: string;
+ label?: string;
+ /** Render/parse hint. Default "string". */
+ type?: FieldType;
+ /** Show as a column in the list table. Default false. */
+ inList?: boolean;
+ /** Show in the detail panel. Default true. */
+ inDetail?: boolean;
+ /** Editable in both create and edit forms. Default false. */
+ inForm?: boolean;
+ /** Settable at create time only (immutable afterwards). Implies form-create. */
+ createOnly?: boolean;
+ /** Required in forms. Default false. */
+ required?: boolean;
+ /** Shown in forms but disabled (e.g. immutable-after-create). */
+ readOnly?: boolean;
+ /** Link target resource key for relation fields. */
+ relation?: string;
+ /**
+ * Render this form field as a searchable select whose options are loaded
+ * lazily from the backend (e.g. tenant types from the types registry).
+ */
+ options?: () => Promise;
+}
+
+/** One selectable option for an `options`-backed field. */
+export interface FieldOption {
+ value: string;
+ label: string;
+}
+
+/** A custom (non-CRUD) action, e.g. suspend / approve / cancel. */
+export interface ActionDef {
+ name: string;
+ label: string;
+ /** HTTP method. Default "POST". */
+ method?: "POST" | "PATCH" | "PUT" | "DELETE";
+ /** Builds the action path (relative to the API prefix). */
+ path: (ctx: AdminContext, id: string) => string;
+ /** Builds the request body from the target record. */
+ body?: (record: Record) => unknown;
+ /** Capability hint gating UI visibility. */
+ capability?: string;
+ /** Safety level — drives confirmation + styling. Default "normal". */
+ safety?: SafetyLevel;
+ /** Only show the action when this predicate holds for the record. */
+ visible?: (record: Record) => boolean;
+}
+
+/** The standard CRUD verbs, keyed as in `ResourcePaths`. */
+export type Verb = "list" | "one" | "create" | "update" | "remove";
+
+/** Path builders for the standard CRUD verbs. Absent verb => unsupported. */
+export interface ResourcePaths {
+ list: (ctx: AdminContext) => string;
+ one?: (ctx: AdminContext, id: string) => string;
+ create?: (ctx: AdminContext) => string;
+ update?: (ctx: AdminContext, id: string) => string;
+ remove?: (ctx: AdminContext, id: string) => string;
+}
+
+/** Capability hints for UI gating; backend stays the final authority. */
+export interface ResourceCapabilities {
+ read?: string;
+ write?: string;
+ delete?: string;
+}
+
+/** Complete declarative description of one admin resource. */
+export interface ResourceDescriptor {
+ key: string;
+ label: string;
+ owningGear: string;
+ tenantScope: TenantScope;
+ safety: SafetyLevel;
+ /** Record identity field. Default "id". */
+ idField?: string;
+ /** HTTP verb for update. Default "PATCH". */
+ updateMethod?: "PATCH" | "PUT";
+ capabilities?: ResourceCapabilities;
+ /**
+ * OpenAPI component schema name (e.g. "Group") for this resource's record.
+ * When set, the resource's fields are enriched at boot from the
+ * gateway-aggregated `/openapi.json`: derived type / `required` / `readOnly`
+ * fill the gaps, and the curated `fields` below override presentation
+ * (visibility, labels, relations). See `resources/openapi.ts`.
+ */
+ schema?: string;
+ /**
+ * The resource's collection path in the aggregated OpenAPI spec, e.g.
+ * `/resource-group/v1/groups` or, for a tenant-scoped resource,
+ * `/account-management/v1/tenants/{tenant_id}/conversions`. The standard
+ * CRUD `paths` are derived from this at boot (see `resources/openapiOps.ts`);
+ * the API is the single source of truth for which verbs exist.
+ */
+ basePath: string;
+ /**
+ * Irregular list path that OpenAPI can't express as `GET {basePath}` — e.g.
+ * tenants list via the caller's `/{home}/children` subtree. Overrides the
+ * derived list path.
+ */
+ listPath?: (ctx: AdminContext) => string;
+ /**
+ * Verbs the admin policy intentionally does NOT offer even though the API
+ * exposes them (e.g. conversions edit their state via actions, not a generic
+ * update form). A `read-only` safety level suppresses create/update/remove
+ * automatically; this list is for finer-grained cases.
+ */
+ suppressVerbs?: Verb[];
+ /**
+ * CRUD path builders. Built at boot from `basePath` + the OpenAPI spec (with
+ * `listPath`/`suppressVerbs`/`safety` applied); not authored by hand. Present
+ * after `ensureRegistryResolved()` runs.
+ */
+ paths?: ResourcePaths;
+ fields: FieldDef[];
+ actions?: ActionDef[];
+ /** Initial create-form values (e.g. default parent to the home tenant). */
+ formDefaults?: (ctx: AdminContext) => Record;
+ /**
+ * For key-addressed resources (e.g. tenant metadata `PUT .../{type_id}`):
+ * create is an upsert via the `update` path, with the record id taken from
+ * this create-form field instead of being server-generated.
+ */
+ createKeyField?: string;
+ /**
+ * Send only this form field's value as the request body (a "transparent"
+ * payload), instead of the whole form object. Used where the API body is
+ * the bare value, e.g. tenant metadata's `PutTenantMetadataDto`.
+ */
+ bodyField?: string;
+}
diff --git a/apps/admin-panel/src/vite-env.d.ts b/apps/admin-panel/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/apps/admin-panel/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/admin-panel/tests/smoke.spec.ts b/apps/admin-panel/tests/smoke.spec.ts
new file mode 100644
index 000000000..7b24d4607
--- /dev/null
+++ b/apps/admin-panel/tests/smoke.spec.ts
@@ -0,0 +1,51 @@
+import { test, expect } from "@playwright/test";
+
+// End-to-end browser smoke: signs in with the platform-admin dev role and
+// checks that the descriptor-driven console actually renders and navigates.
+// Requires the Gears backend on :8087 (`make admin`). The data it reads is
+// served by that backend; this test asserts the UI wiring, not the API
+// (the API is covered by testing/e2e/gears/admin).
+
+const RESOURCES = [
+ "Tenants",
+ "Users",
+ "Tenant metadata",
+ "Resource groups",
+ "Gears",
+ "Egress upstreams",
+ "Egress routes",
+];
+
+test("platform admin: login, context, resource navigation, create form", async ({ page }) => {
+ await page.goto("/");
+
+ // Sign in as the platform-admin dev role.
+ await page.getByRole("button", { name: "Platform admin (dev)" }).click();
+
+ // Context view renders the projected mode, capabilities, and the non-prod banner.
+ await expect(page.getByText("Admin context")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Non-production authentication")).toBeVisible();
+ await expect(page.getByText("platform", { exact: true })).toBeVisible();
+ await expect(page.getByText("tenants:read")).toBeVisible();
+ await expect(page.getByText("users:write")).toBeVisible();
+
+ // Every registered resource's list screen loads (card/table/graceful alert).
+ for (const name of RESOURCES) {
+ await page.getByRole("menuitem", { name: new RegExp(name, "i") }).first().click();
+ await expect(page.locator(".ant-card, .ant-table, .ant-alert").first()).toBeVisible({
+ timeout: 10_000,
+ });
+ }
+
+ // The tenant create form is generated from the descriptor's create fields.
+ await page.getByRole("menuitem", { name: /Tenants/i }).first().click();
+ await page.getByRole("button", { name: /^Create$/ }).first().click();
+ await expect(page.getByText("Create Tenants")).toBeVisible();
+ await expect(page.getByLabel("Name")).toBeVisible();
+ // Type is a searchable async Select (tenant types loaded from the registry),
+ // not a plain input — assert the combobox rendered with its loaded default
+ // ("customer"). An async Select does not associate a `for`/label, so
+ // getByLabel would not match it.
+ await expect(page.getByRole("combobox").first()).toBeVisible();
+ await expect(page.getByText("customer").first()).toBeVisible();
+});
diff --git a/apps/admin-panel/tsconfig.json b/apps/admin-panel/tsconfig.json
new file mode 100644
index 000000000..a4c834a6c
--- /dev/null
+++ b/apps/admin-panel/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts
new file mode 100644
index 000000000..283768db7
--- /dev/null
+++ b/apps/admin-panel/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// Production: the SPA is served under the API Gateway prefix at /cf/admin by
+// the example server (tower-http ServeDir), so base = /cf/admin/.
+//
+// Dev: serve the SPA from root (/) and proxy /cf to the local example server
+// (`make admin`, port 8087). Serving from root avoids the dev proxy's /cf rule
+// swallowing the SPA's own /cf/admin asset paths, while same-origin /cf API
+// calls still proxy correctly (no CORS).
+export default defineConfig(({ command }) => ({
+ base: command === "serve" ? "/" : "/cf/admin/",
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ "/cf": {
+ target: "http://127.0.0.1:8087",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ },
+}));
diff --git a/config/admin.yaml b/config/admin.yaml
new file mode 100644
index 000000000..b472adc19
--- /dev/null
+++ b/config/admin.yaml
@@ -0,0 +1,257 @@
+# CF/Gears Example Server — Admin Panel configuration
+#
+# Derived from config/quickstart.yaml. Differences vs quickstart:
+# 1. api-gateway.auth_disabled = false -> bearer-token auth is ON so the
+# two-role admin stub actually drives the SecurityContext.
+# 2. static-authn-plugin.mode = static_tokens with two NON-PRODUCTION dev
+# tokens mapped to a platform-admin and a tenant-admin identity. The
+# role marker rides on `subject_type`; the admin-context endpoint reads
+# it to select the admin mode.
+#
+# !!! NON-PRODUCTION !!!
+# The static dev tokens below are for local admin-panel development and demos
+# only. They are not secrets and MUST NOT be used in any real deployment.
+#
+# Run: make admin (builds the SPA and starts the server with this config)
+
+server:
+ home_dir: "~/.cf-gears"
+
+database:
+ servers:
+ sqlite_users:
+ engine: "sqlite"
+ params:
+ WAL: "true"
+ synchronous: "NORMAL"
+ busy_timeout: "5000"
+ pool:
+ max_conns: 5
+ acquire_timeout: "30s"
+
+logging:
+ default:
+ console_level: info
+ file: "logs/cf-gears.log"
+ file_level: info
+ max_age_days: 28
+ max_backups: 3
+ max_size_mb: 10
+ sqlx:
+ console_level: debug
+ file: "logs/sql.log"
+ file_level: debug
+ api-gateway:
+ console_level: debug
+ file: "logs/api.log"
+ file_level: debug
+ max_age_days: 28
+ max_backups: 3
+ max_size_mb: 100
+
+gears:
+ api-gateway:
+ config:
+ bind_addr: "127.0.0.1:8087"
+ enable_docs: true
+ cors_enabled: false
+ prefix_path: "/cf"
+ openapi:
+ title: "CF/Gears Example Server API"
+ version: "0.1.0"
+ description: "CF/Gears Example Server API Documentation"
+ defaults:
+ body_limit_bytes: 64000000
+ rate_limit:
+ rps: 1000
+ burst: 200
+ in_flight: 64
+
+ # Admin panel: bearer-token auth ENABLED so the two-role stub applies.
+ auth_disabled: false
+
+ # Serve the built admin-panel SPA from disk at /cf/admin (public static
+ # assets, outside the auth stack). Run `npm run build` in
+ # apps/admin-panel/ first; `make admin` does this for you. Unset this to
+ # disable serving the SPA (e.g. when running vite dev separately).
+ admin_spa_dir: "apps/admin-panel/dist"
+
+ gear-orchestrator:
+ config: {}
+
+ grpc-hub:
+ config:
+ listen_addr: "uds:///tmp/cf-gears-grpc"
+
+ authn-resolver:
+ config:
+ vendor: "constructorfabric"
+
+ authz-resolver:
+ config:
+ vendor: "constructorfabric"
+
+ # NON-PRODUCTION two-role admin stub.
+ # Present a bearer token to authenticate as the matching role:
+ # Authorization: Bearer platform-admin-dev-token -> platform admin (root tenant)
+ # Authorization: Bearer tenant-admin-dev-token -> tenant admin (engineering tenant)
+ static-authn-plugin:
+ config:
+ vendor: "constructorfabric"
+ priority: 100
+ mode: static_tokens
+ tokens:
+ - token: "platform-admin-dev-token"
+ identity:
+ # Platform/operator admin, homed at the structural root tenant.
+ subject_id: "11111111-1111-1111-1111-111111111111"
+ subject_tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ token_scopes: ["*"]
+ subject_type: "platform_admin"
+ - token: "tenant-admin-dev-token"
+ identity:
+ # Tenant admin. Homed at the bootstrap-seeded platform root so its
+ # tenant-scoped views resolve in AM out of the box (AM seeds only
+ # the root deterministically; child tenant ids are server-assigned
+ # and not stable across a fresh db, so we cannot hardcode a child).
+ # The platform-vs-tenant distinction is still demonstrated via
+ # admin_mode + capabilities. Strict per-child tenant scoping in the
+ # demo needs a seed-then-configure step (follow-up).
+ subject_id: "22222222-2222-2222-2222-222222222222"
+ subject_tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ token_scopes: ["*"]
+ subject_type: "tenant_admin"
+ s2s_credentials:
+ - client_id: "mini-chat"
+ client_secret: "mini-chat-dev-secret"
+
+ static-authz-plugin:
+ config:
+ vendor: "constructorfabric"
+ priority: 100
+
+ # Demo IdP: implements provision_tenant (needed by AM bootstrap) and the
+ # tenant user surface. NON-PRODUCTION.
+ static-idp-plugin:
+ config:
+ vendor: "constructorfabric"
+ priority: 100
+
+ account-management:
+ database:
+ server: "sqlite_users"
+ file: "account_management.db"
+ config:
+ bootstrap:
+ root_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ root_name: "e2e-root"
+ root_tenant_type: "gts.cf.core.am.tenant_type.v1~cf.core.am.platform.v1~"
+ idp_wait_timeout: "5m"
+ idp_retry_backoff_initial: "2s"
+ idp_retry_backoff_max: "30s"
+ strict: false
+ idp:
+ # static-idp-plugin (below, feature `static-idp`) implements
+ # provision_tenant, which the bootstrap saga calls when seeding the
+ # platform root. Without it the saga rolls back and the tenants table
+ # stays empty. required=true wires the plugin before AM in topo-sort.
+ vendor: "constructorfabric"
+ required: true
+
+ resource-group:
+ database:
+ server: "sqlite_users"
+ file: "resource_group.db"
+ config: {}
+
+ simple-user-settings:
+ database:
+ server: "sqlite_users"
+ file: "settings.db"
+ config:
+ max_field_length: 100
+
+ # Register the platform-root tenant type so AM's bootstrap saga can seed
+ # the root tenant (the tenant-type checker resolves this via types-registry;
+ # without it the saga fails and the tenants table stays empty -> 404s). The
+ # AM envelope schemas are auto-seeded by the AM SDK; only the concrete
+ # platform/customer types below are operator-controlled.
+ types-registry:
+ config:
+ entities:
+ - $id: "gts://gts.cf.core.am.tenant_type.v1~cf.core.am.platform.v1~"
+ $schema: "http://json-schema.org/draft-07/schema#"
+ description: "Platform-root tenant type (no parents)."
+ type: "object"
+ x-gts-traits:
+ allowed_parent_types: []
+ idp_provisioning: false
+ - $id: "gts://gts.cf.core.am.tenant_type.v1~cf.core.am.customer.v1~"
+ $schema: "http://json-schema.org/draft-07/schema#"
+ description: "Customer tenant under the platform root."
+ type: "object"
+ x-gts-traits:
+ allowed_parent_types:
+ - "gts.cf.core.am.tenant_type.v1~cf.core.am.platform.v1~"
+ - "gts.cf.core.am.tenant_type.v1~cf.core.am.customer.v1~"
+ idp_provisioning: false
+
+ tenant-resolver:
+ config:
+ vendor: "constructorfabric"
+ static-tr-plugin:
+ config:
+ vendor: "constructorfabric"
+ priority: 20
+ tenants:
+ - id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ name: "default"
+ parent_id: null
+ status: active
+ - id: "00000000-0000-0000-0000-000000000001"
+ name: "company-root"
+ parent_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ status: active
+ - id: "00000000-0000-0000-0000-000000000002"
+ name: "engineering"
+ parent_id: "00000000-0000-0000-0000-000000000001"
+ status: active
+ - id: "00000000-0000-0000-0000-000000000005"
+ name: "sales"
+ parent_id: "00000000-0000-0000-0000-000000000001"
+ status: active
+ - id: "00000000-0000-0000-0000-000000000004"
+ name: "backend"
+ parent_id: "00000000-0000-0000-0000-000000000002"
+ status: active
+ - id: "00000000-0000-0000-0000-000000000006"
+ name: "enterprise"
+ parent_id: "00000000-0000-0000-0000-000000000005"
+ status: active
+
+ static-credstore-plugin:
+ config:
+ secrets:
+ - tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ key: "openai-key"
+ value: "sk-test-e2e-fake-key"
+
+ file-parser:
+ config:
+ allowed_local_base_dir: /tmp
+
+opentelemetry:
+ resource:
+ service_name: "cf-gears-api"
+ attributes:
+ service.version: "1.3.7"
+ deployment.environment: "dev"
+ service.namespace: "cf-gears"
+ exporter:
+ kind: "otlp_grpc"
+ endpoint: "http://127.0.0.1:4317"
+ timeout_ms: 5000
+ tracing:
+ enabled: false
+ metrics:
+ enabled: false
diff --git a/config/e2e-local.yaml b/config/e2e-local.yaml
index 3a77bd992..7e0f7d233 100644
--- a/config/e2e-local.yaml
+++ b/config/e2e-local.yaml
@@ -270,6 +270,22 @@ gears:
# not check it are unaffected.
subject_type: "gts.cf.core.security.subject_user.v1~"
token_scopes: ["*"]
+ # Admin-panel role tokens — drive the GET /admin/context projection.
+ # subject_type is the (non-production) role marker the admin-context
+ # endpoint reads to derive admin_mode + capability hints. Both are
+ # homed at the platform root so tenant-scoped reads resolve.
+ - token: "e2e-token-platform-admin"
+ identity:
+ subject_id: "11111111-0000-0000-0000-0000000000a1"
+ subject_tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ subject_type: "platform_admin"
+ token_scopes: ["*"]
+ - token: "e2e-token-tenant-admin"
+ identity:
+ subject_id: "11111111-0000-0000-0000-0000000000a2"
+ subject_tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953"
+ subject_type: "tenant_admin"
+ token_scopes: ["*"]
# Hierarchy tenant tokens — used by budget allocation e2e tests
- token: "e2e-token-hierarchy-root"
identity:
diff --git a/docs/api/api.json b/docs/api/api.json
index 5bac53c9f..5367795be 100644
--- a/docs/api/api.json
+++ b/docs/api/api.json
@@ -47,6 +47,48 @@
],
"type": "object"
},
+ "AdminContextDto": {
+ "description": "Startup admin context for the admin panel.\n\nRicher sibling of [`MeDto`]: in addition to the authenticated subject\nand home tenant, it projects the admin **mode** and a coarse\n**capabilities** hint that the frontend uses for capability-driven\nnavigation. The backend remains the final authority on every action;\nthese capabilities are UI hints only.\n\nv0 derives mode and capabilities from the `subject_type` role marker\nset by the **non-production** static auth stub (`platform_admin` /\n`tenant_admin`). `non_production_auth` is `true` whenever that stub\nmarker is present so the UI can surface a demo banner. When a real\nauthorization model replaces the stub, this projection is re-backed by\nit without changing the wire contract. Enabled gears are intentionally\nnot included here — the frontend reads them from the gear orchestrator.",
+ "properties": {
+ "admin_mode": {
+ "description": "`\"platform\"` or `\"tenant\"`.",
+ "type": "string"
+ },
+ "capabilities": {
+ "description": "Coarse capability hints for UI gating (e.g. `tenants:write`).",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "non_production_auth": {
+ "description": "`true` when the non-production static role stub is in effect.",
+ "type": "boolean"
+ },
+ "subject_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "subject_tenant_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "subject_type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "subject_id",
+ "subject_tenant_id",
+ "admin_mode",
+ "capabilities",
+ "non_production_auth"
+ ],
+ "type": "object"
+ },
"AgeRetentionDto": {
"description": "Age-based retention criterion.",
"properties": {
@@ -5461,6 +5503,103 @@
},
"openapi": "3.1.0",
"paths": {
+ "/account-management/v1/admin/context": {
+ "get": {
+ "description": "Return the authenticated subject's startup admin context -- subject id, subject type, home tenant, admin mode (`platform`/`tenant`), and coarse capability hints -- read from the validated bearer token's security context. Mode and capabilities derive from the `subject_type` role marker; the backend remains the final authority on every action and capability hints are advisory for UI gating only. v0 derives the role from the NON-PRODUCTION static auth stub and sets `non_production_auth=true` when that stub is in effect. Enabled gears are not included here -- read them from the gear orchestrator.",
+ "operationId": "account_management.get_admin_context",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminContextDto"
+ }
+ }
+ },
+ "description": "Authenticated admin startup context"
+ },
+ "400": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Unauthorized"
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Not Found"
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Too Many Requests"
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Return the authenticated admin's startup context",
+ "tags": [
+ "Identity"
+ ]
+ }
+ },
"/account-management/v1/me": {
"get": {
"description": "Return the authenticated subject's identity -- subject id, subject type, and home tenant (`subject_tenant_id`) -- read from the validated bearer token's security context. Non-tenant-scoped: call it right after login to discover your single home tenant before issuing tenant-scoped requests. Pure context reflection: AM performs no tenant-existence check here; a dangling or deleted home tenant surfaces on the tenant-scoped resource routes, not on this endpoint.",
diff --git a/docs/arch/admin-panel/ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md b/docs/arch/admin-panel/ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md
new file mode 100644
index 000000000..dfb5e962d
--- /dev/null
+++ b/docs/arch/admin-panel/ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md
@@ -0,0 +1,135 @@
+---
+status: accepted
+date: 2026-06-25
+decision-makers: gears-rust admin-panel working group
+---
+
+# Embed the Admin Panel in the gears-rust monorepo, served by the example server
+
+
+> **Revision (2026-06-30) — placement direction updated after review.**
+> Reviewer feedback on [#4145](https://github.com/constructorfabric/gears-rust/pull/4145#discussion_r3495062634) reopened this decision: embedding ~all of the SPA's TypeScript under `apps/admin-panel/` forces every other gears-rust-based project to copy that code to get an admin panel (the Django-admin counter-example: install once, run against any project). The agreed target is a **generic, reusable admin SPA** driven at runtime by the aggregated `/cf/openapi.json` plus gear-emitted admin metadata, shipped as a **pre-built artifact** with **zero per-project TypeScript**, ultimately living in a **dedicated `constructorfabric/` repository** (Option B's home) and loaded by the existing thin Rust serving shim (`mount_admin_spa()` in the api-gateway gear).
+>
+> This does **not** revert to Option B wholesale. Option B's main rejection driver — *atomic changes spanning gear APIs and admin resources become cross-repo* — is mitigated by moving the admin metadata **next to each gear's API** (emitted server-side), so a gear's API and its admin descriptors still change together even after the SPA is extracted. Sequencing: keep the SPA in this monorepo until it is fully generic, then extract it as a follow-up (smaller, reviewable steps). The original Option A analysis below stands as the v0 record; the end-state is the generic/distributable shape.
+>
+> **Open (pending reviewer):** the transport for metadata OpenAPI can't express (custom actions, safety levels, tenant-scope, layout) — a config file shipped with the panel, `x-cf-admin-*` OpenAPI vendor extensions emitted via `OperationBuilder` (leaning), or a dedicated descriptor endpoint. Distribution format (release tarball / npm / container) and the exact extract trigger are a later round. See [ADR-0003](0003-cpt-admin-panel-adr-resource-discovery.md) for the discovery side of this pivot.
+
+> **Revision (2026-07-13) — decision resolved; the panel stays in the monorepo for v0, extraction is a follow-up.**
+> The reviewer's core concern was *reusability* — "so much `*.ts` in `apps/admin-panel` means every other project copies that code." That concern is now met **in place, without extracting the repo**: resources are registered declaratively in `apps/admin-panel/src/resources/admin.config.json` and every API-intrinsic fact (field types/`required`/`readOnly`, CRUD verb→path mapping, custom actions, tenant-scope) is derived at runtime from the aggregated `/cf/openapi.json`. Adding an admin object is a JSON edit with **zero per-project TypeScript and no framework changes** — the Django-admin property the reviewer asked for. The metadata-transport question above is therefore closed: no `x-cf-admin-*` vendor extension and no descriptor endpoint are needed for v0 (see [ADR-0003](0003-cpt-admin-panel-adr-resource-discovery.md), concern-split).
+> The one remaining item was **distribution** (extract the SPA to a dedicated `constructorfabric/` repo + pre-built artifact). Deliverable 4 of #4144 explicitly delegates placement to this ADR ("could be even separate repo in `constructorfabric/` if needed"), so we decide it here rather than block on it: **v0 ships embedded in the monorepo (Option A below stands), and extraction is tracked as a separate follow-up issue.** Rationale: the reusability concern is already solved without extraction; extraction is a *distribution* optimization whose cross-repo atomicity cost only pays off once a second consumer project actually exists. The thin serving shim (`mount_admin_spa()`) and the bundled/config-driven design keep that extraction cheap when it is triggered.
+
+
+
+- [Context and Problem Statement](#context-and-problem-statement)
+- [Decision Drivers](#decision-drivers)
+- [Considered Options](#considered-options)
+- [Decision Outcome](#decision-outcome)
+ - [Consequences](#consequences)
+ - [Confirmation](#confirmation)
+- [Pros and Cons of the Options](#pros-and-cons-of-the-options)
+ - [Option A: Embed in the monorepo, served by the example server](#option-a-embed-in-the-monorepo-served-by-the-example-server)
+ - [Option B: Separate repository + `make admin` sidecar](#option-b-separate-repository--make-admin-sidecar)
+ - [Option C: SeaORM Pro as an integrated database-admin gear](#option-c-seaorm-pro-as-an-integrated-database-admin-gear)
+- [More Information](#more-information)
+- [Traceability](#traceability)
+
+
+
+**ID**: `cpt-admin-panel-adr-placement-and-delivery`
+
+## Context and Problem Statement
+
+The Integrated Admin Panel (issue #4144) needs a home: where does its frontend (a browser SPA) and any backing code live, and how is it delivered to operators? The `gears-rust` repository is a pure-Rust monorepo with no existing JavaScript/TypeScript build tooling, and the example server (`apps/cf-gears-example-server`) does not currently serve static web assets. The issue explicitly leaves this open ("it could be even separate repo in `constructorfabric/` if needed"). Where should the admin panel be developed and from where should it be served?
+
+## Decision Drivers
+
+- **Single local bring-up** — `make example` should be able to start the platform and the admin panel together for development, demo, and e2e testing.
+- **Authority boundary reuse** — the panel must talk to Gears APIs through the API Gateway prefix (`/cf`) and reuse the platform security model; co-location simplifies same-origin requests and auth.
+- **Atomic changes** — admin resources track gear APIs; keeping them in the monorepo allows atomic refactors and consistent CI/tooling (a stated repo principle).
+- **e2e coverage** — the existing Python/pytest e2e harness builds and starts the example server; an embedded panel slots into that flow.
+- **Monorepo purity** — the repo is otherwise pure Rust; introducing a Node/JS toolchain adds a build step and dependency surface.
+- **Issue intent** — the issue allows either an integrated gear/example-server delivery or a `make admin` sidecar.
+
+## Considered Options
+
+- **Option A**: Embed the panel in the gears-rust monorepo; build the SPA into static assets served by the example server under `/cf/admin`.
+- **Option B**: Develop the panel in a separate `constructorfabric/` repository; run it as a sidecar via a `make admin` target.
+- **Option C**: Adopt SeaORM Pro as an integrated database-admin gear.
+
+## Decision Outcome
+
+Chosen option: **Option A — embed in the monorepo, served by the example server**, because it gives a single local bring-up (`make example`), same-origin access to the API Gateway and security model, atomic changes alongside gear APIs, and a clean fit with the existing e2e harness. The added JS/TS toolchain is isolated to the admin subtree and gated behind a build step so the rest of the pure-Rust workflow is unaffected.
+
+Implementation direction:
+
+- The SPA lives under a dedicated admin subtree in the monorepo; its production build emits static assets.
+- The example server serves those assets under the API Gateway prefix at `/cf/admin`, using `tower-http`'s `ServeDir` with an SPA fallback to `index.html` (the `tower-http` dependency is already available to the gateway).
+- A `make admin` target builds the SPA and runs the example server with an `admin` feature/config so the panel can be brought up with one command; `make example` remains the plain-server path.
+- The JS/TS build is confined to the admin directory with its own package manifest; CI builds it as a separate step and does not couple it to the Rust build graph.
+- The exact serving gear (API Gateway vs a thin admin gear), the build-output location, and how assets are embedded (on-disk `ServeDir` vs compile-time embed) are settled in DESIGN.
+
+### Consequences
+
+- The monorepo gains a JavaScript/TypeScript toolchain (package manifest, lockfile, `node_modules`, a build step) confined to the admin subtree; CI must build the SPA and publish its assets before the server serves them.
+- The example server must mount a static-asset route with SPA fallback under `/cf/admin` without shadowing existing gear routes; this requires a defined mount point in the gateway/router (specified in DESIGN).
+- `.gitignore`, `Makefile`, and CI configuration must account for the SPA build output and `node_modules`; a decision on whether built assets are committed or built on demand is required in DESIGN.
+- e2e tests can exercise the panel against a locally-started example server through the existing harness.
+- The frontend framework choice (Refine vs alternatives) is a separate decision recorded in a later ADR; this ADR fixes only placement and delivery.
+- Because the panel is co-located and same-origin, authentication can reuse the platform's bearer flow rather than a cross-origin scheme (auth flow detailed in DESIGN/later ADR).
+
+### Confirmation
+
+Confirmed by design review of DESIGN.md (static-asset mount point, build pipeline, `make admin` target), by the presence of a working `make admin` bring-up, and by e2e tests that load the panel and drive v0 flows against the example server.
+
+## Pros and Cons of the Options
+
+### Option A: Embed in the monorepo, served by the example server
+
+The SPA is developed in the monorepo and built into static assets that the example server serves under `/cf/admin`.
+
+- Good, because a single `make` bring-up starts the platform and panel together for dev, demo, and e2e.
+- Good, because same-origin access to the API Gateway and security model avoids cross-origin auth/CORS complexity.
+- Good, because admin resources can change atomically with the gear APIs they track, with consistent CI/tooling.
+- Good, because it fits the existing Python/pytest e2e harness that builds and starts the example server.
+- Neutral, because it matches one of the two delivery shapes the issue explicitly allows.
+- Bad, because it introduces a JS/TS toolchain into an otherwise pure-Rust monorepo (build step, dependencies, `node_modules`).
+
+### Option B: Separate repository + `make admin` sidecar
+
+The panel lives in its own `constructorfabric/` repository and runs as a sidecar process wired in via a `make admin` target.
+
+- Good, because the gears-rust monorepo stays pure Rust with no JS/TS toolchain.
+- Good, because the frontend can have an independent release cycle.
+- Bad, because two repositories must be checked out, versioned, and kept in sync for a single bring-up.
+- Bad, because cross-origin/sidecar wiring complicates auth, CORS, and same-origin assumptions.
+- Bad, because atomic changes spanning gear APIs and admin resources become cross-repo, harder to review and to test in CI/e2e.
+
+### Option C: SeaORM Pro as an integrated database-admin gear
+
+Adopt SeaORM Pro, a low-code admin over SeaORM entities driven by an auto-generated GraphQL layer.
+
+- Good, because it is a native Rust/SeaORM stack and the fastest path to a raw database admin.
+- Bad, because it is database/entity-driven via Seaography GraphQL, not OpenAPI/API-driven — it bypasses the Gears API authority boundary and business logic.
+- Bad, because its frontend is closed-source and RBAC is paywalled, conflicting with the OSS and multi-tenant requirements.
+- Bad, because it cannot drive the custom actions (suspend, approve, convert, etc.) and tenant-scoped authorization the panel requires.
+- Neutral, because it remains acceptable only as an internal DB-inspection experiment, not the primary admin surface.
+
+## More Information
+
+- Issue: constructorfabric/gears-rust#4144
+- The example server already depends on `tower-http`, which provides `ServeDir` for static-asset serving with SPA fallback.
+- The API Gateway serves the aggregated OpenAPI document and proxies gear routes under the configurable prefix (default `/cf`); the panel is served alongside under `/cf/admin`.
+- SeaORM Pro: https://www.sea-ql.org/sea-orm-pro/docs/introduction/sea-orm-pro/
+- Frontend framework selection is recorded separately (see DESIGN.md and the frontend-framework ADR).
+
+## Traceability
+
+- **PRD**: [PRD.md](../PRD.md)
+- **DESIGN**: [DESIGN.md](../DESIGN.md)
+
+This decision directly addresses the following requirements or design elements:
+
+- `cpt-admin-panel-fr-admin-shell` — fixes where the admin shell is built and from where it is served.
+- `cpt-admin-panel-fr-openapi-discovery` — co-location gives same-origin access to the aggregated OpenAPI document via the gateway.
+- `cpt-admin-panel-nfr-backend-authority` — same-origin delivery keeps the backend (API Gateway + resolvers) as the authority boundary.
+- `cpt-admin-panel-contract-openapi` — the panel consumes the gateway-served OpenAPI contract under `/cf`.
diff --git a/docs/arch/admin-panel/ADR/0002-cpt-admin-panel-adr-frontend-framework.md b/docs/arch/admin-panel/ADR/0002-cpt-admin-panel-adr-frontend-framework.md
new file mode 100644
index 000000000..231f16dea
--- /dev/null
+++ b/docs/arch/admin-panel/ADR/0002-cpt-admin-panel-adr-frontend-framework.md
@@ -0,0 +1,132 @@
+---
+status: accepted
+date: 2026-06-25
+decision-makers: gears-rust admin-panel working group
+---
+
+# Build the Admin Panel frontend with Refine (React + TypeScript)
+
+
+
+
+- [Context and Problem Statement](#context-and-problem-statement)
+- [Decision Drivers](#decision-drivers)
+- [Considered Options](#considered-options)
+- [Decision Outcome](#decision-outcome)
+ - [Consequences](#consequences)
+ - [Confirmation](#confirmation)
+- [Pros and Cons of the Options](#pros-and-cons-of-the-options)
+ - [Option A: Refine + TypeScript](#option-a-refine--typescript)
+ - [Option B: React Admin](#option-b-react-admin)
+ - [Option C: SeaORM Pro frontend](#option-c-seaorm-pro-frontend)
+- [More Information](#more-information)
+- [Traceability](#traceability)
+
+
+
+**ID**: `cpt-admin-panel-adr-frontend-framework`
+
+## Context and Problem Statement
+
+The admin panel is delivered as a browser SPA embedded in the monorepo (see [ADR-0001](./0001-cpt-admin-panel-adr-placement-and-delivery.md)). It must be metadata-driven and OpenAPI-discovered, support resources with partial CRUD, drive custom actions (suspend, unsuspend, approve, reject, cancel, retry, resolve, deprovision), enforce capability-driven navigation, and respect backend-authoritative multi-tenant isolation. Which frontend framework and stack should we build it on?
+
+## Decision Drivers
+
+- **OpenAPI-driven** — the panel discovers resources and operations from the aggregated OpenAPI document; the framework must allow a custom data layer over arbitrary REST.
+- **Custom-action-heavy** — tenant lifecycle and conversions are action-driven, not plain CRUD; the framework must support non-CRUD operations cleanly.
+- **Capability-driven access control** — navigation and write actions are gated by backend capabilities; the framework should offer a first-class access-control hook wired to our own policy.
+- **Multi-tenant** — nested tenant routes and tenant-scoped views are required.
+- **Licensing** — the result must remain OSS/MIT-compatible; RBAC and access control must not be paywalled.
+- **Maturity & ecosystem** — active project, stable releases, usable UI components for admin CRUD.
+- **Generated-but-overridable** — screens generated from metadata, with per-resource overrides for custom workflows.
+
+## Considered Options
+
+- **Option A**: Refine (React meta-framework) + TypeScript, Vite build, Ant Design UI.
+- **Option B**: React Admin (Material-UI based).
+- **Option C**: SeaORM Pro frontend.
+
+## Decision Outcome
+
+Chosen option: **Option A — Refine + TypeScript**, because it is MIT across the board (including RBAC and access control), provides a first-class `accessControlProvider.can()` hook that maps cleanly onto our backend capability model, exposes a `custom()` data-provider method purpose-built for our custom-action-heavy API, and is headless so it does not fight the generated/overridable screen requirement. No framework ships a production-grade generic OpenAPI generator, so a hand-written data provider is required regardless; Refine's data-provider contract is the smallest and most flexible.
+
+Stack:
+
+- **Language**: TypeScript.
+- **Framework**: Refine (`@refinedev/core`) with React.
+- **Build**: Vite, emitting a static `dist/` bundle served by the example server under `/cf/admin`.
+- **UI kit**: Ant Design (`@refinedev/antd`) for batteries-included tables, forms, and filters suited to admin CRUD.
+- **Routing**: React Router, with nested routes for tenant scope.
+- **Data**: a custom Refine **data provider** that maps list/read/create/update/delete and `custom()` actions onto Gears API requests, consuming `x-odata-*` extensions and cursor pagination, and normalizing RFC-9457 errors. It issues requests through a small hand-written `apiFetch` helper (`src/httpClient.ts`) rather than a generated client; the spec is consumed **at runtime** for discovery — fields from component schemas, CRUD routes and custom actions from paths — so resources stay in sync with `/openapi.json` without a codegen build step.
+- **Auth**: Refine **auth provider** (bearer token) and **access-control provider** (`can()`), both wired to the admin-context endpoint and the platform security model.
+
+UI kit choice (Ant Design vs MUI) and the exact OpenAPI client tooling are refined in DESIGN; the framework decision (Refine) is fixed here.
+
+### Consequences
+
+- A TypeScript/React/Vite/Refine toolchain is added under the admin subtree (package manifest, lockfile), built as a separate CI step per ADR-0001.
+- A custom Gears data provider must be implemented against the Refine data-provider contract, including OData filter/order mapping, cursor pagination, and RFC-9457 error normalization.
+- An auth provider and an access-control provider must be implemented, both driven by the admin-context endpoint's principal, mode, and capabilities.
+- A typed OpenAPI client generation step (from the gateway-served `/openapi.json`) is introduced to keep the data provider thin and spec-synced.
+- Generated screens use Ant Design components by default; per-resource overrides are implemented as custom Refine pages/components.
+- Resources lacking `getOne`/`update`/`delete` are handled by omitting those provider methods/actions and exposing the gap in the UI.
+
+### Confirmation
+
+Confirmed by design review of DESIGN.md (data-provider, auth-provider, access-control-provider contracts), by a working Refine app served under `/cf/admin`, and by e2e tests exercising list/detail/create/edit and custom actions for v0 resources.
+
+## Pros and Cons of the Options
+
+### Option A: Refine + TypeScript
+
+Headless React meta-framework for internal tools/admin, with provider-based data, auth, and access control. MIT.
+
+- Good, because it is MIT in full — RBAC and access control are not paywalled.
+- Good, because `accessControlProvider.can()` maps directly onto our backend capability model for capability-driven navigation.
+- Good, because the `custom()` data-provider method serves custom actions (suspend, approve, convert, etc.) directly.
+- Good, because headless design leaves UI freedom and supports generated-but-overridable screens.
+- Good, because it is mature and active with a large ecosystem and a UI integration for Ant Design.
+- Neutral, because it has no first-party OpenAPI generator — a custom data provider is needed (true of all options).
+- Bad, because headless means more upfront UI wiring than a batteries-included Material admin.
+
+### Option B: React Admin
+
+Mature, Material-UI based admin framework with a standardized data-provider interface.
+
+- Good, because very mature, with a low-effort custom data provider ("a couple of hours") and batteries-included Material UI.
+- Good, because basic access control moved into the OSS core (v5.3).
+- Neutral, because no generic OpenAPI generator either (API Platform Admin targets Hydra/OpenAPI conventions, not arbitrary specs).
+- Bad, because fine-grained RBAC (`ra-rbac`) and audit logging are in the paid Enterprise Edition.
+- Bad, because custom actions are more manual (no dedicated `custom()` verb; call the HTTP client inside provider methods).
+- Bad, because it is Material-centric and less headless, constraining the generated/overridable design.
+
+### Option C: SeaORM Pro frontend
+
+A low-code admin frontend over SeaORM entities via an auto-generated GraphQL layer.
+
+- Good, because it is the fastest path to a raw database admin in a Rust/SeaORM stack.
+- Bad, because it is entity/DB + GraphQL driven, not OpenAPI/API driven — it bypasses the Gears API authority boundary.
+- Bad, because the frontend is closed-source and RBAC is paywalled, conflicting with OSS and multi-tenant requirements.
+- Bad, because its low-code ceiling cannot express the custom actions and tenant-scoped authorization the panel needs.
+
+## More Information
+
+- Issue: constructorfabric/gears-rust#4144
+- Refine: https://refine.dev/docs/ — data provider: https://refine.dev/docs/data/data-provider/ , access control: https://refine.dev/docs/authorization/access-control-provider/
+- React Admin: https://marmelab.com/react-admin/ — data provider: https://marmelab.com/react-admin/DataProviderWriting.html ; Enterprise (RBAC): https://react-admin-ee.marmelab.com/
+- SeaORM Pro: https://www.sea-ql.org/sea-orm-pro/docs/introduction/sea-orm-pro/
+- OpenAPI typed client: https://github.com/OpenAPITools/openapi-generator , https://github.com/drwpow/openapi-typescript
+
+## Traceability
+
+- **PRD**: [PRD.md](../PRD.md)
+- **DESIGN**: [DESIGN.md](../DESIGN.md)
+
+This decision directly addresses the following requirements or design elements:
+
+- `cpt-admin-panel-fr-custom-actions` — Refine's `custom()` provider method drives non-CRUD actions.
+- `cpt-admin-panel-fr-admin-shell` — capability-driven navigation via the access-control provider.
+- `cpt-admin-panel-fr-generated-screens` — generated, per-resource-overridable screens via Refine + Ant Design.
+- `cpt-admin-panel-fr-partial-crud` — provider methods omitted for resources lacking CRUD operations.
+- `cpt-admin-panel-interface-data-provider` — fixes the framework the data-provider contract is built on.
+- `cpt-admin-panel-nfr-backend-authority` — the access-control provider defers to backend capability decisions.
diff --git a/docs/arch/admin-panel/ADR/0003-cpt-admin-panel-adr-resource-discovery.md b/docs/arch/admin-panel/ADR/0003-cpt-admin-panel-adr-resource-discovery.md
new file mode 100644
index 000000000..7a654ea07
--- /dev/null
+++ b/docs/arch/admin-panel/ADR/0003-cpt-admin-panel-adr-resource-discovery.md
@@ -0,0 +1,142 @@
+---
+status: accepted
+date: 2026-06-25
+decision-makers: gears-rust admin-panel working group
+---
+
+# Discover admin resources OpenAPI-first, with a hardcoded v0 registry
+
+
+> **Revision (2026-06-30) — discovery direction promoted after review.**
+> Reviewer feedback on [#4145](https://github.com/constructorfabric/gears-rust/pull/4145#discussion_r3495062634) (see [ADR-0001 revision](0001-cpt-admin-panel-adr-placement-and-delivery.md)) requires the SPA to carry **zero per-project TypeScript**, which makes the hardcoded in-app registry a blocker rather than an acceptable v0 coupling. Two parts of this ADR that were "Still deferred" are promoted to the **target/v1**:
+> 1. **Runtime parsing of `/openapi.json`** to derive fields/types/`required`/`readOnly`, CRUD verb mapping, and read-only detection — shrinking the curated registry by ~70%.
+> 2. The **gear-contributed metadata mechanism** (`cpt-admin-panel-fr-gear-contributed-metadata`) for what OpenAPI cannot express (custom actions, safety levels, tenant-scope strategy, layout/labels, irregular list paths). This effectively moves the chosen approach from Option A toward **Option C**, but realized incrementally rather than as an upfront cross-gear blocker.
+>
+> **Open (pending reviewer):** the metadata transport — a config file, `x-cf-admin-*` OpenAPI vendor extensions emitted per-gear via `OperationBuilder` (leaning; keeps metadata next to the API), or a dedicated descriptor endpoint. The descriptor shape defined for the v0 registry is already forward-compatible with all three. No-regret work (runtime OpenAPI discovery + registry shrink) proceeds now regardless of the chosen transport.
+
+> **Revision (2026-07-02) — transport resolved by splitting metadata by concern.**
+> Re-examining what the aggregated `/cf/openapi.json` actually expresses closed the "open" question above without a new backend mechanism. Discovery is split by the *nature* of each fact:
+> 1. **API-intrinsic facts → derived from OpenAPI.** Field names/types/`required`/`readOnly` (component schemas), CRUD verb mapping, **custom actions** (e.g. `POST …/suspend`, `…/unsuspend` are first-class operations), and **tenant-scope** (the `{tenant_id}` path parameter marks a tenant-scoped route) are all present in the spec and read at runtime.
+> 2. **Presentation-only facts → a small panel-side config.** List columns, labels, grouping/ordering, confirm/safety level, action button labels, and irregular-list hints (e.g. tenants list via `/children`, conversion state via `PATCH {status}`) are *UX concerns, not API contract*. Encoding them in OpenAPI vendor extensions would leak presentation into the API layer, so they live in a panel-side config instead — the equivalent of Django's `admin.py` registration, **not** copied panel code.
+>
+> **Consequence:** the SPA becomes fully generic — **no per-project TypeScript and no framework changes.** The hardcoded in-app `registry.ts` is dropped: its API-intrinsic content comes from the spec, its presentation content moves to config. The `x-cf-admin-*` vendor-extension idea (Option-B transport) is **not required** and is retained only as a later optimization should a genuinely API-intrinsic fact appear that the spec cannot yet carry. This supersedes the "leaning toward vendor extensions" note above. Distribution (pre-built artifact + thin Rust loader, later extracted to a dedicated `constructorfabric/` repo) is tracked separately in [ADR-0001](0001-cpt-admin-panel-adr-placement-and-delivery.md).
+
+> **Revision (2026-07-13) — implemented; the sections below are the original decision record and are superseded by this note.**
+> The concern-split above is now the shipped design. In the codebase: `apps/admin-panel/src/resources/admin.config.json` holds declarative per-resource registration (list columns, labels, `schema` name, `basePath`, verb suppression, custom actions, option sources); `openapiOps.ts` derives CRUD routes and custom actions structurally from `/cf/openapi.json`; `openapi.ts` derives fields from component schemas. There is **no hardcoded in-app resource registry** — the "New v0 resources are added by editing the in-app registry" and "gear-contributed descriptor mechanism ... migration from the hardcoded registry" consequences below are obsolete. Adding an admin object is a JSON edit; a second project ships the same pre-built panel and edits only its own config. The gear-contributed-descriptor mechanism remains an optional future enhancement, not a v0 dependency.
+
+
+
+- [Context and Problem Statement](#context-and-problem-statement)
+- [Decision Drivers](#decision-drivers)
+- [Considered Options](#considered-options)
+- [Decision Outcome](#decision-outcome)
+ - [Consequences](#consequences)
+ - [Confirmation](#confirmation)
+- [Pros and Cons of the Options](#pros-and-cons-of-the-options)
+ - [Option A: OpenAPI-first + hardcoded v0 registry](#option-a-openapi-first--hardcoded-v0-registry)
+ - [Option B: Pure OpenAPI introspection only](#option-b-pure-openapi-introspection-only)
+ - [Option C: Build gear-contributed descriptors now](#option-c-build-gear-contributed-descriptors-now)
+- [More Information](#more-information)
+- [Traceability](#traceability)
+
+
+
+**ID**: `cpt-admin-panel-adr-resource-discovery`
+
+## Context and Problem Statement
+
+The admin panel must know which resources exist, their fields, operations, filters, ordering, and custom actions, and must render generated screens for them. The PRD requires OpenAPI-first generation with manual overrides, gear-contributed admin metadata, and additive registration so new objects appear without editing the core admin app. The platform already serves an aggregated OpenAPI document (with `x-odata-*` extensions) through the API Gateway, but there is no mechanism today for a gear to ship admin resource descriptors. How should the panel discover and describe admin resources for v0, without blocking on a new backend mechanism?
+
+## Decision Drivers
+
+- **Reuse existing surface** — the aggregated OpenAPI document already describes operations, schemas, filters, and ordering.
+- **Additive extensibility** — long-term, gears should contribute their own admin descriptors without core edits.
+- **Avoid blocking v0** — the gear-contributed descriptor mechanism is new backend work; v0 must not wait on it.
+- **Generated-but-overridable** — default screens from metadata, with per-resource overrides for custom workflows and actions.
+- **Custom actions & partial CRUD** — discovery must capture non-CRUD actions and resources missing some CRUD operations.
+- **Single source of truth drift** — minimize divergence between the API and what the panel shows.
+
+## Considered Options
+
+- **Option A**: OpenAPI-first discovery plus a small, hardcoded resource registry (descriptors + overrides) maintained in the admin app for v0; design the gear-contributed descriptor mechanism as later work.
+- **Option B**: Pure OpenAPI introspection only — derive everything from `/openapi.json` with no curated descriptors.
+- **Option C**: Build the gear-contributed admin-metadata mechanism now and require every gear to ship descriptors before v0.
+
+## Decision Outcome
+
+Chosen option: **Option A — OpenAPI-first discovery plus a hardcoded v0 registry**, because it reuses the existing OpenAPI surface for fields, operations, filters, and pagination, while a small curated registry supplies the things OpenAPI cannot express well (resource grouping, tenant-scope strategy, safety levels, custom-action wiring, layout/label overrides). It delivers v0 without waiting on a new backend mechanism, and the registry is shaped to be replaced later by gear-contributed descriptors (recorded as deferred FR `cpt-admin-panel-fr-gear-contributed-metadata`).
+
+Direction:
+
+- The data provider reads `/openapi.json` (gateway-aggregated) to derive default list/detail/create/update fields, operations, OData filter/order capabilities (`x-odata-*`), and cursor pagination.
+- A curated, in-app **resource registry** declares the v0 resources (tenants, tenant metadata, conversions, resource groups, types/GTS, gateway routes, gear status) with: resource key, owning gear, source operation IDs, tenant-scope strategy, safety level, custom actions, required capabilities, and per-resource layout/label/widget overrides.
+- Where OpenAPI and the registry overlap (fields, operations), OpenAPI provides defaults and the registry overrides.
+- The registry's descriptor shape is designed to match a future gear-contributed descriptor schema, so the v0 hardcoded registry can later be populated from gear-shipped metadata without reworking the frontend.
+
+#### Implementation status (v0)
+
+- **Done**: the curated descriptor registry (`apps/admin-panel/src/resources`) and a fully descriptor-driven data provider and List/Show/Create/Edit screens. Descriptors carry paths, fields with per-view visibility and create-time immutability, capabilities, safety level, tenant scope, and custom actions; new resources are added by appending a descriptor. Covers tenants (full CRUD + suspend/unsuspend/soft-delete), resource-groups (CRUD), conversions (read + approve/reject/cancel), and read-only types/gears.
+- **Still deferred**: runtime parsing of `/openapi.json` to *derive* field/filter/order defaults (the v0 fields are hand-curated from the served OpenAPI rather than read at runtime), and the gear-contributed descriptor mechanism (`cpt-admin-panel-fr-gear-contributed-metadata`). The descriptor shape is forward-compatible with both.
+
+### Consequences
+
+- A descriptor schema (resource + field + action shapes) must be defined in the admin app and documented; it is the contract the future gear-contributed mechanism will emit.
+- The data provider must map OpenAPI operations and `x-odata-*` extensions onto the descriptor's list/read/create/update/delete/custom-action operations and onto filter/order/pagination behavior.
+- New v0 resources are added by editing the in-app registry; this is an accepted, temporary coupling until gear-contributed descriptors land.
+- Discovery must tolerate resources missing `getOne`/`update`/`delete` and expose the gaps rather than render broken controls.
+- A later ADR/feature will specify the gear-contributed descriptor mechanism (e.g. inventory-based registration alongside route registration) and the migration from the hardcoded registry.
+
+### Confirmation
+
+Confirmed by design review of DESIGN.md (descriptor schema and OpenAPI mapping), by the panel rendering generated screens for the v0 resources from OpenAPI + registry, and by e2e tests covering list/detail/create/edit, filtering/ordering, pagination, and custom actions.
+
+## Pros and Cons of the Options
+
+### Option A: OpenAPI-first + hardcoded v0 registry
+
+Derive defaults from the spec; curate a small in-app registry for what the spec cannot express; defer gear-contributed descriptors.
+
+- Good, because it reuses the existing aggregated OpenAPI surface and `x-odata-*` extensions.
+- Good, because the curated registry expresses grouping, tenant-scope strategy, safety levels, custom actions, and overrides that OpenAPI cannot.
+- Good, because it ships v0 without blocking on a new backend descriptor mechanism.
+- Good, because the registry shape is forward-compatible with gear-contributed descriptors.
+- Neutral, because the registry is a temporary in-app coupling.
+- Bad, because adding a v0 resource means editing the admin app until the gear-contributed mechanism exists.
+
+### Option B: Pure OpenAPI introspection only
+
+Generate the entire panel from `/openapi.json` with no curated descriptors.
+
+- Good, because zero curation and fully automatic.
+- Bad, because OpenAPI cannot express resource grouping, tenant-scope strategy, safety levels, custom-action semantics, or layout overrides.
+- Bad, because custom actions (suspend, approve, convert) and confirmation/destructive semantics would be guessed, risking unsafe UI.
+- Bad, because navigation and capability gating would lack the metadata the PRD requires.
+
+### Option C: Build gear-contributed descriptors now
+
+Require every gear to ship admin descriptors before v0 can render them.
+
+- Good, because it directly realizes the long-term extensibility goal.
+- Bad, because it is new backend work across many gears and blocks v0 delivery.
+- Bad, because it couples the first UI milestone to a cross-cutting backend mechanism whose design is not yet settled.
+
+## More Information
+
+- Issue: constructorfabric/gears-rust#4144
+- The API Gateway serves the aggregated OpenAPI document with vendor extensions `x-odata-filter`, `x-odata-orderby`, `x-rate-limit-rps`.
+- Cursor pagination is provided via the platform's `Page`/`PageInfo` envelope.
+- Deferred mechanism is tracked by `cpt-admin-panel-fr-gear-contributed-metadata` (priority p2).
+
+## Traceability
+
+- **PRD**: [PRD.md](../PRD.md)
+- **DESIGN**: [DESIGN.md](../DESIGN.md)
+
+This decision directly addresses the following requirements or design elements:
+
+- `cpt-admin-panel-fr-openapi-discovery` — defines OpenAPI as the primary discovery source.
+- `cpt-admin-panel-fr-resource-descriptor` — the curated registry implements the descriptor model for v0.
+- `cpt-admin-panel-fr-gear-contributed-metadata` — deferred; the registry shape is forward-compatible with it.
+- `cpt-admin-panel-fr-generated-screens` — generated screens from OpenAPI + registry with overrides.
+- `cpt-admin-panel-fr-partial-crud` — discovery tolerates missing CRUD operations.
+- `cpt-admin-panel-fr-pagination-filtering` — `x-odata-*` and cursor pagination mapped from the spec.
diff --git a/docs/arch/admin-panel/ADR/0004-cpt-admin-panel-adr-admin-role-and-context.md b/docs/arch/admin-panel/ADR/0004-cpt-admin-panel-adr-admin-role-and-context.md
new file mode 100644
index 000000000..53ec00597
--- /dev/null
+++ b/docs/arch/admin-panel/ADR/0004-cpt-admin-panel-adr-admin-role-and-context.md
@@ -0,0 +1,144 @@
+---
+status: accepted
+date: 2026-06-25
+decision-makers: gears-rust admin-panel working group
+---
+
+# Admin mode via a static role stub plus an admin-context endpoint
+
+
+
+
+- [Context and Problem Statement](#context-and-problem-statement)
+- [Decision Drivers](#decision-drivers)
+- [Considered Options](#considered-options)
+- [Decision Outcome](#decision-outcome)
+ - [Consequences](#consequences)
+ - [Confirmation](#confirmation)
+- [Pros and Cons of the Options](#pros-and-cons-of-the-options)
+ - [Option A: Static role stub + admin-context endpoint](#option-a-static-role-stub--admin-context-endpoint)
+ - [Option B: Real admin-mode in SecurityContext/authz now](#option-b-real-admin-mode-in-securitycontextauthz-now)
+ - [Option C: Client-side mode heuristics from `/me`](#option-c-client-side-mode-heuristics-from-me)
+ - [Option D: Dedicated admin-context endpoint](#option-d-dedicated-admin-context-endpoint)
+ - [Option E: Extend `GET /me`](#option-e-extend-get-me)
+- [More Information](#more-information)
+- [Traceability](#traceability)
+
+
+
+**ID**: `cpt-admin-panel-adr-admin-role-and-context`
+
+## Context and Problem Statement
+
+The panel must distinguish platform admin from tenant admin and fetch the caller's admin context (principal, tenant, admin mode, enabled gears, capabilities) at startup, while keeping the backend the final authority. Today the security model carries only identity and home tenant (`SecurityContext`: subject id/type/tenant, token scopes, bearer); there is no admin-mode concept, the authorization resolver answers only yes/no per request (capabilities are not enumerable), the only identity endpoint is `GET /me` (subject id/type/home tenant), and there is no context/capabilities endpoint. How do we represent admin mode and expose admin context for v0 without committing the production authorization model?
+
+## Decision Drivers
+
+- **Backend authority** — mode and capabilities must come from the backend, not from a user-toggleable UI control.
+- **No production-model commitment** — v0 must not lock in how admin roles are represented in the real authorization system.
+- **Unblock v0** — a workable mode + context path is needed now; the static plugins are the platform's existing dev seam.
+- **Clearly non-production** — demo/static auth must be unmistakably marked as such.
+- **Forward compatibility** — the admin-context contract should survive a later move to a real role model.
+- **Capability-driven UI** — navigation and write gating need a capabilities list the panel can consume.
+
+## Considered Options
+
+For representing admin mode:
+
+- **Option A**: Stub two roles in the static (non-production) auth plugins — map distinct dev tokens to identities carrying a platform-admin vs tenant-admin marker (via `subject_type` / token scopes) — and add an admin-context endpoint that derives mode and capabilities from the security context.
+- **Option B**: Add a real admin-mode concept to `SecurityContext` and the authorization model now.
+- **Option C**: Compute admin mode purely client-side from `GET /me` plus heuristics.
+
+For exposing context:
+
+- **Option D**: Add a dedicated admin-context endpoint.
+- **Option E**: Extend `GET /me` to return mode, enabled gears, and capabilities.
+
+## Decision Outcome
+
+Chosen options: **Option A** (static role stub) **+ Option D** (dedicated admin-context endpoint).
+
+For v0, two clearly-marked, non-production roles are stubbed in the static auth plugins: distinct dev tokens resolve to identities marked platform-admin or tenant-admin (using `subject_type` and/or token scopes, which the static plugins already support). A dedicated admin-context endpoint returns the principal (subject id/type), resolved home tenant, admin mode, and a capabilities list, derived server-side from the security context and the static role marker. Enabled gears are **not** part of this contract — the panel reads them separately from the gear orchestrator (`GET /gear-orchestrator/v1/gears`), keeping admin-context a thin identity/authorization projection. The panel calls it at startup to drive mode selection, capability-gated navigation, and tenant scope, with the backend remaining the final authority on every action.
+
+A dedicated endpoint (D) is chosen over extending `/me` (E) because `/me` is a minimal, non-tenant-scoped identity reflection used broadly; admin context is a richer, admin-specific concern that should not bloat or change `/me`'s contract.
+
+The production representation of admin mode (real authorization action, token scope claim, or identity field) is explicitly deferred and recorded as an open question; the static stub and the admin-context contract are designed so that the production model can replace the stub without changing the frontend.
+
+### Consequences
+
+- The static auth/authz plugins gain two dev identities/tokens marked as platform-admin and tenant-admin; these and the role stub must be labeled non-production in UI and docs (`cpt-admin-panel-nfr-demo-marking`).
+- A new admin-context endpoint must be implemented; its host gear (account-management, API Gateway, or a thin admin gear) and exact shape are settled in DESIGN (open question).
+- The endpoint must compute a capabilities list and an enabled-gears summary; for v0 capabilities may be derived from the role marker and the enabled gears, not from per-request authorization enumeration.
+- Tenant isolation continues to be enforced server-side (Secure ORM tenant-subtree predicates and authorization decisions); the panel must not implement isolation.
+- When a real role model is introduced, the static stub is removed and the admin-context endpoint is re-backed by it without a frontend contract change.
+- `GET /me` is unchanged.
+
+### Confirmation
+
+Confirmed by design review of DESIGN.md (admin-context endpoint shape and host gear, static role stub), by the panel selecting mode and capability-gated navigation from the endpoint, by tenant admin being unable to reach cross-tenant data, and by e2e tests covering both modes.
+
+## Pros and Cons of the Options
+
+### Option A: Static role stub + admin-context endpoint
+
+Mark two roles in the static plugins; derive mode/capabilities server-side.
+
+- Good, because it reuses the platform's existing static-plugin dev seam (token-to-identity mapping with `subject_type`/scopes).
+- Good, because mode stays backend-derived, not a UI toggle.
+- Good, because it unblocks v0 without committing the production authorization model.
+- Good, because it is forward-compatible — the stub can be swapped for a real model behind the same endpoint.
+- Bad, because it is explicitly non-production and must be clearly marked to avoid misuse.
+
+### Option B: Real admin-mode in SecurityContext/authz now
+
+Introduce a first-class admin-mode in the core security model immediately.
+
+- Good, because it would be the production-correct representation.
+- Bad, because it is a cross-cutting change to the security model that blocks v0.
+- Bad, because the right representation (action vs scope vs claim) is an unresolved design question.
+
+### Option C: Client-side mode heuristics from `/me`
+
+Infer mode in the browser from identity plus guesses (e.g. home tenant is root).
+
+- Good, because no backend change.
+- Bad, because mode would be UI-derived and spoofable, violating backend-authority.
+- Bad, because capabilities and enabled gears are not available from `/me`.
+
+### Option D: Dedicated admin-context endpoint
+
+A purpose-built endpoint returning principal, tenant, mode, enabled gears, capabilities.
+
+- Good, because it isolates admin concerns from the minimal `/me` contract.
+- Good, because it can aggregate enabled gears and capabilities in one startup call.
+- Neutral, because it is one new endpoint to own and version.
+
+### Option E: Extend `GET /me`
+
+Add mode/gears/capabilities to the existing identity endpoint.
+
+- Good, because one fewer endpoint.
+- Bad, because it overloads a minimal, broadly-used, non-tenant-scoped identity reflection with admin-specific, heavier data.
+- Bad, because it couples `/me` consumers to admin-context changes.
+
+## More Information
+
+- Issue: constructorfabric/gears-rust#4144
+- `SecurityContext` carries subject id/type, home tenant, token scopes (`["*"]` = unrestricted), and bearer token.
+- The static auth plugins support `accept_all` and `static_tokens` modes (token-to-identity mapping with `subject_type` and scopes).
+- `GET /me` returns subject id/type and home tenant only.
+- Tenant isolation is enforced at the database layer via tenant-subtree predicates.
+
+## Traceability
+
+- **PRD**: [PRD.md](../PRD.md)
+- **DESIGN**: [DESIGN.md](../DESIGN.md)
+
+This decision directly addresses the following requirements or design elements:
+
+- `cpt-admin-panel-fr-admin-context` — defines the startup admin-context fetch.
+- `cpt-admin-panel-fr-role-stub` — defines the v0 static platform/tenant role stub.
+- `cpt-admin-panel-fr-admin-modes` — mode is backend-derived from the admin context.
+- `cpt-admin-panel-interface-admin-context` — fixes the admin-context endpoint as the contract.
+- `cpt-admin-panel-nfr-demo-marking` — requires marking the stub as non-production.
+- `cpt-admin-panel-nfr-backend-authority` — backend remains the authority for mode and actions.
diff --git a/docs/arch/admin-panel/DESIGN.md b/docs/arch/admin-panel/DESIGN.md
new file mode 100644
index 000000000..f18cef9ca
--- /dev/null
+++ b/docs/arch/admin-panel/DESIGN.md
@@ -0,0 +1,488 @@
+# Technical Design — Integrated Admin Panel
+
+
+> **Revision (2026-06-30):** placement and discovery were updated after PR review — the panel becomes a **generic, reusable SPA** (runtime `/cf/openapi.json` + gear-emitted metadata) shipped as a **pre-built artifact** with zero per-project TypeScript, eventually extracted to a dedicated `constructorfabric/` repository (kept in-monorepo until fully generic). The in-app curated registry described below is the v0 state and shrinks to near-zero as runtime OpenAPI discovery and gear-contributed metadata land. See the revision notes in [ADR-0001](ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md) and [ADR-0003](ADR/0003-cpt-admin-panel-adr-resource-discovery.md). Metadata transport (config / `x-cf-admin-*` extensions / descriptor endpoint) is pending reviewer confirmation.
+>
+> **Revision (2026-07-02) — implemented; discovery transport resolved by concern-split.** The metadata-transport question above is closed **without a new backend mechanism** or framework change (see [ADR-0003](ADR/0003-cpt-admin-panel-adr-resource-discovery.md) 2026-07-02). Facts are split by nature: (1) **API-intrinsic** — field types/`required`/`readOnly`, CRUD verb→path mapping, custom actions (`POST …/suspend`), and tenant-scope (the `{tenant_id}` path param) — are derived at runtime from `/cf/openapi.json`; (2) **presentation & policy** — list columns, labels, safety, action wiring, irregular list paths, withheld verbs — live in a declarative JSON config (`apps/admin-panel/src/resources/admin.config.json`) interpreted by a generic loader. The hand-written in-app registry is gone: `registry.ts` shrank from ~336 LOC to ~46 (config import + build + icons). Net result — **registering a resource is a JSON edit with zero per-project TypeScript**, satisfying the genericity driver below. The §3.1/§3.2 "curated in-app registry" wording is superseded by this config-plus-OpenAPI model. Still deferred to follow-ups: extraction to a dedicated `constructorfabric/` repo as a pre-built artifact, a production admin-role model beyond the dev stub, and the raw-DB operator fallback.
+
+
+
+- [1. Architecture Overview](#1-architecture-overview)
+ - [1.1 Architectural Vision](#11-architectural-vision)
+ - [1.2 Architecture Drivers](#12-architecture-drivers)
+ - [1.3 Architecture Layers](#13-architecture-layers)
+- [2. Principles & Constraints](#2-principles--constraints)
+ - [2.1 Design Principles](#21-design-principles)
+ - [2.2 Constraints](#22-constraints)
+- [3. Technical Architecture](#3-technical-architecture)
+ - [3.1 Domain Model](#31-domain-model)
+ - [3.2 Component Model](#32-component-model)
+ - [3.3 API Contracts](#33-api-contracts)
+ - [3.4 Internal Dependencies](#34-internal-dependencies)
+ - [3.5 External Dependencies](#35-external-dependencies)
+ - [3.6 Interactions & Sequences](#36-interactions--sequences)
+ - [3.7 Database schemas & tables](#37-database-schemas--tables)
+ - [3.8 Deployment Topology](#38-deployment-topology)
+- [4. Additional context](#4-additional-context)
+- [5. Traceability](#5-traceability)
+
+
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-design-overview`
+
+## 1. Architecture Overview
+
+### 1.1 Architectural Vision
+
+The Integrated Admin Panel is a metadata-driven, OpenAPI-discovered single-page application (SPA) that manages gear-owned resources through the existing Gears API authority boundary. It is built with React + Refine + Vite + Ant Design (TypeScript), embedded in the `gears-rust` monorepo, and served as a static bundle by the example server under the API Gateway prefix at `/cf/admin` (see `cpt-admin-panel-adr-placement-and-delivery`, `cpt-admin-panel-adr-frontend-framework`). The panel never embeds business logic or tenant-isolation rules: it renders screens from resource metadata, issues authenticated requests to gear REST endpoints, and treats the backend (API Gateway, AuthN/AuthZ/Tenant resolvers, Secure ORM) as the final authority.
+
+Discovery is OpenAPI-first: the panel reads the gateway-aggregated `/openapi.json` (including the `x-odata-*` vendor extensions and cursor-pagination envelope) to derive default fields, operations, filters, and ordering, and overlays a small curated in-app resource registry that supplies what OpenAPI cannot express — resource grouping, tenant-scope strategy, safety levels, custom-action wiring, and layout overrides (see `cpt-admin-panel-adr-resource-discovery`). The registry's descriptor shape is forward-compatible with a future gear-contributed metadata mechanism, which is deferred beyond v0.
+
+Two admin modes — platform and tenant — are selected from a backend-provided admin context fetched at startup, not from a UI toggle. Because the platform's security model has no admin-mode concept today, v0 introduces a clearly non-production role stub in the static auth plugins and a dedicated admin-context endpoint that returns the principal, resolved tenant, admin mode, enabled gears, and capabilities (see `cpt-admin-panel-adr-admin-role-and-context`). The only new backend code for v0 is this endpoint and the two-role static stub; everything else reuses existing gear APIs.
+
+### 1.2 Architecture Drivers
+
+#### Functional Drivers
+
+| Requirement | Design Response |
+|-------------|------------------|
+| `cpt-admin-panel-fr-admin-modes` | Mode resolved from the admin-context endpoint; no UI toggle. |
+| `cpt-admin-panel-fr-platform-mode` | Platform mode enables cross-tenant resources, tenant-context switch, confirmation + audit on destructive/cross-tenant actions. |
+| `cpt-admin-panel-fr-tenant-mode` | Tenant mode restricts navigation/resources to the in-scope tenant subtree; backend enforces isolation. |
+| `cpt-admin-panel-fr-admin-shell` | Refine shell renders capability-gated navigation from the resource registry + admin context. |
+| `cpt-admin-panel-fr-resource-descriptor` | Resource registry implements the descriptor model (key, owning gear, source ops, fields, scope strategy, actions, capabilities, safety level). |
+| `cpt-admin-panel-fr-generated-screens` | Refine + Ant Design generate list/detail/create/edit screens from descriptors; per-resource overrides via custom pages. |
+| `cpt-admin-panel-fr-openapi-discovery` | Data provider parses `/openapi.json` for fields/operations and `x-odata-*` capabilities. |
+| `cpt-admin-panel-fr-partial-crud` | Provider omits methods/actions for missing operations; UI exposes the gap. |
+| `cpt-admin-panel-fr-custom-actions` | Refine `custom()` provider method drives suspend/unsuspend/approve/reject/cancel/retry/resolve/deprovision with confirmation. |
+| `cpt-admin-panel-fr-pagination-filtering` | Provider maps cursor pagination and OData `$filter`/`$orderby` from advertised extensions. |
+| `cpt-admin-panel-fr-error-normalization` | Provider normalizes RFC-9457 problem responses into admin messages. |
+| `cpt-admin-panel-fr-admin-context` | Dedicated admin-context endpoint fetched at startup. |
+| `cpt-admin-panel-fr-role-stub` | Two non-production roles in the static auth plugins. |
+| `cpt-admin-panel-fr-server-side-scope` | Tenant scope resolved server-side; Secure ORM tenant-subtree predicates prevent escalation. |
+| `cpt-admin-panel-fr-tenants` / `-resource-groups` / `-types-gts` / `-gear-status` | v0 resources backed by account-management, resource-group, types-registry, gear-orchestrator APIs. |
+
+#### NFR Allocation
+
+| NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach |
+|--------|-------------|--------------|-----------------|----------------------|
+| `cpt-admin-panel-nfr-auth-required` | Auth on all admin routes; authz on writes; audit destructive/cross-tenant/raw-DB | API Gateway auth middleware + gear handlers + audit sink | All admin API calls carry a bearer token validated by AuthN; writes evaluated by AuthZ; audited actions emit records | e2e tests for unauthenticated/unauthorized; audit-record assertions |
+| `cpt-admin-panel-nfr-no-secret-exposure` | No secrets exposed by default | Resource registry field visibility + (later) raw-DB column masking | Secret/security fields are not listed by default; masking required before any opt-in | Review + tests asserting masked fields absent |
+| `cpt-admin-panel-nfr-backend-authority` | Isolation enforced backend-side | AuthZ resolver + Secure ORM | No isolation/authorization decision in the SPA; provider only sends scoped requests | e2e: tenant admin cannot reach cross-tenant objects |
+| `cpt-admin-panel-nfr-demo-marking` | Demo/static auth clearly marked | SPA banner + docs | Non-production banner when static auth/role stub active | Review + UI test |
+
+#### Key ADRs
+
+| ADR ID | Decision Summary |
+|--------|-----------------|
+| `cpt-admin-panel-adr-placement-and-delivery` | Embed in monorepo; serve SPA from example server at `/cf/admin`. |
+| `cpt-admin-panel-adr-frontend-framework` | React + Refine + Vite + Ant Design (TypeScript). |
+| `cpt-admin-panel-adr-resource-discovery` | OpenAPI-first discovery + hardcoded v0 registry; gear-contributed descriptors deferred. |
+| `cpt-admin-panel-adr-admin-role-and-context` | Static two-role stub + dedicated admin-context endpoint; `/me` unchanged. |
+
+### 1.3 Architecture Layers
+
+```mermaid
+graph TB
+ subgraph Browser
+ SPA[Admin SPA: React + Refine + Ant Design]
+ DP[Gears Data Provider]
+ AP[Auth Provider]
+ ACP[Access-Control Provider]
+ REG[Resource Registry + OpenAPI client]
+ SPA --> DP & AP & ACP & REG
+ end
+ subgraph "Example Server (cf-gears-example-server)"
+ SD["Static assets /cf/admin (ServeDir + SPA fallback)"]
+ GW[API Gateway: prefix /cf, aggregated /openapi.json]
+ ACX[Admin-Context endpoint]
+ AM[account-management]
+ RG[resource-group]
+ TR[types-registry]
+ GO[gear-orchestrator]
+ AUTHN[authn-resolver + static stub]
+ AUTHZ[authz-resolver + static stub]
+ TEN[tenant-resolver + static stub]
+ GW --> AM & RG & TR & GO & ACX
+ GW --> AUTHN & AUTHZ & TEN
+ end
+ DP -->|REST + OData + cursor| GW
+ AP -->|bearer| AUTHN
+ ACP -->|capabilities| ACX
+ REG -->|/openapi.json| GW
+ SPA -. served by .- SD
+ AM --> DB[(Secure ORM / DB)]
+ RG --> DB
+ TR --> DB
+```
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-tech-layers`
+
+| Layer | Responsibility | Technology |
+|-------|---------------|------------|
+| Presentation | Generated admin screens, navigation, confirmation flows | React, Refine, Ant Design, Vite (TypeScript) |
+| Application (client) | Data/auth/access-control providers, resource registry, OpenAPI client | Refine providers, `openapi-typescript` client |
+| Delivery | Serve SPA static bundle; proxy gear routes; aggregate OpenAPI | `cf-gears-example-server`, API Gateway, `tower-http` `ServeDir` |
+| API | Gear REST endpoints + new admin-context endpoint | ToolKit `OperationBuilder`, Axum, utoipa OpenAPI |
+| Domain/Infra | Business logic, tenant isolation, persistence | Gears, Secure ORM, SeaORM, AuthN/AuthZ/Tenant resolvers |
+
+## 2. Principles & Constraints
+
+### 2.1 Design Principles
+
+#### Backend Is the Authority
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-principle-backend-authority`
+
+The SPA never implements authorization or tenant isolation. It renders what metadata and capabilities allow, but every list, read, write, and action is authorized server-side; the UI showing or hiding a control is a convenience, not a security boundary.
+
+**ADRs**: `cpt-admin-panel-adr-admin-role-and-context`
+
+#### Metadata-Driven, Override-Capable
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-principle-metadata-driven`
+
+Screens are generated from OpenAPI + resource descriptors; bespoke workflows are per-resource overrides, not forks of the core app. New resources are additive.
+
+**ADRs**: `cpt-admin-panel-adr-resource-discovery`
+
+#### API-First, DB-Last
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-principle-api-first`
+
+The panel uses Gears APIs as the authority boundary; raw database access is an operator-only, audited, last-resort fallback for resources with no safe API, and is deferred beyond v0.
+
+**ADRs**: `cpt-admin-panel-adr-resource-discovery`
+
+### 2.2 Constraints
+
+#### JS/TS Toolchain in a Rust Monorepo
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-constraint-js-toolchain`
+
+The admin SPA introduces a Node/TypeScript build confined to the admin subtree, built as a separate CI step and not coupled to the Rust build graph. The repo is otherwise pure Rust.
+
+**ADRs**: `cpt-admin-panel-adr-placement-and-delivery`
+
+#### Non-Production Static Auth for v0
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-constraint-static-auth`
+
+v0 runs against the static (demo) auth/authz/tenant plugins, including the two-role stub. These are not production-grade and must be clearly marked as such.
+
+**ADRs**: `cpt-admin-panel-adr-admin-role-and-context`
+
+## 3. Technical Architecture
+
+### 3.1 Domain Model
+
+**Technology**: TypeScript types (frontend resource descriptors) + existing gear domain models (backend, unchanged).
+
+**Location**: admin SPA resource registry (frontend); gear SDK crates own the backend domain entities.
+
+**Core Entities**:
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-entity-descriptors`
+
+| Entity | Description | Schema |
+|--------|-------------|--------|
+| ResourceDescriptor | Declares a manageable resource: key, owning gear, source operation IDs, list/detail/create/update fields, search/filter/sort/relation fields, tenant-scope strategy, supported actions, required capabilities, safety level | frontend registry (TS) |
+| FieldDescriptor | label, type, visibility, read-only, validation, relation, widget, permission | frontend registry (TS) |
+| ActionDescriptor | custom action: name, HTTP operation, confirmation, destructive/cross-tenant flags, required capability | frontend registry (TS) |
+| AdminContext | principal, resolved tenant, admin mode, enabled gears, capabilities | admin-context endpoint DTO |
+
+**Relationships**:
+- ResourceDescriptor → FieldDescriptor: a resource has list/detail/create/update field sets.
+- ResourceDescriptor → ActionDescriptor: a resource exposes zero or more custom actions.
+- AdminContext → ResourceDescriptor: capabilities gate which resources/actions are visible.
+
+The admin panel introduces no new persistent gear-owned domain entities for v0 (resources are projections of existing gear data). The admin-context endpoint is computed, not stored.
+
+### 3.2 Component Model
+
+```mermaid
+graph LR
+ subgraph SPA
+ Shell[Admin Shell + Navigation]
+ DP[Gears Data Provider]
+ AP[Auth Provider]
+ ACP[Access-Control Provider]
+ REG[Resource Registry]
+ OAC[OpenAPI Client]
+ end
+ Shell --> REG
+ Shell --> ACP
+ DP --> OAC
+ DP --> REG
+ ACP --> ACX
+ AP --> AUTHN
+ OAC --> GW
+ DP --> GW
+ ACX[Admin-Context Endpoint]
+ GW[API Gateway]
+ AUTHN[AuthN Resolver + static stub]
+```
+
+#### Admin Shell
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-shell`
+
+##### Why this component exists
+Hosts the SPA layout, routing, navigation, and admin-mode presentation; the entry point users interact with.
+
+##### Responsibility scope
+Renders capability-gated navigation from the resource registry and admin context; nested tenant routes; mode banner (incl. non-production marking); confirmation dialogs for destructive/cross-tenant actions.
+
+##### Responsibility boundaries
+Does not fetch domain data directly (delegates to the data provider) and does not make authorization decisions (delegates to the access-control provider/backend).
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-resource-registry` — reads descriptors from
+- `cpt-admin-panel-component-access-control-provider` — gates navigation via
+- `cpt-admin-panel-component-data-provider` — triggers CRUD/actions through
+
+#### Gears Data Provider
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-data-provider`
+
+##### Why this component exists
+Translates Refine's list/read/create/update/delete and `custom()` operations into Gears REST requests, hiding gear-specific wire details from screens.
+
+##### Responsibility scope
+Builds requests from descriptors + OpenAPI operations; maps OData `$filter`/`$orderby` from `x-odata-*`; handles cursor pagination; attaches bearer token; normalizes RFC-9457 errors and list metadata.
+
+##### Responsibility boundaries
+Does not define resources (reads them from the registry/OpenAPI) and does not store auth state (uses the auth provider).
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-openapi-client` — issues typed requests via
+- `cpt-admin-panel-component-resource-registry` — resolves operation IDs from
+- `cpt-admin-panel-component-auth-provider` — obtains bearer token from
+
+#### Auth Provider
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-auth-provider`
+
+##### Why this component exists
+Implements Refine's auth lifecycle (login/logout/check/getIdentity) over the platform bearer flow.
+
+##### Responsibility scope
+Acquires/stores the bearer token; resolves identity; handles unauthenticated/expired-session states.
+
+##### Responsibility boundaries
+Does not authorize actions (that is the access-control provider + backend); does not implement an IdP.
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-shell` — drives session states for
+
+#### Access-Control Provider
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-access-control-provider`
+
+##### Why this component exists
+Implements Refine's `can({resource, action})` against backend capabilities to gate navigation and write controls.
+
+##### Responsibility scope
+Maps admin-context capabilities + descriptor required-capabilities to allow/deny for UI gating.
+
+##### Responsibility boundaries
+Convenience gating only; the backend remains the authority and re-checks every action.
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-admin-context` — consumes capabilities from
+
+#### Resource Registry
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-resource-registry`
+
+##### Why this component exists
+Holds the curated v0 resource/field/action descriptors and overlays them on OpenAPI-derived defaults.
+
+##### Responsibility scope
+Declares v0 resources and their source operations, scope strategy, safety levels, custom actions, and layout overrides; shaped to be replaceable by gear-contributed descriptors later.
+
+##### Responsibility boundaries
+No network I/O; pure metadata consumed by the shell and data provider.
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-openapi-client` — overlays defaults from
+
+#### OpenAPI Client
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-openapi-client`
+
+##### Why this component exists
+Provides a typed client and schema view generated from the gateway-served `/openapi.json`.
+
+##### Responsibility scope
+Generates/loads types and operation metadata (paths, params, schemas, `x-odata-*`); keeps the data provider thin and spec-synced.
+
+##### Responsibility boundaries
+Transport only; no business logic.
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-data-provider` — supplies typed operations to
+
+#### Admin-Context Endpoint (backend)
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-component-admin-context`
+
+##### Why this component exists
+Supplies the startup admin context (principal, tenant, mode, enabled gears, capabilities) that does not exist in the platform today.
+
+##### Responsibility scope
+Derives mode and capabilities server-side from the security context + the static role marker; summarizes enabled gears.
+
+##### Responsibility boundaries
+Does not replace `/me`; does not store state; does not perform per-request enumeration of authorization for v0 (derives capabilities from role + enabled gears).
+
+##### Related components (by ID)
+- `cpt-admin-panel-component-access-control-provider` — serves capabilities to
+- `cpt-admin-panel-component-shell` — serves mode + enabled gears to
+
+### 3.3 API Contracts
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-interface-admin-context-api`
+
+- **Contracts**: `cpt-admin-panel-contract-openapi`
+- **Technology**: REST / OpenAPI (JSON), served under the API Gateway prefix `/cf`
+- **Location**: admin-context endpoint hosted by **account-management** (`GET /cf/account-management/v1/admin/context`).
+
+**Endpoints Overview**:
+
+| Method | Path | Description | Stability |
+|--------|------|-------------|-----------|
+| `GET` | `/cf/account-management/v1/admin/context` | Returns subject, home tenant, admin mode, capabilities | unstable |
+| `GET` | `/cf/gear-orchestrator/v1/gears` | Enabled gears + status (existing; read separately by the panel) | stable |
+| `GET` | `/cf/openapi.json` | Aggregated OpenAPI used for discovery (existing) | stable |
+| `GET` | `/cf/admin/*` | Static SPA assets with SPA fallback (existing server, new mount) | unstable |
+| `GET` | `/cf/account-management/v1/me` | Identity reflection (existing, unchanged) | stable |
+
+Admin-context response (the implemented flat `AdminContextDto`):
+
+```json
+{
+ "subject_id": "uuid",
+ "subject_type": "platform_admin|tenant_admin|null",
+ "subject_tenant_id": "uuid",
+ "admin_mode": "platform|tenant",
+ "capabilities": ["tenants:read", "tenants:write", "tenants:suspend", "resource-groups:read", "..."],
+ "non_production_auth": true
+}
+```
+
+Enabled gears are intentionally **not** part of this response — the panel reads them separately from `GET /cf/gear-orchestrator/v1/gears`, so the admin-context contract stays a thin identity/authorization projection. The v0 resources are driven by existing gear endpoints (account-management tenants/metadata/conversions, resource-group groups/memberships, types-registry entities, gear-orchestrator gears); the data provider consumes them via OpenAPI discovery. No gear API is modified for v0 except the addition of the admin-context endpoint.
+
+### 3.4 Internal Dependencies
+
+| Dependency Gear | Interface Used | Purpose |
+|-------------------|----------------|----------|
+| api-gateway | aggregated OpenAPI + route proxy under `/cf`; static mount | Discovery surface and SPA delivery |
+| account-management | REST (tenants, metadata, conversions, users, `/me`) | Tenant management v0 resources |
+| resource-group | REST (groups, descendants/ancestors, memberships) | Resource group v0 resources |
+| types-registry | REST (entities register/list/get) | Types/GTS v0 resources |
+| gear-orchestrator | REST (`/gears`) | Enabled gears + status summary |
+| authn-resolver (+ static stub) | bearer authentication | Principal + bearer for the panel |
+| authz-resolver (+ static stub) | authorization decisions | Server-side write/read authorization |
+| tenant-resolver (+ static stub) | tenant scope resolution | Server-side tenant scoping |
+
+**Dependency Rules** (per project conventions):
+- No circular dependencies.
+- Always use SDK modules for inter-gear communication.
+- No cross-category sideways deps except through contracts.
+- Only integration/adapter gears talk to external systems.
+- `SecurityContext` must be propagated across all in-process calls.
+
+### 3.5 External Dependencies
+
+#### Browser runtime
+
+- **Contract**: `cpt-admin-panel-contract-openapi`
+
+The SPA runs in the operator's browser and communicates only with the example server under `/cf` (same-origin). No third-party backend services are introduced. Frontend build-time dependencies (Node, npm registry packages) are confined to the admin subtree.
+
+**Dependency Rules** (per project conventions):
+- No circular dependencies.
+- Always use SDK modules for inter-gear communication.
+- `SecurityContext` must be propagated across all in-process calls.
+
+### 3.6 Interactions & Sequences
+
+#### Startup and Mode Selection
+
+**ID**: `cpt-admin-panel-seq-startup`
+
+**Use cases**: `cpt-admin-panel-usecase-suspend-tenant`, `cpt-admin-panel-usecase-tenant-metadata`
+
+**Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+```mermaid
+sequenceDiagram
+ participant U as Operator browser
+ participant S as Example server (/cf/admin)
+ participant G as API Gateway
+ participant X as Admin-Context endpoint
+ U->>S: GET /cf/admin (load SPA)
+ S-->>U: static bundle
+ U->>G: GET /cf/openapi.json (bearer)
+ G-->>U: aggregated OpenAPI (+ x-odata-*)
+ U->>X: GET /cf//v1/admin/context (bearer)
+ X-->>U: principal, tenant, admin_mode, enabled_gears, capabilities
+ U->>U: build registry + capability-gated navigation
+```
+
+**Description**: The SPA loads, discovers resources from OpenAPI, fetches admin context, and renders mode-appropriate, capability-gated navigation.
+
+#### Custom Action (Suspend Tenant)
+
+**ID**: `cpt-admin-panel-seq-suspend`
+
+**Use cases**: `cpt-admin-panel-usecase-suspend-tenant`
+
+**Actors**: `cpt-admin-panel-actor-platform-operator`
+
+```mermaid
+sequenceDiagram
+ participant U as Operator browser
+ participant G as API Gateway
+ participant AM as account-management
+ participant Z as AuthZ resolver
+ U->>U: confirm destructive action
+ U->>G: POST /cf/account-management/v1/tenants/{id}/suspend (bearer)
+ G->>AM: proxy
+ AM->>Z: evaluate(suspend, tenant)
+ Z-->>AM: allow + constraints
+ AM-->>G: 200 (status=suspended) / RFC-9457 on deny
+ G-->>U: result; provider normalizes errors
+```
+
+**Description**: A custom action routes through the data provider's `custom()` method; the backend authorizes and the provider normalizes the response.
+
+### 3.7 Database schemas & tables
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-db-none`
+
+The admin panel introduces no new database tables for v0. All managed data is owned by existing gears and accessed through their APIs; tenant isolation is enforced by Secure ORM tenant-subtree predicates in those gears. Audit logging for destructive/cross-tenant/raw-DB actions uses the platform's audit sink (target sink confirmed in implementation). The raw-database fallback (`cpt-admin-panel-fr-raw-db`) — with table allowlist, read-only default, secret masking, and write audit — is deferred beyond v0 and will be specified in a feature/ADR when introduced.
+
+### 3.8 Deployment Topology
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-topology-embedded`
+
+Single-process deployment: the `cf-gears-example-server` serves both the gear APIs (under `/cf`) and the admin SPA static bundle (under `/cf/admin`, via `tower-http` `ServeDir` with SPA fallback to `index.html`). A `make admin` target builds the SPA (`vite build` → static assets) and runs the server with an `admin` feature/config (modeled on the `mini-chat` target and a `config/admin.yaml` copied from `config/mini-chat.yaml`); `make example` remains the plain-server path. The SPA build is a separate CI step; whether built assets are committed or built on demand is decided during implementation. The same topology supports the existing Python/pytest e2e harness, with a new `testing/e2e/gears/admin/` suite driving v0 flows against the locally started server.
+
+## 4. Additional context
+
+- v0 backend work is intentionally minimal: the admin-context endpoint and the two-role static stub. Everything else is frontend plus configuration.
+- Known backend gaps the registry must accommodate: types-registry and OAGW advertise no OData (limit/offset or simple filters), and there is no global `GET /tenants` list — the tenant tree starts from the root via `/children` unless a list endpoint is added (open question).
+- Open questions carried from the PRD: admin-context host gear and exact shape; production representation of admin mode; whether a global tenant list is added; SPA authentication against a non-`auth_disabled` deployment.
+- Out of scope for v0 (per PRD): policy/role editor, credential store editor, billing, audit-log UI, password reset, IdP-native group management, tenant-facing raw DB.
+
+## 5. Traceability
+
+- **PRD**: [PRD.md](./PRD.md)
+- **ADRs**: [ADR/](./ADR/)
+ - [`0001`](./ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md) — placement & delivery
+ - [`0002`](./ADR/0002-cpt-admin-panel-adr-frontend-framework.md) — frontend framework
+ - [`0003`](./ADR/0003-cpt-admin-panel-adr-resource-discovery.md) — resource discovery
+ - [`0004`](./ADR/0004-cpt-admin-panel-adr-admin-role-and-context.md) — admin role & context
+- **Issue**: constructorfabric/gears-rust#4144
diff --git a/docs/arch/admin-panel/PRD.md b/docs/arch/admin-panel/PRD.md
new file mode 100644
index 000000000..36708f9d3
--- /dev/null
+++ b/docs/arch/admin-panel/PRD.md
@@ -0,0 +1,596 @@
+# PRD — Integrated Admin Panel
+
+
+
+- [1. Overview](#1-overview)
+ - [1.1 Purpose](#11-purpose)
+ - [1.2 Background / Problem Statement](#12-background--problem-statement)
+ - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes)
+ - [1.4 Glossary](#14-glossary)
+- [2. Actors](#2-actors)
+ - [2.1 Human Actors](#21-human-actors)
+ - [2.2 System Actors](#22-system-actors)
+- [3. Operational Concept & Environment](#3-operational-concept--environment)
+ - [3.1 Gear-Specific Environment Constraints](#31-gear-specific-environment-constraints)
+- [4. Scope](#4-scope)
+ - [4.1 In Scope](#41-in-scope)
+ - [4.2 Out of Scope](#42-out-of-scope)
+- [5. Functional Requirements](#5-functional-requirements)
+ - [5.1 Admin Shell & Modes](#51-admin-shell--modes)
+ - [5.2 Admin Resource Model & Extensibility](#52-admin-resource-model--extensibility)
+ - [5.3 API-Driven Admin](#53-api-driven-admin)
+ - [5.4 Resource Coverage (v0)](#54-resource-coverage-v0)
+ - [5.5 Session, Capability, and Context](#55-session-capability-and-context)
+ - [5.6 Tenant Isolation](#56-tenant-isolation)
+ - [5.7 User Management](#57-user-management)
+ - [5.8 Raw Database Fallback](#58-raw-database-fallback)
+- [6. Non-Functional Requirements](#6-non-functional-requirements)
+ - [6.1 Gear-Specific NFRs](#61-gear-specific-nfrs)
+- [7. Public Library Interfaces](#7-public-library-interfaces)
+ - [7.1 Public API Surface](#71-public-api-surface)
+ - [7.2 External Integration Contracts](#72-external-integration-contracts)
+- [8. Use Cases](#8-use-cases)
+- [9. Acceptance Criteria](#9-acceptance-criteria)
+- [10. Dependencies](#10-dependencies)
+- [11. Assumptions](#11-assumptions)
+- [12. Risks](#12-risks)
+- [13. Open Questions](#13-open-questions)
+- [14. Traceability](#14-traceability)
+
+
+
+## 1. Overview
+
+### 1.1 Purpose
+
+The Integrated Admin Panel is a Django-admin-like management UI for `gears-rust`. It gives operators and tenant administrators a generated, metadata-driven web console to manage gear-owned resources (tenants, resource groups, types, gateway routes, gear status, and example domain objects) through existing Gears APIs as the authority boundary. It ships in two flavours: a **platform/operator** console with full cross-tenant data management, and a **tenant-scoped** console for customer/tenant administrators that exposes only the current tenant's data, governed by backend authorization and tenant isolation.
+
+The panel is generated from resource metadata and the aggregated OpenAPI specification rather than hand-built per resource, so new admin objects, fields, views, filters, and actions can be added by registering descriptors instead of editing the core admin app.
+
+### 1.2 Background / Problem Statement
+
+`gears-rust` exposes a rich set of REST APIs (account management, resource groups, types registry/GTS, API gateway, gear orchestrator, nodes registry, file parser, example gears), aggregated behind the API Gateway as a single OpenAPI document with vendor extensions for OData filtering, ordering, and rate limits. Today there is no first-party UI to manage these resources: operators rely on raw API calls, Swagger UI, or direct database access. There is no tenant-scoped self-service admin for customers, and no consistent, safe path for cross-tenant operator management.
+
+A vendor composing gears into a product needs an out-of-the-box admin surface that respects the platform's defense-in-depth model — authentication, authorization, tenant isolation, scoped DB access — without re-implementing it in the UI. The admin must prefer the safe API boundary, fall back to raw database access only for operator-only resources that have no API yet, and remain additive so each gear can contribute its own admin resources as the platform grows.
+
+The platform's authorization model currently distinguishes principals only by identity and home tenant; there is no built-in "platform admin vs tenant admin" mode, and no endpoint that returns the caller's admin context (mode, capabilities, enabled gears). The admin panel must introduce a minimal, clearly non-production admin-role model in the static auth plugins for the first version, and an admin-context endpoint, while keeping the backend the final authority.
+
+### 1.3 Goals (Business Outcomes)
+
+- Operators manage all authorized tenants and gear-owned resources from a single console, with cross-tenant and destructive operations gated by confirmation and audit logging.
+- Tenant administrators manage only their own tenant (or authorized subtree) with no access to global lists or platform internals.
+- New admin resources and fields are added by registering metadata, without editing the core admin application.
+- The admin reuses Gears APIs and the platform security model as the authority boundary; raw database access is an explicit, audited, operator-only fallback.
+- A working v0 ships integrated with the example server (or as a `make admin` sidecar) with e2e coverage, web-docs, and README updates.
+
+### 1.4 Glossary
+
+| Term | Definition |
+|------|------------|
+| Admin resource | A manageable object exposed in the admin panel (e.g. tenant, resource group, type, gateway route), described by a resource descriptor. |
+| Resource descriptor | Metadata defining a resource's key, owning gear, API/DB source, fields, views, filters, sort/relation fields, tenant-scope strategy, actions, required capabilities, and safety level. |
+| Platform admin | An admin mode with cross-tenant authority over gear-owned resources, able to switch tenant context and perform operator-only/destructive actions. |
+| Tenant admin | An admin mode scoped to the current tenant or authorized subtree, with no access to global tenant lists or platform internals. |
+| Admin context | The caller's current principal, tenant, admin mode, enabled gears, and capabilities, fetched at startup. |
+| Safety level | A resource/action classification: `normal`, `destructive`, `operator-only`, or `read-only`. |
+| Data provider | The frontend adapter that translates admin list/read/create/update/delete/custom-action calls into Gears API requests. |
+| Raw database fallback | Operator-only, allowlisted, default-read-only access to database tables for resources that have no safe API yet. |
+| OpenAPI discovery | Deriving default resource fields, operations, filters, and ordering from the aggregated OpenAPI spec and its `x-odata-*` extensions. |
+
+## 2. Actors
+
+> **Note**: Stakeholder needs are managed at project/task level by steering committee. Document **actors** (users, systems) that interact with this gear.
+
+### 2.1 Human Actors
+
+#### Platform Operator
+
+**ID**: `cpt-admin-panel-actor-platform-operator`
+
+- **Role**: A first-party operator administering the whole deployment across all authorized tenants and gear-owned resources.
+- **Needs**: Cross-tenant visibility and management, tenant-context switching, operator-only and destructive operations with confirmation and audit, an operational summary of enabled gears and resources.
+
+#### Tenant Administrator
+
+**ID**: `cpt-admin-panel-actor-tenant-admin`
+
+- **Role**: A customer/tenant administrator managing data within their own tenant or authorized tenant subtree.
+- **Needs**: Tenant-scoped lists, details, relations, and actions; no exposure to global tenant lists or platform internals; reliance on backend isolation rather than hidden UI controls.
+
+#### Gear Developer
+
+**ID**: `cpt-admin-panel-actor-gear-developer`
+
+- **Role**: Authors gears and contributes admin resource descriptors and admin metadata alongside the gear's API routes.
+- **Needs**: An additive registration mechanism to expose new objects, fields, filters, and actions without modifying the core admin app.
+
+### 2.2 System Actors
+
+#### API Gateway
+
+**ID**: `cpt-admin-panel-actor-api-gateway`
+
+- **Role**: Serves the aggregated OpenAPI specification and proxies all gear REST routes under the configured prefix; the admin panel's primary integration surface.
+
+#### AuthN / AuthZ / Tenant Resolvers
+
+**ID**: `cpt-admin-panel-actor-security-resolvers`
+
+- **Role**: Authenticate the principal, evaluate authorization decisions, and resolve tenant scope server-side; the final authority for every admin action.
+
+#### Gears REST Services
+
+**ID**: `cpt-admin-panel-actor-gear-services`
+
+- **Role**: Account management, resource group, types registry, gear orchestrator, nodes registry, file parser, and example gears that own the resources the admin panel manages.
+
+## 3. Operational Concept & Environment
+
+> Foundational constraints (runtime, lifecycle, transport, security model) are defined at repository level. See [`docs/ARCHITECTURE_MANIFEST.md`](../../ARCHITECTURE_MANIFEST.md), the foundational [`guidelines/`](../../../guidelines), and the authorization design [`docs/arch/authorization/DESIGN.md`](../authorization/DESIGN.md). Only admin-panel-specific constraints are documented here.
+
+### 3.1 Gear-Specific Environment Constraints
+
+- The admin panel frontend is a browser SPA. The monorepo is otherwise pure Rust with no existing JavaScript/TypeScript build tooling; introducing a frontend toolchain (or a separate repository) is an explicit project decision (see Open Questions).
+- The aggregated OpenAPI document and all gear routes are served by the API Gateway under a configurable prefix (default `/cf`); the admin panel depends on this discovery surface being reachable.
+- The first version targets the static (demo) auth, authz, and tenant-resolver plugins; these are explicitly non-production. Platform-admin vs tenant-admin roles are stubbed in the static auth plugins for v0.
+- Tenant isolation is enforced server-side (Secure ORM tenant-subtree predicates and authorization decisions); the UI must not implement isolation logic.
+
+## 4. Scope
+
+### 4.1 In Scope
+
+- A metadata-driven admin shell with platform and tenant admin modes.
+- A current admin-context/session view (principal, tenant, admin mode, enabled gears, capabilities).
+- An enabled-gears operational summary.
+- A resource registry and capability-driven generated navigation.
+- Tenant management: list/tree/detail, create/update/suspend/unsuspend/soft-delete, metadata read/write/delete, conversion requests list/detail/action handling.
+- Resource Group management: list/tree/detail/create/update/delete and memberships, where the API is stable.
+- Types/GTS management: list/detail/create/update/delete, where the API is stable.
+- API Gateway upstreams and routes management, where the API is stable.
+- Gear/instance status summaries from the gear orchestrator.
+- A user-management placeholder shown when no IdP plugin supports user operations.
+- An API-driven data provider with OpenAPI discovery, OData filtering/ordering where advertised, cursor pagination, custom actions (suspend, unsuspend, approve, reject, cancel, retry, resolve, deprovision), and normalized error/list-metadata handling.
+- A minimal platform-admin vs tenant-admin role stub in the static auth plugins, and an admin-context endpoint.
+- Integration with `apps/cf-gears-example-server` or a `make admin` sidecar, e2e tests, web-docs updates, and README updates.
+
+### 4.2 Out of Scope
+
+- Full policy editor and role editor.
+- Credential store editor and deep plugin configuration editor.
+- Billing management and audit log browsing UI.
+- Password reset and IdP-native group management.
+- Tenant-facing raw database admin.
+- A production-grade identity provider; v0 uses the non-production static auth plugins.
+- The gear-contributed admin-metadata registration mechanism beyond a hardcoded v0 resource registry (deferred; see Open Questions and FR priorities).
+- gRPC service registry management and deployment-mode mutation in the gear orchestrator.
+
+## 5. Functional Requirements
+
+> **Testing strategy**: All requirements verified via automated tests (unit, integration, e2e) targeting 90%+ code coverage unless otherwise specified. Document verification method only for non-test approaches. Priority `p1` marks the v0 scope; `p2`/`p3` mark later increments.
+
+### 5.1 Admin Shell & Modes
+
+#### Two Admin Modes
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-admin-modes`
+
+The admin panel **MUST** support two admin modes — platform admin and tenant admin — and select the active mode from the backend-provided admin context, not from a user-toggleable UI control.
+
+- **Rationale**: The two flavours are the central requirement; mode must derive from backend authority to prevent privilege escalation via UI.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Platform Admin Capabilities
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-platform-mode`
+
+In platform admin mode the panel **MUST** allow managing all authorized tenants and gear-owned resources, switching tenant context when managing tenant-owned data, and viewing enabled gears, API resources, feature status, and operational summaries. Cross-tenant and destructive operations **MUST** require confirmation and **MUST** be audit logged.
+
+- **Rationale**: Operator console must cover full data management with safety controls on dangerous actions.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+#### Tenant Admin Scoping
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-tenant-mode`
+
+In tenant admin mode the panel **MUST** restrict management to the current tenant or authorized tenant subtree, **MUST** show tenant-owned objects only, and **MUST NOT** expose global tenant lists or platform internals by default. Scoping **MUST** rely on backend tenant isolation, not on hidden UI controls.
+
+- **Rationale**: Tenant self-service must never leak cross-tenant data; enforcement is the backend's responsibility.
+- **Actors**: `cpt-admin-panel-actor-tenant-admin`
+
+#### Admin Shell & Generated Navigation
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-admin-shell`
+
+The panel **MUST** provide an admin shell that renders navigation generated from the registered admin resources and the caller's capabilities, showing or hiding resources based on backend capabilities.
+
+- **Rationale**: Capability-driven, generated navigation is core to the metadata-driven design.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+### 5.2 Admin Resource Model & Extensibility
+
+#### Resource Descriptor Model
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-resource-descriptor`
+
+The panel **MUST** treat each manageable object as an admin resource described by a descriptor that defines: resource key, owning gear, API route or database source, list/detail/create/update fields, search/filter fields, sort fields, relation fields, tenant-scope strategy, supported actions, required capabilities, and safety level (`normal`, `destructive`, `operator-only`, `read-only`).
+
+- **Rationale**: The descriptor is the contract that drives generated screens and access decisions.
+- **Actors**: `cpt-admin-panel-actor-gear-developer`
+
+#### Generated Screens from Metadata
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-generated-screens`
+
+The panel **MUST** generate list, create, edit, and detail screens from resource metadata where possible, and **MUST** allow per-resource overrides for custom workflows.
+
+- **Rationale**: Generation keeps the admin additive and low-maintenance while permitting bespoke flows.
+- **Actors**: `cpt-admin-panel-actor-gear-developer`
+
+#### Field Descriptors
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-fr-field-descriptors`
+
+A field descriptor **MUST** be able to define label, type, visibility, read-only state, validation, relation, widget, and permission.
+
+- **Rationale**: Rich field metadata enables faithful generated forms and per-field access control.
+- **Actors**: `cpt-admin-panel-actor-gear-developer`
+
+#### Gear-Contributed Admin Metadata
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-fr-gear-contributed-metadata`
+
+Each gear **MUST** be able to contribute its own admin resource descriptors alongside its API routes, and admin registration **MUST** be additive so new objects and fields can be added without editing the core admin app. For v0, a hardcoded resource registry **MAY** substitute for the gear-contributed mechanism.
+
+- **Rationale**: Long-term extensibility goal; v0 uses a hardcoded registry to avoid blocking on a new backend mechanism.
+- **Actors**: `cpt-admin-panel-actor-gear-developer`
+
+### 5.3 API-Driven Admin
+
+#### OpenAPI Resource Discovery
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-openapi-discovery`
+
+The panel **MUST** discover resources from the aggregated OpenAPI specification plus gear-provided admin metadata, using OpenAPI schemas for default fields and form generation and OpenAPI operations for list, read, create, update, delete, and custom actions.
+
+- **Rationale**: OpenAPI-first generation minimizes per-resource hand-coding.
+- **Actors**: `cpt-admin-panel-actor-api-gateway`, `cpt-admin-panel-actor-gear-developer`
+
+#### Partial CRUD Support
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-partial-crud`
+
+The panel **MUST** support resources without full CRUD, including resources missing `getOne`, `update`, or `delete` operations, and **MUST** clearly expose unsupported operations (especially IdP-backed operations) rather than presenting broken controls.
+
+- **Rationale**: Many gear resources are read-only or action-only; the UI must degrade gracefully.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Custom Actions
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-custom-actions`
+
+The panel **MUST** support custom actions beyond CRUD — including suspend, unsuspend, retry, approve, reject, cancel, resolve, and deprovision — with confirmation flows for destructive or cross-tenant actions.
+
+- **Rationale**: Tenant lifecycle and conversion workflows are action-driven, not plain CRUD.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+#### Pagination, Filtering, and Ordering
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-pagination-filtering`
+
+The panel **MUST** support cursor pagination and **MUST** support OData filtering and ordering where advertised by the OpenAPI `x-odata-*` extensions, and **MUST** normalize list response metadata for the frontend.
+
+- **Rationale**: Gear list endpoints use cursor pagination and advertise OData capabilities; the data provider must consume them.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Error Normalization
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-error-normalization`
+
+The panel **MUST** normalize Gears RFC-9457 problem/error responses into user-friendly admin messages.
+
+- **Rationale**: Consistent, readable error handling is required across all gear responses.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+### 5.4 Resource Coverage (v0)
+
+#### Tenant Management
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-tenants`
+
+The panel **MUST** support tenant list/tree/detail, create, update, suspend, unsuspend, and soft-delete, plus tenant metadata list/read/write/delete and tenant conversion requests list/detail/action handling, using account-management APIs.
+
+- **Rationale**: Tenants are the primary v0 resource and exercise lifecycle, metadata, and action workflows.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Resource Group Management
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-resource-groups`
+
+The panel **MUST** support resource group list/tree/detail/create/update/delete and memberships where the resource-group API is stable.
+
+- **Rationale**: Resource groups are a stable, hierarchical v0 resource.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Types / GTS Management
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-types-gts`
+
+The panel **MUST** support type/GTS list/detail/create/update/delete where the types-registry API is stable.
+
+- **Rationale**: Type definitions underpin metadata across gears and are a v0 target.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+#### API Gateway Upstreams and Routes
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-fr-gateway-routes`
+
+The panel **SHOULD** support API Gateway (OAGW) upstreams and routes management where the API is stable; CORS, auth, header, plugin, and rate-limit rule editing is later work.
+
+- **Rationale**: Gateway management is valuable but its write APIs are feature-gated and lack OData; defer rich rule editing.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+#### Gear and Instance Status
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-gear-status`
+
+The panel **MUST** show enabled gears and gear/instance status summaries from the gear orchestrator.
+
+- **Rationale**: Operators need an at-a-glance operational summary of enabled gears.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+#### Nodes, File Parser, and Example Resources
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-fr-other-resources`
+
+The panel **SHOULD** expose nodes registry information, file-parser capabilities, and example domain resources (users-info, mini-chat) when those gears are enabled and expose them safely.
+
+- **Rationale**: Broader coverage is desirable but secondary to core platform resources.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+### 5.5 Session, Capability, and Context
+
+#### Admin Context at Startup
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-admin-context`
+
+The panel **MUST** fetch the current admin context at startup — current principal, tenant, admin mode, enabled gears, and capabilities — and **MUST** show or hide resources based on backend capabilities, keeping backend authorization as the final authority.
+
+- **Rationale**: All navigation, mode selection, and capability gating depend on a context fetch; this endpoint does not exist today and must be built. As implemented, the admin-context endpoint returns subject/tenant/mode/capabilities (the flat `AdminContextDto`), and **enabled gears are fetched separately** from `GET /gear-orchestrator/v1/gears` — so the context contract stays a thin identity/authorization projection.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`, `cpt-admin-panel-actor-security-resolvers`
+
+#### Admin Role Stub
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-role-stub`
+
+The platform **MUST** provide a minimal platform-admin vs tenant-admin role distinction for v0 via the static (non-production) auth plugins, sufficient to drive admin mode and capabilities, and **MUST** clearly mark this as demo/static and non-production.
+
+- **Rationale**: The security model has no admin-mode concept today; a clearly-marked static stub unblocks v0 without committing the production authorization model.
+- **Actors**: `cpt-admin-panel-actor-security-resolvers`
+
+#### Session State Handling
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-session-states`
+
+The panel **MUST** handle unauthenticated, unauthorized, expired-session, and feature-unavailable states, and **MUST** support feature-gated resources and optional gears.
+
+- **Rationale**: Robust handling of auth and feature states is required for a usable, safe console.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+### 5.6 Tenant Isolation
+
+#### Server-Side Tenant Scope
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-server-side-scope`
+
+Tenant scope **MUST** be resolved server-side, derived from the authenticated security context where possible, and the panel **MUST NOT** allow arbitrary tenant ID escalation. Unauthorized access **MUST** collapse without leaking object existence.
+
+- **Rationale**: Isolation must be structural, enforced by backend policy and Secure ORM, never by UI logic.
+- **Actors**: `cpt-admin-panel-actor-security-resolvers`, `cpt-admin-panel-actor-tenant-admin`
+
+#### Scoped Lists, Relations, and Lookups
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-fr-scoped-views`
+
+Tenant admin lists, details, relations, actions, and lookups **MUST** be scoped by backend policy, and cross-tenant platform actions **MUST** be clearly marked in the UI.
+
+- **Rationale**: Every view must respect tenant scope; operators must see clearly when an action crosses tenant boundaries.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+### 5.7 User Management
+
+#### IdP-Backed User Management
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-fr-user-management`
+
+The panel **MUST** treat users as IdP-backed resources, **MUST** support tenant user list/create/delete when the IdP plugin supports those operations, and **MUST** show user management as unavailable when no IdP provider supports it. The panel **MUST NOT** assume a local users table exists.
+
+- **Rationale**: User management depends on an IdP plugin; absent one, the panel must show a placeholder rather than break.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`, `cpt-admin-panel-actor-tenant-admin`
+
+### 5.8 Raw Database Fallback
+
+#### Operator-Only Raw DB Access
+
+- [ ] `p3` - **ID**: `cpt-admin-panel-fr-raw-db`
+
+Raw database admin **MUST** be available only to platform admins and disabled for tenant admins. Tables **MUST** be explicitly allowlisted and default to read-only; create/update/delete **MUST** require explicit opt-in. Secrets, credentials, tokens, plugin internals, and security-sensitive columns **MUST** be hidden by default, and every raw write **MUST** be audit logged. Resources **MUST** migrate to API-backed access once a safe gear API exists.
+
+- **Rationale**: Raw DB access is the last-resort operator fallback and carries the highest risk; it requires the strictest guardrails. Deferred beyond v0.
+- **Actors**: `cpt-admin-panel-actor-platform-operator`
+
+## 6. Non-Functional Requirements
+
+> **Global baselines**: Project-wide security, reliability, and performance NFRs are defined at repository level — see [`docs/ARCHITECTURE_MANIFEST.md`](../../ARCHITECTURE_MANIFEST.md) and the foundational [`guidelines/`](../../../guidelines). Only admin-panel-specific NFRs are documented here.
+
+### 6.1 Gear-Specific NFRs
+
+#### Authentication on All Admin Routes
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-nfr-auth-required`
+
+All admin routes **MUST** require authentication, and every write action **MUST** require authorization; destructive, raw-database, and cross-tenant actions **MUST** be audit logged.
+
+- **Threshold**: Zero admin routes reachable without an authenticated principal; 100% of write/destructive/cross-tenant/raw-DB actions produce an audit record.
+- **Rationale**: Admin is a privileged surface; security must be structural, not optional.
+
+#### No Secret Exposure by Default
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-nfr-no-secret-exposure`
+
+The panel **MUST NOT** expose secrets, credentials, tokens, or security internals by default in any view, including raw database views.
+
+- **Threshold**: No secret/security-sensitive column or field rendered without explicit, audited opt-in.
+- **Rationale**: Defense-in-depth requires secrets to remain hidden across all admin surfaces.
+
+#### Backend-Authoritative Isolation
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-nfr-backend-authority`
+
+Tenant isolation **MUST** be enforced in Gears services or database policy, not in frontend logic; the frontend **MUST** treat backend authorization as the final authority.
+
+- **Threshold**: No isolation or authorization decision implemented solely in the frontend.
+- **Rationale**: UI-side isolation is bypassable; the backend must remain authoritative.
+
+#### Demo/Static Auth Clearly Marked
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-nfr-demo-marking`
+
+Demo/static auth and IdP plugins, and the v0 admin role stub, **MUST** be clearly marked as non-production in the UI and documentation.
+
+- **Rationale**: Prevents accidental production use of demo credentials and stubbed roles.
+
+## 7. Public Library Interfaces
+
+### 7.1 Public API Surface
+
+#### Admin Context Endpoint
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-interface-admin-context`
+
+- **Type**: REST API
+- **Stability**: unstable
+- **Description**: An endpoint returning the caller's admin context — principal, tenant, admin mode, enabled gears, and capabilities — consumed by the panel at startup. Final design (host gear, path, shape) is determined in DESIGN/ADR.
+- **Breaking Change Policy**: Unstable until v1; shape may change without major version bump.
+
+#### Admin Data Provider Contract
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-interface-data-provider`
+
+- **Type**: Frontend adapter (data provider)
+- **Stability**: unstable
+- **Description**: The frontend data-provider contract mapping admin list/read/create/update/delete/custom-action operations onto Gears API requests, including OData filtering/ordering, cursor pagination, and RFC-9457 error normalization.
+- **Breaking Change Policy**: Unstable until the frontend stack is finalized.
+
+### 7.2 External Integration Contracts
+
+#### Aggregated OpenAPI Specification
+
+- [ ] `p1` - **ID**: `cpt-admin-panel-contract-openapi`
+
+- **Direction**: required from platform (served by API Gateway)
+- **Protocol/Format**: OpenAPI 3.x JSON with `x-odata-filter`, `x-odata-orderby`, `x-rate-limit-rps` vendor extensions
+- **Compatibility**: The panel consumes advertised operations, schemas, and extensions; it must tolerate resources without full CRUD.
+
+## 8. Use Cases
+
+#### Operator Suspends a Tenant
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-usecase-suspend-tenant`
+
+**Actor**: `cpt-admin-panel-actor-platform-operator`
+
+**Preconditions**:
+- Operator is authenticated in platform admin mode with capability to manage the target tenant.
+
+**Main Flow**:
+1. Operator opens the tenant list and selects a tenant in `active` status.
+2. Operator triggers the `suspend` action; the panel shows a confirmation dialog marking it as a state-changing action.
+3. On confirmation, the panel calls the account-management suspend endpoint.
+4. The backend authorizes, performs the transition, and the panel refreshes the tenant detail showing `suspended` status.
+5. The action is audit logged.
+
+**Postconditions**:
+- The tenant status is `suspended`; an audit record exists.
+
+**Alternative Flows**:
+- **Unauthorized**: The backend denies; the panel shows a normalized authorization error and makes no state change.
+
+#### Tenant Admin Edits Tenant Metadata
+
+- [ ] `p2` - **ID**: `cpt-admin-panel-usecase-tenant-metadata`
+
+**Actor**: `cpt-admin-panel-actor-tenant-admin`
+
+**Preconditions**:
+- Tenant admin is authenticated; admin context resolves tenant scope to their own tenant.
+
+**Main Flow**:
+1. Tenant admin opens their tenant's metadata view (scoped server-side).
+2. Tenant admin edits a metadata entry and saves.
+3. The panel calls the metadata upsert endpoint for the in-scope tenant.
+4. The backend validates against the GTS type and persists; the panel shows the updated entry.
+
+**Postconditions**:
+- The metadata entry is updated for the tenant only; no other tenant's data is reachable.
+
+## 9. Acceptance Criteria
+
+- [ ] An operator can authenticate, see platform admin mode, view enabled gears, and manage tenants (list/tree/detail, create/update/suspend/unsuspend/soft-delete) end-to-end.
+- [ ] A tenant admin can authenticate, see only their tenant's data, and is unable to access global tenant lists or other tenants' objects.
+- [ ] The panel renders list/detail/create/edit screens generated from resource metadata and OpenAPI discovery for at least tenants, resource groups, and types.
+- [ ] Custom actions (suspend/unsuspend and conversion approve/reject/cancel) work with confirmation flows.
+- [ ] RFC-9457 errors are shown as user-friendly messages; resources lacking CRUD operations degrade gracefully.
+- [ ] e2e tests cover the v0 flows; web-docs and README are updated; the panel runs via the example server or `make admin`.
+
+## 10. Dependencies
+
+| Dependency | Description | Criticality |
+|------------|-------------|-------------|
+| API Gateway | Serves aggregated OpenAPI and proxies gear routes under the configured prefix | p1 |
+| Account Management | Tenant, metadata, conversion, and user (IdP) APIs | p1 |
+| AuthN / AuthZ / Tenant resolvers | Authentication, authorization decisions, server-side tenant scoping | p1 |
+| Resource Group | Group hierarchy and membership APIs | p1 |
+| Types Registry / GTS | Type definition and schema APIs | p1 |
+| Gear Orchestrator | Enabled-gears and status summaries | p1 |
+| Static auth/authz/tenant plugins | Non-production demo identities and the v0 admin role stub | p1 |
+| Frontend toolchain (Refine or alternative) | SPA framework and build tooling for the admin console | p1 |
+
+## 11. Assumptions
+
+- The aggregated OpenAPI document and gear routes are reachable under the configured API Gateway prefix.
+- Cursor pagination and `x-odata-*` extensions are advertised by list endpoints that support them.
+- Tenant isolation is enforced server-side via Secure ORM tenant-subtree predicates and authorization decisions.
+- v0 runs against the static (non-production) auth, authz, and tenant-resolver plugins.
+- A browser SPA stack (preferring Refine) is acceptable, introduced either embedded in the example server or as a separate repository/sidecar.
+
+## 12. Risks
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| No admin-mode/capabilities concept in the current security model | Cannot drive platform vs tenant mode or capability gating | Build an admin-context endpoint and a clearly-marked static role stub for v0 |
+| Introducing a JS/TS frontend into a pure-Rust monorepo | Build/CI complexity, maintenance burden | Decide embedded vs separate repo/sidecar in an ADR; isolate the toolchain |
+| Some gear APIs lack OData or full CRUD (types registry, OAGW) | Inconsistent generated screens | Support partial CRUD and limit-offset; defer rich filtering/rule editing |
+| Raw DB fallback exposing secrets or enabling unsafe writes | Security incident | Operator-only, allowlist, read-only default, secret masking, mandatory audit; defer beyond v0 |
+| Demo/static auth mistaken for production | Insecure deployment | Mark demo auth and role stub as non-production in UI and docs |
+
+## 13. Open Questions
+
+**Resolved during implementation:**
+
+- ~~Where does the implementation live?~~ **Embedded in the monorepo for v0**, served by the example server at `/cf/admin`; extraction to a dedicated `constructorfabric/` repo is a follow-up ([ADR-0001](ADR/0001-cpt-admin-panel-adr-placement-and-delivery.md), 2026-07-13).
+- ~~Which gear owns the admin-context endpoint, and what is its shape?~~ **account-management**, `GET /account-management/v1/admin/context`, returning the flat `AdminContextDto` (subject, home tenant, admin mode, capabilities); enabled gears are read separately from the gear orchestrator ([ADR-0004](ADR/0004-cpt-admin-panel-adr-admin-role-and-context.md)).
+- ~~OpenAPI discovery vs gear-contributed descriptors vs hybrid?~~ **Concern-split**: API-intrinsic facts derived from OpenAPI at runtime, presentation/policy in a declarative panel-side JSON config — no per-project TypeScript, no framework change ([ADR-0003](ADR/0003-cpt-admin-panel-adr-resource-discovery.md), 2026-07-02).
+- ~~Is the frontend stack confirmed as Refine?~~ **Yes**, Refine + Ant Design (MIT) ([ADR-0002](ADR/0002-cpt-admin-panel-adr-frontend-framework.md)).
+- ~~How does the SPA authenticate against a non-`auth_disabled` deployment?~~ **Reuses the authn-resolver bearer flow** (dev tokens via the non-production static-auth stub for v0).
+
+**Still open (deferred, out of v0):**
+
+- How is admin mode represented beyond the v0 static stub — authorization action, token scope claim, or computed identity field? (The stub and the admin-context contract are designed so the production model can replace the stub without changing the frontend.)
+- Is a global `GET /tenants` list needed for platform admin, or is starting from the root tenant via children sufficient? (v0 uses the root + `/children`; owner of authz decides if a global list is added.)
+
+## 14. Traceability
+
+- **PRD** (this document): [`./PRD.md`](./PRD.md)
+- **Design**: [`./DESIGN.md`](./DESIGN.md)
+- **ADRs**: [`./ADR/`](./ADR/)
+- **Issue**: constructorfabric/gears-rust#4144
diff --git a/docs/web-docs/build-with-gears/admin-panel.md b/docs/web-docs/build-with-gears/admin-panel.md
new file mode 100644
index 000000000..521b26508
--- /dev/null
+++ b/docs/web-docs/build-with-gears/admin-panel.md
@@ -0,0 +1,132 @@
+---
+title: Admin panel
+description: The descriptor-driven admin console — platform vs tenant modes, the admin-context endpoint, and how to add a resource.
+sidebar:
+ label: Admin panel
+ order: 11
+---
+
+Gears ships an optional **admin console** — a Django-admin-style management UI that
+talks to the platform exclusively through the existing Gears HTTP APIs. It is a React
+single-page app (`apps/admin-panel`, built on [Refine](https://refine.dev) + Ant Design)
+served beside the example server; there is no privileged back door, so anything the panel
+can do, an API client can do with the same token.
+
+## Two admin modes
+
+The panel renders the same screens in two postures, decided entirely by the backend:
+
+- **Platform (operator) admin** — manages all authorized tenants and gear-owned
+ resources, runs cross-tenant and destructive operations (with confirmation).
+- **Tenant admin** — manages only the current tenant or its authorized subtree, sees
+ tenant-owned objects only, and never the global tenant list.
+
+Tenant isolation is **not** a UI concern: it is enforced server-side (the account-management
+`SecureORM` `InTenantSubtree` predicate). The panel only hides controls the caller has no
+capability for; the backend re-authorizes every request.
+
+## Startup context
+
+On login the panel calls one endpoint to discover who it is talking to:
+
+```
+GET /account-management/v1/admin/context
+```
+
+It returns the principal, home tenant, an `admin_mode` (`platform` | `tenant`), a list of
+coarse `capabilities` hints used for capability-driven navigation, and
+`non_production_auth` — `true` whenever the demo static-auth stub is in effect, so the UI
+can surface a "non-production" banner. The capabilities are advisory for UI gating only.
+
+## Run it locally
+
+Start the example server with the admin feature set and its config:
+
+```sh
+make admin
+```
+
+This builds the SPA (`npm install && npm run build`), starts the example server with the
+Gears APIs at `http://localhost:8087/cf`, and **serves the built panel at
+`http://localhost:8087/cf/admin`** — no separate dev server needed. Two **non-production**
+dev tokens (a platform admin and a tenant admin) are exposed via the static-auth stub; open
+`/cf/admin` and pick a role to sign in.
+
+For frontend development with hot reload, run the Vite dev server instead and let it proxy
+the API:
+
+```sh
+cd apps/admin-panel
+npm install
+npm run dev
+```
+
+### How the SPA is served
+
+The api-gateway serves the built SPA from disk when its `admin_spa_dir` config points at the
+`dist/` directory (set in `config/admin.yaml`):
+
+```yaml
+gears:
+ api-gateway:
+ config:
+ prefix_path: "/cf"
+ admin_spa_dir: "apps/admin-panel/dist" # serves the SPA at /cf/admin
+```
+
+The static assets are mounted **outside the auth middleware** — the SPA itself is public,
+while its API calls carry a bearer token. Unmatched paths fall back to `index.html` so
+client-side routes deep-link and survive a refresh. Leave `admin_spa_dir` unset to disable
+serving the panel (e.g. when running Vite separately).
+
+## Resources are described, not hardcoded
+
+Every manageable object is one entry in
+**`apps/admin-panel/src/resources/admin.config.json`** — declarative data, not TypeScript.
+Discovery is split by concern:
+
+- **API-intrinsic facts come from OpenAPI.** At startup the panel reads the gateway-aggregated
+ `/cf/openapi.json` and derives each resource's field types / `required` / `readOnly`, its
+ CRUD verb→path mapping, custom operations, and tenant scope. The API is the single source of
+ truth for what exists.
+- **Presentation and policy come from the config.** Each entry supplies only what the spec
+ can't express: list columns and labels, the component `schema` name, an irregular list path,
+ verbs the panel intentionally withholds (`suppressVerbs`, or a `read-only` safety), custom
+ actions, and field option sources.
+
+Adding an admin object — here, or in another `gears-rust` project that ships the pre-built
+panel — is therefore a JSON edit with **no TypeScript and no core changes**. A read-only
+resource backed by a list endpoint:
+
+```json
+{
+ "key": "gears",
+ "label": "Gears",
+ "owningGear": "gear-orchestrator",
+ "tenantScope": "global",
+ "safety": "read-only",
+ "basePath": "/gear-orchestrator/v1/gears",
+ "idField": "name",
+ "capabilities": { "read": "gears:read" },
+ "fields": [
+ { "name": "name", "inList": true, "readOnly": true },
+ { "name": "deployment_mode", "label": "Mode", "inList": true }
+ ]
+}
+```
+
+The panel derives the available verbs from the spec under `basePath` — here only a list
+endpoint exists, so only a list screen renders. Custom (non-CRUD) actions — tenant `suspend` /
+`unsuspend`, conversion `approve` / `reject` / `cancel` — are declared declaratively too: a
+path template (`{tenant}` / `{id}` placeholders), an optional static body, a capability gate,
+a safety level, and a `visibleWhen` predicate; destructive ones get a confirmation prompt.
+
+## v0 coverage
+
+The first version covers the admin shell, the session/context view, the enabled-gears
+summary, tenants (list, detail, create/update and lifecycle actions), resource groups
+(CRUD), conversions (list/detail + actions), and read-only types/GTS and gear status.
+Runtime OpenAPI discovery (fields **and** routes) and declarative JSON registration are in
+place. See `docs/arch/admin-panel/` (PRD, DESIGN, ADRs) for the full design and the deferred
+items (extraction to a dedicated `constructorfabric/` repo as a pre-built artifact, a
+production admin-role model beyond the dev stub, and the raw-database operator fallback).
diff --git a/gears/system/account-management/account-management/src/api/rest/dto.rs b/gears/system/account-management/account-management/src/api/rest/dto.rs
index bf32ac9a9..997e9ea23 100644
--- a/gears/system/account-management/account-management/src/api/rest/dto.rs
+++ b/gears/system/account-management/account-management/src/api/rest/dto.rs
@@ -248,6 +248,105 @@ impl MeDto {
}
}
+/// Startup admin context for the admin panel.
+///
+/// Richer sibling of [`MeDto`]: in addition to the authenticated subject
+/// and home tenant, it projects the admin **mode** and a coarse
+/// **capabilities** hint that the frontend uses for capability-driven
+/// navigation. The backend remains the final authority on every action;
+/// these capabilities are UI hints only.
+///
+/// v0 derives mode and capabilities from the `subject_type` role marker
+/// set by the **non-production** static auth stub (`platform_admin` /
+/// `tenant_admin`). `non_production_auth` is `true` whenever that stub
+/// marker is present so the UI can surface a demo banner. When a real
+/// authorization model replaces the stub, this projection is re-backed by
+/// it without changing the wire contract. Enabled gears are intentionally
+/// not included here — the frontend reads them from the gear orchestrator.
+#[derive(Debug, Clone)]
+#[toolkit_macros::api_dto(response)]
+#[allow(clippy::struct_field_names)]
+pub struct AdminContextDto {
+ pub subject_id: Uuid,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subject_type: Option,
+ pub subject_tenant_id: Uuid,
+ /// `"platform"` or `"tenant"`.
+ pub admin_mode: String,
+ /// Coarse capability hints for UI gating (e.g. `tenants:write`).
+ pub capabilities: Vec,
+ /// `true` when the non-production static role stub is in effect.
+ pub non_production_auth: bool,
+}
+
+/// `admin_mode` value for the platform/operator admin.
+const ADMIN_MODE_PLATFORM: &str = "platform";
+/// `admin_mode` value for the tenant admin.
+const ADMIN_MODE_TENANT: &str = "tenant";
+/// `subject_type` marker for the platform admin (static stub).
+const ROLE_PLATFORM_ADMIN: &str = "platform_admin";
+/// `subject_type` marker for the tenant admin (static stub).
+const ROLE_TENANT_ADMIN: &str = "tenant_admin";
+
+/// Capability hints for the platform/operator admin.
+const PLATFORM_CAPABILITIES: &[&str] = &[
+ "tenants:read",
+ "tenants:write",
+ "tenants:suspend",
+ "tenant-metadata:read",
+ "tenant-metadata:write",
+ "conversions:read",
+ "conversions:write",
+ "resource-groups:read",
+ "resource-groups:write",
+ "types:read",
+ "types:write",
+ "users:read",
+ "users:write",
+ "gears:read",
+];
+
+/// Capability hints for the tenant admin (own-tenant scope).
+const TENANT_CAPABILITIES: &[&str] = &[
+ "tenants:read",
+ "tenant-metadata:read",
+ "tenant-metadata:write",
+ "conversions:read",
+ "resource-groups:read",
+ "types:read",
+ "users:read",
+ "users:write",
+];
+
+impl AdminContextDto {
+ /// Project the request's [`SecurityContext`] into the admin context.
+ ///
+ /// Mode and capabilities derive from the `subject_type` role marker.
+ /// An unknown or absent marker is treated as the least-privileged
+ /// tenant admin; the backend still authorizes every action.
+ #[must_use]
+ pub(crate) fn from_security_context(ctx: &SecurityContext) -> Self {
+ let subject_type = ctx.subject_type();
+ let is_platform = subject_type == Some(ROLE_PLATFORM_ADMIN);
+ let is_stub_role = matches!(subject_type, Some(ROLE_PLATFORM_ADMIN | ROLE_TENANT_ADMIN));
+
+ let (admin_mode, capabilities) = if is_platform {
+ (ADMIN_MODE_PLATFORM, PLATFORM_CAPABILITIES)
+ } else {
+ (ADMIN_MODE_TENANT, TENANT_CAPABILITIES)
+ };
+
+ Self {
+ subject_id: ctx.subject_id(),
+ subject_type: subject_type.map(str::to_owned),
+ subject_tenant_id: ctx.subject_tenant_id(),
+ admin_mode: admin_mode.to_owned(),
+ capabilities: capabilities.iter().map(|c| (*c).to_owned()).collect(),
+ non_production_auth: is_stub_role,
+ }
+ }
+}
+
// ---- Tenant hierarchy DTOs --------------------------------------
/// Wire-shape enum for `Tenant.status` on the REST surface.
diff --git a/gears/system/account-management/account-management/src/api/rest/handlers/admin_context.rs b/gears/system/account-management/account-management/src/api/rest/handlers/admin_context.rs
new file mode 100644
index 000000000..b76953343
--- /dev/null
+++ b/gears/system/account-management/account-management/src/api/rest/handlers/admin_context.rs
@@ -0,0 +1,38 @@
+//! REST handler for `GET /account-management/v1/admin/context`.
+//!
+//! Startup admin context for the admin panel: projects the authenticated
+//! caller's [`SecurityContext`] (subject id, type, home tenant) plus a
+//! derived admin **mode** and coarse **capabilities** hint into the wire
+//! [`AdminContextDto`]. Pure context projection — no service, no domain
+//! logic, no I/O. Mode/capabilities derive from the `subject_type` role
+//! marker; the backend remains the final authority on every action.
+
+use axum::Extension;
+use toolkit::api::canonical_prelude::*;
+use toolkit_security::SecurityContext;
+use tracing::field::Empty;
+
+use crate::api::rest::dto::AdminContextDto;
+
+/// `GET /account-management/v1/admin/context`
+///
+/// Returns the authenticated subject's identity, home tenant, admin mode,
+/// and capability hints, read from the request [`SecurityContext`]. Always
+/// succeeds for an authenticated caller; the `.authenticated()` route gate
+/// produces 401 upstream when the bearer token is missing or invalid.
+///
+/// # Errors
+///
+/// Never returns `Err` for a caller that reaches this handler;
+/// unauthenticated requests are rejected upstream by the
+/// `.authenticated()` route gate.
+#[tracing::instrument(skip(ctx), fields(request_id = Empty))]
+pub async fn get_admin_context(
+ Extension(ctx): Extension,
+) -> ApiResult> {
+ Ok(Json(AdminContextDto::from_security_context(&ctx)))
+}
+
+#[cfg(test)]
+#[path = "admin_context_tests.rs"]
+mod tests;
diff --git a/gears/system/account-management/account-management/src/api/rest/handlers/admin_context_tests.rs b/gears/system/account-management/account-management/src/api/rest/handlers/admin_context_tests.rs
new file mode 100644
index 000000000..a4bfc7df6
--- /dev/null
+++ b/gears/system/account-management/account-management/src/api/rest/handlers/admin_context_tests.rs
@@ -0,0 +1,71 @@
+//! Unit tests for the `GET /account-management/v1/admin/context` handler.
+//!
+//! Scope: pin the pure context-projection from [`super::get_admin_context`]
+//! — verifies that the `subject_type` role marker drives `admin_mode`,
+//! `capabilities`, and the `non_production_auth` flag, and that the subject
+//! and home tenant are reflected from the
+//! [`toolkit_security::SecurityContext`] into the [`super::AdminContextDto`].
+
+use axum::Extension;
+use uuid::Uuid;
+
+use super::*;
+
+fn ctx_with_type(subject_type: Option<&str>) -> SecurityContext {
+ let mut builder = SecurityContext::builder()
+ .subject_id(Uuid::from_u128(0xA11CE))
+ .subject_tenant_id(Uuid::from_u128(0x007E_9A47));
+ if let Some(st) = subject_type {
+ builder = builder.subject_type(st);
+ }
+ builder.build().expect("ctx")
+}
+
+#[tokio::test]
+async fn platform_admin_marker_yields_platform_mode() {
+ let Json(body) = get_admin_context(Extension(ctx_with_type(Some("platform_admin"))))
+ .await
+ .expect("ok");
+
+ assert_eq!(body.admin_mode, "platform");
+ assert_eq!(body.subject_type.as_deref(), Some("platform_admin"));
+ assert!(body.non_production_auth);
+ assert!(body.capabilities.contains(&"tenants:write".to_owned()));
+ assert!(body.capabilities.contains(&"gears:read".to_owned()));
+}
+
+#[tokio::test]
+async fn tenant_admin_marker_yields_tenant_mode() {
+ let Json(body) = get_admin_context(Extension(ctx_with_type(Some("tenant_admin"))))
+ .await
+ .expect("ok");
+
+ assert_eq!(body.admin_mode, "tenant");
+ assert!(body.non_production_auth);
+ assert!(body.capabilities.contains(&"tenants:read".to_owned()));
+ // Tenant admin must not advertise platform-only write capabilities.
+ assert!(!body.capabilities.contains(&"tenants:write".to_owned()));
+ assert!(!body.capabilities.contains(&"gears:read".to_owned()));
+}
+
+#[tokio::test]
+async fn unknown_marker_defaults_to_least_privileged_tenant() {
+ let Json(body) = get_admin_context(Extension(ctx_with_type(Some("something-else"))))
+ .await
+ .expect("ok");
+
+ assert_eq!(body.admin_mode, "tenant");
+ assert!(!body.non_production_auth);
+ assert!(!body.capabilities.contains(&"tenants:write".to_owned()));
+}
+
+#[tokio::test]
+async fn absent_marker_defaults_to_least_privileged_tenant() {
+ let Json(body) = get_admin_context(Extension(ctx_with_type(None)))
+ .await
+ .expect("ok");
+
+ assert_eq!(body.admin_mode, "tenant");
+ assert_eq!(body.subject_type, None);
+ assert!(!body.non_production_auth);
+}
diff --git a/gears/system/account-management/account-management/src/api/rest/handlers/mod.rs b/gears/system/account-management/account-management/src/api/rest/handlers/mod.rs
index b206d5047..ccd911ff9 100644
--- a/gears/system/account-management/account-management/src/api/rest/handlers/mod.rs
+++ b/gears/system/account-management/account-management/src/api/rest/handlers/mod.rs
@@ -1,5 +1,6 @@
//! REST handler functions for the Account Management gear.
+pub mod admin_context;
pub mod common;
pub mod conversions;
pub mod me;
@@ -7,6 +8,7 @@ pub mod metadata;
pub mod tenants;
pub mod users;
+pub(crate) use admin_context::get_admin_context;
pub(crate) use conversions::{
get_child_conversion, get_own_conversion, list_child_conversions, list_own_conversions,
patch_child_conversion, patch_own_conversion, request_child_conversion, request_own_conversion,
diff --git a/gears/system/account-management/account-management/src/api/rest/routes/admin_context.rs b/gears/system/account-management/account-management/src/api/rest/routes/admin_context.rs
new file mode 100644
index 000000000..748a1cb58
--- /dev/null
+++ b/gears/system/account-management/account-management/src/api/rest/routes/admin_context.rs
@@ -0,0 +1,51 @@
+//! `OperationBuilder` route registration for the
+//! `GET /account-management/v1/admin/context` endpoint.
+//!
+//! Startup admin-context endpoint: returns the authenticated subject's id,
+//! type, home tenant, admin mode, and capability hints from
+//! `SecurityContext`. No service extension is layered — the handler reads
+//! the framework-injected `Extension` directly.
+
+use axum::Router;
+use toolkit::api::OpenApiRegistry;
+use toolkit::api::operation_builder::OperationBuilder;
+
+use crate::api::rest::{dto, handlers};
+
+const API_TAG: &str = "Identity";
+/// Admin-context endpoint: `GET /account-management/v1/admin/context`.
+const ADMIN_CONTEXT_PATH: &str = "/account-management/v1/admin/context";
+
+pub(super) fn register_admin_context_routes(
+ router: Router,
+ openapi: &dyn OpenApiRegistry,
+) -> Router {
+ // GET /account-management/v1/admin/context
+ OperationBuilder::get(ADMIN_CONTEXT_PATH)
+ .operation_id("account_management.get_admin_context")
+ .summary("Return the authenticated admin's startup context")
+ .description(
+ "Return the authenticated subject's startup admin context -- \
+ subject id, subject type, home tenant, admin mode \
+ (`platform`/`tenant`), and coarse capability hints -- read from \
+ the validated bearer token's security context. Mode and \
+ capabilities derive from the `subject_type` role marker; the \
+ backend remains the final authority on every action and \
+ capability hints are advisory for UI gating only. v0 derives the \
+ role from the NON-PRODUCTION static auth stub and sets \
+ `non_production_auth=true` when that stub is in effect. Enabled \
+ gears are not included here -- read them from the gear \
+ orchestrator.",
+ )
+ .tag(API_TAG)
+ .authenticated()
+ .no_license_required()
+ .handler(handlers::get_admin_context)
+ .json_response_with_schema::(
+ openapi,
+ http::StatusCode::OK,
+ "Authenticated admin startup context",
+ )
+ .standard_errors(openapi)
+ .register(router, openapi)
+}
diff --git a/gears/system/account-management/account-management/src/api/rest/routes/mod.rs b/gears/system/account-management/account-management/src/api/rest/routes/mod.rs
index 3b6dd5b0c..84a2f9e6d 100644
--- a/gears/system/account-management/account-management/src/api/rest/routes/mod.rs
+++ b/gears/system/account-management/account-management/src/api/rest/routes/mod.rs
@@ -16,6 +16,7 @@ use crate::api::rest::handlers::tenants::ConcreteTenantService;
use crate::domain::metadata::service::MetadataService;
use crate::domain::user::service::UserService;
+mod admin_context;
mod conversions;
mod me;
mod metadata;
@@ -38,6 +39,7 @@ pub fn register_routes(
router = users::register_users_routes(router, openapi);
router = conversions::register_conversions_routes(router, openapi);
router = me::register_me_routes(router, openapi);
+ router = admin_context::register_admin_context_routes(router, openapi);
router = router.layer(axum::Extension(tenant_service));
router = router.layer(axum::Extension(metadata_service));
router = router.layer(axum::Extension(user_service));
diff --git a/gears/system/api-gateway/src/config.rs b/gears/system/api-gateway/src/config.rs
index 0ca959f4f..3a8f4b54a 100644
--- a/gears/system/api-gateway/src/config.rs
+++ b/gears/system/api-gateway/src/config.rs
@@ -57,6 +57,14 @@ pub struct ApiGatewayConfig {
/// HTTP metrics configuration.
#[serde(default)]
pub metrics: MetricsConfig,
+
+ /// Optional filesystem path to a built admin-panel SPA (`dist/`).
+ /// When set, the gateway serves these static assets at `{prefix_path}/admin`
+ /// (e.g. `/cf/admin`) directly from disk, OUTSIDE the auth middleware stack
+ /// (the SPA itself is public; its API calls carry bearer tokens).
+ /// Unset (the default) means the admin panel is not served.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub admin_spa_dir: Option,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
diff --git a/gears/system/api-gateway/src/gear.rs b/gears/system/api-gateway/src/gear.rs
index 402bbe34b..251c1a5b3 100644
--- a/gears/system/api-gateway/src/gear.rs
+++ b/gears/system/api-gateway/src/gear.rs
@@ -112,6 +112,75 @@ impl ApiGateway {
top.merge(router)
}
+ /// Mount a built admin-panel SPA (`dist/`) at `{prefix}/admin`, served from
+ /// disk via `ServeDir`.
+ ///
+ /// This is merged at the top level — OUTSIDE the auth/middleware stack — so
+ /// the static assets are public (no bearer required). The SPA's own API
+ /// calls go to the prefixed, authenticated routes as usual.
+ ///
+ /// Client-side routes with no matching file fall back to `index.html`
+ /// returned with a `200` so deep-links survive a refresh (`ServeFile` as a
+ /// `not_found_service` would preserve the `404`). Two caveats are handled:
+ /// missing `/assets/*` requests `404` instead of returning the HTML shell
+ /// (a browser rejects an HTML body served as a JS module), and the shell is
+ /// sent `Cache-Control: no-cache` so a stale cached `index.html` can never
+ /// keep pointing at a hashed asset that a rebuild has removed. The shell is
+ /// read once into memory; hashed asset files are streamed fresh from disk.
+ fn mount_admin_spa(router: Router, prefix: &str, dir: &str) -> Router {
+ use axum::response::IntoResponse;
+ use tower_http::services::ServeDir;
+
+ let mount = format!("{prefix}/admin");
+ let index_path = format!("{dir}/index.html");
+ // Don't let ServeDir serve `index.html` for the directory request
+ // itself — route the entry point through the fallback below so the
+ // shell always carries `Cache-Control: no-cache`. Otherwise the cached
+ // entry shell keeps pointing at asset hashes a rebuild has removed.
+ let serve_dir = ServeDir::new(dir).append_index_html_on_directories(false);
+
+ match std::fs::read(&index_path) {
+ Ok(index_html) => {
+ let fallback = tower::service_fn(move |req: axum::extract::Request| {
+ let body = index_html.clone();
+ let path = req.uri().path().to_owned();
+ async move {
+ // A missing hashed asset must 404, not return the HTML
+ // shell with 200 — the browser would reject the HTML as
+ // an invalid module MIME type and render a blank page.
+ if path.contains("/assets/") {
+ return Ok::<_, std::convert::Infallible>(
+ axum::http::StatusCode::NOT_FOUND.into_response(),
+ );
+ }
+ Ok::<_, std::convert::Infallible>(
+ (
+ axum::http::StatusCode::OK,
+ [
+ (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
+ (axum::http::header::CACHE_CONTROL, "no-cache"),
+ ],
+ body,
+ )
+ .into_response(),
+ )
+ }
+ });
+ tracing::info!(path = %mount, dir = %dir, "Serving admin-panel SPA from disk");
+ // `fallback` (not `not_found_service`) preserves the fallback's
+ // `200` — `not_found_service` wraps it in `SetStatus(404)`.
+ router.nest_service(&mount, serve_dir.fallback(fallback))
+ }
+ Err(err) => {
+ tracing::warn!(
+ path = %mount, dir = %dir, error = %err,
+ "admin-panel SPA index.html not readable; serving static assets without SPA fallback"
+ );
+ router.nest_service(&mount, serve_dir)
+ }
+ }
+ }
+
/// Create a new `ApiGateway` instance with the given configuration
#[must_use]
pub fn new(config: ApiGatewayConfig) -> Self {
@@ -740,6 +809,11 @@ impl toolkit::contracts::ApiGatewayCapability for ApiGateway {
let prefix = Self::normalize_prefix_path(&config.prefix_path)?;
router = Self::apply_prefix_nesting(router, &prefix);
+ // Serve the admin-panel SPA from disk (public, outside auth) when configured.
+ if let Some(dir) = config.admin_spa_dir.as_deref() {
+ router = Self::mount_admin_spa(router, &prefix, dir);
+ }
+
// Keep the finalized router to be used by `serve()`
*self.final_router.lock() = Some(router.clone());
diff --git a/testing/e2e/gears/admin/__init__.py b/testing/e2e/gears/admin/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/testing/e2e/gears/admin/conftest.py b/testing/e2e/gears/admin/conftest.py
new file mode 100644
index 000000000..edeb7efeb
--- /dev/null
+++ b/testing/e2e/gears/admin/conftest.py
@@ -0,0 +1,67 @@
+"""Pytest fixtures for the admin-panel backend E2E tests.
+
+Covers the admin-panel's one new backend surface:
+``GET /account-management/v1/admin/context``. The endpoint projects the
+caller's security context into an admin mode + capability hints; the role
+markers come from the ``static-authn-plugin`` ``subject_type`` field.
+
+Tokens (see ``config/e2e-local.yaml`` static-authn-plugin static_tokens):
+- ``e2e-token-tenant-a`` — no subject_type, default (tenant) role.
+- ``e2e-token-platform-admin`` — subject_type=platform_admin.
+- ``e2e-token-tenant-admin`` — subject_type=tenant_admin.
+
+All requests flow through the shared gateway process at
+``http://localhost:8086``; any HTTP response means "service up".
+"""
+import os
+
+import httpx
+import pytest
+
+REQUEST_TIMEOUT = 5.0
+
+# Platform structural root — every admin token below is homed here.
+ROOT_TENANT_ID = "00000000-df51-5b42-9538-d2b56b7ee953"
+
+ADMIN_CONTEXT_PATH = "/account-management/v1/admin/context"
+
+
+@pytest.fixture
+def base_url():
+ return os.getenv("E2E_BASE_URL", "http://localhost:8086")
+
+
+def _headers(token: str) -> dict:
+ return {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def headers_default():
+ """Untyped token — exercises the default (least-privileged) projection."""
+ return _headers("e2e-token-tenant-a")
+
+
+@pytest.fixture
+def headers_platform_admin():
+ return _headers("e2e-token-platform-admin")
+
+
+@pytest.fixture
+def headers_tenant_admin():
+ return _headers("e2e-token-tenant-admin")
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _check_reachable():
+ """Skip the suite if the gateway is not up (mirrors AM/RG guards)."""
+ url = os.getenv("E2E_BASE_URL", "http://localhost:8086")
+ try:
+ httpx.get(
+ f"{url}{ADMIN_CONTEXT_PATH}",
+ timeout=5.0,
+ headers={"Authorization": "Bearer e2e-token-tenant-a"},
+ )
+ except httpx.ConnectError:
+ pytest.skip(f"Gateway not running at {url}", allow_module_level=True)
+ except (httpx.TimeoutException, OSError):
+ pytest.skip(f"Gateway not reachable at {url}", allow_module_level=True)
diff --git a/testing/e2e/gears/admin/test_admin_context.py b/testing/e2e/gears/admin/test_admin_context.py
new file mode 100644
index 000000000..bad911219
--- /dev/null
+++ b/testing/e2e/gears/admin/test_admin_context.py
@@ -0,0 +1,81 @@
+"""E2E seam tests for GET /account-management/v1/admin/context.
+
+The admin panel fetches this at startup to discover the principal, home
+tenant, admin mode, and capability hints. These tests pin the wire
+contract and the platform-vs-tenant projection across the role tokens.
+"""
+import uuid
+
+import httpx
+import pytest
+
+from .conftest import ADMIN_CONTEXT_PATH, ROOT_TENANT_ID, REQUEST_TIMEOUT
+
+
+async def _get(base_url: str, headers: dict | None) -> httpx.Response:
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
+ return await client.get(f"{base_url}{ADMIN_CONTEXT_PATH}", headers=headers or {})
+
+
+def _assert_context_shape(body: dict) -> None:
+ uuid.UUID(body["subject_id"])
+ uuid.UUID(body["subject_tenant_id"])
+ assert body["admin_mode"] in ("platform", "tenant")
+ assert isinstance(body["capabilities"], list)
+ assert all(isinstance(c, str) for c in body["capabilities"])
+ assert isinstance(body["non_production_auth"], bool)
+
+
+async def test_requires_authentication(base_url):
+ """The .authenticated() route gate rejects an unauthenticated caller."""
+ resp = await _get(base_url, headers=None)
+ assert resp.status_code == 401, resp.text
+
+
+async def test_default_role_projects_tenant_mode(base_url, headers_default):
+ """An untyped token has no role marker -> least-privileged tenant mode,
+ and is NOT flagged as the non-production role stub."""
+ resp = await _get(base_url, headers_default)
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ _assert_context_shape(body)
+ assert body["admin_mode"] == "tenant"
+ assert body["non_production_auth"] is False
+ assert body["subject_tenant_id"] == ROOT_TENANT_ID
+
+
+async def test_platform_admin_projection(base_url, headers_platform_admin):
+ """platform_admin -> platform mode + full capability set + stub flag."""
+ resp = await _get(base_url, headers_platform_admin)
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ _assert_context_shape(body)
+ assert body["subject_type"] == "platform_admin"
+ assert body["admin_mode"] == "platform"
+ assert body["non_production_auth"] is True
+ caps = set(body["capabilities"])
+ # Platform admin can mutate tenants, manage users, and run lifecycle actions.
+ assert {
+ "tenants:read",
+ "tenants:write",
+ "tenants:suspend",
+ "users:read",
+ "users:write",
+ "gears:read",
+ } <= caps
+
+
+async def test_tenant_admin_projection(base_url, headers_tenant_admin):
+ """tenant_admin -> tenant mode + least-privileged caps (no tenant writes)."""
+ resp = await _get(base_url, headers_tenant_admin)
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ _assert_context_shape(body)
+ assert body["subject_type"] == "tenant_admin"
+ assert body["admin_mode"] == "tenant"
+ assert body["non_production_auth"] is True
+ caps = set(body["capabilities"])
+ assert "tenants:read" in caps
+ # A tenant admin must NOT advertise tenant write/suspend capabilities.
+ assert "tenants:write" not in caps
+ assert "tenants:suspend" not in caps