Skip to content

First try #1

Merged
skyp1nus merged 32 commits into
mainfrom
dev
Jun 11, 2026
Merged

First try #1
skyp1nus merged 32 commits into
mainfrom
dev

Conversation

@skyp1nus

Copy link
Copy Markdown
Owner

No description provided.

skyp1nus added 30 commits June 9, 2026 16:38
- Next 16 (Bun, App Router) + shadcn radix-nova + Tailwind v4 oklch tokens
- ported design styles.css tokens (violet primary, status tones, dark default)
- typed lib/nav.ts single source for sidebar + breadcrumbs + command palette
- sidebar-07 shell: platform switcher (YT active, LinkedIn/Web disabled),
  collapsible tool groups, user menu; sticky header + breadcrumbs + bell + theme
- global command palette (cmdk), custom primitives (BrandMark, StatusBadge, Kbd,
  QuotaBar, charts) + full mock-data port
- Overview page (needs-attention, metrics, activity, connections health)
- Comments: Dashboard (stats+sparklines), Feed (filters+live), Channels grid, Mappings table
- Uploads: Queue (live 1s tick + JobCard states), History table, Mappings table
- Connections: Slack/Google account grids (OAuth connect links), API keys table (quota bars)
- System: unified Logs (tool/level filters), Settings (profile/team/alerts/danger zone)
- shared CommentCard / JobCard / ConnectionCard co-located per subtree
… omits it)

radix-nova's CommandDialog renders children directly without the cmdk
<Command> provider, so CommandInput/CommandItem crashed on open with
'reading subscribe' (undefined cmdk store). Wrap the content in <Command>.
- Hookline.slnx + CPM (Directory.Packages.props) + Directory.Build.props + global.json
- src/Hookline.Host (Web SDK, only runnable), SharedKernel, Infrastructure
- src/Modules/Hookline.Modules.Sample, tests/Hookline.ArchitectureTests
- reference graph: Infra→SharedKernel, Module→SharedKernel, Host→all, Tests→all
- pinned EF Core 10 / Npgsql 10 / Hangfire / Redis / Serilog / BCrypt / NetArchTest
- adds the authoritative architecture guide to docs/
- IModule (explicit-list module contract) + IJobScheduler abstraction
- IEventBus + IntegrationEvent base + connection disconnect events
- Connections accessors (ISlackConnections/IGoogleConnections/IConnectionCatalog,
  ConnectionRequirement, ConnectionType)
- ICurrentUser + UserRole(Owner/Admin/Member)/UserStatus, ISecretProtector,
  ISettingsStore, IAuditLog
- persistence: HooklineDbContext base (UTC), EncryptedStringConverter + IsEncrypted
- Common: Result/Result<T>/Error, PagedResult<T>/PageRequest
Infrastructure:
- AES-256-GCM protector [version|nonce|tag|ct], fresh nonce, fail-fast, no tag-swallow
- signed identity (HMAC, dedicated key) + X-Admin-Token BFF gate; bypass allowlist
  = /slack /google /linkedin /health; everything else requires identity
- users (bcrypt wf12) + bootstrap + race-safe Create-Owner (partial unique index)
- Connections store (schema connections, encrypted secrets) + OAuth state skeleton
- in-process EventBus, Hangfire scheduler, settings, audit, system principal
- Postgres+Redis health checks, advisory-lock DbMigrator (fail-fast)
Host:
- composition root: explicit module list, migrate under advisory lock, Serilog
  (correlation id + module), secured /hangfire (admin filter), /health, auth endpoints
Sample module: /api/_sample/ping + no-op recurring job, schema _sample
EF migrations (shared/auth/connections/_sample) + arch + security tests (14 green)
- docker-compose (postgres + redis + backend + frontend) + .env.example + no-auth toggle
- backend Dockerfile (.NET 10 multi-stage) + frontend Dockerfile (Next standalone, Bun build)
- docs/adding-a-module.md (1 project + 1 feature area + 1 nav entry, zero edits)
- docs/redis-key-prefixes.md (conn/auth/cb/st registry)
verified: compose up → migrations under advisory lock, /health 200, /api/_sample/ping 200,
bootstrap admin seeded, Create-Owner race-safe (2nd → 403; DB index blocks TOCTOU)
BFF (security boundary):
- app/api/[...path] proxy injects X-Admin-Token + mints signed identity server-side
  (HMAC, dedicated key, matches .NET format); strips cookie — never forwarded
- login/logout route handlers; httpOnly + Secure(prod) + SameSite=Lax session cookie
- middleware auth wall (edge-safe cookie const), NEXT_PUBLIC_NO_AUTH dev bypass
Auth screens: login + one-time Owner-bootstrap (drives Create-Owner); user-menu wired
  to real useMe + functional sign-out
Data-hook seam: every page now renders via typed use* hooks (features/*/hooks.ts) —
  mock today via mockFetch, queryFn swaps to api.get('/…') in Phases 1-2, components unchanged
CI: backend build + test (gates on arch tests) → frontend bun build; docker/deploy stubs
- extract SESSION_COOKIE_NAME to a dependency-free module so the Edge middleware
  doesn't pull node:crypto from session.ts (was an Ecmascript build error)
- backend /api/auth/me resolves the user's email from the store (the identity
  assertion carries id+role only) so the BFF/user-menu show the real user
verified e2e: login through BFF session, /me returns the user via verified identity,
Owner-bootstrap screen drives the one-time Create-Owner, second Owner rejected
…boundary test, naming

Independent Phase-1 audit follow-up. Addresses the two PARTIALs plus two
related cleanups; no schema migration.

- audit: centralize IAuditLog in JobService.TransitionAsync so every job
  state change is audited (cancel-while-queued, decline and startup
  recovery no longer slip through). Audit on all transitions; best-effort
  so an audit-store hiccup can't fail a transition. Remove the now-redundant
  per-call audit writes in UploadJobHandler. JobStateHistory unchanged.
- arch: de-vacuum Modules_do_not_reference_other_modules — add an
  assembly-reference guard that fails with a single module in the build,
  so a cross-module reference is caught before Phase 2 lands.
- naming: erase remaining SlackTube/st: drift from code, comments and the
  frontend (nav.ts ModuleId, Redis-key doc comments); correct the
  misleading "(SlackTube-compatible)" note on the AES-GCM protector — the
  version byte makes legacy ciphertext non-decodable.
- connections: soft-deactivate the Google account binding on disconnect
  (IsActive, now honored by rotation) instead of deleting it; record
  Slack/Google disconnects in the audit log. Channel mappings stay
  hard-deleted (rebuildable routing); the audit entry preserves the action.
- tests: add AES-GCM protector unit tests (round-trip, tamper, version
  byte, fail-fast) and JobService audit-emission tests.
…s), keep legacy framing

The guide is the Phase-2 source of truth, so it must not teach the pre-port
schema/route/prefix. Update the CURRENT/target sections to the real names —
module YouTubeUploads, schema youtube_uploads, Redis ytu:*, routes
/api/youtube-uploads/* — and mark Phase 1 as built on the shared kernel.
Name the future module YouTubeComments / youtube_comments / ytc:* /
/api/youtube-comments/*. References to the original apps now read as the
"legacy <name> source repo", never as Hookline's current naming. The Redis
prefix registry (Appendix B) is realigned to ytu:/ytc: and points at the
canonical docs/redis-key-prefixes.md. phase-0-findings.md is left as-is
(it is analysis of the original source app).
…red kernel (Phase 2)

Collapse the legacy .NET 8 Comment Bridge app (5 clean-arch projects) into ONE .NET 10 module Hookline.Modules.YouTubeComments on the shared kernel, behavior preserved, mirroring YouTubeUploads. Schema youtube_comments (own history + design-time factory, advisory-lock migrate), routes /api/youtube-comments/*, Redis ytc:*, provider OAuth /slack/youtube-comments/oauth/*. Per-mapping recurring poll + reply-sweep via the shared IJobScheduler, reconciled on boot (orphan-pruned); jobs run under the system principal writing the shared audit trail. Slack workspaces + YouTube API keys move into the shared Connections subsystem; secrets under the shared AES-GCM protector (re-OAuth fresh, NO secret-migration job). Legacy JWT/multi-admin retired in favour of the shared BFF session + ICurrentUser. Frontend ytc hooks swapped to the BFF; nav ModuleId comment-bridge -> youtube-comments.

Kernel add-backs (both approved): IYouTubeApiKeyConnections accessor (+ decrypted-key getter + YouTubeApiKeyDisconnected event) and a shared host-level audit-log read API (GET /api/system/logs?module=). IJobScheduler gains ListRecurring() (additive; uploads unaffected) so the module reconcile can prune orphan recurring jobs on startup.

DELIBERATE DEVIATIONS (surfaced for the Phase-2 review, not buried):

1. Uniform-quota simplification: the shared api_keys record has no per-key DailyQuotaLimit/IsValid/LastUsedAt, so all keys share a single YouTubeCommentsOptions.DailyQuotaUnits (default 10000) for remaining-quota rotation, and keys are validated at create time (400 + not stored if YouTube rejects) instead of carrying a stored validity flag. Per-key quota usage stays module-local in quota_usage (Pacific-day).

2. Disconnect-on-DELETE-only: YouTubeApiKeyDisconnected is published when a key is hard-DELETED (keys are a rotated pool, not bound to a mapping); the module handler prunes that key's quota_usage rows. Toggling a key inactive does NOT publish the event (the provider already filters on IsActive). Slack 'delete workspace' maps to ISlackConnections.DeactivateAsync, which deactivates the SHARED workspace (so it also affects YouTube Uploads) and fans out SlackWorkspaceDisconnected -> mappings deactivated.

3. ErrorsLast24h = 0 on the dashboard: the per-module 24h error count is no longer computed locally because audit moved to the shared trail (no Level column); error visibility is via the shared System->Logs page filtered by module=youtube-comments.

4. Dropped Polly on YouTubeClient: the legacy explicit Polly resilience pipeline is removed; the Google.Apis client library's built-in exponential backoff covers transient 5xx, quotaExceeded/commentsDisabled still bubble as GoogleApiException for the jobs to branch on, and a poll fully retries on its next tick. SlackClient was rewritten on a raw HttpClient (429/Retry-After loop), replacing SlackNet.

Other adaptations: OAuth CSRF state via httpOnly cookie ytc_slack_oauth_state (mirrors uploads, not Redis); CleanupJob trims only processed_comments (audit retention is the shared host's concern); legacy IAuditLogger(level,category,...) folded onto the shared IAuditLog via a module ICommentsAudit wrapper; frontend hooks are generic-typed BFF calls with live DTO-shape reconciliation deferred to the credentialed checkpoint (Feed renders the 24h timeline, true per-comment feed ticketed).

DoD: dotnet build 0/0; 89 tests pass (10 infra + 15 arch now covering 2 modules + 32 uploads + 32 comments) incl. cron-table, PT-day quota rotation, exactly-once dedup, Block Kit lc deep-link + ChannelGone classification, scheduler reconcile/prune; bun run build green; zero legacy-name references (YouTubeBridge/comment-bridge/slacktube/session_jwt/data_protection_keys) in new code/schema/routes/redis/frontend. Live credentialed pipeline is a separate checkpoint.
…l/quota minors

Backend mutation endpoints already existed; the gap was 100% frontend (no
mutation hooks). Wires the full write-path through the BFF and closes the three
review minors.

Write-path (the buttons were inert; now live):
- Mappings: create / edit / pause-resume / delete via modal dialogs.
- API keys: add (validate-on-create -> 400, never stored) / toggle / delete.
  The Connections -> Keys page is repointed from mock to
  /api/youtube-comments/keys (reuse of the Phase-0 component; key hooks/types
  live in features/comments).
- Channels: add by URL/@handle/id / remove.
- Every mutation invalidates the queries it changes (refetch); ProblemDetails
  surface as sonner toasts. Request/response DTOs typed 1:1 with the backend.
- The dynamic scheduler stays in sync via MappingService (create schedules the
  poll, delete removes it, toggle pauses/resumes, frequency reschedules,
  reply-sweep tracks IncludeReplies + sweep frequency); startup reconcile holds.

Minors:
- Audit level: kept the [Level] detail-prefix as the intended, queryable
  mechanism (no shared-schema column). Added AuditLevel constants + a single
  CommentsAudit.DetailPrefix() used by both the writer and the dashboard
  error-count reader so they can't drift; converted all 28 call sites off
  literals; documented the contract.
- DailyQuotaUnits: dedicated IValidateOptions validator (bounds 1..50M) +
  ValidateOnStart so a misconfig fails the host fast.
- Quota math: dashboard now sums today's usage over the same active-key set as
  the capacity denominator (was over all keys, incl. disabled -> could read
  >100%) and clamps the percent to 100.

Tests (cover the WIRE/SERIALIZATION layer only):
- MutationContractTests: every mutation request/response DTO serialized with the
  host's Web serializer asserts camelCase keys + numeric enum/number shapes.
- MappingSchedulerSyncTests: real MappingService -> HangfirePollingScheduler ->
  IJobScheduler chain proves create/delete add/remove the recurring job, toggle
  pauses/resumes, frequency reschedules, and reply-sweep add/remove/reschedule/off.
- QuotaMathTests + AuditAndOptionsTests: quota aggregation, audit prefix
  contract, options validation.
- Green: dotnet build 0/0; comments 69, uploads 32, infra 10, arch 15;
  bun run build + eslint (0 errors).

UNVERIFIED CHECKPOINT: no live credentialed click-through has been run. The
tests exercise only the wire/serialization layer (DTO shapes, query
invalidation, scheduler bookkeeping). Creating a real mapping/key/channel
against a live Slack workspace + a real YouTube API key — and confirming an
exactly-once post — is still pending an operator smoke-test.

Deferred (untouched): /hangfire prod-reach via Caddy, Next 16 middleware->proxy,
the Phase-0 bootstrap-password Warning log, and arch-rule reference-elision
hardening.
…rfile

The restore layer copied every project's csproj except the Phase-2
YouTubeComments module. Host.csproj references it, so `dotnet publish`
failed with NETSDK1004 (missing project.assets.json) — a blocker for both
the prod image and the CI docker job. Add the COPY so restore covers it.
Production-ready config for danielhub.dev, authored + verified locally, not
deployed:
- deploy/Caddyfile: shared-Caddy site block; providers + /hangfire -> backend,
  rest -> frontend BFF; automatic HTTPS. Closes the /hangfire reachability concern.
- deploy/docker-compose.prod.yml: external web net, pinned names, no host ports,
  Production, COOKIE_SECURE=true, GHCR images.
- deploy/.env.prod.example: every prod var (placeholders contain change-me so the
  boot guard fail-fasts on an unfilled copy).
- deploy/README.md: OAuth redirect-URI checklist + runbook + go-live sequence.
- ci.yml: docker + deploy jobs authored but gated off (if: false on docker;
  one-line flip to enable on main).
The local prod-profile smoke harness lives in deploy/local/ and is gitignored
(holds TEST secrets).
… mapping dialog

Cut the per-comment Feed and standalone Channels pages (page + route + nav +
breadcrumb + ⌘K, all driven off lib/nav). The Feed had no backend per-comment
endpoint; channel tracking now lives inline in the comment mapping dialog —
add by URL/@handle/id, auto-selected once resolved — so creating a mapping still
works end-to-end without the Channels page.

- nav.ts: remove ytc-feed + ytc-channels leaves and RouteId members
- delete comments/feed, comments/channels, add-channel-dialog
- comments dashboard: drop the nav buttons to the removed routes
- comments/hooks: drop orphaned useDeleteChannel (keep useCreateChannel, now
  used by the mapping dialog)
…hrefs

Replace mock connection lists and mock audit logs with real backend reads, and
repoint the OAuth Connect buttons at routes that actually exist.

- connections/hooks: real Slack workspaces (/youtube-uploads/slack/workspaces)
  and Google accounts (/google/accounts), plus disconnect mutations and a
  google-projects query; drop the hardcoded mock arrays
- connection-card: wire Disconnect (real DELETE + confirm dialog), derive the
  badge from connection state; Manage honest-disabled (no endpoint)
- slack/google pages: repoint Connect to /slack|google/youtube-uploads/oauth/start
  (the old /slack/oauth/start + /google/oauth/start 404'd); Google shows an honest
  'add a Google Cloud project first' state when no project exists
- system/logs: read GET /api/system/logs, map PagedResult<AuditLogRecord> -> the
  flat LogEntry rows (level from the folded [Level] marker), filter per-module
  server-side; replace the fake 'Streaming' label with an honest polling 'Live'
- system/hooks: drop useTeam (Team section removed); add NotYet honest-disable helper
…t actions

Make the Uploads write/action surface real where a backend exists, and stop
lying where it does not.

- uploads/hooks: add create/delete upload-mapping mutations + a mapping-options
  query (Slack channels + Google accounts) for the new dialog
- mappings: real 'Add route' dialog (POST /youtube-uploads/mappings) and Delete
  (DELETE + confirm); client-side Search; remove Edit + 'Open Slack channel'
  (no backend). Per-route Privacy is read-only and the Active toggle is
  honest-disabled — neither has a backend mutation (no PATCH on upload mappings)
- history: link the real video URL (was href='#'); remove View-log / Retry /
  Remove (no endpoints), honest-disable Export, drop the fake pagination
- overview: wire the inert Refresh button to a real refetch()
…backend

Cut the Profile and Team & Access sections (both fully mock/inert) and keep
Alerts + Danger Zone as wanted features — but honestly disabled, never faked,
since none of them has a backend yet. Destructive Danger Zone actions are
disabled rather than simulated.

- settings: remove Profile + Team & Access; Alerts switches and Pause-all /
  Reset / Delete buttons disabled with a 'not yet' tooltip
- mock-data: drop the now-orphaned DATA.unifiedLogs, DATA.team and TeamMember
- docs/backend-todo.md: spec the deferred endpoints + domain columns/migrations
  (upload-mapping Active toggle = P0, then alerts persistence, pause-all,
  per-route privacy, reset, delete-workspace)
Make the per-route Active/Pause control real (was an honest-disabled NotYet).
Uploads are event-driven, so a paused route is gated at ingest, not via a job:

- is_active on youtube_uploads.channel_mappings (migration, default true so the
  add-column backfills existing routes to active); IsActive on the entity.
- PATCH /api/youtube-uploads/mappings/{id} {active?} -> ChannelMappingService.UpdateAsync
  (persists + audits mapping.updated); MappingViewDto.Active now from the column.
- SlackIngestService reads IsActive fresh per message and enqueues nothing for a
  paused route -- restart-safe with no reconcile (no per-mapping job exists).
- Web: useUpdateUploadMapping + live row Switch (refetch + ProblemDetails->toast);
  per-route privacy shown as an honest read-only global-default label.
- Tests: pause/resume flips IsActive + the ingest gate skips a paused route.
Real Export buttons (were honest-disabled): the server streams text/csv applying
the same filters the page shows, over a wider window; the web client names the
download client-side (the BFF proxy forwards content-type, not content-disposition).

- Shared RFC-4180 writer SharedKernel/Common/Csv (quotes commas/quotes/newlines,
  CRLF rows) + CsvTests.
- GET /api/youtube-uploads/upload-history/export.csv?account=&status=&q= ->
  UploadsReadService.GetHistoryCsvAsync (account/status/title filters; status uses
  the same lowercased labels the History Select sends).
- Web: lib/download.ts blob download wired into the History + Logs Export buttons.

Note: the System logs/export.csv endpoint ships with the System panel commit
(it lives in SystemEndpoints alongside alerts + danger zone).
Wire the System Settings page to real, audited backends (Alerts + Danger Zone share
SystemEndpoints / DI / hooks / the settings page, so they land together).

Alerts (preference-only; delivery is still a separate unbuilt feature):
- GET/PATCH /api/system/alerts via the shared ISettingsStore (system:alerts:* keys,
  no new table; partial update; audited). Survives a restart. AlertSettingsTests.

Danger Zone — cross-module fan-out via a new SharedKernel IMaintenanceControl
(the host never names a module type, so the boundary arch tests keep holding):
- POST /api/system/pause-all: pause every active route/mapping in BOTH modules
  (comments also tears down its recurring poll + reply sweep).
- POST /api/system/reset: type-to-confirm "RESET" (400 otherwise). Operational-only
  wipe (jobs/history; dedup/pending/quota + advance comment watermark) + best-effort
  ytu:/ytc: Redis purge via the new ICachePurge. KEEPS mappings, settings, connections,
  secrets and the audit log; the system.reset entry is written after the wipe.
- Per-module fan-out is isolated (try/catch): one module failing never aborts the
  others, the host ALWAYS audits a PARTIAL outcome (+ which module failed), and the
  response carries partial/failed[] so the UI warns instead of faking full success.
- Also adds GET /api/system/logs/export.csv (filtered) — lives here with the panel.
- Tests: pause-all flips both modules; reset wipes only operational data and asserts
  connections/secrets/bindings survive.

Note: Danger Zone is gated on IsAuthenticated only (no role check) — fine for the
single-admin install; harden to Owner/Admin before multi-user (see backend-todo.md).
Close the "zero fake/disabled-forever controls" gap — these were live-but-dead
elements the NotYet sweep couldn't catch, plus a fabricated metric:

- UserMenu: drop the dead "Account" item (no page); wire "Settings" to /system/settings.
- SiteHeader: remove the Notifications bell + its fake "live" dot (no notifications
  backend to drive it).
- Dashboard: remove the hardcoded "prod-yt-02 at 87%" quota banner (fabricated; the
  real "Needs attention" card already surfaces live quota warnings).
- Connections: the "Manage" button was already cut in favour of Disconnect.
- docs/backend-todo.md: record the close-out + audit follow-ups (CSV window caveat,
  ICachePurge clarification, role-check note) and the still-deferred items.
…il-fast

The YouTube Comments Slack signing secret lived only in the local docker-compose; both prod templates omitted it. Unset in Production it defaults to "" and the fail-closed verifier 401s every "Reject on YouTube" interactivity press invisibly (boot stays clean, cards keep posting).

- Add YouTubeComments__Slack__SigningSecret to deploy/.env.prod.example and deploy/docker-compose.prod.yml (mirroring the Uploads lines).
- Extend GuardSecurityConfig to require BOTH modules' Slack signing secrets in non-Development; make it internal + add GuardSecurityConfigTests.

BEHAVIOR CHANGE: Production no longer boots monitoring-only with an empty Comments (or Uploads) signing secret — a forgotten interactivity secret now fails fast instead of silently 401-ing.
skyp1nus added 2 commits June 12, 2026 00:38
…ctor

The /slack interactivity callback bypasses the identity middleware, so the current user is anonymous and every moderation audit row recorded Actor="anonymous" — the real moderator survived only in the detail JSON.

Add an optional explicit actor to IAuditLog.WriteAsync (wins over the request principal), forward the previously-ignored ICommentsAudit actor param, and pass SlackActor.AuditActor ("@name (slack:id)") from the moderation service. System->Logs now shows who rejected a comment; the comment_moderations slack_user_id/name columns are unchanged.
CanModerateAsync was correct but had no callers, so the "Reject on YouTube" button rendered whenever a mapping existed — an account lacking the force-ssl scope only discovered it after clicking (NotConnected).

The card now shows the active Reject only when the owning Google account holds the scope; otherwise a proactive "Re-consent to enable removal" link to Connections -> Google. The poll / reply-sweep / delivery-retry jobs resolve CanModerateAsync once per channel. The click-time NotConnected error stays as a backstop.
@skyp1nus skyp1nus self-assigned this Jun 11, 2026
@skyp1nus skyp1nus merged commit f56794a into main Jun 11, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant