Conversation
- 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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.