arcly-http-macros 0.2.2

Procedural macros for arcly-http — #[Controller], #[Module], #[Injectable], and the route-hardening attribute family
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
<div align="center">

# ⚡ arcly-http

### NestJS ergonomics meets Rust safety & speed

*A batteries-included, enterprise-grade HTTP framework built on axum — declarative controllers, zero-lock DI, a complete auth pipeline, realtime gateways, and first-class observability.*

[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg?logo=rust)](https://www.rust-lang.org)
[![axum](https://img.shields.io/badge/built%20on-axum%200.8-blue.svg)](https://github.com/tokio-rs/axum)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](#-license)
[![OpenAPI](https://img.shields.io/badge/OpenAPI-Swagger%20UI-85EA2D.svg?logo=swagger&logoColor=black)](#)

[Quick Start](#-quick-start) •
[Features](#-feature-matrix) •
[Architecture](#-architecture) •
[Auth Pipeline](#-authentication--authorization) •
[Examples](#-examples) •
[Configuration](#-configuration)

</div>

---

## 👀 At a Glance

```rust
use arcly_http::prelude::*;

pub struct UserController;

#[Controller("/users", tags("users"))]
impl UserController {
    /// Fetch a single user by ID — JWT-protected, traced, documented.
    #[Get("/:id", summary("Get user"), security("bearer"))]
    #[UseInterceptors(TraceInterceptor)]
    async fn get_user(
        svc: Inject<UserService>,          // ← zero-lock DI, O(1) resolution
        ctx: RequestContext,               // ← claims & session pre-decoded
        #[Param("id")] id: u64,            // ← typed path extraction
    ) -> Result<Json<User>, HttpException> {
        JWT_AUTH.check(&ctx)?;             // ← zero-overhead guard
        svc.find(id).map(Json).ok_or_else(|| NotFound::new("user not found").into())
    }
}

#[Module(controllers(UserController), providers(UserService))]
pub struct AppModule;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    App::launch::<AppModule>("0.0.0.0:3000").await   // /docs, /healthz, /metrics — free
}
```

One annotation gives you: routing, OpenAPI docs, JWT decoding, distributed tracing, and dependency injection — with **no locks on the request hot path**.

---

## 📦 Feature Matrix

| | Capability | Details |
|---|---|---|
| 🧩 | **Declarative routing** | `#[Controller]`, `#[Get]`/`#[Post]`/`#[Put]`/`#[Patch]`/`#[Delete]`, typed `#[Param]`/`#[Query]`/`#[Body]` extraction |
| 🎛️ | **Production governance** | One `LaunchConfig` (+ `ARCLY_*` env overrides): request deadline (`504`), in-flight admission cap (`503` + `Retry-After`), panic boundary (`500`, never a dropped connection), `413` body cap, bounded response cache + sweeper, WS drain deadline, drain-aware `/readyz`, config-driven CORS, `x-request-id` everywhere — all lock-free |
| 🔌 | **Zero-lock DI** | `#[Injectable]` + `#[Module]` DAG → frozen `&'static` container; `Inject<T>` resolves in O(1) with **no locks, no allocation** |
| 🔐 | **JWT auth** | `JwtService` (HS/RS/ES families), access + refresh pairs, automatic Bearer decoding at the boundary, `JWT_AUTH` / `RoleGuard` |
| 🍪 | **Signed cookies** | `CookieService` — HMAC-SHA256, tamper-proof; boundary falls back to the JWT cookie when no Bearer header (browser-friendly, no localStorage) |
| 🗂️ | **Server-side sessions** | `SessionManager` + pluggable `SessionStore` trait; `ctx.session()` on every request; `SESSION_AUTH` guard |
| 🌐 | **OAuth 2.0 + PKCE** | `OAuth2Provider` trait + registry — Authorization Code flow with atomic single-use CSRF state; Google & GitHub providers in the example |
| 🛡️ | **Fine-grained permissions** | `resource:action` perms embedded in JWT (`issue_access_with_perms`) → zero-I/O permission checks with wildcard support (`users:*`) |
| 🏢 | **Multi-tenancy** | `TenantRegistry` (header / subdomain strategies, frozen map) resolved once per request; `ctx.tenant()` + `TENANT` guard cross-checks the JWT `tenant` claim against the resolved tenant |
| 🌍 | **Distributed rate limiting** | `DistributedRateLimit` over a pluggable `RateLimitBackend` — cluster-wide sliding window (atomic Redis Lua), `sub`-then-IP principal keying, explicit `FailOpen`/`FailClosed` policy |
| 🔄 | **Secrets rotation** | `SecretSource` (Vault / AWS SM / env) + `Rotating<T>` over `ArcSwap` — hot-swap `JWT_SECRET`/cookie secrets with zero restarts; previous key verifies through the grace window |
| 🗄️ | **Datasource routing** | `DataSource` trait + frozen `DataSourceRegistry`: tenant-scoped pools, read/write splitting, `ReadAfterWritePin` defeats replica lag |
| 📋 | **Audit trail** | `#[AuditLog(action, resource)]` — one record per mutation (who/what/when/where/outcome), lock-free `try_send` hot path, hash-chained (SHA-256) batches into an app-provided append-only `AuditSink` |
| 📤 | **Transactional outbox** | `OutboxTx::enqueue` inside the business transaction + background `OutboxRelay` (poll → publish → ack, at-least-once with idempotency keys) — kills the dual-write problem |
| 🏷️ | **API versioning** | `#[Version("v1")]` mounts the controller under `/v1/...`; `#[Deprecated(sunset = "…")]` adds RFC 8594 `Deprecation`/`Sunset` headers so clients learn deadlines on the wire |
| 🧬 | **Unified DB layer** | `ArclyDbPool` — one injectable handle over **SQLx / SeaORM / Diesel** (Cargo features `db-sqlx-*` / `db-seaorm-*` / `db-diesel-*`); tenant routing + RW splitting apply via the existing registry; sync Diesel fully isolated behind `spawn_blocking` |
| 💾 | **`#[Transactional]`** | Begin on the tenant pool → commit on `Ok`, rollback on `Err` / `#[Timeout]` cancellation; task-local tx (no locks); `with_current_tx` reaches it from services — outbox rows ride the **same transaction** |
| 🔏 | **Distributed lock** | `DistributedLock` + `DLockBackend` — fencing tokens (cluster-monotonic), compare-and-delete release, auto-renew heartbeat; `OutboxRelay` leader election ships on it |
| 🗃️ | **DB migrations** | `MigrationRunner` — versioned `arcly_migrations` ledger per datasource, checksum **drift = boot failure**, fleet DDL serialized on the cluster lock, multi-tenant `run_all` |
| 🔁 | **Idempotency keys** | `#[Idempotent(ttl = "24h")]` — Stripe-style `Idempotency-Key`: atomic claim, stored-response replay (`Idempotency-Replayed: true`), 409 for concurrent duplicates |
| ⚖️ | **ABAC policy engine** | `#[RequirePolicies("orders.refund")]` — default-deny attribute rules (subject × resource × env) over `ArcSwap`; hot-reload a new `PolicySet` and the next request obeys it — no OPA call on the hot path, no restart |
| 📨 | **Event consumer mesh** | `#[EventConsumer]` + `#[EventPattern("topic")]` — link-time handler registry (frozen dispatch), consume-side dedupe, **typed failure fates** (`EventError::Retry` vs `DeadLetter` — poison messages park immediately), configurable batch concurrency, drain-aware; transport (Kafka/AMQP/NATS) is one app-side trait |
| 🧪 | **Testing harness** | `testing::TestRequest` builds a production-shaped `RequestContext` through the real boundary pipeline (claims, tenant, trace, DI) for unit-testing guards/interceptors/handlers; `testing::TestServer` boots the app on an ephemeral port for integration tests |
| 🧵 | **Cross-channel tracing** | `traceparent` rides outbox rows and message envelopes — consumers continue the producing request's trace (`TraceContext::from_traceparent`) instead of starting orphan roots |
| 🕶️ | **PII masking** | `#[MaskFields("card:last4")]` + hot-reloadable `Masker` (`ArcSwap`) — Redact/Hash/Last4/Drop applied **before durability** at every sink: responses, the idempotency replay cache, outbox rows, and dead-lettered payloads; fields surface as `x-arcly-masked-fields` |
| 🔏 | **Field-level encryption** | `#[EncryptFields(key = "tenant:acme", fields("ssn"))]` — AES-256-GCM envelope encryption under per-tenant / per-subject DEKs (`CryptoVault`, `ArcSwap` key ring, KMS behind the `KekSource` trait); rotation without re-encryption, and GDPR erasure via **crypto-shredding** (`vault.shred`) even inside append-only sinks |
| 🏬 | **Dynamic tenants** | `TenantRegistry` over `ArcSwap` snapshots — `upsert`/`suspend`/`resume` live on the next request (no redeploy); suspended tenants get 401 with no silent fallback |
| ⏱️ | **Deadlines & bulkheads** | `#[Timeout("2s")]``504` + future cancellation (worker freed); `Bulkhead` (const semaphore) sheds with `503` instead of queueing when a slow dependency saturates |
|| **Validation** | `validator` integration on `#[Body]`/`#[Query]` — violations return RFC-7807 `422` with per-field errors, automatically |
|| **Resilience** | `#[circuit_breaker]` — lock-free state machine with single-probe half-open + fresh-cooldown re-open, transitions observable per method; `RateLimit` fixed-window limiter (single CAS); `Bulkhead` admission control — all with Prometheus counters/gauges |
| 📡 | **Realtime** | WebSocket gateways with rooms & broadcast, JWT auth at handshake; SSE streams |
| 🔭 | **Observability** | W3C `traceparent` in/out, Prometheus `/metrics`, `/healthz` + `/readyz` with pluggable checks, OTLP traces, structured JSON logs |
| 📜 | **OpenAPI** | Swagger UI at `/docs` — and the spec mirrors the whole hardening stack: `Idempotency-Key` param + 409 + replay header, ABAC-aware 403s, 504 with the route deadline, RFC 8594 `Sunset` headers, `x-arcly-*` vendor extensions for gateways/SDK generators |
| 🧱 | **Plugin system** | `ArclyPlugin` lifecycle (init → start → draining → graceful drain, per-plugin budget via `LaunchConfig`); plugin routes share the *exact same* request pipeline as macro routes; global interceptors, pre-body `BoundaryFilter`s, explicit DI overrides, runtime-mounted routes under `/_plugins/*` (`ArcSwap`, lock-free) |
| 🚀 | **Response caching** | `#[CacheTTL(30)]` + `CacheInterceptor` — keyed on path + query, hit/miss stats, one-call flush |

---

## 🏛 Architecture

```mermaid
flowchart LR
    subgraph entry ["Request Entry Points"]
        A["HTTP<br/>macro routes"]
        B["Plugin<br/>routes"]
        C["WebSocket<br/>handshake"]
    end

    subgraph pipeline ["One Shared Pipeline"]
        D["auth::extract<br/>Bearer → cookie → session"]
        E["observability::propagation<br/>W3C traceparent"]
        F["RequestContext"]
    end

    subgraph runtime ["Runtime"]
        G["Guards<br/>JWT · Role · Session · Perms"]
        H["Handler"]
        I["core: frozen DI<br/>(HTTP-agnostic)"]
    end

    A --> D
    B --> D
    C --> D
    D --> F
    E --> F
    F --> G --> H
    H -. "Inject&lt;T&gt; · O(1), no locks" .-> I
```

### Workspace layout

```
arcly-http/                  ── the framework crate
└── src/
    ├── auth/                identity & access — one place, one pipeline
    │   ├── jwt.rs           JwtService: sign / decode / validate (rotating keys)
    │   ├── cookie.rs        HMAC-SHA256 signed cookies (rotating secrets)
    │   ├── session.rs       SessionManager + SessionStore trait
    │   ├── oauth.rs         OAuth2Provider trait + registry
    │   ├── secrets.rs       SecretSource · Rotating<T> (ArcSwap) · watcher
    │   ├── policy.rs        ABAC engine: default-deny PolicySet · hot reload
    │   ├── guards.rs        JWT_AUTH · RoleGuard · SESSION_AUTH
    │   └── extract.rs       ★ the single credential-extraction pipeline
    ├── core/                DI engine + plugin lifecycle (HTTP-agnostic)
    ├── pipeline/            ★ unified Provenance: trace + tenant + credentials,
    │                          ONE extraction shared by HTTP · WS · event mesh
    ├── data/                DataSource · tenant registry · RW splitting · outbox
    │   ├── db.rs / tx.rs    unified ArclyDbPool + #[Transactional] runtime
    │   ├── drivers/         sqlx · seaorm · diesel (feature-gated)
    │   └── migrate.rs       versioned migrations · checksum drift guard
    ├── web/                 boundary (run_entry), RequestContext, governor, CORS,
    │                        security headers, validation, tenants, idempotency
    ├── compliance/          data governance: #[MaskFields] masking · #[EncryptFields] envelope encryption + crypto-shredding
    ├── messaging/           event consumer mesh: #[EventPattern] · retries · DLQ · event bus
    ├── realtime/            WebSocket gateways + SSE
    ├── observability/       telemetry, metrics, health, propagation, audit trail
    ├── resilience/          circuit breaker · rate limiting · timeout · bulkhead · dlock
    └── docs/                OpenAPI spec assembly + Swagger UI

arcly-http-macros/           ── proc-macros (#[Controller], #[Module], …)
examples/
├── enterprise_app/          ── full e-commerce backend, every feature exercised
│   └── src/
│       ├── api/             one file per resource + shared guards
│       ├── auth/            AuthService, OAuth providers, permissions
│       ├── infra/           bootstrap, redis, sessions, datasources, secrets
│       └── domain.rs · services.rs
└── realtime_enterprise_app/ ── realtime-focused walkthrough
```

### Design invariants

> These are enforced by structure, not convention — each has exactly one home in the tree.

| Invariant | Why it matters |
|---|---|
| **One request pipeline** — every transport (HTTP, WS handshakes, event mesh) extracts trace + tenant + credentials through `pipeline::Provenance` | A security fix lands everywhere at once; transports can never drift (WS gets the same tenant enforcement as HTTP, suspended tenants' queued events stop processing) |
| **`core` is HTTP-agnostic** — the DI engine never imports axum | Engine internals evolve without touching routing code |
| **Handlers never see axum**`RequestContext` is the only request surface | Proven in 0.2.0: the axum 0.7→0.8 upgrade changed zero user-facing route syntax (`:id` stays; arcly translates to `{id}` at mount) |
| **Macros target one stable module** — codegen references `__macro_support` only | Internal files move freely; the macro crate never needs to follow |
| **No locks on the request path** — frozen maps, atomics, and `ArcSwap` only | Tenancy, rate limiting, and secret rotation scale without contention |

---

## 🚀 Quick Start

```bash
git clone git@gitlab.com:arcly/arcly-http/arcly-http.git
cd arcly-http

# Run the full-featured example — works without Redis (in-memory fallback)
cargo run -p enterprise_app
```

| Endpoint | URL |
|---|---|
| 📜 Swagger UI | `http://localhost:3000/docs` |
| ❤️ Health | `http://localhost:3000/healthz` |
| 📊 Metrics | `http://localhost:3000/metrics` |
| 📡 Live SSE feed | `curl -N http://localhost:3000/streams/inventory` |

---

## 🔐 Authentication & Authorization

The enterprise example seeds two demo users at startup:

| Email | Password | Role | Permissions |
|---|---|---|---|
| `admin@example.com` | `admin123` | admin | `users:*` `products:*` `orders:*` `admin:*` |
| `user@example.com` | `user123` | customer | `products:read` `orders:read` `orders:create` |

### Three ways in, one pipeline

```bash
# ── 1. Login: returns a JWT pair AND sets signed cookies ──────────────────────
curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"admin123"}' -c cookies.txt | jq

# ── 2a. Bearer auth (API clients) ──────────────────────────────────────────────
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"admin123"}' | jq -r .access_token)
curl -s http://localhost:3000/auth/me -H "Authorization: Bearer $TOKEN" | jq

# ── 2b. Cookie auth (browsers) — no header, the signed cookie carries the JWT ──
curl -s http://localhost:3000/auth/me -b cookies.txt | jq

# ── 3. Tampered cookies are rejected by HMAC verification ─────────────────────
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/auth/me \
  -H "Cookie: arcly_auth=forged.signature"        # → 401
```

### Token lifecycle guarantees

```mermaid
sequenceDiagram
    participant C as Client
    participant S as Server
    participant R as Redis

    C->>S: POST /auth/login
    S->>R: SET refresh::{jti} (TTL 7d)
    S->>R: SET access2refresh::{access_jti} → refresh_jti
    S-->>C: token pair + Set-Cookie ×2

    C->>S: POST /auth/refresh
    S->>R: GETDEL refresh::{jti}  (atomic — single use)
    Note over S,R: concurrent replay of the same<br/>refresh token loses the race → 401
    S-->>C: new token pair

    C->>S: POST /auth/logout
    S->>R: GETDEL access2refresh::{access_jti}
    S->>R: DEL refresh::{refresh_jti}
    S-->>C: 204 + Max-Age=0 cookies (auth + session cleared)
```

- **Refresh tokens are single-use** — rotation uses atomic `GETDEL`, so a stolen-then-replayed token always loses the race.
- **Logout actually revokes** — the `access→refresh` JTI mapping lets a Bearer-only logout kill the paired refresh token, delete the server-side session, and expire both cookies.
- **Permissions ride the token**`PermissionGuard::require("users:read")` checks the JWT `perms` claim first (zero I/O), with a role-map fallback. Wildcards (`users:*`) are honored.
- **Login is brute-force-protected cluster-wide**`/auth/login` sits behind a `DistributedRateLimit` (10/min per principal, **fail-closed**): the sliding window counts in Redis via one atomic Lua call, so the limit holds across every replica.
- **Secrets rotate without restarts** — a `SecretSource` watcher hot-swaps JWT/cookie keys through `ArcSwap`; the previous key keeps verifying until live credentials expire naturally.

### Declarative route hardening

Every cross-cutting policy is one attribute on the handler — the macro weaves
it into the same compiled thunk, so there is no runtime middleware lookup:

```rust
#[Post("/", status(201), security("bearer"))]
#[Idempotent(ttl = "24h")]                                // safe client retries
#[MaskFields("payment.card:last4")]                       // PII never persists raw
#[RequirePolicies("orders.create")]                       // ABAC gate (hot-reloadable)
#[AuditLog(action = "order.create", resource = "order")]  // compliance record
#[Timeout("5s")]                                          // 504 + cancel on expiry
#[UseInterceptors(TraceInterceptor)]
async fn create_order(ctx: RequestContext, #[Body] dto: CreateOrderDto) -> … {
    JWT_AUTH.check(&ctx)?;
    let _permit = ORDER_BULKHEAD.try_enter()?;            // 503 when saturated
    …                                                     // outbox enqueue is
}                                                         // atomic with the write
```

```bash
# Versioned + deprecated endpoints announce their lifecycle on the wire:
curl -si http://localhost:3000/v1/meta/info | grep -iE "deprecation|sunset"
# deprecation: true
# sunset: 2027-01-01
```

And the stack is **self-documenting** — every attribute above surfaces in the
generated OpenAPI spec, no extra annotations:

```jsonc
// curl -s localhost:3000/openapi.json | jq '.paths["/orders/"].post'
{
  "parameters": [ { "name": "Idempotency-Key", "in": "header", … } ],
  "responses": {
    "409": { "description": "Conflict — a request with this Idempotency-Key is already in flight" },
    "504": { "description": "Gateway Timeout — handler exceeded its 5000ms deadline (work cancelled)" },
    "201": { "headers": { "Idempotency-Replayed": { … } } }
  },
  "x-arcly-idempotent-ttl-secs": 86400,
  "x-arcly-audit": { "action": "order.create", "resource": "order" },
  "x-arcly-timeout-ms": 5000
}
// ABAC routes get policy-aware 403s + x-arcly-policies; sunset routes get
// deprecated:true, x-sunset, and documented RFC 8594 response headers.
```

### Multi-tenancy & datasource routing

```bash
# Tenant resolved once per request (X-Tenant-Id header → frozen TenantRegistry);
# TENANT guard rejects a forged header that contradicts the JWT tenant claim.
curl -s http://localhost:3000/admin/tenant-info \
  -H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: acme" | jq
```
```json
{
  "tenant": "acme",
  "datasource": "acme",
  "read_before_write": "acme-replica-0",   // reads → replica (round-robin)
  "write":             "acme-primary",     // writes → primary (trips the pin)
  "read_after_write":  "acme-primary"      // pinned — replica-lag protection
}
```

### OAuth 2.0 (Google / GitHub)

Providers self-register only when credentials are present — no config, no route:

```bash
export GOOGLE_CLIENT_ID=…  GOOGLE_CLIENT_SECRET=…
export GITHUB_CLIENT_ID=…  GITHUB_CLIENT_SECRET=…
export OAUTH_REDIRECT_BASE=https://your-domain.example
```

```
GET /oauth/{provider}/authorize   → { "url": "https://accounts.google.com/…" }
GET /oauth/{provider}/callback    → validates CSRF state (atomic, single-use),
                                    exchanges code with PKCE, upserts the user,
                                    mints tokens through the same path as
                                    password login, sets both cookies
```

---

Tenants are **dynamically provisionable**: `POST /admin/tenants` upserts a
tenant live (effective on the next request — no redeploy), and
`/admin/tenants/:id/suspend` hard-cuts its traffic to `401` with no silent
fallback to the shared pool. The hot path stays one `ArcSwap` pointer load.

### Unified database layer & `#[Transactional]`

Pick your ecosystem with Cargo features — the injected handle and the
transaction semantics stay identical:

```toml
arcly-http = { version = "0.1", features = ["db-sqlx-postgres"] }
# or: db-seaorm-postgres · db-diesel-postgres · db-sqlx-sqlite · …
# Features are composable — enable two drivers during a migration.
```

```rust
// boot (plugin on_init): build pools, register per tenant, freeze
let primary = sqlx_driver(&db_url, 16).await?;
ctx.provide(DataSourceRegistry::new(ArclyDbPool::new("default", primary)));

// handler: one attribute = begin → commit on Ok / rollback on Err or timeout
#[Post("/", status(201), security("bearer"))]
#[Transactional]
#[AuditLog(action = "note.create", resource = "note")]
async fn create_note(ctx: RequestContext, #[Body] dto: CreateNoteDto) -> … {
    with_current_tx(|mut tx| async move {
        let r = async {
            tx.execute_bind("INSERT INTO notes (body) VALUES (?)", &[&dto.body]).await?;
            tx.execute_bind("INSERT INTO arcly_outbox (…) VALUES (…)", &[…]).await?;
            Ok(())                       // ← outbox rides the SAME transaction
        }.await;
        (tx, r)
    }).await?;
}
```

Guarantees, verified by the example's `/notes` demo:

| Path | Behaviour |
|---|---|
| Handler returns `Ok` | transaction commits — note **and** outbox row land together |
| Handler returns `Err` *after* both inserts | rollback — neither row survives |
| `#[Timeout]` expiry / client disconnect | future dropped → uncommitted tx → driver rollback |
| Sync Diesel | `#[Transactional]` rejects with guidance — use `DieselBlockingPool::transaction(…)` (whole tx in one `spawn_blocking` closure; workers never block) |
| Schema changes | `MigrationRunner` — ledgered, checksummed (drift fails the boot), serialized across the fleet on the cluster lock |

---

### Event mesh — produce transactionally, consume declaratively

```rust
// produce (inside #[Transactional] — same DB transaction as the data):
tx.execute_bind("INSERT INTO arcly_outbox (topic, payload, idempotency_key, traceparent) …").await?;

// consume (NestJS-style — registered at link time, dispatched lock-free):
#[EventConsumer]
impl NoteEventsConsumer {
    #[EventPattern("note.created")]
    async fn on_note_created(ctx: EventContext) -> Result<(), String> {
        let evt: NoteCreated = ctx.payload()?;          // typed payload
        // ctx.trace continues the producing request's trace
        ctx.inject::<SearchIndexer>().index(evt.note_id).await
    }
}
```

| Guarantee | Mechanism |
|---|---|
| No dual writes | outbox row commits with the business data |
| Fleet-safe relay | leader election on the distributed lock |
| Effectively-once consume | claim → complete-on-success / release-on-failure via the idempotency store |
| Poison isolation | bounded nack-retries, then dead-letter with the failure reason |
| Trace continuity | `traceparent` column → `EventContext.trace` (same trace ID end-to-end) |

---

### PII masking — redact before durability

The compliance machinery is durable *on purpose* (hash-chained audit,
committed outbox rows, replayable idempotency cache, DLQ) — so raw PII
reaching any of them is permanent. The `Masker` closes that at the sink:

```rust
ctx.provide(Masker::new(MaskingPolicy::new(1)
    .field("email")                      // Redact: j***@e***.com
    .field("payment.card.number:last4")  // ************4242
    .field("ssn:drop")
    .field("items.*.patient_name:hash"), // joinable, unreadable
));
```

One `ArcSwap` load + a pure tree walk per sink write — rules hot-reload at
runtime (`POST /admin/masking` in the example), and `#[MaskFields]` sits
*inside* `#[Idempotent]` so replay caches only ever hold masked bodies.

---

### Plugin system — extend the framework without forking it

A plugin owns a corner of the framework's behaviour through one trait.
Everything it registers rides the same frozen, lock-free runtime as the core:

```rust
use arcly_http::prelude::*;
use futures::future::BoxFuture;

struct MyPlugin;

impl ArclyPlugin for MyPlugin {
    fn name(&self) -> &'static str { "my-plugin" }

    fn on_init<'a>(&'a mut self, ctx: &'a mut ArclyPluginContext)
        -> BoxFuture<'a, Result<(), PluginError>> {
        Box::pin(async move {
            ctx.provide(MyService::new());                    // new DI singleton
            ctx.override_provider(MySessionStore::new());     // replace a core provider — explicit, never silent
            ctx.add_get("/my/route", my_handler);             // mounted on the shared pipeline
            ctx.register_boundary_filter(&MY_FILTER);         // pre-body early reject (signatures, allowlists)
            ctx.register_global_interceptor(&MY_INTERCEPTOR); // wraps EVERY route, macro + plugin
            ctx.modify_openapi(|spec| { /* patch the spec */ });
            Ok(())
        })
    }
    // on_start:    listener live — spawn background tasks, mount runtime routes
    // on_draining: shutdown signal — stop consuming new work (runs WITH the HTTP drain)
    // on_shutdown: HTTP fully drained — cleanup, bounded by LaunchConfig::drain_budget per plugin
}
```

The lifecycle contract, enforced by `App::launch_configured`:

| Phase | When | Guarantees |
|---|---|---|
| `on_init` | before the DI container freezes | queue providers/overrides, routes, filters, interceptors; route & provider collisions fail launch **loudly** (no silent winners, no axum panics) |
| `on_start` | listener is live | resolve anything via `&'static FrozenDiContainer`; a failure rolls back already-started plugins in reverse order |
| `on_draining` | shutdown signal received | fires concurrently with the HTTP in-flight drain — quiesce MQ consumers and schedulers |
| `on_shutdown` | HTTP fully drained | reverse order, per-plugin `drain_budget` timeout — a wedged plugin can't wedge the process |

Two escape hatches keep "dynamic" off the hot path:

- **Runtime routes**`Inject<DynamicRouteTable>`, then `mount()`/`unmount()`
  under `/_plugins/*` at any time. Dispatch is one `ArcSwap` load + one hash
  probe; routes outside the namespace pay nothing. The core router stays frozen.
- **Request-scoped state**`ctx.extensions_mut()` carries typed per-request
  values from interceptors to handlers. The process-wide container is untouched.

See `examples/enterprise_app/src/infra/shield.rs` for a compact plugin that
exercises this entire surface.

---

### Production governance — `LaunchConfig`

Every operational guardrail lives in one struct, applied as outermost layers
at launch (zero per-request cost for anything left disabled):

```rust
App::launch_configured::<AppModule>(addr, info, plugins,
    LaunchConfig::default()
        .request_timeout(Duration::from_secs(10))   // routes w/o #[Timeout] → 504, worker freed
        .max_in_flight(8_192)                       // beyond this → 503 + Retry-After (atomic CAS, pre-body)
        .adaptive_shed_target(Duration::from_millis(250)) // latency EWMA above this → pressure-proportional 503s
        .max_body_bytes(2 * 1024 * 1024)            // request body cap, every entry point
        .cache_max_entries(50_000)                  // #[CacheTTL] store ceiling + background sweeper
        .ws_drain_deadline(Duration::from_secs(10)) // then Close frames — shutdown can't hang on live sockets
        .drain_budget(Duration::from_secs(5))       // per-plugin on_shutdown budget
        .cors(CorsConfig::for_origins(["https://app.example.com"]))
        .expose_docs(false),                        // hide /docs + /openapi.json in hardened deployments
).await
```

(`LaunchConfig` is `#[non_exhaustive]` with chained setters — new knobs land
without ever breaking your construction again.)

Every knob can be **retuned at deploy time** — `ARCLY_*` environment
variables override the coded values (incident response without a rebuild):
`ARCLY_REQUEST_TIMEOUT_MS`, `ARCLY_MAX_IN_FLIGHT`,
`ARCLY_ADAPTIVE_SHED_TARGET_MS`, `ARCLY_MAX_BODY_BYTES`,
`ARCLY_CACHE_MAX_ENTRIES`, `ARCLY_WS_DRAIN_DEADLINE_MS`,
`ARCLY_DRAIN_BUDGET_MS`, `ARCLY_EXPOSE_DOCS`. Unparseable values are
ignored with a warning, never a silent behaviour change.

Operational guarantees behind those knobs:

- **Shutdown always completes**: signal → `/readyz` flips to `503
  {"status":"draining"}` (LB stops routing; `/healthz` stays green so the
  supervisor doesn't kill the drain) → `on_draining` (concurrent with HTTP
  drain) → WS Close frames at the deadline → drain finishes → per-plugin
  `on_shutdown`. No state where a live socket or wedged plugin forces a SIGKILL.
- **Panics become `500`s**: a panicking handler answers `500` with its
  `x-request-id`, increments `http_handler_panics_total`, and logs the
  payload — never a silently dropped connection no dashboard sees.
- **Oversized bodies are `413`**, not silently truncated — a request past
  `max_body_bytes` is rejected before context assembly, so handlers never
  see a deceptively empty body.
- **Every response carries `x-request-id`** (inbound honoured, else minted) —
  including sheds, timeouts, and panics — alongside W3C `traceparent`.
- **Probes are bounded**: each health check gets a 2-second budget; a hung
  dependency degrades the probe instead of hanging it.
- **JWT signing never panics**: `issue_*` return `Result<_, JwtSignError>`;
  a bad rotation payload is a 500 on that request, not a dead process.
- **No unbounded memory on the request path**: response cache is capacity-
  gated and swept; `/openapi.json` is serialized once at boot and served as
  static bytes.

---

### Metrics reference

Emitted via Prometheus (`/metrics` when the observability plugin is
mounted). Everything is automatic except the first row, which rides
`#[UseInterceptors(TraceInterceptor)]` per route/controller.

| Area | Metric | Notes |
|---|---|---|
| HTTP | `http_requests_total` · `http_request_duration_seconds` | labelled by route pattern, method, status — via `TraceInterceptor` |
| HTTP | `http_requests_in_flight` (gauge) · `http_requests_shed_total` · `http_requests_adaptive_shed_total` · `http_requests_deadline_total` · `http_handler_panics_total` · `http_requests_body_too_large_total` | governor: admission cap, adaptive latency shedding, deadline, panic boundary, body cap |
| WebSocket | `ws_connections` (gauge) · `ws_messages_in_total` / `ws_messages_out_total` · `ws_slow_client_evictions_total` · `ws_idle_reaped_total` · `ws_upgrades_refused_total` · `ws_handler_errors_total` | bounded queues, heartbeat reaping, connection cap |
| Resilience | `circuit_breaker_transitions_total{name,to}` · `rate_limited_total` · `bulkhead_rejected_total{name}` · `bulkhead_available{name}` (gauge) · `handler_timeouts_total{route}` | breaker names = method names from `#[circuit_breaker]` |
| Database | `db_acquire_seconds{pool}` (histogram) · `db_acquire_errors_total{pool}` · `db_replica_fallback_total{pool}` | facade-level; replica failover visibility |
| Events | `events_consumed_total{topic}` · `events_retried_total` · `events_dead_lettered_total` · `events_deduped_total` · `events_tenant_rejected_total` | consumer mesh incl. suspended-tenant rejection |
| Outbox | `outbox_published_total` · `outbox_publish_errors_total` · `outbox_fetch_errors_total` | relay health |
| Idempotency | `idempotency_replays_total` · `idempotency_conflicts_total` · `idempotency_store_errors_total` | `#[Idempotent]` |
| Compliance | `masked_responses_total` · `policy_denials_total` · `audit_dropped_total` · `audit_sink_errors_total` | masking, ABAC, audit pipeline backpressure |
| Locks | `dlock_backend_errors_total` | distributed lock backend |

---

### Testing your application

```rust
use arcly_http::testing::{TestRequest, TestServer};

// Unit test a guard — no server, but the REAL boundary pipeline:
#[tokio::test]
async fn admin_guard_rejects_customers() {
    let ctx = TestRequest::get("/admin/users")
        .claims(serde_json::json!({"sub": "1", "role": "customer"}))
        .build()
        .await;
    assert!(RoleGuard("admin").check(&ctx).is_err());
}

// Integration test — full boot on an ephemeral port:
#[tokio::test]
async fn end_to_end() {
    let server = TestServer::launch::<AppModule>(vec![], LaunchConfig::default()).await;
    let resp = reqwest::get(format!("{}/healthz", server.base_url)).await.unwrap();
    assert_eq!(resp.status(), 200);
}
```

`TestRequest` supports `.header()`, `.query()`, `.json()`, `.claims()`, and
`.provide::<T>()` for DI singletons (e.g. a `TenantRegistry`) — tenant
resolution, trace continuation, and the body cap behave exactly as in
production because it runs the same `assemble_context`.

---

## 🧰 Examples

<details>
<summary><b>📦 enterprise_app</b> — full e-commerce backend (click to expand)</summary>

| Feature | Where |
|---|---|
| JWT + refresh rotation (Redis) | `auth.rs` |
| Cookie + session auth | `auth.rs`, `session_store.rs` |
| OAuth 2.0 Google/GitHub + PKCE | `oauth.rs`, `oauth_providers.rs` |
| `resource:action` permissions | `permissions.rs` |
| Module DAG (5 modules) | `main.rs` |
| Circuit-breaker on payments | `services.rs` |
| Response caching with TTL | `controllers.rs` (products) |
| WebSocket order gateway | `gateways.rs` |
| SSE inventory stream | `controllers.rs` |
| Health checks + plugin lifecycle | `bootstrap.rs` |
| Full plugin surface: boundary filter, global interceptor, runtime `/_plugins` route, drain hooks | `shield.rs` |
| Field encryption: dev `KekSource`, per-tenant DEKs, `/_plugins/vault/demo` | `kek_source.rs`, `bootstrap.rs` |
| Production governance: deadline, admission cap, CORS (`LaunchConfig`) | `main.rs` |
| Redis with in-memory fallback | `redis.rs` |

</details>

<details>
<summary><b>🔌 plugin_template</b> — starting point for external `arcly-plugin-*` crates</summary>

Full plugin lifecycle (init/start/draining/shutdown), DI provider, boundary
filter + global interceptor via the ergonomic `add_*` APIs, a runtime
`/_plugins` route, and a `TestServer`-based integration test — see
[`docs/PLUGINS.md`](docs/PLUGINS.md) for the authoring guide.

</details>

<details>
<summary><b>📡 realtime_enterprise_app</b> — realtime-focused walkthrough</summary>

WebSocket gateways, rooms, broadcast patterns, and SSE under one roof.

</details>

---

## ⚙️ Configuration

All settings are environment variables read at startup by the example's `AppInitPlugin`:

| Variable | Default | Purpose |
|---|---|---|
| `REDIS_URL` | `redis://127.0.0.1:6379` | Token store + sessions — **falls back to in-memory** when unreachable |
| `JWT_SECRET` | dev default | JWT HMAC signing secret |
| `COOKIE_SECRET` | dev default | Auth-cookie HMAC secret |
| `SESSION_SECRET` | dev default | Session-cookie HMAC secret |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | unset | Enables the Google OAuth provider |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | unset | Enables the GitHub OAuth provider |
| `OAUTH_REDIRECT_BASE` | `http://localhost:3000` | Base URL for OAuth callbacks |
| `ARCLY_DB_URL` | `sqlite:///tmp/arcly_demo.db?mode=rwc` | Unified DB pool for the `/notes` `#[Transactional]` demo |

Secrets (`JWT_SECRET`, `COOKIE_SECRET`, `SESSION_SECRET`) are watched at runtime: change the value at the source and the corresponding service hot-rotates its keys within 60 s — no restart, no mass logout. Tenants are selected per request via the `X-Tenant-Id` header (`acme` / `globex`, fallback `public`).

> ⚠️ **Production:** set real secrets, enable `secure: true` on cookies (TLS), and point `REDIS_URL` at a persistent instance — the in-memory fallback loses sessions and token state on restart.

---

## 🛠 Development

```bash
cargo build --workspace                   # build everything
cargo test  -p arcly-http                 # unit + doc tests
cargo test  -p arcly-http --test smoke    # integration smoke suite
cargo run   -p enterprise_app             # full e-commerce example server
cargo run   -p realtime_enterprise_app    # WebSocket gateways + rooms + SSE
cargo build -p plugin_template            # external arcly-plugin-* starting point
```

---

## 📚 Documentation

Full API docs are on [docs.rs/arcly-http](https://docs.rs/arcly-http). See
[`docs/PLUGINS.md`](docs/PLUGINS.md) for the plugin-authoring guide and
[`MIGRATION.md`](MIGRATION.md) for upgrade notes between releases.

---

## 📐 Minimum Supported Rust Version

`arcly-http` supports **Rust 1.85** and later. An MSRV bump is a minor-version
change.

---

## 📄 License

MIT — see [`LICENSE`](LICENSE).