Эх сурвалжийг харах

M13b W2: Sources CRUD with one-time secrets + rotation

Implements per-tenant source management in the admin UI. Picks
option (a) from the W2 design discussion: reuse public.sources
directly via authd, with a small bridge that idempotently
inserts a public.companies row when a source is first created
for an auth.tenants id (public.sources.company_id is TEXT and
FKs public.companies; auth.tenants.id is UUID).

Backend (Go):
  - internal/authd/sources.go: Source struct + filter + create
    input + update input + ErrSource*; ListSources, GetSource,
    CreateSource, UpdateSource, SetSourceStatus, RotateSecrets.
    The 'bridge' INSERT is the only place authd writes to
    public.companies.
  - internal/authd/sources_test.go: validators + secret format +
    generate.
  - cmd/authd/sources.go: 6 HTTP handlers (list, create, get,
    update, status, rotate-secrets). CreateSource + RotateSecrets
    return { source, secrets? }; the secrets field is OMITTED
    when no plaintext was generated so the UI knows not to
    render the one-time modal.
  - cmd/authd/main.go: 6 new routes under
    /v1/tenants/{id}/sources[/...] -- RequireAuth scope check
    via canAccessTenant.

Schema (migration 011):
  - public.sources: hmac_secret_hash, api_key_hash (bcrypt cost
    10), mtls_required (default false; M14 reads it), description
    (free text). All nullable so pre-existing rows still load.
  - The down migration drops the columns.

Web:
  - web/src/features/sources/{types,api,format,list,create-dialog,
    detail-page}.{ts,tsx}: feature folder, mirrors companies/.
    One-time secrets modal: visible only on create/rotate success,
    with show/hide + copy per field and a forced 'I have saved
    these' confirmation.
  - web/src/components/ui/checkbox.tsx: new minimal native-input
    checkbox (no Radix dep added just for one screen).
  - web/src/routes/sources.tsx: now wires SourcesList +
    SourceDetailPage (was ComingSoon).
  - web/tests/sources/format.test.ts: 9 vitest format tests.

Smoke:
  - scripts/m13b_w2_smoke.sh: 17-check E2E covering CRUD +
    status + rotate + duplicate + bad input + tenant_admin
    scope + cleanup. Not yet run E2E (no live stack here);
    bash -n clean.

Tests: go test ./internal/authd/ ok (all incl. new validators);
       web tsc -b clean; web vitest 17/17; web vite build clean;
       bash -n smoke clean.

Decision: public.sources reused as-is (no auth.sources view
layer in W2; can refactor later if the auth/admin split needs
to harden). The bridge INSERT is idempotent and only fires on
the first source create for a given auth.tenants id.
jarvis 1 сар өмнө
parent
commit
f618098114

+ 50 - 0
M13b.dlog

@@ -299,6 +299,56 @@ Notes:  <known issues, follow-ups, or 'none'>
 ================================================================================
 ENTRY LOG  (most recent first; append new entries at the TOP of this block)
 ================================================================================
+2026-06-18 02:00 EDT  —  W2 Sources CRUD shipped (one-time secrets + rotate)
+Commit: <this commit>
+Files:  internal/authd/sources.{go,_test.go},
+        cmd/authd/sources.go, cmd/authd/main.go (6 routes wired),
+        migrations/011_sources_secrets.{up,down}.sql,
+        web/src/features/sources/{types,api,format,list,create-dialog,detail-page}.{ts,tsx},
+        web/src/components/ui/checkbox.tsx (new),
+        web/src/routes/sources.tsx (now wired; was ComingSoon),
+        web/tests/sources/format.test.ts,
+        scripts/m13b_w2_smoke.sh,
+        M13b.dlog
+What:   Pick (a) per 01:10 — reuse public.sources directly via authd.
+        CRUD + status + rotate-secrets. Migration 011 adds hmac_secret_hash,
+        api_key_hash, mtls_required, description (all nullable so pre-existing
+        rows still load). One-time secrets: CreateSource / RotateSecrets
+        return plaintext EXACTLY ONCE in `secrets`; only bcrypt hashes
+        persist. auto_secrets: true by default in the UI. tenant_admin
+        can manage their own sources (PATCH allowed; status changes are
+        an open feature, see Notes).
+        Cert UI: placeholder card on detail page (M14 — coming).
+Bridge: public.sources.company_id (TEXT) FKs public.companies.id (TEXT);
+        auth.tenants.id is UUID. W2's CreateSource does an idempotent
+        INSERT INTO public.companies ... ON CONFLICT DO NOTHING keyed
+        by auth.tenants.id::text so the FK is satisfied on first source
+        create. No change to W1's CreateTenant.
+Tests:  go test ./internal/authd/ -count=1  ok (all tests, incl. new
+        TestValidSourceID / TestValidSecretFormat / TestValidAPIKeyFormat
+        / TestCreateSourceInput_Validate / TestUpdateSourceInput_Validate
+        / TestGenerateSecret)
+        web tsc -b  clean
+        web vitest run  17/17 ok (9 new + 6 companies + 2 login)
+        web vite build  clean
+        bash -n scripts/m13b_w2_smoke.sh  clean
+Verify: cd /root/.openclaw/workspace/broad-announce
+        go test -count=1 ./...  &&  cd web && pnpm run test
+        psql -f migrations/011_sources_secrets.up.sql   (apply 011)
+        bash scripts/m13b_w2_smoke.sh                   (run against stack)
+Notes:  Smoke script not yet run E2E (same as W1: needs running authd +
+        Postgres). 17 web tests pass but the W2 component tests are
+        format-only — list/create/detail-page UI tests are a v1.1 add.
+        Decision logged: public.sources reused as-is (no auth.sources
+        view layer in W2; can refactor later if the auth/admin split
+        needs to harden).
+        Open follow-up for v1.1: tenant_admin status-changes (suspend
+        / activate their own source). The route is RequireAuth today
+        and the store allows any caller; the policy dial is whether
+        tenant_admin should be allowed to suspend their own. Not
+        blocking W2; documenting in the .dlog.
+        W3 (Telegram bot CRUD) is next; same pattern as W2.
+
 2026-06-17 18:44 EDT  —  W1 Companies CRUD shipped + this .dlog created
 Commit: c5e15f7 (W1), 63202f2 (.dlog creation)
 Files:  internal/authd/tenants.{go,_test.go}, cmd/authd/tenants.go,

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/forms-BRRx31Es.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/index-B7DU3HvM.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/index-C61UFTGs.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/index-C8nEVWVV.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/index-DJUmCqQa.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
cmd/admind/web-dist/assets/ui-DcUwFLzq.js


+ 4 - 4
cmd/admind/web-dist/index.html

@@ -6,12 +6,12 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="color-scheme" content="light dark" />
     <title>broad-announce admin</title>
-    <script type="module" crossorigin src="/assets/index-DJUmCqQa.js"></script>
+    <script type="module" crossorigin src="/assets/index-B7DU3HvM.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-C4-CelXw.js">
     <link rel="modulepreload" crossorigin href="/assets/query-C_q0vuOo.js">
-    <link rel="modulepreload" crossorigin href="/assets/ui-DsQixLUC.js">
-    <link rel="modulepreload" crossorigin href="/assets/forms-DqymyfYA.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C61UFTGs.css">
+    <link rel="modulepreload" crossorigin href="/assets/ui-DcUwFLzq.js">
+    <link rel="modulepreload" crossorigin href="/assets/forms-BRRx31Es.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-C8nEVWVV.css">
   </head>
   <body class="h-full bg-background text-foreground">
     <div id="root" class="h-full"></div>

+ 14 - 0
cmd/authd/main.go

@@ -113,6 +113,20 @@ func run(logger *slog.Logger) error {
 	mux.Handle("PATCH /v1/tenants/{id}", ad.RequireAuth(updateTenantHandler(ad, logger)))
 	mux.Handle("POST /v1/tenants/{id}/status", ad.RequireRole("super_admin")(setTenantStatusHandler(ad, logger)))
 
+	// M13b W2: Source CRUD. See cmd/authd/sources.go.
+	//   GET    /v1/tenants/{id}/sources                       — any auth, per-tenant scope
+	//   POST   /v1/tenants/{id}/sources                       — any auth, per-tenant scope
+	//   GET    /v1/tenants/{id}/sources/{sid}                 — any auth, per-tenant scope
+	//   PATCH  /v1/tenants/{id}/sources/{sid}                 — any auth, per-tenant scope
+	//   POST   /v1/tenants/{id}/sources/{sid}/status          — any auth, per-tenant scope
+	//   POST   /v1/tenants/{id}/sources/{sid}/rotate-secrets  — any auth, per-tenant scope
+	mux.Handle("GET /v1/tenants/{id}/sources", ad.RequireAuth(listSourcesHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/sources", ad.RequireAuth(createSourceHandler(ad, logger)))
+	mux.Handle("GET /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(getSourceHandler(ad, logger)))
+	mux.Handle("PATCH /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(updateSourceHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/status", ad.RequireAuth(setSourceStatusHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/rotate-secrets", ad.RequireAuth(rotateSourceSecretsHandler(ad, logger)))
+
 	// Start in background, wait for signal, then graceful shutdown.
 	errCh := make(chan error, 1)
 	go func() { errCh <- srv.Start() }()

+ 385 - 0
cmd/authd/sources.go

@@ -0,0 +1,385 @@
+// sources.go — HTTP handlers for the /v1/tenants/{id}/sources/* routes (M13b W2).
+//
+// Routes (all require a valid Bearer access JWT):
+//
+//   GET    /v1/tenants/{id}/sources              — list
+//   POST   /v1/tenants/{id}/sources              — create
+//   GET    /v1/tenants/{id}/sources/{sid}        — detail
+//   PATCH  /v1/tenants/{id}/sources/{sid}        — update
+//   POST   /v1/tenants/{id}/sources/{sid}/status        — set status
+//   POST   /v1/tenants/{id}/sources/{sid}/rotate-secrets — rotate HMAC + API key
+//
+// Errors:
+//   400 — bad input (validation, JSON parse)
+//   401 — handled by RequireAuth middleware (no body rewrite here)
+//   403 — role not allowed, or tenant_admin trying to access another tenant
+//   404 — tenant or source id not found
+//   409 — duplicate source id on create
+//   500 — unexpected DB error
+//
+// Responses:
+//   The Create + RotateSecrets handlers return
+//   { "source": {...}, "secrets": { "hmac_secret": "...", "api_key": "..." } }
+//   on success when secrets were generated. The `secrets` field
+//   is OMITTED if the caller did not request secrets (so the
+//   UI knows not to render the one-time modal). The shape is
+//   always { source, secrets? } so the UI can destructure.
+
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/authd"
+)
+
+// sourcesListResponse is the wire shape for GET /v1/tenants/{id}/sources.
+type sourcesListResponse struct {
+	Items  []authd.Source `json:"items"`
+	Total  int            `json:"total"`
+	Limit  int            `json:"limit"`
+	Offset int            `json:"offset"`
+}
+
+// createOrRotateResponse is the shape returned by Create and Rotate.
+type createOrRotateResponse struct {
+	Source  *authd.Source         `json:"source"`
+	Secrets *authd.SecretsPayload `json:"secrets,omitempty"`
+}
+
+// listSourcesHandler wires GET /v1/tenants/{id}/sources.
+func listSourcesHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		q := strings.TrimSpace(r.URL.Query().Get("q"))
+		typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
+		statusFilter := strings.TrimSpace(r.URL.Query().Get("status"))
+		limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+		offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+		filter := authd.SourceFilter{
+			Q:            q,
+			Type:         typeFilter,
+			Status:       statusFilter,
+			Limit:        limit,
+			Offset:       offset,
+			CallerRole:   claims.Role,
+			CallerTenant: claims.TenantID,
+		}
+		items, total, err := ad.Store().ListSources(r.Context(), filter)
+		if err != nil {
+			logger.Error("list sources", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
+			writeErr(w, http.StatusInternalServerError, "internal", "list failed")
+			return
+		}
+		if filter.Limit <= 0 {
+			filter.Limit = 100
+		}
+		if filter.Limit > 500 {
+			filter.Limit = 500
+		}
+		writeJSON(w, http.StatusOK, sourcesListResponse{
+			Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
+		})
+	}
+}
+
+// createSourceRequest is the POST body. All fields except id,
+// name, type, and rate_limit_per_sec are optional. The hmac_secret
+// and api_key fields, if non-empty, are stored bcrypt-hashed and
+// returned ONCE in the response.
+type createSourceRequest struct {
+	ID              string          `json:"id"`
+	Name            string          `json:"name"`
+	Type            string          `json:"type"`
+	RateLimitPerSec int             `json:"rate_limit_per_sec"`
+	AllowedTargets  json.RawMessage `json:"allowed_targets"`
+	MatchExpr       json.RawMessage `json:"match_expr"`
+	Description     string          `json:"description"`
+	MTLSRequired    bool            `json:"mtls_required"`
+	HMACSecret      string          `json:"hmac_secret"`
+	APIKey          string          `json:"api_key"`
+}
+
+// createSourceHandler wires POST /v1/tenants/{id}/sources.
+func createSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		var req createSourceRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+			return
+		}
+		in := authd.CreateSourceInput{
+			ID:              strings.TrimSpace(req.ID),
+			Name:            strings.TrimSpace(req.Name),
+			Type:            strings.TrimSpace(req.Type),
+			RateLimitPerSec: req.RateLimitPerSec,
+			AllowedTargets:  req.AllowedTargets,
+			MatchExpr:       req.MatchExpr,
+			Description:     req.Description,
+			MTLSRequired:    req.MTLSRequired,
+			HMACSecret:      req.HMACSecret,
+			APIKey:          req.APIKey,
+		}
+		// Look up the auth tenant's display name so the
+		// bridge INSERT into public.companies has a sensible
+		// `name` value when the row is first created.
+		tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
+		if err != nil {
+			if errors.Is(err, authd.ErrTenantNotFound) {
+				writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
+				return
+			}
+			logger.Error("create source: lookup tenant", "err", err, "tenant_id", tenantID)
+			writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
+			return
+		}
+		ip := clientIP(r)
+		ua := r.UserAgent()
+		src, secrets, err := ad.Store().CreateSource(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
+		if err != nil {
+			switch {
+			case errors.Is(err, authd.ErrSourceIDTaken):
+				writeErr(w, http.StatusConflict, "id_taken", "source id already in use")
+			case errors.Is(err, authd.ErrSourceInvalid):
+				writeErr(w, http.StatusBadRequest, "invalid", err.Error())
+			default:
+				logger.Error("create source", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
+				writeErr(w, http.StatusInternalServerError, "internal", "create failed")
+			}
+			return
+		}
+		logger.Info("source created",
+			"tenant_id", tenantID, "source_id", src.ID, "actor", claims.UserID,
+			"hmac_set", src.HMACSet, "api_key_set", src.APIKeySet)
+		writeJSON(w, http.StatusCreated, createOrRotateResponse{Source: src, Secrets: secrets})
+	}
+}
+
+// getSourceHandler wires GET /v1/tenants/{id}/sources/{sid}.
+func getSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		sourceID := r.PathValue("sid")
+		if sourceID == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
+			return
+		}
+		src, err := ad.Store().GetSource(r.Context(), tenantID, sourceID)
+		if err != nil {
+			if errors.Is(err, authd.ErrSourceNotFound) {
+				writeErr(w, http.StatusNotFound, "not_found", "source not found")
+				return
+			}
+			logger.Error("get source", "err", err, "tenant_id", tenantID, "source_id", sourceID)
+			writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
+			return
+		}
+		writeJSON(w, http.StatusOK, src)
+	}
+}
+
+// updateSourceRequest is the PATCH body. All fields optional.
+type updateSourceRequest struct {
+	Name            *string         `json:"name"`
+	Type            *string         `json:"type"`
+	RateLimitPerSec *int            `json:"rate_limit_per_sec"`
+	Description     *string         `json:"description"`
+	MTLSRequired    *bool           `json:"mtls_required"`
+	AllowedTargets  json.RawMessage `json:"allowed_targets"`
+	MatchExpr       json.RawMessage `json:"match_expr"`
+}
+
+// updateSourceHandler wires PATCH /v1/tenants/{id}/sources/{sid}.
+func updateSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		sourceID := r.PathValue("sid")
+		if sourceID == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
+			return
+		}
+		var req updateSourceRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+			return
+		}
+		in := authd.UpdateSourceInput{
+			Name:            req.Name,
+			Type:            req.Type,
+			RateLimitPerSec: req.RateLimitPerSec,
+			Description:     req.Description,
+			MTLSRequired:    req.MTLSRequired,
+			AllowedTargets:  req.AllowedTargets,
+			MatchExpr:       req.MatchExpr,
+		}
+		ip := clientIP(r)
+		ua := r.UserAgent()
+		src, err := ad.Store().UpdateSource(r.Context(), tenantID, sourceID, in, claims.UserID, ip, ua)
+		if err != nil {
+			switch {
+			case errors.Is(err, authd.ErrSourceNotFound):
+				writeErr(w, http.StatusNotFound, "not_found", "source not found")
+			case errors.Is(err, authd.ErrSourceInvalid):
+				writeErr(w, http.StatusBadRequest, "invalid", err.Error())
+			default:
+				logger.Error("update source", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
+				writeErr(w, http.StatusInternalServerError, "internal", "update failed")
+			}
+			return
+		}
+		logger.Info("source updated",
+			"tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
+		writeJSON(w, http.StatusOK, src)
+	}
+}
+
+// setSourceStatusRequest is the POST /status body.
+type setSourceStatusRequest struct {
+	Status string `json:"status"`
+}
+
+// setSourceStatusHandler wires POST /v1/tenants/{id}/sources/{sid}/status.
+func setSourceStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		sourceID := r.PathValue("sid")
+		if sourceID == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
+			return
+		}
+		var req setSourceStatusRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+			return
+		}
+		ip := clientIP(r)
+		ua := r.UserAgent()
+		src, err := ad.Store().SetSourceStatus(r.Context(), tenantID, sourceID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
+		if err != nil {
+			switch {
+			case errors.Is(err, authd.ErrSourceNotFound):
+				writeErr(w, http.StatusNotFound, "not_found", "source not found")
+			case errors.Is(err, authd.ErrSourceInvalid):
+				writeErr(w, http.StatusBadRequest, "invalid", err.Error())
+			default:
+				logger.Error("set source status", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
+				writeErr(w, http.StatusInternalServerError, "internal", "update failed")
+			}
+			return
+		}
+		logger.Info("source status changed",
+			"tenant_id", tenantID, "source_id", sourceID, "to", src.Status, "actor", claims.UserID)
+		writeJSON(w, http.StatusOK, src)
+	}
+}
+
+// rotateSourceSecretsHandler wires POST /v1/tenants/{id}/sources/{sid}/rotate-secrets.
+func rotateSourceSecretsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims := authd.ClaimsFromContext(r.Context())
+		if claims == nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
+			return
+		}
+		tenantID := r.PathValue("id")
+		if !isUUID(tenantID) {
+			writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
+			return
+		}
+		if !canAccessTenant(claims, tenantID) {
+			writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
+			return
+		}
+		sourceID := r.PathValue("sid")
+		if sourceID == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
+			return
+		}
+		ip := clientIP(r)
+		ua := r.UserAgent()
+		src, secrets, err := ad.Store().RotateSecrets(r.Context(), tenantID, sourceID, claims.UserID, ip, ua)
+		if err != nil {
+			if errors.Is(err, authd.ErrSourceNotFound) {
+				writeErr(w, http.StatusNotFound, "not_found", "source not found")
+				return
+			}
+			logger.Error("rotate source secrets", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
+			writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
+			return
+		}
+		logger.Info("source secrets rotated",
+			"tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
+		writeJSON(w, http.StatusOK, createOrRotateResponse{Source: src, Secrets: secrets})
+	}
+}

+ 745 - 0
internal/authd/sources.go

@@ -0,0 +1,745 @@
+// Package authd — sources.go: Source CRUD for M13b W2.
+//
+// Sources are the *runtime* ingest-side counterpart of tenants.
+// Each (company_id, id) row represents a single source that can
+// POST events into ingestd. M13b W2 turns the existing
+// public.sources table (created in migration 003) into a
+// fully-managed resource in the admin UI.
+//
+// Schema (post-migration 011):
+//   id                   TEXT
+//   company_id           TEXT FK -> public.companies(id)
+//   name                 TEXT
+//   type                 TEXT (http | mqtt | ws | grpc)
+//   rate_limit_per_sec   INTEGER
+//   allowed_targets      JSONB  (M13c routing UI will edit; W2 read-only)
+//   match_expr           JSONB  (M13c routing UI will edit; W2 read-only)
+//   status               TEXT   (active | suspended)
+//   hmac_secret_hash     TEXT   (bcrypt; returned only on create/rotate)
+//   api_key_hash         TEXT   (bcrypt; returned only on create/rotate)
+//   mtls_required        BOOLEAN (M14 reads; W2 just stores)
+//   description          TEXT
+//   created_at           TIMESTAMPTZ
+//
+// Bridge to auth.tenants:
+//   auth.tenants.id is a UUID; public.sources.company_id is a
+//   TEXT FK to public.companies.id (also TEXT). The two schemas
+//   predate each other and were never formally linked. The
+//   convention this code enforces: public.companies.id ==
+//   auth.tenants.id::text. CreateSource uses ON CONFLICT
+//   DO NOTHING to ensure a public.companies row exists before
+//   the FK is hit, so creating a source for an auth tenant
+//   without a corresponding public.companies row is a no-op
+//   (idempotent) rather than an error.
+//
+// Threading: safe for concurrent use (pgx pool is goroutine-safe).
+package authd
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+
+	"golang.org/x/crypto/bcrypt"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
+)
+
+// Source is the wire shape returned to handlers / JSON callers.
+// Secrets are NEVER included — only the booleans flagging their
+// presence (`hmac_set`, `api_key_set`). Plaintext values live
+// in the one-time SecretsPayload returned by CreateSource and
+// RotateSecrets.
+type Source struct {
+	ID              string          `json:"id"`
+	CompanyID       string          `json:"company_id"`
+	Name            string          `json:"name"`
+	Type            string          `json:"type"`
+	RateLimitPerSec int             `json:"rate_limit_per_sec"`
+	AllowedTargets  json.RawMessage `json:"allowed_targets"`
+	MatchExpr       json.RawMessage `json:"match_expr"`
+	Status          string          `json:"status"`
+	MTLSRequired    bool            `json:"mtls_required"`
+	Description     string          `json:"description,omitempty"`
+	HMACSet         bool            `json:"hmac_set"`
+	APIKeySet       bool            `json:"api_key_set"`
+	CreatedAt       time.Time       `json:"created_at"`
+}
+
+// SecretsPayload is the one-time plaintext payload returned at
+// create and rotate time. The UI shows it in a modal that
+// requires the operator to confirm "I have saved these before
+// continuing." After that, the values are gone from the server
+// and the modal can never re-display them.
+type SecretsPayload struct {
+	HMACSecret string `json:"hmac_secret"`
+	APIKey     string `json:"api_key"`
+}
+
+// ErrSourceNotFound is returned when (company_id, id) doesn't exist.
+var ErrSourceNotFound = errors.New("authd: source not found")
+
+// ErrSourceIDTaken is returned when CreateSource sees a
+// duplicate (company_id, id) for a tenant that already has it.
+var ErrSourceIDTaken = errors.New("authd: source id already in use")
+
+// ErrSourceInvalid is returned when input validation fails.
+var ErrSourceInvalid = errors.New("authd: source input invalid")
+
+// validSourceTypes is the whitelist of `type` values. Mirrors
+// the schema default comment in 003.
+var validSourceTypes = map[string]struct{}{
+	"http": {},
+	"mqtt": {},
+	"ws":   {},
+	"grpc": {},
+}
+
+// SourceFilter controls ListSources. Empty fields mean "no filter".
+type SourceFilter struct {
+	Q            string // matches id OR name (ILIKE)
+	Type         string // exact match
+	Status       string // exact match
+	Limit        int
+	Offset       int
+	CallerRole   string
+	CallerTenant string // auth.tenants.id (UUID string)
+}
+
+// CreateSourceInput is the validated create payload. The optional
+// HMAC + API key fields, if non-empty, are bcrypt-hashed by
+// CreateSource. The plaintext is NEVER persisted; the caller
+// receives it in the returned SecretsPayload.
+type CreateSourceInput struct {
+	ID              string
+	Name            string
+	Type            string
+	RateLimitPerSec int
+	AllowedTargets  json.RawMessage
+	MatchExpr       json.RawMessage
+	Description     string
+	MTLSRequired    bool
+	HMACSecret      string // optional; if non-empty, will be hashed
+	APIKey          string // optional; if non-empty, will be hashed
+}
+
+// UpdateSourceInput is the PATCH payload. Pointer / non-nil
+// fields mean "apply this." Nil raw-message means "leave the
+// JSON column as-is." All fields are optional; an empty patch
+// is a no-op.
+type UpdateSourceInput struct {
+	Name            *string
+	Type            *string
+	RateLimitPerSec *int
+	Description     *string
+	MTLSRequired    *bool
+	AllowedTargets  json.RawMessage
+	MatchExpr       json.RawMessage
+}
+
+// Validate runs the constraints the DB enforces, but earlier
+// and with friendlier error messages for the UI.
+func (in *CreateSourceInput) Validate() error {
+	if !validSourceID(in.ID) {
+		return fmt.Errorf("%w: id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrSourceInvalid)
+	}
+	if strings.TrimSpace(in.Name) == "" {
+		return fmt.Errorf("%w: name is required", ErrSourceInvalid)
+	}
+	if len(in.Name) > 200 {
+		return fmt.Errorf("%w: name must be \u2264 200 characters", ErrSourceInvalid)
+	}
+	if _, ok := validSourceTypes[in.Type]; !ok {
+		return fmt.Errorf("%w: type must be http|mqtt|ws|grpc", ErrSourceInvalid)
+	}
+	if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
+		return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrSourceInvalid)
+	}
+	if len(in.Description) > 500 {
+		return fmt.Errorf("%w: description must be \u2264 500 characters", ErrSourceInvalid)
+	}
+	// Empty raw messages are allowed; CreateSource defaults them to '[]' / '{}'.
+	if len(in.AllowedTargets) > 0 && !json.Valid(in.AllowedTargets) {
+		return fmt.Errorf("%w: allowed_targets must be valid JSON", ErrSourceInvalid)
+	}
+	if len(in.MatchExpr) > 0 && !json.Valid(in.MatchExpr) {
+		return fmt.Errorf("%w: match_expr must be valid JSON", ErrSourceInvalid)
+	}
+	if in.HMACSecret != "" && !validSecretFormat(in.HMACSecret) {
+		return fmt.Errorf("%w: hmac_secret, if provided, must be 32..128 [A-Za-z0-9_-] chars", ErrSourceInvalid)
+	}
+	if in.APIKey != "" && !validAPIKeyFormat(in.APIKey) {
+		return fmt.Errorf("%w: api_key, if provided, must be 16..128 [A-Za-z0-9_-] chars", ErrSourceInvalid)
+	}
+	return nil
+}
+
+// Validate is the same for Update. We don't enforce presence
+// of fields (PATCH can be empty), just per-field constraints.
+func (in *UpdateSourceInput) Validate() error {
+	if in.Name != nil {
+		s := strings.TrimSpace(*in.Name)
+		if s == "" {
+			return fmt.Errorf("%w: name cannot be empty", ErrSourceInvalid)
+		}
+		if len(s) > 200 {
+			return fmt.Errorf("%w: name must be \u2264 200 characters", ErrSourceInvalid)
+		}
+	}
+	if in.Type != nil {
+		if _, ok := validSourceTypes[*in.Type]; !ok {
+			return fmt.Errorf("%w: type must be http|mqtt|ws|grpc", ErrSourceInvalid)
+		}
+	}
+	if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
+		return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrSourceInvalid)
+	}
+	if in.Description != nil && len(*in.Description) > 500 {
+		return fmt.Errorf("%w: description must be \u2264 500 characters", ErrSourceInvalid)
+	}
+	if in.AllowedTargets != nil && !json.Valid(in.AllowedTargets) {
+		return fmt.Errorf("%w: allowed_targets must be valid JSON", ErrSourceInvalid)
+	}
+	if in.MatchExpr != nil && !json.Valid(in.MatchExpr) {
+		return fmt.Errorf("%w: match_expr must be valid JSON", ErrSourceInvalid)
+	}
+	return nil
+}
+
+// ensurePublicCompanyRow makes sure public.companies has a row
+// keyed by the auth.tenants.id (cast to text). This is the
+// bridge between the M13a auth schema and the M0 data-plane
+// schema. Idempotent; safe to call from CreateSource. The row
+// carries just the minimum data: id, name (display_name), a
+// default rate_limit, status='active'. Operators who want to
+// manage the public.companies row's fields (rate_limit, etc.)
+// can do so via the W1 tenant API; this code path only ensures
+// the FK target exists.
+func (s *Store) ensurePublicCompanyRow(ctx context.Context, tenantID, displayName string) error {
+	if s.pool == nil {
+		return errors.New("authd: no DB pool (test mode)")
+	}
+	const q = `
+		INSERT INTO public.companies (id, name, status, rate_limit_per_sec)
+		VALUES ($1::text, $2, 'active', 10000)
+		ON CONFLICT (id) DO NOTHING
+	`
+	_, err := s.pool.Exec(ctx, q, tenantID, displayName)
+	if err != nil {
+		return fmt.Errorf("ensure public.companies row: %w", err)
+	}
+	return nil
+}
+
+// ListSources returns sources visible to the caller under the
+// given filter, plus the total count. Scope: super_admin sees
+// all tenants' sources; non-super_admin sees only the caller's
+// tenant. CallerTenant is the auth.tenants.id (UUID string).
+func (s *Store) ListSources(ctx context.Context, f SourceFilter) ([]Source, int, error) {
+	if s.pool == nil {
+		return nil, 0, errors.New("authd: no DB pool (test mode)")
+	}
+	if f.Limit <= 0 {
+		f.Limit = 100
+	}
+	if f.Limit > 500 {
+		f.Limit = 500
+	}
+	args := []any{}
+	conds := []string{}
+
+	if f.CallerRole != "super_admin" {
+		if f.CallerTenant == "" {
+			return []Source{}, 0, nil
+		}
+		// Bridge: public.sources.company_id (TEXT) = auth.tenants.id::text
+		args = append(args, f.CallerTenant)
+		conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
+	}
+	if strings.TrimSpace(f.Type) != "" {
+		args = append(args, f.Type)
+		conds = append(conds, fmt.Sprintf("type = $%d", len(args)))
+	}
+	if strings.TrimSpace(f.Status) != "" {
+		args = append(args, f.Status)
+		conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
+	}
+	if strings.TrimSpace(f.Q) != "" {
+		args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
+		conds = append(conds, fmt.Sprintf("(id ILIKE $%d OR name ILIKE $%d)", len(args), len(args)))
+	}
+	where := ""
+	if len(conds) > 0 {
+		where = "WHERE " + strings.Join(conds, " AND ")
+	}
+
+	var total int
+	if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.sources "+where, args...).Scan(&total); err != nil {
+		return nil, 0, fmt.Errorf("count sources: %w", err)
+	}
+
+	args = append(args, f.Limit, f.Offset)
+	q := fmt.Sprintf(`
+		SELECT id, company_id, name, type, rate_limit_per_sec,
+		       allowed_targets, match_expr, status, mtls_required,
+		       COALESCE(description, ''),
+		       (hmac_secret_hash IS NOT NULL),
+		       (api_key_hash IS NOT NULL),
+		       created_at
+		FROM public.sources
+		%s
+		ORDER BY created_at DESC
+		LIMIT $%d OFFSET $%d
+	`, where, len(args)-1, len(args))
+	rows, err := s.pool.Query(ctx, q, args...)
+	if err != nil {
+		return nil, 0, fmt.Errorf("list sources: %w", err)
+	}
+	defer rows.Close()
+	out := make([]Source, 0, f.Limit)
+	for rows.Next() {
+		var src Source
+		if err := rows.Scan(
+			&src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
+			&src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
+			&src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
+		); err != nil {
+			return nil, 0, fmt.Errorf("scan source: %w", err)
+		}
+		out = append(out, src)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, 0, fmt.Errorf("rows: %w", err)
+	}
+	return out, total, nil
+}
+
+// GetSource fetches a single source by (company_id, id). Returns
+// ErrSourceNotFound if missing. Caller is responsible for the
+// per-id scope check (canAccessSource); this method is a
+// straight DB lookup.
+func (s *Store) GetSource(ctx context.Context, companyID, id string) (*Source, error) {
+	if s.pool == nil {
+		return nil, errors.New("authd: no DB pool (test mode)")
+	}
+	const q = `
+		SELECT id, company_id, name, type, rate_limit_per_sec,
+		       allowed_targets, match_expr, status, mtls_required,
+		       COALESCE(description, ''),
+		       (hmac_secret_hash IS NOT NULL),
+		       (api_key_hash IS NOT NULL),
+		       created_at
+		FROM public.sources
+		WHERE company_id = $1 AND id = $2
+	`
+	src := &Source{}
+	err := s.pool.QueryRow(ctx, q, companyID, id).Scan(
+		&src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
+		&src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
+		&src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
+	)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return nil, ErrSourceNotFound
+		}
+		return nil, fmt.Errorf("get source: %w", err)
+	}
+	return src, nil
+}
+
+// CreateSource inserts a new source and returns the row plus a
+// one-time SecretsPayload. The bridge to public.companies is
+// handled inside (ensurePublicCompanyRow). Audit log written.
+//
+// Behavior:
+//   - Duplicate (company_id, id) returns ErrSourceIDTaken (409).
+//   - If HMACSecret / APIKey are empty in the input, no hash is
+//     stored; the resulting Source has hmac_set=false.
+//   - If non-empty, they're bcrypt-hashed at cost 10 and the
+//     plaintext is returned in SecretsPayload. The plaintext
+//     is the ONLY time the UI can see it.
+func (s *Store) CreateSource(
+	ctx context.Context,
+	tenantID, tenantDisplayName string,
+	in CreateSourceInput,
+	actorUserID, actorIP, actorUA string,
+) (*Source, *SecretsPayload, error) {
+	if s.pool == nil {
+		return nil, nil, errors.New("authd: no DB pool (test mode)")
+	}
+	if err := in.Validate(); err != nil {
+		return nil, nil, err
+	}
+
+	// Bridge: ensure public.companies has a row keyed by the
+	// auth.tenants.id cast to text. This is the only place
+	// authd writes to public.companies; everything else is
+	// via the M13a auth.tenants API.
+	if err := s.ensurePublicCompanyRow(ctx, tenantID, tenantDisplayName); err != nil {
+		return nil, nil, err
+	}
+
+	// Default the JSONB columns if the caller didn't send
+	// anything: '[]' for allowed_targets, '{}' for match_expr.
+	allowedTargets := in.AllowedTargets
+	if len(allowedTargets) == 0 {
+		allowedTargets = json.RawMessage(`[]`)
+	}
+	matchExpr := in.MatchExpr
+	if len(matchExpr) == 0 {
+		matchExpr = json.RawMessage(`{}`)
+	}
+
+	// Hash the optional secrets. cost=10 mirrors the bootstrap
+	// path; v1.1 will bump to 12 in prod.
+	hmacHash, err := hashSecret(in.HMACSecret, "hmac")
+	if err != nil {
+		return nil, nil, err
+	}
+	apiKeyHash, err := hashSecret(in.APIKey, "api_key")
+	if err != nil {
+		return nil, nil, err
+	}
+
+	const q = `
+		INSERT INTO public.sources
+		    (company_id, id, name, type, rate_limit_per_sec,
+		     allowed_targets, match_expr, status, mtls_required,
+		     description, hmac_secret_hash, api_key_hash)
+		VALUES
+		    ($1::text, $2, $3, $4, $5,
+		     $6, $7, 'active', $8,
+		     NULLIF($9, ''), $10, $11)
+		RETURNING id, company_id, name, type, rate_limit_per_sec,
+		          allowed_targets, match_expr, status, mtls_required,
+		          COALESCE(description, ''),
+		          (hmac_secret_hash IS NOT NULL),
+		          (api_key_hash IS NOT NULL),
+		          created_at
+	`
+	src := &Source{}
+	err = s.pool.QueryRow(ctx, q,
+		tenantID, in.ID, in.Name, in.Type, in.RateLimitPerSec,
+		allowedTargets, matchExpr, in.MTLSRequired,
+		in.Description, nullableString(hmacHash), nullableString(apiKeyHash),
+	).Scan(
+		&src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
+		&src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
+		&src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
+	)
+	if err != nil {
+		var pgErr *pgconn.PgError
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+			return nil, nil, ErrSourceIDTaken
+		}
+		return nil, nil, fmt.Errorf("create source: %w", err)
+	}
+
+	// Audit. The plaintext secrets are NOT included in the
+	// audit payload — we only audit the fact that a source
+	// was created, not the values.
+	if err := s.WriteAudit(ctx, "source.create", actorUserID, actorIP, actorUA, src.CompanyID, src.ID, map[string]any{
+		"name":              src.Name,
+		"type":              src.Type,
+		"rate_limit_per_sec": src.RateLimitPerSec,
+		"mtls_required":     src.MTLSRequired,
+		"hmac_set":          src.HMACSet,
+		"api_key_set":       src.APIKeySet,
+	}); err != nil {
+		_ = err
+	}
+
+	// Build the secrets payload only if the caller actually
+	// provided one. Empty input => no payload, so the UI knows
+	// not to render the secrets modal.
+	var payload *SecretsPayload
+	if in.HMACSecret != "" || in.APIKey != "" {
+		payload = &SecretsPayload{
+			HMACSecret: in.HMACSecret,
+			APIKey:     in.APIKey,
+		}
+	}
+	return src, payload, nil
+}
+
+// UpdateSource applies a partial update and writes an audit row.
+func (s *Store) UpdateSource(
+	ctx context.Context,
+	companyID, id string,
+	in UpdateSourceInput,
+	actorUserID, actorIP, actorUA string,
+) (*Source, error) {
+	if s.pool == nil {
+		return nil, errors.New("authd: no DB pool (test mode)")
+	}
+	if err := in.Validate(); err != nil {
+		return nil, err
+	}
+	sets := []string{}
+	args := []any{companyID, id}
+	if in.Name != nil {
+		args = append(args, strings.TrimSpace(*in.Name))
+		sets = append(sets, fmt.Sprintf("name = $%d", len(args)))
+	}
+	if in.Type != nil {
+		args = append(args, *in.Type)
+		sets = append(sets, fmt.Sprintf("type = $%d", len(args)))
+	}
+	if in.RateLimitPerSec != nil {
+		args = append(args, *in.RateLimitPerSec)
+		sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
+	}
+	if in.Description != nil {
+		args = append(args, *in.Description)
+		sets = append(sets, fmt.Sprintf("description = $%d", len(args)))
+	}
+	if in.MTLSRequired != nil {
+		args = append(args, *in.MTLSRequired)
+		sets = append(sets, fmt.Sprintf("mtls_required = $%d", len(args)))
+	}
+	if in.AllowedTargets != nil {
+		args = append(args, in.AllowedTargets)
+		sets = append(sets, fmt.Sprintf("allowed_targets = $%d", len(args)))
+	}
+	if in.MatchExpr != nil {
+		args = append(args, in.MatchExpr)
+		sets = append(sets, fmt.Sprintf("match_expr = $%d", len(args)))
+	}
+	if len(sets) == 0 {
+		return s.GetSource(ctx, companyID, id)
+	}
+	q := fmt.Sprintf("UPDATE public.sources SET %s WHERE company_id = $1 AND id = $2", strings.Join(sets, ", "))
+	tag, err := s.pool.Exec(ctx, q, args...)
+	if err != nil {
+		return nil, fmt.Errorf("update source: %w", err)
+	}
+	if tag.RowsAffected() == 0 {
+		return nil, ErrSourceNotFound
+	}
+
+	payload := map[string]any{}
+	if in.Name != nil {
+		payload["name"] = *in.Name
+	}
+	if in.Type != nil {
+		payload["type"] = *in.Type
+	}
+	if in.RateLimitPerSec != nil {
+		payload["rate_limit_per_sec"] = *in.RateLimitPerSec
+	}
+	if in.Description != nil {
+		payload["description"] = *in.Description
+	}
+	if in.MTLSRequired != nil {
+		payload["mtls_required"] = *in.MTLSRequired
+	}
+	if in.AllowedTargets != nil {
+		payload["allowed_targets_set"] = true
+	}
+	if in.MatchExpr != nil {
+		payload["match_expr_set"] = true
+	}
+	if err := s.WriteAudit(ctx, "source.update", actorUserID, actorIP, actorUA, companyID, id, payload); err != nil {
+		_ = err
+	}
+	return s.GetSource(ctx, companyID, id)
+}
+
+// SetSourceStatus flips status. Allowed transitions:
+//   active    -> suspended
+//   suspended -> active
+// No archive state for sources (per the schema; only
+// active|suspended).
+func (s *Store) SetSourceStatus(
+	ctx context.Context,
+	companyID, id, newStatus string,
+	actorUserID, actorIP, actorUA string,
+) (*Source, error) {
+	if s.pool == nil {
+		return nil, errors.New("authd: no DB pool (test mode)")
+	}
+	switch newStatus {
+	case "active", "suspended":
+	default:
+		return nil, fmt.Errorf("%w: status must be active|suspended", ErrSourceInvalid)
+	}
+	cur, err := s.GetSource(ctx, companyID, id)
+	if err != nil {
+		return nil, err
+	}
+	if cur.Status == newStatus {
+		return cur, nil
+	}
+	if _, err := s.pool.Exec(ctx,
+		"UPDATE public.sources SET status = $3 WHERE company_id = $1 AND id = $2",
+		companyID, id, newStatus); err != nil {
+		return nil, fmt.Errorf("set source status: %w", err)
+	}
+	if err := s.WriteAudit(ctx, "source.status", actorUserID, actorIP, actorUA, companyID, id, map[string]any{
+		"from": cur.Status,
+		"to":   newStatus,
+	}); err != nil {
+		_ = err
+	}
+	return s.GetSource(ctx, companyID, id)
+}
+
+// RotateSecrets generates a new HMAC secret + API key, hashes
+// them, and returns the plaintext ONCE in SecretsPayload. The
+// old secrets are immediately invalidated (overwritten in the
+// DB). Use this when a source's credentials are suspected to
+// have leaked.
+func (s *Store) RotateSecrets(
+	ctx context.Context,
+	companyID, id string,
+	actorUserID, actorIP, actorUA string,
+) (*Source, *SecretsPayload, error) {
+	if s.pool == nil {
+		return nil, nil, errors.New("authd: no DB pool (test mode)")
+	}
+	// Confirm the source exists before we generate anything.
+	// If it doesn't, we don't want to surface that a random
+	// pair was generated and then discarded.
+	cur, err := s.GetSource(ctx, companyID, id)
+	if err != nil {
+		return nil, nil, err
+	}
+	hmacPlain, err := generateSecret(32)
+	if err != nil {
+		return nil, nil, fmt.Errorf("generate hmac: %w", err)
+	}
+	apiPlain, err := generateAPIKey()
+	if err != nil {
+		return nil, nil, fmt.Errorf("generate api_key: %w", err)
+	}
+	hmacHash, err := hashSecret(hmacPlain, "hmac")
+	if err != nil {
+		return nil, nil, err
+	}
+	apiKeyHash, err := hashSecret(apiPlain, "api_key")
+	if err != nil {
+		return nil, nil, err
+	}
+	if _, err := s.pool.Exec(ctx,
+		"UPDATE public.sources SET hmac_secret_hash = $3, api_key_hash = $4 WHERE company_id = $1 AND id = $2",
+		companyID, id, hmacHash, apiKeyHash); err != nil {
+		return nil, nil, fmt.Errorf("rotate secrets: %w", err)
+	}
+	if err := s.WriteAudit(ctx, "source.rotate_secrets", actorUserID, actorIP, actorUA, companyID, id, map[string]any{
+		"hmac_rotated":   true,
+		"api_key_rotated": true,
+	}); err != nil {
+		_ = err
+	}
+	updated, err := s.GetSource(ctx, companyID, id)
+	if err != nil {
+		return nil, nil, err
+	}
+	_ = cur
+	return updated, &SecretsPayload{
+		HMACSecret: hmacPlain,
+		APIKey:     apiPlain,
+	}, nil
+}
+
+// -------------------------------------------------------------------
+// helpers
+// -------------------------------------------------------------------
+
+// validSourceID matches the same regex as auth.tenants.slug:
+//   ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
+func validSourceID(s string) bool {
+	if len(s) < 2 || len(s) > 64 {
+		return false
+	}
+	if !isAlnumOrDash(s[0]) || s[0] == '-' {
+		return false
+	}
+	if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
+		return false
+	}
+	for i := 1; i < len(s)-1; i++ {
+		if !isAlnumOrDash(s[i]) {
+			return false
+		}
+	}
+	return true
+}
+
+// validSecretFormat — HMAC secret: 32..128 [A-Za-z0-9_-] chars.
+// Long enough to be cryptographically meaningful, short enough
+// to paste into a config file by hand.
+func validSecretFormat(s string) bool {
+	if len(s) < 32 || len(s) > 128 {
+		return false
+	}
+	for i := 0; i < len(s); i++ {
+		c := s[i]
+		if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+			(c >= '0' && c <= '9') || c == '_' || c == '-') {
+			return false
+		}
+	}
+	return true
+}
+
+// validAPIKeyFormat — API key: 16..128 [A-Za-z0-9_-] chars.
+func validAPIKeyFormat(s string) bool {
+	if len(s) < 16 || len(s) > 128 {
+		return false
+	}
+	for i := 0; i < len(s); i++ {
+		c := s[i]
+		if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+			(c >= '0' && c <= '9') || c == '_' || c == '-') {
+			return false
+		}
+	}
+	return true
+}
+
+// generateSecret returns n random bytes hex-encoded.
+func generateSecret(n int) (string, error) {
+	buf := make([]byte, n)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+// generateAPIKey returns 24 random bytes hex-encoded (48 chars).
+// Hex is fine because the key never appears in URLs.
+func generateAPIKey() (string, error) {
+	return generateSecret(24)
+}
+
+// hashSecret bcrypts the plaintext at cost 10. Returns the
+// empty string if the plaintext is empty (caller checks
+// separately for "no secret was provided"). The `kind` arg is
+// only used in the error path; the actual hash doesn't care.
+func hashSecret(plain, kind string) (string, error) {
+	if plain == "" {
+		return "", nil
+	}
+	h, err := bcrypt.GenerateFromPassword([]byte(plain), 10)
+	if err != nil {
+		return "", fmt.Errorf("hash %s secret: %w", kind, err)
+	}
+	return string(h), nil
+}
+
+// nullableString returns nil for empty input, otherwise &s.
+// Used to pass optional columns to pgx Exec / QueryRow.
+func nullableString(s string) any {
+	if s == "" {
+		return nil
+	}
+	return s
+}
+

+ 184 - 0
internal/authd/sources_test.go

@@ -0,0 +1,184 @@
+// sources_test.go — pure-Go tests for the input validators and
+// the format helpers on the sources store. The DB-backed paths
+// (Create, Update, SetStatus, List, RotateSecrets) are exercised
+// by scripts/m13b_w2_smoke.sh against a real Postgres.
+
+package authd
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestValidSourceID(t *testing.T) {
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		// valid (same shape as auth.tenants.slug)
+		{"primary", true},
+		{"ops-foo", true},
+		{"a-b-c", true},
+		{strings.Repeat("a", 64), true},
+		// invalid
+		{"", false},
+		{"a", false},            // too short (1 char)
+		{"A", false},            // uppercase
+		{"-foo", false},         // leading dash
+		{"foo-", false},         // trailing dash
+		{"foo_bar", false},      // underscore
+		{"foo bar", false},      // space
+		{"foo.bar", false},      // dot
+		{strings.Repeat("a", 65), false},
+	}
+	for _, c := range cases {
+		if got := validSourceID(c.in); got != c.want {
+			t.Errorf("validSourceID(%q) = %v, want %v", c.in, got, c.want)
+		}
+	}
+}
+
+func TestValidSecretFormat(t *testing.T) {
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		{strings.Repeat("a", 32), true},
+		{strings.Repeat("a", 64), true},
+		{strings.Repeat("a", 128), true},
+		{"abc-DEF_123" + strings.Repeat("a", 22), true},
+		// invalid
+		{"", false},
+		{strings.Repeat("a", 31), false},  // too short
+		{strings.Repeat("a", 129), false}, // too long
+		{"with spaces inside", false},
+		{"with!special", false},
+		{"with.dot", false},
+		{"with/slash", false},
+	}
+	for _, c := range cases {
+		if got := validSecretFormat(c.in); got != c.want {
+			t.Errorf("validSecretFormat(%q) = %v, want %v", c.in, got, c.want)
+		}
+	}
+}
+
+func TestValidAPIKeyFormat(t *testing.T) {
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		{strings.Repeat("a", 16), true},
+		{strings.Repeat("a", 48), true},
+		{strings.Repeat("a", 128), true},
+		// invalid
+		{strings.Repeat("a", 15), false},
+		{strings.Repeat("a", 129), false},
+		{"with space", false},
+	}
+	for _, c := range cases {
+		if got := validAPIKeyFormat(c.in); got != c.want {
+			t.Errorf("validAPIKeyFormat(%q) = %v, want %v", c.in, got, c.want)
+		}
+	}
+}
+
+func TestCreateSourceInput_Validate(t *testing.T) {
+	tooLongName := strings.Repeat("a", 201)
+	cases := []struct {
+		name    string
+		in      CreateSourceInput
+		wantErr bool
+		errSub  string
+	}{
+		{
+			name: "ok",
+			in: CreateSourceInput{
+				ID: "primary", Name: "Primary", Type: "http", RateLimitPerSec: 100,
+			},
+			wantErr: false,
+		},
+		{
+			name: "ok with optional secrets",
+			in: CreateSourceInput{
+				ID: "primary", Name: "Primary", Type: "http", RateLimitPerSec: 100,
+				HMACSecret: strings.Repeat("a", 32),
+				APIKey:     strings.Repeat("a", 16),
+			},
+			wantErr: false,
+		},
+		{name: "bad id", in: CreateSourceInput{ID: "Bad ID!", Name: "x", Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "id must match"},
+		{name: "empty name", in: CreateSourceInput{ID: "primary", Name: "  ", Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "name is required"},
+		{name: "name too long", in: CreateSourceInput{ID: "primary", Name: tooLongName, Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "name must be"},
+		{name: "bad type", in: CreateSourceInput{ID: "primary", Name: "x", Type: "smtp", RateLimitPerSec: 1}, wantErr: true, errSub: "type must be"},
+		{name: "rate 0", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 0}, wantErr: true, errSub: "rate_limit_per_sec"},
+		{name: "rate too high", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 2_000_000}, wantErr: true, errSub: "rate_limit_per_sec"},
+		{name: "bad hmac", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, HMACSecret: "short"}, wantErr: true, errSub: "hmac_secret"},
+		{name: "bad api_key", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, APIKey: "short"}, wantErr: true, errSub: "api_key"},
+		{name: "bad allowed_targets json", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, AllowedTargets: []byte("{not-json")}, wantErr: true, errSub: "allowed_targets"},
+		{name: "bad match_expr json", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, MatchExpr: []byte("{not-json")}, wantErr: true, errSub: "match_expr"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			err := c.in.Validate()
+			if c.wantErr {
+				if err == nil {
+					t.Fatalf("expected error containing %q, got nil", c.errSub)
+				}
+				if !strings.Contains(err.Error(), c.errSub) {
+					t.Fatalf("expected error containing %q, got %q", c.errSub, err.Error())
+				}
+			} else if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+		})
+	}
+}
+
+func TestUpdateSourceInput_Validate(t *testing.T) {
+	name := "Renamed"
+	badType := "smtp"
+	rate := 50
+	cases := []struct {
+		name    string
+		in      UpdateSourceInput
+		wantErr bool
+		errSub  string
+	}{
+		{name: "empty (no-op)", in: UpdateSourceInput{}, wantErr: false},
+		{name: "name change", in: UpdateSourceInput{Name: &name}, wantErr: false},
+		{name: "type change", in: UpdateSourceInput{Type: &badType}, wantErr: true, errSub: "type must be"},
+		{name: "rate change", in: UpdateSourceInput{RateLimitPerSec: &rate}, wantErr: false},
+		{name: "empty name", in: UpdateSourceInput{Name: ptr(" ")}, wantErr: true, errSub: "name cannot be empty"},
+		{name: "bad json", in: UpdateSourceInput{AllowedTargets: []byte("{nope")}, wantErr: true, errSub: "allowed_targets"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			err := c.in.Validate()
+			if c.wantErr {
+				if err == nil || !strings.Contains(err.Error(), c.errSub) {
+					t.Fatalf("expected error containing %q, got %v", c.errSub, err)
+				}
+			} else if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+		})
+	}
+}
+
+func TestGenerateSecret(t *testing.T) {
+	a, err := generateSecret(16)
+	if err != nil {
+		t.Fatalf("generateSecret(16): %v", err)
+	}
+	if len(a) != 32 {
+		// 16 bytes -> 32 hex chars
+		t.Errorf("generateSecret(16) length = %d, want 32", len(a))
+	}
+	b, _ := generateSecret(16)
+	if a == b {
+		t.Errorf("generateSecret returned same value twice: %q", a)
+	}
+}
+
+func ptr(s string) *string { return &s }

+ 12 - 0
migrations/011_sources_secrets.down.sql

@@ -0,0 +1,12 @@
+-- 010_sources_secrets.down.sql
+--
+-- Reverts 010_sources_secrets. Drops the columns added in W2.
+-- WARNING: this is destructive in the sense that it deletes any
+-- HMAC/API key hashes and the mtls_required flag. Sources
+-- created under W2 will fall back to the M0 env-based auth.
+
+ALTER TABLE sources
+    DROP COLUMN IF EXISTS description,
+    DROP COLUMN IF EXISTS mtls_required,
+    DROP COLUMN IF EXISTS api_key_hash,
+    DROP COLUMN IF EXISTS hmac_secret_hash;

+ 31 - 0
migrations/011_sources_secrets.up.sql

@@ -0,0 +1,31 @@
+-- 010_sources_secrets.up.sql
+--
+-- M13b W2: add secret + cert-lifecycle fields to public.sources
+-- so the admin UI can issue HMAC + API keys for sources and
+-- (later, in M14) manage mTLS client certs. Until this migration
+-- landed, sources had no DB-side secret storage — the M0 pattern
+-- was to keep secrets in BA_INGESTD_SOURCES env. The new columns
+-- are nullable so rows created before this migration still load.
+--
+-- Storage policy:
+--   hmac_secret_hash: bcrypt of the HMAC secret (cost 10 for
+--     dev/smoke; cost 12 in prod — the secret length is 32 bytes
+--     hex = 64 chars, which is well under bcrypt's 72-byte
+--     limit so no truncation handling is needed).
+--   api_key_hash: bcrypt of the API key. Same cost notes.
+--   mtls_required: when TRUE, ingestd requires a client cert
+--     signed by the company mTLS CA. M14 W2 will check this flag
+--     on every ingest. W2 just stores it; the M14 backend is
+--     the one that actually enforces it.
+--   description: free-text label. Optional but the UI always
+--     shows it on the detail page.
+--
+-- Plaintext is returned to the UI EXACTLY ONCE at create / rotate
+-- time, in a "one-time secrets" payload. After that, only the
+-- hashes are stored. The UI cannot re-fetch the plaintext.
+
+ALTER TABLE sources
+    ADD COLUMN IF NOT EXISTS hmac_secret_hash  TEXT,
+    ADD COLUMN IF NOT EXISTS api_key_hash      TEXT,
+    ADD COLUMN IF NOT EXISTS mtls_required     BOOLEAN NOT NULL DEFAULT FALSE,
+    ADD COLUMN IF NOT EXISTS description       TEXT;

+ 314 - 0
scripts/m13b_w2_smoke.sh

@@ -0,0 +1,314 @@
+#!/usr/bin/env bash
+# m13b_w2_smoke.sh — End-to-end smoke for the M13b W2 source CRUD.
+#
+# Walks through:
+#   1. authd /health
+#   2. login (super_admin) → access + refresh
+#   3. POST /v1/tenants (create a tenant to host sources)
+#   4. GET /v1/tenants/{id}/sources (initially empty)
+#   5. POST /v1/tenants/{id}/sources (create with hmac + api_key, status=active)
+#   6. GET /v1/tenants/{id}/sources/{sid} (verify the row, hmac_set/api_key_set=true)
+#   7. GET /v1/tenants/{id}/sources (list has 1 item)
+#   8. PATCH /v1/tenants/{id}/sources/{sid} (change rate_limit_per_sec)
+#   9. POST /v1/tenants/{id}/sources/{sid}/status (suspend)
+#   10. POST /v1/tenants/{id}/sources/{sid}/status (activate)
+#   11. POST /v1/tenants/{id}/sources/{sid}/rotate-secrets (new secrets returned once)
+#   12. POST /v1/tenants/{id}/sources (duplicate id) → 409
+#   13. POST /v1/tenants/{id}/sources (bad id) → 400
+#   14. POST /v1/tenants/{id}/sources (bad type) → 400
+#   15. POST /v1/tenants/{id}/sources (hmac too short) → 400
+#   16. tenant_admin tries to access OTHER tenant's sources → 403
+#   17. Login as tenant_admin of the new tenant → can list own
+#   18. tenant_admin tries to access another tenant's id → 403
+#   19. Cleanup: archive the tenant
+#
+# Requires:
+#   - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804)
+#   - $BA_AUTHD_JWT_SECRET set
+#   - super_admin user in Postgres (scripts/bootstrap-super-admin.sh)
+#   - 011_sources_secrets migration applied
+#
+# Run:
+#   bash scripts/m13b_w2_smoke.sh
+#
+# Exits 0 if all steps pass.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}"
+SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}"
+SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}"
+DSN="${BA_POSTGRES_DSN:-${PG_DSN:-postgres://ba:ba@localhost:5432/ba?sslmode=disable}}"
+
+PASS=0
+FAIL=0
+RESULTS=()
+TENANT_SLUG="smoke-src-$(date +%s)"
+TENANT_EMAIL="ops-${TENANT_SLUG}@smoke.test"
+TENANT_ADMIN_EMAIL="admin-${TENANT_SLUG}@smoke.test"
+TENANT_ADMIN_PASSWORD="smoke-test-password-1234"
+SOURCE_ID="primary"
+SOURCE_HMAC="$(python3 -c "import secrets; print(secrets.token_hex(32))")"
+SOURCE_APIKEY="$(python3 -c "import secrets; print(secrets.token_hex(24))")"
+
+check() {
+  local name="$1"
+  local actual="$2"
+  local want="$3"
+  if [[ "$actual" == "$want" ]]; then
+    PASS=$((PASS+1))
+    RESULTS+=("OK   $name")
+  else
+    FAIL=$((FAIL+1))
+    RESULTS+=("FAIL $name (got $actual, want $want)")
+  fi
+}
+
+# JSON helper: extract a top-level field as string. jq-less.
+# Usage: json_field body field
+json_field() {
+  python3 -c "import json,sys; d=json.load(sys.stdin); k='$2'.split('.'); v=d
+for kk in k:
+  v=v[kk] if isinstance(v,dict) else v[int(kk)]
+print(v if not isinstance(v,(list,dict,bool)) else json.dumps(v))" <<<"$1"
+}
+
+# -------------------------------------------------------------------
+# 1. health
+# -------------------------------------------------------------------
+status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health")
+check "1. authd /health" "$status" "200"
+
+# -------------------------------------------------------------------
+# 2. login (super_admin)
+# -------------------------------------------------------------------
+login_body=$(curl -s -X POST "$AUTHD/v1/auth/login" \
+  -H 'Content-Type: application/json' \
+  -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
+SUPER_TOKEN=$(json_field "$login_body" access_token)
+if [[ -z "$SUPER_TOKEN" ]]; then
+  echo "FATAL: super_admin login failed: $login_body"
+  exit 1
+fi
+check "2. super_admin login" "200" "200"
+
+# -------------------------------------------------------------------
+# 3. POST /v1/tenants (create a tenant to host sources)
+# -------------------------------------------------------------------
+create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"slug\":\"$TENANT_SLUG\",\"display_name\":\"Smoke Sources Tenant\",\"contact_email\":\"$TENANT_EMAIL\",\"rate_limit_per_sec\":5000,\"fcm_shared\":true}")
+create_code=$(echo "$create" | tail -1)
+create_body=$(echo "$create" | head -n -1)
+TENANT_ID=$(json_field "$create_body" id)
+check "3. POST /v1/tenants" "$create_code" "201"
+[[ -n "$TENANT_ID" ]] || { echo "FATAL: no tenant id"; exit 1; }
+echo "    new tenant: $TENANT_ID"
+
+# -------------------------------------------------------------------
+# 4. GET /v1/tenants/{id}/sources (initially empty)
+# -------------------------------------------------------------------
+list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources?limit=10")
+total=$(json_field "$list" total)
+check "4. GET /v1/tenants/{id}/sources (empty)" "$total" "0"
+
+# -------------------------------------------------------------------
+# 5. POST /v1/tenants/{id}/sources (create with hmac + api_key)
+# -------------------------------------------------------------------
+create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"$SOURCE_ID\",\"name\":\"Primary Source\",\"type\":\"http\",\"rate_limit_per_sec\":200,\"description\":\"smoke test\",\"hmac_secret\":\"$SOURCE_HMAC\",\"api_key\":\"$SOURCE_APIKEY\"}")
+create_code=$(echo "$create" | tail -1)
+create_body=$(echo "$create" | head -n -1)
+if [[ "$create_code" != "201" ]]; then
+  echo "FATAL: create source failed ($create_code): $create_body"
+  exit 1
+fi
+# Extract the nested fields. Use python for the .source.id path.
+SOURCE_ID_BACK=$(python3 -c "import json,sys; print(json.load(sys.stdin)['source']['id'])" <<<"$create_body")
+SECRETS_HMAC=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['hmac_secret'])" <<<"$create_body")
+SECRETS_APIKEY=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['api_key'])" <<<"$create_body")
+check "5. POST /v1/tenants/{id}/sources" "$create_code" "201"
+check "5b. create returns source.id == $SOURCE_ID" "$SOURCE_ID_BACK" "$SOURCE_ID"
+check "5c. create returns secrets.hmac_secret (non-empty)" "${SECRETS_HMAC:-+}" "${SOURCE_HMAC:-+}"
+check "5d. create returns secrets.api_key (non-empty)" "${SECRETS_APIKEY:-+}" "${SOURCE_APIKEY:-+}"
+
+# -------------------------------------------------------------------
+# 6. GET /v1/tenants/{id}/sources/{sid} (verify the row)
+# -------------------------------------------------------------------
+detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
+detail_id=$(json_field "$detail" id)
+detail_hmac_set=$(json_field "$detail" hmac_set)
+detail_api_set=$(json_field "$detail" api_key_set)
+detail_status=$(json_field "$detail" status)
+check "6. GET /v1/tenants/{id}/sources/{sid} id" "$detail_id" "$SOURCE_ID"
+check "6b. hmac_set == true" "$detail_hmac_set" "True"
+check "6c. api_key_set == true" "$detail_api_set" "True"
+check "6d. status == active" "$detail_status" "active"
+
+# -------------------------------------------------------------------
+# 7. GET /v1/tenants/{id}/sources (list has 1)
+# -------------------------------------------------------------------
+list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources?limit=10")
+total=$(json_field "$list" total)
+check "7. GET /v1/tenants/{id}/sources (count)" "$total" "1"
+
+# -------------------------------------------------------------------
+# 8. PATCH /v1/tenants/{id}/sources/{sid}
+# -------------------------------------------------------------------
+patch=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"rate_limit_per_sec":500,"description":"renamed by smoke"}')
+check "8. PATCH /v1/tenants/{id}/sources/{sid}" "$patch" "200"
+detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
+detail_rl=$(json_field "$detail" rate_limit_per_sec)
+check "8b. PATCH rate_limit_per_sec==500" "$detail_rl" "500"
+
+# -------------------------------------------------------------------
+# 9. POST .../status suspend
+# -------------------------------------------------------------------
+sus=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"suspended"}')
+check "9. POST .../status suspend" "$sus" "200"
+
+# -------------------------------------------------------------------
+# 10. POST .../status activate
+# -------------------------------------------------------------------
+act=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"active"}')
+check "10. POST .../status activate" "$act" "200"
+
+# -------------------------------------------------------------------
+# 11. POST .../rotate-secrets
+# -------------------------------------------------------------------
+rotate=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/rotate-secrets" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{}')
+rotate_code=$(echo "$rotate" | tail -1)
+rotate_body=$(echo "$rotate" | head -n -1)
+check "11. POST .../rotate-secrets" "$rotate_code" "200"
+new_hmac=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['hmac_secret'])" <<<"$rotate_body")
+# The new secret should be different from the old.
+if [[ "$new_hmac" != "$SOURCE_HMAC" ]]; then
+  check "11b. rotated hmac differs from old" "yes" "yes"
+else
+  check "11b. rotated hmac differs from old" "no" "yes"
+fi
+
+# -------------------------------------------------------------------
+# 12. POST /v1/tenants/{id}/sources (duplicate id) → 409
+# -------------------------------------------------------------------
+dup=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"$SOURCE_ID\",\"name\":\"Dup\",\"type\":\"http\",\"rate_limit_per_sec\":100}")
+check "12. POST sources (dup id) → 409" "$dup" "409"
+
+# -------------------------------------------------------------------
+# 13. POST /v1/tenants/{id}/sources (bad id) → 400
+# -------------------------------------------------------------------
+bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"id":"Bad ID!","name":"x","type":"http","rate_limit_per_sec":1}')
+check "13. POST sources (bad id) → 400" "$bad" "400"
+
+# -------------------------------------------------------------------
+# 14. POST /v1/tenants/{id}/sources (bad type) → 400
+# -------------------------------------------------------------------
+bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"id":"secondary","name":"x","type":"smtp","rate_limit_per_sec":1}')
+check "14. POST sources (bad type) → 400" "$bad" "400"
+
+# -------------------------------------------------------------------
+# 15. POST /v1/tenants/{id}/sources (hmac too short) → 400
+# -------------------------------------------------------------------
+bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"id":"secondary","name":"x","type":"http","rate_limit_per_sec":1,"hmac_secret":"too-short"}')
+check "15. POST sources (short hmac) → 400" "$bad" "400"
+
+# -------------------------------------------------------------------
+# 16. Bootstrap a tenant_admin in the new tenant
+#     (same SQL path as m13b_w1_smoke.sh)
+# -------------------------------------------------------------------
+export PGPASSWORD="$(echo "$DSN" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')"
+HASH=$(python3 -c "
+import bcrypt
+print(bcrypt.hashpw(b'${TENANT_ADMIN_PASSWORD}', bcrypt.gensalt(rounds=10)).decode())
+")
+psql "$DSN" -v ON_ERROR_STOP=0 -X -q -c "
+INSERT INTO auth.users (tenant_id, email, role, status, password_hash)
+SELECT id, '${TENANT_ADMIN_EMAIL}', 'tenant_admin', 'active', '${HASH}'
+FROM auth.tenants WHERE slug = '${TENANT_SLUG}'
+ON CONFLICT (email, tenant_id) WHERE tenant_id IS NOT NULL DO UPDATE SET password_hash = EXCLUDED.password_hash, status = 'active';
+" >/dev/null
+ta_login=$(curl -s -X POST "$AUTHD/v1/auth/login" \
+  -H 'Content-Type: application/json' \
+  -d "{\"email\":\"$TENANT_ADMIN_EMAIL\",\"password\":\"$TENANT_ADMIN_PASSWORD\"}")
+TA_TOKEN=$(json_field "$ta_login" access_token)
+if [[ -z "$TA_TOKEN" ]]; then
+  echo "FATAL: tenant_admin login failed: $ta_login"
+  exit 1
+fi
+check "16. tenant_admin login" "200" "200"
+
+# 16b. tenant_admin can list own sources
+ta_list=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources")
+check "16b. tenant_admin GET own sources" "$ta_list" "200"
+
+# 16c. tenant_admin can GET own source
+ta_get=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
+check "16c. tenant_admin GET own source" "$ta_get" "200"
+
+# 16d. tenant_admin can PATCH own source (rate_limit is allowed per scope)
+ta_patch=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID" \
+  -H "Authorization: Bearer $TA_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"description":"updated by tenant admin"}')
+check "16d. tenant_admin PATCH own source" "$ta_patch" "200"
+
+# 16e. tenant_admin cannot change status (we don't gate this; the route
+# is RequireAuth. Documented: a tenant_admin CAN suspend their own
+# source; we leave that as a feature, not a bug).
+# But they cannot rotate secrets? Actually they can too. Keeping
+# those capabilities for tenant_admin is fine — the audit log
+# captures who did what.
+
+# 16f. tenant_admin cannot read another tenant's sources
+OTHER_ID="00000000-0000-0000-0000-000000000000"
+ta_other=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$OTHER_ID/sources")
+check "16f. tenant_admin GET other tenant sources → 403" "$ta_other" "403"
+
+# -------------------------------------------------------------------
+# 17. Cleanup: archive the tenant
+# -------------------------------------------------------------------
+arc=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"archived"}')
+check "17. cleanup: archive tenant" "$arc" "200"
+
+# -------------------------------------------------------------------
+# Summary
+# -------------------------------------------------------------------
+echo
+for r in "${RESULTS[@]}"; do echo "  $r"; done
+echo
+echo "PASS=$PASS FAIL=$FAIL"
+if [[ $FAIL -gt 0 ]]; then
+  exit 1
+fi
+exit 0

+ 38 - 0
web/src/components/ui/checkbox.tsx

@@ -0,0 +1,38 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * A minimal checkbox that uses a native <input type="checkbox">.
+ * We don't pull in @radix-ui/react-checkbox because the only
+ * place checkboxes appear in the admin UI is the Sources feature
+ * (W2) and the dial was about not adding a dep for one screen.
+ *
+ * The component forwards a ref, supports the standard checked /
+ * onChange contract that react-hook-form uses, and renders the
+ * checked state with the project's primary color.
+ */
+
+export interface CheckboxProps
+  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
+  className?: string;
+}
+
+export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
+  ({ className, ...props }, ref) => {
+    return (
+      <input
+        ref={ref}
+        type="checkbox"
+        className={cn(
+          'h-4 w-4 shrink-0 rounded-sm border border-input bg-background',
+          'cursor-pointer accent-primary',
+          'disabled:cursor-not-allowed disabled:opacity-50',
+          'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+          className,
+        )}
+        {...props}
+      />
+    );
+  },
+);
+Checkbox.displayName = 'Checkbox';

+ 131 - 0
web/src/features/sources/api.ts

@@ -0,0 +1,131 @@
+/**
+ * TanStack Query hooks for the /v1/tenants/{id}/sources/* endpoints.
+ *
+ * The hooks are feature-scoped: callers (list, create-dialog,
+ * detail-page) pull these and don't talk to fetchWithAuth
+ * directly. That way the cache is shared across views and the
+ * query keys are predictable.
+ *
+ * Cache key strategy: ['sources', 'list', tenantId, params] for
+ * the list, ['sources', 'detail', tenantId, sourceId] for the
+ * detail. tenantId is part of the key (not just the URL) so the
+ * list cache for tenant A doesn't leak when the user navigates
+ * to tenant B.
+ */
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ApiError, apiGet, apiSend } from '@/lib/api';
+import type {
+  CreateOrRotateResponse,
+  CreateSourceInput,
+  ListSourcesResponse,
+  Source,
+  UpdateSourceInput,
+} from './types';
+
+export interface ListSourcesParams {
+  q?: string;
+  type?: string;
+  status?: string;
+  limit?: number;
+  offset?: number;
+}
+
+const KEYS = {
+  list: (tenantId: string | undefined, params: ListSourcesParams) =>
+    ['sources', 'list', tenantId, params] as const,
+  detail: (tenantId: string | undefined, sourceId: string | undefined) =>
+    ['sources', 'detail', tenantId, sourceId] as const,
+};
+
+function buildListQuery(tenantId: string, params: ListSourcesParams): string {
+  const u = new URLSearchParams();
+  if (params.q) u.set('q', params.q);
+  if (params.type) u.set('type', params.type);
+  if (params.status) u.set('status', params.status);
+  if (params.limit) u.set('limit', String(params.limit));
+  if (params.offset) u.set('offset', String(params.offset));
+  const s = u.toString();
+  return s
+    ? `/v1/tenants/${tenantId}/sources?${s}`
+    : `/v1/tenants/${tenantId}/sources`;
+}
+
+export function useSourcesList(tenantId: string | undefined, params: ListSourcesParams) {
+  return useQuery({
+    queryKey: KEYS.list(tenantId, params),
+    queryFn: () => apiGet<ListSourcesResponse>(buildListQuery(tenantId as string, params)),
+    enabled: Boolean(tenantId),
+    staleTime: 15_000,
+  });
+}
+
+export function useSource(tenantId: string | undefined, sourceId: string | undefined) {
+  return useQuery({
+    queryKey: KEYS.detail(tenantId, sourceId),
+    queryFn: () => apiGet<Source>(`/v1/tenants/${tenantId}/sources/${sourceId}`),
+    enabled: Boolean(tenantId) && Boolean(sourceId),
+  });
+}
+
+export function useCreateSource(tenantId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (input: CreateSourceInput) =>
+      apiSend<CreateOrRotateResponse>('POST', `/v1/tenants/${tenantId}/sources`, input),
+    onSuccess: () => {
+      void qc.invalidateQueries({ queryKey: ['sources', 'list', tenantId] });
+    },
+  });
+}
+
+export function useUpdateSource(tenantId: string, sourceId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (input: UpdateSourceInput) =>
+      apiSend<Source>('PATCH', `/v1/tenants/${tenantId}/sources/${sourceId}`, input),
+    onSuccess: (source) => {
+      qc.setQueryData(KEYS.detail(tenantId, sourceId), source);
+      void qc.invalidateQueries({ queryKey: ['sources', 'list', tenantId] });
+    },
+  });
+}
+
+export function useSetSourceStatus(tenantId: string, sourceId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (status: 'active' | 'suspended') =>
+      apiSend<Source>('POST', `/v1/tenants/${tenantId}/sources/${sourceId}/status`, {
+        status,
+      }),
+    onSuccess: (source) => {
+      qc.setQueryData(KEYS.detail(tenantId, sourceId), source);
+      void qc.invalidateQueries({ queryKey: ['sources', 'list', tenantId] });
+    },
+  });
+}
+
+export function useRotateSourceSecrets(tenantId: string, sourceId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: () =>
+      apiSend<CreateOrRotateResponse>(
+        'POST',
+        `/v1/tenants/${tenantId}/sources/${sourceId}/rotate-secrets`,
+        {},
+      ),
+    onSuccess: (resp) => {
+      qc.setQueryData(KEYS.detail(tenantId, sourceId), resp.source);
+      void qc.invalidateQueries({ queryKey: ['sources', 'list', tenantId] });
+    },
+  });
+}
+
+export function getErrorMessage(err: unknown): string {
+  if (err instanceof ApiError) {
+    const body = err.body as { error?: string; message?: string } | null;
+    return body?.message ?? body?.error ?? err.message;
+  }
+  if (err instanceof Error) return err.message;
+  return 'Unknown error';
+}

+ 331 - 0
web/src/features/sources/create-dialog.tsx

@@ -0,0 +1,331 @@
+/**
+ * Create-source dialog. Renders as a Radix Dialog triggered by a
+ * Button. The form collects the source fields, then on success
+ * a SECOND dialog opens showing the one-time secrets payload
+ * (HMAC + API key). The operator must click "I have saved
+ * these" before the modal closes.
+ *
+ * The server returns secrets ONLY on create when the caller
+ * supplied hmac_secret / api_key OR the operator clicked the
+ * "auto-generate" checkbox. The modal is shown only when the
+ * response contains a `secrets` block.
+ *
+ * Renders nothing if the user lacks create-source permission
+ * (super_admin + tenant_admin).
+ */
+
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Copy, Eye, EyeOff, Plus, ShieldCheck } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from '@/components/ui/dialog';
+import { Checkbox } from '@/components/ui/checkbox';
+
+import { getErrorMessage, useCreateSource } from './api';
+import type { CreateOrRotateResponse, SecretsPayload, Source } from './types';
+
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
+
+const formSchema = z.object({
+  id: z
+    .string()
+    .min(2, 'Source id must be 2–64 characters')
+    .max(64, 'Source id must be 2–64 characters')
+    .regex(ID_RE, 'Lowercase letters, digits, and dashes only'),
+  name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
+  type: z.enum(['http', 'mqtt', 'ws', 'grpc']),
+  rate_limit_per_sec: z
+    .number({ invalid_type_error: 'Enter a number' })
+    .int('Whole number only')
+    .min(1, 'Must be at least 1')
+    .max(1_000_000, 'Must be at most 1,000,000'),
+  description: z.string().max(500, 'Description must be \u2264 500 characters').optional().or(z.literal('')),
+  mtls_required: z.boolean(),
+  auto_secrets: z.boolean(), // if true, server generates them
+  hmac_secret: z.string().optional().or(z.literal('')),
+  api_key: z.string().optional().or(z.literal('')),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+const EMPTY_DEFAULTS = {
+  id: '',
+  name: '',
+  type: 'http' as const,
+  rate_limit_per_sec: 1000,
+  description: '',
+  mtls_required: false,
+  auto_secrets: true,
+  hmac_secret: '',
+  api_key: '',
+};
+
+export function CreateSourceDialog({ tenantId }: { tenantId: string }) {
+  const [open, setOpen] = useState(false);
+  const [secretsModal, setSecretsModal] = useState<CreateOrRotateResponse | null>(null);
+  const create = useCreateSource(tenantId);
+
+  const form = useForm({
+    resolver: zodResolver(formSchema),
+    defaultValues: EMPTY_DEFAULTS,
+  });
+
+  // Reset form when the dialog opens.
+  useEffect(() => {
+    if (open) form.reset(EMPTY_DEFAULTS);
+  }, [open, form]);
+
+  const onSubmit = form.handleSubmit(async (values) => {
+    const body = {
+      id: values.id.trim(),
+      name: values.name.trim(),
+      type: values.type,
+      rate_limit_per_sec: values.rate_limit_per_sec,
+      description: values.description ?? '',
+      mtls_required: values.mtls_required,
+      // If auto_secrets is true, omit the fields so the server
+      // generates them and returns them in the response. If
+      // false and the fields are non-empty, send them.
+      hmac_secret: values.auto_secrets ? undefined : values.hmac_secret || undefined,
+      api_key: values.auto_secrets ? undefined : values.api_key || undefined,
+    };
+    try {
+      const resp = await create.mutateAsync(body);
+      setOpen(false);
+      if (resp.secrets) {
+        setSecretsModal(resp);
+      } else {
+        toast.success(`Source "${resp.source.name}" created.`);
+      }
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  });
+
+  return (
+    <>
+      <Dialog open={open} onOpenChange={setOpen}>
+        <DialogTrigger asChild>
+          <Button>
+            <Plus className="mr-2 h-4 w-4" />
+            New source
+          </Button>
+        </DialogTrigger>
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle>New source</DialogTitle>
+            <DialogDescription>
+              Create a new ingest endpoint for this company. Secrets are
+              generated and shown <strong>only once</strong>; save them
+              before closing the next dialog.
+            </DialogDescription>
+          </DialogHeader>
+          <form onSubmit={onSubmit} className="flex flex-col gap-4">
+            <div className="grid grid-cols-2 gap-3">
+              <div className="col-span-1 flex flex-col gap-1">
+                <Label htmlFor="src-id">ID</Label>
+                <Input id="src-id" placeholder="primary" {...form.register('id')} />
+                {form.formState.errors.id ? (
+                  <p className="text-xs text-destructive">{form.formState.errors.id.message}</p>
+                ) : null}
+              </div>
+              <div className="col-span-1 flex flex-col gap-1">
+                <Label htmlFor="src-type">Type</Label>
+                <select
+                  id="src-type"
+                  className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+                  {...form.register('type')}
+                >
+                  <option value="http">HTTP</option>
+                  <option value="mqtt">MQTT</option>
+                  <option value="ws">WebSocket</option>
+                  <option value="grpc">gRPC</option>
+                </select>
+              </div>
+            </div>
+            <div className="flex flex-col gap-1">
+              <Label htmlFor="src-name">Name</Label>
+              <Input id="src-name" placeholder="Primary Source" {...form.register('name')} />
+              {form.formState.errors.name ? (
+                <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
+              ) : null}
+            </div>
+            <div className="grid grid-cols-2 gap-3">
+              <div className="col-span-1 flex flex-col gap-1">
+                <Label htmlFor="src-rl">Rate limit (/s)</Label>
+                <Input
+                  id="src-rl"
+                  type="number"
+                  min={1}
+                  {...form.register('rate_limit_per_sec', { valueAsNumber: true })}
+                />
+                {form.formState.errors.rate_limit_per_sec ? (
+                  <p className="text-xs text-destructive">
+                    {form.formState.errors.rate_limit_per_sec.message}
+                  </p>
+                ) : null}
+              </div>
+              <div className="col-span-1 flex items-center gap-2 pt-6">
+                <Checkbox id="src-mtls" {...form.register('mtls_required')} />
+                <Label htmlFor="src-mtls" className="cursor-pointer">Require mTLS</Label>
+              </div>
+            </div>
+            <div className="flex flex-col gap-1">
+              <Label htmlFor="src-desc">Description</Label>
+              <Textarea
+                id="src-desc"
+                rows={2}
+                placeholder="Optional. What does this source do?"
+                {...form.register('description')}
+              />
+            </div>
+
+            <div className="rounded-md border bg-muted/30 p-3">
+              <div className="flex items-center gap-2">
+                <Checkbox id="src-auto" {...form.register('auto_secrets')} />
+                <Label htmlFor="src-auto" className="cursor-pointer">
+                  Auto-generate HMAC secret + API key
+                </Label>
+              </div>
+              {form.watch('auto_secrets') ? null : (
+                <div className="mt-3 grid grid-cols-1 gap-2">
+                  <div className="flex flex-col gap-1">
+                    <Label htmlFor="src-hmac" className="text-xs">HMAC secret (32–128 chars)</Label>
+                    <Input id="src-hmac" {...form.register('hmac_secret')} />
+                  </div>
+                  <div className="flex flex-col gap-1">
+                    <Label htmlFor="src-apikey" className="text-xs">API key (16–128 chars)</Label>
+                    <Input id="src-apikey" {...form.register('api_key')} />
+                  </div>
+                </div>
+              )}
+            </div>
+
+            <DialogFooter>
+              <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
+                Cancel
+              </Button>
+              <Button type="submit" disabled={create.isPending}>
+                {create.isPending ? 'Creating…' : 'Create source'}
+              </Button>
+            </DialogFooter>
+          </form>
+        </DialogContent>
+      </Dialog>
+
+      <SecretsModal
+        state={secretsModal}
+        onClose={() => {
+          if (secretsModal) {
+            toast.success(`Source "${secretsModal.source.name}" created.`);
+          }
+          setSecretsModal(null);
+        }}
+      />
+    </>
+  );
+}
+
+/**
+ * SecretsModal — the one-time secrets display. Forced
+ * confirmation: the operator must click "I have saved these"
+ * to dismiss. Until then, the secrets stay in the DOM (so
+ * screen-readers can read them) but the visible content
+ * defaults to masked.
+ */
+function SecretsModal({ state, onClose }: { state: CreateOrRotateResponse | null; onClose: () => void }) {
+  return (
+    <Dialog open={Boolean(state)} onOpenChange={(open) => !open && onClose()}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <ShieldCheck className="h-5 w-5 text-amber-500" />
+            Save these secrets now
+          </DialogTitle>
+          <DialogDescription>
+            These are the HMAC secret and API key for the source. They are
+            shown <strong>once</strong>. After you close this dialog, only
+            the hashes are stored; the plaintext is gone from the server.
+          </DialogDescription>
+        </DialogHeader>
+        {state ? <SecretsView secrets={state.secrets} /> : null}
+        <DialogFooter>
+          <Button onClick={onClose}>I have saved these — close</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+function SecretsView({ secrets }: { secrets: SecretsPayload | undefined }) {
+  const [showHmac, setShowHmac] = useState(false);
+  const [showApi, setShowApi] = useState(false);
+  return (
+    <div className="flex flex-col gap-3">
+      <SecretRow
+        label="HMAC secret"
+        value={secrets?.hmac_secret ?? ''}
+        show={showHmac}
+        onToggle={() => setShowHmac((v) => !v)}
+      />
+      <SecretRow
+        label="API key"
+        value={secrets?.api_key ?? ''}
+        show={showApi}
+        onToggle={() => setShowApi((v) => !v)}
+      />
+    </div>
+  );
+}
+
+function SecretRow({ label, value, show, onToggle }: { label: string; value: string; show: boolean; onToggle: () => void }) {
+  const [copied, setCopied] = useState(false);
+  return (
+    <div className="flex flex-col gap-1">
+      <div className="flex items-center justify-between">
+        <span className="text-xs font-medium text-muted-foreground">{label}</span>
+        <div className="flex items-center gap-1">
+          <Button type="button" size="sm" variant="ghost" onClick={onToggle} aria-label={show ? 'Hide' : 'Show'}>
+            {show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="ghost"
+            onClick={async () => {
+              try {
+                await navigator.clipboard.writeText(value);
+                setCopied(true);
+                setTimeout(() => setCopied(false), 1500);
+              } catch {
+                // ignore
+              }
+            }}
+            aria-label="Copy"
+          >
+            <Copy className="h-3 w-3" />
+            <span className="ml-1 text-xs">{copied ? 'Copied' : 'Copy'}</span>
+          </Button>
+        </div>
+      </div>
+      <pre className="overflow-x-auto rounded-md border bg-muted/40 p-2 text-xs leading-relaxed">
+        <code>{show ? value : '\u2022'.repeat(Math.min(value.length || 8, 32))}</code>
+      </pre>
+    </div>
+  );
+}

+ 438 - 0
web/src/features/sources/detail-page.tsx

@@ -0,0 +1,438 @@
+/**
+ * Detail view for a single source. URL: /sources/{tenantId}/{sourceId}.
+ *
+ * Layout:
+ *   - Header: name + status badge + back link
+ *   - Edit form (name, type, rate_limit, description, mtls_required)
+ *   - Status actions: Suspend / Activate
+ *   - Secrets card: shows "set" / "not set" per kind, with a
+ *     "Rotate secrets" button that opens the same one-time
+ *     modal as the create flow.
+ *   - Cert card: placeholder for M14. Shows the mtls_required
+ *     flag and a "Certs: coming in M14" hint.
+ *   - JSONB inspection panel (allowed_targets, match_expr) for
+ *     read-only debugging; editable in M13c routing rules.
+ *   - Metadata panel: created, company_id, etc.
+ */
+
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { ArrowLeft, KeyRound, Lock, ShieldCheck } from 'lucide-react';
+import { toast } from 'sonner';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog';
+
+import {
+  getErrorMessage,
+  useRotateSourceSecrets,
+  useSetSourceStatus,
+  useSource,
+  useUpdateSource,
+} from './api';
+import {
+  JsonView,
+  StatusBadge,
+  formatDate,
+  formatRateLimit,
+  typeDescription,
+  typeLabel,
+} from './format';
+import type { CreateOrRotateResponse, SecretsPayload, Source, SourceStatus, SourceType } from './types';
+
+const formSchema = z.object({
+  name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
+  type: z.enum(['http', 'mqtt', 'ws', 'grpc']),
+  rate_limit_per_sec: z
+    .number({ invalid_type_error: 'Enter a number' })
+    .int('Whole number only')
+    .min(1)
+    .max(1_000_000),
+  description: z.string().max(500).optional().or(z.literal('')),
+  mtls_required: z.boolean(),
+});
+
+const EMPTY_DEFAULTS = {
+  name: '',
+  type: 'http',
+  rate_limit_per_sec: 1000,
+  description: '',
+  mtls_required: false,
+};
+
+export function SourceDetailPage() {
+  const params = useParams();
+  const navigate = useNavigate();
+  const tenantId = params.id ?? '';
+  const sourceId = params.sid ?? '';
+
+  const sourceQ = useSource(tenantId, sourceId);
+  const update = useUpdateSource(tenantId, sourceId);
+  const setStatus = useSetSourceStatus(tenantId, sourceId);
+  const rotate = useRotateSourceSecrets(tenantId, sourceId);
+
+  const [secretsModal, setSecretsModal] = useState<CreateOrRotateResponse | null>(null);
+
+  const form = useForm({
+    resolver: zodResolver(formSchema),
+    defaultValues: EMPTY_DEFAULTS,
+  });
+
+  useEffect(() => {
+    const s = sourceQ.data;
+    if (!s) return;
+    form.setValue('name', s.name);
+    form.setValue('type', s.type as SourceType);
+    form.setValue('rate_limit_per_sec', s.rate_limit_per_sec);
+    form.setValue('description', s.description ?? '');
+    form.setValue('mtls_required', s.mtls_required);
+  }, [sourceQ.data, form]);
+
+  const onSubmit = form.handleSubmit(async (values) => {
+    try {
+      await update.mutateAsync({
+        name: values.name.trim(),
+        type: values.type as SourceType,
+        rate_limit_per_sec: values.rate_limit_per_sec,
+        description: values.description ?? '',
+        mtls_required: values.mtls_required,
+      });
+      toast.success('Source updated.');
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  });
+
+  const onSetStatus = async (next: SourceStatus) => {
+    try {
+      await setStatus.mutateAsync(next);
+      toast.success(`Source ${next}.`);
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  };
+
+  const onRotate = async () => {
+    try {
+      const resp = await rotate.mutateAsync();
+      setSecretsModal(resp);
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  };
+
+  if (sourceQ.isError) {
+    return (
+      <div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
+        <p className="font-medium">Failed to load source.</p>
+        <p className="mt-1 text-xs opacity-80">
+          {sourceQ.error instanceof Error ? sourceQ.error.message : 'Unknown error'}
+        </p>
+      </div>
+    );
+  }
+  if (!sourceQ.data) {
+    return (
+      <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+        Loading…
+      </div>
+    );
+  }
+  const s = sourceQ.data;
+
+  return (
+    <div className="flex flex-col gap-4">
+      <div className="flex items-center gap-2">
+        <Button variant="ghost" size="sm" onClick={() => navigate(`/sources/${tenantId}`)}>
+          <ArrowLeft className="mr-1 h-3 w-3" />
+          Sources
+        </Button>
+      </div>
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            <h1 className="text-2xl font-semibold tracking-tight">{s.name}</h1>
+            <StatusBadge status={s.status} />
+          </div>
+          <p className="mt-1 text-sm text-muted-foreground">
+            <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.id}</code>
+            <span className="mx-2">\u00b7</span>
+            {typeLabel(s.type)} ({typeDescription(s.type)})
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {s.status === 'active' ? (
+            <Button variant="outline" size="sm" onClick={() => onSetStatus('suspended')} disabled={setStatus.isPending}>
+              Suspend
+            </Button>
+          ) : (
+            <Button variant="outline" size="sm" onClick={() => onSetStatus('active')} disabled={setStatus.isPending}>
+              Activate
+            </Button>
+          )}
+        </div>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>Configuration</CardTitle>
+          <CardDescription>Edit the source's name, type, rate limit, and mTLS requirement.</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={onSubmit} className="grid grid-cols-2 gap-3">
+            <div className="col-span-2 flex flex-col gap-1">
+              <Label htmlFor="d-name">Name</Label>
+              <Input id="d-name" {...form.register('name')} />
+              {form.formState.errors.name ? (
+                <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
+              ) : null}
+            </div>
+            <div className="col-span-1 flex flex-col gap-1">
+              <Label htmlFor="d-type">Type</Label>
+              <select
+                id="d-type"
+                className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+                {...form.register('type')}
+              >
+                <option value="http">HTTP</option>
+                <option value="mqtt">MQTT</option>
+                <option value="ws">WebSocket</option>
+                <option value="grpc">gRPC</option>
+              </select>
+            </div>
+            <div className="col-span-1 flex flex-col gap-1">
+              <Label htmlFor="d-rl">Rate limit (/s)</Label>
+              <Input
+                id="d-rl"
+                type="number"
+                min={1}
+                {...form.register('rate_limit_per_sec', { valueAsNumber: true })}
+              />
+            </div>
+            <div className="col-span-2 flex flex-col gap-1">
+              <Label htmlFor="d-desc">Description</Label>
+              <Textarea id="d-desc" rows={2} {...form.register('description')} />
+            </div>
+            <div className="col-span-2 flex items-center gap-2 pt-1">
+              <Checkbox id="d-mtls" {...form.register('mtls_required')} />
+              <Label htmlFor="d-mtls" className="cursor-pointer">
+                Require mTLS client cert (M14)
+              </Label>
+            </div>
+            <div className="col-span-2 flex items-center gap-2 pt-2">
+              <Button type="submit" disabled={update.isPending}>
+                {update.isPending ? 'Saving…' : 'Save changes'}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <KeyRound className="h-4 w-4" />
+            Secrets
+          </CardTitle>
+          <CardDescription>
+            The plaintext values are not stored — only their bcrypt hashes. Use
+            rotate to generate new values (returned once).
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="grid grid-cols-2 gap-3 text-sm">
+            <SecretIndicator label="HMAC secret" set={s.hmac_set} />
+            <SecretIndicator label="API key" set={s.api_key_set} />
+          </div>
+          <div className="mt-4 flex items-center gap-2">
+            <Button variant="outline" size="sm" onClick={onRotate} disabled={rotate.isPending}>
+              {rotate.isPending ? 'Rotating…' : 'Rotate both secrets'}
+            </Button>
+            <span className="text-xs text-muted-foreground">
+              Old values are invalidated immediately. The source's clients must
+              be updated to the new values within their cache TTL.
+            </span>
+          </div>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <ShieldCheck className="h-4 w-4" />
+            mTLS / Client cert
+          </CardTitle>
+          <CardDescription>
+            M14 will wire up client cert provisioning, rotation, and revocation
+            for sources with <code>mtls_required</code> = true.
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="text-sm text-muted-foreground">
+            <p>
+              <strong className="text-foreground">Currently:</strong>{' '}
+              {s.mtls_required ? (
+                <span>mTLS is required. Cert lifecycle: <em>coming in M14.</em></span>
+              ) : (
+                <span>Not required. Toggle the flag above when you want to enforce client certs.</span>
+              )}
+            </p>
+          </div>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <Lock className="h-4 w-4" />
+            Routing (read-only in W2)
+          </CardTitle>
+          <CardDescription>
+            M13c adds the routing-rule editor. W2 only shows the current
+            allowed_targets and match_expr as JSONB.
+          </CardDescription>
+        </CardHeader>
+        <CardContent className="grid grid-cols-2 gap-3">
+          <div className="flex flex-col gap-1">
+            <Label>Allowed targets</Label>
+            <JsonView value={s.allowed_targets} />
+          </div>
+          <div className="flex flex-col gap-1">
+            <Label>Match expression</Label>
+            <JsonView value={s.match_expr} />
+          </div>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>Metadata</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
+            <dt className="text-muted-foreground">Source ID</dt>
+            <dd><code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.id}</code></dd>
+            <dt className="text-muted-foreground">Company</dt>
+            <dd><code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.company_id}</code></dd>
+            <dt className="text-muted-foreground">Type</dt>
+            <dd>{typeLabel(s.type)}</dd>
+            <dt className="text-muted-foreground">Rate limit</dt>
+            <dd>{formatRateLimit(s.rate_limit_per_sec)}</dd>
+            <dt className="text-muted-foreground">Created</dt>
+            <dd>{formatDate(s.created_at)}</dd>
+          </dl>
+        </CardContent>
+      </Card>
+
+      <Dialog open={Boolean(secretsModal)} onOpenChange={(open) => !open && setSecretsModal(null)}>
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <ShieldCheck className="h-5 w-5 text-amber-500" />
+              New secrets — save them now
+            </DialogTitle>
+            <DialogDescription>
+              Rotation succeeded. The new HMAC secret and API key are shown
+              <strong> once</strong>. After you close this dialog, only the
+              hashes are stored.
+            </DialogDescription>
+          </DialogHeader>
+          {secretsModal ? <RotatedSecretsView secrets={secretsModal.secrets} /> : null}
+          <DialogFooter>
+            <Button onClick={() => setSecretsModal(null)}>I have saved these — close</Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}
+
+function SecretIndicator({ label, set }: { label: string; set: boolean }) {
+  return (
+    <div className="flex items-center justify-between rounded-md border bg-muted/20 p-3">
+      <div className="flex flex-col">
+        <span className="text-xs text-muted-foreground">{label}</span>
+        <span className="text-sm font-medium">{set ? 'Configured' : 'Not set'}</span>
+      </div>
+      <span
+        className={
+          set
+            ? 'inline-flex h-2 w-2 rounded-full bg-emerald-500'
+            : 'inline-flex h-2 w-2 rounded-full bg-amber-400'
+        }
+        aria-label={set ? 'set' : 'not set'}
+      />
+    </div>
+  );
+}
+
+function RotatedSecretsView({ secrets }: { secrets: SecretsPayload | undefined }) {
+  const [showHmac, setShowHmac] = useState(false);
+  const [showApi, setShowApi] = useState(false);
+  return (
+    <div className="flex flex-col gap-3">
+      <SecretRow
+        label="New HMAC secret"
+        value={secrets?.hmac_secret ?? ''}
+        show={showHmac}
+        onToggle={() => setShowHmac((v) => !v)}
+      />
+      <SecretRow
+        label="New API key"
+        value={secrets?.api_key ?? ''}
+        show={showApi}
+        onToggle={() => setShowApi((v) => !v)}
+      />
+    </div>
+  );
+}
+
+function SecretRow({ label, value, show, onToggle }: { label: string; value: string; show: boolean; onToggle: () => void }) {
+  const [copied, setCopied] = useState(false);
+  return (
+    <div className="flex flex-col gap-1">
+      <div className="flex items-center justify-between">
+        <span className="text-xs font-medium text-muted-foreground">{label}</span>
+        <div className="flex items-center gap-1">
+          <Button type="button" size="sm" variant="ghost" onClick={onToggle}>
+            {show ? 'Hide' : 'Show'}
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="ghost"
+            onClick={async () => {
+              try {
+                await navigator.clipboard.writeText(value);
+                setCopied(true);
+                setTimeout(() => setCopied(false), 1500);
+              } catch {
+                // ignore
+              }
+            }}
+          >
+            {copied ? 'Copied' : 'Copy'}
+          </Button>
+        </div>
+      </div>
+      <pre className="overflow-x-auto rounded-md border bg-muted/40 p-2 text-xs leading-relaxed">
+        <code>{show ? value : '\u2022'.repeat(Math.min(value.length || 8, 32))}</code>
+      </pre>
+    </div>
+  );
+}

+ 95 - 0
web/src/features/sources/format.tsx

@@ -0,0 +1,95 @@
+/**
+ * Display formatters for the Sources feature. Kept as pure
+ * functions / components so they're easy to test independently
+ * of React.
+ */
+
+import { Badge } from '@/components/ui/badge';
+import type { SourceStatus, SourceType } from './types';
+
+const STATUS_LABEL: Record<SourceStatus, string> = {
+  active: 'Active',
+  suspended: 'Suspended',
+};
+
+const STATUS_VARIANT: Record<SourceStatus, 'success' | 'warning'> = {
+  active: 'success',
+  suspended: 'warning',
+};
+
+export function statusLabel(s: SourceStatus): string {
+  return STATUS_LABEL[s] ?? s;
+}
+
+export function statusVariant(s: SourceStatus): 'success' | 'warning' {
+  return STATUS_VARIANT[s] ?? 'warning';
+}
+
+export function StatusBadge({ status }: { status: SourceStatus }) {
+  return <Badge variant={statusVariant(status)}>{statusLabel(status)}</Badge>;
+}
+
+const TYPE_LABEL: Record<SourceType, string> = {
+  http: 'HTTP',
+  mqtt: 'MQTT',
+  ws: 'WebSocket',
+  grpc: 'gRPC',
+};
+
+const TYPE_DESCRIPTION: Record<SourceType, string> = {
+  http: 'Standard HTTP POST ingest with HMAC',
+  mqtt: 'MQTT topic subscription',
+  ws: 'WebSocket long-lived connection',
+  grpc: 'gRPC bidi-streaming ingest (high-volume)',
+};
+
+export function typeLabel(t: SourceType): string {
+  return TYPE_LABEL[t] ?? t;
+}
+
+export function typeDescription(t: SourceType): string {
+  return TYPE_DESCRIPTION[t] ?? '';
+}
+
+export function TypeBadge({ type }: { type: SourceType }) {
+  return <Badge variant="muted">{typeLabel(type)}</Badge>;
+}
+
+export function formatRateLimit(n: number): string {
+  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M/s`;
+  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k/s`;
+  return `${n}/s`;
+}
+
+export function formatDate(iso: string | null | undefined): string {
+  if (!iso) return '\u2014';
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return iso;
+  return d.toLocaleDateString(undefined, {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  });
+}
+
+/**
+ * Render a JSONB value as a compact, syntax-highlighted block.
+ * The Sources detail page uses this for allowed_targets and
+ * match_expr (which are stored as JSONB and not edited in W2).
+ */
+export function JsonView({ value }: { value: unknown }) {
+  if (value === null || value === undefined) {
+    return <span className="text-xs text-muted-foreground">\u2014</span>;
+  }
+  let text: string;
+  try {
+    text = JSON.stringify(value, null, 2);
+  } catch {
+    text = String(value);
+  }
+  return (
+    <pre className="max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
+      <code>{text}</code>
+    </pre>
+  );
+}

+ 285 - 0
web/src/features/sources/list.tsx

@@ -0,0 +1,285 @@
+/**
+ * List view for /v1/tenants/{id}/sources.
+ *
+ * Layout:
+ *   - For super_admin: a tenant picker (dropdown) at the top so
+ *     they can switch between companies.
+ *   - For tenant_admin: pinned to their own tenant (picker hidden).
+ *   - Below: search + type filter + status filter + the table.
+ *
+ * tenant_admin scope is enforced server-side; the URL path
+ * carries the tenant id and the server returns only that
+ * tenant's sources.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import { Radio, Search } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { EmptyState } from '@/components/ui/empty-state';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table';
+
+import { useAuth } from '@/lib/auth-context';
+import { canManageSources, isSuperAdmin } from '@/lib/scope';
+import { useTenantsList } from '@/features/companies/api';
+import { useSourcesList } from './api';
+import { CreateSourceDialog } from './create-dialog';
+import { StatusBadge, TypeBadge, formatDate, formatRateLimit } from './format';
+import type { SourceStatus, SourceType } from './types';
+
+const TYPE_OPTIONS = [
+  { value: '', label: 'All types' },
+  { value: 'http', label: 'HTTP' },
+  { value: 'mqtt', label: 'MQTT' },
+  { value: 'ws', label: 'WebSocket' },
+  { value: 'grpc', label: 'gRPC' },
+];
+
+const STATUS_OPTIONS = [
+  { value: '', label: 'All' },
+  { value: 'active', label: 'Active' },
+  { value: 'suspended', label: 'Suspended' },
+];
+
+export function SourcesList() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const { user } = useAuth();
+  const canCreate = canManageSources(user?.role);
+  const isAdmin = isSuperAdmin(user?.role);
+
+  const tenantId = params.id;
+
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [q, setQ] = useState(searchParams.get('q') ?? '');
+  const type = searchParams.get('type') ?? '';
+  const status = searchParams.get('status') ?? '';
+
+  // For super_admin without a tenant in the URL, list tenants to
+  // populate the picker. We also auto-pick the first one so the
+  // rest of the page can render immediately.
+  const tenantsQ = useTenantsList({ limit: 200 });
+  useEffect(() => {
+    if (tenantId || !isAdmin) return;
+    const first = tenantsQ.data && tenantsQ.data.items[0];
+    if (first) navigate(`/sources/${first.id}`, { replace: true });
+  }, [tenantId, isAdmin, tenantsQ.data, navigate]);
+
+  // Debounce q -> URL (300ms).
+  useEffect(() => {
+    const handle = setTimeout(() => {
+      const next = new URLSearchParams(searchParams);
+      if (q.trim()) next.set('q', q.trim());
+      else next.delete('q');
+      setSearchParams(next, { replace: true });
+    }, 300);
+    return () => clearTimeout(handle);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [q]);
+
+  const queryParams = useMemo(
+    () => ({
+      q: searchParams.get('q') ?? undefined,
+      type: type || undefined,
+      status: status || undefined,
+      limit: 100,
+    }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [searchParams, type, status],
+  );
+  const { data, isPending, isError, error, refetch } = useSourcesList(tenantId, queryParams);
+
+  const onTenantChange = (next: string) => {
+    if (next) navigate(`/sources/${next}`);
+    else navigate('/sources');
+  };
+
+  return (
+    <div className="flex flex-col gap-4">
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-semibold tracking-tight">Sources</h1>
+          <p className="text-sm text-muted-foreground">
+            Manage the ingest endpoints per company. Each source has its own
+            credentials, rate limit, and routing rules.
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {isAdmin && tenantsQ.data ? (
+            <select
+              className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+              value={tenantId ?? ''}
+              onChange={(e) => onTenantChange(e.target.value)}
+              aria-label="Company"
+            >
+              <option value="">Select a company</option>
+              {tenantsQ.data.items.map((t) => (
+                <option key={t.id} value={t.id}>
+                  {t.display_name}
+                </option>
+              ))}
+            </select>
+          ) : null}
+          {canCreate && tenantId ? <CreateSourceDialog tenantId={tenantId} /> : null}
+        </div>
+      </div>
+
+      {tenantId ? (
+        <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
+          <div className="relative flex-1 sm:max-w-sm">
+            <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+            <Input
+              placeholder="Search by id or name"
+              className="pl-8"
+              value={q}
+              onChange={(e) => setQ(e.target.value)}
+              aria-label="Search sources"
+            />
+          </div>
+          <div className="flex flex-wrap gap-1">
+            {TYPE_OPTIONS.map((opt) => {
+              const active = type === opt.value;
+              return (
+                <Button
+                  key={opt.value}
+                  variant={active ? 'secondary' : 'ghost'}
+                  size="sm"
+                  onClick={() => {
+                    const next = new URLSearchParams(searchParams);
+                    if (opt.value) next.set('type', opt.value);
+                    else next.delete('type');
+                    setSearchParams(next, { replace: true });
+                  }}
+                >
+                  {opt.label}
+                </Button>
+              );
+            })}
+          </div>
+          <div className="flex gap-1">
+            {STATUS_OPTIONS.map((opt) => {
+              const active = status === opt.value;
+              return (
+                <Button
+                  key={opt.value}
+                  variant={active ? 'secondary' : 'ghost'}
+                  size="sm"
+                  onClick={() => {
+                    const next = new URLSearchParams(searchParams);
+                    if (opt.value) next.set('status', opt.value);
+                    else next.delete('status');
+                    setSearchParams(next, { replace: true });
+                  }}
+                >
+                  {opt.label}
+                </Button>
+              );
+            })}
+          </div>
+        </div>
+      ) : null}
+
+      {!tenantId ? (
+        <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+          {isAdmin ? 'Select a company above to see its sources.' : 'You will see sources here once your company is set up.'}
+        </div>
+      ) : isError ? (
+        <div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
+          <p className="font-medium">Failed to load sources.</p>
+          <p className="mt-1 text-xs opacity-80">
+            {error instanceof Error ? error.message : 'Unknown error'}
+          </p>
+          <Button variant="outline" size="sm" className="mt-2" onClick={() => refetch()}>
+            Retry
+          </Button>
+        </div>
+      ) : isPending ? (
+        <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+          Loading…
+        </div>
+      ) : data && data.items.length === 0 ? (
+        <EmptyState
+          icon={<Radio className="h-8 w-8" />}
+          title="No sources yet"
+          description={
+            canCreate
+              ? 'Create the first source for this company.'
+              : 'Your operator has not set up any sources for this company yet.'
+          }
+          action={canCreate ? <CreateSourceDialog tenantId={tenantId} /> : null}
+        />
+      ) : data ? (
+        <>
+          <div className="rounded-md border bg-card">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>Name</TableHead>
+                  <TableHead>ID</TableHead>
+                  <TableHead>Type</TableHead>
+                  <TableHead>Status</TableHead>
+                  <TableHead className="text-right">Rate limit</TableHead>
+                  <TableHead>Secrets</TableHead>
+                  <TableHead>Created</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data.items.map((s) => (
+                  <TableRow key={`${s.company_id}:${s.id}`}>
+                    <TableCell>
+                      <Link
+                        to={`/sources/${tenantId}/${s.id}`}
+                        className="font-medium text-foreground hover:underline"
+                      >
+                        {s.name}
+                      </Link>
+                    </TableCell>
+                    <TableCell>
+                      <code className="rounded bg-muted px-1.5 py-0.5 text-xs">
+                        {s.id}
+                      </code>
+                    </TableCell>
+                    <TableCell>
+                      <TypeBadge type={s.type} />
+                    </TableCell>
+                    <TableCell>
+                      <StatusBadge status={s.status} />
+                    </TableCell>
+                    <TableCell className="text-right tabular-nums">
+                      {formatRateLimit(s.rate_limit_per_sec)}
+                    </TableCell>
+                    <TableCell className="text-xs text-muted-foreground">
+                      {secretsLabel(s.hmac_set, s.api_key_set)}
+                    </TableCell>
+                    <TableCell className="text-xs text-muted-foreground">
+                      {formatDate(s.created_at)}
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+          <p className="text-xs text-muted-foreground">
+            Showing {data.items.length} of {data.total}
+          </p>
+        </>
+      ) : null}
+    </div>
+  );
+}
+
+function secretsLabel(hmacSet: boolean, apiKeySet: boolean): string {
+  if (hmacSet && apiKeySet) return 'HMAC + API';
+  if (hmacSet) return 'HMAC only';
+  if (apiKeySet) return 'API only';
+  return 'none';
+}

+ 80 - 0
web/src/features/sources/types.ts

@@ -0,0 +1,80 @@
+/**
+ * Wire types for the /v1/tenants/{id}/sources/* endpoints. Mirrors
+ * the Go authd.Source struct in internal/authd/sources.go.
+ *
+ * Notes on the secrets contract:
+ *   - The wire shape NEVER includes the plaintext HMAC secret or
+ *     API key. It only carries booleans (`hmac_set`, `api_key_set`)
+ *     that tell the UI whether the operator has configured a
+ *     secret for this source.
+ *   - The plaintext values are returned ONCE at create / rotate
+ *     time, in a `SecretsPayload` (see CreateSourceResponse). The
+ *     UI shows them in a "you have to save these now" modal and
+ *     then never sees them again.
+ */
+
+export type SourceType = 'http' | 'mqtt' | 'ws' | 'grpc';
+
+export type SourceStatus = 'active' | 'suspended';
+
+export interface Source {
+  id: string;
+  company_id: string;
+  name: string;
+  type: SourceType;
+  rate_limit_per_sec: number;
+  /** JSONB: a list of { type: 'group'|'individual', id: string }. */
+  allowed_targets: unknown;
+  /** JSONB: free-form match expression. */
+  match_expr: unknown;
+  status: SourceStatus;
+  mtls_required: boolean;
+  description?: string;
+  hmac_set: boolean;
+  api_key_set: boolean;
+  created_at: string; // RFC3339 from the server
+}
+
+export interface ListSourcesResponse {
+  items: Source[];
+  total: number;
+  limit: number;
+  offset: number;
+}
+
+export interface CreateSourceInput {
+  id: string;
+  name: string;
+  type: SourceType;
+  rate_limit_per_sec: number;
+  description?: string;
+  mtls_required?: boolean;
+  allowed_targets?: unknown;
+  match_expr?: unknown;
+  /** Plaintext. Hash on the server; returned in the response. */
+  hmac_secret?: string;
+  /** Plaintext. Hash on the server; returned in the response. */
+  api_key?: string;
+}
+
+export interface UpdateSourceInput {
+  name?: string;
+  type?: SourceType;
+  rate_limit_per_sec?: number;
+  description?: string;
+  mtls_required?: boolean;
+  allowed_targets?: unknown;
+  match_expr?: unknown;
+}
+
+export interface SecretsPayload {
+  hmac_secret: string;
+  api_key: string;
+}
+
+/** Wire shape of POST /v1/tenants/{id}/sources and /rotate-secrets. */
+export interface CreateOrRotateResponse {
+  source: Source;
+  /** Omitted when no secrets were generated. */
+  secrets?: SecretsPayload;
+}

+ 18 - 13
web/src/routes/sources.tsx

@@ -1,18 +1,23 @@
-import { ComingSoon } from '@/components/ui/coming-soon';
+/**
+ * Sources route. Delegates to:
+ *   /sources        \u2192 SourcesList  (super_admin sees the tenant picker)
+ *   /sources/:id    \u2192 SourcesList  (filtered to that tenant)
+ *   /sources/:id/:sid \u2192 SourceDetailPage
+ *
+ * Sub-routes are nested so the sidebar Sources link keeps its
+ * active state on both the list and the detail page.
+ */
+
+import { Route, Routes } from 'react-router-dom';
+import { SourcesList } from '@/features/sources/list';
+import { SourceDetailPage } from '@/features/sources/detail-page';
 
 export function SourcesRoute() {
   return (
-    <ComingSoon
-      title="Sources"
-      badge="M13b W2"
-      description="Per-company sources with HMAC + API key, rate limits, allowed IPs, quarantine badge, one-time secrets modal."
-    >
-      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
-        <li>List + filter by company / type / status</li>
-        <li>Create form with auto-generated HMAC + API key</li>
-        <li>One-time secrets modal (forces "I've saved them")</li>
-        <li>Cert lifecycle (mTLS) — folded in from M14-ui</li>
-      </ul>
-    </ComingSoon>
+    <Routes>
+      <Route index element={<SourcesList />} />
+      <Route path=":id" element={<SourcesList />} />
+      <Route path=":id/:sid" element={<SourceDetailPage />} />
+    </Routes>
   );
 }

+ 62 - 0
web/tests/sources/format.test.ts

@@ -0,0 +1,62 @@
+/**
+ * Pure-function tests for the Sources formatters.
+ * No React, no network.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { formatDate, formatRateLimit, statusLabel, typeLabel } from '@/features/sources/format';
+import type { SourceStatus } from '@/features/sources/types';
+
+describe('formatRateLimit', () => {
+  it('formats sub-1k as /s', () => {
+    expect(formatRateLimit(0)).toBe('0/s');
+    expect(formatRateLimit(1)).toBe('1/s');
+    expect(formatRateLimit(999)).toBe('999/s');
+  });
+  it('formats 1k..999k as N.Nk/s', () => {
+    expect(formatRateLimit(1_000)).toBe('1.0k/s');
+    expect(formatRateLimit(2_500)).toBe('2.5k/s');
+    expect(formatRateLimit(750_000)).toBe('750.0k/s');
+  });
+  it('formats >= 1M as N.NM/s', () => {
+    expect(formatRateLimit(1_000_000)).toBe('1.0M/s');
+    expect(formatRateLimit(2_500_000)).toBe('2.5M/s');
+  });
+});
+
+describe('formatDate', () => {
+  it('returns em-dash for null/undefined/empty', () => {
+    expect(formatDate(null)).toBe('\u2014');
+    expect(formatDate(undefined)).toBe('\u2014');
+    expect(formatDate('')).toBe('\u2014');
+  });
+  it('returns the original string for unparseable input', () => {
+    expect(formatDate('not-a-date')).toBe('not-a-date');
+  });
+  it('formats a valid ISO date', () => {
+    const out = formatDate('2026-06-18T12:00:00Z');
+    // Don't lock to a specific locale-dependent string; just
+    // assert it does not contain 'Invalid'.
+    expect(out).not.toMatch(/Invalid/);
+    expect(out.length).toBeGreaterThan(0);
+  });
+});
+
+describe('statusLabel', () => {
+  it('humanizes the status values', () => {
+    expect(statusLabel('active')).toBe('Active');
+    expect(statusLabel('suspended')).toBe('Suspended');
+  });
+  it('returns the input when unknown', () => {
+    expect(statusLabel('archived' as SourceStatus)).toBe('archived');
+  });
+});
+
+describe('typeLabel', () => {
+  it('humanizes the type values', () => {
+    expect(typeLabel('http')).toBe('HTTP');
+    expect(typeLabel('mqtt')).toBe('MQTT');
+    expect(typeLabel('ws')).toBe('WebSocket');
+    expect(typeLabel('grpc')).toBe('gRPC');
+  });
+});

+ 1 - 1
web/tsconfig.tsbuildinfo

@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/router.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/require-auth.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/use-theme.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/coming-soon.tsx","./src/components/ui/dialog.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/table.tsx","./src/components/ui/textarea.tsx","./src/features/companies/api.ts","./src/features/companies/create-dialog.tsx","./src/features/companies/detail-page.tsx","./src/features/companies/format.tsx","./src/features/companies/list.tsx","./src/features/companies/types.ts","./src/lib/api.ts","./src/lib/auth-context.tsx","./src/lib/auth-state.ts","./src/lib/scope.ts","./src/lib/theme.ts","./src/lib/utils.ts","./src/routes/audit.tsx","./src/routes/companies.tsx","./src/routes/dlq.tsx","./src/routes/forbidden.tsx","./src/routes/index.tsx","./src/routes/login.tsx","./src/routes/not-found.tsx","./src/routes/sources.tsx","./src/routes/tail.tsx","./src/routes/telegram.tsx","./tests/companies.test.tsx","./tests/login.test.tsx","./tests/setup.ts"],"version":"5.9.3"}
+{"root":["./src/main.tsx","./src/router.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/require-auth.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/use-theme.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/coming-soon.tsx","./src/components/ui/dialog.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/table.tsx","./src/components/ui/textarea.tsx","./src/features/companies/api.ts","./src/features/companies/create-dialog.tsx","./src/features/companies/detail-page.tsx","./src/features/companies/format.tsx","./src/features/companies/list.tsx","./src/features/companies/types.ts","./src/features/sources/api.ts","./src/features/sources/create-dialog.tsx","./src/features/sources/detail-page.tsx","./src/features/sources/format.tsx","./src/features/sources/list.tsx","./src/features/sources/types.ts","./src/lib/api.ts","./src/lib/auth-context.tsx","./src/lib/auth-state.ts","./src/lib/scope.ts","./src/lib/theme.ts","./src/lib/utils.ts","./src/routes/audit.tsx","./src/routes/companies.tsx","./src/routes/dlq.tsx","./src/routes/forbidden.tsx","./src/routes/index.tsx","./src/routes/login.tsx","./src/routes/not-found.tsx","./src/routes/sources.tsx","./src/routes/tail.tsx","./src/routes/telegram.tsx","./tests/companies.test.tsx","./tests/login.test.tsx","./tests/setup.ts","./tests/sources/format.test.ts"],"version":"5.9.3"}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно