@polderlabs/bizar 4.7.0 → 4.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  17. package/bizar-dash/src/server/routes/chat.mjs +14 -0
  18. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  19. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  20. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  21. package/bizar-dash/src/server/server.mjs +17 -0
  22. package/bizar-dash/src/web/App.tsx +8 -1
  23. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  24. package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
  25. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  26. package/bizar-dash/src/web/styles/main.css +70 -8
  27. package/bizar-dash/src/web/views/Activity.tsx +11 -1
  28. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  29. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  30. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  31. package/bizar-dash/src/web/views/History.tsx +4 -1
  32. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  33. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  34. package/bizar-dash/src/web/views/Overview.tsx +8 -1
  35. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  36. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  37. package/bizar-dash/src/web/views/Settings.tsx +97 -1751
  38. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  39. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  40. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  41. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  42. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  43. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  44. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  45. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  46. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  47. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  48. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  49. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  50. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  51. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  52. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  53. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  54. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  55. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  56. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  57. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  58. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  59. package/bizar-dash/tests/a11y.test.tsx +206 -0
  60. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  61. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  62. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  63. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  64. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  65. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  66. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  67. package/cli/bin.mjs +96 -2
  68. package/cli/commands/minimax.mjs +20 -0
  69. package/cli/commands/util.mjs +154 -1
  70. package/cli/digest.mjs +149 -0
  71. package/package.json +1 -1
  72. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  73. package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
  74. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
  75. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
@@ -0,0 +1,112 @@
1
+ /**
2
+ * src/server/routes/backup.mjs
3
+ *
4
+ * v4.8.0 — Backup/restore REST surface.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/backup/list — list available backups
8
+ * POST /api/backup/create — create a new backup
9
+ * POST /api/backup/restore — restore from a backup
10
+ * POST /api/backup/verify — verify backup integrity
11
+ * DELETE /api/backup/:path — delete a backup
12
+ * GET /api/backup/manifest — get manifest for a backup
13
+ */
14
+ import { Router } from 'express';
15
+ import { wrap } from './_shared.mjs';
16
+ import {
17
+ createBackup,
18
+ listBackups,
19
+ restoreBackup,
20
+ deleteBackup,
21
+ verifyBackup,
22
+ getManifest,
23
+ } from '../backup-store.mjs';
24
+
25
+ /**
26
+ * @param {object} deps
27
+ * @param {string} deps.projectRoot
28
+ * @returns {import('express').Router}
29
+ */
30
+ export function createBackupRouter({ projectRoot }) {
31
+ const router = Router();
32
+
33
+ // GET /api/backup/list
34
+ router.get('/backup/list', wrap(async (_req, res) => {
35
+ const backups = await listBackups();
36
+ res.json({ ok: true, backups });
37
+ }));
38
+
39
+ // POST /api/backup/create
40
+ router.post('/backup/create', wrap(async (req, res) => {
41
+ const { label, includeProject } = req.body || {};
42
+ const result = await createBackup({
43
+ label: label || null,
44
+ projectRoot: includeProject ? projectRoot : null,
45
+ });
46
+ res.json(result);
47
+ }));
48
+
49
+ // POST /api/backup/restore
50
+ router.post('/backup/restore', wrap(async (req, res) => {
51
+ const { backupPath, dryRun, conflictStrategy, includeProject } = req.body || {};
52
+ if (!backupPath) {
53
+ const err = new Error('backupPath is required');
54
+ err.status = 400;
55
+ err.code = 'bad_request';
56
+ throw err;
57
+ }
58
+ const result = await restoreBackup({
59
+ backupPath,
60
+ dryRun: Boolean(dryRun),
61
+ conflictStrategy: conflictStrategy || 'merge',
62
+ projectRoot: includeProject ? projectRoot : null,
63
+ });
64
+ res.json(result);
65
+ }));
66
+
67
+ // POST /api/backup/verify
68
+ router.post('/backup/verify', wrap(async (req, res) => {
69
+ const { backupPath } = req.body || {};
70
+ if (!backupPath) {
71
+ const err = new Error('backupPath is required');
72
+ err.status = 400;
73
+ err.code = 'bad_request';
74
+ throw err;
75
+ }
76
+ const result = await verifyBackup({ backupPath });
77
+ res.json(result);
78
+ }));
79
+
80
+ // DELETE /api/backup/:path (path is url-encoded)
81
+ router.delete('/backup/:path', wrap(async (req, res) => {
82
+ const { path: encoded } = req.params;
83
+ const backupPath = decodeURIComponent(encoded);
84
+ const result = await deleteBackup({ backupPath });
85
+ if (!result.ok) {
86
+ const err = new Error(result.error || 'Delete failed');
87
+ err.status = 404;
88
+ throw err;
89
+ }
90
+ res.json(result);
91
+ }));
92
+
93
+ // GET /api/backup/manifest
94
+ router.get('/backup/manifest', wrap(async (req, res) => {
95
+ const { backupPath } = req.query || {};
96
+ if (!backupPath) {
97
+ const err = new Error('backupPath query param is required');
98
+ err.status = 400;
99
+ err.code = 'bad_request';
100
+ throw err;
101
+ }
102
+ const result = await getManifest({ backupPath });
103
+ if (!result.ok) {
104
+ const err = new Error(result.error || 'Manifest not found');
105
+ err.status = 404;
106
+ throw err;
107
+ }
108
+ res.json(result);
109
+ }));
110
+
111
+ return router;
112
+ }
@@ -44,6 +44,7 @@ import {
44
44
  buildAuthHeader,
45
45
  } from '../serve-info.mjs';
46
46
  import { wrap } from './_shared.mjs';
47
+ import { createRateLimiter } from '../lib/rate-limit.mjs';
47
48
 
48
49
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,120}$/;
49
50
 
@@ -95,6 +96,19 @@ function resetChatDeltaCount(chatSessionId) {
95
96
  export function createChatRouter({ state, broadcast }) {
96
97
  const router = Router();
97
98
 
99
+ // v4.8.0 — Per-IP token bucket. Chat endpoints are the most expensive
100
+ // thing the dashboard does (each POST kicks off an SSE subscription +
101
+ // opencode prompt dispatch), so the default budget is conservative:
102
+ // 60 requests / minute / IP, refilling at 1 token per second.
103
+ // Operators can tune via BIZAR_RATE_LIMIT_CHAT_CAPACITY /
104
+ // BIZAR_RATE_LIMIT_CHAT_REFILL.
105
+ const chatLimiter = createRateLimiter({
106
+ capacity: parseInt(process.env.BIZAR_RATE_LIMIT_CHAT_CAPACITY || '60', 10),
107
+ refillPerSecond: parseFloat(process.env.BIZAR_RATE_LIMIT_CHAT_REFILL || '1'),
108
+ scope: 'chat',
109
+ });
110
+ router.use(chatLimiter);
111
+
98
112
  router.get('/chat', wrap(async (req, res) => {
99
113
  const sessionId = req.query.session ? String(req.query.session) : null;
100
114
  const requestedLimit = req.query.limit ? Number(req.query.limit) : 200;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * src/server/routes/digests.mjs
3
+ *
4
+ * /api/digests — list digests
5
+ * /api/digests/generate (POST) — generate a digest
6
+ * /api/digests/:path (GET) — get a specific digest
7
+ * /api/digests/:path (DELETE) — delete a digest
8
+ *
9
+ * The `:path` parameter is URL-encoded so it survives Express routing.
10
+ */
11
+ import { Router } from 'express';
12
+ import {
13
+ generateAndSave,
14
+ listDigests,
15
+ getDigest,
16
+ deleteDigest,
17
+ } from '../digest-store.mjs';
18
+ import { readActiveProjectId, wrap } from './_shared.mjs';
19
+
20
+ /**
21
+ * @param {object} deps
22
+ * @param {object} [deps.projectRoot]
23
+ * @returns {import('express').Router}
24
+ */
25
+ export function createDigestsRouter({ projectRoot } = {}) {
26
+ const router = Router();
27
+
28
+ // GET /api/digests — list all digests
29
+ router.get('/digests', wrap(async (req, res) => {
30
+ const limit = parseInt(req.query.limit, 10) || 20;
31
+ const digests = await listDigests({ limit });
32
+ res.json({ digests });
33
+ }));
34
+
35
+ // GET /api/digests/:path — get a single digest by path
36
+ // The path is double-encoded: first by the client, then by Express.
37
+ router.get('/digests/*', wrap(async (req, res) => {
38
+ // Reconstruct the path from the wildcard
39
+ const rawPath = req.params[0];
40
+ if (!rawPath) {
41
+ // This catches /api/digests with no trailing path (should be handled
42
+ // by the /digests route above, but Express may match this first if the
43
+ // wildcard is greedy — we handle the list case explicitly).
44
+ const digests = await listDigests({ limit: 20 });
45
+ res.json({ digests });
46
+ return;
47
+ }
48
+ const filePath = decodeURIComponent(rawPath);
49
+ const digest = await getDigest(filePath);
50
+ if (!digest) {
51
+ res.status(404).json({ error: 'not_found', message: 'Digest not found' });
52
+ return;
53
+ }
54
+ res.json(digest);
55
+ }));
56
+
57
+ // POST /api/digests/generate — generate a new digest
58
+ router.post('/digests/generate', wrap(async (req, res) => {
59
+ const { weekStart, weekEnd, dryRun } = req.body || {};
60
+ const root = req.body?.projectRoot || projectRoot || process.cwd();
61
+ const result = await generateAndSave({ weekStart, weekEnd, projectRoot: root, dryRun });
62
+ res.status(dryRun ? 200 : 201).json(result);
63
+ }));
64
+
65
+ // DELETE /api/digests/* — delete a digest by path
66
+ router.delete('/digests/*', wrap(async (req, res) => {
67
+ const rawPath = req.params[0];
68
+ if (!rawPath) {
69
+ res.status(400).json({ error: 'bad_request', message: 'Digest path required' });
70
+ return;
71
+ }
72
+ const filePath = decodeURIComponent(rawPath);
73
+ const result = await deleteDigest(filePath);
74
+ if (!result.ok) {
75
+ res.status(404).json({ error: 'not_found', message: result.error || 'Digest not found' });
76
+ return;
77
+ }
78
+ res.status(204).end();
79
+ }));
80
+
81
+ return router;
82
+ }
@@ -14,10 +14,24 @@
14
14
  */
15
15
 
16
16
  import express from 'express';
17
+ import { createRateLimiter } from '../lib/rate-limit.mjs';
17
18
 
18
19
  export function createV2EventsRouter({ eventBus }) {
19
20
  const router = express.Router();
20
21
 
22
+ // v4.8.0 — Per-IP token bucket. The v2 event endpoint accepts
23
+ // arbitrary publishes from the opencode plugin, so we cap it
24
+ // higher than chat to keep automation pipelines flowing under
25
+ // burst. Defaults: 120 requests / minute / IP, 2 tokens/sec refill.
26
+ // Operators can tune via BIZAR_RATE_LIMIT_EVENT_CAPACITY /
27
+ // BIZAR_RATE_LIMIT_EVENT_REFILL.
28
+ const eventLimiter = createRateLimiter({
29
+ capacity: parseInt(process.env.BIZAR_RATE_LIMIT_EVENT_CAPACITY || '120', 10),
30
+ refillPerSecond: parseFloat(process.env.BIZAR_RATE_LIMIT_EVENT_REFILL || '2'),
31
+ scope: 'event',
32
+ });
33
+ router.use(eventLimiter);
34
+
21
35
  router.get('/event', (req, res) => {
22
36
  res.status(200);
23
37
  res.setHeader('Content-Type', 'text/event-stream');
@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
23
23
  import { isIP } from 'node:net';
24
24
  import { projectsStore } from './projects-store.mjs';
25
25
  import { schedulesStore } from './schedules-store.mjs';
26
+ import { Cron } from 'croner';
26
27
 
27
28
  const HOME = homedir();
28
29
  const LOG_DIR = join(HOME, '.config', 'bizar');
@@ -30,6 +31,97 @@ const LOG_FILE = join(LOG_DIR, 'service.log');
30
31
  const ALLOW_PRIVATE_WEBHOOKS = process.env.BIZAR_DASHBOARD_ALLOW_PRIVATE_WEBHOOKS === '1';
31
32
  const SHELL_META = /[;&|`$<>\n\r]/;
32
33
 
34
+ // ── Internal schedules ─────────────────────────────────────────────────────
35
+ //
36
+ // v4.8.0 — Builtin schedules registered at module load. These are NOT
37
+ // persisted in the schedules-store; they live in-memory here and are
38
+ // checked alongside the project schedules during tick(). When a builtin
39
+ // schedule fires, the runner dispatches directly to the handler without
40
+ // going through the task delegator or action runner.
41
+
42
+ /** @type {Array<{ id: string, name: string, schedule: string, type: string, timezone: string, enabled: boolean, builtin: boolean, handler?: Function, lastRun: string|null, nextRun: string|null }>} */
43
+ const internalSchedules = [];
44
+
45
+ /**
46
+ * Register an internal schedule that is evaluated during tick().
47
+ *
48
+ * @param {object} spec
49
+ * @param {string} spec.id
50
+ * @param {string} spec.name
51
+ * @param {string} spec.cron — cron expression
52
+ * @param {string} [spec.timezone='UTC']
53
+ * @param {Function} [spec.handler] — optional direct-call handler for builtin schedules
54
+ */
55
+ export function registerInternalSchedule(spec) {
56
+ const tz = spec.timezone || 'UTC';
57
+ let nextRun = null;
58
+ if (spec.cron) {
59
+ try {
60
+ const job = new Cron(spec.cron, { timezone: tz });
61
+ const n = job.nextRun();
62
+ nextRun = n ? n.toISOString() : null;
63
+ } catch { /* invalid cron */ }
64
+ }
65
+ // Remove existing entry with same id (for hot-reload)
66
+ const existing = internalSchedules.findIndex((s) => s.id === spec.id);
67
+ const entry = {
68
+ id: spec.id,
69
+ name: spec.name || spec.id,
70
+ schedule: spec.cron || '',
71
+ type: 'cron',
72
+ timezone: tz,
73
+ enabled: true,
74
+ builtin: true,
75
+ handler: spec.handler || null,
76
+ lastRun: null,
77
+ nextRun,
78
+ };
79
+ if (existing >= 0) {
80
+ internalSchedules[existing] = { ...internalSchedules[existing], ...entry };
81
+ } else {
82
+ internalSchedules.push(entry);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Recompute nextRun for all internal schedules (called on system clock
88
+ * changes or after midnight).
89
+ */
90
+ export function recomputeInternalSchedules() {
91
+ for (const s of internalSchedules) {
92
+ if (s.type === 'cron' && s.schedule) {
93
+ try {
94
+ const job = new Cron(s.schedule, { timezone: s.timezone || 'UTC' });
95
+ const n = job.nextRun();
96
+ s.nextRun = n ? n.toISOString() : null;
97
+ } catch {
98
+ s.nextRun = null;
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // v4.8.0 — Auto-register the weekly digest cron at module load.
105
+ registerInternalSchedule({
106
+ id: 'bizar-internal-weekly-digest',
107
+ name: 'Weekly digest',
108
+ cron: '0 0 * * 0',
109
+ timezone: 'UTC',
110
+ handler: async () => {
111
+ logLine('[digest] Weekly digest cron fired — generating…');
112
+ try {
113
+ const { generateAndSave } = await import('./digest-store.mjs');
114
+ const projectRoot = process.env.BIZAR_PROJECT_ROOT || process.cwd();
115
+ const result = await generateAndSave({ projectRoot });
116
+ logLine(`[digest] Weekly digest generated: ${result.weekStart} (${result.saveResult.paths.length} file(s))`);
117
+ return { ok: true, result };
118
+ } catch (err) {
119
+ logLine(`[digest] Weekly digest generation failed: ${err.message}`);
120
+ return { ok: false, error: err.message };
121
+ }
122
+ },
123
+ });
124
+
33
125
  function logLine(line) {
34
126
  try {
35
127
  mkdirSync(LOG_DIR, { recursive: true });
@@ -292,6 +384,8 @@ export const schedulesRunner = {
292
384
  console.warn('swallowed in schedules due-list:', err.message);
293
385
  }
294
386
  let skipped = 0;
387
+
388
+ // Project schedules
295
389
  for (const projectId of projectIds) {
296
390
  const due = schedulesStore.due(projectId, Date.now());
297
391
  for (const sched of due) {
@@ -300,6 +394,38 @@ export const schedulesRunner = {
300
394
  fired.push({ projectId, scheduleId: sched.id, ...result });
301
395
  }
302
396
  }
397
+
398
+ // v4.8.0 — Internal (builtin) schedules
399
+ const now = Date.now();
400
+ for (const sched of internalSchedules) {
401
+ if (!sched.enabled) continue;
402
+ if (!sched.nextRun) continue;
403
+ if (new Date(sched.nextRun).getTime() > now) continue;
404
+ const startedAt = new Date().toISOString();
405
+ logLine(`[${startedAt}] run internal ${sched.id} (${sched.name})`);
406
+ try {
407
+ if (typeof sched.handler === 'function') {
408
+ const res = await sched.handler();
409
+ sched.lastRun = new Date().toISOString();
410
+ // Recompute nextRun
411
+ if (sched.schedule) {
412
+ try {
413
+ const job = new Cron(sched.schedule, { timezone: sched.timezone || 'UTC' });
414
+ const n = job.nextRun();
415
+ sched.nextRun = n ? n.toISOString() : null;
416
+ } catch {
417
+ sched.nextRun = null;
418
+ }
419
+ }
420
+ logLine(`[${new Date().toISOString()}] done internal ${sched.id} → ${res.ok ? 'success' : 'error'}`);
421
+ fired.push({ projectId: 'internal', scheduleId: sched.id, ok: res.ok, runResult: res });
422
+ }
423
+ } catch (err) {
424
+ logLine(`[${new Date().toISOString()}] failed internal ${sched.id}: ${err.message}`);
425
+ fired.push({ projectId: 'internal', scheduleId: sched.id, ok: false, runResult: { error: err.message } });
426
+ }
427
+ }
428
+
303
429
  if (skipped > 0) {
304
430
  logLine(`[schedule] tick: ${skipped} skipped by budget pre-flight`);
305
431
  }
@@ -40,6 +40,7 @@ import { V2EventBus } from './v2-event-bus.mjs';
40
40
  import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
41
41
  import { createV2Router } from './routes-v2/index.mjs';
42
42
  import { counter, gauge, render as renderMetrics } from './metrics.mjs';
43
+ import { warn } from './logger.mjs';
43
44
 
44
45
  // v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
45
46
  // registry; render() emits the text exposition format consumed by
@@ -190,6 +191,13 @@ export async function createServer({
190
191
  // `unmatched` for 404s so the cardinality stays bounded. Hooked off
191
192
  // `finish` so the final status code is captured even after error
192
193
  // handlers run.
194
+ //
195
+ // v4.8.0 — When the status is 429, also emit a structured warning
196
+ // so operators can see when an IP is being throttled. The scope
197
+ // comes from the X-RateLimit-Scope header set by the per-route
198
+ // limiter middleware (chat / event); if no scope header is
199
+ // present, log the 429 with scope=unknown (it may be an upstream
200
+ // 429 proxied from the opencode plugin).
193
201
  app.use((req, res, next) => {
194
202
  res.on('finish', () => {
195
203
  const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
@@ -198,6 +206,15 @@ export async function createServer({
198
206
  route,
199
207
  status: String(res.statusCode),
200
208
  });
209
+ if (res.statusCode === 429) {
210
+ const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
211
+ warn('rate_limit_exceeded', {
212
+ ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
213
+ scope,
214
+ method: req.method,
215
+ route,
216
+ });
217
+ }
201
218
  });
202
219
  next();
203
220
  });
@@ -715,6 +715,13 @@ function Shell() {
715
715
 
716
716
  return (
717
717
  <div className="app" data-layout={layout} data-active-tab={activeTab}>
718
+ {/* v4.8.0 — WCAG 2.2 AA: skip-to-main-content link. Hidden visually
719
+ until focused; clicking moves keyboard focus past the nav into
720
+ the active view. Screen readers and keyboard users can jump
721
+ past the topbar + sidebar without tabbing through every tab. */}
722
+ <a className="skip-to-main" href="#main-content">
723
+ Skip to main content
724
+ </a>
718
725
  {showHeader && (
719
726
  <Topbar
720
727
  activeTab={activeTab}
@@ -769,7 +776,7 @@ function Shell() {
769
776
  onTabChange={setActiveTab}
770
777
  />
771
778
  )}
772
- <main className="content">
779
+ <main className="content" id="main-content" tabIndex={-1}>
773
780
  {bootError && (
774
781
  <div className="boot-error">
775
782
  <h2>Dashboard unavailable</h2>