Metadata-Version: 2.4
Name: stapel-core
Version: 0.28.0
Summary: Core Django utilities for the Stapel framework
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-core
Project-URL: Repository, https://github.com/usestapel/stapel-core
Project-URL: Documentation, https://github.com/usestapel/stapel-core#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-core/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-core/issues
Keywords: django,stapel,auth,jwt
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django<6.1,>=5.1
Requires-Dist: djangorestframework>=3.14
Requires-Dist: djangorestframework-dataclasses>=1.2
Requires-Dist: drf-spectacular>=0.27
Requires-Dist: django-cors-headers>=4.3
Requires-Dist: django-redis>=5.4
Requires-Dist: PyJWT>=2.8
Requires-Dist: cryptography>=41.0
Requires-Dist: requests>=2.31
Requires-Dist: phonenumbers>=8.13
Requires-Dist: jsonschema>=4.18
Provides-Extra: kafka
Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
Provides-Extra: nats
Requires-Dist: nats-py>=2.9; extra == "nats"
Provides-Extra: redis-bus
Requires-Dist: redis>=5; extra == "redis-bus"
Provides-Extra: sentry
Requires-Dist: sentry-sdk>=1.40; extra == "sentry"
Provides-Extra: netintel-maxmind
Requires-Dist: geoip2>=4.7; extra == "netintel-maxmind"
Provides-Extra: gateway
Requires-Dist: jsonschema>=4.18; extra == "gateway"
Provides-Extra: channels
Requires-Dist: channels>=4.0; extra == "channels"
Provides-Extra: media
Requires-Dist: Pillow>=9.0; extra == "media"
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Requires-Dist: celery>=5.3; extra == "dev"
Requires-Dist: fakeredis>=2.20; extra == "dev"
Provides-Extra: all
Requires-Dist: stapel-core[channels,gateway,kafka,nats,netintel-maxmind,redis-bus,sentry]; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-core

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-core/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-core/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-core?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-core)
[![pypi](https://img.shields.io/pypi/v/stapel-core?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-core/)
[![downloads](https://static.pepy.tech/badge/stapel-core/month)](https://pepy.tech/project/stapel-core)
[![python](https://img.shields.io/pypi/pyversions/stapel-core?logo=python&logoColor=white)](https://pypi.org/project/stapel-core/)
[![license](https://img.shields.io/github/license/usestapel/stapel-core)](https://github.com/usestapel/stapel-core/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-core/blob/main/docs/llms.txt)

> The Django substrate every Stapel module sits on: comm (Action/Function/Task/Projection inter-module communication over a transactional outbox), the transport-agnostic bus, AppSettings namespaces, step-up verification, self-documenting flows, i18n catalogs, the media/netintel/eventstore/captcha/secrets seams, the privilege gateway, the staff mandate, DRF API conventions (StapelResponse, error registry, permission classes, presenters) and the URL-mount + cross-service navigation registries. No HTTP surface of its own worth cataloguing and no CTO-facing feature axes — the core is what the feature modules are made of.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-core
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.28.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
| Django | `Django>=5.1,<6.1` |
| Usage surface | 24 |
| Extension points | 14 |
| Error codes | 42 |

## Documentation

[capabilities.json](https://github.com/usestapel/stapel-core/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-core/blob/main/docs/llms.txt)

## Quick start for a new Django service

Add to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    ...
    'stapel_core.django',
    'stapel_core.django.users',   # if using the shared User model
]
```

## Modules

### `stapel_core.captcha` — Pluggable captcha verification

Backend-agnostic captcha interface. Supports Cloudflare Turnstile, Google
reCAPTCHA v2, hCaptcha, and custom backends.

**Settings** (per service, in `settings/base.py`):

```python
STAPEL_CAPTCHA = {
    'BACKEND': env.str('CAPTCHA_BACKEND', 'turnstile'),
    'SECRET': env.str('CAPTCHA_SECRET', None),  # absent → disabled
}
```

**Auto-disable**: if the secret is `None` or empty, `build_verifier`
returns `NoopVerifier` regardless of backend. No separate toggle needed.

**DRF integration** (add mixin to any serializer):

```python
from stapel_core.django.captcha import CaptchaMixin

class MySerializer(CaptchaMixin, serializers.Serializer):
    captcha_token = serializers.CharField(required=False, allow_blank=True)

    def validate(self, attrs):
        self._require_captcha_if_configured(attrs)
        return attrs
```

**Custom backend** — subclass `CaptchaVerifier` and point to it via a dotted
import path:

```python
from stapel_core.captcha import CaptchaVerifier

class MyCaptchaVerifier(CaptchaVerifier):
    def verify(self, token: str, ip: str | None = None, *, level: str | None = None) -> bool:
        return my_service.check(token, self.secret)
```

```python
# settings.py
STAPEL_CAPTCHA = {'BACKEND': 'myapp.captcha.MyCaptchaVerifier', 'SECRET': 'my-secret'}
```

**Tiered challenge policy** — instead of a binary on/off, protect a view with
a strictness level derived from the client's network class (via
`stapel_core.netintel`):

```python
from stapel_core.django.captcha import captcha_protected

class RegisterView(APIView):
    @captcha_protected(action="register")
    def post(self, request): ...
```

Levels: `none < invisible < interactive < interactive+ratelimit < block`.
The default matrix (overridable via `STAPEL_CAPTCHA["CHALLENGE_MATRIX"]`,
merged over the defaults) maps residential/unknown → invisible,
datacenter/vpn → interactive, tor → interactive+ratelimit. Per-action
overrides: `STAPEL_CAPTCHA["ACTION_OVERRIDES"] = {"register": "+1"}` (bump one
level) or `{"payout": {"vpn": "block"}}`. The whole policy is swappable via
`STAPEL_CAPTCHA["CHALLENGE_POLICY"]` (dotted path to a `ChallengePolicy`).
`block` returns 403 `error.403.network_blocked`; rate limiting is not done
here — middleware reads `request.stapel_challenge_level`. With no netintel
provider configured every request classifies as `unknown` → behavior is
identical to the classic binary captcha.

---

### `stapel_core.netintel` — IP intelligence (network class + geo)

`classify_ip(ip) -> IpProfile{kind, asn, asn_org, country, confidence}` and
`country_of(ip)`. Kind vocabulary: `residential | datacenter | vpn | tor |
unknown`. Results are cached in the Django cache; provider errors fail open
to `unknown` and never raise.

```python
STAPEL_NETINTEL = {
    # dotted path / class / instance of a NetIntelProvider (replace seam)
    "PROVIDER": "stapel_core.netintel.providers.MaxMindProvider",
    "MAXMIND_ASN_DB": "/var/geoip/GeoLite2-ASN.mmdb",
    "MAXMIND_COUNTRY_DB": "/var/geoip/GeoLite2-Country.mmdb",
    "MAXMIND_ANONYMOUS_DB": "/var/geoip/GeoIP2-Anonymous-IP.mmdb",
}
```

Built-in providers: `NullProvider` (default — always `unknown`),
`MaxMindProvider` (offline mmdb, `pip install stapel-core[netintel-maxmind]`),
`HttpJsonProvider` (ipinfo/IPQS-style HTTP APIs via `HTTP_URL_TEMPLATE` /
`HTTP_API_KEY` / `HTTP_RESPONSE_MAPPER`). `client_ip(request)` honors
`TRUSTED_PROXY_HEADER` (default: `REMOTE_ADDR` only — proxy headers are
spoofable unless your edge overwrites them).

`residential` is a claim that requires evidence, and `MaxMindProvider` has
exactly one source of it: the Anonymous-IP database consulted and not listing
the address. Configure `MAXMIND_ANONYMOUS_DB` or the kind stays `unknown`
with `confidence=None` — a known ASN is not evidence of a residence, and the
`HOSTING_ASNS` fallback list can promote an address to `datacenter` but never
demote one to `residential`. `asn`/`asn_org`/`country` still travel with an
`unknown` profile.

System checks (W-level, never blocking): `stapel_core.netintel.W001`
(`PROVIDER` unimportable), `W002` (not a `NetIntelProvider`), `W003` (the
seam is configured or depended on, but `PROVIDER` is still the default
`NullProvider`, so every rule keyed on network class is dead code).

---

### `stapel_core.django.jwt` — JWT authentication

Unified JWT provider (singleton). Supports HS256 and RS256.

```python
from stapel_core.django.jwt.provider import jwt_provider

access, refresh = jwt_provider.create_tokens(user)
payload = jwt_provider.validate_token(access_token)
```

**Settings**:

```python
JWT_ALGORITHM    = 'HS256'           # or 'RS256'
JWT_SECRET_KEY   = 'your-secret'     # HS256
JWT_PRIVATE_KEY  = '...'             # RS256
JWT_PUBLIC_KEY   = '...'             # RS256
JWT_ISSUER       = 'https://yourapp.com'
JWT_AUDIENCE     = None
JWT_ACCESS_TOKEN_LIFETIME  = 900     # seconds
JWT_REFRESH_TOKEN_LIFETIME = 604800  # seconds
```

---

### `stapel_core.django.jwt.authentication` — JWT cookie auth

`JWTCookieAuthentication` reads JWT from `access_token` cookie or
`Authorization: Bearer <token>` header.

```python
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'stapel_core.django.jwt.authentication.JWTCookieAuthentication',
    ],
}
```

---

### `stapel_core.django.api` — DRF utilities

| Symbol | Purpose |
|---|---|
| `StapelDataclassSerializer` | Serializer that maps `@dataclass` fields |
| `StapelResponse(serializer)` | Wraps `.data` automatically |
| `StapelErrorResponse(status, ERR_KEY)` | Structured error response |
| `StapelValidationError(ERR_KEY)` | Raises DRF validation error with error key |
| `register_service_errors(dict)` | Registers error messages for a service |
| `AnchorPagination` / `CreatedAtAnchorPagination` | Cursor-style paginators |

---

### `stapel_core.bus` — Event bus

Transport-agnostic event bus: in-memory backend for tests/dev, Kafka, NATS
JetStream, or Redis Streams for production — pick one via
`STAPEL_BUS_BACKEND` (or bring your own `BusBackend` subclass).

**Publish** (sync, fire-and-forget):

```python
from stapel_core.bus import publish, Event

publish('user.created', Event(
    event_type='user.created',
    service='auth',
    payload={'user_id': '...'},
))
```

**Consume** by subclassing the management-command base:

```python
from stapel_core.bus import BaseBusConsumerCommand, Event

class ConsumeUsers(BaseBusConsumerCommand):
    topics = ['user.created']
    consumer_group = 'notifications'

    def handle_event(self, event: Event) -> None:
        ...
```

Backend is selected via the `STAPEL_BUS_BACKEND` env var or Django setting
(shorthand `memory` / `kafka` / `nats` / `redis_streams`, or any dotted
path). Default is `memory` (`stapel_core.bus.backends.memory.MemoryBus`);
production picks one of `stapel_core.bus.backends.kafka.KafkaBus`,
`stapel_core.bus.backends.nats.NatsJetStreamBus`, or
`stapel_core.bus.backends.redis_streams.RedisStreamsBus` (needs
`pip install 'stapel-core[kafka]'` / `[nats]` / `[redis-bus]` respectively —
see `MODULE.md` for connection settings and delivery semantics).

---

### `stapel_core.notifications` — Push notifications

```python
from stapel_core.notifications import request_notification

request_notification(
    notification_type='welcome',
    user_id=str(user.id),
    email=user.email,
    variables={'name': user.username},
    source_service='auth',
)
```

---

### `stapel_core.oauth` — OAuth provider registry

Provider classes (`GoogleProvider`, `GitHubProvider`, etc.) and registry for
OAuth consumer flows (when your service accepts OAuth logins from external
providers).

---

### `stapel_core.gdpr` — GDPR utilities

Account closure requests, data export, re-registration hashes.

---

## Running tests

```bash
cd iron-common-python
pip install -e '.[dev]'
pytest stapel_core/tests/ -v
```

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-core/blob/main/LICENSE).

---

<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
