diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7fe826c
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# ── Hookline local stack (docker compose) ─────────────────────────────────
+# Copy to .env and fill real secrets for anything beyond local dev.
+
+# Postgres role password (shared by the DB + the backend connection string).
+POSTGRES_PASSWORD=hookline
+
+# Backend secrets. Generate strong values for prod: openssl rand -base64 36
+TOKEN_ENCRYPTION_KEY=dev-token-encryption-key-change-me # AES master key — NEVER change after launch
+IDENTITY_SIGNING_KEY=dev-identity-signing-key-change-me # HMAC key for the BFF→backend identity assertion
+BACKEND_ADMIN_TOKEN=dev-admin-token # proves the BFF is calling (X-Admin-Token)
+SESSION_SECRET=dev-session-secret-change-me # signs the BFF session cookie
+
+# Fast-dev: backend runs as a dev admin without the BFF token/identity. Development-only —
+# it is forced off and refused at boot in Production. Flip to true locally to skip auth.
+AUTH_DEV_NO_AUTH=false
+NEXT_PUBLIC_NO_AUTH=false
+
+# First-run bootstrap admin (seeded only when the users table is empty).
+BOOTSTRAP_ADMIN_EMAIL=admin@hookline.local
+BOOTSTRAP_ADMIN_PASSWORD=
+
+# Client-visible backend origin for OAuth "Connect" redirects (build-arg inlined).
+NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..34f6f9b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,130 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev, phase-0]
+ pull_request:
+
+jobs:
+ backend:
+ name: Backend (build + arch/unit tests)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+ - run: dotnet restore Hookline.slnx
+ - run: dotnet build Hookline.slnx -c Release --no-restore
+ # The architecture tests run here — an illegal module reference fails this step
+ # (non-zero exit), which fails the job and blocks the merge.
+ - name: Test (gates the build on architecture violations)
+ run: dotnet test Hookline.slnx -c Release --no-build --verbosity normal
+
+ frontend:
+ name: Frontend (build)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - run: bun install --frozen-lockfile
+ - run: bun run build
+
+ # ════════════════════════════════════════════════════════════════════════════
+ # DEPLOY GATE — image build/push + server deploy are DISABLED until you flip it.
+ #
+ # >>> ONE-LINE FLIP: on the `docker` job below, change
+ # if: false
+ # to
+ # if: github.ref == 'refs/heads/main'
+ # That enables image build/push on main; the `deploy` job (needs: [docker])
+ # then runs automatically on main too. Until you flip it, both are SKIPPED
+ # and nothing is pushed or deployed — the backend/frontend jobs above still
+ # run for real on every push/PR and gate correctness.
+ #
+ # Before flipping, set repo secrets DEPLOY_HOST + DEPLOY_SSH_KEY, confirm GH_REPO,
+ # and complete the go-live checklist in deploy/README.md.
+ # ════════════════════════════════════════════════════════════════════════════
+ docker:
+ name: Docker images (build + push to GHCR)
+ needs: [backend, frontend]
+ if: false # ← DEPLOY GATE: flip to `github.ref == 'refs/heads/main'` to enable
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build & push backend
+ uses: docker/build-push-action@v6
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository }}/backend:latest
+ ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
+ - name: Build & push frontend
+ uses: docker/build-push-action@v6
+ with:
+ context: ./web
+ file: ./web/Dockerfile
+ push: true
+ # NEXT_PUBLIC_BACKEND_URL is inlined into the client bundle at BUILD time
+ # (the "Connect Slack/Google" buttons navigate the browser to
+ # ${NEXT_PUBLIC_BACKEND_URL}/{provider}/.../oauth/start). It MUST be the public
+ # origin, baked here — a runtime env cannot override an inlined value.
+ build-args: |
+ NEXT_PUBLIC_BACKEND_URL=https://danielhub.dev
+ tags: |
+ ghcr.io/${{ github.repository }}/frontend:latest
+ ghcr.io/${{ github.repository }}/frontend:${{ github.sha }}
+
+ deploy:
+ name: Deploy (scp compose + Caddy snippet, pull, up -d, reload Caddy)
+ needs: [docker]
+ # Inherits the gate: when `docker` is SKIPPED (if: false above), this is skipped too.
+ # Its own guard keeps deploy to main even after the docker gate is enabled.
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Copy compose + Caddy site block to server
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: deploy
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ source: "deploy/docker-compose.prod.yml,deploy/Caddyfile"
+ target: "/opt/hookline"
+ strip_components: 1
+ - name: Pull, restart, install + reload shared Caddy
+ uses: appleboy/ssh-action@v1.2.0
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: deploy
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ script: |
+ set -e
+ cd /opt/hookline
+ export IMAGE_TAG="${{ github.sha }}"
+ docker compose -f docker-compose.prod.yml pull
+ docker compose -f docker-compose.prod.yml up -d
+ # Publish this app's Caddy site block into the shared Caddy, then hot-reload.
+ install -D -m 644 Caddyfile /opt/shared/sites/hookline.caddy
+ docker exec shared-caddy-1 caddy reload --config /etc/caddy/Caddyfile
+ docker image prune -f
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1966a53
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Design handoff bundle (reference only, not source)
+/design/
+
+# Dependencies / build artifacts
+**/node_modules/
+**/.next/
+**/out/
+**/build/
+*.tsbuildinfo
+
+# Env
+**/.env
+**/.env.local
+**/.env.*.local
+!**/.env.example
+
+# Local Claude settings
+.claude/settings.local.json
+
+# OS
+.DS_Store
+
+# Local prod-profile smoke harness (holds TEST secrets — never commit)
+deploy/local/
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..bd98b4f
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "shadcn": {
+ "command": "npx",
+ "args": [
+ "shadcn@latest",
+ "mcp"
+ ]
+ }
+ }
+}
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..8e84a4d
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,6 @@
+**/bin/
+**/obj/
+.git/
+.vs/
+**/*.user
+[Tt]est[Rr]esults/
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..1955ec2
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,6 @@
+bin/
+obj/
+*.user
+.vs/
+[Tt]est[Rr]esults/
+*.tsbuildinfo
diff --git a/backend/.idea/.idea.Hookline/.idea/.gitignore b/backend/.idea/.idea.Hookline/.idea/.gitignore
new file mode 100644
index 0000000..9abb98e
--- /dev/null
+++ b/backend/.idea/.idea.Hookline/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/modules.xml
+/.idea.Hookline.iml
+/contentModel.xml
+/projectSettingsUpdater.xml
diff --git a/backend/.idea/.idea.Hookline/.idea/.name b/backend/.idea/.idea.Hookline/.idea/.name
new file mode 100644
index 0000000..1a2a76f
--- /dev/null
+++ b/backend/.idea/.idea.Hookline/.idea/.name
@@ -0,0 +1 @@
+Hookline
\ No newline at end of file
diff --git a/backend/.idea/.idea.Hookline/.idea/encodings.xml b/backend/.idea/.idea.Hookline/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/backend/.idea/.idea.Hookline/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/.idea.Hookline/.idea/indexLayout.xml b/backend/.idea/.idea.Hookline/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/backend/.idea/.idea.Hookline/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/.idea.Hookline/.idea/vcs.xml b/backend/.idea/.idea.Hookline/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/backend/.idea/.idea.Hookline/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
new file mode 100644
index 0000000..e80b823
--- /dev/null
+++ b/backend/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ false
+ false
+ false
+
+
diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
new file mode 100644
index 0000000..1ec474f
--- /dev/null
+++ b/backend/Directory.Packages.props
@@ -0,0 +1,34 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..5c61a94
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,30 @@
+# ── build ──────────────────────────────────────────────────────────────────
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# Restore first (cache layer) — copy only the project graph + central config.
+COPY global.json Directory.Build.props Directory.Packages.props Hookline.slnx ./
+COPY src/Hookline.Host/Hookline.Host.csproj src/Hookline.Host/
+COPY src/Hookline.SharedKernel/Hookline.SharedKernel.csproj src/Hookline.SharedKernel/
+COPY src/Hookline.Infrastructure/Hookline.Infrastructure.csproj src/Hookline.Infrastructure/
+COPY src/Modules/Hookline.Modules.YouTubeUploads/Hookline.Modules.YouTubeUploads.csproj src/Modules/Hookline.Modules.YouTubeUploads/
+COPY src/Modules/Hookline.Modules.YouTubeComments/Hookline.Modules.YouTubeComments.csproj src/Modules/Hookline.Modules.YouTubeComments/
+COPY tests/Hookline.ArchitectureTests/Hookline.ArchitectureTests.csproj tests/Hookline.ArchitectureTests/
+RUN dotnet restore src/Hookline.Host/Hookline.Host.csproj
+
+COPY . .
+RUN dotnet publish src/Hookline.Host/Hookline.Host.csproj -c Release -o /app --no-restore
+
+# ── runtime ────────────────────────────────────────────────────────────────
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
+WORKDIR /app
+# Kerberos/GSSAPI lib so Npgsql doesn't log a (harmless) probe error on startup.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libgssapi-krb5-2 \
+ && rm -rf /var/lib/apt/lists/*
+COPY --from=build /app .
+
+ENV ASPNETCORE_URLS=http://+:8080 \
+ DOTNET_gcServer=0
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "Hookline.Host.dll"]
diff --git a/backend/Hookline.slnx b/backend/Hookline.slnx
new file mode 100644
index 0000000..44bdf65
--- /dev/null
+++ b/backend/Hookline.slnx
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json
new file mode 100644
index 0000000..7dcefc3
--- /dev/null
+++ b/backend/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.8",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/global.json b/backend/global.json
new file mode 100644
index 0000000..512142d
--- /dev/null
+++ b/backend/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs b/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs
new file mode 100644
index 0000000..59012ee
--- /dev/null
+++ b/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs
@@ -0,0 +1,94 @@
+using Hookline.Infrastructure.Auth;
+using Hookline.SharedKernel.Auth;
+using Hookline.SharedKernel.Common;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Hookline.Host.Endpoints;
+
+public static class AuthEndpoints
+{
+ public static void MapHooklineAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/auth");
+
+ // Lets the BFF decide whether to show the one-time Create-Owner flow.
+ group.MapGet("/bootstrap-state", async (UserService users) =>
+ {
+ var ownerExists = await users.OwnerExistsAsync();
+ var all = await users.ListAsync();
+ return Results.Ok(new { ownerExists, userCount = all.Count });
+ });
+
+ // The BFF validates here, then mints the session cookie + identity assertion.
+ group.MapPost("/login", async (LoginRequest request, UserService users) =>
+ {
+ var user = await users.ValidateCredentialsAsync(request.Email, request.Password);
+ return user is null
+ ? Results.Problem(statusCode: 401, title: "invalid_credentials", detail: "Invalid email or password.")
+ : Results.Ok(ToDto(user));
+ });
+
+ group.MapPost("/logout", () => Results.NoContent());
+
+ group.MapGet("/me", async (ICurrentUser current, UserService users) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ // The identity assertion carries id + role; resolve the email from the store.
+ var email = current.Email;
+ if (email is null && current.UserId is { } id && !current.IsSystem)
+ {
+ email = (await users.FindAsync(id))?.Email;
+ }
+
+ return Results.Ok(new { id = current.UserId, email, role = current.Role?.ToString(), isSystem = current.IsSystem });
+ });
+
+ group.MapGet("/users", async (ICurrentUser current, UserService users) =>
+ {
+ if (!current.HasAtLeast(UserRole.Admin))
+ {
+ return Forbidden();
+ }
+
+ var all = await users.ListAsync();
+ return Results.Ok(all.Select(ToDto));
+ });
+
+ group.MapPost("/users", async (CreateUserRequest request, ICurrentUser current, UserService users) =>
+ {
+ if (!Enum.TryParse(request.Role, ignoreCase: true, out var role))
+ {
+ return Results.Problem(statusCode: 400, title: "validation", detail: "Unknown role.");
+ }
+
+ return ToResult(await users.CreateUserAsync(current, request.Email, request.Password, role));
+ });
+
+ // One-time Create-Owner. Creating a second Owner is rejected server-side
+ // (race-safe partial unique index → 409).
+ group.MapPost("/owner", async (CreateOwnerRequest request, ICurrentUser current, UserService users) =>
+ ToResult(await users.CreateOwnerAsync(current, request.Email, request.Password)));
+ }
+
+ private static IResult ToResult(Result result) =>
+ result.IsSuccess
+ ? Results.Ok(ToDto(result.Value!))
+ : Results.Problem(statusCode: result.Error!.Status, title: result.Error.Code, detail: result.Error.Message);
+
+ private static IResult Forbidden() =>
+ Results.Problem(statusCode: 403, title: "forbidden", detail: "You do not have permission to do that.");
+
+ private static UserDto ToDto(User user) =>
+ new(user.Id, user.Email, user.Role.ToString(), user.Status.ToString(), user.LastLoginAt);
+
+ private sealed record LoginRequest(string Email, string Password);
+ private sealed record CreateUserRequest(string Email, string Password, string Role);
+ private sealed record CreateOwnerRequest(string Email, string Password);
+ private sealed record UserDto(Guid Id, string Email, string Role, string Status, DateTimeOffset? LastLoginAt);
+}
diff --git a/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs b/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs
new file mode 100644
index 0000000..8f73510
--- /dev/null
+++ b/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs
@@ -0,0 +1,253 @@
+using System.Text.RegularExpressions;
+
+using Hookline.Infrastructure.Settings;
+using Hookline.SharedKernel.Audit;
+using Hookline.SharedKernel.Auth;
+using Hookline.SharedKernel.Common;
+using Hookline.SharedKernel.Maintenance;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Hookline.Host.Endpoints;
+
+/// Body of PATCH /api/system/alerts — any non-null field is applied.
+public sealed record UpdateAlertsRequest(bool? UploadFailures, bool? QuotaWarnings, bool? OauthExpiry, bool? WeeklyDigest);
+
+/// Body of POST /api/system/reset — must carry the literal type-to-confirm phrase.
+public sealed record ResetRequest(string? Confirm);
+
+///
+/// Host-level System endpoints shared by every module: the audit-log read + CSV export, the persisted
+/// Alerts preferences, and the cross-module "Danger Zone" actions (pause-all / reset). The destructive
+/// actions fan out over every registered — the host never names a module
+/// type, so the module-boundary arch tests keep holding. Every state change writes the shared audit trail.
+///
+public static class SystemEndpoints
+{
+ private const string ResetPhrase = "RESET";
+
+ public static void MapHooklineSystemEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/system");
+
+ group.MapGet("/logs", async (
+ ICurrentUser current, IAuditLogReader reader, string? module, int? page, int? pageSize, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ var result = await reader.ListAsync(module, page ?? 1, pageSize ?? 50, ct);
+ return Results.Ok(result);
+ });
+
+ // Real filtered CSV export of the Logs page. Applies the module filter server-side and the
+ // level/search refinements the page uses, over a wider window (up to MaxExportRows). text/csv body;
+ // the web client names the download (the BFF proxy forwards content-type but not content-disposition).
+ group.MapGet("/logs/export.csv", async (
+ ICurrentUser current, IAuditLogReader reader, string? module, string? level, string? q, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ const int pageSize = 200, maxExportRows = 5000;
+ var rows = new List();
+ for (var page = 1; rows.Count < maxExportRows; page++)
+ {
+ var batch = await reader.ListAsync(module, page, pageSize, ct);
+ rows.AddRange(batch.Items);
+ if (batch.Items.Count < pageSize || rows.Count >= batch.Total)
+ {
+ break;
+ }
+ }
+
+ var csv = Csv.Document(
+ ["timestamp", "module", "level", "action", "actor", "role", "entityType", "entityId", "detail"],
+ rows.Where(r => LogMatches(r, level, q)).Take(maxExportRows).Select(r =>
+ {
+ var (lvl, text) = SplitLevel(r.Detail);
+ return new string?[]
+ {
+ r.Timestamp.ToString("o"), r.Module, lvl, r.Action, r.Actor, r.Role,
+ r.EntityType, r.EntityId, text,
+ };
+ }));
+ return Results.Text(csv, "text/csv");
+ });
+
+ // ── Alerts preferences (persisted via the shared settings store; delivery is a separate feature) ──
+ group.MapGet("/alerts", async (ICurrentUser current, AlertSettingsService alerts, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ var a = await alerts.GetAsync(ct);
+ return Results.Ok(new { a.UploadFailures, a.QuotaWarnings, a.OauthExpiry, a.WeeklyDigest });
+ });
+
+ group.MapPatch("/alerts", async (
+ UpdateAlertsRequest body, ICurrentUser current, AlertSettingsService alerts, IAuditLog audit, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ var a = await alerts.UpdateAsync(body.UploadFailures, body.QuotaWarnings, body.OauthExpiry, body.WeeklyDigest, ct);
+ await audit.WriteAsync("system.alerts-updated", module: "system",
+ detail: $"failures={a.UploadFailures}, quota={a.QuotaWarnings}, oauth={a.OauthExpiry}, digest={a.WeeklyDigest}", ct: ct);
+ return Results.Ok(new { a.UploadFailures, a.QuotaWarnings, a.OauthExpiry, a.WeeklyDigest });
+ });
+
+ // ── Danger Zone — cross-module fan-out, each module audits its own slice ──
+ // The fan-out is NOT one transaction: each module's op is its own commit and may already have
+ // succeeded when a LATER module throws. So we isolate every module in its own try/catch — one
+ // failure never aborts the others, the host ALWAYS writes its audit entry (recording a partial
+ // outcome + which module failed), and the caller gets a visible `partial` signal instead of a
+ // bare 500 that would hide "some modules were already changed".
+ group.MapPost("/pause-all", async (
+ ICurrentUser current, IEnumerable controls, IAuditLog audit, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ var (results, failures) = await FanOutAsync(controls, (c, t) => c.PauseAllAsync(t), ct);
+ var total = results.Sum(r => r.Affected);
+ await audit.WriteAsync("system.pause-all", module: "system",
+ detail: failures.Count == 0
+ ? $"paused {total} automation(s) across {results.Count} module(s)"
+ : $"PARTIAL — paused {total} automation(s) across {results.Count} module(s); {FailDetail(failures)}",
+ ct: ct);
+ return Results.Ok(new
+ {
+ paused = total,
+ partial = failures.Count > 0,
+ byModule = results.Select(r => new { r.Module, r.Affected, r.Detail }),
+ failed = failures.Select(f => new { f.Module, f.Error }),
+ });
+ });
+
+ group.MapPost("/reset", async (
+ ResetRequest body, ICurrentUser current, IEnumerable controls, IAuditLog audit, CancellationToken ct) =>
+ {
+ if (!current.IsAuthenticated)
+ {
+ return Results.Problem(statusCode: 401, title: "unauthorized");
+ }
+
+ if (!string.Equals(body?.Confirm, ResetPhrase, StringComparison.Ordinal))
+ {
+ return Results.Problem(statusCode: 400, title: "confirmation_required",
+ detail: $"Type {ResetPhrase} to confirm — this clears operational data and can't be undone.");
+ }
+
+ var (results, failures) = await FanOutAsync(controls, (c, t) => c.ResetDataAsync(t), ct);
+ var total = results.Sum(r => r.Affected);
+ var perModule = string.Join("; ", results.Select(r => $"{r.Module} → {r.Detail}"));
+ // Written AFTER the wipe and NEVER cleared (reset keeps audit_logs), so the trail always records it
+ // — including a partial outcome where one module wiped and another failed.
+ await audit.WriteAsync("system.reset", module: "system",
+ detail: failures.Count == 0
+ ? $"cleared {total} operational row(s): {perModule}"
+ : $"PARTIAL — cleared {total} operational row(s) [{perModule}]; {FailDetail(failures)}",
+ ct: ct);
+ return Results.Ok(new
+ {
+ cleared = total,
+ partial = failures.Count > 0,
+ byModule = results.Select(r => new { r.Module, r.Affected, r.Detail }),
+ failed = failures.Select(f => new { f.Module, f.Error }),
+ });
+ });
+ }
+
+ ///
+ /// Runs a Danger-Zone op across every registered module independently. A module that throws is recorded
+ /// in 's failure list and the loop CONTINUES — the operation is operational-only, so
+ /// there is no cross-module rollback; the goal is to apply what we can and report the rest, never to abort
+ /// silently after a partial change. Returns the per-module successes plus (module, error) failures.
+ ///
+ private static async Task<(List Results, List<(string Module, string Error)> Failures)> FanOutAsync(
+ IEnumerable controls,
+ Func> op,
+ CancellationToken ct)
+ {
+ var results = new List();
+ var failures = new List<(string Module, string Error)>();
+ foreach (var control in controls)
+ {
+ try
+ {
+ results.Add(await op(control, ct));
+ }
+ catch (Exception ex)
+ {
+ failures.Add((control.Module, ex.Message));
+ }
+ }
+
+ return (results, failures);
+ }
+
+ private static string FailDetail(IReadOnlyList<(string Module, string Error)> failures) =>
+ $"{failures.Count} module(s) FAILED: " + string.Join("; ", failures.Select(f => $"{f.Module}: {f.Error}"));
+
+ // ── Mirror the web Logs view's level/search derivation so the CSV matches what the page shows ──
+
+ private static readonly Regex LevelMarker = new(@"^\[(\w+)\]\s*", RegexOptions.Compiled);
+
+ /// Splits the folded [Level] marker off the detail, returning (level, remaining text).
+ private static (string Level, string Text) SplitLevel(string? detail)
+ {
+ if (string.IsNullOrEmpty(detail))
+ {
+ return ("info", "");
+ }
+
+ var m = LevelMarker.Match(detail);
+ if (!m.Success)
+ {
+ return ("info", detail);
+ }
+
+ var level = m.Groups[1].Value.ToLowerInvariant() switch
+ {
+ "error" => "error",
+ "warning" or "warn" => "warn",
+ "success" => "success",
+ _ => "info",
+ };
+ return (level, detail[m.Length..]);
+ }
+
+ private static bool LogMatches(AuditLogRecord r, string? level, string? q)
+ {
+ var (lvl, text) = SplitLevel(r.Detail);
+ if (!string.IsNullOrEmpty(level) && level != "all" && lvl != level)
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(q))
+ {
+ var needle = q.Trim().ToLowerInvariant();
+ var message = (string.IsNullOrEmpty(text) ? r.Action : text).ToLowerInvariant();
+ var target = (r.EntityId ?? r.EntityType ?? r.Actor)?.ToLowerInvariant() ?? "";
+ if (!message.Contains(needle) && !target.Contains(needle))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/backend/src/Hookline.Host/Hookline.Host.csproj b/backend/src/Hookline.Host/Hookline.Host.csproj
new file mode 100644
index 0000000..1d2d6c0
--- /dev/null
+++ b/backend/src/Hookline.Host/Hookline.Host.csproj
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Hookline.Host/Program.cs b/backend/src/Hookline.Host/Program.cs
new file mode 100644
index 0000000..5e464e6
--- /dev/null
+++ b/backend/src/Hookline.Host/Program.cs
@@ -0,0 +1,98 @@
+using Hangfire;
+
+using Hookline.Host.Endpoints;
+using Hookline.Infrastructure;
+using Hookline.Infrastructure.Jobs;
+using Hookline.Modules.YouTubeComments;
+using Hookline.Modules.YouTubeUploads;
+using Hookline.SharedKernel.Jobs;
+using Hookline.SharedKernel.Modules;
+
+using Serilog;
+using Serilog.Context;
+
+Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();
+
+try
+{
+ var builder = WebApplication.CreateBuilder(args);
+
+ builder.Services.AddSerilog((_, lc) => lc
+ .Enrich.FromLogContext()
+ .Enrich.WithProperty("Application", "Hookline")
+ .WriteTo.Console(outputTemplate:
+ "[{Timestamp:HH:mm:ss} {Level:u3}] [{module}] [{CorrelationId}] {Message:lj}{NewLine}{Exception}"));
+
+ builder.Services.AddProblemDetails();
+ builder.Services.AddHooklineInfrastructure(builder.Configuration, builder.Environment);
+
+ // The explicit module list — no reflection scanning. Adding a module is one line here.
+ var modules = new List { new YouTubeUploadsModule(), new YouTubeCommentsModule() };
+ foreach (var module in modules)
+ {
+ module.RegisterServices(builder.Services, builder.Configuration);
+ }
+
+ var app = builder.Build();
+
+ // Migrate every schema under an advisory lock, then seed the bootstrap admin.
+ // Either throwing fails startup — never a half-migrated boot.
+ await app.Services.MigrateHooklineAsync(modules);
+ await app.Services.SeedBootstrapAdminAsync();
+
+ // Correlation id + module enrichment for every request's logs.
+ app.Use(async (context, next) =>
+ {
+ var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
+ ?? Guid.NewGuid().ToString("N")[..8];
+ context.Response.Headers["X-Correlation-ID"] = correlationId;
+
+ var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries) ?? [];
+ var module = segments is ["api", var name, ..] ? name : "host";
+
+ using (LogContext.PushProperty("CorrelationId", correlationId))
+ using (LogContext.PushProperty("module", module))
+ {
+ await next();
+ }
+ });
+
+ app.UseSerilogRequestLogging();
+ app.UseHooklineIdentity();
+
+ app.MapHealthChecks("/health");
+
+ app.UseHangfireDashboard("/hangfire", new DashboardOptions
+ {
+ Authorization = [new HangfireDashboardAuthorizationFilter()],
+ });
+
+ foreach (var module in modules)
+ {
+ module.MapEndpoints(app);
+ }
+
+ var scheduler = app.Services.GetRequiredService();
+ foreach (var module in modules)
+ {
+ module.RegisterJobs(scheduler);
+ }
+
+ app.MapHooklineAuthEndpoints();
+ app.MapHooklineSystemEndpoints();
+
+ app.Run();
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "Hookline host terminated unexpectedly.");
+ Log.CloseAndFlush();
+ // Exit non-zero immediately. Rethrowing here leaves the faulted async host spinning
+ // (99% CPU, container still "Up") instead of dying, which would let an orchestrator or
+ // healthcheck misread a fail-fast (bad secrets, failed migration, …) as a healthy boot.
+ Environment.Exit(1);
+}
+finally
+{
+ Log.CloseAndFlush();
+}
diff --git a/backend/src/Hookline.Host/Properties/launchSettings.json b/backend/src/Hookline.Host/Properties/launchSettings.json
new file mode 100644
index 0000000..216db34
--- /dev/null
+++ b/backend/src/Hookline.Host/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5023",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7124;http://localhost:5023",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/backend/src/Hookline.Host/appsettings.Development.json b/backend/src/Hookline.Host/appsettings.Development.json
new file mode 100644
index 0000000..33e12b0
--- /dev/null
+++ b/backend/src/Hookline.Host/appsettings.Development.json
@@ -0,0 +1,22 @@
+{
+ "ConnectionStrings": {
+ "Postgres": "Host=localhost;Port=5432;Database=hookline;Username=hookline;Password=hookline",
+ "Redis": "localhost:6379"
+ },
+ "TokenEncryption": {
+ "Key": "dev-token-encryption-key-change-me"
+ },
+ "Identity": {
+ "SigningKey": "dev-identity-signing-key-change-me"
+ },
+ "BackendAuth": {
+ "AdminToken": "dev-admin-token"
+ },
+ "Auth": {
+ "DevNoAuth": true
+ },
+ "Bootstrap": {
+ "AdminEmail": "admin@hookline.local",
+ "AdminPassword": ""
+ }
+}
diff --git a/backend/src/Hookline.Host/appsettings.json b/backend/src/Hookline.Host/appsettings.json
new file mode 100644
index 0000000..79443f7
--- /dev/null
+++ b/backend/src/Hookline.Host/appsettings.json
@@ -0,0 +1,31 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "Hangfire": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Postgres": "",
+ "Redis": ""
+ },
+ "TokenEncryption": {
+ "Key": ""
+ },
+ "Identity": {
+ "SigningKey": ""
+ },
+ "BackendAuth": {
+ "AdminToken": ""
+ },
+ "Auth": {
+ "DevNoAuth": false
+ },
+ "Bootstrap": {
+ "AdminEmail": "",
+ "AdminPassword": ""
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs b/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs
new file mode 100644
index 0000000..b2c9709
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs
@@ -0,0 +1,98 @@
+using Hookline.Infrastructure.Persistence;
+
+using Hookline.SharedKernel.Audit;
+using Hookline.SharedKernel.Auth;
+using Hookline.SharedKernel.Common;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Hookline.Infrastructure.Audit;
+
+/// Writes audit entries, stamping the actor from the current user — unless the caller passes an
+/// explicit actor (e.g. the moderating Slack user on the identity-bypassed provider callback,
+/// where the current user is anonymous), which then takes precedence.
+public sealed class AuditLog(SharedDbContext db, ICurrentUser currentUser) : IAuditLog
+{
+ public async Task WriteAsync(
+ string action,
+ string? module = null,
+ string? entityType = null,
+ string? entityId = null,
+ string? detail = null,
+ string? actor = null,
+ CancellationToken ct = default)
+ {
+ db.AuditLogs.Add(new AuditLogEntry
+ {
+ Timestamp = DateTimeOffset.UtcNow,
+ // An explicit actor (a provider-callback actor like the Slack user) wins; otherwise fall back
+ // to the request principal (admin email / "system" for jobs / "anonymous").
+ Actor = !string.IsNullOrWhiteSpace(actor)
+ ? actor
+ : currentUser.Email ?? (currentUser.IsSystem ? "system" : "anonymous"),
+ ActorId = currentUser.UserId,
+ Role = currentUser.Role?.ToString(),
+ Module = module,
+ Action = action,
+ EntityType = entityType,
+ EntityId = entityId,
+ Detail = detail,
+ });
+
+ await db.SaveChangesAsync(ct);
+ }
+}
+
+/// Pages over the shared audit_logs table, newest first, optionally filtered to one module.
+public sealed class AuditLogReader(SharedDbContext db) : IAuditLogReader
+{
+ public async Task> ListAsync(
+ string? module,
+ int page,
+ int pageSize,
+ CancellationToken ct = default)
+ {
+ var req = new PageRequest(page, pageSize);
+
+ var query = db.AuditLogs.AsNoTracking();
+ if (!string.IsNullOrWhiteSpace(module))
+ {
+ query = query.Where(a => a.Module == module);
+ }
+
+ var total = await query.LongCountAsync(ct);
+ var items = await query
+ .OrderByDescending(a => a.Timestamp)
+ .ThenByDescending(a => a.Id)
+ .Skip(req.Skip)
+ .Take(req.SafePageSize)
+ .Select(a => new AuditLogRecord(
+ a.Id, a.Timestamp, a.Actor, a.Role, a.Module, a.Action, a.EntityType, a.EntityId, a.Detail))
+ .ToListAsync(ct);
+
+ return new PagedResult(items, req.SafePage, req.SafePageSize, total);
+ }
+
+ public async Task CountSinceAsync(
+ string? module,
+ DateTimeOffset since,
+ string? detailPrefix = null,
+ CancellationToken ct = default)
+ {
+ var query = db.AuditLogs.AsNoTracking().Where(a => a.Timestamp >= since);
+
+ if (!string.IsNullOrWhiteSpace(module))
+ {
+ query = query.Where(a => a.Module == module);
+ }
+
+ if (!string.IsNullOrWhiteSpace(detailPrefix))
+ {
+ // The level marker is folded into the detail prefix (e.g. "[Error] ..."). Postgres LIKE
+ // treats '[' / ']' literally, so StartsWith matches the marker exactly.
+ query = query.Where(a => a.Detail != null && a.Detail.StartsWith(detailPrefix));
+ }
+
+ return await query.CountAsync(ct);
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs b/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs
new file mode 100644
index 0000000..e21fde5
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs
@@ -0,0 +1,34 @@
+using Hookline.SharedKernel.Persistence;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Hookline.Infrastructure.Auth;
+
+/// Auth schema — the hub's users.
+public sealed class AuthDbContext(DbContextOptions options) : HooklineDbContext(options)
+{
+ public const string SchemaName = "auth";
+
+ public DbSet Users => Set();
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.HasDefaultSchema(SchemaName);
+
+ var user = model.Entity();
+ user.ToTable("users");
+ user.HasKey(u => u.Id);
+ user.Property(u => u.Email).IsRequired().HasMaxLength(320);
+ user.HasIndex(u => u.Email).IsUnique();
+ user.Property(u => u.PasswordHash).IsRequired();
+ user.Property(u => u.Role).HasConversion().HasMaxLength(20);
+ user.Property(u => u.Status).HasConversion().HasMaxLength(20);
+
+ // Race-safe single-Owner guarantee: a partial unique index means two concurrent
+ // "create Owner" requests can never both succeed (one hits a unique violation).
+ user.HasIndex(u => u.Role)
+ .IsUnique()
+ .HasFilter("role = 'Owner'")
+ .HasDatabaseName("ux_users_single_owner");
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs b/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs
new file mode 100644
index 0000000..ff4f0a9
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs
@@ -0,0 +1,24 @@
+namespace Hookline.Infrastructure.Auth;
+
+/// Backend auth configuration (bound from the Auth / BackendAuth sections).
+public sealed class AuthOptions
+{
+ /// Shared secret proving the request comes from the trusted BFF (header X-Admin-Token).
+ public string AdminToken { get; set; } = string.Empty;
+
+ /// Dedicated HMAC key for the BFF→backend identity assertion (NOT the AES master key).
+ public string IdentitySigningKey { get; set; } = string.Empty;
+
+ /// Fast-dev escape hatch: skip the BFF token + identity checks and run as a dev admin.
+ public bool DevNoAuth { get; set; }
+
+ /// Header carrying the short-TTL signed identity assertion.
+ public string IdentityHeader { get; set; } = "X-Hookline-Identity";
+}
+
+/// First-run bootstrap admin seeding (env-only path into a fresh prod DB).
+public sealed class BootstrapOptions
+{
+ public string AdminEmail { get; set; } = string.Empty;
+ public string AdminPassword { get; set; } = string.Empty;
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs b/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs
new file mode 100644
index 0000000..d971878
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs
@@ -0,0 +1,56 @@
+using Hookline.SharedKernel.Auth;
+
+using Microsoft.AspNetCore.Http;
+
+namespace Hookline.Infrastructure.Auth;
+
+/// Concrete value.
+public sealed record HooklineUser(
+ bool IsAuthenticated,
+ Guid? UserId,
+ string? Email,
+ UserRole? Role,
+ bool IsSystem) : ICurrentUser
+{
+ public bool HasAtLeast(UserRole role) => Role is { } r && r >= role;
+
+ public static readonly HooklineUser Anonymous = new(false, null, null, null, false);
+
+ /// The internal principal used by background jobs (full power, no HTTP context).
+ public static readonly HooklineUser System = new(true, null, "system", UserRole.Owner, true);
+
+ public static HooklineUser Authenticated(Guid id, string? email, UserRole role) =>
+ new(true, id, email, role, false);
+}
+
+///
+/// Resolves the caller per request from (set by the
+/// identity middleware), or the system principal when there is no HTTP context
+/// (i.e. inside a background job).
+///
+public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser
+{
+ public const string ItemsKey = "hookline.current-user";
+
+ private ICurrentUser Resolve()
+ {
+ var ctx = httpContextAccessor.HttpContext;
+ if (ctx is null)
+ {
+ return HooklineUser.System;
+ }
+
+ return ctx.Items.TryGetValue(ItemsKey, out var value) && value is ICurrentUser user
+ ? user
+ : HooklineUser.Anonymous;
+ }
+
+ public static void Set(HttpContext context, ICurrentUser user) => context.Items[ItemsKey] = user;
+
+ public bool IsAuthenticated => Resolve().IsAuthenticated;
+ public Guid? UserId => Resolve().UserId;
+ public string? Email => Resolve().Email;
+ public UserRole? Role => Resolve().Role;
+ public bool IsSystem => Resolve().IsSystem;
+ public bool HasAtLeast(UserRole role) => Resolve().HasAtLeast(role);
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs b/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs
new file mode 100644
index 0000000..4396737
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs
@@ -0,0 +1,98 @@
+using System.Security.Cryptography;
+using System.Text;
+
+using Hookline.SharedKernel.Auth;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+
+namespace Hookline.Infrastructure.Auth;
+
+///
+/// The single auth gate. Bypass allowlist = exactly /slack/*, /google/*,
+/// /linkedin/* and /health (signature/state-verified or public).
+/// Everything else must present a valid X-Admin-Token (proves the BFF is calling —
+/// it does NOT establish identity), after which the signed identity assertion is verified
+/// to resolve . Endpoints then enforce role policies.
+/// The DevNoAuth escape hatch is bound off outside Development (see
+/// DependencyInjection.GuardSecurityConfig), so this gate is never weakened in prod.
+///
+public sealed class IdentityMiddleware(
+ RequestDelegate next,
+ IOptions options,
+ IdentityTokenService identityTokens)
+{
+ private static readonly string[] BypassPrefixes =
+ ["/slack", "/google", "/linkedin", "/health"];
+
+ private readonly AuthOptions _options = options.Value;
+ private readonly byte[] _adminTokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(options.Value.AdminToken));
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var path = context.Request.Path;
+
+ if (IsBypassed(path))
+ {
+ CurrentUserAccessor.Set(context, HooklineUser.Anonymous);
+ await next(context);
+ return;
+ }
+
+ // Fast-dev: no BFF token / identity required; run as a dev admin.
+ if (_options.DevNoAuth)
+ {
+ CurrentUserAccessor.Set(
+ context,
+ new HooklineUser(true, Guid.Empty, "dev@hookline.local", UserRole.Admin, false));
+ await next(context);
+ return;
+ }
+
+ // 1) Prove the caller is the trusted BFF (constant-time compare). NOT identity.
+ if (!IsValidAdminToken(context.Request.Headers["X-Admin-Token"]))
+ {
+ await WriteProblem(context, StatusCodes.Status401Unauthorized, "bff_required",
+ "This endpoint is only reachable through the Hookline BFF.");
+ return;
+ }
+
+ // 2) Resolve identity from the short-TTL signed assertion (if present).
+ var assertion = context.Request.Headers[_options.IdentityHeader].ToString();
+ var identity = identityTokens.Verify(assertion);
+ CurrentUserAccessor.Set(
+ context,
+ identity is null
+ ? HooklineUser.Anonymous
+ : HooklineUser.Authenticated(identity.UserId, email: null, identity.Role));
+
+ await next(context);
+ }
+
+ private static bool IsBypassed(PathString path) =>
+ BypassPrefixes.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
+
+ private bool IsValidAdminToken(string? provided)
+ {
+ if (string.IsNullOrEmpty(provided) || _options.AdminToken.Length == 0)
+ {
+ return false;
+ }
+
+ var providedHash = SHA256.HashData(Encoding.UTF8.GetBytes(provided));
+ return CryptographicOperations.FixedTimeEquals(providedHash, _adminTokenHash);
+ }
+
+ private static Task WriteProblem(HttpContext context, int status, string code, string detail)
+ {
+ context.Response.StatusCode = status;
+ context.Response.ContentType = "application/problem+json";
+ return context.Response.WriteAsJsonAsync(new
+ {
+ type = "about:blank",
+ title = code,
+ status,
+ detail,
+ });
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs b/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs
new file mode 100644
index 0000000..4a84e5c
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs
@@ -0,0 +1,120 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Hookline.SharedKernel.Auth;
+
+namespace Hookline.Infrastructure.Auth;
+
+/// A verified identity extracted from a BFF assertion.
+public sealed record AssertedIdentity(Guid UserId, UserRole Role, DateTimeOffset ExpiresAt);
+
+///
+/// Mints and verifies the short-TTL identity assertion the BFF forwards to the backend.
+/// Format (matched by the Next BFF): base64url(JSON{sub,role,exp})."."base64url(HMACSHA256(part1, key)).
+/// Signed with a DEDICATED signing key (Identity__SigningKey) — never the AES master key.
+/// X-Admin-Token only proves the BFF is calling; this assertion is what establishes identity.
+///
+public sealed class IdentityTokenService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly byte[] _key;
+
+ public IdentityTokenService(string? signingKey)
+ {
+ if (string.IsNullOrWhiteSpace(signingKey))
+ {
+ throw new InvalidOperationException(
+ "Identity__SigningKey is required — refusing to start without an identity signing key.");
+ }
+
+ _key = Encoding.UTF8.GetBytes(signingKey);
+ }
+
+ public string Sign(Guid userId, UserRole role, TimeSpan ttl)
+ {
+ var payload = new Payload(
+ userId.ToString("N"),
+ role.ToString(),
+ DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds());
+
+ var part1 = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions));
+ var signature = Base64UrlEncode(HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(part1)));
+ return $"{part1}.{signature}";
+ }
+
+ public AssertedIdentity? Verify(string? token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return null;
+ }
+
+ var dot = token.IndexOf('.');
+ if (dot <= 0 || dot == token.Length - 1)
+ {
+ return null;
+ }
+
+ var part1 = token[..dot];
+ var providedSig = token[(dot + 1)..];
+
+ var expectedSig = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(part1));
+ byte[] providedSigBytes;
+ try
+ {
+ providedSigBytes = Base64UrlDecode(providedSig);
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+
+ if (!CryptographicOperations.FixedTimeEquals(providedSigBytes, expectedSig))
+ {
+ return null;
+ }
+
+ Payload? payload;
+ try
+ {
+ payload = JsonSerializer.Deserialize(Base64UrlDecode(part1), JsonOptions);
+ }
+ catch (Exception ex) when (ex is JsonException or FormatException)
+ {
+ return null;
+ }
+
+ if (payload is null
+ || !Guid.TryParse(payload.Sub, out var userId)
+ || !Enum.TryParse(payload.Role, out var role))
+ {
+ return null;
+ }
+
+ var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.Exp);
+ if (expiresAt <= DateTimeOffset.UtcNow)
+ {
+ return null; // expired
+ }
+
+ return new AssertedIdentity(userId, role, expiresAt);
+ }
+
+ private static string Base64UrlEncode(byte[] bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private static byte[] Base64UrlDecode(string value)
+ {
+ var s = value.Replace('-', '+').Replace('_', '/');
+ s = (s.Length % 4) switch { 2 => s + "==", 3 => s + "=", _ => s };
+ return Convert.FromBase64String(s);
+ }
+
+ private sealed record Payload(
+ [property: JsonPropertyName("sub")] string Sub,
+ [property: JsonPropertyName("role")] string Role,
+ [property: JsonPropertyName("exp")] long Exp);
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs
new file mode 100644
index 0000000..e5f8c8a
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs
@@ -0,0 +1,88 @@
+//
+using System;
+using Hookline.Infrastructure.Auth;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Auth.Migrations
+{
+ [DbContext(typeof(AuthDbContext))]
+ [Migration("20260609161211_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("auth")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Auth.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("role");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_users_email");
+
+ b.HasIndex("Role")
+ .IsUnique()
+ .HasDatabaseName("ux_users_single_owner")
+ .HasFilter("role = 'Owner'");
+
+ b.ToTable("users", "auth");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs
new file mode 100644
index 0000000..5c477b9
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs
@@ -0,0 +1,60 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Auth.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "auth");
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ schema: "auth",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false),
+ password_hash = table.Column(type: "text", nullable: false),
+ role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ created_by = table.Column(type: "uuid", nullable: true),
+ last_login_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_users", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_users_email",
+ schema: "auth",
+ table: "users",
+ column: "email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ux_users_single_owner",
+ schema: "auth",
+ table: "users",
+ column: "role",
+ unique: true,
+ filter: "role = 'Owner'");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "users",
+ schema: "auth");
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs
new file mode 100644
index 0000000..453124e
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs
@@ -0,0 +1,85 @@
+//
+using System;
+using Hookline.Infrastructure.Auth;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Auth.Migrations
+{
+ [DbContext(typeof(AuthDbContext))]
+ partial class AuthDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("auth")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Auth.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("role");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_users_email");
+
+ b.HasIndex("Role")
+ .IsUnique()
+ .HasDatabaseName("ux_users_single_owner")
+ .HasFilter("role = 'Owner'");
+
+ b.ToTable("users", "auth");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs b/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs
new file mode 100644
index 0000000..27174f0
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs
@@ -0,0 +1,22 @@
+namespace Hookline.Infrastructure.Auth;
+
+/// bcrypt password hashing (work factor 12). Passwords are never logged.
+public sealed class PasswordHasher
+{
+ private const int WorkFactor = 12;
+
+ public string Hash(string password) =>
+ BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
+
+ public bool Verify(string password, string hash)
+ {
+ try
+ {
+ return BCrypt.Net.BCrypt.Verify(password, hash);
+ }
+ catch (BCrypt.Net.SaltParseException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/User.cs b/backend/src/Hookline.Infrastructure/Auth/User.cs
new file mode 100644
index 0000000..c380177
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/User.cs
@@ -0,0 +1,16 @@
+using Hookline.SharedKernel.Auth;
+
+namespace Hookline.Infrastructure.Auth;
+
+/// A hub user. Roles: Owner (supreme) / Admin / Member.
+public sealed class User
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Email { get; set; } = string.Empty;
+ public string PasswordHash { get; set; } = string.Empty;
+ public UserRole Role { get; set; } = UserRole.Member;
+ public UserStatus Status { get; set; } = UserStatus.Active;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public Guid? CreatedBy { get; set; }
+ public DateTimeOffset? LastLoginAt { get; set; }
+}
diff --git a/backend/src/Hookline.Infrastructure/Auth/UserService.cs b/backend/src/Hookline.Infrastructure/Auth/UserService.cs
new file mode 100644
index 0000000..e2a6ab6
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Auth/UserService.cs
@@ -0,0 +1,178 @@
+using System.Security.Cryptography;
+
+using Hookline.SharedKernel.Auth;
+using Hookline.SharedKernel.Common;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+using Npgsql;
+
+namespace Hookline.Infrastructure.Auth;
+
+/// The hub's user lifecycle: bootstrap, credential validation, and role-safe creation.
+public sealed class UserService(
+ AuthDbContext db,
+ PasswordHasher hasher,
+ IOptions bootstrap,
+ ILogger logger)
+{
+ ///
+ /// First-run seed: if users is empty, create one bootstrap Admin from env.
+ /// Idempotent — env creds are inert once any user exists. A blank password is
+ /// generated and logged once (break-glass) rather than silently skipped.
+ ///
+ public async Task BootstrapAsync(CancellationToken ct = default)
+ {
+ if (await db.Users.AnyAsync(ct))
+ {
+ return;
+ }
+
+ var email = bootstrap.Value.AdminEmail;
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ logger.LogWarning("No users exist and Bootstrap__AdminEmail is unset — cannot seed a bootstrap admin.");
+ return;
+ }
+
+ var password = bootstrap.Value.AdminPassword;
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ password = GenerateBreakGlassPassword();
+ logger.LogWarning("BOOTSTRAP_ADMIN_PASSWORD (generated, capture now): {Password}", password);
+ }
+
+ db.Users.Add(new User
+ {
+ Email = email.Trim().ToLowerInvariant(),
+ PasswordHash = hasher.Hash(password),
+ Role = UserRole.Admin,
+ Status = UserStatus.Active,
+ });
+
+ await db.SaveChangesAsync(ct);
+ logger.LogInformation("Seeded bootstrap admin {Email}.", email);
+ }
+
+ public async Task ValidateCredentialsAsync(string email, string password, CancellationToken ct = default)
+ {
+ var user = await db.Users
+ .FirstOrDefaultAsync(u => u.Email == email.Trim().ToLowerInvariant(), ct);
+
+ if (user is null || user.Status != UserStatus.Active || !hasher.Verify(password, user.PasswordHash))
+ {
+ return null;
+ }
+
+ user.LastLoginAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync(ct);
+ return user;
+ }
+
+ public Task OwnerExistsAsync(CancellationToken ct = default) =>
+ db.Users.AnyAsync(u => u.Role == UserRole.Owner, ct);
+
+ public Task FindAsync(Guid id, CancellationToken ct = default) =>
+ db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
+
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await db.Users.AsNoTracking().OrderBy(u => u.CreatedAt).ToListAsync(ct);
+
+ ///
+ /// One-time Create-Owner. While no Owner exists, an Admin may create exactly one.
+ /// Once an Owner exists, only an Owner may grant the role. The partial unique index
+ /// ux_users_single_owner is the race-safe backstop: concurrent creates can
+ /// never both succeed.
+ ///
+ public async Task> CreateOwnerAsync(ICurrentUser caller, string email, string password, CancellationToken ct = default)
+ {
+ if (!caller.HasAtLeast(UserRole.Admin))
+ {
+ return Error.Forbidden;
+ }
+
+ var ownerExists = await OwnerExistsAsync(ct);
+ if (ownerExists && caller.Role != UserRole.Owner)
+ {
+ return Error.Forbidden with { Message = "An Owner already exists; only the Owner may grant the Owner role." };
+ }
+
+ var user = new User
+ {
+ Email = email.Trim().ToLowerInvariant(),
+ PasswordHash = hasher.Hash(password),
+ Role = UserRole.Owner,
+ Status = UserStatus.Active,
+ CreatedBy = caller.UserId,
+ };
+ db.Users.Add(user);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ return user;
+ }
+ catch (DbUpdateException ex) when (IsSingleOwnerViolation(ex))
+ {
+ db.Entry(user).State = EntityState.Detached;
+ return Error.Conflict("An Owner already exists.");
+ }
+ catch (DbUpdateException ex) when (IsUniqueViolation(ex))
+ {
+ db.Entry(user).State = EntityState.Detached;
+ return Error.Conflict("A user with that email already exists.");
+ }
+ }
+
+ /// Create a Member/Admin. Admins manage Members; only an Owner may create Admins.
+ public async Task> CreateUserAsync(ICurrentUser caller, string email, string password, UserRole role, CancellationToken ct = default)
+ {
+ if (role == UserRole.Owner)
+ {
+ return await CreateOwnerAsync(caller, email, password, ct);
+ }
+
+ var allowed = role switch
+ {
+ UserRole.Admin => caller.Role == UserRole.Owner,
+ UserRole.Member => caller.HasAtLeast(UserRole.Admin),
+ _ => false,
+ };
+ if (!allowed)
+ {
+ return Error.Forbidden;
+ }
+
+ var user = new User
+ {
+ Email = email.Trim().ToLowerInvariant(),
+ PasswordHash = hasher.Hash(password),
+ Role = role,
+ Status = UserStatus.Active,
+ CreatedBy = caller.UserId,
+ };
+ db.Users.Add(user);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ return user;
+ }
+ catch (DbUpdateException ex) when (IsUniqueViolation(ex))
+ {
+ db.Entry(user).State = EntityState.Detached;
+ return Error.Conflict("A user with that email already exists.");
+ }
+ }
+
+ private static bool IsSingleOwnerViolation(DbUpdateException ex) =>
+ ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "ux_users_single_owner" };
+
+ private static bool IsUniqueViolation(DbUpdateException ex) =>
+ ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
+
+ private static string GenerateBreakGlassPassword() =>
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)).Replace('/', '_').Replace('+', '-');
+}
diff --git a/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs
new file mode 100644
index 0000000..c1b3040
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs
@@ -0,0 +1,51 @@
+using Hookline.SharedKernel.Caching;
+
+using StackExchange.Redis;
+
+namespace Hookline.Infrastructure.Caching;
+
+///
+/// Redis-backed . Walks each connected server endpoint, SCANs the prefix and
+/// deletes the matches. Best-effort by contract: a connection/SCAN failure is swallowed (the shared Redis
+/// runs noeviction but every app key self-expires, so a missed purge only delays cleanup — it must
+/// never fail a data reset).
+///
+public sealed class RedisCachePurge(IConnectionMultiplexer redis) : ICachePurge
+{
+ public async Task PurgeByPrefixAsync(string prefix, CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(prefix))
+ {
+ return 0;
+ }
+
+ long removed = 0;
+ try
+ {
+ var db = redis.GetDatabase();
+ foreach (var endpoint in redis.GetEndPoints())
+ {
+ var server = redis.GetServer(endpoint);
+ if (!server.IsConnected || server.IsReplica)
+ {
+ continue;
+ }
+
+ foreach (var key in server.Keys(pattern: prefix + "*", pageSize: 500))
+ {
+ ct.ThrowIfCancellationRequested();
+ if (await db.KeyDeleteAsync(key))
+ {
+ removed++;
+ }
+ }
+ }
+ }
+ catch (Exception) when (!ct.IsCancellationRequested)
+ {
+ // Best-effort: a cache outage must never fail the surrounding data reset.
+ }
+
+ return removed;
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs b/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs
new file mode 100644
index 0000000..fe1632c
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs
@@ -0,0 +1,255 @@
+using Hookline.SharedKernel.Connections;
+using Hookline.SharedKernel.Messaging;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Hookline.Infrastructure.Connections;
+
+/// Reads/writes Slack bot tokens (encrypted by the converter) + lists workspaces.
+public sealed class SlackConnections(ConnectionsDbContext db, IEventBus events) : ISlackConnections
+{
+ public async Task GetBotTokenAsync(Guid workspaceId, CancellationToken ct = default) =>
+ await db.SlackWorkspaces.AsNoTracking()
+ .Where(w => w.Id == workspaceId && w.IsActive)
+ .Select(w => w.BotTokenEncrypted)
+ .FirstOrDefaultAsync(ct);
+
+ public async Task GetBotTokenForTeamAsync(string teamId, CancellationToken ct = default) =>
+ await db.SlackWorkspaces.AsNoTracking()
+ .Where(w => w.TeamId == teamId && w.IsActive)
+ .Select(w => w.BotTokenEncrypted)
+ .FirstOrDefaultAsync(ct);
+
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await db.SlackWorkspaces.AsNoTracking()
+ .OrderBy(w => w.TeamName)
+ .Select(w => new SlackWorkspaceSummary(w.Id, w.TeamId, w.TeamName, w.IsActive))
+ .ToListAsync(ct);
+
+ public async Task GetByTeamAsync(string teamId, CancellationToken ct = default) =>
+ await db.SlackWorkspaces.AsNoTracking()
+ .Where(w => w.TeamId == teamId)
+ .Select(w => new SlackWorkspaceSummary(w.Id, w.TeamId, w.TeamName, w.IsActive))
+ .FirstOrDefaultAsync(ct);
+
+ public async Task UpsertWorkspaceAsync(SlackWorkspaceWrite write, CancellationToken ct = default)
+ {
+ var existing = await db.SlackWorkspaces.FirstOrDefaultAsync(w => w.TeamId == write.TeamId, ct);
+ if (existing is null)
+ {
+ existing = new SlackWorkspace { TeamId = write.TeamId, InstalledAt = DateTimeOffset.UtcNow };
+ db.SlackWorkspaces.Add(existing);
+ }
+
+ existing.TeamName = write.TeamName;
+ existing.BotTokenEncrypted = write.BotToken; // encrypted on write by the converter
+ existing.BotUserId = write.BotUserId;
+ existing.Scope = write.Scope;
+ existing.AuthedUserId = write.AuthedUserId;
+ existing.IsActive = true;
+
+ await db.SaveChangesAsync(ct);
+ return existing.Id;
+ }
+
+ public async Task DeactivateAsync(Guid workspaceId, CancellationToken ct = default)
+ {
+ var ws = await db.SlackWorkspaces.FirstOrDefaultAsync(w => w.Id == workspaceId, ct);
+ if (ws is null)
+ {
+ return false;
+ }
+
+ ws.IsActive = false;
+ await db.SaveChangesAsync(ct);
+ await events.PublishAsync(new SlackWorkspaceDisconnected(workspaceId), ct);
+ return true;
+ }
+}
+
+/// Reads/writes Google accounts in the shared store. The decrypted refresh token is handed to
+/// the owning module, which rebuilds credentials with its issuing OAuth client (Projects are module-local).
+public sealed class GoogleConnections(ConnectionsDbContext db, IEventBus events) : IGoogleConnections
+{
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await db.GoogleAccounts.AsNoTracking()
+ .OrderBy(g => g.ConnectedAt)
+ .Select(g => new GoogleAccountSummary(g.Id, g.ChannelTitle, g.IsActive))
+ .ToListAsync(ct);
+
+ public async Task GetAsync(Guid accountId, CancellationToken ct = default) =>
+ await db.GoogleAccounts.AsNoTracking()
+ .Where(g => g.Id == accountId)
+ .Select(g => new GoogleAccountDetail(
+ g.Id, g.ChannelId, g.ChannelTitle, g.AccountEmail, g.AvatarUrl, g.Scopes, g.IsActive))
+ .FirstOrDefaultAsync(ct);
+
+ // Needs an app-wide OAuth client to mint access tokens; module-local Projects own that, so this
+ // stays null and callers use GetRefreshTokenAsync + their issuing client instead.
+ public Task GetCredentialAsync(Guid accountId, CancellationToken ct = default) =>
+ Task.FromResult(null);
+
+ public async Task GetRefreshTokenAsync(Guid accountId, CancellationToken ct = default) =>
+ await db.GoogleAccounts.AsNoTracking()
+ .Where(g => g.Id == accountId && g.IsActive)
+ .Select(g => g.RefreshTokenEncrypted) // decrypted on read by the converter
+ .FirstOrDefaultAsync(ct);
+
+ public async Task CreateAccountAsync(GoogleAccountWrite write, CancellationToken ct = default)
+ {
+ var account = new GoogleAccount
+ {
+ ChannelId = write.ChannelId,
+ ChannelTitle = write.ChannelTitle,
+ AccountEmail = write.AccountEmail,
+ AvatarUrl = write.AvatarUrl,
+ RefreshTokenEncrypted = write.RefreshToken, // encrypted on write by the converter
+ Scopes = write.Scopes,
+ IsActive = true,
+ ConnectedAt = DateTimeOffset.UtcNow,
+ };
+ db.GoogleAccounts.Add(account);
+ await db.SaveChangesAsync(ct);
+ return account.Id;
+ }
+
+ public async Task UpdateConsentAsync(
+ Guid accountId, string refreshToken, string scopes,
+ string? channelTitle = null, string? accountEmail = null, string? avatarUrl = null,
+ CancellationToken ct = default)
+ {
+ var account = await db.GoogleAccounts.FirstOrDefaultAsync(g => g.Id == accountId, ct);
+ if (account is null)
+ {
+ return false;
+ }
+
+ account.RefreshTokenEncrypted = refreshToken; // re-encrypted on write by the converter
+ account.Scopes = scopes;
+ if (channelTitle is not null) account.ChannelTitle = channelTitle;
+ if (accountEmail is not null) account.AccountEmail = accountEmail;
+ if (avatarUrl is not null) account.AvatarUrl = avatarUrl;
+ account.IsActive = true; // a fresh consent re-activates the record
+ await db.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task DeactivateAsync(Guid accountId, CancellationToken ct = default)
+ {
+ var account = await db.GoogleAccounts.FirstOrDefaultAsync(g => g.Id == accountId, ct);
+ if (account is null)
+ {
+ return false;
+ }
+
+ account.IsActive = false;
+ await db.SaveChangesAsync(ct);
+ await events.PublishAsync(new GoogleAccountDisconnected(accountId), ct);
+ return true;
+ }
+}
+
+/// Reads/writes YouTube Data API keys (encrypted by the converter) for quota-rotated polling.
+public sealed class YouTubeApiKeyConnections(ConnectionsDbContext db, IEventBus events) : IYouTubeApiKeyConnections
+{
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await db.YouTubeApiKeys.AsNoTracking()
+ .OrderBy(k => k.CreatedAt)
+ .Select(k => new YouTubeApiKeySummary(k.Id, k.Name, k.KeyHint, k.IsActive, k.CreatedAt))
+ .ToListAsync(ct);
+
+ public async Task> ListActiveAsync(CancellationToken ct = default) =>
+ await db.YouTubeApiKeys.AsNoTracking()
+ .Where(k => k.IsActive)
+ .OrderBy(k => k.CreatedAt)
+ .Select(k => new YouTubeApiKeySummary(k.Id, k.Name, k.KeyHint, k.IsActive, k.CreatedAt))
+ .ToListAsync(ct);
+
+ public async Task GetApiKeyAsync(Guid keyId, CancellationToken ct = default) =>
+ await db.YouTubeApiKeys.AsNoTracking()
+ .Where(k => k.Id == keyId)
+ .Select(k => k.ApiKeyEncrypted) // decrypted on read by the converter
+ .FirstOrDefaultAsync(ct);
+
+ public async Task CreateAsync(string name, string apiKey, string keyHint, CancellationToken ct = default)
+ {
+ var key = new YouTubeApiKey
+ {
+ Name = name,
+ ApiKeyEncrypted = apiKey, // encrypted on write by the converter
+ KeyHint = keyHint,
+ IsActive = true,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ db.YouTubeApiKeys.Add(key);
+ await db.SaveChangesAsync(ct);
+ return key.Id;
+ }
+
+ public async Task ToggleAsync(Guid keyId, bool isActive, CancellationToken ct = default)
+ {
+ var key = await db.YouTubeApiKeys.FirstOrDefaultAsync(k => k.Id == keyId, ct);
+ if (key is null)
+ {
+ return false;
+ }
+
+ key.IsActive = isActive;
+ await db.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task DeleteAsync(Guid keyId, CancellationToken ct = default)
+ {
+ var key = await db.YouTubeApiKeys.FirstOrDefaultAsync(k => k.Id == keyId, ct);
+ if (key is null)
+ {
+ return false;
+ }
+
+ db.YouTubeApiKeys.Remove(key);
+ await db.SaveChangesAsync(ct);
+ await events.PublishAsync(new YouTubeApiKeyDisconnected(keyId), ct);
+ return true;
+ }
+}
+
+/// Aggregates every connection into a unified catalog for the UI.
+public sealed class ConnectionCatalog(ConnectionsDbContext db) : IConnectionCatalog
+{
+ public async Task> ListAsync(
+ ConnectionType? type = null,
+ CancellationToken ct = default)
+ {
+ var result = new List();
+
+ if (type is null or ConnectionType.Slack)
+ {
+ result.AddRange(await db.SlackWorkspaces.AsNoTracking()
+ .Select(w => new ConnectionSummary(
+ w.Id, ConnectionType.Slack, w.TeamName,
+ w.IsActive ? "connected" : "disabled", w.TeamId))
+ .ToListAsync(ct));
+ }
+
+ if (type is null or ConnectionType.Google)
+ {
+ result.AddRange(await db.GoogleAccounts.AsNoTracking()
+ .Select(g => new ConnectionSummary(
+ g.Id, ConnectionType.Google, g.ChannelTitle,
+ g.IsActive ? "connected" : "disabled", g.ChannelId))
+ .ToListAsync(ct));
+ }
+
+ if (type is null or ConnectionType.YouTubeApiKey)
+ {
+ result.AddRange(await db.YouTubeApiKeys.AsNoTracking()
+ .Select(k => new ConnectionSummary(
+ k.Id, ConnectionType.YouTubeApiKey, k.Name,
+ k.IsActive ? "active" : "disabled", k.KeyHint))
+ .ToListAsync(ct));
+ }
+
+ return result;
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs b/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs
new file mode 100644
index 0000000..4d0590c
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs
@@ -0,0 +1,51 @@
+using Hookline.SharedKernel.Persistence;
+using Hookline.SharedKernel.Secrets;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Hookline.Infrastructure.Connections;
+
+///
+/// Connections schema — the shared external-credential store. Every secret column is
+/// encrypted at rest through . Multiple credential sets
+/// per provider are supported (rows).
+///
+public sealed class ConnectionsDbContext(
+ DbContextOptions options,
+ ISecretProtector protector) : HooklineDbContext(options)
+{
+ public const string SchemaName = "connections";
+
+ public DbSet SlackWorkspaces => Set();
+ public DbSet GoogleAccounts => Set();
+ public DbSet YouTubeApiKeys => Set();
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.HasDefaultSchema(SchemaName);
+
+ var slack = model.Entity();
+ slack.ToTable("slack_workspaces");
+ slack.HasKey(w => w.Id);
+ slack.HasIndex(w => w.TeamId).IsUnique();
+ slack.Property(w => w.TeamId).IsRequired().HasMaxLength(64);
+ slack.Property(w => w.TeamName).HasMaxLength(200);
+ slack.Property(w => w.BotTokenEncrypted).IsEncrypted(protector);
+
+ var google = model.Entity();
+ google.ToTable("google_accounts");
+ google.HasKey(g => g.Id);
+ google.Property(g => g.ChannelTitle).HasMaxLength(200);
+ google.Property(g => g.AccountEmail).HasMaxLength(320);
+ google.Property(g => g.ChannelId).HasMaxLength(64);
+ google.Property(g => g.RefreshTokenEncrypted).IsEncrypted(protector);
+ google.HasIndex(g => g.ChannelId);
+
+ var key = model.Entity();
+ key.ToTable("api_keys");
+ key.HasKey(k => k.Id);
+ key.Property(k => k.Name).IsRequired().HasMaxLength(120);
+ key.Property(k => k.KeyHint).HasMaxLength(40);
+ key.Property(k => k.ApiKeyEncrypted).IsEncrypted(protector);
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Entities.cs b/backend/src/Hookline.Infrastructure/Connections/Entities.cs
new file mode 100644
index 0000000..dd55bbe
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Entities.cs
@@ -0,0 +1,40 @@
+namespace Hookline.Infrastructure.Connections;
+
+/// A connected Slack workspace (OAuth v2 bot token). Multiple per provider allowed.
+public sealed class SlackWorkspace
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string TeamId { get; set; } = string.Empty;
+ public string TeamName { get; set; } = string.Empty;
+ public string BotTokenEncrypted { get; set; } = string.Empty;
+ public string? BotUserId { get; set; }
+ public string? Scope { get; set; }
+ public string? AuthedUserId { get; set; }
+ public bool IsActive { get; set; } = true;
+ public DateTimeOffset InstalledAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+/// A connected Google account (OAuth refresh token → YouTube + Drive scopes).
+public sealed class GoogleAccount
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string? ChannelId { get; set; }
+ public string ChannelTitle { get; set; } = string.Empty;
+ public string? AccountEmail { get; set; }
+ public string? AvatarUrl { get; set; }
+ public string RefreshTokenEncrypted { get; set; } = string.Empty;
+ public string Scopes { get; set; } = string.Empty;
+ public bool IsActive { get; set; } = true;
+ public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+/// A YouTube Data API key (comment polling). Multiple per provider, quota-rotated.
+public sealed class YouTubeApiKey
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Name { get; set; } = string.Empty;
+ public string ApiKeyEncrypted { get; set; } = string.Empty;
+ public string KeyHint { get; set; } = string.Empty;
+ public bool IsActive { get; set; } = true;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs
new file mode 100644
index 0000000..43148e0
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs
@@ -0,0 +1,164 @@
+//
+using System;
+using Hookline.Infrastructure.Connections;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Connections.Migrations
+{
+ [DbContext(typeof(ConnectionsDbContext))]
+ [Migration("20260609161213_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("connections")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChannelId")
+ .HasColumnType("text")
+ .HasColumnName("channel_id");
+
+ b.Property("ChannelTitle")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("channel_title");
+
+ b.Property("ConnectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("connected_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("RefreshTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("refresh_token_encrypted");
+
+ b.Property("Scopes")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("scopes");
+
+ b.HasKey("Id")
+ .HasName("pk_google_accounts");
+
+ b.ToTable("google_accounts", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthedUserId")
+ .HasColumnType("text")
+ .HasColumnName("authed_user_id");
+
+ b.Property("BotTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("bot_token_encrypted");
+
+ b.Property("BotUserId")
+ .HasColumnType("text")
+ .HasColumnName("bot_user_id");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("installed_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("Scope")
+ .HasColumnType("text")
+ .HasColumnName("scope");
+
+ b.Property("TeamId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("team_id");
+
+ b.Property("TeamName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("team_name");
+
+ b.HasKey("Id")
+ .HasName("pk_slack_workspaces");
+
+ b.HasIndex("TeamId")
+ .IsUnique()
+ .HasDatabaseName("ix_slack_workspaces_team_id");
+
+ b.ToTable("slack_workspaces", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ApiKeyEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("api_key_encrypted");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("KeyHint")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("key_hint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_api_keys");
+
+ b.ToTable("api_keys", "connections");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs
new file mode 100644
index 0000000..7d54634
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs
@@ -0,0 +1,96 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Connections.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "connections");
+
+ migrationBuilder.CreateTable(
+ name: "api_keys",
+ schema: "connections",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false),
+ api_key_encrypted = table.Column(type: "text", nullable: false),
+ key_hint = table.Column(type: "character varying(40)", maxLength: 40, nullable: false),
+ is_active = table.Column(type: "boolean", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_api_keys", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "google_accounts",
+ schema: "connections",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ channel_id = table.Column(type: "text", nullable: true),
+ channel_title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ refresh_token_encrypted = table.Column(type: "text", nullable: false),
+ scopes = table.Column(type: "text", nullable: false),
+ is_active = table.Column(type: "boolean", nullable: false),
+ connected_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_google_accounts", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "slack_workspaces",
+ schema: "connections",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ team_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ team_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ bot_token_encrypted = table.Column(type: "text", nullable: false),
+ bot_user_id = table.Column(type: "text", nullable: true),
+ scope = table.Column(type: "text", nullable: true),
+ authed_user_id = table.Column(type: "text", nullable: true),
+ is_active = table.Column(type: "boolean", nullable: false),
+ installed_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_slack_workspaces", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_slack_workspaces_team_id",
+ schema: "connections",
+ table: "slack_workspaces",
+ column: "team_id",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "api_keys",
+ schema: "connections");
+
+ migrationBuilder.DropTable(
+ name: "google_accounts",
+ schema: "connections");
+
+ migrationBuilder.DropTable(
+ name: "slack_workspaces",
+ schema: "connections");
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs
new file mode 100644
index 0000000..1864129
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs
@@ -0,0 +1,177 @@
+//
+using System;
+using Hookline.Infrastructure.Connections;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Connections.Migrations
+{
+ [DbContext(typeof(ConnectionsDbContext))]
+ [Migration("20260609232123_AddGoogleAccountDisplayFields")]
+ partial class AddGoogleAccountDisplayFields
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("connections")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AccountEmail")
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("account_email");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text")
+ .HasColumnName("avatar_url");
+
+ b.Property("ChannelId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("channel_id");
+
+ b.Property("ChannelTitle")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("channel_title");
+
+ b.Property("ConnectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("connected_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("RefreshTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("refresh_token_encrypted");
+
+ b.Property("Scopes")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("scopes");
+
+ b.HasKey("Id")
+ .HasName("pk_google_accounts");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_google_accounts_channel_id");
+
+ b.ToTable("google_accounts", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthedUserId")
+ .HasColumnType("text")
+ .HasColumnName("authed_user_id");
+
+ b.Property("BotTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("bot_token_encrypted");
+
+ b.Property("BotUserId")
+ .HasColumnType("text")
+ .HasColumnName("bot_user_id");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("installed_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("Scope")
+ .HasColumnType("text")
+ .HasColumnName("scope");
+
+ b.Property("TeamId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("team_id");
+
+ b.Property("TeamName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("team_name");
+
+ b.HasKey("Id")
+ .HasName("pk_slack_workspaces");
+
+ b.HasIndex("TeamId")
+ .IsUnique()
+ .HasDatabaseName("ix_slack_workspaces_team_id");
+
+ b.ToTable("slack_workspaces", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ApiKeyEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("api_key_encrypted");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("KeyHint")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("key_hint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_api_keys");
+
+ b.ToTable("api_keys", "connections");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs
new file mode 100644
index 0000000..09c92ff
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs
@@ -0,0 +1,76 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Connections.Migrations
+{
+ ///
+ public partial class AddGoogleAccountDisplayFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "channel_id",
+ schema: "connections",
+ table: "google_accounts",
+ type: "character varying(64)",
+ maxLength: 64,
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "text",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "account_email",
+ schema: "connections",
+ table: "google_accounts",
+ type: "character varying(320)",
+ maxLength: 320,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "avatar_url",
+ schema: "connections",
+ table: "google_accounts",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_google_accounts_channel_id",
+ schema: "connections",
+ table: "google_accounts",
+ column: "channel_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "ix_google_accounts_channel_id",
+ schema: "connections",
+ table: "google_accounts");
+
+ migrationBuilder.DropColumn(
+ name: "account_email",
+ schema: "connections",
+ table: "google_accounts");
+
+ migrationBuilder.DropColumn(
+ name: "avatar_url",
+ schema: "connections",
+ table: "google_accounts");
+
+ migrationBuilder.AlterColumn(
+ name: "channel_id",
+ schema: "connections",
+ table: "google_accounts",
+ type: "text",
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "character varying(64)",
+ oldMaxLength: 64,
+ oldNullable: true);
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs
new file mode 100644
index 0000000..b7da0aa
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs
@@ -0,0 +1,174 @@
+//
+using System;
+using Hookline.Infrastructure.Connections;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Connections.Migrations
+{
+ [DbContext(typeof(ConnectionsDbContext))]
+ partial class ConnectionsDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("connections")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AccountEmail")
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("account_email");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text")
+ .HasColumnName("avatar_url");
+
+ b.Property("ChannelId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("channel_id");
+
+ b.Property("ChannelTitle")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("channel_title");
+
+ b.Property("ConnectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("connected_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("RefreshTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("refresh_token_encrypted");
+
+ b.Property("Scopes")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("scopes");
+
+ b.HasKey("Id")
+ .HasName("pk_google_accounts");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_google_accounts_channel_id");
+
+ b.ToTable("google_accounts", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthedUserId")
+ .HasColumnType("text")
+ .HasColumnName("authed_user_id");
+
+ b.Property("BotTokenEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("bot_token_encrypted");
+
+ b.Property("BotUserId")
+ .HasColumnType("text")
+ .HasColumnName("bot_user_id");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("installed_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("Scope")
+ .HasColumnType("text")
+ .HasColumnName("scope");
+
+ b.Property("TeamId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("team_id");
+
+ b.Property("TeamName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("team_name");
+
+ b.HasKey("Id")
+ .HasName("pk_slack_workspaces");
+
+ b.HasIndex("TeamId")
+ .IsUnique()
+ .HasDatabaseName("ix_slack_workspaces_team_id");
+
+ b.ToTable("slack_workspaces", "connections");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ApiKeyEncrypted")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("api_key_encrypted");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("KeyHint")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("key_hint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_api_keys");
+
+ b.ToTable("api_keys", "connections");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs b/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs
new file mode 100644
index 0000000..6f454cc
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+
+using Hookline.SharedKernel.Common;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+using StackExchange.Redis;
+
+namespace Hookline.Infrastructure.Connections;
+
+///
+/// OAuth start/callback skeleton owned by the Connections subsystem. Phase 0 implements
+/// the CSRF-safe state lifecycle (generate → store in Redis with a short TTL → verify);
+/// the provider-specific code exchange + token storage land when the modules are absorbed.
+///
+public sealed class OAuthFlowService(
+ IConnectionMultiplexer redis,
+ IConfiguration config,
+ ILogger logger)
+{
+ private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(10);
+ private static readonly string[] KnownProviders = ["slack", "google"];
+
+ /// Generate state, persist it, and return the provider's authorize URL.
+ public async Task> StartAsync(string provider, string? returnUrl, CancellationToken ct = default)
+ {
+ provider = provider.ToLowerInvariant();
+ if (!KnownProviders.Contains(provider))
+ {
+ return Error.Validation($"Unknown OAuth provider '{provider}'.");
+ }
+
+ var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
+ await redis.GetDatabase().StringSetAsync(
+ RedisKeys.OAuthState(provider, state),
+ returnUrl ?? "/",
+ StateTtl);
+
+ var clientId = config[$"{Capitalize(provider)}:ClientId"];
+ if (string.IsNullOrWhiteSpace(clientId))
+ {
+ // Skeleton: the provider isn't configured yet (real wiring is Phase 1).
+ logger.LogInformation("OAuth start requested for {Provider} but no client is configured.", provider);
+ return Error.Conflict($"The {provider} connection is not configured yet.");
+ }
+
+ var redirectUri = config[$"{Capitalize(provider)}:RedirectUri"] ?? string.Empty;
+ var authorizeUrl = provider switch
+ {
+ "slack" => $"https://slack.com/oauth/v2/authorize?client_id={clientId}&state={state}&redirect_uri={Uri.EscapeDataString(redirectUri)}",
+ "google" => $"https://accounts.google.com/o/oauth2/v2/auth?client_id={clientId}&state={state}&redirect_uri={Uri.EscapeDataString(redirectUri)}&response_type=code&access_type=offline&prompt=consent",
+ _ => string.Empty,
+ };
+
+ return authorizeUrl;
+ }
+
+ /// Verify the returned state (one-shot) before any code exchange.
+ public async Task> VerifyStateAsync(string provider, string state, CancellationToken ct = default)
+ {
+ provider = provider.ToLowerInvariant();
+ var key = RedisKeys.OAuthState(provider, state);
+ var returnUrl = await redis.GetDatabase().StringGetDeleteAsync(key);
+ if (returnUrl.IsNullOrEmpty)
+ {
+ return Error.Validation("Invalid or expired OAuth state.");
+ }
+
+ // Phase 1 will exchange the code for tokens and persist the connection here.
+ return returnUrl.ToString();
+ }
+
+ private static string Capitalize(string s) => char.ToUpperInvariant(s[0]) + s[1..];
+}
diff --git a/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs b/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs
new file mode 100644
index 0000000..2239fe9
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs
@@ -0,0 +1,19 @@
+namespace Hookline.Infrastructure.Connections;
+
+///
+/// Redis key-prefix registry. One shared Redis; every key is namespaced per owner so
+/// modules can't collide. Keep this in sync with docs/redis-key-prefixes.md.
+///
+/// - conn:* — Connections (OAuth state, transient caches)
+/// - auth:* — Auth (session/rate-limit helpers)
+/// - ytu:* — YouTube Uploads module
+/// - ytc:* — YouTube Comments module
+///
+///
+public static class RedisKeys
+{
+ public const string ConnectionsPrefix = "conn:";
+ public const string AuthPrefix = "auth:";
+
+ public static string OAuthState(string provider, string state) => $"{ConnectionsPrefix}oauth:{provider}:{state}";
+}
diff --git a/backend/src/Hookline.Infrastructure/DependencyInjection.cs b/backend/src/Hookline.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..dd7a0ac
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,207 @@
+using Hangfire;
+using Hangfire.PostgreSql;
+
+using Hookline.Infrastructure.Audit;
+using Hookline.Infrastructure.Auth;
+using Hookline.Infrastructure.Caching;
+using Hookline.Infrastructure.Connections;
+using Hookline.Infrastructure.Health;
+using Hookline.Infrastructure.Jobs;
+using Hookline.Infrastructure.Messaging;
+using Hookline.Infrastructure.Persistence;
+using Hookline.Infrastructure.Secrets;
+using Hookline.Infrastructure.Settings;
+
+using Hookline.SharedKernel.Audit;
+using Hookline.SharedKernel.Auth;
+using Hookline.SharedKernel.Caching;
+using Hookline.SharedKernel.Connections;
+using Hookline.SharedKernel.Jobs;
+using Hookline.SharedKernel.Messaging;
+using Hookline.SharedKernel.Modules;
+using Hookline.SharedKernel.Secrets;
+using Hookline.SharedKernel.Settings;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+using StackExchange.Redis;
+
+namespace Hookline.Infrastructure;
+
+public static class DependencyInjection
+{
+ /// Registers every shared service the modules and host consume.
+ public static IServiceCollection AddHooklineInfrastructure(this IServiceCollection services, IConfiguration config, IHostEnvironment env)
+ {
+ GuardSecurityConfig(config, env);
+
+ var postgres = config.GetConnectionString("Postgres")
+ ?? throw new InvalidOperationException("ConnectionStrings:Postgres is required.");
+ var redis = config.GetConnectionString("Redis")
+ ?? throw new InvalidOperationException("ConnectionStrings:Redis is required.");
+
+ // DevNoAuth is honored ONLY in Development. Outside it the flag is forced off so a
+ // stray config value can never disable auth (GuardSecurityConfig also refuses to boot
+ // if it is set), and IdentityMiddleware therefore never impersonates a dev admin.
+ var devNoAuth = config.GetValue("Auth:DevNoAuth") && env.IsDevelopment();
+
+ services.Configure(o =>
+ {
+ o.AdminToken = config["BackendAuth:AdminToken"] ?? string.Empty;
+ o.IdentitySigningKey = config["Identity:SigningKey"] ?? string.Empty;
+ o.DevNoAuth = devNoAuth;
+ });
+ services.Configure(o =>
+ {
+ o.AdminEmail = config["Bootstrap:AdminEmail"] ?? string.Empty;
+ o.AdminPassword = config["Bootstrap:AdminPassword"] ?? string.Empty;
+ });
+
+ // Security primitives (fail fast on missing keys).
+ services.AddSingleton(_ => new AesGcmSecretProtector(config["TokenEncryption:Key"]));
+ services.AddSingleton(_ => new IdentityTokenService(config["Identity:SigningKey"]));
+ services.AddSingleton();
+
+ // Redis (lazy, never aborts the app on a transient outage).
+ services.AddSingleton(_ =>
+ {
+ var options = ConfigurationOptions.Parse(redis);
+ options.AbortOnConnectFail = false;
+ return ConnectionMultiplexer.Connect(options);
+ });
+
+ // Prefix purge for the module data-reset path (best-effort; a cache outage never fails a reset).
+ services.AddSingleton();
+
+ services.AddHttpContextAccessor();
+ services.AddScoped();
+
+ void ConfigureContext(DbContextOptionsBuilder builder, string schema) => builder
+ .UseNpgsql(postgres, npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", schema))
+ .UseSnakeCaseNamingConvention();
+
+ services.AddDbContext(o => ConfigureContext(o, SharedDbContext.SchemaName));
+ services.AddDbContext(o => ConfigureContext(o, AuthDbContext.SchemaName));
+ services.AddDbContext(o => ConfigureContext(o, ConnectionsDbContext.SchemaName));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddHangfire(cfg => cfg
+ .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
+ .UseSimpleAssemblyNameTypeSerializer()
+ .UseRecommendedSerializerSettings()
+ .UsePostgreSqlStorage(opt => opt.UseNpgsqlConnection(postgres)));
+ services.AddHangfireServer();
+
+ services.AddHealthChecks()
+ .AddCheck("postgres")
+ .AddCheck("redis");
+
+ return services;
+ }
+
+ ///
+ /// Fails the boot in any non-Development environment if auth is weakened: DevNoAuth must
+ /// be off, and the required secrets must not be empty or a known dev placeholder. This is
+ /// the loud backstop behind the silent DevNoAuth gating in .
+ /// The required set is the three core auth primitives PLUS each module's Slack signing secret:
+ /// both modules always map their /slack/.../interactivity callback, and the verifier is
+ /// fail-closed, so an empty/placeholder signing secret would make every "Reject on YouTube" button
+ /// press 401 INVISIBLY in prod (boot stays clean, cards keep posting). Guarding it turns that silent
+ /// misconfig into a fast, loud boot failure.
+ /// internal (not private) so it can be unit-tested directly without standing up the DB.
+ ///
+ internal static void GuardSecurityConfig(IConfiguration config, IHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ return;
+ }
+
+ if (config.GetValue("Auth:DevNoAuth"))
+ {
+ throw new InvalidOperationException(
+ $"Auth:DevNoAuth=true is only allowed in Development (environment: {env.EnvironmentName}). Refusing to start.");
+ }
+
+ (string Key, string? Value)[] secrets =
+ [
+ ("TokenEncryption:Key", config["TokenEncryption:Key"]),
+ ("Identity:SigningKey", config["Identity:SigningKey"]),
+ ("BackendAuth:AdminToken", config["BackendAuth:AdminToken"]),
+ // Slack signing secrets — verify the interactivity callbacks (always mapped, fail-closed).
+ ("YouTubeUploads:Slack:SigningSecret", config["YouTubeUploads:Slack:SigningSecret"]),
+ ("YouTubeComments:Slack:SigningSecret", config["YouTubeComments:Slack:SigningSecret"]),
+ ];
+
+ foreach (var (key, value) in secrets)
+ {
+ if (string.IsNullOrWhiteSpace(value)
+ || value.Contains("change-me", StringComparison.OrdinalIgnoreCase)
+ || value == "dev-admin-token")
+ {
+ throw new InvalidOperationException(
+ $"{key} is missing or a known dev placeholder — refusing to start outside Development. " +
+ "Set a real value (auth primitives: `openssl rand -base64 36`; Slack signing secrets " +
+ "come from the Slack app's Basic Information page).");
+ }
+ }
+ }
+
+ /// The single auth gate (BFF token + signed identity). Place early in the pipeline.
+ public static IApplicationBuilder UseHooklineIdentity(this IApplicationBuilder app) =>
+ app.UseMiddleware();
+
+ ///
+ /// Migrate the shared contexts + every module's context under one advisory lock.
+ /// Throws (host fails to start) if any migration fails.
+ ///
+ public static async Task MigrateHooklineAsync(this IServiceProvider rootServices, IReadOnlyList modules)
+ {
+ await using var scope = rootServices.CreateAsyncScope();
+ var sp = scope.ServiceProvider;
+ var config = sp.GetRequiredService();
+ var postgres = config.GetConnectionString("Postgres")!;
+ var logger = sp.GetRequiredService().CreateLogger("Hookline.Migrations");
+
+ var contexts = new List
+ {
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ };
+ foreach (var module in modules)
+ {
+ if (module.Migrate(sp) is { } moduleContext)
+ {
+ contexts.Add(moduleContext);
+ }
+ }
+
+ await DbMigrator.MigrateAsync(postgres, contexts, logger);
+ }
+
+ /// Seed the first-run bootstrap admin (idempotent) after migrations.
+ public static async Task SeedBootstrapAdminAsync(this IServiceProvider rootServices)
+ {
+ await using var scope = rootServices.CreateAsyncScope();
+ await scope.ServiceProvider.GetRequiredService().BootstrapAsync();
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs b/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs
new file mode 100644
index 0000000..f99b745
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs
@@ -0,0 +1,40 @@
+using Hookline.Infrastructure.Persistence;
+
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+using StackExchange.Redis;
+
+namespace Hookline.Infrastructure.Health;
+
+public sealed class PostgresHealthCheck(SharedDbContext db) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
+ {
+ try
+ {
+ return await db.Database.CanConnectAsync(ct)
+ ? HealthCheckResult.Healthy()
+ : HealthCheckResult.Unhealthy("Postgres is unreachable.");
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy("Postgres check failed.", ex);
+ }
+ }
+}
+
+public sealed class RedisHealthCheck(IConnectionMultiplexer redis) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
+ {
+ try
+ {
+ await redis.GetDatabase().PingAsync();
+ return HealthCheckResult.Healthy();
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy("Redis check failed.", ex);
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj b/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj
new file mode 100644
index 0000000..5805e1d
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs b/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs
new file mode 100644
index 0000000..a5fd7b0
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs
@@ -0,0 +1,22 @@
+using Hangfire.Dashboard;
+
+using Hookline.SharedKernel.Auth;
+
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Hookline.Infrastructure.Jobs;
+
+///
+/// Gates the Hangfire dashboard behind real admin authorization. The identity middleware
+/// has already resolved for the request (the dashboard is not
+/// in the auth bypass allowlist), so only an Admin+ may view it — never anonymous/local-only.
+///
+public sealed class HangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter
+{
+ public bool Authorize(DashboardContext context)
+ {
+ var http = context.GetHttpContext();
+ var user = http.RequestServices.GetService();
+ return user?.HasAtLeast(UserRole.Admin) == true;
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs b/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs
new file mode 100644
index 0000000..e77791b
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs
@@ -0,0 +1,28 @@
+using System.Linq.Expressions;
+
+using Hangfire;
+using Hangfire.Storage;
+
+using Hookline.SharedKernel.Jobs;
+
+namespace Hookline.Infrastructure.Jobs;
+
+/// Hangfire-backed . Jobs are resolved from DI by type.
+public sealed class HangfireJobScheduler : IJobScheduler
+{
+ public void AddOrUpdateRecurring(string id, Expression> methodCall, string cron)
+ where TJob : notnull =>
+ RecurringJob.AddOrUpdate(id, methodCall, cron);
+
+ public string Enqueue(Expression> methodCall)
+ where TJob : notnull =>
+ BackgroundJob.Enqueue(methodCall);
+
+ public void RemoveRecurring(string id) => RecurringJob.RemoveIfExists(id);
+
+ public IReadOnlyList ListRecurring()
+ {
+ using var connection = JobStorage.Current.GetConnection();
+ return connection.GetRecurringJobs().Select(j => j.Id).ToList();
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs
new file mode 100644
index 0000000..10576de
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs
@@ -0,0 +1,34 @@
+using Hookline.SharedKernel.Messaging;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Hookline.Infrastructure.Messaging;
+
+///
+/// In-process event bus. Resolves all registered handlers for the event type and
+/// invokes them in a fresh scope. A failing handler is logged and does not stop the
+/// others. Swapping this for a real broker is how a module gets extracted later.
+///
+public sealed class InProcessEventBus(IServiceProvider services, ILogger logger) : IEventBus
+{
+ public async Task PublishAsync(TEvent @event, CancellationToken ct = default)
+ where TEvent : IntegrationEvent
+ {
+ using var scope = services.CreateScope();
+ var handlers = scope.ServiceProvider.GetServices>();
+
+ foreach (var handler in handlers)
+ {
+ try
+ {
+ await handler.HandleAsync(@event, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Handler {Handler} failed for {Event}",
+ handler.GetType().Name, typeof(TEvent).Name);
+ }
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs b/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs
new file mode 100644
index 0000000..4683ddf
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+using Npgsql;
+
+namespace Hookline.Infrastructure.Persistence;
+
+///
+/// Runs every context's migrations under a single Postgres advisory lock so concurrent
+/// host instances serialize and never race. If any migration fails the exception
+/// propagates — the host fails to start rather than serving a half-migrated schema.
+///
+public static class DbMigrator
+{
+ // Stable arbitrary key ("HOOKLINE" as ASCII bytes); positive bigint.
+ private const long AdvisoryLockKey = 0x484F_4F4B_4C49_4E45;
+
+ public static async Task MigrateAsync(
+ string connectionString,
+ IEnumerable contexts,
+ ILogger logger,
+ CancellationToken ct = default)
+ {
+ await using var lockConnection = new NpgsqlConnection(connectionString);
+ await lockConnection.OpenAsync(ct);
+
+ await using (var acquire = new NpgsqlCommand("SELECT pg_advisory_lock(@key)", lockConnection))
+ {
+ acquire.Parameters.AddWithValue("key", AdvisoryLockKey);
+ await acquire.ExecuteNonQueryAsync(ct);
+ }
+
+ logger.LogInformation("Acquired migration advisory lock.");
+ try
+ {
+ foreach (var context in contexts)
+ {
+ logger.LogInformation("Applying migrations for {Context}.", context.GetType().Name);
+ await context.Database.MigrateAsync(ct);
+ }
+ }
+ finally
+ {
+ await using var release = new NpgsqlCommand("SELECT pg_advisory_unlock(@key)", lockConnection);
+ release.Parameters.AddWithValue("key", AdvisoryLockKey);
+ await release.ExecuteNonQueryAsync(ct);
+ logger.LogInformation("Released migration advisory lock.");
+ }
+ }
+}
diff --git a/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs b/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs
new file mode 100644
index 0000000..119210b
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs
@@ -0,0 +1,41 @@
+using Hookline.Infrastructure.Auth;
+using Hookline.Infrastructure.Connections;
+using Hookline.Infrastructure.Secrets;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Hookline.Infrastructure.Persistence;
+
+// Design-time factories for `dotnet ef migrations add`. The connection string is a
+// placeholder — `migrations add` does not connect; it only generates the model diff.
+
+internal static class DesignTime
+{
+ public const string PlaceholderConnection =
+ "Host=localhost;Port=5432;Database=hookline;Username=hookline;Password=design-time";
+
+ public static DbContextOptionsBuilder Build(string schema)
+ where TContext : DbContext =>
+ new DbContextOptionsBuilder()
+ .UseNpgsql(PlaceholderConnection, npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", schema))
+ .UseSnakeCaseNamingConvention();
+}
+
+public sealed class SharedDbContextFactory : IDesignTimeDbContextFactory
+{
+ public SharedDbContext CreateDbContext(string[] args) =>
+ new(DesignTime.Build(SharedDbContext.SchemaName).Options);
+}
+
+public sealed class AuthDbContextFactory : IDesignTimeDbContextFactory
+{
+ public AuthDbContext CreateDbContext(string[] args) =>
+ new(DesignTime.Build(AuthDbContext.SchemaName).Options);
+}
+
+public sealed class ConnectionsDbContextFactory : IDesignTimeDbContextFactory
+{
+ public ConnectionsDbContext CreateDbContext(string[] args) =>
+ new(DesignTime.Build(ConnectionsDbContext.SchemaName).Options, new PassthroughSecretProtector());
+}
diff --git a/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs
new file mode 100644
index 0000000..63bd5f1
--- /dev/null
+++ b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs
@@ -0,0 +1,117 @@
+//
+using System;
+using Hookline.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hookline.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(SharedDbContext))]
+ [Migration("20260609161208_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("shared")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hookline.Infrastructure.Persistence.AppSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("key");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("Key")
+ .HasName("pk_app_settings");
+
+ b.ToTable("app_settings", "shared");
+ });
+
+ modelBuilder.Entity("Hookline.Infrastructure.Persistence.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)")
+ .HasColumnName("action");
+
+ b.Property("Actor")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("actor");
+
+ b.Property("ActorId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_id");
+
+ b.Property("Detail")
+ .HasColumnType("text")
+ .HasColumnName("detail");
+
+ b.Property("EntityId")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)")
+ .HasColumnName("entity_type");
+
+ b.Property