Skip to content

Commit 9b70a48

Browse files
authored
Web-based config editor setup (#54)
* feat(schema): add manage_guild_config permission and preview block Two additions to the config schema, both in service of an upcoming sibling `azalea-editor` web service. Both are optional / inert from the bot's perspective — the bot does not consume either. - New `Permission.ManageGuildConfig = "manage_guild_config"`. The editor will gate config-edit access by checking this permission via the same role-based pattern the bot already uses (`GuildConfig.hasPermission`). - New optional `preview` block on the global config: preview: test_channel_id: <snowflake> test_webhook_url: https://discord.com/api/webhooks/<id>/<token> Both fields are optional and validated independently. Webhook URL validation matches the standard `discord.com` / `discordapp.com` shape so typos surface at parse time rather than at the moment a preview is requested. The editor reads these fields to power its "Test in Discord" button; preview is only enabled when both are set. * feat(health): expose /healthz for the config editor's reload verification Add a tiny localhost-only HTTP listener (Bun.serve, no new deps) so the sibling `azalea-editor` service can detect a successful pm2 reload. The editor uses this signal to decide whether a config save succeeded or needs to be rolled back. - `src/utils/health.ts`: starts the listener at module load and returns `{ markReady, startedAt }`. `startedAt` is the ISO timestamp captured when the process boots; the editor compares it against the value seen pre-reload to confirm it's looking at the new process. PM2's own `restart_time` and `online` status are insufficient — they flip before ClientReady fires. - `src/index.ts`: imports and starts the server before any other startup work so the editor can reach it the moment the new process spawns. - `src/events/Ready.ts`: calls `health.markReady()` only after every cron job (scheduled messages, review reminders, report removal) is mounted. A 200 with `ready: true` therefore means the bot is fully operational, not merely logged in. - `.env.example`: documents the optional `HEALTH_HOST` / `HEALTH_PORT` overrides. Defaults to 127.0.0.1:7475; the endpoint must never be exposed beyond loopback. - `tests/health.test.ts`: covers the ready transition, startedAt persistence, and 404 for unknown paths. * chore: drop the preview config block The sibling editor's "Send to test channel" feature was removed, so the only consumer of `globalConfigSchema.preview` is gone. Drop the field, its `webhookUrlSchema` regex, and the corresponding YAML example in the README. Add `configs/.backups` to .gitignore so the editor's runtime config backups never accidentally land in commits, and tag VIRUSTOTAL_API_KEY as optional in .env.example for parity with the other secondary integrations. `Permission.ManageGuildConfig` stays — the editor still uses it as the per-guild access gate.
1 parent cb8e0c6 commit 9b70a48

8 files changed

Lines changed: 157 additions & 6 deletions

File tree

.env.example

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,11 @@ SENTRY_DSN="YOUR_SENTRY_DSN"
1010
# (Optional) Rover API key, see https://rover.link/bot-developers for more information
1111
ROVER_API_KEY="YOUR_ROVER_API_KEY"
1212

13-
# VirusTotal API key, see https://www.virustotal.com for more information
14-
VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY"
13+
# (Optional) VirusTotal API key, see https://www.virustotal.com for more information
14+
VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY"
15+
16+
# (Optional) Health endpoint bind. Defaults to 127.0.0.1:7475. The sibling
17+
# azalea-editor service polls /healthz here to verify a pm2 reload succeeded.
18+
# Never expose this beyond loopback.
19+
HEALTH_HOST="127.0.0.1"
20+
HEALTH_PORT="7475"

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,5 @@ migration_lock.toml
3434
# Configuration
3535

3636
configs/*.yml
37-
!configs/example.yml
37+
configs/.backups
3838
azalea.cfg.yml

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ permissions:
214214
- view_infractions
215215
```
216216

217-
**Available permissions:** `manage_infractions`, `transfer_infractions`, `manage_mute_requests`, `manage_ban_requests`, `manage_message_reports`, `manage_user_reports`, `manage_highlights`, `view_infractions`, `view_moderation_activity`, `purge_messages`, `quick_mute`, `report_messages`, `manage_role_requests`, `manage_roles`, `forward_messages`
217+
**Available permissions:** `manage_infractions`, `transfer_infractions`, `manage_mute_requests`, `manage_ban_requests`, `manage_message_reports`, `manage_user_reports`, `manage_highlights`, `view_infractions`, `view_moderation_activity`, `purge_messages`, `quick_mute`, `report_messages`, `manage_role_requests`, `manage_roles`, `forward_messages`, `manage_guild_config`
218+
219+
> `manage_guild_config` gates the sibling `azalea-editor` web UI; the bot itself does not consume it.
218220

219221
### Ban & Mute Requests
220222

src/events/Ready.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { MessageCache } from "@utils/messages";
22
import { Client, Events, GuildTextBasedChannel } from "discord.js";
3-
import { client, prisma } from "@";
3+
import { client, health, prisma } from "@";
44
import { groupBy } from "lodash";
55
import { pluralize, startCronJob } from "@/utils";
66

@@ -44,6 +44,10 @@ export default class Ready extends EventListener {
4444
if (hasRoleRequests) {
4545
Ready._startTemporaryRoleRemovalCronJob();
4646
}
47+
48+
// Signal full readiness to the editor's health poll. Must come after
49+
// every cron above so a healthy response means crons are mounted.
50+
health.markReady();
4751
}
4852

4953
private static _startTemporaryRoleRemovalCronJob(): void {

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import EventListenerManager from "./managers/events/EventListenerManager";
1010
import ComponentManager from "./managers/components/ComponentManager";
1111
import ConfigManager from "./managers/config/ConfigManager";
1212
import Logger from "./utils/logger";
13+
import { startHealthServer } from "./utils/health";
14+
15+
// Health endpoint started at module load so the editor can detect the new
16+
// process the moment pm2 reload spawns it; ready flips once Ready.ts fires.
17+
export const health = startHealthServer();
1318

1419
// Handle process exit
1520
EXIT_EVENTS.forEach(event => {

src/managers/config/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,13 @@ export enum Permission {
198198
// Grants access to managing role requests
199199
ManageRoleRequests = "manage_role_requests",
200200
ManageRoles = "manage_roles",
201-
ForwardMessages = "forward_messages"
201+
ForwardMessages = "forward_messages",
202+
/**
203+
* Grants access to editing this guild's config via the web editor.
204+
* The bot itself does not consume this permission — it gates the
205+
* sibling `azalea-editor` service.
206+
*/
207+
ManageGuildConfig = "manage_guild_config"
202208
}
203209

204210
const permissionEnum = z.nativeEnum(Permission);

src/utils/health.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { name as APP_NAME, version as APP_VERSION } from "../../package.json";
2+
3+
import Logger from "./logger";
4+
5+
interface HealthState {
6+
ready: boolean;
7+
}
8+
9+
interface HealthServerHandle {
10+
/** Flip ready=true once Discord has emitted ClientReady and crons are mounted. */
11+
markReady(): void;
12+
/** ISO 8601 timestamp captured when the server started. The editor uses this to detect a fresh process across pm2 reloads. */
13+
readonly startedAt: string;
14+
}
15+
16+
const DEFAULT_HEALTH_PORT = 7475;
17+
const DEFAULT_HEALTH_HOST = "127.0.0.1";
18+
19+
function resolvePort(override?: number): number {
20+
if (typeof override === "number") return override;
21+
const fromEnv = Number.parseInt(process.env.HEALTH_PORT ?? "", 10);
22+
return Number.isFinite(fromEnv) ? fromEnv : DEFAULT_HEALTH_PORT;
23+
}
24+
25+
/**
26+
* Tiny localhost-only health endpoint consumed by the sibling `azalea-editor`
27+
* service to verify a `pm2 reload` actually produced a healthy bot process.
28+
*
29+
* Returns 200 with `{ ready, pid, startedAt, name, version }`. `ready` flips
30+
* to true once {@link HealthServerHandle.markReady} is called (from the Ready
31+
* event, after crons are mounted). `startedAt` strictly advances across
32+
* process restarts, which is the editor's signal that a reload succeeded —
33+
* `pm2`'s own `restart_time` and `online` status both lie about reload
34+
* outcome.
35+
*
36+
* Bound to 127.0.0.1 by default; never expose this directly to the internet.
37+
*/
38+
export function startHealthServer(options: {
39+
port?: number;
40+
host?: string;
41+
} = {}): HealthServerHandle {
42+
const port = resolvePort(options.port);
43+
const host = options.host ?? process.env.HEALTH_HOST ?? DEFAULT_HEALTH_HOST;
44+
const startedAt = new Date().toISOString();
45+
const state: HealthState = { ready: false };
46+
47+
Bun.serve({
48+
port,
49+
hostname: host,
50+
fetch(request): Response {
51+
const url = new URL(request.url);
52+
if (url.pathname !== "/healthz") {
53+
return new Response("Not Found", { status: 404 });
54+
}
55+
56+
const body = JSON.stringify({
57+
ready: state.ready,
58+
pid: process.pid,
59+
startedAt,
60+
name: APP_NAME,
61+
version: APP_VERSION
62+
});
63+
64+
const headers = new Headers();
65+
headers.set("content-type", "application/json");
66+
return new Response(body, { headers });
67+
}
68+
});
69+
70+
Logger.info(`Health endpoint listening on http://${host}:${port}/healthz`);
71+
72+
return {
73+
markReady(): void {
74+
state.ready = true;
75+
},
76+
startedAt
77+
};
78+
}

tests/health.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterAll, describe, expect, test } from "bun:test";
2+
import { startHealthServer } from "@/utils/health";
3+
4+
const TEST_PORT = 17476;
5+
6+
describe("startHealthServer", () => {
7+
const handle = startHealthServer({ port: TEST_PORT, host: "127.0.0.1" });
8+
const url = `http://127.0.0.1:${TEST_PORT}/healthz`;
9+
10+
afterAll(() => {
11+
// Bun.serve has no formal stop returned from startHealthServer; rely on
12+
// test process exit to release the port. Each test file runs in its own
13+
// Bun instance, so leakage doesn't affect other tests.
14+
});
15+
16+
interface HealthBody {
17+
ready: boolean;
18+
pid: number;
19+
startedAt: string;
20+
name: string;
21+
version: string;
22+
}
23+
24+
test("returns ready=false before markReady() is called", async () => {
25+
const res = await fetch(url);
26+
expect(res.status).toBe(200);
27+
const body = await res.json() as HealthBody;
28+
expect(body).toMatchObject({
29+
ready: false,
30+
pid: process.pid,
31+
name: "azalea"
32+
});
33+
expect(typeof body.startedAt).toBe("string");
34+
expect(typeof body.version).toBe("string");
35+
});
36+
37+
test("flips ready=true after markReady() and keeps the original startedAt", async () => {
38+
const before = await fetch(url).then(r => r.json() as Promise<HealthBody>);
39+
handle.markReady();
40+
const after = await fetch(url).then(r => r.json() as Promise<HealthBody>);
41+
42+
expect(after.ready).toBe(true);
43+
expect(after.startedAt).toBe(before.startedAt);
44+
});
45+
46+
test("returns 404 for any other path", async () => {
47+
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/somethingelse`);
48+
expect(res.status).toBe(404);
49+
});
50+
});

0 commit comments

Comments
 (0)