Skip to content

0.0.9#23

Merged
eldonm merged 55 commits into
mainfrom
dev
Jun 14, 2026
Merged

0.0.9#23
eldonm merged 55 commits into
mainfrom
dev

Conversation

@eldonm

@eldonm eldonm commented Jun 14, 2026

Copy link
Copy Markdown
Member

Type of Change

What type of change does this PR introduce? Mark all that apply:

  • 🐛 Bug Fix
  • 🚀 Feature Request
  • 🔄 Refactor
  • 📖 Documentation Update
  • 🔧 Other (Please specify): Security hardening, packaging/release cleanup for PyPI 0.0.9

Summary

What does this PR address?

Cuts the 0.0.9 release. It bundles a new production database backend, three new persistence/observability subsystems, a library-wide audit remediation (Waves 1–5), hot-path performance work, a Redis RCE fix, and packaging cleanup that makes pyproject.toml the single source of build truth.

  • Adds the PostgreSQL backend (asyncpg + JSONB, RLS, pgvector) — the new recommended production backend.
  • Adds schema migrations, cursor pagination, and production-hardening primitives (walker trail persistence, shared session store, OpenTelemetry tracing, graph validator).
  • Fixes ~60 audit findings spanning entity-name correctness, walker protection, auth timing oracles, path traversal, webhook HMAC, async-correctness, and query-operator parity.
  • Closes a Redis cache RCE (untrusted pickle.loads in the default JSON mode).
  • Refactors entity hot paths (~7–11× on key operations) and removes dead/dev-only surfaces.
  • Cleans packaging for PyPI: removes setup.py/requirements*.txt, fixes Requires-Python, adds missing extras, switches publishing to OIDC Trusted Publishing.

Description

Bug Fixes:

Security / correctness (highlights — full list in CHANGELOG [0.0.9]):

  • Redis cache RCE (this release): in default json serialization mode, unprefixed values fell through to pickle.loads, giving RCE to anyone able to write to the Redis keyspace. JSON mode now refuses non-JSON blobs (cache miss → recompute); legacy pickle reads require explicit opt-in on a trusted keyspace.
  • __entity_name__ corruption: save_object regenerated IDs through cls.__name__, silently corrupting IDs for any entity using the override on every save. Edge lookups/queries similarly ignored the override.
  • Timing oracle: refresh-token / password-reset hash comparison used ==; now hmac.compare_digest.
  • Path traversal: LocalFileInterface version methods bypassed sanitization — ../../etc/passwd escaped the storage root. Now routed through a sanitized base.
  • Webhook HMAC always rejected: a stray slice truncated the digest so compare_digest always returned False.
  • Async-correctness leaks: missing awaits and async def __init__ leaked coroutines in webhook/file-delete paths; blocking Path I/O in async JsonDB/graph-export bodies now offloaded to asyncio.to_thread.
  • Walker protection: limits now raise (InfiniteLoopError/WalkerTimeoutError/…) instead of silently swallowing; queue front/middle inserts now respect max_size.
  • Docs bug: authentication examples used a flat auth_enabled=True kwarg that ServerConfig silently ignores (auth stayed off); corrected to nested auth=dict(...) in README + docs/md/{authentication,api-keys,migration}.md.

Root cause theme: most findings trace to (a) cls.__name__ used where the per-subclass __entity_name__ discriminator was required, (b) == / unawaited coroutines / blocking I/O on async paths, and (c) protection/security defaults that failed open or were silently bypassed.

Feature Request:

  • PostgreSQL backend (jvspatial/db/postgres.py): JSONB schema per collection, serverless-aware pool auto-tuning, near-100% Mongo-operator pushdown (_postgres_translate.py), atomic find_one_and_* via RETURNING ... FOR UPDATE, COPY-based bulk writes, walker BFS via recursive CTE, multi-tenant Row-Level Security, and pgvector hybrid KG+metadata+vector queries ($near). Registered as create_database("postgres"|"postgresql"). Extras: [postgres], [pgvector].
  • Schema migrations (jvspatial/core/migrations.py): __schema_version__ + @migration decorator + MRO-walking chain resolution; load-path hook; jvspatial migrate CLI.
  • Cursor pagination (Database.find_iter / Object.find_iter): constant-memory keyset iteration across all backends with opaque resumable cursors; native PG implementation.
  • Production hardening: pluggable walker TrailStore (in-memory/DB) + Walker.restore() cold-start resume; shared SessionStore (in-process/Redis) so token revocations propagate across workers/Lambda; no-op-safe OpenTelemetry tracing; read-only validate_graph() invariant checker.
  • Motivation: moves jvspatial from a single-process/dev posture toward multi-worker, serverless, and large-dataset production deployments (Neon / Aurora Serverless v2).

Refactor Request:

  • Entity hot paths: per-subclass frozenset caches for hierarchy/protected/transient fields (populated at class-definition time) replace O(MRO) walks — _get_class_hierarchy_fields() ~7×, attribute set ~11×, instantiation ~1.8×. JsonDB uses orjson when available + compact writes (~1.6× scans).
  • De-duplication: Node/Edge @on_visit collection unified into _visit_hooks.register_visit_hooks; bool parsing consolidated to env.parse_bool across four modules.
  • Removals (BREAKING): JsonDBTransaction + best_effort buffered-commit mode (dev-only, weaker-than-ACID); Python 3.8 support; aiofiles runtime dep; dead JSONTransaction.

Changes Made

High-Level Summary:

  1. Add PostgreSQL backend (JSONB, RLS, pgvector, CTE traversal) + [postgres]/[pgvector] extras.
  2. Add schema-migration framework + jvspatial migrate CLI.
  3. Add cursor pagination (find_iter) across all backends.
  4. Add production-hardening primitives: trail persistence, shared session store, OTEL tracing, graph validator.
  5. Remediate ~60 audit findings (entity-name, walker protection, auth, storage, async correctness, query parity).
  6. Fix Redis cache RCE; gate legacy pickle behind explicit opt-in.
  7. Refactor entity hot paths for 7–11× wins; remove dev-only/dead surfaces (BREAKING).
  8. Packaging: drop setup.py/requirements*.txt, single-source pyproject.toml, fix Requires-Python >=3.9, add cache/complete all extras, switch publishing to OIDC Trusted Publishing.
  9. Docs: new Postgres/RLS/vector/Neon/Aurora/migrations/cursor-pagination guides; fix broken auth examples.

Checklist

Mark all that apply:

  • Code follows the project's coding guidelines.
  • Tests have been added or updated for new functionality.
  • Documentation has been updated (if applicable).
  • Existing tests pass locally with these changes.
  • Any dependencies introduced are justified and documented.

Steps to Test

  1. pip install -e '.[dev,test]' then pytest -q — full suite passes (PG live-DB tests skip gracefully without a reachable Postgres; benchmarks excluded by default).
  2. Packaging: python -m build && twine check dist/* — both artifacts PASS; confirm wheel METADATA shows Version: 0.0.9 and Requires-Python: >=3.9. Confirm pip install 'dist/…​.whl[cache]' resolves redis.
  3. Postgres (optional): with a PG 16 + pgvector container, run pytest tests/db/test_postgres_integration.py — CRUD, operator pushdown, walker traverse, RLS tenant isolation, pgvector ANN + hybrid queries.
  4. Migrations: jvspatial migrate --collection <c> --import-module <m> --dry-run then --apply.
  5. Redis security: in default JSON mode, write an unprefixed pickle blob to a key and confirm get() returns a miss (no unpickle); set JVSPATIAL_REDIS_ALLOW_LEGACY_PICKLE=true and confirm legacy reads work.
  6. Auth docs: copy the nested auth=dict(auth_enabled=True, jwt_secret=...) example and confirm /auth/* endpoints register and protected routes 401 without a token.

Additional Context

  • Full itemized change list with file:line and audit-section references is in CHANGELOG.md under ## [0.0.9].
  • Release mechanics: pyproject.toml is now the single metadata source; version is read from jvspatial/version.py; merge to main auto-publishes via OIDC.
  • Before merge: the PyPI Trusted Publisher must be registered (owner TrueSelph, repo jvspatial, workflow publish.yml, env blank) or the OIDC publish step will fail.

Questions or Concerns

  • BREAKING changes in this release (require a major-ish note for downstreams): walker protection now raises instead of swallowing; JsonDBTransaction removed; Python 3.8 dropped; deferred-invoke route fails closed without a secret; ObjectPager no longer caches; Walker.skip() raises TraversalSkipped. Confirm downstreams are prepared.
  • Two intentional security defaults left as-is — flag if you want them changed or release-noted: (1) token-revocation blacklist is fail-open (blacklist_fail_closed=False); (2) open Dynamic Client Registration (/oauth/register, unauthenticated + rate-limited, opt-in for MCP).
  • Confirm whether a 0.0.8 line should appear in the changelog — it currently jumps 0.0.9 → 0.0.7.

Want this written to a file (e.g. .github/PR_0.0.9.md) or opened directly as a PR with gh pr create (dev → main)?

eldonm and others added 30 commits May 26, 2026 11:53
PostgresDB joins JsonDB/SQLite/MongoDB/DynamoDB as a first-class
backend. Targets Neon, Aurora Serverless v2, and self-hosted PG 15+.

- jvspatial/db/postgres.py — asyncpg pool with auto-tuned sizing
  (Lambda: 0/3, long-running: 2/10), session/transaction pooler modes,
  schema-scoped tables, JSONB storage, vector column support, native
  $near peeling, multi-tenant RLS via current_tenant contextvar,
  transactional save/get/find/delete via PostgresTransaction, and
  drop_deprecated_indexes for migrations.
- jvspatial/db/_postgres_translate.py — Mongo-style query → SQL
  translator with positional-placeholder builder.
- jvspatial/db/factory.py — register "postgres"/"postgresql" with
  env-driven defaults (DSN, pool sizes, pooler mode).
- jvspatial/db/transaction.py — backend-agnostic transaction_context.
- docker/ — local dev pgvector container + .env example.
- docs/md/postgres-guide.md, neon-deployment.md,
  aurora-serverless-deployment.md, multi-tenant-rls.md,
  cursor-pagination.md, schema-migrations.md, vector-store.md.
- tests/db/test_postgres_{unit,integration,translate}.py,
  tests/db/test_cursor_pagination.py.

Companion changes pulled in:
- jvspatial/core/migrations.py + tests — record-level schema migrations.
- jvspatial/core/validate.py + tests — graph-shape validation.
- jvspatial/core/entities/walker_components/trail_store.py + tests —
  pluggable walker trail persistence.
- jvspatial/api/auth/_session_store.py + tests — pluggable auth session
  store (in-process / Redis) for multi-worker deployments.
- jvspatial/observability/tracing.py + tests — OTel span helpers.
- jvspatial/cli.py — `jvspatial migrate` CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authlib-based OAuth 2.1 AS+RS layered on jvspatial's existing auth.
RS256+JWKS access tokens, DCR, PRM/AS metadata, audience binding.
Foundation for OAuth-secured MCP (M2 = Integral MCP endpoint, M3 = UI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add OAuthSigningKey Object model and keys.py module: RSA-2048
keypair generation, persist/load via Object.find, idempotent
ensure_signing_key(), and JWKS builder (public keys only). All
three TDD tests pass. PyJWT 2.x to_jwk requires cryptography key
object; PEM loaded via serialization.load_pem_public_key before
conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add oauth_enabled, oauth_issuer_url, oauth_prefix, oauth_supported_scopes,
oauth_dcr_enabled, oauth_access_token_ttl_minutes, oauth_code_ttl_seconds,
and accept_oauth_bearer to AuthConfig. All default off/empty so existing
apps are unaffected. Covered by two-test TDD suite in
tests/api/auth/oauth/test_oauth_authconfig.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements OAuthClientAdapter wrapping OAuthClient so Authlib can
validate redirect URIs, grant/response types, scope filtering, and
token endpoint auth methods (public PKCE + confidential secret check).
All three tests (redirect/grant checks, public auth method + scope
filter, confidential secret check) pass; black/isort/flake8/mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…io-bridged)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds RequiredS256CodeChallenge subclass that overrides the authorize-step
hook to reject missing code_challenge (regardless of client auth method)
and reject code_challenge_method=plain. Registers it in
build_authorization_server instead of CodeChallenge(required=True), which
only enforced PKCE for public clients (auth_method=="none").

Two regression tests added: confidential client without PKCE is rejected,
and plain code_challenge_method is rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire _generate_refresh_token into JvSpatialJWTTokenGenerator so Authlib
emits an opaque rt_ token when client.check_grant_type("refresh_token")
is true; implement save_token to persist the plaintext (hashed) via
refresh_store.mint_refresh_token through a positional async shim
(_persist_refresh) compatible with call_async's positional-only contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-use code

Three hardening fixes from the M1b-2 spec §6:

1. scope∩permissions — `save_authorization_code` now reads `permissions`
   from `request.user` (when the grant_user dict carries it) and stores
   only the intersection of the client-allowed scope and the user's
   permissions in `AuthorizationCode.scope`.  Back-compat: callers that
   pass `{"id":...}` without `permissions` keep the full client scope.
   Because `create_token_response` reads scope from
   `authorization_code.get_scope()`, the intersected scope propagates
   to the token `scope` claim automatically with no generator changes.

2. nbf claim — `JvSpatialJWTTokenGenerator.get_extra_claims` overrides
   the Authlib base (which returns `{}`) to inject `"nbf": int(time.time())`.
   `access_token_generator` merges extra claims via `token_data.update(...)`,
   so `nbf` lands in every issued JWT.  super() is called first so any
   future upstream additions are preserved.

3. atomic single-use — `query_authorization_code` calls `_consume_code`
   (sets `consumed=True` + saves) *before* returning the `StoredAuthCode`
   to the grant.  A concurrent duplicate exchange sees `consumed=True` on
   the first load and is rejected.  `delete_authorization_code` remains
   idempotent (Authlib still calls it after token issuance; the record is
   already consumed at that point, which is harmless).

All 9 server-flow tests pass (incl. 2 new: scope-intersection+nbf,
back-compat no-permissions); all 24 OAuth tests pass.
…ckout)

Move single-use enforcement from query_authorization_code (runs before
redirect_uri + PKCE validation) to delete_authorization_code (runs only
after all validation passes on the success path, per authlib 1.7.2
authorization_code.py:create_token_response). A request with a wrong
code_verifier or wrong redirect_uri previously burned the code, locking
out the legitimate client's retry. Now query_authorization_code is
read-only; delete_authorization_code is the single-use point.

Also corrects the atomicity docstring: single-use is serialized by the
single-process anyio bridge, not a DB-level CAS; multi-worker deployment
will require a conditional-update primitive (tracked for a later phase).

Adds regression test test_failed_verifier_does_not_burn_code_for_retry
proving a failed verifier does NOT lock out a correct retry, and confirms
the existing single-use replay test still rejects a replayed code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements OAuth 2.1 §rotation family kill (review finding I-3).
When a rotated/revoked refresh token is replayed, the entire rotation
family is immediately revoked (including the live child token), and the
request is rejected. Normal rotation is unaffected.

- OAuthRefreshToken: +family_id field (shared across rotations of one grant)
- refresh_store: mint_refresh_token gains family_id param; add find_any
  (lookup without is_active filter for replay detection) and revoke_family
  (kill all tokens in a family)
- server.py: authenticate_refresh_token stashes rec.family_id on
  request._oauth_family_id; save_token forwards it to _persist_refresh so
  the rotated token inherits the family; fresh uuid4 family_id generated at
  first mint (auth-code path). Replay of revoked token triggers revoke_family.
Implements JvSpatialClientRegistrationEndpoint (open registration,
MCP zero-config) + async_register_client on the authorization server.
JSON body wired via BasicOAuth2Payload on request.payload before
dispatching; public clients (auth_method=none) receive no client_secret
in the 201 response and store no secret hash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure builder function ``build_as_metadata`` constructs the RFC 8414
Authorization Server metadata document (issuer, endpoints, PKCE,
grant types). Validated against ``authlib.oauth2.rfc8414.AuthorizationServerMetadata``
in the accompanying TDD test. Route to serve the document at
``/.well-known/oauth-authorization-server`` is deferred to M1b-3b.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-limit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… key hook

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…known metadata-prefix bug)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test

build_as_metadata gains api_prefix kwarg (default "" for back-compat); the
well-known handler now passes APIRoutes.PREFIX so token/registration/revocation/
authorization endpoints are advertised as https://as.example/api/oauth/…
instead of missing the /api segment.  register route pre-reads JSON body then
builds a StarletteOAuth2Request with empty form to avoid double-read error.
New test_oauth_http_flow.py covers both assertions over TestClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
eldonm and others added 23 commits June 3, 2026 12:13
Implement the real GET/POST /oauth/authorize flow, replacing the HTML
placeholder. GET renders a consent page (client name + requested scopes +
approve/deny form re-carrying the OAuth params); POST builds grant_user from
the bearer-authenticated session user ONLY — its id plus effective permissions
via get_effective_permissions(roles, permissions, role_permission_mapping) —
and calls async_create_authorization_response so the authorization server
intersects scope with those permissions. Deny redirects with
error=access_denied. Permissions are never read from client/request input,
making the scope∩permissions narrowing the trust boundary.

- Thread get_current_user from the auth configurator into build_oauth_routers
  and Depends() it on both authorize routes (401 without bearer).
- Add JvSpatialAuthorizationServer.async_get_consent_grant to validate the
  authorize request off-thread (anyio bridge) and surface the client/scope for
  rendering; invalid requests yield a 400 page (no open redirect).
- Optional auth_config.oauth_consent_handler override; 503 when no
  get_current_user is wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `oauth_dcr_rate_limit_per_minute` knob (default 12) to AuthConfig and
wire a tight rate-limit override onto `/api/oauth/register` at OAuth-configure
time — before `get_app()` builds the rate-limit middleware — so the MemoryRateLimitBackend
caps DCR bursts with a 429. When the server operator has not enabled rate limiting
globally, the flag is implicitly set to True so the override takes effect.
Existing auth and OAuth tests (42) remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rect)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…earer)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `build_prm()` and `www_authenticate_header()` to metadata.py.
Mounts `/.well-known/oauth-protected-resource` on `well_known_router`
(active only when `oauth_enabled=True`). Exempts the PRM path from the
bearer-auth middleware unconditionally so FastAPI returns a natural 404
when OAuth is disabled, matching the existing AS-metadata + JWKS pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t_oauth_bearer)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… discovery)

When `accept_oauth_bearer=True`, the middleware now attaches
`WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource"`
to every 401 response (missing bearer AND failed-auth paths), so MCP
clients can auto-discover the Authorization Server per RFC 9728 §5.1 /
RFC 6750 §3.  Header is absent when `accept_oauth_bearer=False` (no
change for non-OAuth servers).  Import is lazy/guarded to avoid
coupling when the oauth subpackage is unavailable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gin SPA

When AuthConfig.oauth_authorize_login_redirect is set, an unauthenticated
GET /oauth/authorize now 302-redirects the browser to that SPA login URL
with the original OAuth query string appended, instead of returning the
legacy 401 JSON. This makes the OAuth authorization-code browser flow
completable. Empty (default) preserves the legacy 401 behavior.

The GET handler uses a non-raising optional-user seam (_optional_current_user)
that invokes get_current_user imperatively and converts its 401 to None, so
it can branch on the unauthenticated case. POST /authorize and the authed-GET
consent page are unchanged (still bearer-required). The redirect target is
ONLY the trusted config base + the original query — never a request-derived
host — so it cannot be turned into an open redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dening

Make the authorization-code scope grant secure-by-default once a scope
ceiling is declared, while staying a strict no-op for callers that do
not declare one.

- build_authorization_server / JvSpatialAuthorizationServer gain an
  optional supported_scopes; stored as self._supported_scopes (defaults
  to []). routes.py wires auth_config.oauth_supported_scopes through.
- save_authorization_code intersects the permission-narrowed scope
  against the supported set when non-empty (empty => skip, back-compat).
- _intersect_scope: permissions is None => no narrowing (absent key,
  back-compat); '*' in permissions => authorize all requested scope
  (admin wildcard, fixes the inverted-admin footgun); else membership
  filter. A present-but-empty [] now narrows to nothing.
- save_authorization_code no longer collapses []->None, so an explicit
  empty permission set denies all scope.

BEHAVIOUR CHANGE (for CHANGELOG in M4-T6): a present-but-empty
request.user['permissions'] == [] now grants NO scope (was: full
requested scope). Absent key still preserves full scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the authorization server declares a supported-scope ceiling
(server._supported_scopes non-empty), the DCR endpoint now silently
intersects a client's requested scope against that set in
extract_client_metadata, so a client cannot self-register an unsupported
or elevated scope. Unsupported tokens are dropped (RFC 7591 permits the
AS to filter) rather than rejected, keeping zero-config MCP registration
working. Filtering in extract_client_metadata (not save_client) keeps a
single source of truth so both the persisted record and the echoed 201
scope reflect the filtered value.

Back-compat: an empty/undeclared ceiling is a no-op — requested scope is
persisted verbatim. This is defense-in-depth; the authorize-time ceiling
in JvSpatialAuthCodeGrant.save_authorization_code (M4-T1) remains the
primary bound on issued-token scope.

scopes_supported is deliberately NOT advertised in get_server_metadata:
Authlib's get_claims_options turns it into a hard issuperset validation
that would 400 an out-of-set scope before our silent filter runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the blind `consumed = True; save()` in _consume_code with an
atomic compare-and-swap (find_one_and_update predicated on consumed=False),
mirroring the work_claim.py idiom. On a lost race (None result) the consume
raises InvalidGrantError — the same error a replayed/consumed code produces
via query_authorization_code — so the loser never gets a second token.

Consume still runs after PKCE/redirect validation (failed verifier does
not burn the code). Atomic on postgres (SELECT ... FOR UPDATE) and mongo
(native findOneAndUpdate); best-effort on sqlite/json (no regression vs
the prior blind write). Drop the consumed record from the GraphContext
cache so a stale cached entry can't let a replay slip through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7a — Drop the attacker-controlled User-Agent from the unauthenticated
rate-limit bucket key. The key is now the source IP alone (sha256-hashed):
folding in the UA let an attacker rotate the header per request to mint a
fresh bucket and evade the per-IP cap. Authenticated `user:{id}` path is
unchanged. (X-Forwarded-For trusted-proxy parsing remains deferred as 7b;
`request.client.host` stays as-is, documented as a known proxy limitation.)

3 — Declare `rate_limit_backend` as a real Optional[RateLimitBackend] field
on ServerConfig (default None -> process-local MemoryRateLimitBackend, behaviour
unchanged), replacing the prior getattr lookup. ServerConfig now allows
arbitrary types so the Protocol-typed backend can be stored. Documents the
multi-worker limitation (in-memory counter is per-process, so effective cap is
N x configured under N workers — including the DCR /oauth/register cap) on the
field, the server_configurator selection site, and _wire_dcr_rate_limit.

Tests: UA-independence of the identifier + same-IP shared bucket (no UA-rotation
evasion); rate_limit_backend field declared/defaults-None/accepts-instance and
server_configurator consumes a supplied backend (falls back to Memory otherwise).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Access tokens are stateless RS256 JWTs that previously stayed valid until
their exp regardless of /oauth/revoke. Add a per-token jti denylist so a
holder can revoke a specific access token before expiry:

- OAuthRevokedToken Object (jti + self-expiring expires_at = token exp)
- denylist.py store: revoke_jti (idempotent) / is_jti_revoked
- resource.verify_oauth_access_token: require jti + reject denylisted jtis
  (Authlib's RFC-9068 generator always stamps jti; verified by decoding a
  freshly-minted token). One indexed lookup per RS validation (hot-path note;
  in-proc cache is a future optimization).
- revocation endpoint: when an access-token JWT is presented (or
  token_type_hint=access_token), validate it via the same verify path and
  denylist its jti. Refresh-token revocation unchanged. Still requires
  client-auth + token possession (check_client on the client_id claim), so a
  caller can only revoke a token they hold.

Per-token only; mass revocation (per-(user,client) revoked-after watermark)
is a separate future item since stateless jtis can't be enumerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the five M4 fixes (scope ceiling, DCR scope filter, auth-code CAS,
IP-only rate-limit key + pluggable backend, jti denylist), the two intentional
behavior changes (permissions:[] => deny scope; IP-only rate-limit key), the
holistic adversarial review verdict (no over-grant on any branch, no fail-open,
back-compat preserved, no cross-cutting bypass), and the two documented
deferrals (Item 5 RFC-8707 per-request resource->aud; Item 7b trusted-XFF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsent ceiling fix 1bb5f5a)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ferences

jvspatial is a generic application framework; remove the two remaining
authoritative references to a specific downstream app (integral):
- db/postgres.py: partial-filter docstring now says 'jvspatial and its
  downstream applications' instead of naming jvagent/integral
- docs/md/multi-tenant-rls.md: RLS is 'a load-bearing isolation primitive
  for any multi-tenant platform built on it', not 'the Integral platform'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mes in jvspatial)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nion to the COMPLETE record)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make pyproject.toml the single source of build metadata and fix the
metadata/packaging issues found in the pre-release review.

Packaging:
- Remove setup.py; resolve version dynamically via
  [tool.setuptools.dynamic] from jvspatial/version.py. Fixes a
  dual-source conflict where the wheel published Requires-Python
  >=3.8 (EOL) while the project targets >=3.9.
- Add the `cache` extra (redis[hiredis]) so `pip install
  jvspatial[cache]` works; it backs jvspatial.cache.redis.
- Complete the `all` extra: lambda, postgres, pgvector, otel, cache,
  scheduler.
- Remove the redundant top-level requirements*.txt files; deps live
  solely in pyproject.toml extras.

CI / release:
- publish.yml: switch from API-token upload to PyPI Trusted
  Publishing (OIDC) via pypa/gh-action-pypi-publish; add id-token:
  write; drop dead setup.py/requirements config-change grep.
- security.yml: drop the now-inert requirements*.txt path filters.

Docs / templates:
- CHANGELOG: promote [Unreleased] to [0.0.9] - 2026-06-14 with a
  Packaging section.
- bug_report.md: drop downstream Jaclang/Jivas fields; add OS, DB
  backend, serverless mode.
- Update workflow docs (README/VERSIONING/WORKFLOW_SUMMARY),
  RELEASING.md, and docs/md to drop setup.py/requirements*.txt
  references.

Verified: clean sdist+wheel build, twine check PASSED, fresh-venv
install + import + CLI all green; wheel METADATA shows version 0.0.9
and Requires-Python >=3.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x auth docs

Security:
- jvspatial/cache/redis.py: in the default `json` serialization mode the
  deserializer fell through to pickle.loads on any unprefixed value, so
  anyone able to write to the Redis keyspace could achieve RCE even with
  the "safe" default. JSON mode now refuses non-JSON values (raised and
  caught by get() as a cache miss -> recompute). Legacy pickle entries are
  readable only via explicit opt-in (allow_legacy_pickle=True /
  JVSPATIAL_REDIS_ALLOW_LEGACY_PICKLE=true) on a trusted keyspace.
  Explicit `pickle` mode is unchanged.

Docs:
- README + docs/md/{authentication,api-keys,migration}.md used a flat
  `auth_enabled=True` kwarg that ServerConfig silently ignores (auth
  stayed disabled). Corrected to the nested `auth=dict(...)` form.

Cleanup:
- jvspatial/cache/layered.py: replace print() Redis-unavailable warnings
  with logging.warning.
- .env.example: document JVSPATIAL_REDIS_ALLOW_LEGACY_PICKLE.
- tests: cover safe-default rejection, opt-in legacy read, and env toggle.

CHANGELOG: 0.0.9 Security/Fixed entries.
@eldonm eldonm self-assigned this Jun 14, 2026
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.050066 0.019121 -61.8% IMPROVED (-61.8%)
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.052645 0.018853 -64.2% IMPROVED (-64.2%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.541573 0.409950 -24.3% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.159199 0.759531 -34.5% IMPROVED (-34.5%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.361629 0.704422 -48.3% IMPROVED (-48.3%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 1.017479 0.563482 -44.6% IMPROVED (-44.6%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001761 0.001176 -33.2% IMPROVED (-33.2%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.327689 0.118848 -63.7% IMPROVED (-63.7%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.389060 0.147995 -62.0% IMPROVED (-62.0%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.327183 0.131550 -59.8% IMPROVED (-59.8%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.347182 0.144949 -58.2% IMPROVED (-58.2%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.340361 0.124088 -63.5% IMPROVED (-63.5%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.355369 0.141189 -60.3% IMPROVED (-60.3%)

Two independent CI failures on the 0.0.9 PR.

1. Starlette >=0.52 broke auth/OpenAPI route introspection (4 failures).
   Newer Starlette wraps `app.include_router(...)` in an `_IncludedRouter`
   object instead of flattening the included APIRoutes into `app.routes`.
   jvspatial's auth resolver only scanned top-level APIRoute instances, so
   included routes (e.g. the file-storage GET/DELETE endpoints) looked
   unregistered -> deny-by-default -> spurious 401s; OpenAPI security
   padlocks were also dropped. Added `jvspatial/api/_route_utils.py`
   (`iter_api_routes`) which recurses through `_IncludedRouter`
   (`.original_router.routes`) and mounts, and routed every route-table
   scan through it: endpoint auth resolver (3 sites), OpenAPI security
   wiring, dynamic-walker registration, and duplicate-route detection.
   Compatible with old and new Starlette. Reproduced and verified fixed
   in a python:3.12-slim container (fastapi 0.137 / starlette 0.52);
   does not regress the older local stack (fastapi 0.119 / starlette 0.48).

2. Postgres unit tests errored when asyncpg absent (14 failures).
   `tests/db/test_postgres_unit.py` now `importorskip("asyncpg")` so it
   skips cleanly instead of erroring; CI installs the postgres+pgvector
   extras so the unit tests actually run (translator pushdown, pool
   auto-tuning, vector encoding). Live-DB integration tests still skip
   without JVSPATIAL_POSTGRES_TEST_DSN.

CHANGELOG: 0.0.9 Fixed entry for the Starlette compatibility fix.
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.050066 0.049014 -2.1% OK
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.052645 0.049611 -5.8% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.541573 0.512294 -5.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.159199 1.048997 -9.5% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.361629 1.172011 -13.9% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 1.017479 0.905257 -11.0% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001761 0.001687 -4.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.327689 0.368074 +12.3% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.389060 0.402921 +3.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.327183 0.370229 +13.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.347182 0.401383 +15.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.340361 0.361416 +6.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.355369 0.401269 +12.9% OK

Starlette 0.52.x carries PYSEC-2026-161 (fixed in 1.0.1); the previous
`starlette<1.0.0` cap pinned installs to the vulnerable line and failed
the pip-audit CI gate. The cap's original rationale (Starlette 1.0 drops
Router on_startup/on_shutdown) does not apply: jvspatial wires lifecycle
hooks through its own LifecycleManager (FastAPI lifespan), and route
introspection is version-agnostic via _route_utils.iter_api_routes.

Dependency is now `starlette>=0.46.0` (matches FastAPI's own floor, no
upper cap). Fresh installs resolve to a patched Starlette (verified 1.3.1).
Full suite green against Starlette 1.3.1; pip-audit reports no
vulnerabilities with the CI-exact install.
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.050066 0.049009 -2.1% OK
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.052645 0.046124 -12.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.541573 0.525376 -3.0% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.159199 1.072867 -7.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.361629 1.165536 -14.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 1.017479 0.935477 -8.1% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001761 0.001679 -4.7% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.327689 0.342670 +4.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.389060 0.400577 +3.0% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.327183 0.389692 +19.1% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.347182 0.384618 +10.8% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.340361 0.342060 +0.5% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.355369 0.412024 +15.9% OK

@eldonm eldonm merged commit 9b3fac2 into main Jun 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants