diff --git a/create-a-container/client/src/app/Header.tsx b/create-a-container/client/src/app/Header.tsx index 2f5f81d8..8e937609 100644 --- a/create-a-container/client/src/app/Header.tsx +++ b/create-a-container/client/src/app/Header.tsx @@ -10,12 +10,12 @@ import { DropdownItem, DropdownSeparator, SidebarMobileToggle, - useCommandPalette, useThemeContext, } from '@mieweb/ui'; -import { LogOut, Moon, Search, Settings, Sun } from 'lucide-react'; +import { LogOut, Moon, Settings, Sun } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useLogoutMutation, useSession } from '@/lib/auth'; +import { NotificationsBell } from './NotificationsBell'; function initialsOf(name: string | undefined) { if (!name) return '?'; @@ -27,7 +27,6 @@ function initialsOf(name: string | undefined) { export function AppTopHeader() { const { data: session } = useSession(); const { resolvedTheme, setTheme } = useThemeContext(); - const palette = useCommandPalette(); const logout = useLogoutMutation(); const navigate = useNavigate(); @@ -48,11 +47,7 @@ export function AppTopHeader() { - } - label="Search (⌘K)" - onClick={palette.open} - /> + : } label={isDark ? 'Switch to light theme' : 'Switch to dark theme'} diff --git a/create-a-container/client/src/app/NotificationsBell.tsx b/create-a-container/client/src/app/NotificationsBell.tsx new file mode 100644 index 00000000..53b51ed5 --- /dev/null +++ b/create-a-container/client/src/app/NotificationsBell.tsx @@ -0,0 +1,110 @@ +/** + * Notification bell — replaces the (unused) header search button. Polls the + * owner-scoped notification feed, shows an unread badge, and renders the shared + * @mieweb/ui NotificationCenter (list, empty/loading states, mark-(all-)read) + * inside a dropdown. + * + * The feed is returned unacked-first + newest-first, so the unread count is + * derived directly from the fetched rows (capped by the server's page size — + * displayed as "N+" when saturated). + */ +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AppHeaderIconButton, + Dropdown, + NotificationCenter, + type Notification as UINotification, +} from '@mieweb/ui'; +import { Bell } from 'lucide-react'; +import { keys, queries } from '@/lib/queries'; +import type { AppNotification, NotificationSeverity } from '@/lib/types'; + +// The server caps the list; keep this in sync with the API default (20). +const PAGE_SIZE = 20; + +// Map our event severity onto the shared component's icon `type` and `priority`. +const SEVERITY_TYPE: Record = { + info: 'system', + warning: 'alert', + critical: 'alert', +}; +const SEVERITY_PRIORITY: Record = { + info: 'normal', + warning: 'high', + critical: 'urgent', +}; + +/** A concise title, e.g. "critical · freeze — pve1 · CT 392". */ +function titleFor(n: AppNotification): string { + const lead = [n.severity, n.action].filter(Boolean).join(' · '); + const target = [n.node, n.ctid ? `CT ${n.ctid}` : null].filter(Boolean).join(' · '); + return target ? `${lead} — ${target}` : lead; +} + +/** Adapt an API notification to the @mieweb/ui NotificationCenter shape. */ +function toUINotification(n: AppNotification): UINotification { + return { + id: String(n.id), + type: SEVERITY_TYPE[n.severity], + title: titleFor(n), + message: n.message, + timestamp: n.eventAt || n.createdAt, + isRead: !!n.acknowledgedAt, + senderName: n.source, + priority: SEVERITY_PRIORITY[n.severity], + }; +} + +export function NotificationsBell() { + const qc = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: keys.notifications(), + queryFn: queries.listNotifications, + refetchInterval: 30000, + }); + + const notifications = useMemo(() => data ?? [], [data]); + const uiNotifications = useMemo(() => notifications.map(toUINotification), [notifications]); + const unreadCount = notifications.filter((n) => !n.acknowledgedAt).length; + + const invalidate = () => qc.invalidateQueries({ queryKey: keys.notifications() }); + + const ackOne = useMutation({ + mutationFn: (id: number) => queries.ackNotification(id), + onSuccess: invalidate, + }); + const ackAll = useMutation({ + mutationFn: () => queries.ackAllNotifications(), + onSuccess: invalidate, + }); + + return ( + } + label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'} + badge={unreadCount > 0 ? unreadCount : undefined} + /> + } + > + ackOne.mutate(Number(id))} + onMarkAllRead={() => ackAll.mutate()} + // Sits inside the Dropdown's own panel; drop the component's border and + // shadow so it renders flush rather than as a card-within-a-card. + // (className is appended after the component's base classes, so these + // utilities win.) + className="!border-0 !shadow-none" + /> + + ); +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index 29b75e56..f8d282f2 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -6,6 +6,7 @@ import { api } from './api'; import type { Agent, ApiKey, + AppNotification, Container, ContainerMetadata, ContainerNewBootstrap, @@ -37,6 +38,7 @@ export const keys = { externalDomains: () => ['external-domains'] as const, externalDomain: (id: number | string) => ['external-domains', String(id)] as const, agents: () => ['agents'] as const, + notifications: () => ['notifications'] as const, users: () => ['users'] as const, user: (uid: number | string) => ['users', String(uid)] as const, groups: () => ['groups'] as const, @@ -65,6 +67,14 @@ export const queries = { // Agents listAgents: () => api.get('/api/v1/agents'), + // Notifications (owner-scoped). The list is unacked-first + newest-first, so + // the bell derives its unread badge from the returned rows. + listNotifications: () => api.get('/api/v1/notifications'), + ackNotification: (id: number) => + api.post(`/api/v1/notifications/${id}/ack`), + ackAllNotifications: () => + api.post<{ acknowledged: number }>('/api/v1/notifications/all/ack'), + // Containers listContainers: ( siteId: number | string, diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index fbb7d30d..a31955ac 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -50,6 +50,29 @@ export interface Agent { secondsSinceCheckin: number | null; } +export type NotificationSeverity = 'info' | 'warning' | 'critical'; + +/** + * A node-side event surfaced in the notification bell. Named AppNotification to + * avoid clashing with the DOM's global `Notification` type. + */ +export interface AppNotification { + id: number; + source: string; + severity: NotificationSeverity; + node: string | null; + ctid: string | null; + owner: string | null; + action: string | null; + message: string; + evidence: Record | null; + eventAt: string | null; + acknowledgedAt: string | null; + acknowledgedBy: string | null; + createdAt: string; + updatedAt: string; +} + export interface ExternalDomain { id: number; name: string; diff --git a/create-a-container/docs/notification-webhook.md b/create-a-container/docs/notification-webhook.md new file mode 100644 index 00000000..17a93f93 --- /dev/null +++ b/create-a-container/docs/notification-webhook.md @@ -0,0 +1,91 @@ +# Notification webhook contract + +Node-side tools (e.g. `lxc-oomd`) report events to the `create-a-container` +manager over an authenticated HTTP webhook. The manager persists each event and +surfaces it to the owning user in the UI notification bell. + +This is the integration target for the `lxc-oomd` packaged hook script (see +issue #431 / #434). + +## Endpoint + +``` +POST /api/v1/notifications +Authorization: Bearer +Content-Type: application/json +``` + +Authentication is an **admin API key** (mint one on the API Keys page as an +admin user). Non-admin keys receive `403`; missing/invalid keys receive `401`. + +The read/acknowledge endpoints (`GET /api/v1/notifications`, +`POST /api/v1/notifications/all/ack`, `POST /api/v1/notifications/{id}/ack`) are +owner-scoped and used by the web UI; the hook script only needs the `POST` +above. + +## Payload + +```jsonc +{ + "source": "lxc-oomd", // required. Emitter name. + "severity": "critical", // required. one of: info | warning | critical + "node": "opensource-phxdc-pve1", // optional. hypervisor node name + "ctid": 392, // optional. container id (int or string) + "owner": "mbachelder", // optional. Users.uid; see "Owner resolution" + "action": "freeze", // optional. freeze | kill | bump | quarantine | detect | ... + "message": "CT 392 frozen: memory PSI full avg10=83 for 45s", // required + "evidence": { // optional. free-form structured detail + "psiFullAvg10": 83.6, + "refaultRate": "...", + "topProcs": ["..."] + }, + "ts": 1771234560 // optional. epoch seconds; stored as eventAt +} +``` + +Field notes: + +- **`severity`** must be one of `info`, `warning`, `critical`. Anything else is + a `400`. +- **`ctid`** accepts a number or string and is stored as a string (matching the + hypervisor container id). +- **`ts`** is epoch **seconds** (not milliseconds); the manager records it as + `eventAt`. When omitted, `eventAt` is null and only the server receipt time + (`createdAt`) is available. +- **`evidence`** is stored verbatim as JSON; put anything not covered by a + first-class field here. +- Unknown top-level keys are ignored. + +## Owner resolution + +Visibility in the UI is per-owner: a user sees only notifications whose `owner` +matches their username (`Users.uid`). + +- If the payload includes `owner`, it is used as-is. +- If `owner` is omitted, the manager resolves it best-effort from `node` + + `ctid` via the Containers table (node name → node, then `containerId` on that + node → owning `username`). +- If resolution fails (unknown node/ctid), the event is **still stored** with a + null owner. It will not appear in any user's bell. Prefer sending an explicit + `owner`, or a resolvable `node` + `ctid`, when the event should reach a human. + +## Example + +```sh +curl -sS -X POST https:///api/v1/notifications \ + -H "Authorization: Bearer $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "source": "lxc-oomd", + "severity": "critical", + "node": "opensource-phxdc-pve1", + "ctid": 392, + "action": "freeze", + "message": "CT 392 frozen: memory PSI full avg10=83 for 45s", + "evidence": { "psiFullAvg10": 83.6 }, + "ts": '"$(date +%s)"' + }' +``` + +A `201` response returns the persisted notification (including the assigned +`id` and the resolved `owner`) so the caller can log it. diff --git a/create-a-container/migrations/20260731120000-create-notifications.js b/create-a-container/migrations/20260731120000-create-notifications.js new file mode 100644 index 00000000..1ac6b77e --- /dev/null +++ b/create-a-container/migrations/20260731120000-create-notifications.js @@ -0,0 +1,47 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Notifications', { + id: { allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER }, + // Emitter of the event, e.g. 'lxc-oomd'. + source: { type: Sequelize.STRING, allowNull: false }, + // 'info' | 'warning' | 'critical' (validated in the app layer). + severity: { type: Sequelize.STRING, allowNull: false }, + // Hypervisor node name the event originated on. + node: { type: Sequelize.STRING, allowNull: true }, + // Container id on the hypervisor (CTID/VMID). STRING to match + // Containers.containerId, which was widened to a string. + ctid: { type: Sequelize.STRING, allowNull: true }, + // Owning user (Users.uid). Drives per-user UI visibility. Resolved from + // node+ctid at ingest time when the payload omits it. + owner: { type: Sequelize.STRING, allowNull: true }, + // 'freeze' | 'kill' | 'bump' | 'quarantine' | 'detect' | ... (free-form). + action: { type: Sequelize.STRING, allowNull: true }, + message: { type: Sequelize.TEXT, allowNull: false }, + // Arbitrary structured evidence blob (PSI figures, top procs, ...). + evidence: { type: Sequelize.JSON, allowNull: true }, + // When the event happened on the node (from the payload's `ts` epoch). + // Distinct from createdAt, which is when the manager recorded it. + eventAt: { type: Sequelize.DATE, allowNull: true }, + // Ack state. NULL = unread/unacknowledged. + acknowledgedAt: { type: Sequelize.DATE, allowNull: true }, + acknowledgedBy: { type: Sequelize.STRING, allowNull: true }, + createdAt: { allowNull: false, type: Sequelize.DATE }, + updatedAt: { allowNull: false, type: Sequelize.DATE }, + }); + + // Owner-scoped, unacked-first listing is the hot path (the bell dropdown). + await queryInterface.addIndex('Notifications', ['owner', 'acknowledgedAt'], { + name: 'notifications_owner_acknowledged_at', + }); + await queryInterface.addIndex('Notifications', ['createdAt'], { + name: 'notifications_created_at', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('Notifications'); + }, +}; diff --git a/create-a-container/models/notification.js b/create-a-container/models/notification.js new file mode 100644 index 00000000..dec2a00f --- /dev/null +++ b/create-a-container/models/notification.js @@ -0,0 +1,39 @@ +'use strict'; + +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class Notification extends Model { + static associate(/* models */) { + // Intentionally unassociated. `owner` is a loose reference to Users.uid + // and `node`/`ctid` loosely reference a Container; events must survive + // deletion of the container or user they describe, so no FK constraints. + } + } + + Notification.init( + { + source: { type: DataTypes.STRING, allowNull: false }, + severity: { type: DataTypes.STRING, allowNull: false }, + node: { type: DataTypes.STRING, allowNull: true }, + ctid: { type: DataTypes.STRING, allowNull: true }, + owner: { type: DataTypes.STRING, allowNull: true }, + action: { type: DataTypes.STRING, allowNull: true }, + message: { type: DataTypes.TEXT, allowNull: false }, + evidence: { type: DataTypes.JSON, allowNull: true }, + eventAt: { type: DataTypes.DATE, allowNull: true }, + acknowledgedAt: { type: DataTypes.DATE, allowNull: true }, + acknowledgedBy: { type: DataTypes.STRING, allowNull: true }, + }, + { + sequelize, + modelName: 'Notification', + indexes: [ + { fields: ['owner', 'acknowledgedAt'], name: 'notifications_owner_acknowledged_at' }, + { fields: ['createdAt'], name: 'notifications_created_at' }, + ], + } + ); + + return Notification; +}; diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index e44204a6..f33dde14 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -33,6 +33,7 @@ tags: - name: Containers - name: Nodes - name: Agents + - name: Notifications - name: Jobs - name: External Domains - name: Users @@ -73,6 +74,23 @@ components: required: [data] properties: data: {} + Notification: + type: object + properties: + id: { type: integer } + source: { type: string, description: Emitter of the event, e.g. lxc-oomd } + severity: { type: string, enum: [info, warning, critical] } + node: { type: string, nullable: true, description: Hypervisor node name } + ctid: { type: string, nullable: true, description: Container id on the hypervisor (CTID/VMID) } + owner: { type: string, nullable: true, description: Owning user (Users.uid); drives per-user visibility } + action: { type: string, nullable: true, description: 'freeze | kill | bump | quarantine | detect | ...' } + message: { type: string } + evidence: { type: object, nullable: true, additionalProperties: true } + eventAt: { type: string, format: date-time, nullable: true, description: When the event happened on the node } + acknowledgedAt: { type: string, format: date-time, nullable: true } + acknowledgedBy: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } Site: type: object properties: @@ -582,6 +600,75 @@ paths: summary: Current status of all agents (admin) responses: { '200': { description: 'List of agents with lastCheckinAt, services and server-computed secondsSinceCheckin' } } + /notifications: + post: + tags: [Notifications] + summary: Ingest a node-side event (admin API key) + description: | + Inbound webhook for node-side tools (e.g. lxc-oomd) to report events. + Requires an admin API key (Bearer). The event is persisted and surfaced + per owner in the UI notification bell. When `owner` is omitted it is + resolved best-effort from `node` + `ctid` via the Containers table; an + unresolved owner is stored as null and appears in no user's feed. `ts` + is epoch seconds and is recorded as `eventAt`. See + docs/notification-webhook.md for the full contract. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [source, severity, message] + properties: + source: { type: string, example: lxc-oomd } + severity: { type: string, enum: [info, warning, critical] } + node: { type: string, nullable: true, example: opensource-phxdc-pve1 } + ctid: + nullable: true + oneOf: [{ type: integer }, { type: string }] + example: 392 + owner: { type: string, nullable: true, example: mbachelder } + action: { type: string, nullable: true, example: freeze } + message: { type: string, example: 'CT 392 frozen: memory PSI full avg10=83 for 45s' } + evidence: { type: object, additionalProperties: true, nullable: true } + ts: { type: integer, description: Epoch seconds when the event occurred, example: 1771234560 } + responses: + '201': { description: Created — the persisted notification } + '400': { description: Invalid payload } + '401': { description: Missing/invalid credentials } + '403': { description: Non-admin credentials } + get: + tags: [Notifications] + summary: List the current user's notifications + description: | + Owner-scoped feed for the notification bell. Returned unacknowledged + first, then newest first, so the client can derive its unread badge from + the response. + parameters: + - in: query + name: limit + required: false + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + '200': { description: Array of the caller's notifications } + '401': { description: Authentication required } + /notifications/all/ack: + post: + tags: [Notifications] + summary: Acknowledge all of the caller's notifications + responses: + '200': { description: '`{ data: { acknowledged: } }`' } + '401': { description: Authentication required } + /notifications/{id}/ack: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + post: + tags: [Notifications] + summary: Acknowledge one notification the caller owns + responses: + '200': { description: The updated notification } + '401': { description: Authentication required } + '404': { description: Not found or not owned by the caller } + /external-domains: get: { tags: [External Domains], responses: { '200': { description: List } } } post: { tags: [External Domains], responses: { '201': { description: Created (admin) } } } diff --git a/create-a-container/resources/notifications/__tests__/notifications.api.test.js b/create-a-container/resources/notifications/__tests__/notifications.api.test.js new file mode 100644 index 00000000..6368bfd0 --- /dev/null +++ b/create-a-container/resources/notifications/__tests__/notifications.api.test.js @@ -0,0 +1,308 @@ +/** + * Integration tests for /api/v1/notifications — the node-side event queue. + * + * Ingest (POST /) is admin-API-key only; the list/ack endpoints are owner + * scoped. Auth uses Bearer API keys: apiAuth accepts them and csrfGuard exempts + * Bearer-only requests, so no session/CSRF choreography is needed. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../tests/helpers/app'); +const { + sequelize, + resetDb, + closeDb, + createUser, + createApiKey, +} = require('../../../tests/helpers/db'); +const { Site, Node, Container, Notification } = require('../../../models'); + +const SERIALIZED_KEYS = [ + 'id', + 'source', + 'severity', + 'node', + 'ctid', + 'owner', + 'action', + 'message', + 'evidence', + 'eventAt', + 'acknowledgedAt', + 'acknowledgedBy', + 'createdAt', + 'updatedAt', +]; + +const VALID_PAYLOAD = { + source: 'lxc-oomd', + severity: 'critical', + node: 'opensource-phxdc-pve1', + ctid: 392, + owner: 'alice', + action: 'freeze', + message: 'CT 392 frozen: memory PSI full avg10=83 for 45s', + evidence: { psiFullAvg10: 83.6, topProcs: ['node', 'chrome'] }, + ts: 1771234560, +}; + +describe('/api/v1/notifications', () => { + let app; + let admin; + let adminKey; // admin Bearer credential (may ingest) + let alice; + let aliceKey; + let bob; + let bobKey; + + beforeAll(async () => { + // resetDb() before buildApp() — see apikeys suite for the ordering rationale. + await resetDb(); + app = buildApp(); + await createUser({ uid: 'firstadmin' }); // burns the auto-admin promotion + admin = await createUser({ uid: 'admin', admin: true }); + alice = await createUser({ uid: 'alice' }); + bob = await createUser({ uid: 'bob' }); + adminKey = await createApiKey(admin, 'admin key'); + aliceKey = await createApiKey(alice, 'alice key'); + bobKey = await createApiKey(bob, 'bob key'); + }); + + afterAll(async () => { + await closeDb(); + }); + + beforeEach(async () => { + await Notification.destroy({ where: {}, truncate: true, restartIdentity: true }); + }); + + describe('POST / (ingest)', () => { + test('401 without credentials', async () => { + const res = await request(app).post('/api/v1/notifications').send(VALID_PAYLOAD); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('unauthorized'); + }); + + test('403 with a non-admin key', async () => { + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(aliceKey.plainKey)) + .send(VALID_PAYLOAD); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('forbidden'); + }); + + test('201 persists the payload and maps ts -> eventAt', async () => { + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(adminKey.plainKey)) + .send(VALID_PAYLOAD); + expect(res.status).toBe(201); + expect(Object.keys(res.body.data).sort()).toEqual([...SERIALIZED_KEYS].sort()); + expect(res.body.data).toMatchObject({ + source: 'lxc-oomd', + severity: 'critical', + node: 'opensource-phxdc-pve1', + ctid: '392', // normalised to string + owner: 'alice', + action: 'freeze', + evidence: { psiFullAvg10: 83.6 }, + }); + expect(new Date(res.body.data.eventAt).getTime()).toBe(1771234560 * 1000); + + const stored = await Notification.findByPk(res.body.data.id); + expect(stored.acknowledgedAt).toBeNull(); + }); + + test('400 on invalid severity', async () => { + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(adminKey.plainKey)) + .send({ ...VALID_PAYLOAD, severity: 'emergency' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('invalid_request'); + expect(res.body.error.fields).toHaveProperty('severity'); + }); + + test('400 when required fields are missing', async () => { + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(adminKey.plainKey)) + .send({ source: 'lxc-oomd' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('invalid_request'); + }); + + test('resolves owner from node + ctid when omitted', async () => { + const site = await Site.create({ name: 'site-a', internalDomain: 'a.test' }); + const node = await Node.create({ name: 'pve-resolve', siteId: site.id }); + await Container.create({ + hostname: 'ct-resolve', + username: 'bob', + nodeId: node.id, + siteId: site.id, + containerId: '4001', + }); + + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(adminKey.plainKey)) + .send({ + source: 'lxc-oomd', + severity: 'warning', + node: 'pve-resolve', + ctid: 4001, + message: 'detect only', + }); + expect(res.status).toBe(201); + expect(res.body.data.owner).toBe('bob'); + }); + + test('stores a null owner when node + ctid do not resolve', async () => { + const res = await request(app) + .post('/api/v1/notifications') + .set(...bearer(adminKey.plainKey)) + .send({ + source: 'lxc-oomd', + severity: 'warning', + node: 'no-such-node', + ctid: 9999, + message: 'orphan event', + }); + expect(res.status).toBe(201); + expect(res.body.data.owner).toBeNull(); + }); + }); + + describe('GET / (owner-scoped list)', () => { + beforeEach(async () => { + await Notification.bulkCreate([ + { source: 's', severity: 'critical', owner: 'alice', message: 'a1' }, + { source: 's', severity: 'warning', owner: 'alice', message: 'a2', acknowledgedAt: new Date(), acknowledgedBy: 'alice' }, + { source: 's', severity: 'warning', owner: 'bob', message: 'b1' }, + ]); + }); + + test('401 without credentials', async () => { + const res = await request(app).get('/api/v1/notifications'); + expect(res.status).toBe(401); + }); + + test('returns only the caller’s notifications', async () => { + const res = await request(app) + .get('/api/v1/notifications') + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data.every((n) => n.owner === 'alice')).toBe(true); + }); + + test('unacknowledged sort before acknowledged', async () => { + const res = await request(app) + .get('/api/v1/notifications') + .set(...bearer(aliceKey.plainKey)); + expect(res.body.data[0].message).toBe('a1'); // unacked + expect(res.body.data[1].message).toBe('a2'); // acked + }); + + test('a user with no notifications gets an empty list', async () => { + const other = await createUser({ uid: 'carol' }); + const carolKey = await createApiKey(other); + const res = await request(app) + .get('/api/v1/notifications') + .set(...bearer(carolKey.plainKey)); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + test('honours the limit query param', async () => { + const res = await request(app) + .get('/api/v1/notifications?limit=1') + .set(...bearer(aliceKey.plainKey)); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].message).toBe('a1'); + }); + }); + + describe('POST /:id/ack', () => { + let aliceNote; + + beforeEach(async () => { + aliceNote = await Notification.create({ + source: 's', + severity: 'critical', + owner: 'alice', + message: 'ack me', + }); + }); + + test('acknowledges a notification the caller owns', async () => { + const res = await request(app) + .post(`/api/v1/notifications/${aliceNote.id}/ack`) + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(200); + expect(res.body.data.acknowledgedAt).not.toBeNull(); + expect(res.body.data.acknowledgedBy).toBe('alice'); + }); + + test('is idempotent', async () => { + await request(app) + .post(`/api/v1/notifications/${aliceNote.id}/ack`) + .set(...bearer(aliceKey.plainKey)); + const first = await Notification.findByPk(aliceNote.id); + const firstAt = first.acknowledgedAt.getTime(); + + const res = await request(app) + .post(`/api/v1/notifications/${aliceNote.id}/ack`) + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(200); + const second = await Notification.findByPk(aliceNote.id); + expect(second.acknowledgedAt.getTime()).toBe(firstAt); // unchanged + }); + + test('404 when acking someone else’s notification', async () => { + const res = await request(app) + .post(`/api/v1/notifications/${aliceNote.id}/ack`) + .set(...bearer(bobKey.plainKey)); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('not_found'); + }); + + test('400 on a non-integer id', async () => { + const res = await request(app) + .post('/api/v1/notifications/not-a-number/ack') + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(400); + }); + }); + + describe('POST /all/ack', () => { + test('acknowledges only the caller’s unacked notifications', async () => { + await Notification.bulkCreate([ + { source: 's', severity: 'critical', owner: 'alice', message: 'a1' }, + { source: 's', severity: 'warning', owner: 'alice', message: 'a2' }, + { source: 's', severity: 'warning', owner: 'bob', message: 'b1' }, + ]); + + const res = await request(app) + .post('/api/v1/notifications/all/ack') + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(200); + expect(res.body.data.acknowledged).toBe(2); + + const bobUnacked = await Notification.count({ + where: { owner: 'bob', acknowledgedAt: null }, + }); + expect(bobUnacked).toBe(1); // bob's untouched + }); + + test('"all" is not treated as an id', async () => { + // Regression guard for router ordering: /all/ack must match before /:id/ack. + const res = await request(app) + .post('/api/v1/notifications/all/ack') + .set(...bearer(aliceKey.plainKey)); + expect(res.status).toBe(200); + expect(res.body.data).toHaveProperty('acknowledged'); + }); + }); +}); diff --git a/create-a-container/resources/notifications/__tests__/service.test.js b/create-a-container/resources/notifications/__tests__/service.test.js new file mode 100644 index 00000000..7802eaad --- /dev/null +++ b/create-a-container/resources/notifications/__tests__/service.test.js @@ -0,0 +1,81 @@ +/** + * Unit tests for the notifications service — the wire-payload coercions that + * are easiest to pin below the HTTP layer. + */ + +const { resetDb, closeDb } = require('../../../tests/helpers/db'); +const { Site, Node, Container, Notification } = require('../../../models'); +const svc = require('../service'); + +describe('notifications service', () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await closeDb(); + }); + + test('ingest maps ts (epoch seconds) to eventAt', async () => { + const n = await svc.ingest({ + source: 'lxc-oomd', + severity: 'critical', + message: 'x', + ts: 1771234560, + }); + expect(n.eventAt.getTime()).toBe(1771234560 * 1000); + }); + + test('ingest leaves eventAt null when ts is absent', async () => { + const n = await svc.ingest({ source: 's', severity: 'info', message: 'x' }); + expect(n.eventAt).toBeNull(); + }); + + test('ingest coerces ctid to a string', async () => { + const n = await svc.ingest({ source: 's', severity: 'info', message: 'x', ctid: '392' }); + expect(n.ctid).toBe('392'); + }); + + test('ingest keeps an explicit owner without touching the container table', async () => { + const n = await svc.ingest({ + source: 's', + severity: 'info', + message: 'x', + owner: 'alice', + node: 'unknown', + ctid: 1, + }); + expect(n.owner).toBe('alice'); + }); + + test('ingest resolves owner from node + ctid when omitted', async () => { + const site = await Site.create({ name: 'site', internalDomain: 's.test' }); + const node = await Node.create({ name: 'pve1', siteId: site.id }); + await Container.create({ + hostname: 'ct1', + username: 'dave', + nodeId: node.id, + siteId: site.id, + containerId: '500', + }); + + const n = await svc.ingest({ + source: 's', + severity: 'warning', + message: 'x', + node: 'pve1', + ctid: 500, + }); + expect(n.owner).toBe('dave'); + }); + + test('acknowledgeAll reports the number acked and skips already-acked rows', async () => { + await Notification.bulkCreate([ + { source: 's', severity: 'info', owner: 'eve', message: '1' }, + { source: 's', severity: 'info', owner: 'eve', message: '2' }, + { source: 's', severity: 'info', owner: 'eve', message: '3', acknowledgedAt: new Date() }, + ]); + const result = await svc.acknowledgeAll('eve'); + expect(result.acknowledged).toBe(2); + }); +}); diff --git a/create-a-container/resources/notifications/controller.js b/create-a-container/resources/notifications/controller.js new file mode 100644 index 00000000..5ee787b9 --- /dev/null +++ b/create-a-container/resources/notifications/controller.js @@ -0,0 +1,28 @@ +const svc = require('./service'); +const { serializeNotification } = require('./serializer'); +const { asyncHandler, ok, created } = require('../../middlewares/api'); + +// Inbound webhook. Auth is admin-API-key (enforced in the router). Persists the +// event and echoes it back so callers can log the assigned id. +const create = asyncHandler(async (req, res) => { + const notification = await svc.ingest(req.validated.body); + return created(res, serializeNotification(notification)); +}); + +// Owner-scoped list for the current user's bell. +const list = asyncHandler(async (req, res) => { + const notifications = await svc.listForOwner(req.session.user, req.validated.query.limit); + return ok(res, notifications.map(serializeNotification)); +}); + +const acknowledge = asyncHandler(async (req, res) => { + const notification = await svc.acknowledge(req.session.user, req.validated.params.id); + return ok(res, serializeNotification(notification)); +}); + +const acknowledgeAll = asyncHandler(async (req, res) => { + const result = await svc.acknowledgeAll(req.session.user); + return ok(res, result); +}); + +module.exports = { create, list, acknowledge, acknowledgeAll }; diff --git a/create-a-container/resources/notifications/repository.js b/create-a-container/resources/notifications/repository.js new file mode 100644 index 00000000..ce21c35f --- /dev/null +++ b/create-a-container/resources/notifications/repository.js @@ -0,0 +1,80 @@ +const { Notification, Container } = require('../../models'); + +// Columns returned to the UI. Kept explicit so a future internal-only column +// doesn't leak through the serializer by accident. +const LIST_ATTRS = [ + 'id', + 'source', + 'severity', + 'node', + 'ctid', + 'owner', + 'action', + 'message', + 'evidence', + 'eventAt', + 'acknowledgedAt', + 'acknowledgedBy', + 'createdAt', + 'updatedAt', +]; + +async function create(fields) { + const created = await Notification.create(fields); + // Reload so DB-side defaults (acknowledgedAt/acknowledgedBy = null) are + // populated on the instance; a freshly built instance leaves them undefined, + // which would drop those keys from the serialized JSON. + return created.reload(); +} + +// Owner-scoped listing: unacknowledged first, then newest first. This ordering +// guarantees the bell's unread items sit at the top of the page, so a badge +// computed from the first `limit` rows stays accurate up to that limit. +async function findAllForOwner(owner, limit) { + return Notification.findAll({ + where: { owner }, + attributes: LIST_ATTRS, + order: [ + // acknowledgedAt IS NULL sorts before non-null in both Postgres and + // SQLite once expressed as a boolean (false < true). + [require('sequelize').literal('"acknowledgedAt" IS NOT NULL'), 'ASC'], + ['createdAt', 'DESC'], + ], + limit, + }); +} + +async function findForOwner(id, owner) { + return Notification.findOne({ where: { id, owner }, attributes: LIST_ATTRS }); +} + +async function acknowledgeAllForOwner(owner, by, at) { + const [count] = await Notification.update( + { acknowledgedAt: at, acknowledgedBy: by }, + { where: { owner, acknowledgedAt: null } } + ); + return count; +} + +// Best-effort owner resolution when the webhook payload omits `owner`. Maps a +// node name + container id back to the owning user via the Containers table. +// Returns null when it can't be resolved (unknown node/ctid, or ambiguous). +async function resolveOwner(node, ctid) { + if (!node || !ctid) return null; + const { Node } = require('../../models'); + const nodeRow = await Node.findOne({ where: { name: node }, attributes: ['id'] }); + if (!nodeRow) return null; + const container = await Container.findOne({ + where: { nodeId: nodeRow.id, containerId: String(ctid) }, + attributes: ['username'], + }); + return container ? container.username : null; +} + +module.exports = { + create, + findAllForOwner, + findForOwner, + acknowledgeAllForOwner, + resolveOwner, +}; diff --git a/create-a-container/resources/notifications/router.js b/create-a-container/resources/notifications/router.js new file mode 100644 index 00000000..5d27e4e8 --- /dev/null +++ b/create-a-container/resources/notifications/router.js @@ -0,0 +1,35 @@ +/** + * /api/v1/notifications — node-side event queue. + * + * POST / inbound webhook (admin API key): persist a structured event. + * GET / owner-scoped list for the current user's bell dropdown. + * POST /all/ack acknowledge all of the caller's notifications. + * POST /:id/ack acknowledge one notification the caller owns. + * + * Node-side tools (e.g. lxc-oomd) POST events with an admin API key; the web app + * surfaces them per owner. See docs/notification-webhook.md for the contract. + */ + +const express = require('express'); +const { apiAuth, apiAdmin } = require('../../middlewares/api'); +const { validate } = require('../../middlewares/validate'); +const { createNotification, idParam, listQuery } = require('./validator'); +const ctrl = require('./controller'); + +const router = express.Router(); + +// Ingest: admin API key only. A remote node authenticates with a Bearer key; +// the Bearer-without-cookie path is exempt from csrfGuard, so this works even +// though the router is mounted after the app-level CSRF guard. +router.post('/', apiAuth, apiAdmin, validate(createNotification), ctrl.create); + +// Everything below is owner-scoped and available to any authenticated caller. +router.use(apiAuth); + +router.get('/', validate({ query: listQuery }), ctrl.list); +// Registered before "/:id/ack" so the literal "all" segment isn't captured as +// an id (which would fail the integer coercion in idParam). +router.post('/all/ack', ctrl.acknowledgeAll); +router.post('/:id/ack', validate({ params: idParam }), ctrl.acknowledge); + +module.exports = router; diff --git a/create-a-container/resources/notifications/serializer.js b/create-a-container/resources/notifications/serializer.js new file mode 100644 index 00000000..0d74f19e --- /dev/null +++ b/create-a-container/resources/notifications/serializer.js @@ -0,0 +1,21 @@ +/** Notification row -> API JSON. */ +function serializeNotification(n) { + return { + id: n.id, + source: n.source, + severity: n.severity, + node: n.node, + ctid: n.ctid, + owner: n.owner, + action: n.action, + message: n.message, + evidence: n.evidence, + eventAt: n.eventAt, + acknowledgedAt: n.acknowledgedAt, + acknowledgedBy: n.acknowledgedBy, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + }; +} + +module.exports = { serializeNotification }; diff --git a/create-a-container/resources/notifications/service.js b/create-a-container/resources/notifications/service.js new file mode 100644 index 00000000..a21a2805 --- /dev/null +++ b/create-a-container/resources/notifications/service.js @@ -0,0 +1,57 @@ +const repo = require('./repository'); +const { ApiError } = require('../../middlewares/api'); + +// Persist an inbound webhook event. Coerces the wire payload into the stored +// shape: ts (epoch seconds) -> eventAt (Date), and resolves the owner from +// node+ctid when the caller didn't supply one. Owner resolution is best-effort +// and never fails ingest — an event with an unresolved owner is still stored +// (it just won't surface in any user's bell until/unless owner is set). +async function ingest(payload) { + let owner = payload.owner ?? null; + if (!owner) { + try { + owner = await repo.resolveOwner(payload.node, payload.ctid); + } catch (err) { + // Resolution is a convenience, not a requirement. Log and move on. + console.error('Notification owner resolution failed:', err); + owner = null; + } + } + + return repo.create({ + source: payload.source, + severity: payload.severity, + node: payload.node ?? null, + ctid: payload.ctid ?? null, + owner, + action: payload.action ?? null, + message: payload.message, + evidence: payload.evidence ?? null, + eventAt: + payload.ts === null || payload.ts === undefined ? null : new Date(payload.ts * 1000), + }); +} + +async function listForOwner(owner, limit) { + return repo.findAllForOwner(owner, limit); +} + +/** Acknowledge a single notification the caller owns. Idempotent. */ +async function acknowledge(owner, id) { + const notification = await repo.findForOwner(id, owner); + if (!notification) throw new ApiError(404, 'not_found', 'Notification not found'); + if (!notification.acknowledgedAt) { + notification.acknowledgedAt = new Date(); + notification.acknowledgedBy = owner; + await notification.save({ fields: ['acknowledgedAt', 'acknowledgedBy'] }); + } + return notification; +} + +/** Acknowledge all of the caller's unacknowledged notifications. */ +async function acknowledgeAll(owner) { + const count = await repo.acknowledgeAllForOwner(owner, owner, new Date()); + return { acknowledged: count }; +} + +module.exports = { ingest, listForOwner, acknowledge, acknowledgeAll }; diff --git a/create-a-container/resources/notifications/validator.js b/create-a-container/resources/notifications/validator.js new file mode 100644 index 00000000..905f644c --- /dev/null +++ b/create-a-container/resources/notifications/validator.js @@ -0,0 +1,36 @@ +const { z } = require('zod'); + +// Inbound webhook payload (see docs/notification-webhook.md). Mirrors the +// contract in issue #434. Unknown keys are stripped; anything that isn't +// explicitly typed here belongs in `evidence`. +// +// ctid accepts a number or string and is normalised to a string so it lines up +// with Containers.containerId (widened to STRING). ts is epoch seconds; the +// service converts it to eventAt. +const createNotification = z.object({ + source: z.string().min(1).max(255), + severity: z.enum(['info', 'warning', 'critical']), + node: z.string().max(255).nullish(), + ctid: z + .union([z.number().int(), z.string().max(255)]) + .transform((v) => (v === null || v === undefined ? v : String(v))) + .nullish(), + owner: z.string().max(255).nullish(), + action: z.string().max(255).nullish(), + message: z.string().min(1).max(4000), + evidence: z.record(z.string(), z.unknown()).nullish(), + ts: z.number().int().nonnegative().nullish(), +}); + +// Notification ids are auto-increment integer PKs. Coerce so "/:id" (a string +// from the URL) parses, and reject non-integers before they reach the DB. +const idParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +// Listing controls for the bell dropdown. Defaults keep the payload small. +const listQuery = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +module.exports = { createNotification, idParam, listQuery }; diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index b345b1dc..eb83aa46 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -94,6 +94,7 @@ router.use('/apikeys', require('../../../resources/apikeys/router')); router.use('/settings', require('./settings')); router.use('/jobs', require('./jobs')); router.use('/resource-requests', require('./resource-requests')); +router.use('/notifications', require('../../../resources/notifications/router')); // Final error handler — must come after all routes router.use(jsonErrorHandler);