mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(settings): refuse enabling a channel the operator switched off
With the switches now enforced at dispatch, a user could still flip a channel on, see the toggle stick, and get nothing. Refuse the enable instead, with 403 and a message that names the reason: the ntfy and Telegram config endpoints on both accepted body shapes, and the Web Push subscribe endpoint, which is the enable step for that channel because it creates the channel row. Telegram is checked before the webhook registration, so a refused enable makes no outbound call and stores nothing. Disabling a channel keeps working while the switch is off — a user must always be able to turn a channel off, whatever the operator has done. The three refusals are published in the contract; `/api/settings/global-services` already exposes the switches so a client can hide the control instead of provoking the refusal.
This commit is contained in:
1 parent
8d2753f942
commit
de0876b215
8 files changed
+368
-11
No files matched your search
+44
-7
@@ -11763,9 +11763,16 @@ paths:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Update the caller's ntfy channel config
|
||||
description: "Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved:
|
||||
true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie
|
||||
or Bearer."
|
||||
description: >-
|
||||
Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }`
|
||||
and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or
|
||||
Bearer.
|
||||
|
||||
|
||||
An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to
|
||||
true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend
|
||||
otherwise. Disabling the channel keeps working either way. Read the switch from `GET
|
||||
/api/settings/global-services`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -11786,6 +11793,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413": &a16
|
||||
description: Body exceeds 64 KiB.
|
||||
content:
|
||||
@@ -11827,10 +11840,16 @@ paths:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Update the caller's Telegram channel config
|
||||
description: "Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable)
|
||||
the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not
|
||||
fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is
|
||||
stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer."
|
||||
description: >-
|
||||
Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the
|
||||
Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail
|
||||
the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored.
|
||||
The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.
|
||||
|
||||
|
||||
An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled`
|
||||
to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working
|
||||
either way. Read the switch from `GET /api/settings/global-services`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -11846,6 +11865,13 @@ paths:
|
||||
$ref: "#/components/schemas/PutTelegramSettingsResponse"
|
||||
"400": *a15
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was
|
||||
registered and nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: The session's user row no longer exists.
|
||||
content:
|
||||
@@ -18649,6 +18675,11 @@ paths:
|
||||
Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a
|
||||
non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and
|
||||
`message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.
|
||||
|
||||
|
||||
An operator can switch Web Push off for the whole instance. While that switch is off this answers 403 before it
|
||||
reads the body, and no subscription and no channel row are created. Read the switch from `GET
|
||||
/api/settings/global-services` and hide the subscribe control rather than retrying.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -18663,6 +18694,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebPushSubscribeEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched Web Push off for this instance. Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: Request body exceeds 65536 bytes. Nothing was stored.
|
||||
content:
|
||||
|
||||
@@ -26,12 +26,19 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The route reads the operator's instance-wide Web Push switch before it
|
||||
// touches the body; the default stub leaves it on so these cases keep
|
||||
// asserting the validation refusals they were written for.
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() },
|
||||
notificationChannel: { findFirst: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` }));
|
||||
|
||||
@@ -67,6 +74,12 @@ type Handler = (r: Request) => Promise<Response>;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
apiGlobal: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/notifications/web-push — the subscribe refusal", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
@@ -43,6 +44,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.subscribe" } });
|
||||
|
||||
// Subscribing IS the enable step for this channel — it creates the
|
||||
// WEB_PUSH `NotificationChannel` row. An operator who switched Web Push
|
||||
// off instance-wide (`AppSettings.webPushGlobal`) gets a refusal that says
|
||||
// so rather than a subscription that stores fine and delivers nothing.
|
||||
if (!(await isChannelGloballyEnabled("WEB_PUSH"))) {
|
||||
annotate({ meta: { refused: "globally_disabled" } });
|
||||
return apiError(
|
||||
"Web Push is disabled on this instance by the operator",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Enabling a channel the operator switched off instance-wide is refused.
|
||||
*
|
||||
* The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` /
|
||||
* `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching
|
||||
* refusal on the way in, a user would still see the toggle flip, the row
|
||||
* would still say `enabled = true`, and nothing would ever arrive. These
|
||||
* tests pin the refusal and the absence of any write behind it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
pushSubscription: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
|
||||
const setTelegramWebhookMock = vi.fn().mockResolvedValue(true);
|
||||
vi.mock("@/lib/telegram", () => ({
|
||||
setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args),
|
||||
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT as putNtfy } from "../ntfy/route";
|
||||
import { PUT as putTelegram } from "../telegram/route";
|
||||
import { POST as postWebPush } from "../../notifications/web-push/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const WEB_PUSH_SUBSCRIPTION = {
|
||||
endpoint: "https://push.example.com/subscription/abc",
|
||||
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) },
|
||||
};
|
||||
|
||||
function stubSettings(over: Record<string, boolean>) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
apiGlobal: true,
|
||||
...over,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function request(path: string, method: string, body: unknown) {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}',
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
telegramBotToken: "encrypted:saved-token",
|
||||
telegramChatId: "saved-chat",
|
||||
telegramEnabled: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("ntfy enable against the instance-wide switch", () => {
|
||||
it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => {
|
||||
stubSettings({ ntfyGlobal: false });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
data: null,
|
||||
error: expect.stringContaining("disabled on this instance"),
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows disabling the channel while the switch is off", async () => {
|
||||
stubSettings({ ntfyGlobal: false });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: false }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "NTFY" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows enabling while the switch is on", async () => {
|
||||
stubSettings({ ntfyGlobal: true });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "NTFY" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram enable against the instance-wide switch", () => {
|
||||
it("refuses with 403, registers no webhook, and writes nothing", async () => {
|
||||
stubSettings({ telegramGlobal: false });
|
||||
|
||||
const response = await putTelegram(
|
||||
request("/api/settings/telegram", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a full-config save that enables the channel", async () => {
|
||||
stubSettings({ telegramGlobal: false });
|
||||
|
||||
const response = await putTelegram(
|
||||
request("/api/settings/telegram", "PUT", {
|
||||
botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii",
|
||||
chatId: "9876543",
|
||||
enabled: true,
|
||||
}) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Push subscribe against the instance-wide switch", () => {
|
||||
it("refuses with 403 and stores no subscription", async () => {
|
||||
stubSettings({ webPushGlobal: false });
|
||||
|
||||
const response = await postWebPush(
|
||||
request(
|
||||
"/api/notifications/web-push",
|
||||
"POST",
|
||||
WEB_PUSH_SUBSCRIPTION,
|
||||
) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the subscription while the switch is on", async () => {
|
||||
stubSettings({ webPushGlobal: true });
|
||||
|
||||
const response = await postWebPush(
|
||||
request(
|
||||
"/api/notifications/web-push",
|
||||
"POST",
|
||||
WEB_PUSH_SUBSCRIPTION,
|
||||
) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,17 @@ import {
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Refusal for an enable attempt on a channel the operator switched off
|
||||
* instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work
|
||||
* and then delivered nothing is worse than a refusal that says why.
|
||||
*/
|
||||
const NTFY_GLOBALLY_DISABLED =
|
||||
"ntfy is disabled on this instance by the operator";
|
||||
|
||||
/**
|
||||
* GET: Return current ntfy config (without auth token).
|
||||
* PUT: Update ntfy config.
|
||||
@@ -60,6 +69,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("NTFY"))) {
|
||||
annotate({
|
||||
action: { name: "settings.ntfy.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(NTFY_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "NTFY" } },
|
||||
@@ -106,6 +123,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { serverUrl, topic, authToken, enabled } = parsed.data;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("NTFY"))) {
|
||||
annotate({
|
||||
action: { name: "settings.ntfy.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(NTFY_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!serverUrl || !topic)) {
|
||||
return apiError(
|
||||
"Server URL and topic are required when ntfy is enabled",
|
||||
|
||||
@@ -7,8 +7,17 @@ import { notificationChannelEnabledSchema } from "@/lib/validations/notification
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Refusal for an enable attempt on a channel the operator switched off
|
||||
* instance-wide (`AppSettings.telegramGlobal`). A toggle that appeared to
|
||||
* work and then delivered nothing is worse than a refusal that says why.
|
||||
*/
|
||||
const TELEGRAM_GLOBALLY_DISABLED =
|
||||
"Telegram is disabled on this instance by the operator";
|
||||
|
||||
/**
|
||||
* Get Telegram notification settings for the current user.
|
||||
* Never returns the bot token in plaintext.
|
||||
@@ -117,6 +126,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
if (enabled && !(await isChannelGloballyEnabled("TELEGRAM"))) {
|
||||
annotate({
|
||||
action: { name: "settings.telegram.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(TELEGRAM_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!current.telegramBotToken || !current.telegramChatId)) {
|
||||
return apiError(
|
||||
"Bot token and chat ID are required when Telegram is enabled",
|
||||
@@ -166,6 +183,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const hasChatIdAfter =
|
||||
chatId !== undefined ? !!trimmedChatId : !!current.telegramChatId;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("TELEGRAM"))) {
|
||||
annotate({
|
||||
action: { name: "settings.telegram.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(TELEGRAM_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!hasTokenAfter || !hasChatIdAfter)) {
|
||||
return apiError(
|
||||
"Bot token and chat ID are required when Telegram is enabled",
|
||||
|
||||
@@ -136,7 +136,8 @@ export const notificationTransportPaths: NonNullable<
|
||||
description:
|
||||
"Stores a browser Push subscription for the caller and, on the first one, creates the account's Web Push notification channel enabled. Upsert by endpoint: re-posting the same endpoint refreshes its keys rather than duplicating the row, so a client can send this on every page load without checking first.\n\n" +
|
||||
"The subscription keys are encrypted at rest. The endpoint is validated as HTTPS and as a public host before anything is written — the push library dials it later through its own fetch, outside the shared egress wrapper, so input time is where that has to be caught.\n\n" +
|
||||
"Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.",
|
||||
"Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.\n\n" +
|
||||
"An operator can switch Web Push off for the whole instance. While that switch is off this answers 403 before it reads the body, and no subscription and no channel row are created. Read the switch from `GET /api/settings/global-services` and hide the subscribe control rather than retrying.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: webPushSubscribeRequest } },
|
||||
@@ -153,6 +154,11 @@ export const notificationTransportPaths: NonNullable<
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched Web Push off for this instance. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 65536 bytes. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
@@ -375,7 +375,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update the caller's ntfy channel config",
|
||||
description:
|
||||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.",
|
||||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.\n\n" +
|
||||
"An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend otherwise. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: ntfyPutRequest } },
|
||||
@@ -389,6 +390,11 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...jsonBodyRefusals,
|
||||
...stdResponses,
|
||||
"422": {
|
||||
@@ -424,7 +430,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update the caller's Telegram channel config",
|
||||
description:
|
||||
"Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.",
|
||||
"Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.\n\n" +
|
||||
"An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: telegramPutRequest } },
|
||||
@@ -441,6 +448,11 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched Telegram off for this instance and the body asked to enable it. No webhook was registered and nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "The session's user row no longer exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
Reference in new issue
Block a user