M41: harden trust boundaries and delivery
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-21 17:06:59 +02:00
parent a830e8a2d0
commit 24dcb3494c
38 changed files with 699 additions and 113 deletions
+2 -3
View File
@@ -13,9 +13,8 @@ POSTGRES_PASSWORD=mobilityops
# this (or MOBILITYOPS_CALLBACK_TOKEN) still holds its placeholder value. # this (or MOBILITYOPS_CALLBACK_TOKEN) still holds its placeholder value.
APP_SECRET=replace-in-production APP_SECRET=replace-in-production
TZ=Europe/Brussels TZ=Europe/Brussels
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # Session cookie Secure flag. Development on localhost may use false; production startup
# current Unraid review environment); set true only once MobilityOps is served over HTTPS, # requires both an HTTPS public URL and this value set to true.
# otherwise browsers will silently drop the cookie and no one can log in.
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
# Optional OpenID Connect login. Public demo role buttons remain available when enabled. # Optional OpenID Connect login. Public demo role buttons remain available when enabled.
+14 -12
View File
@@ -9,9 +9,9 @@ jobs:
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Secret scan - name: Secret scan
uses: trufflesecurity/trufflehog@v3.79.0 uses: trufflesecurity/trufflehog@b9dd330365132cd2d01dd5dc8a857a056a2544e1 # v3.79.0
with: with:
path: ./ path: ./
extra_args: --only-verified extra_args: --only-verified
@@ -19,13 +19,15 @@ jobs:
run: sh scripts/run-isolated-tests.sh run: sh scripts/run-isolated-tests.sh
- name: Backend static checks - name: Backend static checks
run: | run: |
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests scripts docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
- name: Backend dependency vulnerability scan (HIGH/CRITICAL) - name: Build production API image for vulnerability scan
uses: aquasecurity/trivy-action@v0.30.0 run: docker build --target runtime --tag mobilityops-api-ci --file backend/Dockerfile .
- name: Production API image vulnerability scan (HIGH/CRITICAL)
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with: with:
scan-type: fs scan-type: image
scan-ref: backend image-ref: mobilityops-api-ci
format: table format: table
severity: HIGH,CRITICAL severity: HIGH,CRITICAL
exit-code: "1" exit-code: "1"
@@ -37,8 +39,8 @@ jobs:
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
@@ -62,8 +64,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend, frontend] needs: [backend, frontend]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
@@ -90,7 +92,7 @@ jobs:
run: npx playwright test run: npx playwright test
- name: Upload Playwright report - name: Upload Playwright report
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: playwright-report name: playwright-report
path: frontend/playwright-report path: frontend/playwright-report
+2 -2
View File
@@ -13,8 +13,8 @@ test:
sh scripts/run-isolated-tests.sh sh scripts/run-isolated-tests.sh
lint: lint:
docker compose run --rm api ruff check . docker compose -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose run --rm api mypy app docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
cd frontend && npm run lint cd frontend && npm run lint
seed: seed:
+34
View File
@@ -1,5 +1,39 @@
# Project state # Project state
## M41 — full review remediation and hardening (2026-08-21)
- Closed all findings from the repository-wide review without expanding the locked PoC:
production now refuses placeholder MCP credentials, cleartext public URLs and insecure
session cookies; OIDC requires an explicit verified-email claim; nginx overwrites the
forwarded client address and the API uses the proxy-appended hop for rate limiting.
- Added bounded per-IP/per-session knowledge requests and an explicit minimum RAGcore
retrieval score. Weak or concept-mismatched search fallback evidence is returned as
`insufficient`, never `grounded`. MCP audit attribution now authenticates the fixed Hub
service identity and stores the Hub-reported caller only as non-authoritative metadata.
- Serialised data-quality scans with a PostgreSQL transaction advisory lock, added a
partial unique index for one open issue per condition, and locked issue rows for every
mutating resolution. Concurrent scan and concurrent-resolution regression tests pass.
- Split the backend production/test image stages and locks: the runtime contains no test
suite, pytest, Ruff or mypy. All container bases and CI actions are digest/SHA pinned.
CI now builds and scans the real runtime image. The initial Debian 13 base exposed 36
fixable HIGH findings; switching to the pinned Python 3.12 Bookworm image reduced the
final Trivy result to **0 HIGH/CRITICAL** across OS and Python packages.
- Moved every central n8n callback/source URL to the existing HTTPS endpoint
`fleetops.itworx.tech`, refreshed workflow checksums, corrected stale workflow status/
node-count documentation, fixed the return-odometer documentation and made the Unraid
bootstrap enforce HTTPS plus Secure cookies. Makefile lint now always builds the test
target and cannot silently inspect a stale runtime image.
- Validation evidence: focused security/integration/data-quality suite **116 passed**;
final isolated PostgreSQL backend suite **270 passed**; Ruff and mypy clean; frontend
lint/build clean; npm audit **0 vulnerabilities**; production runtime contains no dev
dependencies/tests; Trivy runtime scan **0 HIGH/CRITICAL**; full Playwright acceptance
**153/153 passed in 5.1 minutes**; Compose test/Unraid configs and `git diff --check`
clean. Existing public TLS returns 200 with HSTS and HTTP redirects to HTTPS.
- Exact next action: commit and push M41, take a verified production backup, update the
deployment's public URL/Secure-cookie settings, deploy the committed archive and
migration `4f2b9c8d7e61`, safely republish the four HTTPS n8n definitions, then repeat
live health, migration, security-header and browser acceptance checks.
## M40 — publish and redeploy M39 (2026-08-17) ## M40 — publish and redeploy M39 (2026-08-17)
- Published three validated commits to Gitea `master`: the backend dependency and secret - Published three validated commits to Gitea `master`: the backend dependency and secret
+11 -4
View File
@@ -1,19 +1,26 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY backend/requirements.lock ./ COPY backend/requirements-prod.lock ./
RUN pip install --no-cache-dir -r requirements.lock RUN pip install --no-cache-dir -r requirements-prod.lock
COPY backend/pyproject.toml ./ COPY backend/pyproject.toml ./
COPY backend/app ./app COPY backend/app ./app
COPY backend/alembic ./alembic COPY backend/alembic ./alembic
COPY backend/alembic.ini ./ COPY backend/alembic.ini ./
COPY backend/tests ./tests
COPY seed ./seed COPY seed ./seed
COPY knowledge ./knowledge COPY knowledge ./knowledge
COPY backend/entrypoint.sh ./entrypoint.sh COPY backend/entrypoint.sh ./entrypoint.sh
RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \ RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \
&& addgroup --system app && adduser --system --ingroup app --home /app app \ && addgroup --system app && adduser --system --ingroup app --home /app app \
&& chown -R app:app /app && chown -R app:app /app
FROM runtime-base AS test
COPY backend/requirements.lock ./requirements.lock
RUN pip install --no-cache-dir -r requirements.lock
COPY backend/tests ./tests
USER app
FROM runtime-base AS runtime
# Run migrations and the API as an unprivileged user; nothing here needs root. # Run migrations and the API as an unprivileged user; nothing here needs root.
USER app USER app
EXPOSE 8000 EXPOSE 8000
@@ -0,0 +1,27 @@
"""enforce one open issue per detected condition
Revision ID: 4f2b9c8d7e61
Revises: 0a4c1d2e3f5b
"""
from alembic import op
import sqlalchemy as sa
revision = "4f2b9c8d7e61"
down_revision = "0a4c1d2e3f5b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_data_quality_one_open_condition",
"data_quality_issues",
["rule_type", "entity_type", "entity_id"],
unique=True,
postgresql_where=sa.text("status = 'open'"),
)
def downgrade() -> None:
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
+2 -2
View File
@@ -67,7 +67,7 @@ def require_operations_manager(
@dataclass(frozen=True) @dataclass(frozen=True)
class McpClientContext: class McpClientContext:
client_id: str reported_client_id: str
tenant: str tenant: str
@@ -91,4 +91,4 @@ def require_mcp_service_token(
) )
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant: if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
return McpClientContext(client_id=x_client_id, tenant=settings.ragcore_tenant) return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
+4 -3
View File
@@ -38,10 +38,11 @@ _login_limiter = (
def _client_key(request: Request) -> str: def _client_key(request: Request) -> str:
# The API sits behind the web container's reverse proxy in every documented # The API sits behind the web container's reverse proxy in every documented
# deployment; honour the first hop of X-Forwarded-For when present. # deployment. The proxy appends/overwrites the socket peer as the final hop, so an
# attacker-controlled leading value must never select a fresh limiter bucket.
forwarded = request.headers.get("x-forwarded-for", "") forwarded = request.headers.get("x-forwarded-for", "")
if forwarded: if forwarded:
return forwarded.split(",")[0].strip() return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown" return request.client.host if request.client else "unknown"
@@ -143,7 +144,7 @@ def _allowed_oidc_email(email: str) -> bool:
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User: def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip() subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower() email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is False: if not subject or not email or claims.get("email_verified") is not True:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required") raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email): if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed") raise HTTPException(status_code=403, detail="Email domain is not allowed")
+33 -1
View File
@@ -1,20 +1,32 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import uuid import uuid
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.schemas import CurrentUser from app.schemas import CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
settings = get_settings()
_question_limiter = (
SlidingWindowLimiter(
max_requests=settings.knowledge_max_requests,
window_seconds=settings.knowledge_rate_limit_window_seconds,
)
if settings.knowledge_max_requests > 0
else None
)
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
@@ -32,9 +44,29 @@ class KnowledgeFeedbackRequest(BaseModel):
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
def ask_question( def ask_question(
body: AskQuestionRequest, body: AskQuestionRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer: ) -> GroundedAnswer:
if _question_limiter is not None:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
retry_after = max(
_question_limiter.consume(f"ip:{client_ip}"),
_question_limiter.consume(f"session:{session_key}"),
)
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many knowledge questions. Try again later.",
headers={"Retry-After": str(retry_after)},
)
correlation_id = str(uuid.uuid4()) correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, body.language) answer = provider.ask(body.question, correlation_id, body.language)
+16 -8
View File
@@ -44,7 +44,7 @@ def get_correlation_id(
def _audit_service_request( def _audit_service_request(
db: Session, db: Session,
*, *,
client_id: str, reported_client_id: str,
tool: str, tool: str,
status_label: str, status_label: str,
correlation_id: str, correlation_id: str,
@@ -53,11 +53,19 @@ def _audit_service_request(
record_audit_event( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, # The shared service token authenticates the Hub, not the caller identity that
# the Hub reports in a header. Keep attribution authoritative and retain the
# reported value only as explicitly non-authenticated diagnostic metadata.
actor_label="itworx-mcp-hub",
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.UUID(correlation_id), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label, **(metadata or {})}, metadata={
"tool": tool,
"status": status_label,
"reported_client_id": reported_client_id,
**(metadata or {}),
},
) )
db.commit() db.commit()
@@ -79,7 +87,7 @@ def operations_summary(
_set_trace_headers(response, correlation_id, client.tenant) _set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, db,
client_id=client.client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_operations_summary", tool="fleet_ops_get_operations_summary",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -103,7 +111,7 @@ def attention_vehicles(
_set_trace_headers(response, correlation_id, client.tenant) _set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, db,
client_id=client.client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_list_attention_vehicles", tool="fleet_ops_list_attention_vehicles",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -124,7 +132,7 @@ def vehicle_details(
if vehicle is None: if vehicle is None:
_audit_service_request( _audit_service_request(
db, db,
client_id=client.client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -146,7 +154,7 @@ def vehicle_details(
_audit_service_request( _audit_service_request(
db, db,
client_id=client.client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -182,7 +190,7 @@ def search_knowledge(
response.headers["X-Sources-Returned"] = str(len(answer.sources)) response.headers["X-Sources-Returned"] = str(len(answer.sources))
_audit_service_request( _audit_service_request(
db, db,
client_id=client.client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id, correlation_id=correlation_id,
+10 -4
View File
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
ragcore_api_token: str = "" ragcore_api_token: str = ""
ragcore_space_id: str = "" ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0 ragcore_http_timeout_seconds: float = 5.0
# Search fallback is only labelled grounded above this explicit retrieval threshold.
ragcore_min_search_score: float = 0.05
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token" n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
n8n_callback_token: str = "replace-me-n8n-callback-token" n8n_callback_token: str = "replace-me-n8n-callback-token"
@@ -67,6 +69,8 @@ class Settings(BaseSettings):
# Failed password logins per client IP before a temporary 429 (0 disables). # Failed password logins per client IP before a temporary 429 (0 disables).
login_max_failures: int = 10 login_max_failures: int = 10
login_failure_window_seconds: int = 900 login_failure_window_seconds: int = 900
knowledge_max_requests: int = 30
knowledge_rate_limit_window_seconds: int = 60
metrics_bearer_token: str = "" metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30 privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555 privacy_audit_retention_days: int = 2555
@@ -86,13 +90,11 @@ INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
def insecure_default_secrets(settings: "Settings") -> list[str]: def insecure_default_secrets(settings: "Settings") -> list[str]:
"""Return the names of secret settings that still carry their placeholder value. """Return the names of secret settings that still carry their placeholder value.
Only secrets that actually guard something in the given deployment are reported: MCP routes are always mounted, independently of the Hub reachability-status flag, so
``mcp_hub_service_token`` is irrelevant while MCP Hub registration is disabled. their inbound token must always be non-placeholder in production.
""" """
insecure: list[str] = [] insecure: list[str] = []
for name, placeholder in INSECURE_DEFAULT_SECRETS: for name, placeholder in INSECURE_DEFAULT_SECRETS:
if name == "mcp_hub_service_token" and not settings.mcp_hub_registration_enabled:
continue
value = getattr(settings, name) value = getattr(settings, name)
if not value or value == placeholder or value.startswith("replace-me"): if not value or value == placeholder or value.startswith("replace-me"):
insecure.append(name) insecure.append(name)
@@ -112,4 +114,8 @@ def get_settings() -> Settings:
+ ", ".join(insecure) + ", ".join(insecure)
+ ". Set real values in the environment (see .env.example)." + ". Set real values in the environment (see .env.example)."
) )
if not settings.mobilityops_public_url.lower().startswith("https://"):
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
if not settings.session_cookie_secure:
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
return settings return settings
+23
View File
@@ -47,3 +47,26 @@ class FailedAttemptLimiter:
def reset(self, key: str) -> None: def reset(self, key: str) -> None:
with self._lock: with self._lock:
self._failures.pop(key, None) self._failures.pop(key, None)
class SlidingWindowLimiter:
"""Thread-safe request limiter where every accepted request consumes capacity."""
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def consume(self, key: str) -> int:
"""Record an accepted request, or return the seconds until capacity is available."""
now = time.monotonic()
with self._lock:
bucket = self._requests.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= self.max_requests:
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
bucket.append(now)
return 0
+9 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -37,6 +37,14 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name="ck_data_quality_status", name="ck_data_quality_status",
), ),
Index("ix_data_quality_work_queue", "status", "due_at", "severity"), Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
Index(
"uq_data_quality_one_open_condition",
"rule_type",
"entity_type",
"entity_id",
unique=True,
postgresql_where=text("status = 'open'"),
),
) )
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
+17 -6
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher from difflib import SequenceMatcher
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.errors import AppError from app.core.errors import AppError
@@ -26,6 +26,7 @@ from app.services.vehicle_status import (
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name") REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location") REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70 DUPLICATE_THRESHOLD = 70
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
def issue_due_at(detected_at: datetime, severity: str) -> datetime: def issue_due_at(detected_at: datetime, severity: str) -> datetime:
@@ -356,6 +357,9 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
def run_scan( def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user" db: Session, *, actor_label: str | None = None, actor_type: str = "user"
) -> ScanResult: ) -> ScanResult:
# The check-then-insert work below spans several rules. Serialise whole scans at the
# database boundary so API and n8n triggers cannot both observe an empty condition.
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
scan = ScanResult() scan = ScanResult()
_scan_duplicate_customers(db, scan) _scan_duplicate_customers(db, scan)
_scan_missing_required_fields(db, scan) _scan_missing_required_fields(db, scan)
@@ -375,8 +379,13 @@ def run_scan(
return scan return scan
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue: def _load_open_issue(
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)) db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update()
issue = db.scalar(statement)
if issue is None: if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404) raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open": if issue.status != "open":
@@ -707,8 +716,10 @@ def resolve_booking_overlap(
return issue return issue
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue: def _load_vehicle_status_conflict_issue(
issue = _load_open_issue(db, public_ref) db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref, lock=lock)
if issue.rule_type != "vehicle_status_conflict": if issue.rule_type != "vehicle_status_conflict":
raise AppError( raise AppError(
"NOT_A_STATUS_CONFLICT_ISSUE", "NOT_A_STATUS_CONFLICT_ISSUE",
@@ -724,7 +735,7 @@ def preview_vehicle_status_recommendation(
"""Non-mutating: computes and returns the recommendation only. Never resolves the """Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects.""" as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref) issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id)) vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None: if vehicle is None:
raise AppError( raise AppError(
+37 -5
View File
@@ -92,6 +92,17 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
) )
def _retrieval_score(result: dict) -> float | None:
scores = result.get("scores")
if not isinstance(scores, dict):
return None
for name in ("rerank", "fused"):
value = scores.get(name)
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
return None
class RAGcoreKnowledgeProvider: class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service, against its real `/v1/*` contract """Adapter for the central RAGcore service, against its real `/v1/*` contract
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and (see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
@@ -338,6 +349,8 @@ class RAGcoreKnowledgeProvider:
try: try:
results = body.get("results", []) results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [ sources = [
SourceCard( SourceCard(
document_id=str(result["citation"]["document_id"]), document_id=str(result["citation"]["document_id"]),
@@ -351,10 +364,21 @@ class RAGcoreKnowledgeProvider:
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
return unavailable return unavailable
sources = _deduplicate_sources(sources) all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question) concepts = _question_concepts(question)
sources = _rank_sources_for_concepts(sources, concepts) qualified_sources = [
if not sources: SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
return GroundedAnswer( return GroundedAnswer(
answer="", answer="",
evidence_state="insufficient", evidence_state="insufficient",
@@ -363,11 +387,19 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
if not concepts: damage_evidence = any(
term
in (
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
return GroundedAnswer( return GroundedAnswer(
answer="", answer="",
evidence_state="insufficient", evidence_state="insufficient",
sources=sources, sources=all_sources,
provider=self.name, provider=self.name,
correlation_id=correlation_id, correlation_id=correlation_id,
) )
+173
View File
@@ -0,0 +1,173 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml
#
alembic==1.18.5
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
annotated-doc==0.0.5
# via
# -c requirements.lock
# fastapi
annotated-types==0.8.0
# via
# -c requirements.lock
# pydantic
anyio==4.14.2
# via
# -c requirements.lock
# httpx
# starlette
# watchfiles
authlib==1.7.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
certifi==2026.7.22
# via
# -c requirements.lock
# httpcore
# httpx
cffi==2.1.1
# via
# -c requirements.lock
# cryptography
click==8.4.2
# via
# -c requirements.lock
# uvicorn
cryptography==50.0.0
# via
# -c requirements.lock
# authlib
# joserfc
fastapi==0.141.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
greenlet==3.5.4
# via
# -c requirements.lock
# sqlalchemy
h11==0.16.0
# via
# -c requirements.lock
# httpcore
# uvicorn
httpcore==1.0.9
# via
# -c requirements.lock
# httpx
httptools==0.8.0
# via
# -c requirements.lock
# uvicorn
httpx==0.28.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
idna==3.18
# via
# -c requirements.lock
# anyio
# httpx
itsdangerous==2.2.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
joserfc==1.7.4
# via
# -c requirements.lock
# authlib
mako==1.3.12
# via
# -c requirements.lock
# alembic
markupsafe==3.0.3
# via
# -c requirements.lock
# mako
prometheus-client==0.26.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg[binary]==3.3.4
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4
# via
# -c requirements.lock
# psycopg
pycparser==3.0
# via
# -c requirements.lock
# cffi
pydantic==2.13.4
# via
# -c requirements.lock
# fastapi
# pydantic-settings
pydantic-core==2.46.4
# via
# -c requirements.lock
# pydantic
pydantic-settings==2.14.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
python-dotenv==1.2.2
# via
# -c requirements.lock
# pydantic-settings
# uvicorn
pyyaml==6.0.3
# via
# -c requirements.lock
# uvicorn
sqlalchemy==2.0.51
# via
# -c requirements.lock
# alembic
# mobilityops-api (pyproject.toml)
starlette==1.3.1
# via
# -c requirements.lock
# fastapi
typing-extensions==4.16.0
# via
# -c requirements.lock
# alembic
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# -c requirements.lock
# fastapi
# pydantic
# pydantic-settings
uvicorn[standard]==0.52.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
uvloop==0.22.1
# via
# -c requirements.lock
# uvicorn
watchfiles==1.2.0
# via
# -c requirements.lock
# uvicorn
websockets==17.0.1
# via
# -c requirements.lock
# uvicorn
+28 -1
View File
@@ -46,6 +46,29 @@ def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
assert session.json()["role"] == "rental_employee" assert session.json()["role"] == "rental_employee"
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
import app.api.routers.auth as auth_router
class FakeClient:
async def authorize_access_token(self, _request):
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
class FakeOAuth:
def create_client(self, _name):
return FakeClient()
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
assert response.status_code == 401
assert client.get("/api/v1/auth/session").status_code == 401
def test_demo_login_grants_access(ops_client): def test_demo_login_grants_access(ops_client):
response = ops_client.get("/api/v1/dashboard") response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200 assert response.status_code == 200
@@ -173,4 +196,8 @@ def test_demo_reset_preserves_integration_telemetry(ops_client, client):
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200 assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
assert any(event["actor_label"].endswith(":reset-probe") for event in events) assert any(
event["actor_label"] == "itworx-mcp-hub"
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
for event in events
)
+147 -7
View File
@@ -3,33 +3,37 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime from datetime import UTC, datetime
from threading import Barrier
import pytest import pytest
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from app.core.config import Settings, get_settings, insecure_default_secrets from app.core.config import Settings, get_settings, insecure_default_secrets
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.errors import AppError
from app.core.observability import UNMATCHED_ROUTE_LABEL from app.core.observability import UNMATCHED_ROUTE_LABEL
from app.core.ratelimit import FailedAttemptLimiter from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.services.data_quality import run_scan from app.schemas import CurrentUser
from app.services.data_quality import defer_issue, run_scan
from tests.test_return import _activate_booking, _return_body from tests.test_return import _activate_booking, _return_body
def test_insecure_defaults_are_detected_only_for_relevant_secrets(): def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
defaults = Settings(_env_file=None) defaults = Settings(_env_file=None)
assert "app_secret" in insecure_default_secrets(defaults) assert "app_secret" in insecure_default_secrets(defaults)
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults) assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
hardened = Settings( hardened = Settings(
_env_file=None, _env_file=None,
app_secret="x" * 32, app_secret="x" * 32,
n8n_callback_token="c" * 32, n8n_callback_token="c" * 32,
mcp_hub_registration_enabled=True, mcp_hub_service_token="m" * 32,
) )
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"] assert insecure_default_secrets(hardened) == []
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch): def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
@@ -47,6 +51,37 @@ def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
assert get_settings().mobilityops_env == "test" assert get_settings().mobilityops_env == "test"
@pytest.mark.parametrize(
("public_url", "secure_cookie", "message"),
[
("http://fleetops.example.test", "true", "must use HTTPS"),
("https://fleetops.example.test", "false", "must be true"),
],
)
def test_production_refuses_cleartext_or_insecure_session_cookie(
monkeypatch, public_url, secure_cookie, message
):
values = {
"MOBILITYOPS_ENV": "production",
"APP_SECRET": "a" * 32,
"N8N_CALLBACK_TOKEN": "c" * 32,
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
"MOBILITYOPS_PUBLIC_URL": public_url,
"SESSION_COOKIE_SECURE": secure_cookie,
}
for name, value in values.items():
monkeypatch.setenv(name, value)
get_settings.cache_clear()
try:
with pytest.raises(RuntimeError, match=message):
get_settings()
finally:
get_settings.cache_clear()
monkeypatch.undo()
get_settings.cache_clear()
assert get_settings().mobilityops_env == "test"
def test_failed_attempt_limiter_blocks_after_threshold_and_resets(): def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60) limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
for _ in range(3): for _ in range(3):
@@ -58,6 +93,29 @@ def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
assert limiter.retry_after_seconds("1.2.3.4") == 0 assert limiter.retry_after_seconds("1.2.3.4") == 0
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
assert limiter.consume("session-a") == 0
assert limiter.consume("session-a") == 0
assert limiter.consume("session-a") > 0
assert limiter.consume("session-b") == 0
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
from starlette.requests import Request
from app.api.routers.auth import _client_key
request = Request(
{
"type": "http",
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
"client": ("172.20.0.3", 12345),
}
)
assert _client_key(request) == "198.51.100.7"
def test_audit_export_accepts_naive_datetimes(ops_client): def test_audit_export_accepts_naive_datetimes(ops_client):
response = ops_client.get( response = ops_client.get(
"/api/v1/audit/export.csv", "/api/v1/audit/export.csv",
@@ -194,3 +252,85 @@ def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id)) db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
db.execute(delete(Customer).where(Customer.id == customer_id)) db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit() db.commit()
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
barrier = Barrier(2)
def scan() -> None:
with SessionLocal() as db:
barrier.wait()
run_scan(db)
with ThreadPoolExecutor(max_workers=2) as executor:
list(executor.map(lambda _index: scan(), range(2)))
with SessionLocal() as db:
duplicates = db.execute(
select(
DataQualityIssue.rule_type,
DataQualityIssue.entity_type,
DataQualityIssue.entity_id,
func.count(DataQualityIssue.id),
)
.where(DataQualityIssue.status == "open")
.group_by(
DataQualityIssue.rule_type,
DataQualityIssue.entity_type,
DataQualityIssue.entity_id,
)
.having(func.count(DataQualityIssue.id) > 1)
).all()
assert duplicates == []
def test_concurrent_issue_resolution_records_exactly_one_decision():
issue_id = uuid.uuid4()
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
with SessionLocal() as db:
db.add(
DataQualityIssue(
id=issue_id,
public_ref=issue_ref,
rule_type="missing_required_field",
entity_type="customer",
entity_id=uuid.uuid4(),
severity="low",
status="open",
evidence_json={},
proposed_action_json={},
detected_at=datetime.now(UTC),
)
)
db.commit()
actor = CurrentUser(
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
)
barrier = Barrier(2)
def resolve() -> str:
with SessionLocal() as db:
barrier.wait()
try:
return defer_issue(db, issue_ref, actor).status
except AppError as exc:
return exc.code
try:
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
with SessionLocal() as db:
decisions = db.scalar(
select(func.count(AuditEvent.id)).where(
AuditEvent.entity_id == issue_id,
AuditEvent.action == "data_quality_issue_deferred",
)
)
assert decisions == 1
finally:
with SessionLocal() as db:
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
db.commit()
+44
View File
@@ -7,6 +7,7 @@ import httpx
from app.api.routers import knowledge as knowledge_router from app.api.routers import knowledge as knowledge_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.services.knowledge import KnowledgeHealth from app.services.knowledge import KnowledgeHealth
from app.services.knowledge.demo import DemoKnowledgeProvider from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.procedures import iter_procedure_documents from app.services.knowledge.procedures import iter_procedure_documents
@@ -140,6 +141,16 @@ def test_ask_question_requires_authentication(client):
assert response.status_code == 401 assert response.status_code == 401
def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch):
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
monkeypatch.setattr(knowledge_router, "_question_limiter", limiter)
body = {"question": "What is the vehicle return procedure?"}
assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200
blocked = ops_client.post("/api/v1/knowledge/questions", json=body)
assert blocked.status_code == 429
assert int(blocked.headers["retry-after"]) >= 1
def test_ask_question_is_audited_without_leaking_full_text(ops_client): def test_ask_question_is_audited_without_leaking_full_text(ops_client):
ops_client.post( ops_client.post(
"/api/v1/knowledge/questions", "/api/v1/knowledge/questions",
@@ -695,5 +706,38 @@ def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
), ),
) )
answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE") answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert any(source.document_id == "damage-procedure" for source in answer.sources)
def test_ragcore_search_fallback_accepts_relevant_damage_evidence_above_threshold(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body()
search["results"].append(
{
"citation": {
"document_id": "damage-procedure",
"document_version_id": "version-2",
"title": "damage-procedure.md",
"section": "Damage",
"excerpt": "Record damage and keep the vehicle blocked.",
},
"rank": 1,
"scores": {"fused": 0.5},
}
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, search),
}
),
)
answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE")
assert answer.evidence_state == "grounded" assert answer.evidence_state == "grounded"
assert answer.sources[0].document_id == "damage-procedure" assert answer.sources[0].document_id == "damage-procedure"
+7 -1
View File
@@ -96,6 +96,8 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
assert len(events) >= 1 assert len(events) >= 1
assert events[0]["actor_type"] == "service" assert events[0]["actor_type"] == "service"
assert events[0]["actor_label"] == "itworx-mcp-hub"
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
def test_search_knowledge_respects_requested_locale(client): def test_search_knowledge_respects_requested_locale(client):
@@ -135,7 +137,11 @@ def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_
) )
assert response.status_code == 200 assert response.status_code == 200
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["actor_label"].endswith(":no-correlation-probe")] matching = [
e
for e in events
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
]
assert len(matching) >= 1 assert len(matching) >= 1
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
prometheus: prometheus:
image: prom/prometheus:v3.7.1 image: prom/prometheus:v3.7.1@sha256:ff7e389acbe064a4823212a500393d40a28a8f362e4b05cbf6742a9a3ef736b2
profiles: ["observability"] profiles: ["observability"]
command: command:
- --config.file=/etc/prometheus/prometheus.yml - --config.file=/etc/prometheus/prometheus.yml
@@ -16,7 +16,7 @@ services:
networks: [mobilityops] networks: [mobilityops]
grafana: grafana:
image: grafana/grafana:12.2.0 image: grafana/grafana:12.2.0@sha256:74144189b38447facf737dfd0f3906e42e0776212bf575dc3334c3609183adf7
profiles: ["observability"] profiles: ["observability"]
environment: environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+2
View File
@@ -8,6 +8,8 @@ services:
test: ["CMD-SHELL", "pg_isready -U mobilityops_test -d mobilityops_test"] test: ["CMD-SHELL", "pg_isready -U mobilityops_test -d mobilityops_test"]
api: api:
build:
target: test
environment: environment:
MOBILITYOPS_ENV: test MOBILITYOPS_ENV: test
MOBILITYOPS_DEMO_MODE: "true" MOBILITYOPS_DEMO_MODE: "true"
+1 -1
View File
@@ -36,7 +36,7 @@ services:
retries: 20 retries: 20
backup: backup:
image: postgres:16-alpine image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-mobilityops} POSTGRES_DB: ${POSTGRES_DB:-mobilityops}
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-mobilityops} POSTGRES_DB: ${POSTGRES_DB:-mobilityops}
POSTGRES_USER: ${POSTGRES_USER:-mobilityops} POSTGRES_USER: ${POSTGRES_USER:-mobilityops}
@@ -91,7 +91,7 @@ services:
n8n: n8n:
# Explicit fallback only; production reuses the server's existing central n8n. # Explicit fallback only; production reuses the server's existing central n8n.
image: n8nio/n8n:2.33.7 image: n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30
environment: environment:
TZ: ${TZ:-Europe/Brussels} TZ: ${TZ:-Europe/Brussels}
GENERIC_TIMEZONE: ${TZ:-Europe/Brussels} GENERIC_TIMEZONE: ${TZ:-Europe/Brussels}
+7 -6
View File
@@ -9,7 +9,7 @@ existing shared n8n remains available on its established port 5678.
- Directory: `/mnt/user/appdata/mobilityops` - Directory: `/mnt/user/appdata/mobilityops`
- Compose project: `mobilityops` - Compose project: `mobilityops`
- Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`) - Public web: `https://fleetops.itworx.tech` (TLS reverse proxy to host port `1236`)
- API and PostgreSQL: Compose network only - API and PostgreSQL: Compose network only
- Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project) - Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project)
@@ -17,14 +17,15 @@ existing shared n8n remains available on its established port 5678.
Create `.env` from `.env.example`, replace every placeholder secret, set Create `.env` from `.env.example`, replace every placeholder secret, set
`MOBILITYOPS_ENV=production`, set both public URLs to `MOBILITYOPS_ENV=production`, set both public URLs to
`http://192.168.10.150:1236`, and retain `KNOWLEDGE_PROVIDER=demo` while RAGcore is not `https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and retain
available. Keep `MCP_HUB_REGISTRATION_ENABLED=false` until the central Hub is ready. `KNOWLEDGE_PROVIDER=demo` while RAGcore is not available. The internal `1236` listener is
an upstream for the TLS proxy, not a user-facing URL.
```bash ```bash
cd /mnt/user/appdata/mobilityops cd /mnt/user/appdata/mobilityops
./deploy/unraid/configure-env.sh \ ./deploy/unraid/configure-env.sh \
http://192.168.10.150:1236 \ https://fleetops.itworx.tech \
http://192.168.10.150:5678/webhook/mobilityops-return https://n8n.itworx.tech/webhook/mobilityops-return
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web backup docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web backup
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \
python -m app.cli seed --reset python -m app.cli seed --reset
@@ -36,7 +37,7 @@ workflow into the existing n8n container:
```bash ```bash
./deploy/unraid/setup-existing-n8n.sh \ ./deploy/unraid/setup-existing-n8n.sh \
n8n \ n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback https://fleetops.itworx.tech/api/v1/integrations/n8n/return-callback
``` ```
The callback token remains server-side and is never written to the repository. The The callback token remains server-side and is never written to the repository. The
+8 -2
View File
@@ -1,8 +1,13 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
public_url="${1:-http://192.168.10.150:1236}" public_url="${1:-https://fleetops.itworx.tech}"
n8n_webhook_url="${2:-http://192.168.10.150:5678/webhook/mobilityops-return}" n8n_webhook_url="${2:-https://n8n.itworx.tech/webhook/mobilityops-return}"
case "$public_url" in
https://*) ;;
*) echo "Production public URL must use HTTPS" >&2; exit 1 ;;
esac
if [ -e .env ]; then if [ -e .env ]; then
echo "Refusing to overwrite existing .env" >&2 echo "Refusing to overwrite existing .env" >&2
@@ -26,6 +31,7 @@ sed -i \
-e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \ -e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \ -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
-e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \ -e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \
-e "s|^SESSION_COOKIE_SECURE=.*|SESSION_COOKIE_SECURE=true|" \
-e "s|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=${n8n_webhook_url}|" \ -e "s|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=${n8n_webhook_url}|" \
-e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \ -e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \
-e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \ -e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}" callback_url="${2:-https://fleetops.itworx.tech/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}" source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}" scan_url="${2:-https://fleetops.itworx.tech/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}" source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
+2 -1
View File
@@ -27,7 +27,8 @@ created, and next-booking risk. `register_vehicle_return` (below) calls the same
1. Authorize Rental Employee or Operations Manager. 1. Authorize Rental Employee or Operations Manager.
2. Lock booking and vehicle rows. 2. Lock booking and vehicle rows.
3. Reject cancelled/already-returned booking unless idempotency replay matches. 3. Reject cancelled/already-returned booking unless idempotency replay matches.
4. Validate required fields and submitted reading against booking start reading. 4. Validate required fields; compare the submitted reading only with the vehicle's
canonical odometer so a lower reading is recorded as evidence instead of rejected.
5. Create a return inspection. 5. Create a return inspection.
6. Set booking to returned and store submitted end reading. 6. Set booking to returned and store submitted end reading.
7. If submitted reading >= canonical odometer, update canonical odometer. 7. If submitted reading >= canonical odometer, update canonical odometer.
+6 -11
View File
@@ -20,10 +20,6 @@ The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-ret
## Second live workflow: scheduled quality scan ## Second live workflow: scheduled quality scan
RAGcore is not connected in this environment, so the originally sketched "knowledge sync"
workflow below remains deferred (see "Deferred: knowledge sync"). The second implemented
workflow does not depend on RAGcore or MCP Hub, so it is not blocked by them.
Input: hourly schedule trigger, or a manual trigger for on-demand testing. Input: hourly schedule trigger, or a manual trigger for on-demand testing.
Steps: Steps:
@@ -45,16 +41,15 @@ workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`).
active on the live instance; a fresh import ships inactive until credentials are wired up active on the live instance; a fresh import ships inactive until credentials are wired up
and it is deliberately published. and it is deliberately published.
## RAGcore procedure sync (in progress) ## RAGcore procedure sync
RAGcore is now reachable in this environment; a live inspection of its real contract is Workflow 3, "Fleet Ops — RAGcore Procedure Sync", is published and active against the
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops — live RAGcore contract. See `n8n/workflows/MANIFEST.md` for its bounded upload contract,
RAGcore Procedure Sync", is being built against that real contract (not the sketch credentials and live validation evidence.
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
## Workflow error handler (in progress) ## Workflow error handler
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached Workflow 4, "Fleet Ops — Workflow Error Handler", is active and attached
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded, to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status. secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
+6 -6
View File
@@ -71,8 +71,8 @@ On startup the API creates the first active Operations Manager only when no user
email exists. The sign-in page then accepts email/password instead of exposing demo roles; email exists. The sign-in page then accepts email/password instead of exposing demo roles;
demo reset, the guided tour and the synthetic-data badge are hidden. demo reset, the guided tour and the synthetic-data badge are hidden.
Use a long unique `APP_SECRET`, set `SESSION_COOKIE_SECURE=true` once the public endpoint Use a long unique `APP_SECRET`; production requires an HTTPS public endpoint and
uses HTTPS, and keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sessions are `SESSION_COOKIE_SECURE=true`. Keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sessions are
revalidated against the current user record on every request, so deactivating an account revalidated against the current user record on every request, so deactivating an account
invalidates its next request. invalidates its next request.
@@ -168,17 +168,17 @@ deliberately published with a real service token.
### Existing shared n8n on the Unraid review server ### Existing shared n8n on the Unraid review server
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not The Unraid deployment uses the existing n8n at `https://n8n.itworx.tech`; it does not
start MobilityOps's bundled n8n service. `compose.unraid.yaml` places that fallback behind start MobilityOps's bundled n8n service. `compose.unraid.yaml` places that fallback behind
the opt-in `bundled-n8n` profile. Configure the API target and publish the workflow with: the opt-in `bundled-n8n` profile. Configure the API target and publish the workflow with:
```bash ```bash
sed -i \ sed -i \
's|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return|' \ 's|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=https://n8n.itworx.tech/webhook/mobilityops-return|' \
.env .env
./deploy/unraid/setup-existing-n8n.sh \ ./deploy/unraid/setup-existing-n8n.sh \
n8n \ n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback https://fleetops.itworx.tech/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
``` ```
@@ -215,7 +215,7 @@ Publish the scheduled quality-scan workflow the same way:
```bash ```bash
./deploy/unraid/setup-scheduled-scan.sh \ ./deploy/unraid/setup-scheduled-scan.sh \
n8n \ n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan https://fleetops.itworx.tech/api/v1/integrations/n8n/scheduled-scan
``` ```
## Required operational checks ## Required operational checks
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS build FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./ COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
@@ -10,7 +10,7 @@ ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
+3 -3
View File
@@ -24,21 +24,21 @@ server {
limit_req zone=demo_login burst=100 nodelay; limit_req zone=demo_login burst=100 nodelay;
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /api/v1/demo/reset { location = /api/v1/demo/reset {
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /api/ { location /api/ {
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
+7 -6
View File
@@ -19,7 +19,7 @@ credential values are never embedded; nodes reference named n8n credentials inst
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) | | Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
| Timeouts / bounded retries | `Record follow-up` and heartbeat HTTP nodes: 15s timeout, retry on fail (3 tries, 1000ms wait) | | Timeouts / bounded retries | `Record follow-up` and heartbeat HTTP nodes: 15s timeout, retry on fail (3 tries, 1000ms wait) |
| Execution telemetry | Successful runs POST execution ID and status to `/api/v1/integrations/n8n/heartbeat`; failed runs are registered by the Error Workflow. | | Execution telemetry | Successful runs POST execution ID and status to `/api/v1/integrations/n8n/heartbeat`; failed runs are registered by the Error Workflow. |
| Checksum (sha256) | `17b8dd5b8e7ab5d0c0856a40f4199a401ad38f63895f611a096a80c9740f1e99` (heartbeat-enabled definition, 2026-08-10) | | Checksum (sha256) | `19f233b6de90a3474010d5495a53c75e969fb5de591e84dd3abcf9635304c1f1` (HTTPS callback definition, 2026-08-21) |
## 2. Fleet Ops — Scheduled Data Quality Scan ## 2. Fleet Ops — Scheduled Data Quality Scan
@@ -35,7 +35,7 @@ credential values are never embedded; nodes reference named n8n credentials inst
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) | | Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
| Timeouts / bounded retries | Scan and heartbeat HTTP nodes: 15s timeout, retry on fail (3 tries, 1000ms wait) | | Timeouts / bounded retries | Scan and heartbeat HTTP nodes: 15s timeout, retry on fail (3 tries, 1000ms wait) |
| Execution telemetry | Every successful scheduled/manual run posts an idempotent heartbeat with its n8n execution ID. | | Execution telemetry | Every successful scheduled/manual run posts an idempotent heartbeat with its n8n execution ID. |
| Checksum (sha256) | `f0bda8b0fa99d1a2403e970e00bd1f896cd4d10eaa626147fcca577a7ad7cb46` | | Checksum (sha256) | `057bfe27ea7e2b497c57dec2a5967cad2960170a5c17535021f4480e63a6073d` |
## 3. Fleet Ops — RAGcore Procedure Sync ## 3. Fleet Ops — RAGcore Procedure Sync
@@ -56,8 +56,9 @@ trailing `}}` (e.g. `={{ $json.execution_id }} }}` instead of `={{ $json.executi
}}`), which would have sent malformed values on every real run. Fixed via `n8n }}`), which would have sent malformed values on every real run. Fixed via `n8n
import:workflow` (the safe CLI path — not the REST API, which caused a prior wipe import:workflow` (the safe CLI path — not the REST API, which caused a prior wipe
incident in this environment) against the same live workflow ID, keeping it inactive; incident in this environment) against the same live workflow ID, keeping it inactive;
re-exported and verified the fix applied with no other change (still 6 real nodes, re-exported and verified the fix applied with no other change (7 real nodes; the cleaned
`active: false`). Also found and fixed a second gap: `settings.errorWorkflow` was unset repository export intentionally remains `active: false` so importing cannot start work
before credentials are wired). Also found and fixed a second gap: `settings.errorWorkflow` was unset
(workflows 1-2 wire `"errorWorkflow": "Xppn2rAEqUuyiCJF"`, workflow 3 did not) — wired it (workflows 1-2 wire `"errorWorkflow": "Xppn2rAEqUuyiCJF"`, workflow 3 did not) — wired it
the same way via the same CLI import path, re-verified. the same way via the same CLI import path, re-verified.
@@ -72,7 +73,7 @@ the same way via the same CLI import path, re-verified.
| Active status (as of 2026-08-05) | **Active / Published** | | Active status (as of 2026-08-05) | **Active / Published** |
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) | | Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
| Execution telemetry | Successful syncs report both the bounded sync result and the canonical workflow heartbeat. | | Execution telemetry | Successful syncs report both the bounded sync result and the canonical workflow heartbeat. |
| Checksum (sha256) | `8644d9faec4d17a43ba797e300b91fff976881c9b259e457fadd5a7cfaf39c6f` | | Checksum (sha256) | `4afd46b6ef57b7e0a4705611732506d7fcc2a37567266847991b85050e1d37c3` |
## 4. Fleet Ops — Workflow Error Handler ## 4. Fleet Ops — Workflow Error Handler
@@ -93,7 +94,7 @@ Ops, which registers an audit event idempotently keyed on `execution_id`.
| Active status (as of 2026-08-04) | Active / Published | | Active status (as of 2026-08-04) | Active / Published |
| Error Workflow (on itself) | `- No Workflow -` (deliberately unset — prevents a recursive error loop) | | Error Workflow (on itself) | `- No Workflow -` (deliberately unset — prevents a recursive error loop) |
| Execution telemetry | A successfully handled failure posts its own execution heartbeat after registering the target failure. | | Execution telemetry | A successfully handled failure posts its own execution heartbeat after registering the target failure. |
| Checksum (sha256) | `1e50cf8b679b9e9f9b5cf4a1594bda459597ade40f44e8a68f540890f9c4b61d` | | Checksum (sha256) | `57e6c1206b1be4522d88a5983f53b2ddf94f8d6e55c1b7be8c397f75e46412da` |
Validated this round: mock-data run (Error Trigger pinned to a realistic payload) Validated this round: mock-data run (Error Trigger pinned to a realistic payload)
produced a real `200 {"status":"registered", ...}` from the live Fleet Ops server; produced a real `200 {"status":"registered", ...}` from the live Fleet Ops server;
@@ -30,7 +30,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/scheduled-scan",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendHeaders": true, "sendHeaders": true,
@@ -68,7 +68,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/heartbeat", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/heartbeat",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,
+2 -2
View File
@@ -24,7 +24,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/workflow-error", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/workflow-error",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,
@@ -58,7 +58,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/heartbeat", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/heartbeat",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,
@@ -32,7 +32,7 @@
}, },
{ {
"parameters": { "parameters": {
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/procedures", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/procedures",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"options": { "options": {
@@ -57,7 +57,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/heartbeat", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/heartbeat",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,
@@ -164,7 +164,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/procedures-sync-result", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/procedures-sync-result",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,
+2 -2
View File
@@ -35,7 +35,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/return-callback",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendHeaders": true, "sendHeaders": true,
@@ -72,7 +72,7 @@
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/heartbeat", "url": "https://fleetops.itworx.tech/api/v1/integrations/n8n/heartbeat",
"authentication": "genericCredentialType", "authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth", "genericAuthType": "httpHeaderAuth",
"sendBody": true, "sendBody": true,