@@ -13,6 +13,7 @@ import (
1313 "github.com/a-h/templ"
1414 "github.com/xraph/forge"
1515 "github.com/xraph/vessel"
16+ "go.opentelemetry.io/otel"
1617
1718 internalmetrics "github.com/xraph/forge/internal/metrics"
1819
@@ -21,6 +22,7 @@ import (
2122 "github.com/xraph/forge/extensions/dashboard/collector"
2223 "github.com/xraph/forge/extensions/dashboard/contract"
2324 "github.com/xraph/forge/extensions/dashboard/contract/dispatcher"
25+ "github.com/xraph/forge/extensions/dashboard/contract/idempotency"
2426 "github.com/xraph/forge/extensions/dashboard/contract/loader"
2527 "github.com/xraph/forge/extensions/dashboard/contract/pilot"
2628 "github.com/xraph/forge/extensions/dashboard/contract/transport"
@@ -273,6 +275,37 @@ func (e *Extension) Register(app forge.App) error {
273275 e .searcher .RebuildIndex ()
274276 }
275277
278+ // Slice (b) Phase 6: replace the safe defaults wired in NewExtension with
279+ // production-grade contract plumbing — Prometheus metrics emission, OTel
280+ // tracing, idempotency dedup, and structured audit logging. The swap is
281+ // gated by EnableContractSecurity so deployments mid-rollout (clients not
282+ // yet sending CSRF tokens / idempotency keys) can opt out without losing
283+ // the rest of the contract path. Must run before pilot.Register so the
284+ // pilot binds against the upgraded dispatcher.
285+ var metricsEmitter dispatcher.MetricsEmitter = dispatcher.NoopMetricsEmitter {}
286+ if app != nil && app .Metrics () != nil {
287+ metricsEmitter = dispatcher .NewPrometheusMetricsEmitter (app .Metrics ())
288+ }
289+ var dispOpts []dispatcher.Option
290+ if e .config .EnableContractSecurity {
291+ dispOpts = append (dispOpts ,
292+ dispatcher .WithTracer (otel .Tracer ("forge.dashboard.contract" )),
293+ dispatcher .WithIdempotencyStore (adaptIdempotencyStore (idempotency .NewInMemoryStore ())),
294+ )
295+ }
296+ e .dispatcher = dispatcher .NewWithOptions (metricsEmitter , dispOpts ... )
297+
298+ var auditEmitter contract.AuditEmitter = contract .NewLogAuditEmitter (os .Stdout )
299+ if app != nil && app .Logger () != nil {
300+ auditEmitter = dispatcher .NewLoggerAuditEmitter (app .Logger ())
301+ }
302+ e .auditEmitter = auditEmitter
303+
304+ // The streamBroker captured the old dispatcher at NewExtension time;
305+ // rebind it to the upgraded dispatcher so SSE subscriptions resolve
306+ // against the same handler registry the POST endpoint uses.
307+ e .streamBroker = transport .NewStreamBroker (e .contractRegistry , e .wardenRegistry , e .dispatcher )
308+
276309 // Register the contract-track pilot contributor (core-contract). This
277310 // loads the embedded manifest, validates it against the warden registry,
278311 // and binds the four pilot handlers (extensions.list / services.list /
@@ -1334,6 +1367,13 @@ func (e *Extension) registerRoutes() {
13341367 must (router .GET (base + "/api/dashboard/v1/stream" , http .HandlerFunc (e .streamBroker .ServeStream )))
13351368 must (router .POST (base + "/api/dashboard/v1/stream/control" , http .HandlerFunc (e .streamBroker .ServeControl )))
13361369 }
1370+ // Slice (b) Phase 6: surface CSRF tokens to the shell only when the
1371+ // security stack is wired (csrfMgr is non-nil iff EnableCSRF is true,
1372+ // and EnableContractSecurity gates the contract path's enforcement).
1373+ if e .csrfMgr != nil && e .config .EnableContractSecurity {
1374+ must (router .GET (base + "/api/dashboard/v1/csrf" ,
1375+ transport .NewCSRFTokenHandler (e .csrfMgr , 12 * time .Hour ).ServeHTTP ))
1376+ }
13371377 }
13381378
13391379 // 4. Export endpoints (stay on forge.Router)
@@ -1419,8 +1459,17 @@ func (e *Extension) registerRoutes() {
14191459// replaces slice (a)'s safe NilDispatcher with the real dispatcher wired in
14201460// NewExtension; intent handlers are bound by pilot.Register during
14211461// Extension.Register so requests resolve to live data instead of CodeUnavailable.
1462+ //
1463+ // Slice (b) Phase 6 routes CSRF validation through the handler when the
1464+ // extension's CSRF manager is configured AND EnableContractSecurity is on.
1465+ // Passing nil to NewHandlerWithCSRF preserves the slice-(a) behaviour — useful
1466+ // during a rollout window where clients have not yet adopted CSRF tokens.
14221467func (e * Extension ) handleContractPOST () http.HandlerFunc {
1423- h := transport .NewHandler (e .contractRegistry , e .wardenRegistry , e .dispatcher , e .auditEmitter )
1468+ var mgr * security.CSRFManager
1469+ if e .config .EnableContractSecurity && e .csrfMgr != nil {
1470+ mgr = e .csrfMgr
1471+ }
1472+ h := transport .NewHandlerWithCSRF (e .contractRegistry , e .wardenRegistry , e .dispatcher , e .auditEmitter , mgr )
14241473 return h .ServeHTTP
14251474}
14261475
@@ -1558,3 +1607,44 @@ func (e *Extension) registerAuthPages() {
15581607 forge .F ("count" , len (pages )),
15591608 )
15601609}
1610+
1611+ // idempotencyAdapter bridges idempotency.Store (the production interface) to
1612+ // dispatcher.IdempotencyStore (the dispatcher-private surface). The two types
1613+ // are intentionally separate: the dispatcher defines its own minimal
1614+ // IdempotencyStore + IdempotencyCached pair to avoid an import cycle with
1615+ // the contract/idempotency sub-package, which itself imports nothing from
1616+ // dispatcher. The conversion is lossless.
1617+ type idempotencyAdapter struct { inner idempotency.Store }
1618+
1619+ // adaptIdempotencyStore returns a dispatcher.IdempotencyStore backed by an
1620+ // idempotency.Store. Used at NewExtension/Register time to wire the in-memory
1621+ // store into the dispatcher.
1622+ func adaptIdempotencyStore (s idempotency.Store ) dispatcher.IdempotencyStore {
1623+ return & idempotencyAdapter {inner : s }
1624+ }
1625+
1626+ // Lookup forwards to the underlying store, converting the cached envelope
1627+ // shape between the two types.
1628+ func (a * idempotencyAdapter ) Lookup (ctx context.Context , key , identity string ) (* dispatcher.IdempotencyCached , bool ) {
1629+ c , ok := a .inner .Lookup (ctx , key , identity )
1630+ if ! ok {
1631+ return nil , false
1632+ }
1633+ return & dispatcher.IdempotencyCached {
1634+ Status : c .Status ,
1635+ WireBody : c .WireBody ,
1636+ StoredAt : c .StoredAt ,
1637+ TTL : c .TTL ,
1638+ }, true
1639+ }
1640+
1641+ // Store forwards to the underlying store, converting the cached envelope
1642+ // shape between the two types.
1643+ func (a * idempotencyAdapter ) Store (ctx context.Context , key , identity string , c dispatcher.IdempotencyCached ) error {
1644+ return a .inner .Store (ctx , key , identity , idempotency.Cached {
1645+ Status : c .Status ,
1646+ WireBody : c .WireBody ,
1647+ StoredAt : c .StoredAt ,
1648+ TTL : c .TTL ,
1649+ })
1650+ }
0 commit comments