feat: add shared ai workspace assistant service

This commit is contained in:
Codex 2026-06-09 11:43:58 +03:00
parent 8154890ac9
commit 65da785a9d
13 changed files with 2116 additions and 3 deletions

View File

@ -32,6 +32,8 @@ Git repo:
- `infra/README.md` - `infra/README.md`
- `packages/auth-sdk/README.md` - `packages/auth-sdk/README.md`
- `services/notification-core/README.md` - `services/notification-core/README.md`
- `services/ai-workspace-assistant/README.md`
- `services/ai-workspace-hub/README.md`
- `tasks/CODEX_PLATFORM_AUTH_TASK.md` - `tasks/CODEX_PLATFORM_AUTH_TASK.md`
## Базовое правило ## Базовое правило
@ -39,3 +41,5 @@ Git repo:
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. Notification Core живёт в `services/notification-core` как отдельный платформенный сервис с собственным Postgres. Он не использует Authentik DB: Authentik отвечает за identity/session, Notification Core отвечает за events/deliveries/read-state.
AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента.

View File

@ -52,3 +52,10 @@ NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env NOTIFICATION_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185 NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185
# AI Workspace Assistant shared registry/conversations
AI_WORKSPACE_PG_DB=nodedc_ai_workspace
AI_WORKSPACE_PG_USER=nodedc_ai_workspace
AI_WORKSPACE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
AI_WORKSPACE_ASSISTANT_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082

View File

@ -20,6 +20,8 @@ services:
condition: service_started condition: service_started
ai-workspace-hub: ai-workspace-hub:
condition: service_started condition: service_started
ai-workspace-assistant:
condition: service_started
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -128,6 +130,46 @@ services:
notification-postgres: notification-postgres:
condition: service_healthy condition: service_healthy
ai-workspace-postgres:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}
POSTGRES_USER: ${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- ai-workspace-database:/var/lib/postgresql/data
ai-workspace-assistant:
image: nodedc/ai-workspace-assistant:local
build:
context: ../services/ai-workspace-assistant
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 18082
DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token}
expose:
- "18082"
ports:
- "${AI_WORKSPACE_ASSISTANT_HOST_BIND:-127.0.0.1:18082}:18082"
depends_on:
ai-workspace-postgres:
condition: service_healthy
ai-workspace-hub: ai-workspace-hub:
image: nodedc/ai-workspace-hub:local image: nodedc/ai-workspace-hub:local
build: build:
@ -151,5 +193,6 @@ volumes:
authentik-data: authentik-data:
authentik-certs: authentik-certs:
notification-database: notification-database:
ai-workspace-database:
caddy-data: caddy-data:
caddy-config: caddy-config:

View File

@ -57,5 +57,11 @@ NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=replace-with-random-synology-secret NOTIFICATION_PG_PASS=replace-with-random-synology-secret
NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185 NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185
AI_WORKSPACE_PG_DB=nodedc_ai_workspace
AI_WORKSPACE_PG_USER=nodedc_ai_workspace
AI_WORKSPACE_PG_PASS=replace-with-random-synology-secret
AI_WORKSPACE_ASSISTANT_TOKEN=replace-with-random-synology-secret
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret
AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081

View File

@ -30,12 +30,16 @@ echo "== ai workspace hub image build =="
cd "${PLATFORM_DIR}/ai-workspace-hub" cd "${PLATFORM_DIR}/ai-workspace-hub"
"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-hub:local . "${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-hub:local .
echo "== ai workspace assistant image build =="
cd "${PLATFORM_DIR}/ai-workspace-assistant"
"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-assistant: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 reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-hub launcher up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-postgres ai-workspace-assistant ai-workspace-hub launcher
echo "== notification core health check ==" echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \ "${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
@ -45,6 +49,10 @@ echo "== ai workspace hub health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \ "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' 'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== ai workspace assistant health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
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

@ -51,6 +51,14 @@ rsync -av --delete \
"${PLATFORM_REPO}/services/ai-workspace-hub/" \ "${PLATFORM_REPO}/services/ai-workspace-hub/" \
"${NAS_ROOT}/platform/ai-workspace-hub/" "${NAS_ROOT}/platform/ai-workspace-hub/"
mkdir -p "${NAS_ROOT}/platform/ai-workspace-assistant"
rsync -av --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/ai-workspace-assistant/" \
"${NAS_ROOT}/platform/ai-workspace-assistant/"
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 \
@ -201,14 +209,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 --build --force-recreate --no-deps ai-workspace-hub notification-core launcher up -d --build --force-recreate --no-deps ai-workspace-hub ai-workspace-assistant 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 --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub notification-core launcher up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub ai-workspace-assistant 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

@ -21,6 +21,8 @@ services:
condition: service_started condition: service_started
ai-workspace-hub: ai-workspace-hub:
condition: service_started condition: service_started
ai-workspace-assistant:
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"
@ -43,6 +45,7 @@ services:
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 NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
expose: expose:
- "5173" - "5173"
volumes: volumes:
@ -102,6 +105,50 @@ services:
- identity - identity
- engine - engine
ai-workspace-postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:?ai workspace database password required}
POSTGRES_USER: ${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- ai-workspace-database:/var/lib/postgresql/data
networks:
- identity
ai-workspace-assistant:
image: nodedc/ai-workspace-assistant:local
build:
context: ./ai-workspace-assistant
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18082
DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:?ai workspace database password required}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
expose:
- "18082"
ports:
- "${AI_WORKSPACE_ASSISTANT_HOST_BIND:-127.0.0.1:18082}:18082"
depends_on:
ai-workspace-postgres:
condition: service_healthy
networks:
- identity
- engine
ai-workspace-hub: ai-workspace-hub:
image: nodedc/ai-workspace-hub:local image: nodedc/ai-workspace-hub:local
build: build:
@ -209,5 +256,6 @@ volumes:
authentik-data: authentik-data:
authentik-certs: authentik-certs:
notification-database: notification-database:
ai-workspace-database:
caddy-data: caddy-data:
caddy-config: caddy-config:

View File

@ -59,6 +59,10 @@ echo "== ai workspace hub health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \ "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' 'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== ai workspace assistant health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
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,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 18082
CMD ["npm", "start"]

View File

@ -0,0 +1,64 @@
# NODE.DC AI Workspace Assistant
Platform-owned shared state service for the cross-surface AI Workspace assistant.
It owns:
- user AI Workspace executors/devices and selected executor;
- assistant conversation/thread metadata;
- surface context and enabled tool packs;
- linked artifacts between Engine, Ops and future platform surfaces.
It does not replace the AI Workspace Hub. The Hub remains the thin transport/rendezvous layer for remote Codex workers.
It does not replace the legacy Ops MCP config flow:
```text
external Codex client -> ~/.codex/config.toml -> nodedc-ops-agent MCP -> Ops Agent Gateway
```
That flow remains a separate product path.
## API
All endpoints require the shared internal token. App BFFs pass the resolved platform subject through:
```text
X-NODEDC-User-Id
X-NODEDC-User-Email
```
Executor registry:
```text
GET /api/ai-workspace/assistant/v1/executors
POST /api/ai-workspace/assistant/v1/executors
PATCH /api/ai-workspace/assistant/v1/executors/:executorId
DELETE /api/ai-workspace/assistant/v1/executors/:executorId
POST /api/ai-workspace/assistant/v1/executors/:executorId/select
```
Conversations:
```text
GET /api/ai-workspace/assistant/v1/threads
POST /api/ai-workspace/assistant/v1/threads
GET /api/ai-workspace/assistant/v1/threads/:threadId
PATCH /api/ai-workspace/assistant/v1/threads/:threadId
POST /api/ai-workspace/assistant/v1/threads/:threadId/messages
GET /api/ai-workspace/assistant/v1/threads/:threadId/messages
```
Supported initial surfaces:
- `engine`
- `ops`
- `global`
Supported initial tool packs:
- `engine`
- `ops`
- `ndc-agent-core`
- `deploy`
- `docs`

View File

@ -0,0 +1,990 @@
{
"name": "@nodedc/ai-workspace-assistant",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/ai-workspace-assistant",
"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.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"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.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"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/ai-workspace-assistant",
"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,904 @@
import express from "express";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { Pool } from "pg";
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]);
const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]);
const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]);
const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
const config = readConfig();
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
const app = express();
const httpServer = createServer(app);
app.disable("x-powered-by");
app.use(express.json({ limit: "2mb" }));
app.get("/healthz", asyncRoute(async (_req, res) => {
await pool.query("select 1");
res.json({
ok: true,
service: "nodedc-ai-workspace-assistant",
database: "ready",
internalApiConfigured: Boolean(config.internalAccessToken),
});
}));
app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const [executors, settings] = await Promise.all([
listExecutors(owner),
getOwnerSettings(owner),
]);
res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executors });
}));
app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const command = sanitizeExecutorCommand(req.body, { partial: false });
const executor = await createExecutor(owner, command);
if (req.body?.select === true) {
await selectExecutor(owner, executor.id);
}
const settings = await getOwnerSettings(owner);
res.status(201).json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executor });
}));
app.patch("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const executorId = sanitizeUuid(req.params.executorId, "executorId");
const command = sanitizeExecutorCommand(req.body, { partial: true });
const executor = await updateExecutor(owner, executorId, command);
if (!executor) {
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
return;
}
res.json({ ok: true, owner: publicOwner(owner), executor });
}));
app.delete("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const executorId = sanitizeUuid(req.params.executorId, "executorId");
const deleted = await deleteExecutor(owner, executorId);
if (!deleted) {
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
return;
}
res.json({ ok: true, owner: publicOwner(owner), deleted: true });
}));
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const executorId = sanitizeUuid(req.params.executorId, "executorId");
const executor = await selectExecutor(owner, executorId);
if (!executor) {
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
return;
}
res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor });
}));
app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null;
const limit = sanitizeLimit(req.query.limit, 100, 200);
const threads = await listThreads(owner, { surface, limit });
res.json({ ok: true, owner: publicOwner(owner), threads });
}));
app.post("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const command = sanitizeThreadCommand(req.body, { partial: false });
const thread = await createThread(owner, command);
res.status(201).json({ ok: true, owner: publicOwner(owner), thread });
}));
app.get("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const threadId = sanitizeUuid(req.params.threadId, "threadId");
const thread = await getThread(owner, threadId);
if (!thread) {
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
return;
}
res.json({ ok: true, owner: publicOwner(owner), thread });
}));
app.patch("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const threadId = sanitizeUuid(req.params.threadId, "threadId");
const command = sanitizeThreadCommand(req.body, { partial: true });
const thread = await updateThread(owner, threadId, command);
if (!thread) {
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
return;
}
res.json({ ok: true, owner: publicOwner(owner), thread });
}));
app.get("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const threadId = sanitizeUuid(req.params.threadId, "threadId");
const limit = sanitizeLimit(req.query.limit, 200, 1000);
const cursor = optionalString(req.query.before);
const thread = await getThread(owner, threadId);
if (!thread) {
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
return;
}
const messages = await listThreadMessages(owner, threadId, { limit, before: cursor });
res.json({ ok: true, owner: publicOwner(owner), threadId, messages });
}));
app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => {
const owner = getRequestOwner(req);
const threadId = sanitizeUuid(req.params.threadId, "threadId");
const command = sanitizeMessageCommand(req.body);
const message = await createThreadMessage(owner, threadId, command);
if (!message) {
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
return;
}
res.status(201).json({ ok: true, owner: publicOwner(owner), message });
}));
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 AI Workspace Assistant listening on http://0.0.0.0:${config.port}`);
});
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function listExecutors(owner) {
const result = await pool.query(
`select *
from ai_workspace_executors
where owner_key = $1
order by updated_at desc, created_at desc`,
[owner.key]
);
return result.rows.map(toExecutor);
}
async function createExecutor(owner, command) {
const result = await pool.query(
`insert into ai_workspace_executors (
id,
owner_key,
owner_user_id,
owner_email,
name,
type,
connection_mode,
endpoint,
workspace_path,
agent_port,
pairing_code,
model,
account_label,
capabilities,
status,
status_detail,
last_seen_at,
metadata
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14::jsonb, $15, $16, $17, $18::jsonb
)
returning *`,
[
randomUUID(),
owner.key,
owner.userId,
owner.email,
command.name,
command.type,
command.connectionMode,
command.endpoint,
command.workspacePath,
command.agentPort,
command.pairingCode,
command.model,
command.accountLabel,
JSON.stringify(command.capabilities),
command.status,
command.statusDetail,
command.lastSeenAt,
JSON.stringify(command.metadata),
]
);
return toExecutor(result.rows[0]);
}
async function updateExecutor(owner, executorId, command) {
const fields = [];
const values = [owner.key, executorId];
const mappings = {
name: "name",
type: "type",
connectionMode: "connection_mode",
endpoint: "endpoint",
workspacePath: "workspace_path",
agentPort: "agent_port",
pairingCode: "pairing_code",
model: "model",
accountLabel: "account_label",
capabilities: "capabilities",
status: "status",
statusDetail: "status_detail",
lastSeenAt: "last_seen_at",
metadata: "metadata",
};
for (const [key, column] of Object.entries(mappings)) {
if (!Object.hasOwn(command, key)) continue;
const value = key === "capabilities" || key === "metadata" ? JSON.stringify(command[key]) : command[key];
values.push(value);
fields.push(`${column} = $${values.length}${key === "capabilities" || key === "metadata" ? "::jsonb" : ""}`);
}
if (fields.length === 0) {
return getExecutor(owner, executorId);
}
values.push(new Date());
const result = await pool.query(
`update ai_workspace_executors
set ${fields.join(", ")}, updated_at = $${values.length}
where owner_key = $1 and id = $2
returning *`,
values
);
return result.rows[0] ? toExecutor(result.rows[0]) : null;
}
async function deleteExecutor(owner, executorId) {
const client = await pool.connect();
try {
await client.query("begin");
await client.query(
`update ai_workspace_owner_settings
set selected_executor_id = null, updated_at = now()
where owner_key = $1 and selected_executor_id = $2`,
[owner.key, executorId]
);
const result = await client.query(
"delete from ai_workspace_executors where owner_key = $1 and id = $2",
[owner.key, executorId]
);
await client.query("commit");
return result.rowCount > 0;
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
}
async function getExecutor(owner, executorId) {
const result = await pool.query(
"select * from ai_workspace_executors where owner_key = $1 and id = $2",
[owner.key, executorId]
);
return result.rows[0] ? toExecutor(result.rows[0]) : null;
}
async function selectExecutor(owner, executorId) {
const executor = await getExecutor(owner, executorId);
if (!executor) return null;
await pool.query(
`insert into ai_workspace_owner_settings (
owner_key,
owner_user_id,
owner_email,
selected_executor_id
) values ($1, $2, $3, $4)
on conflict (owner_key) do update set
owner_user_id = excluded.owner_user_id,
owner_email = excluded.owner_email,
selected_executor_id = excluded.selected_executor_id,
updated_at = now()`,
[owner.key, owner.userId, owner.email, executorId]
);
return executor;
}
async function getOwnerSettings(owner) {
const result = await pool.query(
"select * from ai_workspace_owner_settings where owner_key = $1",
[owner.key]
);
return result.rows[0] ? toOwnerSettings(result.rows[0]) : { ownerKey: owner.key, selectedExecutorId: null };
}
async function listThreads(owner, { surface, limit }) {
const values = [owner.key, limit];
const surfaceSql = surface ? "and origin_surface = $3" : "";
if (surface) values.push(surface);
const result = await pool.query(
`select *
from ai_workspace_threads
where owner_key = $1
${surfaceSql}
order by updated_at desc, created_at desc
limit $2`,
values
);
return result.rows.map(toThread);
}
async function createThread(owner, command) {
const result = await pool.query(
`insert into ai_workspace_threads (
id,
owner_key,
owner_user_id,
owner_email,
title,
origin_surface,
active_context,
enabled_tool_packs,
linked_artifacts,
selected_executor_id,
lifecycle_state,
metadata
) values (
$1, $2, $3, $4, $5, $6, $7::jsonb, $8::text[], $9::jsonb, $10, $11, $12::jsonb
)
returning *`,
[
randomUUID(),
owner.key,
owner.userId,
owner.email,
command.title,
command.originSurface,
JSON.stringify(command.activeContext),
command.enabledToolPacks,
JSON.stringify(command.linkedArtifacts),
command.selectedExecutorId,
command.lifecycleState,
JSON.stringify(command.metadata),
]
);
return toThread(result.rows[0]);
}
async function getThread(owner, threadId) {
const result = await pool.query(
"select * from ai_workspace_threads where owner_key = $1 and id = $2",
[owner.key, threadId]
);
return result.rows[0] ? toThread(result.rows[0]) : null;
}
async function updateThread(owner, threadId, command) {
const fields = [];
const values = [owner.key, threadId];
const mappings = {
title: "title",
originSurface: "origin_surface",
activeContext: "active_context",
enabledToolPacks: "enabled_tool_packs",
linkedArtifacts: "linked_artifacts",
selectedExecutorId: "selected_executor_id",
lifecycleState: "lifecycle_state",
metadata: "metadata",
};
for (const [key, column] of Object.entries(mappings)) {
if (!Object.hasOwn(command, key)) continue;
const isJson = key === "activeContext" || key === "linkedArtifacts" || key === "metadata";
const isArray = key === "enabledToolPacks";
const value = isJson ? JSON.stringify(command[key]) : command[key];
values.push(value);
fields.push(`${column} = $${values.length}${isJson ? "::jsonb" : isArray ? "::text[]" : ""}`);
}
if (fields.length === 0) {
return getThread(owner, threadId);
}
values.push(new Date());
const result = await pool.query(
`update ai_workspace_threads
set ${fields.join(", ")}, updated_at = $${values.length}
where owner_key = $1 and id = $2
returning *`,
values
);
return result.rows[0] ? toThread(result.rows[0]) : null;
}
async function listThreadMessages(owner, threadId, { limit, before }) {
const values = [owner.key, threadId, limit];
const beforeSql = before ? "and m.created_at < $4::timestamptz" : "";
if (before) values.push(before);
const result = await pool.query(
`select m.*
from ai_workspace_thread_messages m
join ai_workspace_threads t on t.id = m.thread_id
where t.owner_key = $1
and m.thread_id = $2
${beforeSql}
order by m.created_at desc, m.sequence desc
limit $3`,
values
);
return result.rows.reverse().map(toThreadMessage);
}
async function createThreadMessage(owner, threadId, command) {
const client = await pool.connect();
try {
await client.query("begin");
const thread = await client.query(
"select id from ai_workspace_threads where owner_key = $1 and id = $2 for update",
[owner.key, threadId]
);
if (thread.rowCount === 0) {
await client.query("rollback");
return null;
}
const sequenceResult = await client.query(
"select coalesce(max(sequence), 0) + 1 as next_sequence from ai_workspace_thread_messages where thread_id = $1",
[threadId]
);
const sequence = Number(sequenceResult.rows[0]?.next_sequence || 1);
const inserted = await client.query(
`insert into ai_workspace_thread_messages (
id,
thread_id,
sequence,
role,
content,
payload
) values ($1, $2, $3, $4, $5, $6::jsonb)
returning *`,
[randomUUID(), threadId, sequence, command.role, command.content, JSON.stringify(command.payload)]
);
await client.query("update ai_workspace_threads set updated_at = now() where id = $1", [threadId]);
await client.query("commit");
return toThreadMessage(inserted.rows[0]);
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
}
async function migrate() {
await pool.query(`
create table if not exists ai_workspace_executors (
id uuid primary key,
owner_key text not null,
owner_user_id text,
owner_email text,
name text not null,
type text not null default 'codex-remote',
connection_mode text not null default 'hub',
endpoint text,
workspace_path text,
agent_port integer,
pairing_code text,
model text,
account_label text,
capabilities jsonb not null default '{}'::jsonb,
status text not null default 'unknown',
status_detail text,
last_seen_at timestamptz,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint ai_workspace_executors_owner_check
check (owner_user_id is not null or owner_email is not null),
constraint ai_workspace_executors_type_check
check (type in ('codex-remote', 'ndc-agent-core')),
constraint ai_workspace_executors_connection_mode_check
check (connection_mode in ('hub', 'direct')),
constraint ai_workspace_executors_status_check
check (status in ('unknown', 'online', 'offline', 'checking', 'error'))
)
`);
await pool.query(`
create table if not exists ai_workspace_owner_settings (
owner_key text primary key,
owner_user_id text,
owner_email text,
selected_executor_id uuid references ai_workspace_executors(id) on delete set null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint ai_workspace_owner_settings_owner_check
check (owner_user_id is not null or owner_email is not null)
)
`);
await pool.query(`
create table if not exists ai_workspace_threads (
id uuid primary key,
owner_key text not null,
owner_user_id text,
owner_email text,
title text not null,
origin_surface text not null default 'global',
active_context jsonb not null default '{}'::jsonb,
enabled_tool_packs text[] not null default '{}'::text[],
linked_artifacts jsonb not null default '[]'::jsonb,
selected_executor_id uuid references ai_workspace_executors(id) on delete set null,
lifecycle_state text not null default 'active',
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint ai_workspace_threads_owner_check
check (owner_user_id is not null or owner_email is not null),
constraint ai_workspace_threads_surface_check
check (origin_surface in ('engine', 'ops', 'global')),
constraint ai_workspace_threads_lifecycle_check
check (lifecycle_state in ('active', 'archived'))
)
`);
await pool.query(`
create table if not exists ai_workspace_thread_messages (
id uuid primary key,
thread_id uuid not null references ai_workspace_threads(id) on delete cascade,
sequence integer not null,
role text not null,
content text not null default '',
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
constraint ai_workspace_thread_messages_role_check
check (role in ('user', 'assistant', 'system', 'tool')),
unique (thread_id, sequence)
)
`);
await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)");
await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null");
await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)");
await pool.query("create index if not exists ai_workspace_thread_messages_thread_idx on ai_workspace_thread_messages(thread_id, sequence)");
}
function sanitizeExecutorCommand(payload, { partial }) {
const source = isPlainObject(payload) ? payload : {};
const command = {};
if (!partial || Object.hasOwn(source, "name")) {
command.name = requireNonEmptyString(source.name, "name");
}
if (!partial || Object.hasOwn(source, "type")) {
command.type = sanitizeEnum(source.type || "codex-remote", SUPPORTED_EXECUTOR_TYPES, "unsupported_executor_type");
}
if (!partial || Object.hasOwn(source, "connectionMode") || Object.hasOwn(source, "connection_mode")) {
command.connectionMode = sanitizeEnum(source.connectionMode || source.connection_mode || "hub", SUPPORTED_CONNECTION_MODES, "unsupported_connection_mode");
}
copyOptionalString(command, "endpoint", source.endpoint);
copyOptionalString(command, "workspacePath", source.workspacePath || source.workspace_path);
copyOptionalInteger(command, "agentPort", source.agentPort || source.agent_port, 1, 65535);
copyOptionalString(command, "pairingCode", source.pairingCode || source.pairing_code);
copyOptionalString(command, "model", source.model);
copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label);
if (!partial || Object.hasOwn(source, "capabilities")) {
command.capabilities = isPlainObject(source.capabilities) ? source.capabilities : {};
}
if (!partial || Object.hasOwn(source, "status")) {
command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status");
}
copyOptionalString(command, "statusDetail", source.statusDetail || source.status_detail);
if (Object.hasOwn(source, "lastSeenAt") || Object.hasOwn(source, "last_seen_at")) {
command.lastSeenAt = sanitizeOptionalIso(source.lastSeenAt || source.last_seen_at, "lastSeenAt");
}
if (!partial || Object.hasOwn(source, "metadata")) {
command.metadata = isPlainObject(source.metadata) ? source.metadata : {};
}
return command;
}
function sanitizeThreadCommand(payload, { partial }) {
const source = isPlainObject(payload) ? payload : {};
const command = {};
if (!partial || Object.hasOwn(source, "title")) {
command.title = requireNonEmptyString(source.title || "AI Workspace Thread", "title");
}
if (!partial || Object.hasOwn(source, "originSurface") || Object.hasOwn(source, "origin_surface")) {
command.originSurface = sanitizeSurface(source.originSurface || source.origin_surface || "global");
}
if (!partial || Object.hasOwn(source, "activeContext") || Object.hasOwn(source, "active_context")) {
const value = source.activeContext || source.active_context;
command.activeContext = isPlainObject(value) ? value : {};
}
if (!partial || Object.hasOwn(source, "enabledToolPacks") || Object.hasOwn(source, "enabled_tool_packs")) {
command.enabledToolPacks = sanitizeToolPacks(source.enabledToolPacks || source.enabled_tool_packs || []);
}
if (!partial || Object.hasOwn(source, "linkedArtifacts") || Object.hasOwn(source, "linked_artifacts")) {
const value = source.linkedArtifacts || source.linked_artifacts;
command.linkedArtifacts = Array.isArray(value) ? value.filter(isPlainObject) : [];
}
if (Object.hasOwn(source, "selectedExecutorId") || Object.hasOwn(source, "selected_executor_id")) {
const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id;
command.selectedExecutorId = selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null;
}
if (!partial || Object.hasOwn(source, "lifecycleState") || Object.hasOwn(source, "lifecycle_state")) {
command.lifecycleState = sanitizeEnum(source.lifecycleState || source.lifecycle_state || "active", SUPPORTED_THREAD_STATES, "unsupported_thread_state");
}
if (!partial || Object.hasOwn(source, "metadata")) {
command.metadata = isPlainObject(source.metadata) ? source.metadata : {};
}
return command;
}
function sanitizeMessageCommand(payload) {
const source = isPlainObject(payload) ? payload : {};
return {
role: sanitizeEnum(source.role || "user", SUPPORTED_MESSAGE_ROLES, "unsupported_message_role"),
content: optionalString(source.content) || "",
payload: isPlainObject(source.payload) ? source.payload : {},
};
}
function getRequestOwner(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("ai_workspace_owner_required");
}
return {
key: userId ? `user:${userId}` : `email:${email}`,
userId,
email,
};
}
function publicOwner(owner) {
return {
ownerKey: owner.key,
userId: owner.userId,
email: owner.email,
};
}
function toExecutor(row) {
return {
id: row.id,
ownerKey: row.owner_key,
ownerUserId: row.owner_user_id,
ownerEmail: row.owner_email,
name: row.name,
type: row.type,
connectionMode: row.connection_mode,
endpoint: row.endpoint,
workspacePath: row.workspace_path,
agentPort: row.agent_port,
pairingCode: row.pairing_code,
model: row.model,
accountLabel: row.account_label,
capabilities: row.capabilities || {},
status: row.status,
statusDetail: row.status_detail,
lastSeenAt: toIso(row.last_seen_at),
metadata: row.metadata || {},
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function toOwnerSettings(row) {
return {
ownerKey: row.owner_key,
ownerUserId: row.owner_user_id,
ownerEmail: row.owner_email,
selectedExecutorId: row.selected_executor_id,
metadata: row.metadata || {},
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function toThread(row) {
return {
id: row.id,
ownerKey: row.owner_key,
ownerUserId: row.owner_user_id,
ownerEmail: row.owner_email,
title: row.title,
originSurface: row.origin_surface,
activeContext: row.active_context || {},
enabledToolPacks: row.enabled_tool_packs || [],
linkedArtifacts: row.linked_artifacts || [],
selectedExecutorId: row.selected_executor_id,
lifecycleState: row.lifecycle_state,
metadata: row.metadata || {},
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function toThreadMessage(row) {
return {
id: row.id,
threadId: row.thread_id,
sequence: row.sequence,
role: row.role,
content: row.content,
payload: row.payload || {},
createdAt: toIso(row.created_at),
};
}
function requireInternalApi(req, res, next) {
if (!config.internalAccessToken) {
res.status(503).json({ ok: false, error: "ai_workspace_assistant_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: "ai_workspace_assistant_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) {
return sanitizeEnum(value || "global", SUPPORTED_SURFACES, "unsupported_surface");
}
function sanitizeToolPacks(value) {
if (!Array.isArray(value)) return [];
return Array.from(new Set(value.map(normalizeKey).filter(Boolean))).filter((toolPack) => {
if (!SUPPORTED_TOOL_PACKS.has(toolPack)) throw badRequest("unsupported_tool_pack");
return true;
});
}
function sanitizeEnum(value, supported, errorMessage) {
const key = normalizeKey(value);
if (!supported.has(key)) {
throw badRequest(errorMessage);
}
return key;
}
function sanitizeLimit(value, fallback, max) {
const limit = Number(value || fallback);
if (!Number.isFinite(limit)) return fallback;
return Math.min(Math.max(Math.trunc(limit), 1), max);
}
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 sanitizeOptionalIso(value, name) {
const text = optionalString(value);
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) {
throw badRequest(`${name}_invalid`);
}
return date.toISOString();
}
function copyOptionalString(target, key, value) {
if (value === undefined) return;
target[key] = optionalString(value);
}
function copyOptionalInteger(target, key, value, min, max) {
if (value === undefined) return;
if (value === null || value === "") {
target[key] = null;
return;
}
const number = Number(value);
if (!Number.isInteger(number) || number < min || number > max) {
throw badRequest(`${key}_invalid`);
}
target[key] = number;
}
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.AI_WORKSPACE_ASSISTANT_DATABASE_URL ||
"postgres://nodedc_ai_workspace:nodedc_ai_workspace@localhost:5432/nodedc_ai_workspace";
return {
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
databaseUrl,
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
internalAccessToken:
process.env.AI_WORKSPACE_ASSISTANT_TOKEN ||
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);
}
}