mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
feat: weekly UTC maintenance windows for mute rules (#1661)
* feat: add weekly UTC maintenance windows to mute rules
Let mute rules suppress only during recurring UTC windows, normalize
replica node identity, and fail-open when remotes lack schedule support
so older nodes never keep an all-day scheduled mute after a successful cleanup DELETE.
* fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
* fix: require explicit repair before clearing a corrupt mute schedule
The suppression engine already fails closed on an unreadable stored
schedule (scheduleInvalid), but the frontend never surfaced that flag:
a corrupt rule looked identical to an ordinary unscheduled one, and
opening Edit then clicking Update sent an explicit schedule: null,
silently turning the corruption into a valid all-day mute. Add the
flag to the rule type, show an Invalid schedule badge on the card, and
block saving in the edit form until the operator explicitly touches
the weekly window (configures a new one, or toggles it to confirm the
clear).
* fix: correct contradictory toggle-sequence copy in schedule-repair toast
The blocking toast told operators to toggle the weekly window "off then
on" to confirm clearing a corrupt schedule, but the toggle starts off
for a corrupt rule, so that sequence leaves it on and trips the
no-selected-day validation instead. The correct, tested sequence is on
then off, matching the inline hint below the toggle. Also add a
regression test confirming the invalid-schedule save gate resets
cleanly across edit sessions on different rules.
* fix: enforce replica node_id and guard fleet sync against stale writes
Two hardenings to the suppression-rule fleet sync path found during
review: the /replica endpoint trusted the payload's node_id instead of
forcing it to null server-side, so a direct proxy-authenticated caller
could persist a scoped replica; and upsertNotificationSuppressionRuleReplica
overwrote unconditionally with no ordering check, so a delayed older
POST arriving after a newer one could downgrade the stored rule. Force
node_id to null on every replica write, and skip (with a warning log)
any incoming write whose updated_at is not newer than what's stored.
* test: assert the exact-tie updated_at case in the fleet sync stale-write guard
The staleness guard added in c31458a1 uses >= (ties are ignored, not
just strictly older writes); add the missing assertion for that
boundary and make the comment explicit about it.
* fix: bump vulnerable transitive backend dependencies
npm audit flagged body-parser, fast-uri, and protobufjs (one high
severity: fast-uri host confusion via failed IDN canonicalization).
All three have patch/minor fixes within existing semver ranges;
npm audit fix resolves all three with no package.json changes.
* fix: sanitize suppression replica fields before logging
Log entries built from fleet-sync replica payloads embedded rule id
and timestamp values directly, allowing a compromised peer to forge
log lines via control characters.
* fix: prevent delayed replica writes from resurrecting deleted mute rules
A network-reordered replica POST arriving after a DELETE fell into the
insert-when-absent branch with no protection, since the staleness guard
only compares against a row that still exists. Add a permanent
per-id tombstone (safe because rule ids are AUTOINCREMENT and never
reused): every delete records one, and the replica upsert refuses to
recreate a tombstoned id regardless of the incoming updated_at.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Weekly UTC maintenance windows for notification suppression rules.
|
||||
* days are start days (Date#getUTCDay); start inclusive, end exclusive.
|
||||
*/
|
||||
|
||||
export interface NotificationSchedule {
|
||||
days: number[];
|
||||
start_minute: number;
|
||||
end_minute: number;
|
||||
tz: 'UTC';
|
||||
}
|
||||
|
||||
export type ParseNotificationScheduleResult =
|
||||
| { ok: true; schedule: NotificationSchedule }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const SCHEDULE_KEYS = new Set(['days', 'start_minute', 'end_minute', 'tz']);
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Strict write-boundary parser. Accepts days in any order; returns sorted unique days. */
|
||||
export function parseNotificationSchedule(raw: unknown): ParseNotificationScheduleResult {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { ok: false, error: 'schedule must be an object or null' };
|
||||
}
|
||||
const keys = Object.keys(raw);
|
||||
if (keys.length !== 4 || keys.some((k) => !SCHEDULE_KEYS.has(k))) {
|
||||
return { ok: false, error: 'schedule must have exactly days, start_minute, end_minute, and tz' };
|
||||
}
|
||||
if (raw.tz !== 'UTC') {
|
||||
return { ok: false, error: 'schedule.tz must be UTC' };
|
||||
}
|
||||
if (!Array.isArray(raw.days) || raw.days.length === 0) {
|
||||
return { ok: false, error: 'schedule.days must be a nonempty array' };
|
||||
}
|
||||
if (raw.days.some((d) => typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 6)) {
|
||||
return { ok: false, error: 'schedule.days must be integers 0..6' };
|
||||
}
|
||||
const days = [...new Set(raw.days as number[])].sort((a, b) => a - b);
|
||||
if (days.length !== raw.days.length) {
|
||||
return { ok: false, error: 'schedule.days must not contain duplicates' };
|
||||
}
|
||||
if (
|
||||
typeof raw.start_minute !== 'number'
|
||||
|| !Number.isInteger(raw.start_minute)
|
||||
|| raw.start_minute < 0
|
||||
|| raw.start_minute > 1439
|
||||
) {
|
||||
return { ok: false, error: 'schedule.start_minute must be an integer 0..1439' };
|
||||
}
|
||||
if (
|
||||
typeof raw.end_minute !== 'number'
|
||||
|| !Number.isInteger(raw.end_minute)
|
||||
|| raw.end_minute < 0
|
||||
|| raw.end_minute > 1439
|
||||
) {
|
||||
return { ok: false, error: 'schedule.end_minute must be an integer 0..1439' };
|
||||
}
|
||||
if (raw.start_minute === raw.end_minute) {
|
||||
return { ok: false, error: 'schedule.start_minute and end_minute must differ' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
schedule: {
|
||||
days,
|
||||
start_minute: raw.start_minute,
|
||||
end_minute: raw.end_minute,
|
||||
tz: 'UTC',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DB column value. Only SQL null / undefined → legacy null (always in window).
|
||||
* Empty string, whitespace, or any non-null corrupt value → invalid (must not suppress).
|
||||
*/
|
||||
export function parseStoredNotificationSchedule(
|
||||
raw: unknown,
|
||||
): { kind: 'null' } | { kind: 'ok'; schedule: NotificationSchedule } | { kind: 'invalid' } {
|
||||
if (raw == null) return { kind: 'null' };
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return { kind: 'invalid' };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
// JSON null is not SQL NULL; treat as corrupt rather than legacy always-on.
|
||||
if (parsed == null) return { kind: 'invalid' };
|
||||
const result = parseNotificationSchedule(parsed);
|
||||
if (!result.ok) return { kind: 'invalid' };
|
||||
return { kind: 'ok', schedule: result.schedule };
|
||||
}
|
||||
const result = parseNotificationSchedule(raw);
|
||||
if (!result.ok) return { kind: 'invalid' };
|
||||
return { kind: 'ok', schedule: result.schedule };
|
||||
}
|
||||
|
||||
/** True when the schedule window covers atMs (UTC). Null schedule is always active. */
|
||||
export function isScheduleActive(schedule: NotificationSchedule | null, atMs: number): boolean {
|
||||
if (schedule == null) return true;
|
||||
const date = new Date(atMs);
|
||||
const day = date.getUTCDay();
|
||||
const minute = date.getUTCHours() * 60 + date.getUTCMinutes();
|
||||
const { days, start_minute: start, end_minute: end } = schedule;
|
||||
if (start < end) {
|
||||
return days.includes(day) && minute >= start && minute < end;
|
||||
}
|
||||
const prevDay = (day + 6) % 7;
|
||||
return (days.includes(day) && minute >= start)
|
||||
|| (days.includes(prevDay) && minute < end);
|
||||
}
|
||||
|
||||
/** Whether a loaded rule may suppress at atMs (filters already matched). */
|
||||
export function scheduleAllowsSuppression(
|
||||
schedule: NotificationSchedule | null,
|
||||
scheduleInvalid: boolean,
|
||||
atMs: number,
|
||||
): boolean {
|
||||
if (scheduleInvalid) return false;
|
||||
return isScheduleActive(schedule, atMs);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { DatabaseService, type NotificationSuppressionRule, type Node } from '..
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY,
|
||||
} from '../services/CapabilityRegistry';
|
||||
import { remoteAdvertisesCapability } from './remoteCapabilities';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const SYNC_TIMEOUT_MS = 15_000;
|
||||
@@ -16,7 +20,12 @@ function buildRemoteHeaders(apiToken: string): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
||||
/** Hub node ids that should receive a replica for this rule (before wire identity normalize). */
|
||||
export function replicationTargetIds(rule: NotificationSuppressionRule): number[] {
|
||||
return replicationTargets(rule).map((n) => n.id);
|
||||
}
|
||||
|
||||
export function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remotes = db.getNodes().filter((n) => n.type === 'remote');
|
||||
if (rule.node_id != null) {
|
||||
@@ -26,6 +35,10 @@ function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
||||
return remotes;
|
||||
}
|
||||
|
||||
function replicaPayload(rule: NotificationSuppressionRule): NotificationSuppressionRule {
|
||||
return { ...rule, node_id: null };
|
||||
}
|
||||
|
||||
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target?.apiUrl) {
|
||||
@@ -36,7 +49,7 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
|
||||
method: 'POST',
|
||||
headers: buildRemoteHeaders(target.apiToken),
|
||||
body: JSON.stringify({ rule }),
|
||||
body: JSON.stringify({ rule: replicaPayload(rule) }),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -48,8 +61,7 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr
|
||||
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target?.apiUrl) {
|
||||
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
|
||||
return;
|
||||
throw new Error(`no proxy target for node "${node.name}" (id=${node.id})`);
|
||||
}
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
|
||||
@@ -63,6 +75,61 @@ async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const supportsSchedule = await remoteAdvertisesCapability(
|
||||
node.id,
|
||||
NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY,
|
||||
);
|
||||
if (supportsSchedule) {
|
||||
await pushRuleToNode(node, rule);
|
||||
return;
|
||||
}
|
||||
// Probe false means unsupported OR unreachable. Never POST a scheduled rule
|
||||
// through the legacy contract (older remotes would mute all day). Attempt DELETE;
|
||||
// only claim cleanup when DELETE succeeds.
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
console.warn(
|
||||
`[SuppressionSync] Scheduled rule ${rule.id} not applied on node "${node.name}" (id=${node.id}): ` +
|
||||
`capability unsupported-or-unreachable; DELETE succeeded and replica was removed`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Scheduled rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
|
||||
`capability unsupported-or-unreachable and DELETE failed (${getErrorMessage(err, String(err))}). ` +
|
||||
`Prior replica may remain until connectivity returns and the rule is re-saved`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupInvalidScheduleReplica(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
// Never POST an invalid schedule (would mute all day on remotes that ignore the field).
|
||||
// Attempt DELETE so a prior valid/unscheduled replica cannot keep muting.
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
console.warn(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: replica removed on node "${node.name}" (id=${node.id}); not posting`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
|
||||
`DELETE failed (${getErrorMessage(err, String(err))}). Prior replica may remain until connectivity returns`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
if (rule.scheduleInvalid) {
|
||||
await cleanupInvalidScheduleReplica(node, rule);
|
||||
return;
|
||||
}
|
||||
if (rule.schedule != null) {
|
||||
await pushOrCleanupScheduled(node, rule);
|
||||
return;
|
||||
}
|
||||
await pushRuleToNode(node, rule);
|
||||
}
|
||||
|
||||
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
|
||||
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
|
||||
const targets = replicationTargets(rule);
|
||||
@@ -70,7 +137,7 @@ export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): v
|
||||
void Promise.allSettled(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
await pushRuleToNode(node, rule);
|
||||
await syncRuleToNode(node, rule);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
|
||||
@@ -81,6 +148,45 @@ export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): v
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After an update, DELETE replicas on targets that left the set, then refresh
|
||||
* remaining / new targets. previous is the pre-update rule (for target diff).
|
||||
*/
|
||||
export function syncSuppressionRuleUpdateToFleet(
|
||||
previous: NotificationSuppressionRule,
|
||||
updated: NotificationSuppressionRule,
|
||||
): void {
|
||||
const oldIds = new Set(replicationTargetIds(previous));
|
||||
const newIds = new Set(replicationTargetIds(updated));
|
||||
const db = DatabaseService.getInstance();
|
||||
const staleIds = [...oldIds].filter((id) => !newIds.has(id));
|
||||
|
||||
void Promise.allSettled([
|
||||
...staleIds.map(async (id) => {
|
||||
const node = db.getNode(id);
|
||||
if (!node || node.type !== 'remote') return;
|
||||
try {
|
||||
await deleteRuleOnNode(node, previous.id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to delete stale rule ${previous.id} on node "${node.name}":`,
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
...replicationTargets(updated).map(async (node) => {
|
||||
try {
|
||||
await syncRuleToNode(node, updated);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to push rule ${updated.id} to node "${node.name}":`,
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Best-effort delete of a replicated rule on fleet nodes. */
|
||||
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
|
||||
const targets = replicationTargets(rule);
|
||||
|
||||
Reference in New Issue
Block a user