fix: restrict platform maintenance and backup visibility to Owner
This commit is contained in:
9 files changed
+68
-55
No files matched your search
+8
-8
@@ -4603,7 +4603,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin-updates]
|
tags: [admin-updates]
|
||||||
operationId: getUpdateWindow
|
operationId: getUpdateWindow
|
||||||
summary: Read the SysAdmin-set auto-update maintenance window (admin).
|
summary: Read the Owner-set auto-update maintenance window (Owner).
|
||||||
description: >-
|
description: >-
|
||||||
Felis applies no update on its own. `felis update` checks versions and
|
Felis applies no update on its own. `felis update` checks versions and
|
||||||
prints an explicit apply command. `felis update --apply` reads this
|
prints an explicit apply command. `felis update --apply` reads this
|
||||||
@@ -4612,7 +4612,7 @@ paths:
|
|||||||
An unreadable window is always a refusal, including with `--now` or
|
An unreadable window is always a refusal, including with `--now` or
|
||||||
`--force`. An unset window reads back as {start:null,end:null}.
|
`--force`. An unset window reads back as {start:null,end:null}.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: owner
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4628,7 +4628,7 @@ paths:
|
|||||||
put:
|
put:
|
||||||
tags: [admin-updates]
|
tags: [admin-updates]
|
||||||
operationId: setUpdateWindow
|
operationId: setUpdateWindow
|
||||||
summary: Set or clear the SysAdmin auto-update maintenance window (admin).
|
summary: Set or clear the SysAdmin auto-update maintenance window (Owner).
|
||||||
description: >-
|
description: >-
|
||||||
Persist the maintenance window as an absolute [start,end) interval. Both
|
Persist the maintenance window as an absolute [start,end) interval. Both
|
||||||
ends must be set with end strictly after start, or both null to clear the
|
ends must be set with end strictly after start, or both null to clear the
|
||||||
@@ -4638,7 +4638,7 @@ paths:
|
|||||||
setting a window only permits an apply inside it; outside, a Scheduled
|
setting a window only permits an apply inside it; outside, a Scheduled
|
||||||
component degrades to notify.
|
component degrades to notify.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: owner
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4664,14 +4664,14 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin-updates]
|
tags: [admin-updates]
|
||||||
operationId: getDBBackup
|
operationId: getDBBackup
|
||||||
summary: Freshness of the newest control-plane database backup (admin).
|
summary: Freshness of the newest control-plane database backup (Owner).
|
||||||
description: >-
|
description: >-
|
||||||
What the host's felis-db-backup.timer (or a manual `felis db backup`)
|
What the host's felis-db-backup.timer (or a manual `felis db backup`)
|
||||||
last recorded in platform_settings. last is null before the first
|
last recorded in platform_settings. last is null before the first
|
||||||
backup; stale is true then, and whenever the newest daily backup
|
backup; stale is true then, and whenever the newest daily backup
|
||||||
(last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
|
(last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: owner
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4689,7 +4689,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin-updates]
|
tags: [admin-updates]
|
||||||
operationId: getUpdateReport
|
operationId: getUpdateReport
|
||||||
summary: The newest recorded version check of every tracked component (admin).
|
summary: The newest recorded version check of every tracked component (Owner).
|
||||||
description: >-
|
description: >-
|
||||||
What `felis update --record` last stored in platform_settings; the
|
What `felis update --record` last stored in platform_settings; the
|
||||||
installer's felis-update-check.timer runs it daily on the host, where the
|
installer's felis-update-check.timer runs it daily on the host, where the
|
||||||
@@ -4697,7 +4697,7 @@ paths:
|
|||||||
stale is true then, and whenever the check is older than max_age_seconds.
|
stale is true then, and whenever the check is older than max_age_seconds.
|
||||||
Read-only: Felis applies no update on its own.
|
Read-only: Felis applies no update on its own.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: owner
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
|
|||||||
+8
-8
@@ -783,16 +783,16 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// lives inside it, so approval would otherwise be blind.
|
// lives inside it, so approval would otherwise be blind.
|
||||||
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
||||||
// Auto-update maintenance window (spec §B; decision core internal/updates).
|
// Auto-update maintenance window (spec §B; decision core internal/updates).
|
||||||
// Admin-tier: it governs whether Felis may apply an update to itself, so setting
|
// Owner-tier: it governs whether Felis may apply an update to itself, so setting
|
||||||
// it requires the admin Zero-Trust path, not a mere session. Advisory: `felis
|
// it requires Owner access through the operator host. `felis update --apply`
|
||||||
// update` on the host reads it and warns before an apply outside it.
|
// checks the configured window before backup and installation.
|
||||||
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
|
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, Owner: true, h: a.handleGetUpdateWindow},
|
||||||
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
|
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, Owner: true, h: a.handleSetUpdateWindow},
|
||||||
// The newest version check felis-update-check.timer recorded on the host.
|
// The newest version check felis-update-check.timer recorded on the host.
|
||||||
{Method: "GET", Pattern: "/api/v1/updates/report", Admin: true, h: a.handleGetUpdateReport},
|
{Method: "GET", Pattern: "/api/v1/updates/report", Admin: true, Owner: true, h: a.handleGetUpdateReport},
|
||||||
// Control-plane database backup freshness, as the host's felis-db-backup.timer
|
// Control-plane database backup freshness, as the host's felis-db-backup.timer
|
||||||
// last recorded it. Admin-tier: it names the host backup directory.
|
// last recorded it. Owner-tier: it names the host backup directory.
|
||||||
{Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, h: a.handleGetDBBackup},
|
{Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, Owner: true, h: a.handleGetDBBackup},
|
||||||
|
|
||||||
// User admin (spec §7, owner-only). Every route gates on the admin Zero-Trust
|
// User admin (spec §7, owner-only). Every route gates on the admin Zero-Trust
|
||||||
// path AND the owner role: listing, mutating, disabling, or deleting users is
|
// path AND the owner role: listing, mutating, disabling, or deleting users is
|
||||||
|
|||||||
@@ -6,24 +6,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auto-update maintenance-window admin API tests (task #38; decision core
|
// Platform maintenance handlers require Owner access through the operator host.
|
||||||
// internal/updates). The load-bearing cases: an unset window and an explicitly
|
// Handler tests use an Owner; boundary tests vary the principal explicitly.
|
||||||
// cleared window both read back as {null,null} (two paths, one shape); a valid
|
|
||||||
// window round-trips through persistence; a half-set or inverted window is
|
|
||||||
// rejected fail-closed and never stored; and — the tier boundary — the routes are
|
|
||||||
// admin-only, gating precisely on IsAdmin() = role==admin AND the admin-access
|
|
||||||
// path, so neither a role=user nor an admin off the operator host can touch them.
|
|
||||||
|
|
||||||
// seedUpdatesAPI returns an API whose external face authenticates every request as
|
|
||||||
// a full admin (role=admin AND ViaAdminAccess), so the admin-tier routes are
|
|
||||||
// reachable and the tests exercise the handler logic. Gating tests override
|
|
||||||
// api.External to vary the principal.
|
|
||||||
func seedUpdatesAPI(t *testing.T) (*API, *fakeRepo) {
|
func seedUpdatesAPI(t *testing.T) (*API, *fakeRepo) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = staticExternal{p: &Principal{
|
api.External = staticExternal{p: &Principal{
|
||||||
UserID: "admin1", Email: "admin@" + testRoot, Role: "admin", ViaAdminAccess: true,
|
UserID: "owner1", Email: "owner@" + testRoot, Role: "owner", ViaAdminAccess: true,
|
||||||
}}
|
}}
|
||||||
return api, repo
|
return api, repo
|
||||||
}
|
}
|
||||||
@@ -152,17 +142,16 @@ func TestUpdateWindowContentTypeGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUpdateWindowAdminGating proves the tier boundary discriminates on IsAdmin()
|
// Verify the Owner and operator-host boundaries for reads and writes.
|
||||||
// precisely — role==admin AND the admin-access path — not on accident. A full admin
|
func TestUpdateWindowOwnerGating(t *testing.T) {
|
||||||
// reads 200; an admin who did NOT arrive via admin access, and a role=user player,
|
|
||||||
// are both 403 on both the GET and the mutating PUT.
|
|
||||||
func TestUpdateWindowAdminGating(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
p *Principal
|
p *Principal
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{"admin via admin-access", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: true}, http.StatusOK},
|
{"owner via operator host", &Principal{UserID: "o1", Role: "owner", ViaAdminAccess: true}, http.StatusOK},
|
||||||
|
{"owner off operator host", &Principal{UserID: "o1", Role: "owner", ViaAdminAccess: false}, http.StatusForbidden},
|
||||||
|
{"admin via admin-access", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: true}, http.StatusForbidden},
|
||||||
{"admin off operator host", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: false}, http.StatusForbidden},
|
{"admin off operator host", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: false}, http.StatusForbidden},
|
||||||
{"role=user player", &Principal{UserID: "u1", Role: "user", ViaAdminAccess: false}, http.StatusForbidden},
|
{"role=user player", &Principal{UserID: "u1", Role: "user", ViaAdminAccess: false}, http.StatusForbidden},
|
||||||
} {
|
} {
|
||||||
@@ -171,11 +160,13 @@ func TestUpdateWindowAdminGating(t *testing.T) {
|
|||||||
api := newTestAPI(repo, newFakeCluster())
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = staticExternal{p: tc.p}
|
api.External = staticExternal{p: tc.p}
|
||||||
|
|
||||||
get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil)
|
for _, path := range []string{"/api/v1/updates/window", "/api/v1/updates/report", "/api/v1/platform/db-backup"} {
|
||||||
if get.Code != tc.want {
|
get := do(api.ExternalHandler(), "GET", path, "", nil)
|
||||||
t.Fatalf("GET code = %d, want %d (%s)", get.Code, tc.want, get.Body.String())
|
if get.Code != tc.want {
|
||||||
|
t.Fatalf("GET %s code = %d, want %d (%s)", path, get.Code, tc.want, get.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A valid PUT by a full admin is 200; a non-admin is 403 (adminOnly rejects
|
// A valid PUT by an Owner is 200; other identities are rejected
|
||||||
// before the handler), so the wanted PUT code is the same as the GET's.
|
// before the handler), so the wanted PUT code is the same as the GET's.
|
||||||
put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window",
|
put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window",
|
||||||
`{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader)
|
`{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader)
|
||||||
|
|||||||
@@ -1073,8 +1073,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] });
|
sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] });
|
||||||
return true;
|
return true;
|
||||||
case "GET platform/db-backup": {
|
case "GET platform/db-backup": {
|
||||||
if (!isAdmin(ctx.account.role)) {
|
if (ctx.account.role !== "owner") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "owner account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Yesterday's daily run: fresh, so the card shows its healthy state.
|
// Yesterday's daily run: fresh, so the card shows its healthy state.
|
||||||
@@ -1097,8 +1097,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "GET updates/report": {
|
case "GET updates/report": {
|
||||||
if (!isAdmin(ctx.account.role)) {
|
if (ctx.account.role !== "owner") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "owner account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Last night's timer run: one update waiting, one feed unreachable, one
|
// Last night's timer run: one update waiting, one feed unreachable, one
|
||||||
@@ -1122,15 +1122,15 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "GET updates/window":
|
case "GET updates/window":
|
||||||
if (!isAdmin(ctx.account.role)) {
|
if (ctx.account.role !== "owner") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "owner account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
||||||
return true;
|
return true;
|
||||||
case "PUT updates/window": {
|
case "PUT updates/window": {
|
||||||
if (!isAdmin(ctx.account.role)) {
|
if (ctx.account.role !== "owner") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "owner account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
|
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
|
||||||
|
|||||||
@@ -115,3 +115,19 @@ test("Owner executes node management and receives stage, failure logs and retry"
|
|||||||
await expect(page.getByText(t("admin:node_control_state_running"), { exact: true })).toBeVisible();
|
await expect(page.getByText(t("admin:node_control_state_running"), { exact: true })).toBeVisible();
|
||||||
await expectFitsScreen(page);
|
await expectFitsScreen(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("platform backup and version maintenance are Owner-only", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
expect((await page.request.patch("/api/v1/users/user", { data: { role: "admin" } })).ok()).toBe(true);
|
||||||
|
await signIn("user");
|
||||||
|
await page.goto("/admin/updates");
|
||||||
|
await expect(page.getByRole("navigation").getByRole("link", { name: t("navigation:admin_updates"), exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: t("admin:updates_title"), exact: true })).toHaveCount(0);
|
||||||
|
for (const path of ["updates/window", "updates/report", "platform/db-backup"]) {
|
||||||
|
expect((await page.request.get(`/api/v1/${path}`)).status()).toBe(403);
|
||||||
|
}
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/updates");
|
||||||
|
await expect(page.getByRole("heading", { name: t("admin:updates_title"), exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("navigation").getByRole("link", { name: t("navigation:admin_updates"), exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
+1
-1
@@ -130,9 +130,9 @@ export default function App() {
|
|||||||
<Route path="images" element={<ImageAdmin />} />
|
<Route path="images" element={<ImageAdmin />} />
|
||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
<Route path="submissions" element={<SubmissionsPage />} />
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
<Route path="updates" element={<UpdatesPage />} />
|
|
||||||
{/* Owner-gated platform settings and user management. */}
|
{/* Owner-gated platform settings and user management. */}
|
||||||
<Route element={<RequireOwner />}>
|
<Route element={<RequireOwner />}>
|
||||||
|
<Route path="updates" element={<UpdatesPage />} />
|
||||||
<Route path="platform" element={<PlatformSettingsPage />} />
|
<Route path="platform" element={<PlatformSettingsPage />} />
|
||||||
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ describe("visibleSections", () => {
|
|||||||
expect(ids).toEqual(["user", "admin"]);
|
expect(ids).toEqual(["user", "admin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("places platform maintenance exclusively in Owner navigation, before platform settings", () => {
|
||||||
|
expect(visibleSections(true, false).flatMap((section) => section.items).some((item) => item.to === "/admin/updates")).toBe(false);
|
||||||
|
const owner = visibleSections(true, true).find((section) => section.id === "owner");
|
||||||
|
expect(owner?.items.map((item) => item.to)).toContain("/admin/updates");
|
||||||
|
expect(owner?.items.at(-1)?.to).toBe("/admin/platform");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the User-Side section ungated so it survives both branches", () => {
|
it("keeps the User-Side section ungated so it survives both branches", () => {
|
||||||
const user = NAV_SECTIONS.find((s) => s.id === "user");
|
const user = NAV_SECTIONS.find((s) => s.id === "user");
|
||||||
expect(user?.adminOnly).toBe(false);
|
expect(user?.adminOnly).toBe(false);
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import {
|
|||||||
// identity see" decision is a pure function (visibleSections) that vitest can pin
|
// identity see" decision is a pure function (visibleSections) that vitest can pin
|
||||||
// without rendering React. The three sections map onto DESIGN-WEB-3SIDES §2:
|
// without rendering React. The three sections map onto DESIGN-WEB-3SIDES §2:
|
||||||
// User-Side is always present (app-tier); Admin-Side and SysAdmin-Side are a
|
// User-Side is always present (app-tier); Admin-Side and SysAdmin-Side are a
|
||||||
// navigational separation of *concern* over the SAME admin tier — both gated by
|
// separation between server administration and Owner-only platform operations.
|
||||||
// the one `is_admin` flag, surfaced as two sections only for admins.
|
|
||||||
//
|
//
|
||||||
// Hiding a section is UX convenience, NOT a security control: every /admin and
|
// Hiding a section is UX convenience, NOT a security control: every /admin and
|
||||||
// /ops data call is independently 403-gated server-side (the RequireAdmin route
|
// /ops data call is independently 403-gated server-side (the RequireAdmin route
|
||||||
@@ -68,7 +67,6 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
||||||
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,6 +77,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/admin/users", key: "admin_users", icon: Users },
|
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||||
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
||||||
|
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
||||||
{ to: "/admin/platform", key: "platform_settings", icon: Settings },
|
{ to: "/admin/platform", key: "platform_settings", icon: Settings },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1404,12 +1404,12 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Read the SysAdmin-set auto-update maintenance window (admin).
|
* Read the Owner-set auto-update maintenance window (Owner).
|
||||||
* @description Felis applies no update on its own. `felis update` checks versions and prints an explicit apply command. `felis update --apply` reads this platform-wide [start,end) window before backup and before installation, refusing outside it unless `--now` explicitly starts manual maintenance. An unreadable window is always a refusal, including with `--now` or `--force`. An unset window reads back as {start:null,end:null}.
|
* @description Felis applies no update on its own. `felis update` checks versions and prints an explicit apply command. `felis update --apply` reads this platform-wide [start,end) window before backup and before installation, refusing outside it unless `--now` explicitly starts manual maintenance. An unreadable window is always a refusal, including with `--now` or `--force`. An unset window reads back as {start:null,end:null}.
|
||||||
*/
|
*/
|
||||||
get: operations["getUpdateWindow"];
|
get: operations["getUpdateWindow"];
|
||||||
/**
|
/**
|
||||||
* Set or clear the SysAdmin auto-update maintenance window (admin).
|
* Set or clear the SysAdmin auto-update maintenance window (Owner).
|
||||||
* @description Persist the maintenance window as an absolute [start,end) interval. Both ends must be set with end strictly after start, or both null to clear the window to unset. A half-set (exactly one end) or inverted/empty (end not after start) body is rejected 400, mirroring the decision core's fail-closed Window so a malformed schedule can never be stored. No forced auto-update: setting a window only permits an apply inside it; outside, a Scheduled component degrades to notify.
|
* @description Persist the maintenance window as an absolute [start,end) interval. Both ends must be set with end strictly after start, or both null to clear the window to unset. A half-set (exactly one end) or inverted/empty (end not after start) body is rejected 400, mirroring the decision core's fail-closed Window so a malformed schedule can never be stored. No forced auto-update: setting a window only permits an apply inside it; outside, a Scheduled component degrades to notify.
|
||||||
*/
|
*/
|
||||||
put: operations["setUpdateWindow"];
|
put: operations["setUpdateWindow"];
|
||||||
@@ -1428,7 +1428,7 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Freshness of the newest control-plane database backup (admin).
|
* Freshness of the newest control-plane database backup (Owner).
|
||||||
* @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest daily backup (last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
|
* @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest daily backup (last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
|
||||||
*/
|
*/
|
||||||
get: operations["getDBBackup"];
|
get: operations["getDBBackup"];
|
||||||
@@ -1448,7 +1448,7 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The newest recorded version check of every tracked component (admin).
|
* The newest recorded version check of every tracked component (Owner).
|
||||||
* @description What `felis update --record` last stored in platform_settings; the installer's felis-update-check.timer runs it daily on the host, where the installed versions are readable. report is null before the first check; stale is true then, and whenever the check is older than max_age_seconds. Read-only: Felis applies no update on its own.
|
* @description What `felis update --record` last stored in platform_settings; the installer's felis-update-check.timer runs it daily on the host, where the installed versions are readable. report is null before the first check; stale is true then, and whenever the check is older than max_age_seconds. Read-only: Felis applies no update on its own.
|
||||||
*/
|
*/
|
||||||
get: operations["getUpdateReport"];
|
get: operations["getUpdateReport"];
|
||||||
|
|||||||
Reference in new issue
Block a user