Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,9 @@ JVSPATIAL_LOG_DB_PATH=./jvspatial_logs
# JVSPATIAL_AWS_SQS_QUEUE_URL=
# Optional: skip mounting POST …/_internal/deferred (e.g. alternate worker entry)
# JVSPATIAL_DEFERRED_INVOKE_DISABLED=false
# Optional: require X-JVSPATIAL-Deferred-Authorize or Authorization: Bearer …
# Required for non-loopback callers of POST …/_internal/deferred (header
# X-JVSPATIAL-Deferred-Authorize or Authorization: Bearer …). LWA self-invoke
# from 127.0.0.1 is always allowed without this secret.
# JVSPATIAL_DEFERRED_INVOKE_SECRET=
# Work-claim lease TTL for persistent background work (seconds)
# JVSPATIAL_WORK_CLAIM_STALE_SECONDS=600
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`(sort_value, id)` cursor — the default implementation tracks `id` only, so a
non-`id` sort drops records that sort late but carry a lower `id`.

## [0.0.15] - 2026-07-31

### Fixed

- **Deferred-invoke LWA self-invoke 401** (`jvspatial/api/deferred_invoke_route.py`).
Fail-closed when `JVSPATIAL_DEFERRED_INVOKE_SECRET` is unset rejected
Lambda Web Adapter pass-through from `127.0.0.1`, so serverless WhatsApp
(and other deferred tasks) never ran after scheduling. Loopback peers are
now always authorized; non-loopback callers still require the secret (or
get 401 when it is unset). Coverage in
`tests/api/test_deferred_invoke_fail_closed_audit.py`.

## [0.0.11] - 2026-07-02

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/md/environment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ See the [Caching Documentation](caching.md) for detailed information about cache
|----------|------|---------|-------------|
| `SERVERLESS_MODE` | boolean | auto | Force serverless-safe runtime behavior. When unset, auto-detects AWS Lambda (`AWS_LAMBDA_RUNTIME_API` / `AWS_LAMBDA_FUNCTION_NAME`) and other common serverless runtimes. |
| `JVSPATIAL_DEFERRED_INVOKE_DISABLED` | boolean | `false` | When true, `register_deferred_invoke_route` does not mount `POST …/_internal/deferred`. |
| `JVSPATIAL_DEFERRED_INVOKE_SECRET` | string | _(empty)_ | When set, deferred-invoke HTTP requests must send this value via `X-JVSPATIAL-Deferred-Authorize` or `Authorization: Bearer …`. |
| `JVSPATIAL_DEFERRED_INVOKE_SECRET` | string | _(empty)_ | Required for non-loopback callers of `POST …/_internal/deferred` (`X-JVSPATIAL-Deferred-Authorize` or `Authorization: Bearer …`). Loopback (LWA self-invoke) is always allowed without this secret. |
| `JVSPATIAL_WORK_CLAIM_STALE_SECONDS` | float | `600` | Default TTL for work-claim leases (`claim_record`). After this many seconds another worker can re-claim the document. |

Use `is_serverless_mode()` from `jvspatial` or `jvspatial.runtime.serverless` to check at runtime. With no argument, `is_serverless_mode()` uses `get_current_server().config` when the server context is set (see serverless-mode docs):
Expand Down
2 changes: 1 addition & 1 deletion docs/md/serverless-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Direct Lambda invocations (async invoke and EventBridge targets) deliver a JSON
- **Canonical path**: `{JVSPATIAL_API_PREFIX}/_internal/deferred` (default **`/api/_internal/deferred`**), exposed by `register_deferred_invoke_route()` when core routes are registered (`AppBuilder.register_core_routes`). If you assemble a `FastAPI` app without that path, call `jvspatial.api.deferred_invoke_route.register_deferred_invoke_route(app)` yourself.
- **Disable**: set `JVSPATIAL_DEFERRED_INVOKE_DISABLED=true` to skip registering the route (e.g. when another entrypoint handles deferred work).
- **Dispatch**: the body must be a JSON object with a string **`task_type`**. jvspatial dispatches to handlers registered via **`register_deferred_invoke_handler(task_type, fn)`** or **`@deferred_invoke_handler("…")`** (also exported from top-level **`jvspatial`**). Unknown `task_type` yields HTTP 404.
- **Auth**: The deferred HTTP path is **always exempt from `AuthenticationMiddleware`** (JWT/API key): Lambda async invoke bodies cannot carry your app’s `Authorization` header. Optional **`JVSPATIAL_DEFERRED_INVOKE_SECRET`** is checked **inside** the deferred route only. If that secret is set, each request must send the same value in **`X-JVSPATIAL-Deferred-Authorize`** or **`Authorization: Bearer <secret>`**; otherwise the route returns 401. For same-function self-invoke, leave the secret unset unless you inject headers in infra. Still prefer private network / VPC boundaries for production.
- **Auth**: The deferred HTTP path is **always exempt from `AuthenticationMiddleware`** (JWT/API key): Lambda async invoke bodies cannot carry your app’s `Authorization` header. Inside the deferred route: **loopback peers** (`127.0.0.1` / `::1` / `localhost` — LWA pass-through self-invoke) are always allowed. **Non-loopback** callers (Function URL / API Gateway) require **`JVSPATIAL_DEFERRED_INVOKE_SECRET`** via **`X-JVSPATIAL-Deferred-Authorize`** or **`Authorization: Bearer <secret>`**; if the secret is unset, those callers get 401 (fail-closed). Still prefer private network / VPC boundaries for production.
- **LWA environment (best-effort)**: when `is_serverless_mode()` is true, `detect_serverless_provider() == "aws"`, and LWA is detected (e.g. `AWS_LWA_PORT` or `AWS_LAMBDA_EXEC_WRAPPER` indicating the adapter), **`apply_aws_lwa_env_defaults()`** (`jvspatial.runtime.lwa`) runs from **`Server.__init__`** and uses `os.environ.setdefault` for **`AWS_LWA_PASS_THROUGH_PATH`** (same path rule as `{JVSPATIAL_API_PREFIX}/_internal/deferred`) and **`AWS_LWA_INVOKE_MODE=RESPONSE_STREAM`**. Set **`JVSPATIAL_LWA_ENV_DEFAULTS=true`** to force these defaults if detection misses; **`JVSPATIAL_LWA_ENV_DEFAULTS=false`** to disable. The LWA extension may still read env before Python starts, so **set them in Lambda / IaC** when you need guarantees.
- **EventBridge default (best-effort)**: when `is_serverless_mode()` is true and `detect_serverless_provider() == "aws"`, **`apply_aws_eventbridge_env_default()`** (`jvspatial.runtime.lwa`) runs from **`Server.__init__`**. If **`JVSPATIAL_EVENTBRIDGE_SCHEDULER_ENABLED`** is absent, it sets **`true`** or **`false`** based on whether EventBridge prerequisites are satisfied (see `jvspatial.runtime.eventbridge_readiness`). Provide **`JVSPATIAL_EVENTBRIDGE_ROLE_ARN`** and either **`JVSPATIAL_EVENTBRIDGE_LAMBDA_ARN`** or **`AWS_LAMBDA_FUNCTION_NAME`** + **`AWS_ACCOUNT_ID`** (+ region) in IaC when you want scheduler-backed `run_at`.

Expand Down
48 changes: 37 additions & 11 deletions jvspatial/api/deferred_invoke_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import hmac
import logging
from typing import Any, Dict
from typing import Any, Dict, Optional

from fastapi import FastAPI, HTTPException, Request

Expand All @@ -20,27 +20,52 @@

_DEFERRED_INVOKE_REGISTERED_ATTR = "_jvspatial_deferred_invoke_route_registered"

# LWA forwards non-HTTP (async self-invoke / EventBridge) payloads as POST from
# the local adapter process. Those requests cannot carry custom auth headers.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})


def _deferred_invoke_disabled() -> bool:
return env("JVSPATIAL_DEFERRED_INVOKE_DISABLED", default=False, parse=parse_bool)


def _client_host(request: Request) -> Optional[str]:
client = request.client
if client is None:
return None
host = (client.host or "").strip().lower()
return host or None


def _is_loopback_client(request: Request) -> bool:
"""True when the peer is LWA / local adapter (not API Gateway / public HTTP)."""
host = _client_host(request)
return host in _LOOPBACK_HOSTS


def _deferred_invoke_secret_ok(request: Request) -> bool:
"""Authorize the internal deferred-invoke endpoint.

Fail-closed when ``JVSPATIAL_DEFERRED_INVOKE_SECRET`` is unset or
empty: the previous "no secret = allow everything" semantics were
a footgun — a misconfigured deployment exposed the internal
endpoint to any caller (audit §4.16 / SPEC §15.2). Disable the
route entirely via ``JVSPATIAL_DEFERRED_INVOKE_DISABLED=true`` if
you do not need it.
Lambda Web Adapter self-invoke POSTs from loopback without auth headers, so
loopback peers are always allowed. Non-loopback callers (Function URL /
API Gateway) fail closed when ``JVSPATIAL_DEFERRED_INVOKE_SECRET`` is unset
or empty (audit §4.16 / SPEC §15.2); when set, they must send the value in
``X-JVSPATIAL-Deferred-Authorize`` or ``Authorization: Bearer …``.

Disable the route entirely via ``JVSPATIAL_DEFERRED_INVOKE_DISABLED=true``
if you do not need it.
"""
if _is_loopback_client(request):
return True

secret = env("JVSPATIAL_DEFERRED_INVOKE_SECRET") or ""
if not secret:
logger.warning(
"Deferred-invoke route rejected: "
"JVSPATIAL_DEFERRED_INVOKE_SECRET is unset. Either set a "
"secret or set JVSPATIAL_DEFERRED_INVOKE_DISABLED=true."
"JVSPATIAL_DEFERRED_INVOKE_SECRET is unset and peer is not "
"loopback (host=%r). Set a secret for public callers, or rely "
"on LWA self-invoke from 127.0.0.1.",
_client_host(request),
)
return False
hdr = (request.headers.get("X-JVSPATIAL-Deferred-Authorize") or "").strip()
Expand All @@ -54,8 +79,9 @@ def _deferred_invoke_secret_ok(request: Request) -> bool:
def register_deferred_invoke_route(app: FastAPI) -> None:
"""Mount the internal deferred-invoke endpoint.

When ``JVSPATIAL_DEFERRED_INVOKE_SECRET`` is set, requests must send the same
value in header ``X-JVSPATIAL-Deferred-Authorize`` or ``Authorization: Bearer …``.
Loopback callers (LWA pass-through) are always authorized. Non-loopback
callers require ``JVSPATIAL_DEFERRED_INVOKE_SECRET`` via header
``X-JVSPATIAL-Deferred-Authorize`` or ``Authorization: Bearer …``.
Set ``JVSPATIAL_DEFERRED_INVOKE_DISABLED=true`` to skip registering the route.
"""

Expand Down
2 changes: 1 addition & 1 deletion jvspatial/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
# - MAJOR: Breaking changes
# - MINOR: New features, backward compatible
# - PATCH: Bug fixes, backward compatible
__version__ = "0.0.14"
__version__ = "0.0.15"
74 changes: 63 additions & 11 deletions tests/api/test_deferred_invoke_fail_closed_audit.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,102 @@
"""Deferred-invoke fail-closed when secret unset (audit §4.16 / SPEC §15.2)."""
"""Deferred-invoke auth: fail-closed for public peers; loopback for LWA."""

from __future__ import annotations

import os
from types import SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock, patch

from jvspatial.api.deferred_invoke_route import _deferred_invoke_secret_ok
from jvspatial.api.deferred_invoke_route import (
_deferred_invoke_secret_ok,
_is_loopback_client,
)


def _fake_request(headers: dict) -> MagicMock:
def _fake_request(headers: dict, host: Optional[str] = "testclient") -> MagicMock:
req = MagicMock()
req.headers.get = lambda k, default=None: headers.get(k, default)
req.headers.__getitem__ = lambda _self, k: headers[k]
if host is None:
req.client = None
else:
req.client = SimpleNamespace(host=host, port=50000)
return req


def test_no_secret_set_denies_access():
def test_no_secret_set_denies_non_loopback():
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("JVSPATIAL_DEFERRED_INVOKE_SECRET", None)
req = _fake_request({}, host="3.16.58.158")
assert _deferred_invoke_secret_ok(req) is False


def test_no_secret_set_allows_loopback():
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("JVSPATIAL_DEFERRED_INVOKE_SECRET", None)
for host in ("127.0.0.1", "::1", "localhost", "LOCALHOST"):
req = _fake_request({}, host=host)
assert _deferred_invoke_secret_ok(req) is True, host


def test_no_client_denies_when_secret_unset():
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("JVSPATIAL_DEFERRED_INVOKE_SECRET", None)
req = _fake_request({})
req = _fake_request({}, host=None)
assert _deferred_invoke_secret_ok(req) is False


def test_matching_header_allows():
def test_loopback_allows_even_when_secret_set_without_header():
"""LWA self-invoke cannot attach custom headers; loopback must still work."""
with patch.dict(
os.environ,
{"JVSPATIAL_DEFERRED_INVOKE_SECRET": "shh"}, # pragma: allowlist secret
clear=False,
):
req = _fake_request({"X-JVSPATIAL-Deferred-Authorize": "shh"})
req = _fake_request({}, host="127.0.0.1")
assert _deferred_invoke_secret_ok(req) is True


def test_matching_bearer_allows():
def test_matching_header_allows_non_loopback():
with patch.dict(
os.environ,
{"JVSPATIAL_DEFERRED_INVOKE_SECRET": "shh"}, # pragma: allowlist secret
clear=False,
):
req = _fake_request({"Authorization": "Bearer shh"})
req = _fake_request(
{"X-JVSPATIAL-Deferred-Authorize": "shh"},
host="3.16.58.158",
)
assert _deferred_invoke_secret_ok(req) is True


def test_mismatched_secret_denies():
def test_matching_bearer_allows_non_loopback():
with patch.dict(
os.environ,
{"JVSPATIAL_DEFERRED_INVOKE_SECRET": "shh"}, # pragma: allowlist secret
clear=False,
):
req = _fake_request({"X-JVSPATIAL-Deferred-Authorize": "wrong"})
req = _fake_request(
{"Authorization": "Bearer shh"},
host="3.16.58.158",
)
assert _deferred_invoke_secret_ok(req) is True


def test_mismatched_secret_denies_non_loopback():
with patch.dict(
os.environ,
{"JVSPATIAL_DEFERRED_INVOKE_SECRET": "shh"}, # pragma: allowlist secret
clear=False,
):
req = _fake_request(
{"X-JVSPATIAL-Deferred-Authorize": "wrong"},
host="3.16.58.158",
)
assert _deferred_invoke_secret_ok(req) is False


def test_is_loopback_client_helpers():
assert _is_loopback_client(_fake_request({}, host="127.0.0.1")) is True
assert _is_loopback_client(_fake_request({}, host="testclient")) is False
assert _is_loopback_client(_fake_request({}, host=None)) is False
19 changes: 17 additions & 2 deletions tests/serverless/test_deferred_invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ async def test_dispatch_malformed_task_type(body: dict):


def test_deferred_invoke_http_route(monkeypatch):
# Audit §4.16: route is now fail-closed when the secret is unset.
# Set a secret + send matching header for the happy-path test.
# TestClient peers as host "testclient" (not loopback), so a secret +
# matching header is required for the happy path — same as API Gateway.
monkeypatch.setenv("JVSPATIAL_DEFERRED_INVOKE_SECRET", "test-secret-value")
app = FastAPI()

Expand All @@ -106,6 +106,21 @@ async def handler(event: dict) -> dict:
assert r.json() == {"ok": True, "sender": "u1"}


def test_deferred_invoke_http_rejects_unset_secret_for_non_loopback(monkeypatch):
monkeypatch.delenv("JVSPATIAL_DEFERRED_INVOKE_SECRET", raising=False)
app = FastAPI()

async def handler(event: dict) -> dict:
return {"ok": True}

register_deferred_invoke_handler("app.task", handler)
register_deferred_invoke_route(app)
path = APIRoutes.deferred_invoke_full_path()
client = TestClient(app)
r = client.post(path, json={"task_type": "app.task"})
assert r.status_code == 401


def test_deferred_invoke_http_unknown_returns_404(monkeypatch):
monkeypatch.setenv("JVSPATIAL_DEFERRED_INVOKE_SECRET", "test-secret-value")
app = FastAPI()
Expand Down