feat: add platform notification core

This commit is contained in:
Codex 2026-05-26 18:01:39 +03:00
parent c1ac8b1a10
commit e605e95920
17 changed files with 1865 additions and 5 deletions

View File

@ -31,8 +31,11 @@ Git repo:
- `docs/MIGRATION_PLANE_USER.md` - `docs/MIGRATION_PLANE_USER.md`
- `infra/README.md` - `infra/README.md`
- `packages/auth-sdk/README.md` - `packages/auth-sdk/README.md`
- `services/notification-core/README.md`
- `tasks/CODEX_PLATFORM_AUTH_TASK.md` - `tasks/CODEX_PLATFORM_AUTH_TASK.md`
## Базовое правило ## Базовое правило
Plane не переносится внутрь Launcher. Launcher не становится владельцем таблиц Plane. Authentik становится единым Identity Provider, а приложения сохраняют собственные доменные БД и роли. Plane не переносится внутрь Launcher. Launcher не становится владельцем таблиц Plane. Authentik становится единым Identity Provider, а приложения сохраняют собственные доменные БД и роли.
Notification Core живёт в `services/notification-core` как отдельный платформенный сервис с собственным Postgres. Он не использует Authentik DB: Authentik отвечает за identity/session, Notification Core отвечает за events/deliveries/read-state.

View File

@ -38,6 +38,14 @@ Hosted Authentik login используется только как времен
- backend-интеграция с Authentik API как внутренняя sync/projection; - backend-интеграция с Authentik API как внутренняя sync/projection;
- audit log админских действий. - audit log админских действий.
`Notification Core` является платформенным владельцем уведомлений:
- принимает факты от Hub, Engine, Operational Core и будущих сервисов;
- хранит `notification_events` как факт произошедшего события;
- хранит `notification_deliveries` как адресную доставку на surface `hub`, `engine` и будущие поверхности;
- хранит server-side read-state, не завязанный на localStorage браузера;
- не использует Authentik DB и не становится владельцем доменных заявок Hub или Engine.
`Task Manager / Plane` остается отдельным приложением: `Task Manager / Plane` остается отдельным приложением:
- собственная БД; - собственная БД;
@ -83,6 +91,7 @@ NODEDC/
```text ```text
launcher_db -> clients, users, memberships, groups, invites, app registry, access model, profiles, audit launcher_db -> clients, users, memberships, groups, invites, app registry, access model, profiles, audit
authentik_db -> identity/session/OIDC projection from Launcher authentik_db -> identity/session/OIDC projection from Launcher
notification_db -> notification_events, notification_deliveries, read-state
plane_db -> workspace, projects, tasks, comments, app roles plane_db -> workspace, projects, tasks, comments, app roles
future_app_db -> доменная логика будущих приложений future_app_db -> доменная логика будущих приложений
``` ```
@ -91,6 +100,8 @@ future_app_db -> доменная логика будущих приложе
Launcher является источником бизнес-пользователя. Authentik хранит техническую identity, необходимую для SSO, но не заменяет Launcher в администрировании клиентов, команд, доступов и профиля. Launcher является источником бизнес-пользователя. Authentik хранит техническую identity, необходимую для SSO, но не заменяет Launcher в администрировании клиентов, команд, доступов и профиля.
Notification recipient определяется как распознанный platform subject. Для email без platform user record delivery не создается: source flow может хранить pending intent/invite, а delivery появляется после регистрации/создания platform identity. Pending access-request user, который уже может войти в Hub и видеть витрину, считается допустимым recipient для `hub` delivery. Surface-specific delivery, например `engine`, создается только когда пользователь имеет доступ к этой surface.
## App standalone rule ## App standalone rule
Подключаемые приложения не должны становиться невынимаемыми частями NODE.DC. Подключаемые приложения не должны становиться невынимаемыми частями NODE.DC.

View File

@ -46,3 +46,9 @@ SESSION_SECRET=change-me-generate-with-infra-scripts-init-dev-env
NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
COOKIE_DOMAIN=.local.nodedc COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false COOKIE_SECURE=false
# notification core
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185

View File

@ -16,6 +16,8 @@ services:
depends_on: depends_on:
authentik-server: authentik-server:
condition: service_started condition: service_started
notification-core:
condition: service_started
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -87,9 +89,47 @@ services:
- authentik-certs:/certs - authentik-certs:/certs
- ./authentik/custom-templates:/templates - ./authentik/custom-templates:/templates
notification-postgres:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications}
POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:-nodedc_notifications}
POSTGRES_USER: ${NOTIFICATION_PG_USER:-nodedc_notifications}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- notification-database:/var/lib/postgresql/data
notification-core:
image: nodedc/notification-core:local
build:
context: ../services/notification-core
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 5185
DATABASE_URL: postgres://${NOTIFICATION_PG_USER:-nodedc_notifications}:${NOTIFICATION_PG_PASS:-nodedc_notifications}@notification-postgres:5432/${NOTIFICATION_PG_DB:-nodedc_notifications}
expose:
- "5185"
depends_on:
notification-postgres:
condition: service_healthy
volumes: volumes:
authentik-database: authentik-database:
authentik-data: authentik-data:
authentik-certs: authentik-certs:
notification-database:
caddy-data: caddy-data:
caddy-config: caddy-config:

View File

@ -51,3 +51,8 @@ NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-random-synology-secret
SESSION_SECRET=replace-with-random-synology-secret SESSION_SECRET=replace-with-random-synology-secret
COOKIE_DOMAIN=.nas.nodedc COOKIE_DOMAIN=.nas.nodedc
COOKIE_SECURE=false COOKIE_SECURE=false
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=replace-with-random-synology-secret
NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185

View File

@ -49,9 +49,9 @@ http://task.nas.nodedc:18090
## Что входит ## Что входит
- `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher и Caddy edge. - `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher, Notification Core и Caddy edge.
- `Caddyfile.http` маршрутизирует локальные `auth/auth-admin/launcher/task.nas.nodedc`, IP fallback `172.22.0.222` для Authentik Admin и внешние `id/hub/ops.nodedc.ru`. - `Caddyfile.http` маршрутизирует локальные `auth/auth-admin/launcher/task.nas.nodedc`, IP fallback `172.22.0.222` для Authentik Admin и внешние `id/hub/ops.nodedc.ru`.
- `deploy-current.sh` синхронизирует compose, Caddyfile и опционально Launcher source в NAS mount. Authentik templates синхронизируются только при явном `SYNC_AUTHENTIK_TEMPLATES=1`. - `deploy-current.sh` синхронизирует compose, Caddyfile, Notification Core source и опционально Launcher source в NAS mount. Authentik templates синхронизируются только при явном `SYNC_AUTHENTIK_TEMPLATES=1`.
- `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres. - `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres.
- Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`. - Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`.
- Ops Agents Gateway поднимается отдельным compose из `NODEDC_TASKMANAGER_CODEXAPI/docker-compose.synology.yml` на `172.22.0.222:18190`; Synology reverse proxy должен вести `ops-agents.nodedc.ru` на этот порт, а не на `18090`. - Ops Agents Gateway поднимается отдельным compose из `NODEDC_TASKMANAGER_CODEXAPI/docker-compose.synology.yml` на `172.22.0.222:18190`; Synology reverse proxy должен вести `ops-agents.nodedc.ru` на этот порт, а не на `18090`.
@ -116,6 +116,7 @@ GATEWAY_REPO=/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXA
Что синхронизируется: Что синхронизируется:
- Platform compose/Caddy. - Platform compose/Caddy.
- Notification Core source в `/volume1/docker/nodedc-platform/platform/notification-core`.
- Authentik templates только при `SYNC_AUTHENTIK_TEMPLATES=1`; по умолчанию они не трогаются, чтобы лёгкий Hub deploy не уносил экспериментальную тему/брендинг в prod. - Authentik templates только при `SYNC_AUTHENTIK_TEMPLATES=1`; по умолчанию они не трогаются, чтобы лёгкий Hub deploy не уносил экспериментальную тему/брендинг в prod.
- Launcher source в `/volume1/docker/nodedc-platform/launcher/source`. - Launcher source в `/volume1/docker/nodedc-platform/launcher/source`.
- Tasker `plane-app/docker-compose.yaml` и, если задан `TASKER_CHANGED_BASE`, только изменённые source-файлы из диапазона `TASKER_CHANGED_BASE..HEAD`. - Tasker `plane-app/docker-compose.yaml` и, если задан `TASKER_CHANGED_BASE`, только изменённые source-файлы из диапазона `TASKER_CHANGED_BASE..HEAD`.
@ -223,6 +224,12 @@ NAS_ROOT=/Volumes/docker/nodedc-platform ./infra/synology/backup-current.sh
bash /volume1/docker/nodedc-platform/backups/platform-current-YYYYMMDD-HHMMSS/run-authentik-db-dump-on-synology.sh bash /volume1/docker/nodedc-platform/backups/platform-current-YYYYMMDD-HHMMSS/run-authentik-db-dump-on-synology.sh
``` ```
Для Notification Core Postgres dump:
```bash
bash /volume1/docker/nodedc-platform/backups/platform-current-YYYYMMDD-HHMMSS/run-notification-db-dump-on-synology.sh
```
Если планируются изменения Tasker backend/schema, дополнительно выполнить: Если планируются изменения Tasker backend/schema, дополнительно выполнить:
```bash ```bash
@ -239,6 +246,7 @@ bash /volume1/docker/nodedc-platform/backups/platform-current-YYYYMMDD-HHMMSS/ru
- Собрать или загрузить `linux/amd64` images: - Собрать или загрузить `linux/amd64` images:
- `nodedc/launcher:local` - `nodedc/launcher:local`
- `nodedc/notification-core:local`
- `nodedc/plane-frontend:ru` - `nodedc/plane-frontend:ru`
- `nodedc/plane-admin:ru` - `nodedc/plane-admin:ru`
- `nodedc/plane-space:ru` - `nodedc/plane-space:ru`

View File

@ -22,12 +22,20 @@ echo "== launcher image build =="
cd "${LAUNCHER_SOURCE_DIR}" cd "${LAUNCHER_SOURCE_DIR}"
"${DOCKER_BIN}" build --no-cache -t nodedc/launcher:local . "${DOCKER_BIN}" build --no-cache -t nodedc/launcher:local .
echo "== notification core image build =="
cd "${PLATFORM_DIR}/notification-core"
"${DOCKER_BIN}" build --no-cache -t nodedc/notification-core:local .
echo "== platform services recreate ==" echo "== platform services recreate =="
cd "${PLATFORM_DIR}" cd "${PLATFORM_DIR}"
"${DOCKER_BIN}" compose \ "${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \ --env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \ -f "${COMPOSE_FILE}" \
up -d --force-recreate --no-deps reverse-proxy authentik-server authentik-worker launcher up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core launcher
echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25'
echo "== clear authentik global brand css ==" echo "== clear authentik global brand css =="
DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh" DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh"

View File

@ -44,6 +44,7 @@ rsync_dir "${NAS_ROOT}/launcher/server-storage/" "${BACKUP_DIR}/files/launcher/s
rsync_dir "${NAS_ROOT}/launcher/uploads/" "${BACKUP_DIR}/files/launcher/uploads/" rsync_dir "${NAS_ROOT}/launcher/uploads/" "${BACKUP_DIR}/files/launcher/uploads/"
rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authentik/custom-templates/" rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authentik/custom-templates/"
rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/" rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/"
rsync_dir "${NAS_ROOT}/platform/notification-core/" "${BACKUP_DIR}/files/platform/notification-core/"
rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/" rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/"
rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/" rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/"
@ -69,6 +70,7 @@ Contains:
- Launcher uploads: launcher/uploads - Launcher uploads: launcher/uploads
- Authentik custom templates: authentik/custom-templates - Authentik custom templates: authentik/custom-templates
- Platform runtime config: platform/.env.synology, compose, Caddyfile - Platform runtime config: platform/.env.synology, compose, Caddyfile
- Notification Core source/config: platform/notification-core, platform compose/env
- Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override - Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override
- Ops Agents Gateway runtime config: ops-agents/.env, compose - Ops Agents Gateway runtime config: ops-agents/.env, compose
@ -110,6 +112,31 @@ echo "authentik-db-dump-ok: \${BACKUP_DIR}/authentik-postgres.dump"
EOF EOF
chmod +x "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh" chmod +x "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
cat > "${BACKUP_DIR}/run-notification-db-dump-on-synology.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")"
cd "${NAS_PLATFORM_DIR}"
sudo "${DOCKER_BIN}" compose \\
--env-file "${ENV_FILE}" \\
-f "${COMPOSE_FILE}" \\
exec -T notification-postgres \\
sh -lc 'pg_dump -U "\${POSTGRES_USER:-nodedc_notifications}" -d "\${POSTGRES_DB:-nodedc_notifications}" --format=custom --no-owner --no-acl' \\
> "\${BACKUP_DIR}/notification-postgres.dump"
sudo "${DOCKER_BIN}" compose \\
--env-file "${ENV_FILE}" \\
-f "${COMPOSE_FILE}" \\
exec -T notification-postgres \\
sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\
< "\${BACKUP_DIR}/notification-postgres.dump"
echo "notification-db-dump-ok: \${BACKUP_DIR}/notification-postgres.dump"
EOF
chmod +x "${BACKUP_DIR}/run-notification-db-dump-on-synology.sh"
cat > "${BACKUP_DIR}/run-tasker-db-dump-on-synology.sh" <<EOF cat > "${BACKUP_DIR}/run-tasker-db-dump-on-synology.sh" <<EOF
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@ -164,11 +191,13 @@ chmod +x "${BACKUP_DIR}/run-ops-agents-db-dump-on-synology.sh"
find "${BACKUP_DIR}" -name @eaDir -prune -o -type d -exec chmod 700 {} \; find "${BACKUP_DIR}" -name @eaDir -prune -o -type d -exec chmod 700 {} \;
find "${BACKUP_DIR}" -name @eaDir -prune -o -type f -exec chmod 600 {} \; find "${BACKUP_DIR}" -name @eaDir -prune -o -type f -exec chmod 600 {} \;
chmod 700 "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh" chmod 700 "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
chmod 700 "${BACKUP_DIR}/run-notification-db-dump-on-synology.sh"
chmod 700 "${BACKUP_DIR}/run-tasker-db-dump-on-synology.sh" chmod 700 "${BACKUP_DIR}/run-tasker-db-dump-on-synology.sh"
chmod 700 "${BACKUP_DIR}/run-ops-agents-db-dump-on-synology.sh" chmod 700 "${BACKUP_DIR}/run-ops-agents-db-dump-on-synology.sh"
if [[ -x "${DOCKER_BIN}" && "${NAS_ROOT}" == /volume1/* ]]; then if [[ -x "${DOCKER_BIN}" && "${NAS_ROOT}" == /volume1/* ]]; then
"${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh" "${BACKUP_DIR}/run-authentik-db-dump-on-synology.sh"
"${BACKUP_DIR}/run-notification-db-dump-on-synology.sh"
else else
cat <<EOF cat <<EOF
file-backup-ok: ${BACKUP_DIR} file-backup-ok: ${BACKUP_DIR}
@ -178,6 +207,8 @@ Run on Synology:
bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-authentik-db-dump-on-synology.sh bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-authentik-db-dump-on-synology.sh
bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-notification-db-dump-on-synology.sh
# If Tasker database exists and backend/schema changes are planned: # If Tasker database exists and backend/schema changes are planned:
bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-tasker-db-dump-on-synology.sh bash /volume1/docker/nodedc-platform/backups/$(basename "${BACKUP_DIR}")/run-tasker-db-dump-on-synology.sh

View File

@ -35,6 +35,14 @@ rsync -av \
"${PLATFORM_REPO}/infra/synology/clear-authentik-brand-css.sh" \ "${PLATFORM_REPO}/infra/synology/clear-authentik-brand-css.sh" \
"${NAS_ROOT}/platform/" "${NAS_ROOT}/platform/"
mkdir -p "${NAS_ROOT}/platform/notification-core"
rsync -av --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/notification-core/" \
"${NAS_ROOT}/platform/notification-core/"
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
mkdir -p "${NAS_ROOT}/authentik/custom-templates" mkdir -p "${NAS_ROOT}/authentik/custom-templates"
rsync -av --delete \ rsync -av --delete \
@ -185,14 +193,14 @@ cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \ sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate --no-deps launcher up -d --build --force-recreate --no-deps notification-core launcher
Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes: Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes:
sudo /usr/local/bin/docker compose \ sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate --no-deps reverse-proxy authentik-server authentik-worker launcher up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker notification-core launcher
After Authentik template rollout, clear global Brand custom CSS from the live DB. After Authentik template rollout, clear global Brand custom CSS from the live DB.
NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css: NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css:

View File

@ -17,6 +17,8 @@ services:
condition: service_started condition: service_started
launcher: launcher:
condition: service_started condition: service_started
notification-core:
condition: service_started
extra_hosts: extra_hosts:
- "id.nodedc.ru:host-gateway" - "id.nodedc.ru:host-gateway"
- "hub.nodedc.ru:host-gateway" - "hub.nodedc.ru:host-gateway"
@ -38,6 +40,7 @@ services:
NODEDC_LAUNCHER_UPLOADS_DIR: /app/server/storage/uploads NODEDC_LAUNCHER_UPLOADS_DIR: /app/server/storage/uploads
NODEDC_AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 NODEDC_AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
expose: expose:
- "5173" - "5173"
volumes: volumes:
@ -55,6 +58,48 @@ services:
- identity - identity
- engine - engine
notification-postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications}
POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:?notification database password required}
POSTGRES_USER: ${NOTIFICATION_PG_USER:-nodedc_notifications}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- notification-database:/var/lib/postgresql/data
networks:
- identity
notification-core:
image: nodedc/notification-core:local
build:
context: ./notification-core
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 5185
DATABASE_URL: postgres://${NOTIFICATION_PG_USER:-nodedc_notifications}:${NOTIFICATION_PG_PASS:?notification database password required}@notification-postgres:5432/${NOTIFICATION_PG_DB:-nodedc_notifications}
expose:
- "5185"
ports:
- "${NOTIFICATION_CORE_HOST_BIND:-127.0.0.1:5185}:5185"
depends_on:
notification-postgres:
condition: service_healthy
networks:
- identity
- engine
postgresql-authentik: postgresql-authentik:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
@ -140,5 +185,6 @@ volumes:
authentik-database: authentik-database:
authentik-data: authentik-data:
authentik-certs: authentik-certs:
notification-database:
caddy-data: caddy-data:
caddy-config: caddy-config:

View File

@ -51,6 +51,10 @@ echo "== launcher -> authentik api check =="
exit 1 exit 1
' '
echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25'
echo "== auth flow check ==" echo "== auth flow check =="
auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)" auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)"
printf '%s' "$auth_flow" \ printf '%s' "$auth_flow" \

View File

@ -0,0 +1,4 @@
node_modules/
npm-debug.log
.env
.env.*

View File

@ -0,0 +1,23 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV PORT=5185
WORKDIR /app
COPY --from=deps /app/package.json /app/package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
USER node
EXPOSE 5185
CMD ["node", "src/server.mjs"]

View File

@ -0,0 +1,36 @@
# NODE.DC Notification Core
Platform-owned notification service for NODE.DC.
It owns:
- `notification_events`: immutable facts from Hub, Engine, Operational Core and future services.
- `notification_deliveries`: per-recipient, per-surface delivery/read state.
It does not use Authentik DB. Authentik remains the identity/session provider next to this service, not the notification data owner.
## API
Internal producer:
```text
POST /internal/notifications/events
```
Consumer surface API:
```text
GET /api/notifications?surface=hub|engine
POST /api/notifications/:deliveryId/read
POST /api/notifications/read-all
GET /api/notifications/stream
```
All endpoints require the shared internal token. App BFFs pass the resolved platform subject through:
```text
X-NODEDC-User-Id
X-NODEDC-User-Email
```
The recipient rule is platform-subject based: deliveries are created only for known NODE.DC subjects. An unknown email should be held as a pending intent/invite by the source workflow, not inserted as a delivery.

View File

@ -0,0 +1,990 @@
{
"name": "@nodedc/notification-core",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/notification-core",
"version": "0.1.0",
"dependencies": {
"express": "^5.2.1",
"pg": "^8.18.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "@nodedc/notification-core",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"dev": "node --watch src/server.mjs"
},
"dependencies": {
"express": "^5.2.1",
"pg": "^8.18.0"
}
}

View File

@ -0,0 +1,623 @@
import express from "express";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { Pool } from "pg";
const config = readConfig();
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
const app = express();
const httpServer = createServer(app);
const streamClients = new Set();
app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));
app.get("/healthz", asyncRoute(async (_req, res) => {
await pool.query("select 1");
res.json({
ok: true,
service: "nodedc-notification-core",
database: "ready",
internalApiConfigured: Boolean(config.internalAccessToken),
});
}));
app.post("/internal/notifications/events", requireInternalApi, asyncRoute(async (req, res) => {
const command = sanitizeEventCommand(req.body);
const result = await createNotificationEvent(command);
res.status(result.created ? 201 : 200).json(result);
}));
app.get("/api/notifications", requireInternalApi, asyncRoute(async (req, res) => {
const subject = getRequestSubject(req);
const surface = sanitizeSurface(req.query.surface || "hub");
const limit = sanitizeLimit(req.query.limit);
const unreadOnly = req.query.unreadOnly === "1" || req.query.unreadOnly === "true";
const deliveries = await listDeliveries({ subject, surface, limit, unreadOnly });
res.json({ ok: true, surface, deliveries });
}));
app.post("/api/notifications/:deliveryId/read", requireInternalApi, asyncRoute(async (req, res) => {
const subject = getRequestSubject(req);
const deliveryId = sanitizeUuid(req.params.deliveryId, "deliveryId");
const delivery = await markDeliveryRead({ subject, deliveryId });
if (!delivery) {
res.status(404).json({ ok: false, error: "notification_delivery_not_found" });
return;
}
broadcastDeliveryUpdate("notification.delivery.read", delivery);
res.json({ ok: true, delivery });
}));
app.post("/api/notifications/read-all", requireInternalApi, asyncRoute(async (req, res) => {
const subject = getRequestSubject(req);
const surface = sanitizeSurface(req.query.surface || req.body?.surface || "hub");
const result = await markAllDeliveriesRead({ subject, surface });
broadcastSubjectUpdate("notification.deliveries.read-all", { subject, surface, count: result.count });
res.json({ ok: true, surface, count: result.count });
}));
app.get("/api/notifications/stream", requireInternalApi, (req, res) => {
const subject = getRequestSubject(req);
const surface = sanitizeSurface(req.query.surface || "hub");
const client = { id: randomUUID(), res, subject, surface };
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
writeSse(res, "notification.ready", { ok: true, surface });
const keepAlive = setInterval(() => {
res.write(": keep-alive\n\n");
}, 30000);
streamClients.add(client);
req.on("close", () => {
clearInterval(keepAlive);
streamClients.delete(client);
});
});
app.use((error, _req, res, _next) => {
const status = Number(error?.status || 500);
const message = error instanceof Error ? error.message : "internal_error";
res.status(status >= 400 && status < 600 ? status : 500).json({ ok: false, error: message });
});
await migrate();
httpServer.listen(config.port, "0.0.0.0", () => {
console.log(`NODE.DC Notification Core listening on http://0.0.0.0:${config.port}`);
});
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function createNotificationEvent(command) {
const client = await pool.connect();
try {
await client.query("begin");
let event = null;
if (command.idempotencyKey) {
const existing = await client.query(
"select * from notification_events where idempotency_key = $1",
[command.idempotencyKey]
);
if (existing.rowCount > 0) {
event = existing.rows[0];
const deliveries = await loadDeliveriesForEvent(client, event.id);
await client.query("commit");
return { ok: true, created: false, idempotent: true, event: toEvent(event), deliveries };
}
}
const eventId = randomUUID();
const insertedEvent = await client.query(
`insert into notification_events (
id,
type,
source_service,
actor_user_id,
actor_email,
subject_type,
subject_id,
payload,
idempotency_key
) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
returning *`,
[
eventId,
command.type,
command.sourceService,
command.actor.userId,
command.actor.email,
command.subject.type,
command.subject.id,
JSON.stringify(command.payload),
command.idempotencyKey,
]
);
event = insertedEvent.rows[0];
const deliveries = [];
for (const delivery of command.deliveries) {
const insertedDelivery = await client.query(
`insert into notification_deliveries (
id,
event_id,
recipient_user_id,
recipient_email,
surface,
title,
body,
meta,
status,
actionable,
action_type,
action_url,
entity_type,
entity_id
) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14)
returning *`,
[
randomUUID(),
event.id,
delivery.recipientUserId,
delivery.recipientEmail,
delivery.surface,
delivery.title,
delivery.body,
JSON.stringify(delivery.meta),
delivery.status,
delivery.actionable,
delivery.actionType,
delivery.actionUrl,
delivery.entityType,
delivery.entityId,
]
);
deliveries.push(toDelivery({ ...insertedDelivery.rows[0], event_type: event.type, source_service: event.source_service, event_payload: event.payload }));
}
await client.query("commit");
for (const delivery of deliveries) {
broadcastDeliveryUpdate("notification.delivery.created", delivery);
}
return { ok: true, created: true, event: toEvent(event), deliveries };
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
}
async function listDeliveries({ subject, surface, limit, unreadOnly }) {
const values = [surface, subject.userId, subject.email, limit];
const unreadSql = unreadOnly ? "and d.status = 'unread'" : "";
const result = await pool.query(
`select
d.*,
e.type as event_type,
e.source_service,
e.payload as event_payload
from notification_deliveries d
join notification_events e on e.id = d.event_id
where d.surface = $1
and (
($2::text is not null and d.recipient_user_id = $2)
or ($3::text is not null and lower(d.recipient_email) = lower($3))
)
${unreadSql}
order by d.created_at desc
limit $4`,
values
);
return result.rows.map(toDelivery);
}
async function markDeliveryRead({ subject, deliveryId }) {
const result = await pool.query(
`update notification_deliveries
set status = 'read', read_at = coalesce(read_at, now()), updated_at = now()
where id = $1
and status = 'unread'
and (
($2::text is not null and recipient_user_id = $2)
or ($3::text is not null and lower(recipient_email) = lower($3))
)
returning *`,
[deliveryId, subject.userId, subject.email]
);
if (result.rowCount > 0) {
const delivery = await loadDelivery(result.rows[0].id);
return delivery;
}
const existing = await loadDeliveryForSubject({ subject, deliveryId });
return existing?.status === "read" ? existing : null;
}
async function markAllDeliveriesRead({ subject, surface }) {
const result = await pool.query(
`update notification_deliveries
set status = 'read', read_at = coalesce(read_at, now()), updated_at = now()
where surface = $1
and status = 'unread'
and (
($2::text is not null and recipient_user_id = $2)
or ($3::text is not null and lower(recipient_email) = lower($3))
)`,
[surface, subject.userId, subject.email]
);
return { count: result.rowCount };
}
async function loadDeliveriesForEvent(client, eventId) {
const result = await client.query(
`select
d.*,
e.type as event_type,
e.source_service,
e.payload as event_payload
from notification_deliveries d
join notification_events e on e.id = d.event_id
where d.event_id = $1
order by d.created_at asc`,
[eventId]
);
return result.rows.map(toDelivery);
}
async function loadDelivery(deliveryId) {
const result = await pool.query(
`select
d.*,
e.type as event_type,
e.source_service,
e.payload as event_payload
from notification_deliveries d
join notification_events e on e.id = d.event_id
where d.id = $1`,
[deliveryId]
);
return result.rows[0] ? toDelivery(result.rows[0]) : null;
}
async function loadDeliveryForSubject({ subject, deliveryId }) {
const result = await pool.query(
`select
d.*,
e.type as event_type,
e.source_service,
e.payload as event_payload
from notification_deliveries d
join notification_events e on e.id = d.event_id
where d.id = $1
and (
($2::text is not null and d.recipient_user_id = $2)
or ($3::text is not null and lower(d.recipient_email) = lower($3))
)`,
[deliveryId, subject.userId, subject.email]
);
return result.rows[0] ? toDelivery(result.rows[0]) : null;
}
async function migrate() {
await pool.query(`
create table if not exists notification_events (
id uuid primary key,
type text not null,
source_service text not null,
actor_user_id text,
actor_email text,
subject_type text,
subject_id text,
payload jsonb not null default '{}'::jsonb,
idempotency_key text unique,
created_at timestamptz not null default now()
)
`);
await pool.query(`
create table if not exists notification_deliveries (
id uuid primary key,
event_id uuid not null references notification_events(id) on delete cascade,
recipient_user_id text,
recipient_email text,
surface text not null,
title text not null,
body text not null default '',
meta jsonb not null default '{}'::jsonb,
status text not null default 'unread',
actionable boolean not null default false,
action_type text,
action_url text,
entity_type text,
entity_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
read_at timestamptz,
constraint notification_deliveries_recipient_check
check (recipient_user_id is not null or recipient_email is not null),
constraint notification_deliveries_status_check
check (status in ('unread', 'read', 'archived'))
)
`);
await pool.query("create index if not exists notification_deliveries_recipient_user_idx on notification_deliveries(recipient_user_id, surface, status, created_at desc)");
await pool.query("create index if not exists notification_deliveries_recipient_email_idx on notification_deliveries(lower(recipient_email), surface, status, created_at desc)");
await pool.query("create index if not exists notification_events_type_idx on notification_events(type, created_at desc)");
}
function sanitizeEventCommand(payload) {
const type = requireNonEmptyString(payload?.type, "type");
const sourceService = normalizeKey(payload?.sourceService || payload?.source_service || "platform");
const deliveries = Array.isArray(payload?.deliveries) ? payload.deliveries.map(sanitizeDeliveryCommand).filter(Boolean) : [];
if (deliveries.length === 0) {
throw badRequest("deliveries_required");
}
return {
type,
sourceService,
actor: sanitizeActor(payload?.actor),
subject: sanitizeSubject(payload?.subject),
payload: isPlainObject(payload?.payload) ? payload.payload : {},
deliveries,
idempotencyKey: optionalString(payload?.idempotencyKey || payload?.idempotency_key),
};
}
function sanitizeDeliveryCommand(payload) {
const recipientUserId = optionalString(payload?.recipientUserId || payload?.recipient_user_id);
const recipientEmail = normalizeEmail(payload?.recipientEmail || payload?.recipient_email);
if (!recipientUserId && !recipientEmail) {
return null;
}
return {
recipientUserId,
recipientEmail,
surface: sanitizeSurface(payload?.surface || "hub"),
title: requireNonEmptyString(payload?.title, "delivery.title"),
body: optionalString(payload?.body) || "",
meta: isPlainObject(payload?.meta) ? payload.meta : {},
status: sanitizeStatus(payload?.status || "unread"),
actionable: payload?.actionable === true,
actionType: optionalString(payload?.actionType || payload?.action_type),
actionUrl: optionalString(payload?.actionUrl || payload?.action_url),
entityType: optionalString(payload?.entityType || payload?.entity_type),
entityId: optionalString(payload?.entityId || payload?.entity_id),
};
}
function sanitizeActor(payload) {
const actor = isPlainObject(payload) ? payload : {};
return {
userId: optionalString(actor.userId || actor.user_id),
email: normalizeEmail(actor.email),
};
}
function sanitizeSubject(payload) {
const subject = isPlainObject(payload) ? payload : {};
return {
type: optionalString(subject.type),
id: optionalString(subject.id),
};
}
function getRequestSubject(req) {
const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId);
const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email);
if (!userId && !email) {
throw badRequest("notification_subject_required");
}
return { userId, email };
}
function toEvent(row) {
return {
id: row.id,
type: row.type,
sourceService: row.source_service,
actorUserId: row.actor_user_id,
actorEmail: row.actor_email,
subjectType: row.subject_type,
subjectId: row.subject_id,
payload: row.payload || {},
idempotencyKey: row.idempotency_key,
createdAt: toIso(row.created_at),
};
}
function toDelivery(row) {
return {
id: row.id,
eventId: row.event_id,
eventType: row.event_type,
sourceService: row.source_service,
recipientUserId: row.recipient_user_id,
recipientEmail: row.recipient_email,
surface: row.surface,
title: row.title,
body: row.body,
meta: row.meta || {},
status: row.status,
unread: row.status === "unread",
actionable: row.actionable,
actionType: row.action_type,
actionUrl: row.action_url,
entityType: row.entity_type,
entityId: row.entity_id,
eventPayload: row.event_payload || {},
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
readAt: toIso(row.read_at),
};
}
function broadcastDeliveryUpdate(eventName, delivery) {
for (const client of streamClients) {
if (!deliveryMatchesClient(delivery, client)) continue;
writeSse(client.res, eventName, { delivery });
}
}
function broadcastSubjectUpdate(eventName, payload) {
for (const client of streamClients) {
if (client.surface !== payload.surface) continue;
if (!subjectsMatch(client.subject, payload.subject)) continue;
writeSse(client.res, eventName, payload);
}
}
function deliveryMatchesClient(delivery, client) {
if (delivery.surface !== client.surface) return false;
return subjectsMatch(client.subject, {
userId: delivery.recipientUserId,
email: delivery.recipientEmail,
});
}
function subjectsMatch(left, right) {
return Boolean(
(left.userId && right.userId && left.userId === right.userId) ||
(left.email && right.email && left.email.toLowerCase() === right.email.toLowerCase())
);
}
function writeSse(res, event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function requireInternalApi(req, res, next) {
if (!config.internalAccessToken) {
res.status(503).json({ ok: false, error: "notification_core_internal_token_not_configured" });
return;
}
const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] || "";
const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : "";
const requestToken = bearerToken || headerToken;
if (!safeTokenEquals(requestToken, config.internalAccessToken)) {
res.status(401).json({ ok: false, error: "notification_core_unauthorized" });
return;
}
next();
}
function safeTokenEquals(actual, expected) {
if (!actual || !expected) return false;
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
if (actualBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(actualBuffer, expectedBuffer);
}
function sanitizeSurface(value) {
const surface = normalizeKey(value || "hub");
if (!["hub", "engine", "operational-core"].includes(surface)) {
throw badRequest("unsupported_notification_surface");
}
return surface;
}
function sanitizeStatus(value) {
const status = normalizeKey(value || "unread");
if (!["unread", "read", "archived"].includes(status)) {
throw badRequest("unsupported_notification_status");
}
return status;
}
function sanitizeLimit(value) {
const limit = Number(value || 100);
if (!Number.isFinite(limit)) return 100;
return Math.min(Math.max(Math.trunc(limit), 1), 200);
}
function sanitizeUuid(value, name) {
const text = optionalString(value);
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) {
throw badRequest(`${name}_invalid`);
}
return text;
}
function requireNonEmptyString(value, name) {
const text = optionalString(value);
if (!text) throw badRequest(`${name}_required`);
return text;
}
function optionalString(value) {
if (typeof value !== "string") return null;
const text = value.trim();
return text ? text : null;
}
function normalizeEmail(value) {
const text = optionalString(value);
return text ? text.toLowerCase() : null;
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
}
function isPlainObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function toIso(value) {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function badRequest(message) {
const error = new Error(message);
error.status = 400;
return error;
}
function asyncRoute(handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
function readConfig() {
const databaseUrl =
process.env.DATABASE_URL ||
process.env.NOTIFICATION_DATABASE_URL ||
"postgres://nodedc_notifications:nodedc_notifications@localhost:5432/nodedc_notifications";
return {
port: Number(process.env.PORT || process.env.NOTIFICATION_CORE_PORT || "5185"),
databaseUrl,
databasePoolSize: Number(process.env.NOTIFICATION_DATABASE_POOL_SIZE || "10"),
internalAccessToken:
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
"",
};
}
async function shutdown() {
try {
httpServer.close();
await pool.end();
} finally {
process.exit(0);
}
}