Ctrl K
ComunidadGuías…

llms-full.txt

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Janua Full LLM Context

Generated: 2026-05-13 · contracts section updated 2026-09-07 · email events section 2026-09-27

## Purpose

This file gives LLM agents a stable, tool-agnostic map for working in this
repository without depending on vendor-specific instruction files.

## Canonical instruction order

1. `AGENTS.md`
2. `README.md` if present
3. `ECOSYSTEM.md` if present
4. Relevant files under `docs/`, `infra/`, `.github/workflows/`, and source
   directories for the specific task

## Agent compatibility

`AGENTS.md` is the source of truth. `CLAUDE.md` exists only to redirect Claude
and other legacy agent integrations to `AGENTS.md`.

## Enclii-first operations contract

Use Enclii web, API, or CLI for routine production provisioning, deployment,
observability, domain management, secrets, provider operations, scaling,
rollback, and remediation.

Raw `kubectl`, `helm`, SSH, provider CLIs/APIs, `docker exec`, and direct
container access are allowed only for platform bootstrap or documented
break-glass emergencies when Enclii is unavailable or lacks an implemented
adapter. Any break-glass use must record the actor, reason, target
service/environment, commands executed, result, and follow-up Enclii adapter
gap or incident link.

## Durable contracts an agent must not break

These are load-bearing behaviours with production evidence behind them. Changing
one is a decision, not a refactor. Each names the file that owns it.

### Sending identity — `app/services/sender_binding.py`, `email_sender.py`

A From header pairing a client's display NAME with an address on MADFAM's domain
(`Client <hola@madfam.io>`) must never be produced. Display name and
address move together on one fact: is the binding's address domain verified for
the ACCOUNT that will send it. There is no intermediate state. This reversed an
earlier rule after the forbidden header reached a real client inbox on
2026-09-07; several tests exist only to assert it can never recur, so a test
naming that string is a guard, not a stale reference.

Verification is per ACCOUNT, not per domain. A tenant on its own provider
account reads its own `verified_domains`; the global `RESEND_VERIFIED_DOMAINS`
describes MADFAM's account only.

The PLATFORM sender depends on the message class (ruling R101, 2026-10-04):
system and security mail (sign-in links, codes, resets, invitations, alerts)
comes from `MADFAM <noreply@madfam.io>` with a Reply-To on `support@madfam.io`
(`security@madfam.io` for security mail); welcome and every unclassified
message keep `MADFAM <hola@madfam.io>`. A tenant's branded sender is unchanged
for every class. Classes live in `email_sender.TEMPLATE_MESSAGE_CLASS`; an
empty `EMAIL_SYSTEM_FROM_ADDRESS` is the rollback.

A missing tenant credential degrades the sender to the platform From line and
logs `sender_credentials.tenant_credential_missing`. It never blocks the send: a
degraded From is a degraded outcome, a magic link that never arrives is an
outage.

`credential_ref` names an ENV VAR for a tenant-account binding, not a Vault path, because janua-api
runs with no `VAULT_ADDR`/`VAULT_TOKEN` — a `path#field` reference cannot
resolve at runtime in production. Vault reaches the pod through an
enclii-managed ExternalSecret instead.

### Magic links — `app/auth/hosted_hop.py`, `routers/v1/auth.py`

`GET` on the magic-link callback spends nothing; only `POST` exchanges. Mail
scanners GET every URL in an email, and this estate has already lost a live
ceremony to a scanner-consumed link.

When the destination host cannot receive the estate cookie (it is outside
`COOKIE_DOMAIN`), the link lands on janua for one hop and then forwards
`?token=<access_token>` to the registered `redirect_url`. The rule is DERIVED
from the cookie domain, never configured, so it cannot drift from the relay
guard in `@madfam/janua-next`. Products land the forward with 0.3.0 or later.

### Estate session — `app/auth/sso_cookie.py`

`janua_sso` is HttpOnly, `Domain=.madfam.io`, and outranks `janua_access_token`
at `/authorize`. It is the only session that reaches the browser across the
estate, because products exchange magic links server-to-server and Node drops
the `Set-Cookie` on a fetch response.

### Revocation and readiness — `services/auth_service.py`, `services/token_revocation.py`, `routers/v1/health.py`

Revocation-list and refresh-reuse reads are strict: Redis unavailable answers
`503` + `Retry-After`, never "not revoked" (owner ruling 2026-10-04). Every
revocation path calls `AuthService.revoke_sessions`; `/auth/refresh` also
checks the `sessions` row, so a revocation holds when its Redis write is lost.
`POST /oauth/revoke` requires client authentication. Readiness reports Redis
and the database and gates on neither (each check bounded at 2 s); health
bodies never carry error text. Hub: `docs/runbooks/oauth-shared-state-redis.md`.

## Email API surface — `apps/api/app/routers/v1/email.py`

Two internal send endpoints under `/api/v1/internal/email/`:

- `POST /send` — raw email: caller supplies `html`/`text`, subject, recipients.
- `POST /send-template` — renders a registry template with `variables`.

Also `GET /templates` (lists the registry live) and `GET /health` (reports
`templates_available` as `len(EMAIL_TEMPLATES)`, a live count).

The template registry is two paired dicts in the same file: `EMAIL_TEMPLATES`
(id → description, `required`/`optional` variables, `subject`, optional
`default_from_email`) and `TEMPLATE_FILENAMES` (id → whitelisted HTML filename
under `templates/emails/`). An id must be in both. A template with no HTML file
falls back to `generate_fallback_html()` rather than erroring.

**Two render paths — do not conflate them.** The `send-template` route renders
with **naive `{{key}}` string substitution** (`render_template()`): no Jinja
conditionals, loops, or auto-escaping. Anything conditional is composed in
Python first (e.g. the tenant payment-confirmation template derives
`{{sesiones_detalle}}` from an optional count in `_derive_template_variables()`).
The **Jinja2** path
(`FileSystemLoader`, autoescape) lives in `services/email_i18n.py` and
`services/email/resend_service.py` and renders the localized auth/magic-link
mail via `email_service.py` — a different template set, not this route.

**Sender resolution** is not the caller's to dictate: the From line is chosen by
`services/email_sender.py` under `EMAIL_SENDER_POLICY.md` (THE RULE above). A
caller's explicit `from_email` is honoured only from a verified domain; a
template may declare a `default_from_email` (e.g. `billing/cfdi` →
`facturacion@madfam.io`); otherwise the sender resolves from `org_id` or the
`redirect_url` host (e.g. a tenant's template → that tenant's own sender). `attachments`
on either endpoint are forwarded to Resend (used for CFDI XML+PDF delivery).
`bcc` on `/send-template` rides on the FIRST `to` recipient's message only (each
recipient gets its own rendering, so a BCC holder gets one copy of one real
message); `/send` puts its `cc`/`bcc` on every message.

Full walkthrough and the template table: `docs/MADFAM_EMAIL_INTEGRATION.md`.

## Email events and first-party measurement

Operator guide: `docs/runbooks/resend-email-events.md`. Secret handling:
`docs/runbooks/secrets/resend-webhook-secret-rotation.md`.

### Webhook receiver — `routers/v1/email_webhooks.py`, `services/email_events.py`

`POST /api/v1/email/webhooks/resend/{cuenta}`, one `cuenta` per Resend ACCOUNT
(`platform`, plus one slug per tenant account in `WEBHOOK_SECRET_SETTINGS`),
because each account's endpoint has its own signing
secret. The Svix-style signature is the only authentication, checked against
`RESEND_WEBHOOK_SECRET_<CUENTA>` before anything is written. Unset or blank
secret: 404, indistinguishable from an unknown account. Bad, stale or missing
signature headers: 401 `{"detail":"Invalid webhook signature"}`. Stored: 200
`{"status":"stored"}`. Rows are minimized: no recipient, sender, subject, body,
IP, user agent, or link query string. The secret reaches the pod from Vault
`secret/janua` through the enclii-managed `janua-secrets` ExternalSecret
(`resend_webhook_secret_<cuenta>` → `resend-webhook-secret-<cuenta>` →
`RESEND_WEBHOOK_SECRET_<CUENTA>`), read at process start.

### First-party measurement — `services/email_engagement.py`, `routers/v1/email_engagement.py`

Token-bearing mail is never measured. Only `POST /internal/email/send` with
`track_engagement: true`, no token link (explicit `contains_token_link` or the
credential-parameter detector), an HTML part, and a sender binding with a
tracking host on the From domain actually used. Otherwise the message goes out
unmodified. Resend's own tracking stays OFF and `EMAIL_TRACKED_SENDER_DOMAINS`
stays empty, so branded sign-in mail keeps its HTML. The setting a binding names
in `tracking_host_setting` (an https origin) is the per-tenant on-switch. Pixel
`/e/o/{token}.gif` is constant; click
`/e/c/{token}/{i}` redirects only to the target stored for that token and
index, else to the tenant's site. Only the token's SHA-256 is stored.

### Tracking host scope — `middleware/tracking_host_scope.py`

Tracking hosts join `TrustedHostMiddleware`'s global list at startup, so
`TrackingHostScopeMiddleware` (added after it, so it runs first) keeps them to
`/e/o/*` and `/e/c/*`: 404 for any other path, websocket close 1008. Removing
it would serve sign-in, reset and OIDC discovery under a tenant's marketing
domain.

### Production migrations — `apps/api/alembic/PROD_ALEMBIC_STATE.json`

Deploys never migrate. Revisions are applied by hand (`docs/ops/sql/*.sql`)
before promoting code that needs them, and the ledger records the revision a
human last read out of production. `promote-to-prod.yml` refuses a promote
whose alembic head is ahead of the ledger unless `migrations_acknowledged=true`.
Update the ledger only after reading the database
(`docs/runbooks/ALEMBIC_CONVERGENCE.md`).

### Token verification — `app/core/jwt_manager.py`, `routers/v1/oauth_provider.py`

Issuer = `https://<JANUA_CUSTOM_DOMAIN>` when set, else `API_BASE_URL`; read it
from `/.well-known/openid-configuration`. JWKS (`/.well-known/jwks.json`)
publishes exactly one RS256 key whose `kid` is `JWT_KID`; rotating the key or
the `kid` is a hard cut (no overlap). `aud`: ID token = `client_id`; access
and service tokens = the OAuth client's registered `audience`, else
`JWT_AUDIENCE`. Reference: `docs/reference/ISSUER_AND_JWKS.md`.

### Data-subject request exports — `services/compliance_service.py`

`DataSubjectRightsService.get_request_for_processor` lets only the request's
subject or a platform admin (`users.is_admin`) process a request; the export
route returns the unknown-id 404 to anyone else and leaves the request
unchanged. Organization admins get no extra access. Read-only counts:
`apps/api/scripts/audit_data_subject_request_access.py` (exit 2 = a request was
last processed by someone else). Runbook:
`docs/runbooks/data-subject-request-access-audit.md`.

## Pending work

One list, with priorities and owner-decision vs engineering: AGENTS.md →
«Known gaps / Backlog». Don't copy it here.

## Repo entrypoints detected

- `README.md`
- `ECOSYSTEM.md`
- `docs/`
- `infra/`
- `.github/workflows/`

## Maintenance

These files are maintained by `internal-devops/scripts/sync-agent-docs.py`.
If repo-specific long-form agent guidance is needed, put it in `AGENTS.md`,
not `CLAUDE.md`.