Metadata-Version: 2.4
Name: commondirectory-django
Version: 0.2.0
Summary: CommonDirectory SDK for secure Django relying-party integrations
Project-URL: Documentation, https://pypi.org/project/commondirectory-django/
Author: Uncommon Software
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: authlib<2,>=1.7.2
Requires-Dist: django<6.2,>=5.2
Requires-Dist: joserfc<2,>=1.7.3
Requires-Dist: requests<3,>=2.32
Provides-Extra: drf
Requires-Dist: django<6.1,>=5.2; extra == 'drf'
Requires-Dist: djangorestframework<3.18,>=3.17.1; extra == 'drf'
Provides-Extra: feed
Requires-Dist: jsonschema<5,>=4.26; extra == 'feed'
Requires-Dist: rfc3339-validator<1,>=0.1.4; extra == 'feed'
Description-Content-Type: text/markdown

# commondirectory-django

`commondirectory-django` is the CommonDirectory SDK for Django consumer
products. It composes Authlib's maintained OIDC client with CommonDirectory's
exact browser-session, assurance-claim, and logout contract. It is not a
generic OIDC framework.

The package runs inside a consumer product. It does **not** add an endpoint to
the CommonDirectory API, and CommonDirectory does not require relying parties
to install it. Any standards-compliant OIDC relying party can continue to use
the issuer's discovery document directly.

## Compatibility

| Surface | Python | Django | Authlib / DRF | CommonDirectory contract |
| --- | --- | --- | --- | --- |
| Core, browser initiation, Admin, feed | 3.12–3.14 | 5.2, 6.0, 6.1 | Authlib 1.7.x | OIDC/provider v1, logout v1, feed HTTP v2/event schema v1 |
| Optional DRF adapters | 3.12–3.14 | 5.2, 6.0 | DRF 3.17.x | RFC 7662 introspection plus the same OIDC/provider v1 identity contract |

Django 6.1 is tested against the repository's pinned pre-release until the
final 6.1 release replaces it. The release workflow tests every supported
Python/Django pair before publishing. DRF 3.17 does not support Django 6.1, so
the `drf` extra declares `Django<6.1`; the package does not monkeypatch either
framework to manufacture unsupported compatibility.

## Release history

- **0.2.0:** adds the product-owned, route-neutral
  `begin_browser_login()` API while retaining the shipped POST+CSRF login view.
  Callback validation, session binding, logout, and database schemas are
  unchanged.
- **0.1.1:** adds the typed, scope-gated contact/profile handoff without making
  profile data an identity key.
- **0.1.0:** initial public Django relying-party, Admin, DRF, machine, session,
  logout, and provisioning-feed integration.

## Install

```bash
python -m pip install commondirectory-django
```

Add the app, URLs, and the opt-in immutable-sub backend:

```python
# settings.py: code wiring only; client data and credentials stay in the environment.
INSTALLED_APPS += ["commondirectory_django"]
AUTHENTICATION_BACKENDS = [
    "commondirectory_django.backends.ImmutableSubjectAuthenticationBackend",
    # Optional: retain product-local password login and permissions.
    "django.contrib.auth.backends.ModelBackend",
]
```

```python
# urls.py
from django.urls import include, path

urlpatterns = [
    path("auth/", include("commondirectory_django.urls")),
]
```

Add exactly one immutable, unique field to the product's user model:

```python
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    commondirectory_subject = models.UUIDField(
        unique=True,
        editable=False,
    )
```

`editable=False` is the portable package-detectable write-once marker; Django
does not turn it into database immutability. The product must populate the
field only from a previously validated CommonDirectory subject and prevent
later ORM, bulk-update, or SQL changes. The package proves that the migrated
database enforces exact single-field uniqueness.

The backend receives an already-validated `OIDCClaims`, selects only by `sub`,
and returns only one active user. It does not validate tokens, create or link
users, grant permissions, call a CommonDirectory resource API, or turn email,
profile fields, upstream identity metadata, or groups into authorization.

`OIDCClaims.profile` is a frozen `ContactProfileClaims` value derived only from
the validated browser token and its actual granted scopes. The `email` scope
must return canonical `email` plus a Boolean `email_verified`; an unverified
address is validated but withheld (`email is None`, `email_verified is False`).
The `profile` scope may return bounded `name`, `given_name`, and `family_name`.
Claims returned without their authorizing scope, malformed values, missing
email verification state, and non-canonical email fail the browser handshake.
These fields are product-owned initial contact/display metadata only. They must
never select, merge, authorize, or resurrect a local presence.

Products whose identity presence does not live on `AUTH_USER_MODEL` can
subclass `SubjectAuthenticationBackend`. Configure that exact dotted path in
both `COMMONDIRECTORY_RP_SUBJECT_BACKEND` and as the first, exactly-once member
of `AUTHENTICATION_BACKENDS`; leave `COMMONDIRECTORY_RP_USER_SUBJECT_FIELD`
unset. The subclass must resolve only the validated immutable subject and its
`get_user()` method must return the same active saved local user. The package
binds and rechecks the backend path, model label, primary key, subject, issuer,
client, and cache profile, so resolver disagreement fails closed. This custom
path supports the same browser, Admin, DRF session, RP logout, and back-channel
logout helpers as the default path.

Existing subclasses that implement `resolve_subject(subject)` remain source
compatible. A product that explicitly owns greenfield local-presence creation
may instead override `resolve_claims(claims)`. The default implementation
canonicalizes `claims.subject` and delegates to `resolve_subject`; the SDK does
not create users. The callback also passes the identical frozen value as the
`commondirectory_profile` authentication kwarg and rejects disagreement with
`claims.profile` before resolution.

Render a CSRF-protected POST form for login and logout:

```django
<form method="post" action="{% url 'commondirectory_django:login' %}">
  {% csrf_token %}
  <input type="hidden" name="next" value="{{ request.path }}">
  <button type="submit">Sign in</button>
</form>
```

That shipped login view remains the drop-in integration. A product that owns
its public sign-in route can instead call the same initiation boundary without
exposing an SDK-named path:

```python
from commondirectory_django import begin_browser_login
from django.contrib.auth.decorators import login_not_required
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_POST


@login_not_required
@require_POST
@csrf_protect
def login(request: HttpRequest) -> HttpResponse:
    return begin_browser_login(request, next_url=request.POST.get("next"))
```

The product route proves local user intent; the helper then owns configuration,
discovery, state, nonce, S256 PKCE, safe local return-target handling, and
sanitized failures. It returns a non-cacheable redirect with
`Referrer-Policy: no-referrer`, but it does not authenticate, provision, bind a
session, or authorize a product capability. Those operations remain behind the
validated callback and product backend.

Do not mount `begin_browser_login` itself as an unconstrained GET view. A
returning-user route should remain POST+CSRF. A product-owned GET may invoke it
only after that request has proved an active, purpose-bound capability and
recorded any sensitive capability state server-side. Pass only a safe local
post-login path as `next_url`; never put an invitation token or other capability
in the issuer redirect or in `next_url`. The helper never implicitly reads
`request.GET` or `request.POST`.

Products with the package Admin bridge disabled may omit the shipped login view
and map the remaining protocol views under neutral local paths. Keep their
method and CSRF decorators intact:

```python
from commondirectory_django import views as oidc_views
from django.urls import path

from product.views import login

urlpatterns = [
    path("auth/login/", login, name="login"),
    path("auth/callback/", oidc_views.callback, name="callback"),
    path("auth/logout/", oidc_views.logout, name="logout"),
    path("auth/logout/complete/", oidc_views.logout_complete, name="logout-complete"),
    path("auth/backchannel-logout/", oidc_views.backchannel_logout, name="backchannel-logout"),
]
```

The package Admin bridge renders a form to the shipped, namespaced login route
and its deployment check requires that route to remain mounted. Products using
that bridge should retain the full package URL include under a neutral internal
prefix, as the installed reference product does, and keep any separate
product-owned sign-in route at its own path.

## Environment

Required browser OIDC variables:

```text
COMMONDIRECTORY_RP_ISSUER=https://auth.commondir.com
COMMONDIRECTORY_RP_CLIENT_ID=product-web
COMMONDIRECTORY_RP_CLIENT_SECRET=<secret>
COMMONDIRECTORY_RP_REDIRECT_URI=https://flow.example.com/auth/callback/
COMMONDIRECTORY_RP_SCOPES=openid profile email
COMMONDIRECTORY_RP_POST_LOGOUT_REDIRECT_URI=https://flow.example.com/auth/logout/complete/
COMMONDIRECTORY_RP_USER_SUBJECT_FIELD=commondirectory_subject
```

The issuer is one exact HTTPS origin without a trailing slash. HTTPS callback
URLs may include a path but not userinfo, a query, or a fragment. Hostnames use
ASCII (punycode for IDNs), and an explicit port must use its canonical decimal
form between 1 and 65535. Configured URLs and local paths reject raw
backslashes, whitespace, and control characters; percent-encode valid special
path characters instead.

Optional bounded values:

```text
COMMONDIRECTORY_RP_LOGIN_SUCCESS_URL=/
COMMONDIRECTORY_RP_LOGOUT_SUCCESS_URL=/
COMMONDIRECTORY_RP_CACHE_ALIAS=default
COMMONDIRECTORY_RP_METADATA_TTL_SECONDS=3600
COMMONDIRECTORY_RP_JWKS_TTL_SECONDS=900
COMMONDIRECTORY_RP_DRF_INTROSPECTION_CACHE_TTL_SECONDS=300
COMMONDIRECTORY_RP_HTTP_TIMEOUT_SECONDS=5
COMMONDIRECTORY_RP_CLOCK_SKEW_SECONDS=60
COMMONDIRECTORY_RP_SESSION_INDEX_TTL_SECONDS=2678400
COMMONDIRECTORY_RP_SESSION_BINDING_LIMIT=16
COMMONDIRECTORY_RP_REQUIRED_ACR=urn:commondirectory:acr:workforce
COMMONDIRECTORY_RP_MAX_AUTH_AGE_SECONDS=1800
```

The last two settings are optional and product-specific. When present, the
package requests the step-up through standard OIDC `acr_values`/`max_age` and
independently rejects a lower ACR or stale `auth_time` in the validated callback
before creating a product session. Leaving both unset preserves the issuer's
registration policy without imposing a stricter consumer-side policy.

Register the product's read-only back-channel receiver as:

```text
https://flow.example.com/auth/backchannel-logout/
```

That route is an endpoint in the **consumer product**, not a CommonDirectory
resource API. CommonDirectory's independent logout worker delivers a signed,
short-lived logout token to it.

The Django security check requires:

- one deployment-wide shared server-side session namespace;
- one deployment-wide cache behind `COMMONDIRECTORY_RP_CACHE_ALIAS`;
- host-only session and CSRF cookies;
- `Secure`, `HttpOnly`, and `SameSite=Lax` session cookies; and
- a secure, host-only CSRF cookie.

Run `python manage.py migrate` after installing the package. The package owns
the small database registry that maps one authoritative `(issuer, client,
sid)` to a bounded set of exact local Django sessions, plus retry-safe
back-channel logout receipts. The binding limit is per authoritative SID
(default 16, maximum 256). At the limit the SDK probes the configured session
engine and prunes only keys proven absent or expired; it never evicts a live
sibling. A Django deployment has one configured session-engine key namespace,
and Django's database session backend already makes `session_key` globally
unique, so the package enforces the same global invariant while separately
attesting backend, issuer/client, local identity, and cache alias.

Versions 0.1.x and 0.2.0 store all package-owned binding and receipt models on Django's
`default` database. Database routers must route reads and writes for those
models to `default`; run the package migration there. The system check rejects
a different registry alias because its locks would not share the transaction
used by logout.

Schedule the bounded metadata cleanup command on every deployed product:

```bash
python manage.py commondirectory_prune_session_bindings --limit 100
```

Run one bounded batch per scheduled invocation, at an interval short enough
for the product's session volume. The limit is the number of registries scanned
in one invocation (1–1000); each registry is independently locked and capped
at 256 bindings. The command reports
registries scanned, expired bindings deleted, exact live bindings retained,
empty registries deleted, and whether another candidate exceeded the batch's
scan cap. `scan_limit_reached=true` is a capacity signal, not a drain
instruction: increase the schedule frequency or limit if cleanup lag is
unacceptable. Expired bindings are already invalid for package authentication, but they
remain authoritative back-channel logout locators while the configured session
store still contains an exact matching live session. Under the registry lock,
the command deletes metadata only for a key the shared session store proves
absent. It retains an exact live locator, rotates that registry behind older
unchecked candidates, and fails closed without changing that registry if the
store is unavailable or live payload disagrees. It never deletes the underlying
Django session and deliberately retains logout receipts. Continue to run
Django's `clearsessions` command separately for expired Django sessions.

Django's database session engine is supported directly. The standard `cache`
and `cached_db` engines are supported only when `SESSION_CACHE_ALIAS` names a
deployment-wide shared cache. LocMem, Dummy, FileBased, file-session, and
signed-cookie configurations are rejected: another instance could otherwise
retain a live session after back-channel logout. Django's Database, Redis, and
Memcached cache backends are recognized as shared-capable. An unrecognized
third-party session cache backend also requires the explicit shared-store
attestation below.

`COMMONDIRECTORY_RP_CACHE_ALIAS` separately controls discovery, JWKS, and the
browser/logout unknown-signing-key refresh leases. In a deployed product it must name one
cache namespace shared by every application instance, and that backend's
`add()` must be atomic across those instances. Django's Redis and Memcached
backends are recognized as shared and atomic. The deployment check rejects
DatabaseCache, LocMem, Dummy, and FileBased caches even when an attestation is
set. DatabaseCache remains supported for cache-backed session storage, where
the requirement is a shared namespace rather than an atomic lease.
An unrecognized custom backend is rejected unless the operator has verified
both properties and explicitly attests the configured RP cache:

```text
COMMONDIRECTORY_RP_CUSTOM_CACHE_SHARED=true
```

That attestation applies only to the unrecognized backend selected by
`COMMONDIRECTORY_RP_CACHE_ALIAS`. Machine and feed caches have independent
attestations below; setting one never authorizes another surface or alias. No
attestation makes a host-local cache supported or attests the session engine.

An unrecognized session cache backend or product-owned custom session engine
is supported only after the operator has verified that every application
instance sees the same key namespace through the session engine's
`SessionStore.exists()`, `load()`, and `delete()` methods. Attest that
deployment contract in the environment:

```text
COMMONDIRECTORY_RP_CUSTOM_SESSION_STORE_SHARED=true
```

The custom engine must also configure code wiring in `settings.py`:

```python
COMMONDIRECTORY_BACKCHANNEL_LOGOUT_HANDLER = (
    "product.sessions.delete_commondirectory_session"
)
```

The callable receives keyword-only `session_key`, `subject`, and `sid`. It must
idempotently delete exactly that local session or raise. The SDK reloads the
configured session engine and proves the key is absent before deleting the
binding or completing replay evidence. A missing, non-callable, no-op, or
failing handler fails closed and leaves the database receipt pending for retry.
The shared-store attestation is not a bypass: if any instance has a private
namespace, or the handler and `SessionStore` observe different namespaces, the
configuration is unsupported.

When the default backend is enabled, it also requires the backend first in
`AUTHENTICATION_BACKENDS`, the subject field to be non-null, non-blank,
non-editable and unconditionally unique, and `SessionMiddleware` before
`AuthenticationMiddleware`. Prove the applied database constraint before
deploying:

```bash
python manage.py check --deploy --database default
```

Pass every routed database alias that can store `AUTH_USER_MODEL`. The package
does not silently assume `default`.

## Django Admin

The optional Admin integration keeps Django's local staff, superuser, model,
and object permissions authoritative. It changes only how an Admin user proves
identity: an authenticated Admin request must carry a live product session
bound to this exact CommonDirectory issuer, client registration, immutable
subject field, backend, cache profile, and binding schema. A password-created
or otherwise product-local staff session is not enough.

Enable the no-JavaScript OIDC bridge:

```text
COMMONDIRECTORY_RP_ADMIN_ENABLED=true
COMMONDIRECTORY_RP_ADMIN_PATH=/admin/
```

Place the guard after Django's authentication middleware:

```python
MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "commondirectory_django.middleware.CommonDirectoryAdminAuthenticationMiddleware",
    # ...
]
```

Mount the interceptors immediately before the Admin site at the same path:

```python
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("auth/", include("commondirectory_django.urls")),
    path("admin/", include("commondirectory_django.admin_urls")),
    path("admin/", admin.site.urls),
]
```

The bridge renders a server-side CSRF-protected POST to the normal OIDC login
flow. Admin logout is also a CSRF-protected POST and performs RP-initiated
CommonDirectory logout. Back-channel logout deletes the same durable Django
session, so it ends both Admin and browser-session API access. Unknown
anonymous Admin paths remain Django Admin's responsibility and retain its
non-enumerating redirect behavior. If a product mounts additional
`AdminSite` instances, their native login views redirect to this one canonical
bridge and their native logout views are replaced by the same
POST+CSRF-protected RP logout.

Run the deployment check after enabling this surface. It rejects missing or
misordered middleware, a missing interceptor, a different Admin mount path,
and an interceptor mounted after `admin.site.urls`.

## Django REST Framework

Install the separately bounded extra:

```bash
python -m pip install "commondirectory-django[drf]"
```

For normal multi-instance products, configure exactly the caching bearer
adapter followed by the bound-session adapter:

```python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "commondirectory_django.drf.CommonDirectoryCachingBearerAuthentication",
        "commondirectory_django.drf.CommonDirectorySessionAuthentication",
    ],
}
```

Declare the product-owned scopes every bearer request must carry:

```text
COMMONDIRECTORY_RP_DRF_REQUIRED_SCOPES="openid profile"
```

The session adapter accepts only the exact CommonDirectory-bound Django
session described above and applies DRF's CSRF enforcement to unsafe methods.
Both bearer adapters accept only a canonical opaque access token. The caching
adapter first looks up strictly validated, non-secret token metadata in the
deployment-wide cache selected by `COMMONDIRECTORY_RP_CACHE_ALIAS`. Its key
contains only SHA-256 digests of the fixed RP profile and raw token; the raw
token and Django user are never cached. A miss, corrupt entry, eviction, or
cache outage falls back to live RFC 7662 introspection using the configured RP
client with HTTP Basic authentication against the issuer's advertised
same-origin endpoint. The live response must be exact and active, bound to the
configured issuer, audience, and client; a canonical human UUID subject;
current integer timestamps; and the configured product scopes. ID tokens,
machine subjects, cross-client tokens, mixed session/bearer credentials, and
malformed or unavailable introspection all fail with the same generic 401.
`request.auth` contains typed non-secret metadata and never the raw token.

Successful metadata is cached for the smaller of the token's remaining
lifetime and
`COMMONDIRECTORY_RP_DRF_INTROSPECTION_CACHE_TTL_SECONDS` (default 300 seconds,
range 1–3600). This deliberately bounds rather than eliminates revocation
delay: a token revoked centrally can remain accepted by a consumer API until
that entry expires. Product-local user activity and permissions are still
resolved on every request and are never cached. Products requiring immediate
central revocation may instead configure
`CommonDirectoryBearerAuthentication`, which performs live introspection on
every request; the two bearer adapters cannot be enabled together.

The cache is consumer-product infrastructure. Point
`COMMONDIRECTORY_RP_CACHE_ALIAS` at that product deployment's existing shared
Valkey/Redis or Memcached namespace; do not provision a CommonDirectory-owned
cache for the consumer. Introspection entries are ephemeral and require no
persistence. Eviction is safe because it becomes a live-introspection miss.
This namespace is part of the product's authentication trust boundary: a
party that can write arbitrary entries can forge the validated token metadata
accepted during the bounded cache lifetime. Restrict write access to the
consumer application, isolate it from untrusted workloads, require encrypted
and authenticated network access, and rotate access credentials after any
suspected compromise.
The package's deployment check rejects process-local or otherwise unsupported
RP caches.

CommonDirectory must grant `first_party_trusted` to this confidential App
Registration before it may call introspection. That is an audited operator
grant and is deliberately broader than an introspection-only privilege: the
same existing flag gates CommonDirectory's headless credential/MFA path. Grant
it to a confidential registration only after accepting that wider capability;
DRF adoption does not create a narrower grant. The server permits this adapter
to introspect only human access tokens
issued to the **same registration**. It cannot introspect another product's
token, a native/public client's token, or an M2M token, and this package does
not turn bearer authentication into authority over a CommonDirectory resource
API. DRF permission classes and product-local model/domain permissions remain
the product's authorization boundary.

## Machine API helper

Machine access is deliberately separate from browser login:

```python
from commondirectory_django.machine import (
    MachineConfiguration,
    MachineTokenClient,
)

client = MachineTokenClient(MachineConfiguration.from_environment())
authorization = f"Bearer {client.access_token()}"
```

It reads the separate `COMMONDIRECTORY_API_ISSUER`, `_CLIENT_ID`,
`_CLIENT_SECRET`, `_AUDIENCE`, and `_SCOPES` variables. It requests a
`client_credentials` token, validates its RS256 signature and exact single
audience, and refreshes it between 70% and 80% of its lifetime. This helper
allows the product to call a granted CommonDirectory API; it does not give the
browser user that API authority.

`COMMONDIRECTORY_API_CACHE_ALIAS` selects the discovery/JWKS cache for this
helper. It defaults to `default` and must meet the same deployment-wide shared
namespace and atomic `add()` requirements as the browser cache. Runtime
validation rejects LocMem, DatabaseCache, Dummy, FileBased, unavailable, and
unattested custom backends before an unknown-key refresh can make a network
request. An unrecognized API cache requires its own exact attestation after
that alias is verified:

```text
COMMONDIRECTORY_API_CUSTOM_CACHE_SHARED=true
```

## Failure behavior

Missing environment, cache failure, issuer mismatch, state/nonce/PKCE failure,
unknown keys, invalid assurance claims, and local subject-resolution failure
all fail closed. A callback never establishes a product session unless the
entire OIDC transaction and product-local `sub` lookup succeed.

Every public signature validator—browser ID token, unauthenticated
back-channel logout, exact-audience machine token, and product-scoped feed
event—binds an unknown-key refresh to a fixed configuration-derived profile.
Each profile can trigger at most one shared JWKS refresh per 60-second
cooldown. Presented key IDs, tokens, signatures, and validation errors never
enter that profile or its cache key. The refreshed set is still
signature-verified normally; an unknown key is never accepted or remembered.
The lease winner publishes a non-secret success or failure result. Contending
requests never contact the issuer: they make at most six adaptive result
probes over one second, use the newly cached keys only after a matching success
result, and fail promptly after a matching failure result. A slow winner that
does not publish within that bound fails closed rather than tying every
request's wait to the configured upstream HTTP timeout.
`manage.py check --deploy` enforces the browser RP cache configuration, and
machine/feed validators enforce the same shared atomic requirement at runtime
for their separately configured aliases.

Django stores the successful backend path in each authenticated session.
Flush existing sessions before removing/reordering the backend or changing the
configured subject field.

The source distribution includes `MIGRATING.md` with product adoption and
upgrade guidance.

## Provisioning feed

Products that consume CommonDirectory's signed provisioning feed install the
separate extra:

```bash
python -m pip install "commondirectory-django[feed]"
```

The extra uses `jsonschema` plus a direct permissively licensed RFC 3339
validator. It deliberately does not install jsonschema's aggregate `format`
extra, which includes GPL-licensed URI validation. The release gate resolves
every package extra from the built wheel, records the complete runtime license
closure, and rejects any license outside the explicit reviewed policy.

Browser OIDC and machine-API consumers do not need this extra and do not create
feed tables. A feed consumer opts in explicitly:

```python
INSTALLED_APPS += ["commondirectory_django.feed"]
```

Create a product-owned handler registry. Handlers receive a fully validated,
typed event and run in the same durable database transaction as the immutable
receipt:

```python
# my_product/commondirectory_feed.py
from commondirectory_django.feed.registry import HandlerRegistry

handlers = HandlerRegistry()


@handlers.register("entitlement.granted", handler_version="2026-07-29")
def grant_entitlement(event):
    ProductEntitlement.objects.update_or_create(
        organization_id=event.data["organization_id"],
        defaults={"enabled": event.data["gates"]["enabled"]},
    )
```

The atomicity guarantee applies to one configured Django database alias.
Handlers that affect another database or an external system must write to a
product-owned transactional outbox, or use an independently idempotent effect.
The package proves durable feed receipt and acknowledgement; the product owns
its domain-model convergence and related health signal.

Required feed variables:

```text
COMMONDIRECTORY_FEED_ISSUER=https://auth.commondir.com
COMMONDIRECTORY_FEED_CLIENT_ID=product-feed
COMMONDIRECTORY_FEED_CLIENT_SECRET=<secret>
COMMONDIRECTORY_FEED_PRODUCT=product
COMMONDIRECTORY_FEED_HANDLER_REGISTRY=my_product.commondirectory_feed.handlers
```

Optional bounded settings:

```text
COMMONDIRECTORY_FEED_DATABASE_ALIAS=default
COMMONDIRECTORY_FEED_CACHE_ALIAS=default
COMMONDIRECTORY_FEED_METADATA_TTL_SECONDS=3600
COMMONDIRECTORY_FEED_JWKS_TTL_SECONDS=900
COMMONDIRECTORY_FEED_HTTP_TIMEOUT_SECONDS=5
COMMONDIRECTORY_FEED_CLOCK_SKEW_SECONDS=60
COMMONDIRECTORY_FEED_PAGE_SIZE=50
COMMONDIRECTORY_FEED_POLL_INTERVAL_SECONDS=5
COMMONDIRECTORY_FEED_WORKER_LEASE_SECONDS=120
COMMONDIRECTORY_FEED_LAG_DEGRADED_THRESHOLD=100
COMMONDIRECTORY_FEED_HEALTH_STALE_SECONDS=60
```

`COMMONDIRECTORY_FEED_CACHE_ALIAS` must identify a deployment-wide cache with
atomic `add()` semantics. The feed validator binds its unknown-key lease to
the configured issuer, client, product, and feed surface; a remote JWS `kid`
cannot create a novel lease. The same unsupported-backend and outage behavior
described above fails closed before a forced issuer request. An unrecognized
feed cache requires the separate
`COMMONDIRECTORY_FEED_CUSTOM_CACHE_SHARED=true` attestation after that exact
alias is verified. RP or API cache attestations cannot authorize it.

Migrate the selected database, then run one worker per issuer, client, and
product identity:

```bash
python manage.py migrate
python manage.py commondirectory_feed_consume
```

Operational commands have deliberately different semantics:

- `commondirectory_feed_consume` validates a complete page, applies each new
  event once, and acknowledges only after the product transaction commits.
- `commondirectory_feed_backfill --max-pages N` uses an independent resumable
  checkpoint and never changes the normal cursor or remote acknowledgement.
- `commondirectory_feed_replay --max-pages N` verifies signed history against
  immutable receipts without invoking handlers or acknowledging.
- `commondirectory_feed_recover` retries a pending acknowledgement or the first
  currently unreceipted event; it never resets or deletes cursor state.
- `commondirectory_feed_health` emits non-sensitive, feed-integration-scoped
  JSON and exits nonzero when degraded. It does not claim that the whole
  product is converged.

Only one live worker may own an identity. A bounded database lease fences
concurrent or superseded workers without holding a transaction open during
network calls. Discovery documents, JWKS, token responses, and feed pages are
strictly bounded; stale keys are never used beyond their configured TTL.
Feed cursors are opaque: MAC-key rotation can change their text without moving
their position. The worker proves that case by idempotently re-acknowledging its
stored cursor, then stores the server's canonical representation; immutable
event evidence, not cursor text, is the deduplication identity.
