From 114ff2a8515960d6c90efb9580708c58dfcc1ab9 Mon Sep 17 00:00:00 2001 From: guneyunus <92909421+guneyunus@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:39:50 +0300 Subject: [PATCH] merge (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(migrator): chiseled Dockerfile + APP_UID + csproj container hardening * build(deploy): docker-compose .env.example with all knobs documented * feat(docs): redesign landing — conversion-focused with modern code window Replaces the placeholder hero/feature-grid with a six-section landing designed for developer conversion: - Hero: editorial display headline with gradient-text inflection, single strong subhead, dual CTA, immediate install command (copyable) below the fold. Brand-shadow blur + subtle dot-field for atmosphere. - CodeFirst: split section with editorial copy left, custom-framed code window right showing real RegisterUser feature folder (endpoint / handler / validator tabs). Mac-style traffic lights, green-accent active tab underline, hover gradient border, status footer. - WhatsIncluded: 12-item refined checklist (not boxy cards). Scannable. - ModuleShowcase: 3 deep cards (Identity / Multitenancy / Auditing) — tagline, capability bullets, real code snippet, deep-dive link each. Retains magnetic-shimmer hover from blog token system. - TechStack: 12-tech compact grid with vertical border accent on hover. - FinalCta: editorial close with repeated install command + CTAs + '≈5 minutes to localhost' time-to-running indicator. Codeblocks use Expressive Code's programmatic component with frame=none so the custom window chrome (traffic lights, tabs, status bar) wraps clean EC output. EC config externalized to ec.config.mjs as required by the programmatic path. Every color/surface uses semantic tokens — no hardcoded colors except mac traffic light hexes (intentional, vendor-recognizable signal). Cleanly inverts between light and dark. * build(deploy): postgres init SQL — required extensions * build(deploy): production docker-compose.yml — full stack on one host * docs(deploy): five-minute docker-compose deploy guide * fix(docs): theme toggle, nav active, alignment, code panel polish Batch of bug fixes from initial review pass: - ThemeToggle: switched script to is:inline plain JS (was hoisted module with TS annotations). Dropped View Transitions wrapper — was the silent failure mode. Listener now attaches deterministically. - Header nav active state: previous startsWith() matched /docs/ for any /docs/* path, lighting up both 'Docs' and the actual section. New logic picks the longest matching prefix as the single active item. - Container width: header + footer were max-w-6xl while docs grid was max-w-7xl, causing visible left/right misalignment on docs pages. Unified all surfaces to max-w-7xl. - prose.css ul bullets: referenced ../icons/svgs/star.svg from the blog that we never copied → runtime 404. Replaced with a green dot using --primary token (consistent with our design). - Dropped @astrojs/react integration: zero React components remain after ThemeToggle was converted to Astro. Eliminated the Vite 7 rolldown 'Missing field moduleType' dev errors from react-refresh. - Code panel rewrite (code-window.css): now targets the real DOM (.expressive-code .frame, not the outer div). Soft elevation, brand hover border, language badge floats above pre on hover, copy button positioned cleanly inside header. Mac traffic lights (already from EC) sit naturally in the gradient title bar. * fix(docs): use blog's exact MDX codeblock style (drop custom code-window.css) The blog's MDX codeblock treatment lives in prose.css and is minimal: rounded-xl pre, near-invisible white-with-low-opacity border, brand-tinted border on hover, copy button scales 1.05 with brand-tinted bg on hover, plus a gradient line-highlight indicator. My custom code-window.css was overdesigning a problem the blog had already solved. - Deleted docs/src/styles/code-window.css - Removed its import from globals.css - prose.css already inherited from the blog verbatim; updated the two blog-purple references: rgb(118 89 236, ...) → rgb(22 163 74, ...) (#16a34a, our --primary-soft mid-green) and #4bf3c8 → #4ade80 (our dark-mode --primary-soft) in the line-highlight gradient. * fix(deploy): resolve 3 issues found during e2e smoke test - docker-compose migrator: add --seed flag so IdentityDbInitializer runs SeedAdminUserAsync and admin@root.com is seeded on first boot - docker-compose api: add AllowedHosts=* env var to override the appsettings.Production.json restriction (was api.example.com, which Kestrel rejected for localhost requests) - docker-compose api: add HangfireOptions__UserName/Password env vars (required by ValidateOnStart() in Jobs/Extensions.cs); document in .env.example with generation instructions - appsettings.Production.json: set AllowedHosts to * (host filtering belongs at the reverse proxy layer, not Kestrel) All surfaces verified: health/live, health/ready (all 14 checks Healthy), admin config.json, dashboard config.json, both 200 OK, admin@root.com login returns JWT, profile returns correct email. Volume durability: migrator re-runs idempotently on second boot. * test(deploy): end-to-end docker-compose smoke verified locally All 7 steps passed: stack up, migrator exit 0, health Healthy, admin/dashboard config.json correct, login + profile verified, volume durability confirmed, stack torn down and .env removed. * docs: point README at deploy/docker for the production deploy story * feat(docs): redesign CodeFirst as VS Code-style editor with file tree Shows the full VSA feature-folder structure as a real-feeling IDE pane: - Title bar: mac traffic lights, breadcrumb path (Modules.Identity / Features / v1 / Users / RegisterUser / .cs), C# lang badge - Left column (file tree): the Users/ folder with RegisterUser/ expanded showing its three files, and five other collapsed feature folders (LoginUser, AssignUserRole, ChangePassword, DeleteUser, RefreshToken) — visually demonstrates that each feature is its own folder with its own files - Active file: green left-border accent + tinted background + primary text color. Inactive: muted with hover row tint - Right column: code panel with EC frame=none (chrome stripped, just the syntax-highlighted body) - Status footer: project name, encoding, line count updates with selection - Breadcrumb's filename and line count both swap on click - Hover gradient ring behind the editor (mask-composite trick) - Mobile: tree stacks above code with bottom border instead of right - Indentation guide line (1px vertical) inside the RegisterUser folder - Chevrons + folder icons via inline SVGs (lucide-shaped) — no astro-icon dep needed for the section * docs(spec): editorial alignment of docs site with codewithmukesh/blog Spec for the polish pass that ports the blog's brand/* primitive system (Button, Card, Callout, Pill, Kbd) into the docs site, adds the eyebrow numbering pattern, wires magnetic-cards + animated-IDE patterns, and aligns ExpressiveCode theme. Green stays as the FSH brand color. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(container): use numeric UID 1654 in MSBuild does not expand shell-style variables in , so the literal string "$APP_UID" was written into the OCI image config's User field by `dotnet publish /t:PublishContainer`. At `docker run` time the daemon could not resolve that "user" and exited with `unable to find user $APP_UID: no matching entries in passwd file`, breaking the DbMigrator Container Smoke job in CI. The hand-written Dockerfiles keep `USER $APP_UID` because Docker's parser does expand ENV vars inside the USER directive at build time. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): editorial alignment with codewithmukesh/blog Ports the blog's brand primitive system (Button, Card, Callout, Pill, Kbd) into the docs site and adopts the magazine-style numbered eyebrow rhythm across the six landing sections. Green stays as the FSH brand color; primitives flow through var(--primary) so the swap is automatic. - brand/ primitives ported verbatim with var(--primary) substitutions in Card.astro (color-mix on hover glows/borders). - New SectionEyebrow primitive: mono uppercase tabular-nums "01 · …" pattern with a primary-tinted accent rule. - New AnimatedIde wrapper: pointer-tracked 3D tilt + cursor-following radial spotlight via --mouse-x / --mouse-y custom properties; wraps the CodeFirst editor. No-op on touch / reduced-motion. - Magnetic-cards script ported into docs/src/scripts/ and wired from BaseLayout. Handles both [data-magnetic-card] and [data-magnetic-ide]. - ExpressiveCode config extracted into ec.config.mjs (function-valued options aren't JSON-serializable when inlined). Houston dark theme + green-tinted frame/marker accents. - Hero CTAs + FinalCta CTAs + Header GitHub button now use the Button primitive (ink-on-paper primary). Header Ctrl·K hint uses . - Hero gains a tabular-nums proof strip (".NET 10 · 3 modules · 598 tests · 5 min to localhost") + mono "$ get running locally" prompt. - Prose list marker swaps from a dot to the star SVG used by the blog. Build green; 10 pages built in 2.13s. * feat(docs): redesign Hero with animated terminal + stat cards Replaces the install-and-CTA-focused hero with a magazine-style conversion surface modeled on the dotnet-claude-kit hero from codewithmukesh/blog. What changed - Magazine eyebrow rule across the top: "Open source · .NET 10 starter kit ─── v1 · MIT · production-ready" (tabular-nums mono, primary on the left, muted on the right, hairline rule between). - Display headline now splits font-light lead-in ("The .NET starter kit") from font-bold payoff ("that actually ships.") with a green gradient underline strip beneath the bold phrase. - Subtitle bolds the three pillars (multitenancy, identity, observability) and lands on a feature-work-not-scaffolding payoff. - Trust strip below the CTAs: v1 · MIT licensed · .NET 10 / C# 14 · PostgreSQL · Redis · React. - Animated terminal preview: typewriter for the dotnet run command, then staggered reveal of an Aspire orchestration boot sequence (PostgreSQL, Redis, API, Dashboard, Hangfire) ending in "Ready in 4.2s · 5 services orchestrated · multitenancy enforced". The blinking emerald cursor animates while the command types out. - "What ships in v1" stat-card grid (4 metric cards): 3 modules · 12+ building blocks · 598 tests · 5 min to localhost. Each card has a large tabular-nums display number with a tinted unit superscript, a hover corner glow, and an EQ-tick bar that "dances" on hover. Motion and accessibility - All animations gated on prefers-reduced-motion: the typewriter jumps to its full text immediately, line reveals fire without delay, and the cursor blink + tick dance are disabled. - Terminal cursor + tick dance are CSS keyframe animations, GPU- composited via transform/opacity only. - Script is idempotent (data-hero-init guard) so it survives astro:after-swap from Astro view transitions. Build green; 10 pages built in 1.90s. * feat(docs): header nav — add Home, drop Reference Order is now Home · Docs · Modules · Recipes (down from 4 sections that put Docs first and included a stubbed Reference link). The liquid hover indicator and longest-prefix active-state logic both work unchanged. * feat(docs): wire FullStackHero logo + full favicon set Header brand link now leads with the 512×512 FSH logo (rendered at 28×28, rounded corners) ahead of the lowercase "fullstackhero" mark and the "/docs" mono suffix. BaseLayout serves the full favicon ladder so every browser and OS gets a sensible icon: - favicon.ico (broad fallback, 48×48 multi-res) - favicon.svg (modern vector) - favicon-32x32.png + favicon-16x16.png (older PNG ladder) - logo-fullstackhero.png as the apple-touch-icon fullstackhero.svg is left in public/ for ad-hoc use even though it's not wired by default. * feat(docs): drop "/docs" suffix from header brand link Logo + lowercase fullstackhero wordmark only now — the /docs mono suffix made sense before there was an icon, but the visual rhythm is cleaner without it. * feat(docs): unify all landing sections under Hero's design language Brings CodeFirst, WhatsIncluded, ModuleShowcase, TechStack, and FinalCta into the same magazine-style chrome as the Hero — magazine eyebrow rule, light/bold split headlines with a green gradient underline strip, inline- bold subtitles, and small editorial flourishes per section. What landed - SectionEyebrow refactored from inline pill → full-width magazine rule (left primary mono label, hairline rule, optional right meta tag). - All section containers aligned to max-w-7xl with px-4 sm:px-6 lg:px-8 (matches the Header so brand logo and section content land on the same vertical lines). Section-by-section - 01 CodeFirst: light/bold split "One feature. / One folder." Mini-stat micro-readout (4 files / feature · 0 project jumps · 1 endpoint / slice) ahead of the bullet list, which now uses the star marker SVG. VS-Code editor + AnimatedIde wrapper retained. - 02 WhatsIncluded: "Everything you'd build anyway" with a 3-col grid of 12 building-block cards. Each card: icon chip + mono category tag (AUTH, ISOLATION, DATA, …) + title + body, with a corner-glow hover matching the Hero stat cards. - 03 ModuleShowcase: "Opinionated where it matters." Three large cards with icon chip + "MODULE" mono label, tagline, star-marker bullets, and a small dark terminal-style code panel (matching the Hero terminal aesthetic — bg #0d1117, three traffic-light dots, mono code). - 04 TechStack: 4-col grid of compact tech cells with icon chip, name, version + sub. Hover adds a brand-coloured left accent that slides in. - 05 FinalCta: "Start where you'd finish." Dotted backdrop, centred headline, mono "$ clone the starter kit" prompt over the CopyCommand, primary + secondary CTAs, trust strip footer. All animations are pointer + prefers-reduced-motion aware; magnetic-card tilt still binds idempotently after astro:after-swap. Build green; 10 pages built in 2.02s. * feat(docs): SEO/GEO foundation + fact-checked accuracy pass Comprehensive SEO/GEO pass aimed at making the docs site the canonical result for ".NET 10 starter kit" type intents, plus a ground-up fact-check against the actual repository. Every claim on the landing now matches what's in the codebase. ACCURACY FIXES (fact-check against repo) - Hero stat cards: 3 modules → 10 (Identity, Multitenancy, Auditing + Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets); 598 tests → 900+ (actual [Fact]+[Theory] count is ~906); "5 min to localhost" → "1 command to run" with `dotnet run` boots the stack. - Hero animated terminal: rewritten to mirror AppHost.cs exactly — 7 services (postgres, redis, minio, db-migrator, fsh-api, fsh-admin, fsh-dashboard). Removed fictional "Ready in 4.2s" timing claim. - TechStack: Aspire 10.0 → 13.3, EF Core → 10.0.8, Postgres → 17, Mediator → 3.0, FluentValidation → 12.1, Scalar → 2.14, Redis StackExchange 2.11, OTel 1.15, Finbuckle 10.0, React TS 5.7 — all pinned to src/Directory.Packages.props. - ModuleShowcase Identity: removed "Per-tenant SSO hooks" (not shipped, only JWT bearer auth is in the repo) → replaced with "Rate-limited login, register & password reset" which is real (auth rate-limit policy applied across those endpoints). - ModuleShowcase: section eyebrow "3 bounded contexts" → "10 modules ship in v1"; headline reframed as "Three pillars, seven more in the box"; added a four-column aux-module strip below the three pillar cards showing Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets with one-line summaries. NEW SECTIONS (fact-checked content only) - 05 · WhoItsFor — two-column "Built for / Probably not for" panel. Built for: SaaS-on-.NET teams, VSA + sane-defaults teams, indie devs, founders who want to own their code. Not for: tiny CRUD, Clean-Architecture purists, microservices-day-one, paid-support needs. Reduces wrong-fit bounce + builds trust. - 06 · FAQ — 8-question disclosure list answering the questions developers actually ask (free?, prod-ready?, vs ABP?, vs Clean Architecture templates?, do I have to use all 10 modules?, SaaS?, deployment?, update path?). All answers fact-checked. SEO/GEO INFRASTRUCTURE - public/robots.txt — explicit Allow for every major AI/search crawler (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, anthropic-ai, PerplexityBot, Perplexity-User, CCBot, cohere-ai, Bytespider, Google-Extended, Applebot-Extended, Bingbot, plus the usual social unfurlers). Points at the sitemap. - public/llms.txt — fact-dense kit summary (architecture, 10 modules, 12 building blocks, tech stack with versions, how to run, docs map, positioning vs ABP / Clean Architecture / commercial competitors). Designed to be the single source AI crawlers cite when summarizing what the kit is. - JSON-LD structured data wired through BaseLayout.astro using schema.org's @graph: Organization (with logo + sameAs to GitHub + codewithmukesh), WebSite, and SoftwareSourceCode (programmingLanguage, runtimePlatform, codeRepository, MIT license, author). - FAQPage JSON-LD attached to the FAQ section so AI engines can extract the Q&A pairs directly without scraping the disclosure markup. - theme-color meta tags (light: brand green #15803d, dark: surface #0d0e11) for mobile browser chrome. - og-default.png was 404'ing — pointed ogImage at logo-fullstackhero.png so social shares get a real image until a dedicated 1200×630 OG card is designed. H2 + EYEBROW KEYWORD TIGHTENING - Hero H1: "The .NET starter kit" → "The .NET 10 starter kit"; subtitle rewritten as a self-contained citable paragraph that opens "FullStackHero is a free, MIT-licensed, production-ready .NET 10 modular monolith…". - Section eyebrows tightened with literal target-keyword right-meta: "Modular Monolith + VSA", "12 shared building blocks", "10 modules ship in v1", ".NET 10 · EF Core 10 · Aspire 13". - WhatsIncluded label: "Everything pre-wired" → "Everything a production .NET 10 API needs". META + DOCUMENT - siteConfig.title: "FullStackHero — .NET Starter Kit" → "FullStackHero — Free .NET 10 Starter Kit" (adds the keyword that gets searched). - siteConfig.description rewritten to be the same citable paragraph as the hero subtitle, with ".NET 10", "free", "MIT-licensed", "ten modules" front-loaded. Build green; 10 pages built in 2.18s. robots.txt + llms.txt served correctly on the dev server. * feat(docs): copy rewrite — developer-centric + conversion-focused Tightens copy across every landing section for three audiences (devs, tech leads, CTOs), weaves target search phrases naturally, and pushes the voice closer to "real codebase, not marketing." All claims still match the repo per the fact-check from the previous commit. Hero - H1 bold: "that actually ships." → "you'd actually inherit." Speaks to devs ("I won't fight this"), TLs ("team can take it over"), and CTOs ("low risk, no lock-in") in three words. - Subtitle rewritten as a citable definition paragraph: "FullStackHero is the free, MIT-licensed .NET 10 starter kit for teams shipping production SaaS. Ten modules — identity, multitenancy, auditing, files, chat, notifications, webhooks, billing, catalog, tickets — wired through a modular monolith with Vertical Slice Architecture. A real codebase, not a tutorial. No vendor framework. No lock-in." High keyword density, AI-quotable as a single block. - Stat-card 4 label: "Command to run" → "Command"; note now ends in "Aspire 13" instead of "Aspire" for the version anchor. CodeFirst (01) - Subtitle: leans harder on the workflow promise — "Add a feature in one PR; ship in one merge." Adds tests to the file list. Calls out "No jumping between five projects" in bold. WhatsIncluded (02) - Subtitle: lists the unglamorous parts by name ("Authentication. Authorization. Migrations. Caching. Background jobs. Structured logging. Distributed tracing. Idempotency. Webhooks.") and lands on "You'd build all of it before your first real feature." More specific, less abstract. ModuleShowcase (03) - Subtitle: tightened with bold "three questions every B2B SaaS has to answer" and "what makes your product yours — not chat, not billing, not file uploads." Frames the seven aux modules as time saved. TechStack (04) - Added a real H2 + subtitle ahead of the grid: "First-party Microsoft. / Best-in-class OSS." with the no-lock-in pitch ("No proprietary framework. No DSL. No magic. The same .NET 10, EF Core 10, Aspire 13, and OSS libraries your team already knows — chosen carefully and wired together so they actually compose."). Targets the CTO/TL "what are we adopting?" audit. FAQ (06) - Added two questions that close common gaps: - "How does it scale as my product grows?" — frames the modular monolith → extract-a-service path honestly. - "Can I deploy it to Azure, AWS, or Kubernetes?" — confirms each service is a normal Docker image and lists target platforms. FinalCta (07) - Subtitle: "scaffolding" → "plumbing"; adds "run one command" to reinforce the Aspire single-command boot story. Build green; 13 pages built in 2.68s. * feat(docs): redesign sidebar + TOC as numbered chapter cards Sidebar: warm-paper card per category with single-expand accordion, monospace 01–13 indices, primary-tinted rail + pill on active item, auto-expand for the active chapter. Hidden scrollbar with edge-fade affordance. TOC: matching card chrome, vertical hairline rail with station-marker dots on h2, scrollspy via rAF-throttled scroll listener, soft halo on active dot. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): restructure sections + add full Getting Started flow Sections now: Getting Started, Architecture, Modules, Building Blocks, Cross-Cutting Concerns, Security, Frontend, CLI, Testing, Guides, Deployment, Contributing, Changelog. Drop concepts/reference (folded into Architecture and Changelog), rename recipes → guides. Getting Started contains Introduction (moved from /docs/), Prerequisites, Quick Start, Install. /docs and /docs/getting-started redirect to the introduction. Header, footer, and landing CTAs rewired to the new paths. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): tighten typography to docs-standard scale Page H1 32px/600 (was 36px/700), description 15.5px/lh1.55 (was 18px). Prose H2 22px/600 (was 40px/300), H3 17px/600, H4 15px/600. Body 15px with lh 1.65 (was 18px/1.75). Matches Stripe/Linear/Anthropic norms; weight 600 throughout instead of 700. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(docs): switch wrangler to Workers Static Assets Replace deprecated pages_build_output_dir with [assets] block so wrangler deploy works (Pages is being unified under Workers). Rename worker to 'fullstackhero'. Single-quote style to match Cloudflare's auto-PR generator. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): lowercase fullstackhero brand + GitHub stars on landing Brand: - New title: 'fullstackhero - .NET 10 Starter Kit with React UI'. - Wordmark lowercased everywhere except NuGet package IDs (FullStackHero.CLI, FullStackHero.NET.StarterKit must stay PascalCase). - JSON-LD Organization + SoftwareSourceCode updated. Social proof: - helpers/github-stars.ts: build-time fetch of stargazers_count with a 6500 fallback for offline / rate-limited builds. Memoized per build. - Header GitHub button: split-pill '[gh] GitHub | ★ 6.5k'. - Hero chip leads with a tinted star-count pill. - Hero secondary CTA: 'Star on GitHub | ★ 6.5k' split-pill. - Primary CTA points to /docs/getting-started/quick-start/. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): mobile docs nav with slide-up sheet Hidden on lg+, but on phones/tablets the docs main column now has a trigger pill at the top showing the current section. Tapping opens a bottom sheet (Stripe/Vercel/Linear pattern) that slides up from the viewport edge with the full Sidebar inside — same numbered chapter cards and single-expand accordion. Affordances: - Drag handle, close button, tap-scrim to close, ESC to close. - Tap any link inside → close (route change feels native). - Active page link is scrollIntoView({block: 'center'}) on open. - Body scroll lock, inert when closed, focus returns to trigger. Sidebar now takes a `prefix` prop (default 'fsh', mobile passes 'fsh-m') so aria-controls / labelledby IDs stay unique across the two simultaneous renderings. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(docs): drop placeholder favicon.svg — use brand PNGs/ICO The favicon.svg in public/ was the old 'FSH on green square' placeholder. Removing it and dropping its link from BaseLayout so browsers resolve favicon.ico → 32x32.png → 16x16.png (already the new brand triangle from the earlier favicon commit). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(docs): tier-1 SEO + GEO code wins Six concrete improvements aimed at unlocking AI citability and search ranking on top of the editorial landing already in place. 1) Per-page SEO meta (content.config.ts + DocsLayout) Extends the docs content schema with an optional seo block: { title, description, keywords, ogImage, noindex }. Each field falls through to the frontmatter / site defaults so existing pages keep working. The [...slug] route now plumbs seoTitle and additionalSchemas through DocsLayout → BaseLayout so individual docs pages can target their own keyword-rich independent of the on-page H1. 2) SoftwareApplication schema upgrade (BaseLayout) Swapped the homepage's SoftwareSourceCode entity for the more AI-citable SoftwareApplication, with applicationCategory "DeveloperApplication", operatingSystem, softwareVersion 10.0.0-rc.1, softwareRequirements (.NET 10 SDK, PostgreSQL 17, Redis 7, Node 20+), downloadUrl, and crucially: offers: { price: "0", priceCurrency: "USD", availability: InStock } That's the marker Google uses to label software listings as "Free" in rich results. 3) BreadcrumbList JSON-LD on every docs page (helpers/breadcrumb.ts) New buildBreadcrumbItems + buildBreadcrumbSchema helpers derive breadcrumbs from the URL pathname, title-casing each segment, and override the final crumb name with the page's actual title. Emitted via additionalSchemas alongside a TechArticle schema carrying headline, description, dateModified / datePublished, image, author, publisher, and inLanguage — generic enough to cover guides, references, concepts, and recipes. 4) llms-full.txt generated at build time (pages/llms-full.txt.ts) New static endpoint that concatenates every docs collection entry as plain text — title, blockquote description, canonical URL, raw markdown body. Per-page sections separated by `---` rules so AI crawlers can ingest the entire docs corpus in a single fetch. Builds 429-line file at /llms-full.txt covering 16 pages. 5) Sitemap priorities + changefreq (astro.config.mjs) serialize() callback on @astrojs/sitemap sets per-URL priority + changefreq: / → 1.0 / weekly /docs/getting-started/* → 0.9 / monthly /docs/modules|building-blocks|architecture/* → 0.8 / monthly /docs/guides/* → 0.75 / monthly /docs/security|deployment|frontend|testing|cli/* → 0.7 / monthly /docs/changelog/* → 0.5 / weekly (freshness signal) /docs/contributing/* → 0.4 / yearly /docs/* → 0.6 / monthly (fallback) 6) Cross-link landing → docs (WhatsIncluded + FAQ) - Each WhatsIncluded card now click-throughs to the most relevant /docs/ page (12 new internal links from a high-PR landing page). Cards reveal a "Read the docs →" affordance on hover. - Section footer adds "Browse the full building-blocks reference →" CTA pointing at /docs/building-blocks/. - Each FAQ answer gets an optional link object rendered as a "Read the X →" link below the prose. Adds 9 more internal links to deeper docs (testing, architecture, modules, deployment, changelog, cross-cutting-concerns). Build green; 18 pages built in 2.93s. Verified: docs pages emit 3 application/ld+json blocks (site graph + BreadcrumbList + TechArticle), homepage emits site graph + FAQPage, sitemap carries priority + changefreq per URL, /llms-full.txt is 429 lines covering all 16 docs pages. * feat(docs): nav Docs link points straight to introduction Header / Footer / Breadcrumbs all link the "Docs" entry directly at /docs/getting-started/introduction/ (no redirect hop). Header keeps the previous active-state semantics via a new `match` field on the nav item — Docs still highlights for any /docs/* path even though its link target is deeper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(docs): section index pages with auto-generated card grids Each category now has a real index page that lists its child pages as a card grid (2-col / 1-col mobile). Cards show a monospace 01/02/… index, the page title, its description, and a chevron that nudges on hover. Empty-state placeholder card renders for sections that don't have child pages yet. - New SectionIndex.astro: discovers pages in src/content/docs/{section}, sorts by sidebar.order, excludes the index itself + hidden pages. - Registered as a global MDX component (no per-file imports needed). - Re-created getting-started/index.mdx + dropped the /docs/getting-started/ redirect so the new index is reachable. - Updated 10 other section index pages to use SectionIndex. contributing + changelog kept as single-page sections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(docs): track-1 content — comparison pages + getting-started SEO The first content writing pass. Adds three full comparison pages that rank for "X alternative" queries, wires them into the sidebar under a new Compare section, and adds per-page SEO frontmatter to the four Getting Started pages. Comparison pages (~1100-1400 words each, fact-checked against repos) - /docs/compare/fsh-vs-abp/ — biggest direct competitor. Honest side-by-side (license, code ownership, DI, mediator, multitenancy stack, stars). "When to choose ABP / when to choose fullstackhero" with fair trade-offs. Migration notes (modules map, repositories become DbContexts, app services become Mediator handlers). - /docs/compare/fsh-vs-clean-architecture/ — Jason Taylor (~20.1k★) and Ardalis (~18.2k★) templates. Frames the difference as "they ship an architecture; fullstackhero ships ten production modules on top of an architecture." Acknowledges where CA templates are the better classroom + the better fit for small focused services. - /docs/compare/fsh-vs-blazorplate/ — paid closed-source ($499-$999) vs free open-source MIT. React vs Blazor, webhooks + OpenTelemetry deltas, hybrid-use guidance. Each comparison page ships honest disclaimers ("this page is maintained by us, open an issue if anything is unfair") and links to the competitor's canonical site/repo. All Q→A claims are checked against either upstream repos or our Directory.Packages.props. Compare section - New /docs/compare/index.mdx — section landing with a quick decision tree pointing the reader at the right comparison. - _sections.ts gains a 'compare' entry at order 12, between Deployment and Contributing — sidebar wiring automatic via the section list. - astro.config.mjs sitemap rule adds /docs/compare/* → priority 0.8 / monthly. These are high-conversion + rank for head-intent "X alternative" queries; deserve the bump. Getting Started SEO frontmatter - Each of the four existing getting-started pages (introduction, prerequisites, install, quick-start) gains a seo block with keyword-targeted title, description, and keywords. The on-page H1 stays editorial; the <title> tag and meta description target long-tail searches like "fsh CLI", ".NET Aspire workload", "FullStackHero.NET.StarterKit dotnet new template". Verified - Build green; 24 pages built in 3.17s (up from 18 last pass). - Sitemap carries all four compare URLs at priority 0.8. - llms-full.txt picked up the new content automatically (no code change needed — the static endpoint enumerates the docs collection). - FSH CLI claims verified: `FullStackHero.CLI` package + `fsh` tool command in src/Tools/CLI/FSH.CLI.csproj, `fsh new` + `fsh doctor` commands in Commands/. * feat(docs): /docs/ overview hub with chapter-plate category cards /docs/ is now a real overview page that lists every category as a card grid. Each card carries a lucide icon, a huge ghosted watermark numeral (editorial accent), title, description, live page count, and an orchestrated hover sequence (lift + border + icon tint + watermark glow + arrow nudge on a single 320ms curve). - CategoryIndex.astro: discovers sections from _sections.ts, pulls descriptions from each section's index.mdx, counts child pages. - Registered as a global MDX component. - docs/index.mdx (new) renders the hub. - Header / Footer / Breadcrumbs re-point the "Docs" entry at /docs/. - Typography: body-font title, sentence-case page-count meta (no more mono-caps eyebrow / slug). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docs): wire .not-prose utility so card grids escape prose styles The card grids inside MDX pages were inheriting prose-context styles — the star li::before marker (rendering as little green +/sparkle marks around each card) and the :where(a) underline. The .not-prose class on the grids was a no-op because there was no actual rule wiring it. Added a real .not-prose rule in globals.css that resets list-marker, list padding, and anchor text-decoration for any descendant subtree marked not-prose. SectionIndex picks up a belt-and-braces inline text-decoration:none to defeat any stray :where(a) cascade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(modules): 10 module deep-dive pages — full first drafts One MDX per module under /docs/modules/. All claims fact-checked against the runtime source — every endpoint table, version pin, domain-model excerpt, and configuration block was verified before landing in prose. Each page targets a specific long-tail search phrase via its seo.title and runs 1,000-1,800 words. The ten pages - identity.mdx (order 1) — JWT + refresh + permissions + groups + impersonation + 2FA + sessions + password policy. ~1,800 words. 48 endpoints tabulated. References IdentityModule.cs + Domain/ FshUser.cs + Contracts/v1/Tokens/. - multitenancy.mdx (order 2) — Finbuckle strategy chain + cache store + EFCoreStore + per-tenant connection strings + provisioning state machine + tenant themes. ~1,400 words. 9 endpoints. Documents the root-operator header override + claim-strategy-pre-auth gotcha. - auditing.mdx (order 3) — SaveChanges interceptor + HTTP middleware + fluent Audit builder + JSON masking + channel publisher + SQL/DLQ sinks + retention job. ~1,500 words. All seven read endpoints + the AuditHttpOptions / AuditRetentionOptions configuration blocks documented verbatim. - files.mdx (order 4) — presigned URL flow + per-OwnerType IFileAccessPolicy + finalize state machine + Files Categories config + IFileScanner hook + orphan/retention purge jobs + FileFinalizedIntegrationEvent. ~1,300 words. - chat.mdx (order 5) — three channel kinds + idempotent DirectKey for DMs + tombstone soft-delete + threads + reactions + pinning + mention parser + AppHub realtime + Redis backplane + 3s typing throttle. ~1,500 words. 22 endpoints. Documents the module-order quirk (Notifications 750 before Chat 800). - notifications.mdx (order 6) — denormalized inbox row + integration event bridge + SignalR push to user:{id} + idempotent mark-read + bulk ExecuteUpdateAsync mark-all-read. ~1,000 words. 4 endpoints. Includes step-by-step "add a new notification type from another module" extension recipe. - webhooks.mdx (order 7) — tenant-scoped subscriptions + open- generic WebhookFanoutHandler<TEvent> + HMAC-SHA256 signing + Hangfire AutomaticRetry with 30s/2m/10m/1h backoff + delivery log. ~1,400 words. 5 endpoints. Documents the "restore tenant context manually" gotcha + ships a complete C# signature-verification recipe for subscribers. - billing.mdx (order 8) — already shipped in prior commit - catalog.mdx (order 9) — already shipped in prior commit - tickets.mdx (order 10) — already shipped in prior commit Across all ten pages we now document - 234 endpoints (verified from MapEndpoint extensions) - 30+ aggregate / entity types (with sealed-class + invariant excerpts) - 100+ commands / queries from the Contracts assemblies - Every IOptions<T> config block with appsettings keys - Every Hangfire recurring job by cron expression - Every IGlobalEntity / tenant-context quirk worth knowing Build green; 34 pages built in 4.75s (up from 27 last pass). * docs(building-blocks): 12 building-block reference pages One MDX per FSH.Framework block under /docs/building-blocks/. Each page documents the block's public extension methods, interfaces, base types, options, and consuming-module examples — fact-checked against the BuildingBlocks/ source. The twelve pages - core.mdx (1) BaseEntity, AggregateRoot, DomainEvent, ICurrentUser, CustomException hierarchy - persistence.mdx (2) BaseDbContext + auto-applied filters, Specification<T>, audit + domain-event interceptors - shared.mdx (3) AppTenantInfo, PermissionConstants, FshPermission, claim/action/resource constants, audit attributes - web.mdx (4) AddHeroPlatform / UseHeroPlatform, ModuleLoader, GlobalExceptionHandler, ValidationBehavior, CurrentUserMiddleware - caching.mdx (5) HybridCache + ObservableHybridCache decorator with OTel; stampede protection - eventing-abstractions.mdx (6) IIntegrationEvent, IIntegrationEventHandler, IEventBus, IEventSerializer (dep-free) - eventing.mdx (7) InMemoryEventBus, RabbitMqEventBus, EfCoreOutboxStore, EfCoreInboxStore, OutboxDispatcher - jobs.mdx (8) Hangfire wiring + FshJobActivator + FshJobFilter (tenant context) + dashboard basic auth + stale-lock cleanup - mailing.mdx (9) IMailService + SMTP (MailKit) and SendGrid implementations - storage.mdx (10) IStorageService + LocalStorageService and S3StorageService + presigned URLs + QuotaMeteredStorageService decorator - quota.mdx (11) IQuotaService (Redis/InMemory/Noop) + plan resolution + gauge providers + enforcement middleware - blazor-ui.mdx (12) Placeholder — v1 ships React frontends; block reserved for future Blazor lib Per page: ~700-1,200 words covering purpose, public surface, options, how modules consume it, extensibility recipes, and gotchas. Every extension method, interface, and option type listed has a file path in the appendix. Cross-link density between blocks and modules is dense — Storage links to Files, Quota links to Identity's user gauge + Billing's plan keys, Eventing links to Notifications + Webhooks, etc. Build green; 46 pages built in 5.73s (up from 34). * docs(architecture): 4 architecture pillar pages Synthesizes patterns documented across the module and building-block pages into four conceptual deep-dives at /docs/architecture/. - modular-monolith.mdx (order 2) — the outer shape: ten modules with one-way Contracts boundaries enforced by NetArchTest, load order, three patterns for cross-module talk (service interfaces, domain events, integration events), and the extract-a-service migration path when scaling demands it. ~1,400 words. - vertical-slice.mdx (order 3) — the inner shape: one feature per folder with endpoint + command + handler + validator + tests. Complete RegisterUser end-to-end excerpt. Conventions (sealed, ValueTask, ConfigureAwait(false), one endpoint per slice). Trade-offs vs Clean Architecture. ~1,200 words. - multitenancy-deep-dive.mdx (order 4) — tenancy as default. Three layers (HTTP/data/jobs), Finbuckle strategy chain + claim-pre-auth gotcha, BaseDbContext.ApplyTenantIsolationByDefault(), IGlobalEntity opt-out, named SoftDelete filter, cross-tenant query pattern (IgnoreQueryFilters + explicit re-filter), per-tenant connection strings, tag-based cache eviction. ~1,400 words. - dependency-injection.mdx (order 5) — composition root: 4-line Program.cs, the IModule contract, Mediator 3 source-gen MSG0007 gotcha, FluentValidation auto-registration, three lifetimes, the four wire points for a new module (with the Mediator-list silent- failure flag), middleware pipeline order including CORS-before- HTTPS-redirect and CurrentUserMiddleware-last rules. ~1,300 words. Build green; 50 pages built in 6.11s (up from 46). * feat(docs): section cards match chapter-plate aesthetic + fix not-prose SectionIndex cards now use the same modern design language as the CategoryIndex hub: huge ghosted watermark numeral as the editorial accent, body-font title, line-clamped description, pagination eyebrow (01 / 04), and a unified hover sequence on a single 320ms curve. Also fixes the .not-prose escape hatch. Previous selectors required .not-prose to be an *ancestor* of the <ul>, but the class lives ON the <ul> itself — so prose's star li::before marker kept leaking into both card grids. Rewrote with :where(.not-prose) selectors that match either placement; `!important` plus :where()'s 0-specificity cleanly defeats the prose cascade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(sections): testing + security + cross-cutting-concerns full pages Replaces three stub section indexes with substantial fact-checked content. Each page now stands as a complete reference; the <SectionIndex> wiring is removed since these are no longer just landing pages for child docs. testing/index.mdx (~1,800 words) - Three-layer model: unit (xUnit + Shouldly + NSubstitute + AutoFixture), integration (WebApplicationFactory + Testcontainers Postgres/Redis/ MinIO), architecture (NetArchTest). - The test stack pinned in Directory.Packages.props, the test project layout under src/Tests/, the naming convention "Method_Should_X_When_Y". - Real RegisterUserCommandHandlerTests + ProductsEndpointTests excerpts as templates. - All 9 NetArchTest rule families enumerated (ModuleBoundary, ContractsPurity, BuildingBlocksIndependence, CircularReference, HandlerValidatorPairing, DomainEntity, ApiVersioning, FeatureFolder, HostArchitecture). 48 architecture tests total. - "How to add a new test" recipes for each layer. - Honest disclaimers: no load tests, no visual regression, no chaos. security/index.mdx (~2,000 words) - The eight pillars: authentication, authorization, tenant isolation, audit, rate limiting, webhook integrity, secret protection, data masking. - JWT bearer + rotating refresh, 5-attempt lockout, password policy (12-char min, 5-history, 90-day expiry), email confirmation, optional 2FA TOTP. - Permission-based authorization via .RequirePermission(); roles as groupings, permissions as the gate; the RequiredPermissionAttribute silent-no-op gotcha flagged inline. - Tenant isolation default; IgnoreQueryFilters discipline; named SoftDelete filter; IGlobalEntity opt-out. - Impersonation grant lifecycle + revocation list. - Rate-limit auth policy on 6 endpoints enumerated. - HMAC-SHA256 webhook signing + full subscriber-side verifier excerpt. - Redis-backed Data Protection key persistence. - Audit + JSON masking + retention. - CORS-before-HTTPS-redirect + SignalR credentialed CORS gotcha. - A 10-item day-one production security checklist. cross-cutting-concerns/index.mdx (~1,800 words) - The 11 platform features in a single table + per-feature deep dives. - HybridCache stampede protection + tag-based eviction + shared multiplexer rationale. - Hangfire FshJobFilter (tenant context), FshJobActivator (scoped DI), HangfireTelemetryFilter, plus the 5 recurring jobs the kit ships. - OpenTelemetry: traces (incl. MediatorTracingBehavior), metrics, logs over OTLP; Serilog enrichers. - Idempotency-Key replay via HybridCache. - Feature flags with TenantFeatureFilter for per-tenant overrides. - Rate limiting (vs Quota — different concerns). - Health checks: db, redis, MinIO, tenant migrations. - SSE + SignalR + Redis backplane + AppHub group conventions. - HTTP resilience pipeline (Polly v8 via Microsoft.Extensions.Http.Resilience). - ProblemDetails (RFC 9457) global handler + Mediator pipeline behaviors (ValidationBehavior, MediatorTracingBehavior). All claims fact-checked against the source — versions pinned from Directory.Packages.props, recurring-job crons verified from each module's WebhooksModule.cs / BillingModule.cs / FilesModule.cs / etc., NetArchTest rule families verified from src/Tests/Architecture.Tests/. Build green; 50 pages (unchanged — these replaced existing stubs). * docs(cross-cutting): split into 11 per-topic pages Replaces the single-page cross-cutting-concerns index with one MDX per concern under /docs/cross-cutting-concerns/. Each page is now rank-able for its own long-tail search query and the section index auto-lists children via <SectionIndex>. The eleven new pages - caching.mdx (1) HybridCache L1+L2 + stampede + tag eviction + shared multiplexer - background-jobs.mdx (2) Hangfire + FshJobFilter (tenant context) + FshJobActivator (scoped DI) + the 5 cron jobs the kit ships + retry attributes - observability.mdx (3) Serilog enrichers + OpenTelemetry 1.15 + instrumentation matrix + MediatorTracing - idempotency.mdx (4) Idempotency-Key header + HybridCache replay + retention window + content-dedupe disclaimer - feature-flags.mdx (5) Microsoft.FeatureManagement + TenantFeatureFilter + staged rollout / kill switch / per-env patterns - rate-limiting.mdx (6) auth policy on 6 endpoints + AuthRateLimitWiringTests + adding custom policies + monitoring 429s - health-checks.mdx (7) /health and /health/ready + per-DbContext + Redis + MinIO + TenantMigrationsHealthCheck + k8s liveness/readiness probe wiring - server-sent-events.mdx (8) SSE primitive + browser EventSource client + ?access_token= auth + heartbeat config - realtime.mdx (9) SignalR over Redis backplane + AppHub group conventions (user:{id} + channel:{id}) + Context.User vs ICurrentUser gotcha + TestServer-no-WebSocket integration testing - http-resilience.mdx (10) Polly v8 standard pipeline + HTTP-layer vs job-layer retry split + circuit breaker + hedging + non-idempotent retry pitfall - error-handling.mdx (11) GlobalExceptionHandler + RFC 9457 ProblemDetails + the 4-exception hierarchy + FluentValidation field errors + throw-from- domain idiom Index page is now slim — short intro + <SectionIndex> auto-listing. Build green; 61 pages built in 8.19s (up from 50). * docs(security): split into 8 per-topic pages Replaces the single-page security index with one MDX per concern. The eight new pages - authentication.mdx (1) JWT bearer + rotating refresh + lockout + password policy + email confirmation flow - authorization.mdx (2) Permission-based gates, role+group aggregation, and the silent-no-op gotcha for duplicate RequiredPermissionAttribute declarations - impersonation.mdx (3) Time-bound grants, IGlobalEntity persistence, jti revocation list, cross-tenant root override, full audit trail - two-factor.mdx (4) TOTP enrol/verify/disable; recovery-codes gap flagged as recommended add-on - webhook-signing.mdx (5) HMAC-SHA256 signing scheme + both C# and Node/Express subscriber-side verification recipes + replay protection via delivery id - data-protection.mdx (6) Redis-backed key persistence so cookies + antiforgery + email tokens survive rolling deploys - cors-and-headers.mdx (7) CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, full CSP + HSTS + X-Frame-Options reference - production-checklist.mdx (8) The 10-item release gate — JWT rotation, password policy, CORS, headers, rate limits, HTTPS, debug-endpoint lockdown, Hangfire auth, Data Protection persistence, audit retention Index page is now a slim landing with <SectionIndex> auto-listing. Build green; 69 pages built in 7.27s (up from 61). * docs(testing): split into 6 per-topic pages Replaces the single-page testing index with one MDX per layer plus meta pages (fixtures, running, writing). The six new pages - unit-tests.mdx (1) xUnit + Shouldly + NSubstitute + AutoFixture; Method_Should_X_When_Y naming; three ground rules (Shouldly, direct SUT, no infrastructure); aggregate-invariant + validator + mock-verification patterns - integration-tests.mdx (2) WebApplicationFactory + Testcontainers Postgres/Redis/MinIO; FshWebApplicationFactory + Seed.* + CreateAuthenticatedClient helpers; full ImpersonationTests example - architecture-tests.mdx (3) 9 rule families + 48 tests; module-boundary + endpoint-convention + feature-folder examples; "specification in XML doc + assertion in code" pattern; what arch tests don't cover - fixtures-and-seed-data.mdx (4) DatabaseFixture + RedisFixture + MinioFixture composition; FshWebApplicationFactory wiring; Seed.* static accessor; insert-don't-mutate discipline; common mistakes - running-tests.mdx (5) Commands (everything, single project, filter, verbose, coverage); Docker requirement; full GitHub Actions workflow; CI sharding pattern; container cleanup - writing-new-tests.mdx (6) Three step-by-step recipes: unit (with full handler example), integration (with full endpoint example), architecture rule (with IL-walker disclaimer) Index page is now a slim landing with <SectionIndex>. Build green; 75 pages built in 8.60s (up from 69). * docs(frontend + guides): impersonation walkthrough + frontend split + screenshot placeholders Adds a comprehensive operator impersonation guide and splits the frontend section into per-app pages. New <Screenshot> MDX component renders styled placeholder boxes when the image asset doesn't exist yet — owner can drop screenshots into docs/public/screenshots/ and the placeholders flip to real images automatically. New MDX component - components/docs/Screenshot.astro — file-exists check at build time; styled dashed-border placeholder when missing, real <img> + caption when present. Wired into mdxComponents export. New guides page - guides/operator-impersonation.mdx (~1,700 words) — end-to-end UI walkthrough: open admin → find user → start grant (with mandatory reason) → act as user in new tab → end or revoke → audit trail query. 9 screenshot placeholders covering every step. Plus a cross-tenant section explaining the root-operator override, audit query examples (Identity.Impersonation events), operational policies (mandate reason, short duration, alert on patterns, train operators), and 4 common-question answers (notified? chained? self? lost device?). Frontend section — 5 sub-pages + slim index - admin.mdx (~1,500 words) — operator-facing app: 11 page areas, JWT auth flow with refresh, permission-gated UI, TanStack Query patterns, SignalR notifications, the impersonation modal + flow + audit, theme editor for tenants. 8 screenshot placeholders. - dashboard.mdx (~1,400 words) — tenant-facing app: catalog, chat (the densest realtime page), files presigned upload, tickets, identity (profile + sessions + 2FA). 12 screenshot placeholders covering every major page. - architecture.mdx (~1,500 words) — shared patterns across both apps: folder layout, provider stack order, apiClient interceptors (auth + refresh + ProblemDetails), TanStack Query conventions, RouteGuard, RealtimeProvider, env handling. - theming.mdx (~1,200 words) — per-tenant theming via CSS custom properties, the 9 palette roles (light + dark), brand assets, typography, layout. Theme editor UI walkthrough with 4 screenshot placeholders. Loading-flash mitigation pattern + dark mode story. - development.mdx (~1,400 words) — Vite npm scripts, Aspire- orchestrated vs npm-direct dev, production build, runtime config via envsubst (full Dockerfile + entrypoint.sh), Nginx SPA config, Vercel / Netlify / S3+CloudFront / Cloudflare Pages deployment shapes, Vitest setup, common issues. Total: ~33 screenshot placeholders embedded across the new pages. Each Screenshot has a target path under public/screenshots/ — drop images there and they replace the placeholders automatically. Build green; 81 pages built in 9.40s (up from 75). * fix(docs): hide screenshot placeholders in production builds The <Screenshot> placeholder box now renders only in dev (\`astro dev\`) where the author needs to see what's still missing. Production builds (\`astro build\`) emit nothing when the asset doesn't exist — so missing screenshots leave no trace in shipped docs. The "image exists" path is unchanged; once a screenshot lands at the expected path under docs/public/screenshots/, the real <img> renders in any environment. Toggle uses import.meta.env.DEV, which Astro/Vite sets to true under the dev server and false during production builds. Verified: 0 .screenshot-placeholder elements in the production build across operator-impersonation, admin, dashboard, theming pages. * feat(dashboard+backend): v1 UI polish, file visibility/sharing, permission catalog Dashboard: revert warm-paper-hue chassis to untinted neutrals; polish list primitives (combobox, density-toggle, empty-state, hero, pagination, sort, stat) and add entity-detail/entity-shell/tone-icon-tile; tighten auth shell, command palette, file pickers, notifications, sidebar/topbar, theme toggle; page-level passes across activity, audits, auth, catalog, chat, files, login, settings, system, tickets. Files: add ChangeFileVisibility command + ListSharedFiles query, surface visibility on FileAssetDto. Identity: add GetPermissionCatalog query/endpoint; RoleService surfaces catalog data. Tests: add PermissionCatalogTests; add adminPassword to TenantCreation/TenantActivation payloads; update RoleManagementTests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): update role helpers to deserialize PagedResponse<RoleDto> GET /roles now returns PagedResponse<RoleDto> (paged) instead of a flat RoleDto[]; three integration test helpers still expected the array shape, blowing up on the first byte of every response and failing 8 tests across the Roles and Users suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dashboard): A+ rollup — deps + a11y + perf + tokens + UX Closes the P0 + most P1 items from the 2026-05-21 dashboard audit (docs/superpowers/audits/2026-05-21-dashboard-world-class-audit.md). Bundle: main JS 197.5 KB -> 178.0 KB gzip (-10%). New lazy chunks: command-palette-dialog 8.2 KB, SignalR 14.5 KB. Chat-specific CSS extracted to a 3.9 KB per-route chunk. Main CSS 127.5 -> 121.4 KB. Build + lint + Playwright (37/37) all green. Zero npm advisories. Dependency hygiene - Wave-1 bumps: react 19.2.6, react-dom 19.2.6, @tanstack/react-query 5.100.11, @tanstack/react-virtual 3.13.25, react-router-dom 7.15.1, tailwindcss 4.3.0, @tailwindcss/vite 4.3.0, tailwind-merge 3.6, vite 7.3.3, @vitejs/plugin-react 4.7, typescript 5.9.3, typescript-eslint 8.59.4, eslint 9.39.4, plus @types/react, @types/react-dom, @types/node patches. - Removed unused deps: recharts (declared, zero imports), react-hook-form, @hookform/resolvers, zod, @types/lodash, autoprefixer (Tailwind v4 handles prefixing natively). Dead code - Deleted 10 orphan files (zero cross-grep consumers): file-gallery, sse/live-feed, sse/sse-status-badge, theme-toggle, list/density-toggle, list/empty-state, list/list-hero, list/pagination, list/sort-chips, list/stat. components/sse/ directory removed. - Pruned components/list/index.ts barrel. Removed unused API exports: getAuditsByTrace, reorderProductImages, discoverChannels, restoreChannel, getRoleById. Dropped unused export keywords (BRAND_LADDER). Tokens + visual cohesion - Dropped backdrop-grayscale from the Dialog scrim (it was desaturating the entire page behind every modal). - Light-mode elevation restored: --surface-3 now differs from --card / --background so cards float again. - Muted-foreground bumped (dark L 0.680 -> 0.730; light 0.575 -> 0.500) so placeholders + chevrons clear WCAG 2.2 AA 4.5:1. - Added canonical type scale: --text-display-page/section/card/stat (Tailwind v4 auto-generates the text-display-* utilities). - Defined missing fsh-sheet-in/out keyframes for the mobile drawer (previously referenced but undefined). - Replaced 19 shadow-[0_1px_2px_oklch(...0.04)] literals across 12 files with the shadow-xs token. Fixed clipped oklch lightness in overview hover-borders (now uses --color-border-strong). - Removed dead .chat-empty-hero rule. Type scale migration - Migrated h1s in EntityPageHeader, PageHero, OverviewPage, NotFoundPage to the new tokens. Chat channel header demoted h1 -> h2 so each route has a single h1. Fonts - index.html eager Google Fonts trimmed from 12 families to 3 (Figtree + Outfit + JetBrains Mono). The other 9 are lazy-injected by ensureLazyFontsLoaded() when Appearance settings mounts, or synchronously at boot when the user has a non-default body font stored in localStorage. ~200-400 KB cold-load savings. Accessibility (WCAG 2.2 AA) - Programmatic-label fixes: aria-label on cmdk Command.Input + chat composer textarea; aria-expanded={!collapsed} on sidebar collapse button. - FilePreviewDialog no longer renders a whitespace-only DialogDescription (SR-read as empty description). - Field primitive: required indicator gets an sr-only "required" sibling alongside the visual dot. - RouteError stack trace gated behind import.meta.env.DEV — prod users see a recoverable card with no internals exposed. - RealtimeStatusPill: new `announce` prop, single mount (bell footer) now owns the live region — the chat-rail instance stops duplicate-announcing reconnect events. - role="log" aria-live="polite" on chat MessageList scroll container + Activity feed (mobile cards + desktop table). - role="status" aria-live="polite" on typing-indicator. - Touch targets bumped from h-5 w-5 (20px) to h-6 w-6 (24px) on composer reply-quote/attachment/upload clears, channel-rail filter clear + section-action plus, login eye-toggle, with focus-visible rings added where missing. Forms - aria-invalid + aria-describedby wired on login + forgot-password + reset-password forms so SR users hear errors linked to the fields they apply to. Sibling error blocks got stable ids. Performance - @microsoft/signalr is now dynamic-imported inside realtime-context's connect flow (37 KB gzip off the main shell). HubConnectionState comparisons rewritten as string-literal so the shutdown / invoke paths don't pull SignalR back in. - CommandPaletteDialog extracted to its own file and React.lazy imported by CommandPaletteRoot — cmdk + the full action graph load on first Cmd+K, not on cold start. - SseContext split into useSseStatus (status + eventCount) and useSseEvents (events array). Topbar SSE dot + bell pill stop re-rendering on every SSE event; only LiveFeed / Activity / Overview's livefeed-body subscribe to the events stream. - Avatar <img> gained loading="lazy" + decoding="async" + explicit width/height (matches sizeClass), eliminating CLS during avatar swap-in. UX - Command palette expanded: Identity (users/roles/groups), Catalog (products/brands/categories), Tickets, Files, Chat, Trash, Sessions, Appearance — every leaf route is now navigable from Cmd+K. New Create group with 9 entries (user/role/group/product/ brand/category/ticket/channel/file-upload). - Tickets list + detail render assignee/reporter via useUserDisplay (real names + avatars) instead of authorUserId.slice(0,8) + "...". - Chat-specific CSS rules (typing dots, unread divider, day rule, reaction chip, jump pill, mention pill) moved to pages/chat/chat.css — Vite extracts them into the chat-page CSS chunk so other pages stop shipping ~3 KB of selectors they don't use. .chat-status-pill stays in globals.css because the global notification bell consumes it. ESLint - jsx-a11y/no-noninteractive-element-interactions promoted from warn -> error (configured to exclude onError so legitimate image fallback handlers don't trip). Added no-noninteractive-element- to-interactive-role / no-aria-hidden-on-focusable / anchor-has- content as error. Fixed the one violation in user-picker.tsx (ul role=listbox -> div role=listbox). Tests - Refreshed two Playwright auth specs (forgot-password, reset-password) that were asserting the old "// 02.FORGOT- PASSWORD" / "// 03.RESET-PASSWORD" eyebrow chrome and <code> chip rendering that the v1 AuthShell redesign (commit 519d664d) retired. Tests now target the current headline + body text. Docs - Added docs/superpowers/audits/2026-05-21-dashboard-world-class- audit.md (the original six-agent composite audit, updated with a Status preamble noting what landed in this rollup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(docs): page view counter (D1) + frontmatter dates + hero copy Add a Cloudflare D1-backed page view counter to the docs site: - worker.ts handles POST/GET /api/views with KV per-(IP+UA) dedup (1h TTL) and delegates all non-API paths to the static assets binding - migrations/0001_init.sql defines the views table (+ count index) - client beacon (views-counter.ts) posts the slug and renders the count into a PageHeader chip with an eased count-up reveal; dev shows a sample, prod stays hidden until the beacon resolves - wrangler.toml wires the worker entry, assets, D1, and KV bindings Move page dates into MDX frontmatter (lastUpdated), backfilled across all 79 docs from git history, and remove the broken remark-modified-time plugin (Astro 6's glob loader discards remark frontmatter mutations, so the value never reached entry.data). Reword the hero headline to "built to ship, not to demo." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(identity,multitenancy): propagate CancellationToken through service layer + endpoints Full-sweep CancellationToken audit (Roslyn navigator AP009). Adds `CancellationToken cancellationToken = default` to the Identity service interfaces and their implementations, propagating the token into every real EF/IO sink (SaveChangesAsync, FirstOrDefaultAsync, storage upload/ remove, downstream expiry/history services). Wires the request token through the affected command handlers and the Multitenancy CreateTenant / ChangeTenantActivation endpoints (mediator.Send). Methods deliberately left unchanged: private helpers that only call UserManager/RoleManager (no cancellable sink — ASP.NET Identity exposes no CT overloads) and framework-fixed signatures. Logging audit (AP006): no changes required — the codebase already uses structured logging throughout; the 2 flagged OutboxDispatcher hits are false positives (arithmetic args to proper message templates). Also fixes the DeleteUserCommandHandler test assertion to verify the request token is forwarded (was asserting the implicit default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(test): master test plan — risk-tiered coverage matrix + gap backlog Audits the ~390 existing integration tests into a feature x scenario x tier matrix, defines the scenario taxonomy, and produces a dedupe-aware gap backlog (P0-P3). Foundation artifact for the test-hardening workstream (sub-project A of 3: plan / gap-fill+E2E / CI ratchet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(architecture): add validators for the 3 handlers flagged by HandlerValidatorPairingTests Adds the missing FluentValidation validators so every command handler and paginated query handler has a paired validator: - ChangeFileVisibilityCommandValidator — FileAssetId not empty, Visibility restricted to Public(0)/Private(1) (mirrors the handler's guard). - ListSharedFilesQueryValidator — Page >= 1, PageSize in [1,100] (matches ListMyFilesQueryValidator). - GetRolesQueryValidator — PageNumber/PageSize >= 1 (matches GetTenantSessionsValidator for the IPagedQuery shape). Architecture.Tests now 49/49 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(identity): forgot/reset + change-password integration tests Fills two total gaps from the master test plan (P0 #2, #3): the password reset and change flows had zero functional coverage. Each proves the change end-to-end (new password authenticates, old one is rejected) plus the unhappy paths (invalid/expired token, wrong current password, 401). 6 tests, all green against Testcontainers Postgres. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(files): cross-tenant isolation + visibility/sharing Closes the two Files-module test gaps from the master plan (P2 #10): cross-TENANT file access and the visibility/sharing feature. FileTenantIsolationTests proves a user in tenant B cannot read metadata or mint a download URL for a file owned in tenant A (404, no existence leak) — isolation comes from the schema-per-tenant BaseDbContext. FileVisibilityAndSharingTests covers the actual model: a Public/Private bit enforced by DefaultUploaderOnlyPolicy. Private files are owner-only; Public files are readable by any same-tenant user and appear in /shared; flipping visibility (the "share" action) is uploader-only (403 otherwise). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(chat): cross-tenant channel/message isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(billing): cross-tenant fetch-by-id isolation Adds BillingTenantIsolationTests proving direct fetch-by-id does not leak across tenants: a different tenant fetching another tenant's invoice id gets 404, and passing another tenant's id to the subscription query returns no subscription — while the owning tenant fetches the same id successfully. The tests surfaced two real isolation bugs (BillingDbContext extends raw DbContext, so there is no global tenant query filter — each handler must scope by the caller's tenant explicitly): - GetInvoiceById had no tenant filter at all (fetched by id only) -> leak. - GetSubscription trusted the caller-supplied tenantId param -> any tenant could read another tenant's subscription by passing its id. Fixes both handlers to scope by the authenticated tenant (mirrors GetMyInvoices). Subscription still allows the root operator to pass an arbitrary tenantId for admin cross-tenant reads. All 28 existing BillingEndpointTests remain green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(webhooks): HMAC signature correctness Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(catalog/tickets/groups): cross-tenant isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(multitenancy): provisioning failure path + activation block Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(identity): permission-cache invalidation + role tenant isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(test): mark wave 1+2 gap-fill complete; note Billing P1 leak fix Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(webhooks): deliveries/dispatch/fanout/test-send coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(multitenancy): theme/upgrade/retry/migrations coverage Add integration coverage for the lowest-covered Multitenancy targets: - TenantThemeTests: get/update/reset end-to-end, validation, cross-tenant isolation - UpgradeTenantTests: plan upgrade happy path, validation, root-only authz - RetryTenantProvisioningTests: retry of a failed (unreachable conn string) tenant - TenantMigrationsTests: per-tenant migration-status query + authz - ChangeTenantActivationValidationTests: mismatch/notfound/root/double/reactivate Fix: wire the previously-unreachable GetTenantMigrations endpoint into MultitenancyModule.MapEndpoints (handler/endpoint existed but were never mapped). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(auditing): by-id/correlation/trace/exception query coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(identity): sessions/users/forgot-password coverage Add integration tests for previously-uncovered Identity handlers: - Sessions: GetUserSessions, GetTenantSessions, AdminRevokeSession, AdminRevokeAllSessions, RevokeAllSessions (+ cross-tenant isolation) - Users: UpdateUser (PUT /profile), SetProfileImage, GetUserRoles, GetUserGroups, SearchUsers (filter/sort/page) - ForgotPassword request flow (POST /forgot-password) Fix a real bug surfaced by the profile tests: UserProfileService.UpdateAsync dereferenced image.Data on a null FileUploadRequest, so any text-only profile update returned 500. Guard the null and handle delete-only image clears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(identity): close forgot-password user-enumeration leak ForgotPasswordAsync returned 404 for unknown emails but 200 for real ones, letting an anonymous caller enumerate registered accounts. Now returns a uniform 200 regardless (real users still get the reset email). Un-skips the ForgotPasswordRequestTests enumeration test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(catalog): product-image remove/reorder + access-policy + update coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(tickets): comments + mappings + event-handler + search coverage Fix TicketComment.Id missing ValueGeneratedNever() — EF tracked the nav-collection child as Modified (UPDATE 0 rows) instead of Added, raising DbUpdateConcurrencyException on AddComment. Un-skips the existing AddComment endpoint test and adds comment, search-filter, lifecycle, and validation coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(files): purge jobs + access-policy + storage flow coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auditing): cast jsonb PayloadJson to text for ILIKE filters + tests EF.Functions.ILike was applied directly to the PayloadJson column, which maps to PostgreSQL jsonb. PostgreSQL has no like_escape(jsonb, unknown), so every payload-backed filter crashed at execution with HTTP 500 (GetSecurityAudits action, GetExceptionAudits area/exceptionType/ routeOrLocation, and the GetAudits search whose jsonb OR-leg poisoned the whole predicate). Fix: introduce AuditJsonbFunctions.AsText, mapped via HasDbFunction + HasTranslation to a SQL CAST(x AS text) in AuditDbContext, and wrap the jsonb column with it before ILIKE so valid SQL is generated. Also align the key-match patterns with PostgreSQL jsonb canonical text form, which emits a space after each colon ({"area": "Value"}); the previous compact patterns ("area":"Value") never matched the casted text. No column mapping change and no migration: the jsonb mapping and GIN index in AuditRecordConfiguration are untouched. Changes are Auditing-module-local. Adds AuditPayloadFilterTests (7 tests) seeding known payloads and asserting each filter returns the correct row and does not 500, plus an end-to-end test driving search over a real async-written login audit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(billing): usage-snapshots query + monthly-invoice job + domain coverage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dashboard,admin): dentalOS-style login + demo picker, FSH logo lockup - Rebuild the dashboard login to the dentalOS card layout (Welcome back / tenant+email+password / dashed demo button) and add a "Step into any role" demo-account picker (components/auth/demo-accounts-dialog.tsx) that signs in instantly, each account carrying its own tenant. - AuthShell brand lockup now uses the FSH logo (logo-fullstackhero.png) + "fullstackhero" wordmark + ".NET 10 Starter Kit" caption; admin BrandMarkXL leads with the logo while keeping its Console aesthetic. - Gate the demo button on a runtime config.json `demoMode` flag (env.ts) so one build serves staging (on) and prod (off); remove the old login.demo-panel.tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): Playwright suites for dashboard + admin (214 tests) Full route-mocked, JWT-seeded Playwright coverage of every page in both React apps — no live backend required. - Dashboard (121): login/demo picker, overview/activity/invoices, catalog, identity, tickets, chat, files, settings, system. - Admin (93): login/dashboard, tenants, users/roles, billing, audits/impersonation/webhooks/notifications/health, settings. - Shared harness in tests/helpers/shell-mocks.ts mocks all global AppShell calls (notifications/chat/profile/permissions; aborts SSE/realtime). All green, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(auditing): un-skip the 6 jsonb-filter tests now that the cast fix is merged * test(catalog): unit tests for product/money/category domain Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(billing): unit tests for domain transitions + services Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(webhooks): unit tests for subscription matching + delivery + signer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(tests): wire Billing/Catalog/Webhooks unit-test projects into solution * build(coverage): exclude *HostedService background loops from coverage denominator * test(identity): unit tests for device/current-user/password services Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(framework): unit tests for storage/core/web/persistence/eventing helpers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(tests): wire Framework.Tests into solution * ci: add Billing/Catalog/Chat/Files/Framework/Webhooks to test matrix + coverage-gate job Fixes the stale test matrix (Chat.Tests/Files.Tests were never run in CI) and adds a Coverage Gate job that runs the whole solution with Coverlet and fails below an 80%-ish line floor (ratchet — bump MIN_LINE as coverage rises). * ci: raise coverage floor to 80% (meaningful coverage now 83.3%) * test(middleware): real-wiring tests for global exception handler + rate limiting + security headers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "test(middleware): real-wiring tests for global exception handler + rate limiting + security headers" This reverts commit 3d9955a5547b67b009072f5f88cc7a3fa31c912a, reversing changes made to 679e6e3a63d6da3ddda3e741d677e1a48e027c55. * test(middleware): real-wiring tests in isolated assembly (global exception handler + rate limiting + security headers) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(ci): wire Integration.Middleware.Tests into solution + integration-test job * chore: gitignore generated coverage-report/ and TestResults/ dirs * refactor: harden concurrency hotspots + cleanup from Roslyn-navigator audit Findings and fixes from a race-condition / code-smell audit (Roslyn navigator detect_antipatterns, find_dead_code, detect_circular_dependencies + targeted concurrency sweeps). Concurrency hygiene was already strong — no async void, no sync-over-async, no fire-and-forget, thread-safe singletons, zero circular deps. The genuine items: - Audit (static): swap the enricher list for an atomically-replaced immutable array read once per WriteAsync. Kills the latent "collection modified" race if Configure() ever runs concurrently with enrichment, and removes a process-global mutable-List test-isolation hazard. - QuotaEnforcementMiddleware: inject TimeProvider instead of DateTimeOffset.UtcNow for Retry-After, matching the rest of the Quota subsystem (deterministic in tests). - PresenceTracker.Connect: also report the offline->online transition when the AddOrUpdate update factory resurrects a count==0 key (Disconnect set 0 but hadn't removed it yet) — previously a reconnect in that window missed the presence broadcast. - ChannelMember: remove dead SetMuted (zero callers); IsMuted is now get-only (still persisted + exposed via DTO, populated by EF via backing field). Investigated but intentionally NOT changed (false positives confirmed): - catch(Exception) in hosted services already filter OperationCanceledException. - Flagged EF "missing AsNoTracking" queries all read-then-mutate-then-save (tracking required); the read-only paths already use AsNoTracking. - find_dead_code hits for ChannelAuthorization / EntityEntryExtensions are used extension methods; EF model snapshots are tooling artifacts. All unit (779) + integration (665) + middleware (5) tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): AGENTS.md guide + .agents/ rules, skills, and workflows (accurate to current codebase) Establish AGENTS.md as the canonical, tool-neutral AI guide (CLAUDE.md/GEMINI.md are thin @import bridges) and build out .agents/ guidance verified against the current code via Roslyn navigation + real-file extraction. Rules (.agents/rules/) — lean, on-demand, indexed from AGENTS.md: - cross-cutting: architecture, api-conventions, database, eventing, caching, jobs, resilience, storage, security, realtime, logging, testing, integration-testing - per-module: identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications - frontend: shared + admin + dashboard (the two React apps' real divergences) - replaces the old flat modules.md/persistence.md/testing-rules.md Skills (.agents/skills/) — audited the 6 existing against the live codebase and rewrote them (they were stale: IRepository<T>, PagedList<T>, Moq/FluentAssertions, Guid.NewGuid, class-level [FshModule(Order=n)], DbContext vs BaseDbContext, only 2 of the 4 module-registration sites). Added 5: add-react-page, add-full-slice, create-migration, add-integration-event, add-permission. Skills hold the recipe. Workflows (.agents/workflows/) — reconciled so they orchestrate and delegate to skills (no duplicated/contradictory templates); fixed stale facts (migrations run via DbMigrator, not on startup; TenantDbContext not "MultitenancyDbContext"). Also corrects the [FshModule] attribute (assembly-level positional, not class-level) in architecture.md and the AGENTS.md pointer. Docs only; no source/build impact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove NSwag OpenAPI client-gen tooling + obsolete scripts/docs - Drop nswag.consolecore from dotnet-tools and delete scripts/openapi/* (NSwag-based C# client generation is no longer used). - Remove scripts/test-cli.ps1 and the requirements/frontend-and-platform.md planning doc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(site): AI-Driven Development section, sponsorship surfaces, homepage redesign, v10 - AI-Driven Development docs section (overview, AGENTS.md & .agents/, skills & workflows, developing with Claude Code) + section registration. - Sponsorship: sticky sponsor card atop the docs ToC, and a "Back the build" home section (Open Collective), placed right under the architecture section. - Homepage: new "Stack at a glance" bento opener; the architecture section rebuilt as a modular-monolith + vertical-slice diagram (two balanced boards); FSH brand mark in the hero; "Who it's for" redesigned as icon-led cards; module-card code panels removed; competitor product references removed. - Version: product version aligned to v10 (.NET 10) across the homepage and docs copy. API version paths (/api/v1, Features/v1, Contracts.v1) untouched. - Fix: type the header nav `match` field — astro check now 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(home): testing section, View Transitions theme toggle, mobile polish - Add "Tested to production standard" landing section (Testing.astro): a four-pillar scoreboard (unit / integration / E2E / architecture) over a green test-run terminal, leading with real-PostgreSQL-via-Testcontainers. Wire in as section 05; renumber WhoItsFor/FAQ/FinalCta to 06/07/08. Sync stale test counts in Hero (900+ -> 1,400+) and the FAQ answer. - Theme toggle: replace the muddy CSS token cross-fade with a View Transitions API snapshot cross-fade (GPU-composited, text stays crisp, no frame drops); clean instant fallback for reduced-motion / unsupported browsers. - Mobile responsiveness pass on the homepage: drop heading bases (sections 30px, hero 34px) so they stop dominating phones; hero stats, TechStack, and the testing scoreboard go single-column on mobile; hero terminal scrolls instead of clipping; FAQ answers reclaim full width on mobile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(infra): modernize Terraform — TF 1.15.4 / AWS 6.46, collapse root, React SPA hosting - Pin Terraform >= 1.15.4 and AWS provider ~> 6.46 in the roots; permissive floors (>= 1.15 / >= 6.0) in child modules. Commit a multi-platform .terraform.lock.hcl locked to aws 6.46.0. - Collapse the shared/ -> app_stack/ wrapper into a single root (provider + backend now live in app_stack/), removing ~500 lines of duplicated vars and outputs plus a passthrough bug that silently dropped ~30 tunables. Dedupe tags via provider default_tags. - Add reusable modules/static_site (private S3 + OAC CloudFront, SPA 403/404->index.html fallback, default-on managed security headers, Terraform-owned config.json) and host the two React SPAs (admin, dashboard) that replaced the removed server-rendered Blazor service. SPA CloudFront origins are auto-added to the API CORS allow-list. No Route53/domain resources (custom aliases optional). - Drop the unnecessary api_task_secrets IAM policy (least privilege). - Add one-command deploy.sh / deploy.ps1: terraform apply -> optional API image build/push -> build + s3 sync + CloudFront invalidate for both SPAs. - Refresh README; terraform fmt + validate clean against aws 6.46.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: sweep in-flight distribution-template WIP + gitignore local .claude Pre-existing working-tree changes outside the Terraform audit, committed so the branch is clean: - templates/ NuGet pack csproj, .template.config/template.json, README-template - CLI NewCommand updates; AppHost + solution (slnx) wiring - CI: add template-smoke workflow, ci.yml tweak - docs: site.ts, breadcrumb helper, llms.txt/robots.txt, Cloudflare _headers/_redirects, error-handling page - .gitignore: exclude local .claude worktrees, scheduled_tasks.lock, last30days.env Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docs): render section Overview first, dedup breadcrumbs, name index pages "Overview" - sidebar: detect the Astro 6 glob-loader bare-dir index id ('frontend' as well as 'frontend/index') so every section's overview is pulled out and rendered first, instead of leaking into the page list and sorting by order. - breadcrumbs: a section overview now renders 'Docs / <Section>' (section is the current page) instead of duplicating it ('Docs / Modules / Modules'); fixed in both the visible component and the JSON-LD schema. - name all 15 category index pages 'Overview' (with descriptive per-page seo.title). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(deployment): add Aspire, DbMigrator, AWS Terraform, CI/CD guides; rewrite install paths - deployment/aspire: services that spin up locally + the design decisions. - deployment/database-migrations: the DbMigrator (commands, multitenancy, prod usage). - deployment/aws-terraform: end-to-end AWS deploy from prerequisites to one command. - deployment/ci-cd: the GitHub Actions pipeline + template smoke test. - getting-started/install: four install paths with screenshot placeholders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: remove Blazor references (the kit no longer ships Blazor) Delete the Blazor.UI building-block page and drop Blazor.UI / FSH.Starter.Blazor from the project trees, the building-blocks list (llms.txt), and the CORS CSP note. Competitor comparisons (BlazorPlate, ABP) and legacy blazor-* redirects are kept intentionally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(architecture): expand tenant isolation deep-dive + fix cross-links - deep-dive: add the subclass 'base.OnModelCreating LAST' rule, the documented Billing isolation exception (plain DbContext + manual TenantId), per-tenant migration ordering, and the AsyncLocal test gotcha. - fix multitenancy module links that pointed at the section index instead of the deep-dive; cross-link Billing to the documented exception. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: http(s) scheme validation for webhook URLs + cross-platform BuildingBlocks tests (#1244) * fix(tests): make BuildingBlocks dependency checks cross-platform ProjectReference Include paths are authored with Windows separators (..\Core\Core.csproj). Path.GetFileNameWithoutExtension only treats "\" as a separator on Windows, so on Linux CI it returned the full path minus extension (..\Core\Core) instead of the bare name (Core). This caused BuildingBlocks_Should_Follow_Layered_Dependencies to report 12 false-positive violations on Linux (passed locally on Windows), and silently disabled the module-reference enforcement in BuildingBlocks_Projects_Should_Not_Reference_Modules_Directly. Normalize "\" to "/" before extracting the project name so both checks work on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webhooks): require http(s) scheme for subscription URL Uri.TryCreate(UriKind.Absolute) accepts a leading-/ path as an implicit file:// URI on Unix (but not Windows), so "/relative/path" passed validation on the Linux CI runner while the test passed locally on Windows. A webhook target must be an HTTP(S) endpoint regardless of platform, so assert the scheme explicitly. Fixes the Linux-only failure of Create_Should_Fail_When_Url_Relative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add Google Analytics (gtag.js) to BaseLayout Loads gtag.js site-wide via the base layout head. Both script tags use is:inline so Astro emits them verbatim instead of bundling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * @ chore: extract docs site to a separate repo (fullstackhero/docs) The Astro docs site now lives at github.com/fullstackhero/docs. Remove docs/ from this repo and repoint the references that pointed into it. - Move docs/superpowers/ (audits, specs, plans -- whole-project notes, not docs-site content) to repo root superpowers/, preserving history - Remove the docs/ row from the AGENTS.md repo map - Drop docs/{node_modules,dist,.astro} entries from .gitignore - Repoint the README "deeper story" link to the new repo - Drop "docs" from the code-reviewer workflow change-area grouping Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * @ fix(docker): create MinIO bucket in prod compose + add migrator to root compose deploy/docker (prod): nothing created the S3 bucket the Files module writes to (S3StorageService just PutObjects into Storage:S3:Bucket and never creates it), so the first upload failed with NoSuchBucket. Add a minio-init one-shot (mc mb local/fsh) and gate api on its completion. No anonymous policy -- objects are served via the API / presigned URLs. docker-compose.yml (root/dev): had no migrator, so the API ran against an empty schema (the DB is not migrated at API startup). Add a migrator (apply --seed) + matching JWT/seed env so the seeded admin can log in, gate api on it, and add a header clarifying this is the dev compose (Aspire and deploy/docker are the other paths). DEV-ONLY secrets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * feat(infra): switch Redis to Valkey + add RedisInsight cache browser Valkey 8 (BSD-3, the Linux Foundation Redis fork) replaces the source-available Redis image across Aspire and both compose files. Drop-in: the kit uses only StackExchange.Redis over RESP (cache, data protection, SignalR backplane, quota) with no Redis Stack modules, and Hangfire is on Postgres. Resource/service name stays "redis" so connection strings and config keys do not churn. - AppHost: AddRedis(...).WithImage("valkey/valkey","8") + .WithRedisInsight() (auto-wired cache browser; SSPL but dev-only). The valkey image ships redis-* symlinks and supports Aspire's TLS command, so the Redis integration works unmodified. - docker-compose.yml (dev): valkey/valkey:8-alpine. - deploy/docker (prod): valkey/valkey:8-alpine + valkey-server/valkey-cli. Verified by booting Aspire: Valkey starts clean (tcp+tls listeners), authenticated PING returns PONG, RedisInsight comes up auto-connected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(integration): run the HybridCache distributed-cache test against Valkey Switch the Testcontainers image from redis:7-alpine to valkey/valkey:8-alpine so the suite proves the cache round-trip works on the new engine. Verified locally: 4 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): docs repo + changelog must travel with each change Add golden rule #10 — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the separate docs repo (github.com/fullstackhero/docs) is updated to match and a changelog entry is added. Also fix the stale "Redis 6379" ports line to "Valkey 6379". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * @ (#1247) fix(web): return 400 (not 500) for malformed requests / missing required params Anonymous, tenant-scoped endpoints (forgot-password, reset-password, self-register) bind a required `tenant` header. When it is missing, ASP.NET Core throws BadHttpRequestException (StatusCode 400) during parameter binding. GlobalExceptionHandler did not recognise that type and rendered it as a generic 500. Map BadHttpRequestException to its own StatusCode so missing required header/route/query params (and unreadable/oversized bodies) surface as a proper 400 (or 413, etc.) with a ProblemDetails body. The fix applies to every endpoint with a required bound parameter, not just identity. Mirror the mapping in the test-only DetailedTestExceptionHandler, add integration regression tests for the three identity endpoints, and add GlobalExceptionHandler unit tests. Closes #1245 @ Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove obsolete MCP config and superpowers audit/spec/plan files * @ chore(docker): remove redundant root docker-compose.yml Local dev is covered by Aspire (FSH.Starter.AppHost) and production by deploy/docker/docker-compose.yml. The root quick-run compose duplicated infra config with no unique role; nothing references it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * fix(dashboard): UX/a11y/perf/correctness pass (#1248) Audit-driven fixes across the tenant dashboard. No backend or BuildingBlocks changes; all verified (tsc clean, eslint 22->12 warnings, build OK, Playwright 121/121). Functional bugs: - realtime-context: wire 4 subscribed-but-unregistered hub events (PresenceChanged, ChatMessagePinned/Unpinned, ChatChannelMemberRead) so live presence/pin/read-receipt updates actually arrive. - chat-search: drop a stateful /g RegExp.test() whose lastIndex drifted and mis-highlighted matches. - user-detail: key the staged-roles reset on userId (not the data array) so a background refetch can no longer wipe unsaved edits. Accessibility: - Combobox: replace invalid role="option" in a menu / role="combobox" without aria-expanded with valid menuitemradio; lift the nested interactive clear button out to a sibling. - accessible names for reaction chips, quick-react emoji, EntitySearch, collapsed sidebar nav items. - role="status"/sr-only on skeletons, role="progressbar" on upload bars, DialogTitle on the mobile nav Sheet, anchor-as-menuitem for link items, h1->h2 heading hierarchy, status rows hidden from the chat log region. - raise placeholder contrast to AA (drop sub-AA alpha multipliers). UX/correctness: - retire hardcoded text-white/bg-white/bg-black via a new --color-overlay-foreground/--color-overlay token + semantic foregrounds. - profile form: surface load errors, gate inputs while loading, seed once. - wire the previously no-op "Reduce motion" toggle through the theme provider; fix the notifications "open bell" selector. Cleanup: - remove 3 `void X` dead-import hacks + a dead effect, swap a full-page window.location reload for router navigate(), and resolve all 10 exhaustive-deps lint warnings via useMemo. Tests: update 3 identity empty-state heading-level assertions to match the corrected h2 hierarchy. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin): UX/a11y/perf/correctness pass Audit-driven fixes across the operator console. No backend or BuildingBlocks changes; all verified (tsc clean, eslint 2 errors -> 0, build OK, Playwright 93/93). Build-red + real bugs: - fix 2 lint errors: drop the unused `grant` prop on RowActions (active-grants-card) and the dangling `react/no-danger` disable directive that referenced an uninstalled rule (security). - App.tsx: wrap RouterProvider in a top-level Suspense so the public lazy routes (login, password reset, confirm-email) have a boundary on cold chunk fetch instead of throwing. Accessibility (admin had no eslint-plugin-jsx-a11y — now added): - Field primitive now threads aria-describedby + aria-invalid to its control, so every RHF form announces hints/errors (one fix, all forms). - notification-bell: drop the focusable aria-hidden click-away (tabIndex=-1), remove the invalid role="menu", add Escape-to-close, and stop redefining a component inside render. - skip-to-content link + <main id> landmark in AppShell. - accessible names on unlabelled search/filter inputs (users, audits x3, impersonate) and the icon-only webhook delete button. - Segmented toggle gets role="group" + aria-pressed; impersonation details disclosure gets aria-expanded. - role="alert" on login/users/tenants inline errors; role="status" on loaders; reduced-motion now also stops Tailwind's animate-spin. Performance: - impersonation list: collapse two overlapping 5s take:200 polls into one fetch + client-side filtering/counts. - notification-bell: coalesce the per-event invalidation burst. - security: lazy-import the ~50KB qrcode lib only at 2FA enrollment. Correctness: - sessions revoke: track in-flight ids in a Set so concurrent revokes don't clear each other's busy state (user-sessions-card + settings). - invoices row: real <button> inside the <li> instead of a noninteractive <li role="button">. - mobile-nav: capture the trigger node in the effect (ref-in-cleanup). Tooling: add eslint-plugin-jsx-a11y (recommended) with no-autofocus off and label-has-associated-control depth:3; lint passes clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * @ ci: split backend/frontend pipelines, pin SDK, dedupe test load Replace the monolithic ci.yml with path-scoped backend.yml + frontend.yml so a client-only change never builds/tests the API and vice versa. - backend.yml: unit + integration each run ONCE with coverage collection; the coverage job now merges those reports instead of re-running the whole solution (the old double-run was the bulk of the load). Vuln scan gates on direct vulnerable packages. Drops the fragile bin/obj artifact hand-off. - frontend.yml: lint + tsc/vite build + Playwright E2E, matrixed over admin and dashboard (Node 22, npm-cached) — the frontend had no CI before. - global.json pins the .NET 10 GA SDK; all workflows use global-json-file and drop dotnet-quality: preview. Excluded from the template so scaffolded consumer projects are unaffected. - Always-running "Backend CI" / "Frontend CI" gate jobs report green when their side is skipped, so required status checks resolve on cross-cutting PRs. NOTE: branch protection must require the new "Backend CI"/"Frontend CI" checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * fix(admin): UX/a11y/perf/correctness pass (#1249) * @ chore(docker): remove redundant root docker-compose.yml Local dev is covered by Aspire (FSH.Starter.AppHost) and production by deploy/docker/docker-compose.yml. The root quick-run compose duplicated infra config with no unique role; nothing references it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * fix(admin): UX/a11y/perf/correctness pass Audit-driven fixes across the operator console. No backend or BuildingBlocks changes; all verified (tsc clean, eslint 2 errors -> 0, build OK, Playwright 93/93). Build-red + real bugs: - fix 2 lint errors: drop the unused `grant` prop on RowActions (active-grants-card) and the dangling `react/no-danger` disable directive that referenced an uninstalled rule (security). - App.tsx: wrap RouterProvider in a top-level Suspense so the public lazy routes (login, password reset, confirm-email) have a boundary on cold chunk fetch instead of throwing. Accessibility (admin had no eslint-plugin-jsx-a11y — now added): - Field primitive now threads aria-describedby + aria-invalid to its control, so every RHF form announces hints/errors (one fix, all forms). - notification-bell: drop the focusable aria-hidden click-away (tabIndex=-1), remove the invalid role="menu", add Escape-to-close, and stop redefining a component inside render. - skip-to-content link + <main id> landmark in AppShell. - accessible names on unlabelled search/filter inputs (users, audits x3, impersonate) and the icon-only webhook delete button. - Segmented toggle gets role="group" + aria-pressed; impersonation details disclosure gets aria-expanded. - role="alert" on login/users/tenants inline errors; role="status" on loaders; reduced-motion now also stops Tailwind's animate-spin. Performance: - impersonation list: collapse two overlapping 5s take:200 polls into one fetch + client-side filtering/counts. - notification-bell: coalesce the per-event invalidation burst. - security: lazy-import the ~50KB qrcode lib only at 2FA enrollment. Correctness: - sessions revoke: track in-flight ids in a Set so concurrent revokes don't clear each other's busy state (user-sessions-card + settings). - invoices row: real <button> inside the <li> instead of a noninteractive <li role="button">. - mobile-nav: capture the trigger node in the effect (ref-in-cleanup). Tooling: add eslint-plugin-jsx-a11y (recommended) with no-autofocus off and label-has-associated-control depth:3; lint passes clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * @ ci: split backend/frontend pipelines, pin SDK, dedupe test load Replace the monolithic ci.yml with path-scoped backend.yml + frontend.yml so a client-only change never builds/tests the API and vice versa. - backend.yml: unit + integration each run ONCE with coverage collection; the coverage job now merges those reports instead of re-running the whole solution (the old double-run was the bulk of the load). Vuln scan gates on direct vulnerable packages. Drops the fragile bin/obj artifact hand-off. - frontend.yml: lint + tsc/vite build + Playwright E2E, matrixed over admin and dashboard (Node 22, npm-cached) — the frontend had no CI before. - global.json pins the .NET 10 GA SDK; all workflows use global-json-file and drop dotnet-quality: preview. Excluded from the template so scaffolded consumer projects are unaffected. - Always-running "Backend CI" / "Frontend CI" gate jobs report green when their side is skipped, so required status checks resolve on cross-cutting PRs. NOTE: branch protection must require the new "Backend CI"/"Frontend CI" checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * @ ci: make pipelines main-only after retiring develop develop has been consolidated into main and deleted. Drop develop from all workflow triggers, key dev-container publishing to main pushes, and update the contributor/security docs that pointed at develop. - backend.yml / frontend.yml / codeql.yml / template-smoke.yml: triggers main-only - backend.yml: publish-dev-containers now runs on push to main - CONTRIBUTING.md: branch from and target main - SECURITY.md: fixes ship on main Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> @ * @ docs(agents): document the single-main branching model Add a "Branching & PRs" section to AGENTS.md (the canonical AI guide that CLAUDE.md imports): single main branch, no develop, target main, releases via v* tags, and require only the Backend CI / Frontend CI gate checks. @ * fix(template): correct Dockerfile packaging + nested IDE-cache excludes Two packing bugs that only surface on a real dev machine (CI uses clean checkouts, so neither was caught): 1. Extensionless files (every Dockerfile) were duplicated to a nested path in the package (src/Host/X.Api/Dockerfile/src/Host/X.Api/Dockerfile) because NuGet treats an extensionless computed PackagePath as a directory and re-appends the recursive path. Scaffolded projects got a Dockerfile directory instead of a file, breaking deploy/docker. Fixed by setting PackagePath="content" and letting NuGet append the recursive path uniformly. 2. The .vs/.idea/.vscode excludes were root-anchored, so a nested src/.vs/ (Visual Studio per-solution cache) was not excluded; dotnet pack failed on a locked .vsidx (or bundled IDE junk). Made them recursive (**/.vs/** etc.) to match the bin/obj pattern, in both the csproj glob and template.json. Verified end-to-end: pack -> install -> dotnet new fsh / fsh new -> scaffold builds (-warnaserror) + both clients build + tests pass; Dockerfiles are files. * chore(deps): update Aspire to 13.3.5 + bump remaining outdated packages Bring all outdated NuGet packages to latest for the v10 release: - Aspire.Hosting.{JavaScript,PostgreSQL,Redis} + AppHost SDK: 13.3.3 -> 13.3.5 - Finbuckle.MultiTenant family (all 5): 10.0.8 -> 10.1.0 - SonarAnalyzer.CSharp: 10.25.0 -> 10.27.0 - MailKit / MimeKit: 4.16.0 -> 4.17.0 - AWSSDK.S3: 4.0.23.1 -> 4.0.23.4 - Scalar.AspNetCore: 2.14.11 -> 2.14.14 OpenTelemetry.Instrumentation.{EntityFrameworkCore,StackExchangeRedis} left on 1.15.0-beta.1 (no newer published). Verified: solution builds with -warnaserror (0 warnings, even on SonarAnalyzer 10.27), full test suite green (1611 passed / 2 skipped / 0 failed incl. Testcontainers integration), and the full Aspire 13.3.5 stack boots and serves (API + OpenAPI + Scalar + both React clients). * fix(cli): correct template detection in doctor/info + drop obsolete Aspire check - `fsh doctor` and `fsh info` reported "Template: not installed" even when it was, because detection parsed `dotnet new list` (which has no version column). Read `dotnet new uninstall` instead, which lists the package id + version. - `fsh doctor` recommended `dotnet workload install aspire`, but Aspire is no longer a workload (NuGet SDK since .NET 9). Removed the obsolete check. Verified: `fsh doctor` now shows FSH Template PASS v10.0.0 and no Aspire row; `fsh info` shows the template version. Builds clean with -warnaserror. * chore(release): set default version to 10.0.0 The repo-wide <Version> was 10.0.0-rc.1, so locally-packed CLI/NuGet artifacts defaulted to rc.1 while the template hardcoded 10.0.0. Align everything to the 10.0.0 GA release version. (Tagged releases still override via the CI publish-release job, but the default now matches the release.) * fix(scaffold): per-app Docker volume names + init git on main Two DX fixes for scaffolded apps: 1. AppHost data volumes were literal ("postgres-data", "fsh-redis-data", "fsh-minio-data"), so every FSH-based app on a machine (and this repo) shared the same Docker volumes — one app's DB clobbered another's. Prefix each volume with the AppHost name (e.g. "dentalos-postgres-data") so apps get isolated, namespaced, safe volume names. 2. `fsh new` ran a bare `git init`, so the initial branch followed the user's `init.defaultBranch` (often `master`). Force `main` via symbolic-ref on the unborn HEAD (works on every git version) — the kit standardized on `main`. * fix(scaffold): seed the root admin on Aspire launch (apply --seed) The Aspire AppHost ran the migrator with bare `apply`, which migrates schema but never seeds the root admin user — so a freshly-run scaffolded app came up with an empty Users table and nobody could log in (admin@root.com). deploy/docker already used `apply --seed`; the Aspire dev path was inconsistent. Run `apply --seed` and pass the dev-default Seed__DefaultAdminPassword (mirrors the API's appsettings.Development.json) so `dotnet run AppHost` yields a working login out of the box. Verified end-to-end: fresh scaffold -> run -> log in as admin@root.com -> exercise tenants/users/catalog/audits APIs. * fix(scaffold): seed demo tenants on Aspire launch The dashboard's demo-login panel advertises acme/globex accounts, but AppHost only ran the migrator's `apply --seed` verb, which provisions the root tenant/admin only. The acme/globex demo tenants are created exclusively by the dev-only `seed-demo` verb, which AppHost never invoked - so every acme/globex login failed against a freshly-provisioned DB. - AppHost: add a dev-only `fsh-demo-seeder` resource that runs `seed-demo` after the base migration; the API waits on it so the demo accounts exist before the dashboard is reachable. Pin DOTNET_ENVIRONMENT=Development (the generic-host migrator ignores Aspire's injected ASPNETCORE_ENVIRONMENT) and pass Seed__DemoPassword explicitly. - DbMigrator: disable build-time DI validation. The migrator runs a reduced service graph (Mailing/SignalR/Jobs disabled); Development's auto-on ValidateOnBuild walked request handlers the migrator never invokes (needing IHubContext/IMailService) and crashed startup. The migrator only resolves migration/seed services. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): treat an expired access token as logged-out at boot Both apps derived isAuthenticated from token presence/decodability and never checked `exp`. A stale/expired token in localStorage booted straight into the app, firing protected requests that 401'd in a loop (the SecurityTokenExpiredException bursts) instead of refreshing or routing to /login. - jwt.ts: add isTokenExpired (with a small clock-skew margin). - auth-context: on boot, if the access token is missing/expired but a refresh token is present, run one silent refresh before deciding signed-in vs /login (new isInitializing flag). A live session whose access token expires mid-use stays authenticated (reactive 401-refresh handles it) so long-lived sessions are preserved. - protected-route: render a loader while initializing so no protected request fires with a dead token. - api-client: export refreshAccessToken for the boot path. - tests: session-restore specs for both apps (refresh-fails -> /login; refresh-succeeds -> session restored, no /login bounce). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(scaffold): derive AppHost resource names from the app namespace The Aspire resource names were hardcoded `fsh-*`. Derive them from the AppHost assembly name — the same prefix already used for the Docker volume names — so a CLI-scaffolded app gets resources under its OWN namespace (e.g. `acme-store-api`) instead of the kit's. Renamed `volumePrefix` -> `appPrefix` since the one prefix now namespaces both volumes and resource/container names. Affected resources: api, db-migrator, demo-seeder, admin, dashboard (this repo resolves them to `fsh-starter-*`). Third-party infra (postgres/redis/minio) and the `fsh-db` database resource are intentionally left literal — the DB name flows into connection strings and the persistent data volume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scaffold): no-frontend AppHost build + correct advertised health URL Two issues surfaced while smoke-testing the template distribution end-to-end: 1. Scaffolding with --no-frontend produced an AppHost that didn't compile. The `api` resource handle is only consumed by the React apps' .WithReference(api), so excluding the frontend left it unused -> S1481 under TreatWarningsAsErrors. Added a //#else branch that discards it (`_ = api;`) so the no-frontend render stays warning-clean; the full (frontend) template is unaffected. 2. The advertised health URL was wrong. The CLI "Next Steps" and the docker README pointed at /health, which is not a route -- the probes are /health/live and /health/ready. (A request to /health matches no endpoint and the global fallback authorization policy returns 401, not 404.) Point both at /health/live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(identity): advertise the dev root password as Password123! The demo seeder realigns every tenant admin -- including root's admin@root.com -- to the shared demo password, so under the Aspire dev stack the root account signs in with Password123!, not the framework default 123Pa$$word!. Surfaced by running seed-demo on launch (PR #1260). Point the demo login panel (ROOT_PASSWORD) and the sample token request at Password123! so the advertised dev credential actually works. The framework seed default (Seed:DefaultAdminPassword) is unchanged for non-demo / production seeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): correct update-check version comparison + support --version `fsh info` and `fsh new`'s update check compared versions with string inequality, and the current version carries a `+gitsha` build-metadata suffix, so they always reported "update available" -- even when the local build was NEWER than the latest published prerelease (10.0.0 vs 10.0.0-rc.2, and template 10.0.0 vs 2.0.4-rc). Added a small semver-aware VersionComparer.IsNewer (drops build metadata, ranks a stable release above a prerelease of the same core) and used it in both places, so the hint only shows for a genuine upgrade. Also wired config.SetApplicationVersion so `fsh --version` prints the version instead of erroring "Did you forget the command?". Found while smoke-testing the CLI for release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,template): drop the unimplemented --db sqlserver option The CLI `--db` option and the template's `db` choice symbol advertised SQL Server, but selecting it had NO effect -- the generated app was always PostgreSQL (provider, connection string, and the sole `*.Migrations.PostgreSQL` project). Shipping a no-op option in the grand release would mislead users, so drop it; the scaffold now offers only what actually works (PostgreSQL). Removed: the `--db` CLI option + its interactive prompt (ResolveDatabaseAsync), the `db` template symbol, and the `--db sqlserver` example. The backend's latent multi-provider abstraction (DbProviders / OptionsBuilderExtensions) is left untouched, so real SQL Server support can be added later without re-plumbing. Found while smoke-testing the distribution for release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(template-smoke): drop --db from the scaffold commands The smoke test scaffolded with `dotnet new fsh ... --db postgresql`, which now fails ("unknown option --db") because this PR removed the db template symbol. Drop the flag from both the full and minimal scaffold steps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(readme): modernize and complete the root README Rewrote the README for the 10.0.0 release: a proper hero + pitch, the full feature set (both React apps and all 10 modules — previously only 4 were listed and the front-ends weren't mentioned at all), an accurate tech-stack table, current ports/commands, a deploy section, and prominent links to the documentation site (fullstackhero.net). Removed stale claims (`--db sqlserver`, Redis→now Valkey, old ports) and pointed docs links at the site rather than the docs repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin,multitenancy): users/roles crash, health probes, toast, demo provisioning - admin: unwrap the now-paged roles response in listRoles so the Users role filter and the Roles registry stop throwing "data?.map is not a function". Update Playwright mocks to the real paged shape so the drift is caught. - admin: proxy /health in the Vite dev server so the Health page's /health/live and /health/ready probes reach the API instead of 404ing. - admin: replace the bare toast with a dashboard-aligned FshToaster (tinted icon, accent stripe, animation) and lift the description color toward the foreground for readability. - multitenancy: DemoSeeder records a completed TenantProvisioning row for demo tenants (acme/globex); the tenant detail page now treats a 404 provisioning status as a neutral "Not tracked" state instead of a red FAILURE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): admin → dashboard design unification program Whole-program spec for reskinning the admin app onto the dashboard's design system (Approach A: copy/mirror). Covers end state, token reconciliation, component + page inventory, and a 4-phase plan (tokens → primitives → shell → pages), each phase verified before the next. Avatar presigned upload lands as part of Phase 4 settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat): deliver live messages to channels joined after connect AppHub.OnConnectedAsync only joins the channel groups a user already belonged to at connect time, so a DM/channel created — or a membership granted — after the socket is live never received channel:{id} broadcasts until a page reload. The recipient saw nothing until refresh. - AppHub: add membership-gated, idempotent JoinChannel(Guid) hub method - dashboard chat-page: invoke JoinChannel on channel open + on reconnect - FindOrCreateDm: push ChatChannelAdded to other participants' user:{id} group so a new DM appears in their channel rail without a refresh - channel-rail: handle ChatChannelAdded (was pre-registered but unhandled) - tests: JoinChannelTests (late-joiner receives; non-member rejected) - docs: realtime.md group-join model note Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): phase 1 — adopt dashboard design tokens & global styles Replace admin's "Console" token system with the dashboard's (palette, surfaces, shadows, radii, motion, base atmosphere/paper-grain) and switch fonts to Figtree / Outfit / JetBrains Mono. A temporary LEGACY ADMIN SHIM keeps the old console tokens (--accent-signal, --shadow-card*, --grid-alpha, …) and utility classes (.meta, .section-rule, .card-shell, .code-chip, .canvas-grid/mesh, .caret, .mono-tone*, …) resolving against the new palette so unmigrated pages don't break; the shim is deleted as Phases 2–4 migrate its consumers. Part of the admin → dashboard design unification (spec + PR #1268). Build: admin `npm run build` ✓. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): phase 2 — UI primitives, section/field layout, presigned upload Bring the dashboard's component vocabulary into admin: - ui/: add avatar, dropdown-menu, switch; align button/input/badge/card/ dialog/label/skeleton/table to the dashboard versions (admin-only variants like button `signal` and badge `muted` preserved; `signal` now maps to the unified rose primary). - list/: add SettingsSection, Field/SettingsField, EntityPageHeader, ToneIconTile (the dashboard's header-bar section + field-grid vocabulary). Existing FormShell/FormSection kept until their consumers migrate in Phase 4. - file/: port the presigned ImageInput + use-file-upload hook + api/files client (replaces admin's broken data-URL avatar upload in Phase 4 settings). Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): phase 3 — rebuild app shell on the dashboard pattern Retire the "Console" shell identity and adopt the dashboard's shell: - sidebar: collapsible accordion (220/52px), persisted, active brand bar, collapsed hover tooltips; same nav destinations/icons/permission guards (numeric 01..09 prefixes dropped). nav-items restructured into sections/top/bottom; SidebarNavBody shared with mobile nav. - mobile-nav: dashboard's Sheet-based MobileNavProvider/Trigger, auto-closes on route change, reuses the shared nav body. - topbar: MobileNav trigger → TenantChip (static success dot, no caret) → NotificationBell → user dropdown (theme light/dark, Profile, Settings, destructive Sign-out w/ confirm). Standalone theme toggle + settings link folded into the dropdown; mono breadcrumb retired. - app-shell: drop the chartreuse vignette + gridded backdrop; impersonation banner slot noted for later. - brand-mark: gradient-square "F" + "fullstackhero / Admin" wordmark; the chartreuse FSH/admin lockup retired. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat): reposition hover rail, portal emoji picker, add pinned bar Three dashboard chat UX fixes: - Hover action rail now tucks into the margin immediately beside the bubble instead of drifting to the far pane edge (own messages sit right → rail tucks left; others → right). - Emoji picker is a portaled DropdownMenu anchored to the React button, so it can't be clipped or have its clicks stolen by the next virtualized message row (it was overflowing its row, and the row below intercepted the clicks and surfaced its own hover toolbar). - Pinned messages get a discoverable "N pinned" bar under the channel header that opens the pinned list and jumps to a message — replaces the unlabelled header pin glyph. Inline PINNED tag + ring unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): phase 4 — migrate all pages to the dashboard vocabulary Restyle every admin page onto the dashboard's section/field/page-header vocabulary (EntityPageHeader, SettingsSection, Field, list cards), retiring the FormShell/FormSection 18rem rail, `\ SECTION` markers, and console classes: - settings/* (profile now uses the presigned ImageInput — fixes the data-URL avatar bug), roles/*, users/*, tenants/* (list now matches the users/roles card-table + mobile-card pattern), billing/*, webhooks/*, audits/*, notifications, health, impersonation, auth/*, login, not-found, dashboard. Also re-applies the PR #1267 fixes that this branch (cut from main) was missing: - listRoles() unwraps the paged response (fixes the users/roles `.map` crash). - /health added to the Vite dev proxy. - tenant detail treats a 404 provisioning status as a neutral "Not tracked" state (no retry/poll storm) instead of a red FAILURE. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): login demo-account dialog + modernized tenant detail - login: replace the inline dev callout with a dashboard-style demo-account popup dialog (pick → fills tenant/email/password → instant sign-in, gated on import.meta.env.DEV), and adopt the dashboard's AuthShell logo/header treatment. Existing login behavior preserved. - tenants/detail: full modern pass — Overview hero, InfoRow key/value rows (replacing the old dl/DetailRow), and a proper provisioning step timeline with status nodes. The 404 "Not tracked" provisioning logic is kept intact. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): new-tenant creation as a modern dialog Replace the full /tenants/new page with a CreateTenantDialog launched from the Tenants list "New tenant" button (same fields, validation, operator-supplied admin password, and createTenant mutation; on success closes, refreshes the list, and routes to the new tenant). /tenants/new now redirects to /tenants so existing links don't dead-end. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): audit detail as a sheet + modern filter select - audits: detail now opens as a side Sheet from the list (selected-row state) instead of a full-page route, mirroring the dashboard; /audits/:id redirects to /audits. Same audit query, sections, and fields preserved. - ui/select: new reusable modern single-select built on the DropdownMenu primitive (no new deps) — rounded trigger + chevron, rose-tinted active item. Replaces the raw native <select> filters on the impersonation list (status) and users list (role); filter state/behavior unchanged. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): modernize tenant-detail cards + settings layout parity - tenant-branding-card + active-grants-card: retire the console `\ BRANDING` / `// LIGHT PREVIEW` rail treatment; wrap in SettingsSection, modern rounded swatch grids, Field inputs, polished preview. All branding/palette/grant mutations and polling preserved. - settings/layout: adopt the dashboard's editorial numbered left-nav shell (260px rail, active brand bar, "Settings · {section}" masthead, mobile pill tabs). Admin's actual tabs (Profile/Security/Sessions/Appearance) preserved. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin): health check rows expand reliably (controlled disclosure) Replace the native <details>/<summary> check rows with a controlled useState toggle, and only render the expand affordance (chevron + click) when a check actually reports details. Fixes rows appearing unresponsive. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): user + role creation as modern dialogs Mirror the new-tenant dialog: user and role creation now open well-designed popup dialogs from their list "New …" buttons instead of separate pages. Same fields/validation/mutations (registerUser, upsertRole); on success close, refresh the list query, and route to the created entity. /users/new and /roles/new redirect to their lists. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(dashboard): flatten impersonation banner gradient + rename "Console" → "Dashboard" - impersonation-banner: drop the radial-gradient tone wash and the icon square's linear-gradient; keep the solid muted background, tone border, and cross-tenant left ribbon. - sidebar/mobile-nav: the logo sub-label and footer now read "Dashboard" instead of "Console". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(multitenancy): enforce tenant deactivation (block login + requests) Deactivating a tenant only flipped AppTenantInfo.IsActive in the store — nothing in the auth or request pipeline checked it, so a deactivated tenant's users could still log in and call the API. - Add a post-auth guard in MultitenancyModule.ConfigureMiddleware that 403s any request whose resolved tenant is non-root and inactive. It runs before every endpoint, so it covers the anonymous login/refresh requests too. Operators (JWT tenant claim == root) are exempt so they can still manage/reactivate deactivated tenants cross-tenant. - Refresh the distributed-cache store on activate/deactivate. Finbuckle resolves tenants from a 60-min cache, and TenantService only wrote the EF store, so the guard would otherwise keep seeing the stale "active" copy for up to an hour. - Add the missing regression test: TenantActivationTests only asserted the activation endpoint returned 200, never that access was denied. New test asserts a login attempt is not-403 while active and 403 once deactivated. Scope: enforces IsActive (deactivation). ValidUpto/subscription-expiry is intentionally not enforced here to avoid surprise lockouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): design spec for tenant billing lifecycle Wire the Multitenancy tenant lifecycle to the Billing module: plan-driven subscriptions + invoices on create/renew, billing-interval term model, grace-windowed expiry enforcement, expiry notifications, and PDF invoices. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): phase 1 implementation plan for tenant billing lifecycle Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): add billing interval + annual price to plans BillingPlan gains Interval (Monthly/Yearly) + AnnualPrice with TermMonths/ TermPrice helpers; persisted, exposed via DTO/GetPlans, and accepted by Create/Update plan commands + validators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): add invoice purpose + term period span Invoice gains Purpose (Subscription/Usage) + PeriodStartUtc/PeriodEndUtc via a CreateDraft overload; the per-month unique index now includes Purpose so the subscription and usage streams never collide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): overage-only monthly job + subscription invoice service The monthly job (GenerateInvoiceForPeriodAsync) now bills metered overage only (USG- prefix, Usage purpose) — the plan base fee moves to a new CreateSubscriptionInvoiceAsync (SUB- prefix, Subscription purpose) invoked on tenant create/renew, idempotent per term. Updates the two integration tests that asserted the old base-fee-in-monthly behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): GetPlanTerm cross-module query Read-only Mediator query in Billing.Contracts returning a plan's interval, term length, and unit price so Multitenancy can compute tenant validity without a runtime dependency on Billing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): handle tenant subscribed/renewed events TenantSubscribed/TenantRenewed integration events (Multitenancy.Contracts) are handled in Billing to start/swap the active subscription and issue the term's subscription invoice. Adds a Subscription term-end overload and registers the handlers via AddIntegrationEventHandlers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): seed default plans (free, pro, pro-annual) Seeds the platform plan catalogue once so the trial fallback ('free') resolves on a fresh install; includes a yearly plan to exercise the new interval. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(db): migration for plan interval + invoice purpose Adds Plans.Interval/AnnualPrice and Invoices.Purpose/PeriodStartUtc/PeriodEndUtc; replaces the per-month unique invoice index with one that includes Purpose. Existing rows backfill Purpose=Usage and Interval=Monthly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): default-plan + grace-window options Adds a shared "Billing" config section (DefaultPlanKey, GraceWindowDays) bound locally in Multitenancy (TenantBillingOptions) and Identity (TenantGraceOptions), with sensible defaults so config is optional. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(multitenancy): create tenant subscribes to a plan CreateTenant now resolves a plan (defaulting to the trial plan), sets ValidUpto from the plan term, refreshes the tenant cache, and publishes TenantSubscribedIntegrationEvent so Billing creates the subscription + term invoice. PlanKey is optional on the command with a slug validator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(multitenancy): plan-driven renew replaces explicit-date upgrade RenewTenant extends ValidUpto by one plan term (stacking on remaining time), optionally switches plan, refreshes the tenant cache (fixing the old upgrade cache-staleness gap), and publishes TenantRenewedIntegrationEvent. Removes the explicit-date UpgradeTenant endpoint/handler and its tests; TenantStatusDto now carries Plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(multitenancy): enforce subscription expiry with a grace window The post-auth tenant guard now blocks requests past ValidUpto + grace (not just deactivated tenants); the login/refresh check honors the same grace window so a lapsed tenant can still authenticate during dunning. GetStatus exposes Plan/ExpiryState/GraceEndsUtc. Adds expiry-enforcement + end-to-end tenant-billing integration tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(arch): allow Renew as an endpoint action verb Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing): collision-free invoice numbers + correct invoice-purpose default Invoice numbers now derive from a SHA-256 tenant token instead of an 8-char prefix, so the monthly job no longer clashes on the unique InvoiceNumber index when many tenants share a prefix. Reorders InvoicePurpose so Usage=0 (the CLR default) — Subscription(1) is always written explicitly, fixing subscription invoices being stored as Usage via EF's store-default behavior. Regenerates the migration and updates two integration tests whose assumptions (no subscription for new tenants; global generate count) the auto-subscription feature changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): billing lifecycle UI — plan selector, intervals, renew Create-tenant dialog gains a plan selector (preselects the trial plan; optional so creation never hard-fails if plans are unavailable). Plan form gains billing interval + annual price. Tenant detail shows plan + expiry/grace badges and a Renew / change-plan dialog. Extends the billing + tenants API clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(admin): realign E2E specs with the reskinned dashboard UI The admin app's design unification with the dashboard changed copy, headings, and DOM structure, leaving 42 of 95 Playwright E2E tests asserting the old UI (the "E2E (admin)" job, and the "Frontend CI" gate that depends on it, were red). Update all 16 admin spec files to match the new components: - removed "// SECTION" mono crumbs -> real EntityPageHeader/SettingsSection headings - Console -> Overview/Platform Admin; KPI labels via the Stat ".meta" class - password show-toggle made getByLabel ambiguous -> { exact: true } - create forms are now modal dialogs and audit detail a side sheet -> drive via the trigger + scope to getByRole("dialog") - /health hard-nav 500s (Vite proxies /health -> API) -> serve the SPA shell - assert main-page content before opening a modal (Radix aria-hides main) Selectors verified against the actual components; full admin suite now 95/95 green, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): show plan interval + invoice purpose in billing views Plans list shows the billing interval + per-term price; invoices list and detail show the invoice purpose (Subscription/Usage) and the term span for subscription invoices. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): modernize form dropdowns to the Radix dropdown Replace the native <select> in components/list/select.tsx with the Radix DropdownMenu primitive already used by the filter Select, keeping the public API (value/onValueChange/options/emptyLabel) so every consumer is untouched: the renew + create-tenant plan pickers, the plan-form interval, and the audits/notifications filters. Also swap the raw <select> status filter in the invoices list. Realign the affected E2E specs (audits, notifications, invoices) from native-select assertions to the dropdown's button/menuitem. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(admin/billing): plan create/edit as a dialog Move plan create/edit from the /billing/plans/new and /:id page-forms to a PlanFormDialog opened from the plans list, and remove the now-dead routes. Cover the dialog flows (create, interval-reveals-annual-price, edit-prefill) with new tests in tenant-billing.spec.ts and drop the obsolete page-form tests from plans.spec.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): confirm tenant activate/deactivate + drop redundant tenant chip Activate/deactivate now goes through a styled ConfirmDialog (important, blast- radius-y operation) instead of firing immediately. Removes the topbar TENANT pill since the user dropdown already shows the active tenant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashboard): end impersonation logs out on a cross-app handoff When an operator started impersonation from another app (no dashboard session stashed), ending it minted + installed the operator's account here — dropping a root-tenant SuperAdmin into the tenant dashboard, which login otherwise forbids. Now, with no stash, end-impersonation logs out cleanly instead of restoring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashboard): permission-gate admin-only nav items Sessions + Audit trail require elevated permissions (Sessions.ViewAll / AuditTrails.View), so impersonated or limited users landed on a 403 page. The shared SidebarNavBody now hides nav items whose permission the user lacks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashboard): end impersonation instantly on cross-app handoff The server end call has a 30s timeout, so an intermittently-slow response left the banner stuck on "Ending…". For a cross-app handoff the local impersonation token is disposable, so log out immediately and fire the server end best-effort in the background (grant revocation + audit) instead of blocking the UI on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): spec for tenant-billing production hardening + completion Design spec for completing the tenant billing feature to production grade: backend hardening (clock fix, grace header, indexes, AdjustTenantValidity), Phase 3 notifications (scan job + expiry/invoice events + email handlers), Phase 4 PDF invoices (QuestPDF behind IInvoicePdfRenderer), dashboard self-serve view + expiry banners, and the full regression/integration test plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): implementation plan + spec correction (indexes already exist) Add the task-decomposed implementation plan for the production-hardening pass and correct the spec: the UsageSnapshot unique index and Subscription partial-unique active index already exist (audit was wrong), so no index migration is needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing): use injected TimeProvider in TenantService.RenewAsync RenewAsync derived "now" from DateTime.UtcNow instead of the injected TimeProvider the rest of the service uses, making the renewal stacking math uncontrollable in tests. Add a unit test that pins the clock and asserts the period starts from the injected provider. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): grace header + operator AdjustTenantValidity override - Emit X-Subscription-Grace (days left) during the grace window via Response.OnStarting so the signal survives error responses too. - Add AdjustTenantValidityCommand: a root-only operator override that sets a tenant's ValidUpto to an explicit date (backdating allowed) with no invoice, subscription, or renewal event — for comps, support extensions, or immediate expiry. Logs the change for audit. - Tests: grace-header present in-grace / absent when active; adjust-validity future + backdate-to-expire, no-billing-side-effect, 400/401/403 authz; GetStatusAsync expiry-state boundary unit tests (at ValidUpto / grace end). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(billing): renewal stacking edge cases (double-renew, lapsed restart) Cover renewing twice stacks two terms, and renewing a tenant lapsed 30 days ago restarts the term from now rather than the past validity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): expiry/renewal email notifications (Phase 3) - New integration events: TenantNearingExpiry / TenantEnteredGrace / TenantExpired (Multitenancy.Contracts) and InvoiceIssued (Billing.Contracts). - BillingService publishes InvoiceIssued when a subscription invoice is issued. - TenantExpiryScanJob: daily Hangfire scan classifies each active tenant's state, dedups via a new TenantExpiryNotice ledger in TenantDbContext (one notice per tenant/state/validity period, re-arms on renewal), and publishes the matching event. - Notifications module gains email handlers (IMailService) for the 4 events. - Config: Billing:ExpiryNotificationLeadDays (default 7). Critical fix: background-published lifecycle events must set the Finbuckle tenant context before publishing — BaseDbContext (e.g. WebhookDbContext via the webhook fan-out handler) captures the ambient tenant at construction, so a no-context background scope made its query filter NRE. The scan job now installs the tenant context before publish; the webhook fan-out + email handlers then run correctly. Tests: scan-job records the right notice + dedups on re-run + emails the admin; issuing a subscription invoice emails the admin. NoOpMailService now captures sent mail so email behavior is assertable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(billing): on-demand invoice PDF download (Phase 4) - IInvoicePdfRenderer + QuestPDF implementation (A4 invoice: header, bill-to, period, line-items table, subtotal, notes). License set to Community; the dependency is isolated behind the interface so it's swappable. - GET /api/v1/billing/invoices/{id}/pdf streams application/pdf. Reuses the caller-tenant scoping of GetInvoiceById (View is basic), so one endpoint serves both operators and tenant self-service; cross-tenant ids return 404. - Tests: renderer produces a valid %PDF for full + empty invoices; endpoint returns the tenant's own invoice as PDF and 404 for another tenant's. Note: QuestPDF Community license is free under $1M USD/yr revenue; documented for downstream commercial users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(multitenancy): tenant-self status endpoint for the dashboard GET /api/v1/tenants/me/status resolves the calling tenant from context and returns its plan, validity, and expiry/grace state — powering the dashboard's plan view and expiry warning banner. Tests cover own-status resolution and the unauthenticated 401. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(frontend): tenant billing self-serve + admin glue (Phase 2 completion) Dashboard (tenant-facing): - /subscription page: current plan, validity, expiry badge, usage with limits/ overage, recent invoices. - Global expiry/grace banner in the AppShell (InGrace warning + nearing-expiry info), driven by GET /tenants/me/status; dismissible per session. - Invoice detail page with line items + Download PDF. - Fixed SubscriptionStatus enum to match backend; typed invoice line items; getMyInvoices now consumes the paged envelope. Admin (operator): - Download PDF button on invoice detail. - Adjust-validity dialog on tenant detail (operator override, no invoice), gated by the same permission as Renew. - Plan-form client-side validation: non-negative prices + overage rates. Tests: route-mocked Playwright for the dashboard subscription page, expiry banner states, invoice detail + PDF download, admin PDF button, adjust-validity POST, and plan-form negative-price rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing,identity,multitenancy): close cross-tenant security holes + string-enum API contract Deep audit of the billing/subscription/tenant system — backend. Security (P0, cross-tenant): - Invoice read/PDF/mutation (issue/pay/void), AssignSubscription, Get/CaptureUsage now root-gate via MultitenancyConstants.Root.Id (operator acts cross-tenant; tenants pinned to self). GetInvoices list scopes non-root callers to their own tenant. - GenerateInvoices is now root-only (ForbiddenException for non-root). - RoleService.FilterRootPermissions stripped only a "Permissions.Root." prefix that matched NO real root permission, letting a non-root admin grant root permissions to a role (privilege escalation). Now filtered by the IsRoot permission catalog. Correctness: - Usage/overage invoice was skipped when a subscription invoice shared the month (GenerateInvoiceForPeriodAsync idempotency lacked Purpose==Usage). - Same-plan renewal now advances Subscription.EndUtc (Subscription.Extend) to track the renewed ValidUpto, fixing the dashboard term drift. - IdentityDbInitializer now checks the admin CreateAsync result (silent failure previously marked a tenant provisioned with no usable admin login). - Invoice.Void idempotent; invoice pageSize clamped <=100; AdjustTenantValidity blocks the root tenant. API contract: - Enums serialize as string names globally (JsonStringEnumConverter via ConfigureHttpJsonOptions); [Flags] AuditTag/BodyCapture stay numeric via NumericEnumConverter. Every bug has a failing-first test (BillingTenantIsolationTests, RolePrivilegeEscalationTests, RenewTenantTests, AdjustTenantValidityTests, InvoiceTests). Integration suite: 699 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(admin,dashboard): mirror string-enum contract, permission-gate actions, expiry UX Frontend half of the billing/tenant audit. Enum contract: mirror the API's string enums as string unions across both apps (audits, chat, files, billing); AuditTag stays numeric (bitwise flags). Route-mock fixtures updated to emit string enum values. Admin: permission-gate every plan/invoice/tenant action affordance (previously shown to View-only users and 403-ing on submit); fix invoice-detail stuck "Loading..." on an error state. Dashboard: "Valid for"/validity now reads grace/expired from tenant status (was a healthy day-count off subscription.endUtc); persistent Expired banner; overview no longer masks an API error as a no-plan first-run; real server-side invoice pagination; removed dead Suspended/Cancelled rendering; subscription term now tracks start->end, not the calendar month. Playwright updated/added: admin specs green; dashboard 136 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(release): publish only the CLI + template to NuGet (source-ownership); silence template-pack warnings The publish-release job packed per-module/BuildingBlocks NuGets (only 4 of 10 modules, inconsistently) and pushed them — contradicting the locked source-ownership distribution model where consumers get the full source via `dotnet new fsh`, not runtime packages. Only the `fsh` CLI tool and the template package now publish. Also suppress NU5110/NU5111 on the template pack: the bundled Terraform deploy.ps1 files are template content, not NuGet install scripts, and must stay where they are. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat,tickets,webhooks): close pre-release Tier-1 gaps (membership loss, ticket lifecycle, webhook secrets+authz) Tier-1 release blockers from the 10-module pre-release audit: - Chat: archiving a channel hard-deleted ChannelMember rows (db.Remove cascaded Deleted onto the members; the soft-delete interceptor only rescues owned refs), so restore returned an empty channel and SendMessage 404'd. Archive is now an explicit domain state change (ChatChannel.Archive) that leaves members intact. Un-skips the documented round-trip test. - Tickets: the Closed state was unreachable (no Close()), and Tickets.Update / Tickets.Delete permissions were registered with no handlers (RBAC covenant broken; ListTrashed+Restore had no entry point). Adds Close (Resolved->Closed), Update (edit details), and Delete (soft-delete) slices with validators, permission gates, and a Tickets.Close permission. Completes the trash/restore round-trip. - Webhooks: signing secrets were stored as plaintext in a field named SecretHash. Since the secret is the HMAC key it must stay recoverable, so a hash is wrong -- it is now encrypted at rest via Data Protection (IWebhookSecretProtector), decrypted only at sign time. Adds WebhooksPermissions (View/Create/Delete/Test) and gates every endpoint (previously authn-only). Tests: new Ticket close/update/delete + delete/restore round-trip integration tests; WebhookSecretProtector unit tests (no plaintext at rest, reversible). Build clean (0 warnings, TreatWarningsAsErrors). No schema changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(multitenancy,notifications,tickets,webhooks): Tier-2 correctness gaps From the pre-release audit (non-migration items): - Multitenancy: GetTenantProvisioningStatus and RetryTenantProvisioning endpoints now accept and forward CancellationToken to mediator.Send (graceful shutdown); every other endpoint in the module already did. - Notifications: MentionedInChannelIntegrationEventHandler now fails loud on a tenant-context mismatch instead of risking a cross-tenant write. (NotificationsDbContext captures its tenant at construction, so a post-construction "restore" is a no-op; the guard turns a future context-less publisher into a visible error rather than corrupt data.) - Tickets: ListTicketComments returns 404 for a non-existent ticket instead of a misleading empty 200 (which also leaked ticket-id existence). - Webhooks: GetWebhookSubscriptions/GetWebhookDeliveries gain validators — PageSize=0 previously divided by zero and surfaced as a 500; now a clean 400. Removed both from the HandlerValidatorPairingTests known-missing allowlist. Tests: ListTicketComments 404; webhook PageSize=0 -> 400. Build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(auditing,identity,billing): Tier-3 polish from pre-release audit - Auditing: GetAuditById throws the framework NotFoundException instead of KeyNotFoundException, matching every other module's 404 convention. - Identity: simplify the session-cleanup predicate — cutoffDate (now - retention) already implies ExpiresAt < now, so the redundant clause is removed. - Billing: MonthlyInvoiceJob takes its clock from TimeProvider instead of DateTime.UtcNow, so the billed period is deterministic under test. Deliberately not changed: webhook delete read-then-Remove (tracking is required; AsNoTracking would force a re-attach, not an improvement); Notifications domain DateTime.UtcNow (the domain layer uses DateTime.UtcNow consistently across Tickets/Chat/Notifications — TimeProvider stays an infra/job concern). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(architecture): allow 'Close' endpoint verb for CloseTicket The verb-noun endpoint convention test enumerates allowed action verbs; add 'Close' for the new CloseTicket endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auditing): keep KeyNotFoundException in GetAuditById Reverts the cosmetic NotFoundException swap: the audit exception-type fixtures and severity classification key off the concrete KeyNotFoundException (it is still mapped to 404 by the global handler), and the swap broke AuditExceptionAndFilterTests.GetExceptionAudits_Should_FilterByExceptionType. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(dashboard): DRY pre-release pass + frontend audit Frontend pre-release audit of both React apps (admin + dashboard) committed at superpowers/audits/2026-06-01-frontend-prerelease-audit.md. Headline: the design foundation is already uniform (globals.css is byte-identical across both apps); the remaining work is component-level DRY + a few appearance-feature gaps, captured as a prioritized roadmap. This commit lands the low-risk Batch 1 (uses already-proven shared components): - Extract the duplicated ticket status/priority label+tone maps (verbatim copies in tickets.tsx and ticket-detail.tsx) into lib/ticket-enums.ts so they can't drift. - Convert the catalog products page's hand-rolled pagination to the shared EntityPager (brands/categories already used it), dropping the now-unused ChevronLeft import. Verified: dashboard `tsc -b && vite build` clean, `eslint` 0 errors, Playwright catalog + tickets specs green (22 passed). Batches 2-5 (primitive variant reconciliation, admin appearance parity, shared form primitives + RHF, detail-page skeletons / optimistic updates / row-level perms) are staged in the audit doc — they bear visual/UX judgment and broad surface area, so they are left for review against the live apps rather than landed unsupervised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(packaging): enforce source-ownership model; only CLI + template publish The repo carried two contradictory distribution models: CI publishes only FullStackHero.CLI + the template (source-ownership), yet IsPackable defaulted true and all 33 module/framework projects declared a PackageId, so `dotnet pack` emitted 33 orphan binary packages that are never published, reference each other, and would be unresolvable for external consumers. - Directory.Build.props: IsPackable defaults false; explicitly re-enabled only on the CLI. Add SourceLink, deterministic + CI build, symbols (snupkg) for shipped packages. - Strip the 33 vestigial PackageIds from BuildingBlocks/Modules projects. - Pin Serilog 4.3.2-dev-02419 (a dev-feed build) to stable 4.3.1 — fixes NU5104 pack failure for every runtime module under TreatWarningsAsErrors. - Wire the existing icon.png as PackageIcon on CLI + template; CLI drops its empty XML doc file. - GA 10.0.0: container tags off -rc. - Web.csproj: remove stale Compile/EmbeddedResource/None Remove globs for deleted folders and an obsolete empty-folder placeholder. `dotnet pack` of the solution now emits only FullStackHero.CLI; template packs clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): remove committed secrets from shipped config These ship inside the public template, so any consumer scaffolding the kit inherited them. - appsettings.json: blank the default Hangfire dashboard credentials (was admin/Secure1234!Me). The auth filter is fail-safe — blank config locks the dashboard, matching appsettings.Production.json. Working dev credentials move to appsettings.Development.json so local dev is unaffected. - appsettings.Development.json: blank the committed Ethereal SMTP login; devs generate their own throwaway mailbox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf,refactor: fix hot-path allocations, N+1, reflection, and dead code Verified by the full suite (1769 passed, 1 skipped, incl. Integration.Tests against Postgres which exercises the rewritten chat query). Performance: - Mailing: inject a singleton ISendGridClient instead of constructing SendGridClient (and its HttpClient) per send — fixes socket exhaustion. - Chat ListMyChannels: collapse the per-channel unread CountAsync (N+1, up to 200 round-trips) into one correlated subquery, mirroring SearchTickets. - Eventing InMemoryEventBus: cache the closed handler-interface type + HandleAsync MethodInfo per event type instead of recomputing reflection per publish. - Eventing JsonEventSerializer: cache Type.GetType resolution per event name. - Web ValidationBehavior (runs on every message): materialize validators once, fast-path the 0/1-validator cases, drop the redundant null filter. - Storage S3/Local: source-generated regexes instead of recompiling per upload. - Identity SearchUsers: resolve image URLs in place instead of re-projecting the whole page into a second DTO list. - Persistence DomainEventsInterceptor: drop the no-op SavingChangesAsync override. - Library ConfigureAwait(false) gaps closed across the above. Correctness / dead code: - Tickets: remove the unused Ticket.ToDto() overload (silently returned 0 when Comments wasn't loaded). - Storage LocalStorageService: thread-safe SharedTokenStore via LazyInitializer. - Mailing SendGrid: guard empty recipient list with a clear error. - Seal four public types not designed for inheritance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): update Aspire to 13.4.0 - Aspire.AppHost.Sdk and Aspire.Hosting.{JavaScript,PostgreSQL,Redis}: 13.3.5 -> 13.4.0. - StackExchange.Redis 2.11.0 -> 2.13.17: Aspire.Hosting.Redis 13.4.0 requires >= 2.13.1 (NU1109 downgrade otherwise). Build clean; full suite green (1769 passed, 1 skipped), incl. Caching and the Redis-backed Integration.Tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(eventing): establish tenant context for background event dispatch; run a single outbox dispatcher Background outbox dispatch carried no tenant context, so InMemoryEventBus resolved WebhookFanoutHandler (and constructed its tenant-filtered WebhookDbContext) before any tenant was set. Finbuckle captures TenantInfo at construction, so the tenant query filter dereferenced null and threw NRE; the handler's own in-HandleAsync tenant-set ran too late. Events dead-lettered after 6 retries. Fix systemically in the event pipeline via a new tenant-agnostic IEventTenantScope (Eventing.Abstractions). InMemoryEventBus.PublishSingleAsync now wraps handler resolution+dispatch in a tenant scope keyed on event.TenantId, so the tenant is established BEFORE handler DbContexts are built. AddEventingCore registers a no-op NullEventTenantScope; MultitenancyModule replaces it with a Finbuckle-backed FinbuckleEventTenantScope that sets/restores the context. Also remove the duplicate identity-outbox-dispatcher Hangfire job: it raced the always-on OutboxDispatcherHostedService over the same rows (GetPendingBatchAsync has no row claim), producing doubled dispatch and swallowed PK_InboxMessages duplicate-key errors. The hosted service is now the sole dispatcher. Tests: InMemoryEventBusTenantScopeTests, FinbuckleEventTenantScopeTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * wip: Aspire 13.4.0 tweaks + in-progress Chat/Files enum-to-Contracts migration DOES NOT COMPILE — pushed at request to capture all working-tree changes. Two unrelated, pre-existing in-flight workstreams bundled here: - Aspire 13.4.0 working changes: docker-compose, appsettings.Development.json, AppHost, DbMigrator, HybridCacheRedisTests, and some dashboard file UI tweaks. - Chat/Files enum migration (incomplete): ChannelMemberRole, ChannelType, FileAssetStatus, Visibility moved from the runtime Domain projects into the .Contracts projects, but several runtime references still point at the old FSH.Modules.Chat.Domain / FSH.Modules.Files.Domain locations, so the Chat and Files modules currently fail to build. Finish updating those references (and their tests) before this is mergeable. --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Mukesh Murugan <31455818+iammukeshm@users.noreply.github.com> Co-authored-by: cesarcastrocuba <cesar_castro_cuba@msn.com> --- .agents/rules/api-conventions.md | 93 +- .agents/rules/architecture.md | 269 +- .agents/rules/caching.md | 30 + .agents/rules/database.md | 48 + .agents/rules/eventing.md | 39 + .agents/rules/frontend/admin.md | 38 + .agents/rules/frontend/dashboard.md | 42 + .agents/rules/frontend/shared.md | 90 + .agents/rules/integration-testing.md | 23 + .agents/rules/jobs.md | 36 + .agents/rules/logging.md | 31 + .agents/rules/modules.md | 375 - .agents/rules/modules/auditing.md | 15 + .agents/rules/modules/billing.md | 13 + .agents/rules/modules/catalog.md | 14 + .agents/rules/modules/chat.md | 16 + .agents/rules/modules/files.md | 15 + .agents/rules/modules/identity.md | 37 + .agents/rules/modules/multitenancy.md | 16 + .agents/rules/modules/notifications.md | 13 + .agents/rules/modules/tickets.md | 12 + .agents/rules/modules/webhooks.md | 13 + .agents/rules/persistence.md | 431 -- .agents/rules/realtime.md | 26 + .agents/rules/resilience.md | 19 + .agents/rules/security.md | 26 + .agents/rules/storage.md | 26 + .agents/rules/testing-rules.md | 77 - .agents/rules/testing.md | 47 + .agents/skills/add-entity/SKILL.md | 169 +- .agents/skills/add-feature/SKILL.md | 130 +- .agents/skills/add-full-slice/SKILL.md | 50 + .agents/skills/add-integration-event/SKILL.md | 94 + .agents/skills/add-module/SKILL.md | 193 +- .agents/skills/add-permission/SKILL.md | 72 + .agents/skills/add-react-page/SKILL.md | 121 + .agents/skills/create-migration/SKILL.md | 77 + .agents/skills/mediator-reference/SKILL.md | 136 +- .agents/skills/query-patterns/SKILL.md | 206 +- .agents/skills/testing-guide/SKILL.md | 229 +- .agents/workflows/architecture-guard.md | 121 +- .agents/workflows/code-reviewer.md | 117 +- .agents/workflows/feature-scaffolder.md | 121 +- .agents/workflows/migration-helper.md | 141 +- .agents/workflows/module-creator.md | 160 +- .config/dotnet-tools.json | 7 - .dockerignore | 26 +- .gitattributes | 9 + .github/workflows/backend.yml | 409 + .github/workflows/ci.yml | 362 - .github/workflows/codeql.yml | 5 +- .github/workflows/frontend.yml | 127 + .github/workflows/template-smoke.yml | 116 + .gitignore | 10 +- .mcp.json | 8 - .template.config/template.json | 123 +- AGENTS.md | 137 + CLAUDE.md | 192 +- CONTRIBUTING.md | 2 +- GEMINI.md | 140 +- README-template.md | 122 +- README.md | 246 +- SECURITY.md | 4 +- clients/admin/.dockerignore | 12 + clients/admin/Dockerfile | 38 + clients/admin/docker/config.json.template | 5 + clients/admin/docker/docker-entrypoint.sh | 19 + clients/admin/docker/nginx.conf | 31 + clients/admin/eslint.config.js | 10 + clients/admin/index.html | 2 +- clients/admin/package-lock.json | 2382 +++++- clients/admin/package.json | 10 +- clients/admin/playwright.config.ts | 30 + clients/admin/public/config.json | 5 + clients/admin/public/logo-fullstackhero.png | Bin 0 -> 36399 bytes clients/admin/src/App.tsx | 73 +- clients/admin/src/api/audits.ts | 200 + clients/admin/src/api/billing.ts | 254 + clients/admin/src/api/files.ts | 139 + clients/admin/src/api/health.ts | 63 + clients/admin/src/api/impersonation-grants.ts | 56 + clients/admin/src/api/impersonation.ts | 64 + clients/admin/src/api/notifications.ts | 36 + clients/admin/src/api/roles.ts | 54 +- clients/admin/src/api/sessions.ts | 56 + clients/admin/src/api/tenants.ts | 146 + clients/admin/src/api/two-factor.ts | 31 + clients/admin/src/api/users.ts | 111 +- clients/admin/src/api/webhooks.ts | 95 + clients/admin/src/auth/auth-context.tsx | 133 +- clients/admin/src/auth/jwt.ts | 15 + clients/admin/src/auth/protected-route.tsx | 21 +- clients/admin/src/auth/route-guard.tsx | 49 + clients/admin/src/auth/token-store.ts | 23 + .../admin/src/components/auth/auth-shell.tsx | 107 + .../components/auth/demo-accounts-dialog.tsx | 173 + .../components/billing/plan-form-dialog.tsx | 377 + clients/admin/src/components/brand-mark.tsx | 68 + clients/admin/src/components/empty-state.tsx | 59 + .../admin/src/components/file/image-input.tsx | 197 + .../admin/src/components/forbidden-view.tsx | 59 +- .../impersonation/active-grants-card.tsx | 211 + .../impersonation/impersonate-dialog.tsx | 466 ++ .../impersonation/revoke-grant-dialog.tsx | 166 + clients/admin/src/components/kpi-tile.tsx | 36 + .../admin/src/components/layout/app-shell.tsx | 66 +- .../src/components/layout/mobile-nav.tsx | 169 + .../admin/src/components/layout/nav-items.ts | 221 + .../src/components/layout/sidebar-content.tsx | 8 + .../admin/src/components/layout/sidebar.tsx | 449 +- .../admin/src/components/layout/topbar.tsx | 324 +- .../components/list/entity-page-header.tsx | 61 + .../admin/src/components/list/error-band.tsx | 26 + clients/admin/src/components/list/field.tsx | 67 + .../admin/src/components/list/filter-bar.tsx | 29 + .../admin/src/components/list/form-shell.tsx | 109 + clients/admin/src/components/list/index.ts | 14 + clients/admin/src/components/list/loading.tsx | 26 + .../admin/src/components/list/page-header.tsx | 53 + .../admin/src/components/list/pagination.tsx | 52 + clients/admin/src/components/list/select.tsx | 137 + .../src/components/list/settings-field.tsx | 37 + .../src/components/list/settings-section.tsx | 67 + clients/admin/src/components/list/stat.tsx | 78 + .../src/components/list/tone-icon-tile.tsx | 75 + .../notifications/notification-bell.tsx | 268 + .../components/roles/create-role-dialog.tsx | 165 + clients/admin/src/components/route-error.tsx | 89 +- .../sessions/user-sessions-card.tsx | 212 + .../tenants/adjust-validity-dialog.tsx | 163 + .../tenants/create-tenant-dialog.tsx | 406 + .../tenants/renew-tenant-dialog.tsx | 148 + .../tenants/tenant-branding-card.tsx | 487 ++ .../src/components/theme/theme-provider.tsx | 50 + .../src/components/theme/theme-toggle.tsx | 26 + clients/admin/src/components/ui/avatar.tsx | 129 + clients/admin/src/components/ui/badge.tsx | 49 + clients/admin/src/components/ui/button.tsx | 62 +- clients/admin/src/components/ui/card.tsx | 46 +- .../src/components/ui/confirm-dialog.tsx | 81 + clients/admin/src/components/ui/dialog.tsx | 208 + .../admin/src/components/ui/dropdown-menu.tsx | 127 + clients/admin/src/components/ui/input.tsx | 20 +- clients/admin/src/components/ui/select.tsx | 150 + clients/admin/src/components/ui/skeleton.tsx | 15 + clients/admin/src/components/ui/switch.tsx | 51 + clients/admin/src/components/ui/table.tsx | 97 +- .../components/users/create-user-dialog.tsx | 327 + .../webhooks/create-webhook-dialog.tsx | 248 + clients/admin/src/env.ts | 44 +- clients/admin/src/hooks/use-file-upload.ts | 261 + clients/admin/src/lib/api-client.ts | 61 +- clients/admin/src/lib/permissions.ts | 171 + clients/admin/src/main.tsx | 6 + clients/admin/src/pages/audits/detail.tsx | 451 ++ clients/admin/src/pages/audits/list.tsx | 349 + .../admin/src/pages/auth/confirm-email.tsx | 208 + .../admin/src/pages/auth/forgot-password.tsx | 272 + .../admin/src/pages/auth/reset-password.tsx | 340 + .../src/pages/billing/invoice-detail.tsx | 400 + .../admin/src/pages/billing/invoices-list.tsx | 401 + clients/admin/src/pages/billing/layout.tsx | 54 + .../admin/src/pages/billing/plans-list.tsx | 193 + clients/admin/src/pages/dashboard.tsx | 225 +- clients/admin/src/pages/health/page.tsx | 285 + .../admin/src/pages/impersonation/list.tsx | 361 + .../admin/src/pages/login.demo-accounts.ts | 35 + clients/admin/src/pages/login.tsx | 428 +- clients/admin/src/pages/not-found.tsx | 78 +- .../admin/src/pages/notifications/inbox.tsx | 204 + clients/admin/src/pages/roles/create.tsx | 8 + clients/admin/src/pages/roles/detail.tsx | 513 ++ clients/admin/src/pages/roles/list.tsx | 310 + .../admin/src/pages/settings/appearance.tsx | 109 + clients/admin/src/pages/settings/layout.tsx | 206 + clients/admin/src/pages/settings/profile.tsx | 155 + clients/admin/src/pages/settings/security.tsx | 548 ++ clients/admin/src/pages/settings/sessions.tsx | 242 + clients/admin/src/pages/tenants/create.tsx | 156 +- clients/admin/src/pages/tenants/detail.tsx | 701 +- clients/admin/src/pages/tenants/list.tsx | 369 +- clients/admin/src/pages/users/create.tsx | 273 +- clients/admin/src/pages/users/detail.tsx | 449 +- clients/admin/src/pages/users/list.tsx | 392 +- clients/admin/src/pages/webhooks/detail.tsx | 280 + clients/admin/src/pages/webhooks/list.tsx | 260 + .../admin/src/realtime/realtime-context.tsx | 173 + clients/admin/src/routes.tsx | 211 +- clients/admin/src/styles/globals.css | 1427 +++- clients/admin/tests/audits/audits.spec.ts | 129 + .../admin/tests/auth/confirm-email.spec.ts | 39 + .../admin/tests/auth/forgot-password.spec.ts | 63 + clients/admin/tests/auth/login.spec.ts | 114 + .../admin/tests/auth/reset-password.spec.ts | 81 + .../admin/tests/auth/session-restore.spec.ts | 117 + .../tests/billing/invoice-detail.spec.ts | 292 + clients/admin/tests/billing/invoices.spec.ts | 113 + clients/admin/tests/billing/plans.spec.ts | 104 + .../admin/tests/dashboard/dashboard.spec.ts | 147 + clients/admin/tests/health/health.spec.ts | 103 + clients/admin/tests/helpers/api-mocks.ts | 43 + clients/admin/tests/helpers/auth-seed.ts | 68 + clients/admin/tests/helpers/shell-mocks.ts | 92 + .../tests/impersonation/impersonation.spec.ts | 94 + .../tests/notifications/notifications.spec.ts | 72 + clients/admin/tests/roles/roles.spec.ts | 226 + .../admin/tests/settings/appearance.spec.ts | 61 + clients/admin/tests/settings/profile.spec.ts | 157 + clients/admin/tests/settings/security.spec.ts | 160 + clients/admin/tests/settings/sessions.spec.ts | 127 + clients/admin/tests/tenants/branding.spec.ts | 191 + .../tests/tenants/tenant-billing.spec.ts | 326 + .../admin/tests/tenants/tenant-create.spec.ts | 155 + .../admin/tests/tenants/tenant-detail.spec.ts | 150 + .../admin/tests/tenants/tenants-list.spec.ts | 111 + clients/admin/tests/users/users.spec.ts | 309 + clients/admin/tests/webhooks/webhooks.spec.ts | 114 + clients/admin/vite.config.ts | 1 + clients/dashboard/.dockerignore | 12 + clients/dashboard/Dockerfile | 20 + clients/dashboard/docker/config.json.template | 4 + clients/dashboard/docker/docker-entrypoint.sh | 13 + clients/dashboard/docker/nginx.conf | 27 + clients/dashboard/eslint.config.js | 25 +- clients/dashboard/index.html | 48 +- clients/dashboard/package-lock.json | 1228 ++- clients/dashboard/package.json | 55 +- clients/dashboard/playwright.config.ts | 61 + clients/dashboard/public/config.json | 5 + .../dashboard/public/logo-fullstackhero.png | Bin 0 -> 36399 bytes clients/dashboard/src/App.tsx | 18 +- clients/dashboard/src/api/audits.ts | 79 +- clients/dashboard/src/api/billing.ts | 119 +- clients/dashboard/src/api/catalog.ts | 10 - clients/dashboard/src/api/chat.ts | 26 +- clients/dashboard/src/api/files.ts | 39 +- clients/dashboard/src/api/identity.ts | 175 +- .../dashboard/src/api/permissions-catalog.ts | 94 +- clients/dashboard/src/auth/auth-context.tsx | 83 +- .../src/auth/impersonation-handoff.ts | 55 + clients/dashboard/src/auth/jwt.ts | 15 + .../dashboard/src/auth/protected-route.tsx | 21 +- .../src/components/auth/auth-shell.tsx | 106 + .../components/auth/demo-accounts-dialog.tsx | 266 + .../command-palette-dialog.tsx | 523 ++ .../command-palette/command-palette.tsx | 375 +- .../src/components/file/file-dropzone.tsx | 62 +- .../src/components/file/file-gallery.tsx | 367 - .../components/file/file-preview-dialog.tsx | 269 +- .../src/components/file/image-input.tsx | 6 +- .../components/file/product-image-manager.tsx | 20 +- .../src/components/identity/user-picker.tsx | 220 + .../src/components/layout/app-shell.tsx | 2 + .../src/components/layout/expiry-banner.tsx | 163 + .../layout/impersonation-banner.tsx | 168 +- .../src/components/layout/mobile-nav.tsx | 21 +- .../src/components/layout/nav-data.ts | 30 +- .../src/components/layout/sidebar.tsx | 57 +- .../src/components/layout/topbar.tsx | 381 +- .../src/components/list/combobox.tsx | 119 +- .../src/components/list/density-toggle.tsx | 90 - .../src/components/list/empty-state.tsx | 171 - .../src/components/list/entity-detail.tsx | 321 + .../src/components/list/entity-shell.tsx | 534 ++ .../src/components/list/error-band.tsx | 6 +- .../dashboard/src/components/list/field.tsx | 13 +- .../dashboard/src/components/list/index.ts | 39 +- .../src/components/list/list-hero.tsx | 202 - .../src/components/list/page-hero.tsx | 63 +- .../src/components/list/pagination.tsx | 44 - .../src/components/list/sort-chips.tsx | 85 - .../dashboard/src/components/list/stat.tsx | 77 - .../src/components/list/tone-icon-tile.tsx | 79 + .../notifications/chat-global-notifier.tsx | 14 +- .../notifications/chat-unread-badge.tsx | 2 +- .../notifications/notification-bell.tsx | 89 +- .../realtime/realtime-status-pill.tsx | 15 +- .../dashboard/src/components/route-error.tsx | 13 +- .../src/components/sse/live-feed.tsx | 135 - .../src/components/sse/sse-status-badge.tsx | 43 - .../components/theme/appearance-options.ts | 48 +- .../src/components/theme/theme-provider.tsx | 33 + .../src/components/theme/theme-toggle.tsx | 57 - .../dashboard/src/components/ui/avatar.tsx | 13 + clients/dashboard/src/components/ui/badge.tsx | 2 +- .../dashboard/src/components/ui/button.tsx | 66 +- clients/dashboard/src/components/ui/card.tsx | 36 +- .../dashboard/src/components/ui/dialog.tsx | 24 +- .../src/components/ui/dropdown-menu.tsx | 25 +- clients/dashboard/src/components/ui/input.tsx | 25 +- .../dashboard/src/components/ui/switch.tsx | 2 +- clients/dashboard/src/env.ts | 47 +- clients/dashboard/src/lib/api-client.ts | 56 +- clients/dashboard/src/lib/ticket-enums.ts | 33 + clients/dashboard/src/main.tsx | 11 + clients/dashboard/src/pages/activity.tsx | 219 +- clients/dashboard/src/pages/audits.tsx | 655 +- .../src/pages/auth/confirm-email.tsx | 166 + .../src/pages/auth/forgot-password.tsx | 224 + .../src/pages/auth/reset-password.tsx | 328 + .../dashboard/src/pages/catalog/brands.tsx | 773 +- .../src/pages/catalog/categories.tsx | 740 +- .../src/pages/catalog/product-detail.tsx | 912 +-- .../dashboard/src/pages/catalog/products.tsx | 1198 ++- .../dashboard/src/pages/chat/channel-rail.tsx | 210 +- .../src/pages/chat/channel-settings.tsx | 28 +- .../dashboard/src/pages/chat/chat-page.tsx | 162 +- .../dashboard/src/pages/chat/chat-pinned.tsx | 93 +- .../dashboard/src/pages/chat/chat-search.tsx | 29 +- .../dashboard/src/pages/chat/chat-utils.ts | 6 +- clients/dashboard/src/pages/chat/chat.css | 189 + clients/dashboard/src/pages/chat/composer.tsx | 73 +- .../src/pages/chat/mention-picker.tsx | 12 +- .../dashboard/src/pages/chat/message-list.tsx | 28 +- clients/dashboard/src/pages/chat/message.tsx | 187 +- .../src/pages/chat/typing-indicator.tsx | 7 +- .../dashboard/src/pages/files/my-files.tsx | 712 +- clients/dashboard/src/pages/health.tsx | 596 +- .../src/pages/identity/group-detail.tsx | 360 +- .../dashboard/src/pages/identity/groups.tsx | 367 +- .../src/pages/identity/role-detail.tsx | 1045 ++- .../dashboard/src/pages/identity/roles.tsx | 372 +- .../src/pages/identity/user-detail.tsx | 643 +- .../dashboard/src/pages/identity/users.tsx | 749 +- .../dashboard/src/pages/invoice-detail.tsx | 396 + clients/dashboard/src/pages/invoices.tsx | 577 +- .../src/pages/login.demo-accounts.ts | 10 +- .../dashboard/src/pages/login.demo-panel.tsx | 358 - clients/dashboard/src/pages/login.tsx | 642 +- clients/dashboard/src/pages/not-found.tsx | 325 +- clients/dashboard/src/pages/overview.tsx | 1486 ++-- .../dashboard/src/pages/settings/api-keys.tsx | 125 +- .../src/pages/settings/appearance.tsx | 27 +- .../src/pages/settings/notifications.tsx | 135 +- .../dashboard/src/pages/settings/profile.tsx | 189 +- .../dashboard/src/pages/settings/security.tsx | 560 +- .../src/pages/settings/settings-layout.tsx | 269 +- clients/dashboard/src/pages/subscription.tsx | 540 ++ .../dashboard/src/pages/system/sessions.tsx | 591 +- clients/dashboard/src/pages/system/trash.tsx | 526 +- .../src/pages/tickets/ticket-detail.tsx | 743 +- .../dashboard/src/pages/tickets/tickets.tsx | 845 +-- .../src/realtime/realtime-context.tsx | 75 +- .../dashboard/src/realtime/use-presence.ts | 9 - clients/dashboard/src/routes.tsx | 44 +- clients/dashboard/src/sse/sse-context.tsx | 56 +- clients/dashboard/src/styles/globals.css | 1694 ++--- .../tests/auth/confirm-email.spec.ts | 63 + .../tests/auth/forgot-password.spec.ts | 71 + clients/dashboard/tests/auth/login.spec.ts | 162 + .../tests/auth/reset-password.spec.ts | 126 + .../tests/auth/session-restore.spec.ts | 111 + .../tests/billing/subscription.spec.ts | 274 + .../dashboard/tests/catalog/catalog.spec.ts | 253 + clients/dashboard/tests/chat/chat.spec.ts | 177 + clients/dashboard/tests/files/files.spec.ts | 139 + clients/dashboard/tests/helpers/api-mocks.ts | 108 + clients/dashboard/tests/helpers/auth-seed.ts | 81 + .../dashboard/tests/helpers/shell-mocks.ts | 80 + .../dashboard/tests/identity/groups.spec.ts | 142 + .../dashboard/tests/identity/roles.spec.ts | 137 + .../dashboard/tests/identity/users.spec.ts | 157 + .../dashboard/tests/overview/overview.spec.ts | 172 + .../dashboard/tests/settings/api-keys.spec.ts | 47 + .../tests/settings/appearance.spec.ts | 77 + .../tests/settings/notifications.spec.ts | 46 + .../dashboard/tests/settings/profile.spec.ts | 122 + .../dashboard/tests/settings/security.spec.ts | 226 + clients/dashboard/tests/system/audits.spec.ts | 143 + clients/dashboard/tests/system/health.spec.ts | 116 + .../dashboard/tests/system/sessions.spec.ts | 126 + clients/dashboard/tests/system/trash.spec.ts | 115 + .../tests/tickets/tickets-list.spec.ts | 141 + .../tests/tickets/user-picker.spec.ts | 165 + clients/dashboard/vite.config.ts | 11 +- coverage.runsettings | 22 + deploy/docker/.env.example | 53 + deploy/docker/README.md | 114 + deploy/docker/docker-compose.yml | 181 + .../postgres-init/01-create-databases.sql | 12 + deploy/terraform/apps/starter/README.md | 67 +- .../starter/app_stack/.terraform.lock.hcl | 29 + .../apps/starter/app_stack/backend.tf | 6 + .../terraform/apps/starter/app_stack/main.tf | 168 +- .../apps/starter/app_stack/outputs.tf | 26 + .../apps/starter/app_stack/providers.tf | 22 + .../apps/starter/app_stack/variables.tf | 98 + .../apps/starter/app_stack/versions.tf | 7 +- deploy/terraform/apps/starter/deploy.ps1 | 106 + deploy/terraform/apps/starter/deploy.sh | 128 + .../envs/dev/us-east-1/terraform.tfvars | 8 + .../envs/prod/us-east-1/terraform.tfvars | 14 + .../envs/staging/us-east-1/terraform.tfvars | 13 + .../terraform/apps/starter/shared/backend.tf | 5 - deploy/terraform/apps/starter/shared/main.tf | 114 - .../terraform/apps/starter/shared/outputs.tf | 69 - .../apps/starter/shared/variables.tf | 381 - deploy/terraform/bootstrap/main.tf | 4 +- deploy/terraform/modules/alb/versions.tf | 6 +- .../modules/cloudwatch_alarms/versions.tf | 6 +- .../terraform/modules/ecs_cluster/versions.tf | 6 +- deploy/terraform/modules/ecs_service/main.tf | 4 +- .../terraform/modules/ecs_service/versions.tf | 6 +- .../modules/elasticache_redis/main.tf | 4 +- .../modules/elasticache_redis/versions.tf | 6 +- deploy/terraform/modules/network/versions.tf | 6 +- .../modules/rds_postgres/versions.tf | 6 +- .../terraform/modules/s3_bucket/versions.tf | 6 +- deploy/terraform/modules/static_site/main.tf | 222 + .../terraform/modules/static_site/outputs.tf | 39 + .../modules/static_site/variables.tf | 164 + .../terraform/modules/static_site/versions.tf | 12 + deploy/terraform/modules/waf/versions.tf | 6 +- docker-compose.yml | 78 - docs/.gitignore | 21 - docs/.vscode/extensions.json | 4 - docs/.vscode/launch.json | 11 - docs/.wrangler/deploy/config.json | 1 - ...48b3e8089669e0eefcaee6437c0000019d2bc2d79a | Bin 16606 -> 0 bytes ...6f8ab3f1f466fbd34204285e9e0000019d2d779f24 | Bin 54056 -> 0 bytes ...f4c70edb2a356577476b55dd0e0000019d2b787b4c | 0 ...268ec678187f6b9edd273622920000019d2b787b37 | 0 ...cd8f76b98bb3e14b24200906000000019d2b787b49 | 0 ...d1af2e5fc19a403e8361420dbd0000019d2b6f6b34 | Bin 9458 -> 0 bytes ...8d2669fe5f72696db8c0c039610000019d2d882fb4 | Bin 6128 -> 0 bytes ...1890ec35b3fe419b0f5a9a70530000019d2b787b3e | 0 ...08bf76c8397cee5a255cfd5e6c0000019d2d7557d5 | Bin 6128 -> 0 bytes ...78ad423c786c0b2be9db5d41bf0000019d2b787b46 | 0 ...09ae9d9c5746c6fead4b0d46be0000019d2b9161e3 | Bin 3070 -> 0 bytes ...480006eb7d9cbd8f62b3a684630000019d2b787b42 | 0 ...1baf92d034efbcaaf4336379640ed744ded.sqlite | Bin 24576 -> 0 bytes docs/ISSUE_TRIAGE_2026-05-14.md | 121 - docs/README.md | 49 - docs/astro.config.mjs | 25 - docs/package-lock.json | 6561 ----------------- docs/package.json | 21 - docs/public/apple-touch-icon.png | Bin 14027 -> 0 bytes docs/public/favicon-96x96.png | Bin 8550 -> 0 bytes docs/public/favicon.ico | Bin 15086 -> 0 bytes docs/public/favicon.svg | 1 - docs/public/llms-full.txt | 89 - docs/public/llms.txt | 51 - docs/public/robots.txt | 34 - docs/public/site.webmanifest | 21 - docs/public/web-app-manifest-192x192.png | Bin 13948 -> 0 bytes docs/public/web-app-manifest-512x512.png | Bin 51910 -> 0 bytes docs/src/assets/apple-touch-icon.png | Bin 14027 -> 0 bytes docs/src/assets/favicon-96x96.png | Bin 8550 -> 0 bytes docs/src/assets/favicon.ico | Bin 15086 -> 0 bytes docs/src/assets/favicon.svg | 1 - docs/src/assets/icon.png | Bin 153939 -> 0 bytes docs/src/assets/mukesh_murugan.png | Bin 3304883 -> 0 bytes docs/src/assets/site.webmanifest | 21 - docs/src/assets/web-app-manifest-192x192.png | Bin 13948 -> 0 bytes docs/src/assets/web-app-manifest-512x512.png | Bin 51910 -> 0 bytes docs/src/components/Aside.astro | 112 - docs/src/components/Badge.astro | 38 - docs/src/components/Breadcrumbs.astro | 84 - docs/src/components/DocsPagination.astro | 37 - docs/src/components/DocsSidebar.astro | 48 - docs/src/components/DocsToc.astro | 91 - docs/src/components/FileTree.astro | 40 - docs/src/components/FileTreeNode.astro | 127 - docs/src/components/Footer.astro | 117 - docs/src/components/Nav.astro | 284 - docs/src/components/SearchModal.astro | 284 - docs/src/components/Steps.astro | 100 - docs/src/components/TabPanel.astro | 4 - docs/src/components/Tabs.astro | 93 - docs/src/config/docs.config.ts | 133 - docs/src/content.config.ts | 12 - docs/src/content/docs/adding-a-feature.mdx | 275 - docs/src/content/docs/adding-a-module.mdx | 280 - docs/src/content/docs/architecture.mdx | 421 -- docs/src/content/docs/auditing.mdx | 151 - .../docs/authentication-and-authorization.mdx | 80 - docs/src/content/docs/authentication.mdx | 186 - .../content/docs/aws-terraform-deployment.mdx | 899 --- docs/src/content/docs/background-jobs.mdx | 182 - .../content/docs/building-blocks-overview.mdx | 132 - docs/src/content/docs/caching.mdx | 233 - docs/src/content/docs/ci-cd-pipelines.mdx | 98 - .../content/docs/configuration-reference.mdx | 454 -- docs/src/content/docs/contributing.mdx | 110 - docs/src/content/docs/core-building-block.mdx | 284 - docs/src/content/docs/cqrs.mdx | 214 - docs/src/content/docs/deployment-overview.mdx | 56 - docs/src/content/docs/docker.mdx | 124 - docs/src/content/docs/domain-events.mdx | 207 - docs/src/content/docs/dotnet-aspire.mdx | 81 - docs/src/content/docs/eventing.mdx | 372 - docs/src/content/docs/exception-handling.mdx | 115 - docs/src/content/docs/feature-flags.mdx | 199 - docs/src/content/docs/file-storage.mdx | 275 - docs/src/content/docs/http-resilience.mdx | 152 - docs/src/content/docs/idempotency.mdx | 160 - docs/src/content/docs/identity-module.mdx | 203 - docs/src/content/docs/introduction.mdx | 141 - docs/src/content/docs/mailing.mdx | 212 - docs/src/content/docs/module-system.mdx | 203 - .../content/docs/multitenancy-deep-dive.mdx | 97 - docs/src/content/docs/multitenancy.mdx | 157 - docs/src/content/docs/observability.mdx | 142 - .../src/content/docs/outbox-inbox-pattern.mdx | 187 - .../docs/persistence-building-block.mdx | 283 - docs/src/content/docs/prerequisites.mdx | 242 - docs/src/content/docs/project-structure.mdx | 204 - docs/src/content/docs/querying-audits.mdx | 123 - docs/src/content/docs/quick-start.mdx | 173 - docs/src/content/docs/rate-limiting.mdx | 126 - .../content/docs/roles-and-permissions.mdx | 216 - docs/src/content/docs/security-headers.mdx | 81 - docs/src/content/docs/server-sent-events.mdx | 226 - docs/src/content/docs/sessions-and-groups.mdx | 117 - docs/src/content/docs/shared-library.mdx | 357 - .../content/docs/specification-pattern.mdx | 207 - docs/src/content/docs/tenant-provisioning.mdx | 134 - docs/src/content/docs/testing.mdx | 448 -- docs/src/content/docs/user-management.mdx | 137 - docs/src/content/docs/web-building-block.mdx | 526 -- docs/src/content/docs/webhooks.mdx | 224 - docs/src/layouts/Docs.astro | 333 - docs/src/layouts/Landing.astro | 174 - docs/src/pages/404.astro | 97 - .../pages/dotnet-starter-kit/[...slug].astro | 24 - docs/src/pages/index.astro | 2358 ------ docs/src/styles/docs.css | 813 -- .../plans/2026-05-12-files-module-phase-a.md | 2648 ------- .../2026-05-13-chat-module-implementation.md | 2021 ----- ...chat-slice-4-deferred-ui-implementation.md | 834 --- .../specs/2026-05-12-files-module-design.md | 463 -- .../specs/2026-05-13-chat-module-design.md | 443 -- .../2026-05-13-chat-slice-4-deferred-ui.md | 278 - .../specs/2026-05-14-backend-health-audit.md | 84 - ...-05-14-remove-api-auto-migration-design.md | 257 - ...min-dashboard-design-unification-design.md | 212 + docs/tsconfig.json | 5 - global.json | 6 + requirements/frontend-and-platform.md | 79 - scripts/openapi/README.md | 28 - scripts/openapi/check-openapi-drift.ps1 | 21 - scripts/openapi/generate-api-clients.ps1 | 21 - scripts/openapi/nswag-playground.json | 106 - scripts/test-cli.ps1 | 119 - src/BuildingBlocks/Caching/CacheKeys.cs | 6 + src/BuildingBlocks/Caching/Caching.csproj | 4 +- src/BuildingBlocks/Caching/Extensions.cs | 32 +- src/BuildingBlocks/Core/Core.csproj | 3 +- .../Core/Domain/IGlobalEntity.cs | 13 + .../Eventing.Abstractions.csproj | 1 - .../IEventTenantScope.cs | 27 + src/BuildingBlocks/Eventing/Eventing.csproj | 1 - .../Eventing/InMemory/InMemoryEventBus.cs | 73 +- .../Eventing/Inbox/InboxMessage.cs | 10 +- .../Eventing/NullEventTenantScope.cs | 19 + .../Eventing/Outbox/OutboxMessage.cs | 10 +- .../Serialization/JsonEventSerializer.cs | 8 +- .../Eventing/ServiceCollectionExtensions.cs | 7 + src/BuildingBlocks/Jobs/Jobs.csproj | 3 +- src/BuildingBlocks/Mailing/Extensions.cs | 14 +- src/BuildingBlocks/Mailing/Mailing.csproj | 3 +- .../Mailing/Services/SendGridMailService.cs | 15 +- .../Persistence/Context/BaseDbContext.cs | 5 + .../Inteceptors/DomainEventsInterceptor.cs | 21 +- .../Persistence/Persistence.csproj | 3 +- .../Persistence/TenantIsolationExtensions.cs | 49 + src/BuildingBlocks/Quota/Quota.csproj | 1 - .../Quota/QuotaEnforcementMiddleware.cs | 10 +- .../ITenantInitialPasswordBuffer.cs | 23 + .../Multitenancy/MultitenancyConstants.cs | 3 - src/BuildingBlocks/Shared/Shared.csproj | 3 +- .../Shared/Storage/FileUploadRequest.cs | 2 +- src/BuildingBlocks/Storage/FileType.cs | 2 +- .../Storage/Local/LocalStorageService.cs | 16 +- .../Storage/S3/S3StorageService.cs | 13 +- src/BuildingBlocks/Storage/Storage.csproj | 1 - .../Web/Exceptions/GlobalExceptionHandler.cs | 13 + src/BuildingBlocks/Web/Extensions.cs | 2 +- .../Mediator/Behaviors/ValidationBehavior.cs | 29 +- src/BuildingBlocks/Web/Realtime/AppHub.cs | 23 + .../Web/Realtime/PresenceTracker.cs | 11 +- src/BuildingBlocks/Web/Web.csproj | 19 +- src/Directory.Build.props | 31 +- src/Directory.Packages.props | 43 +- src/FSH.Starter.slnx | 14 +- .../DevSeeding/DevDataSeeder.cs | 433 -- src/Host/FSH.Starter.Api/Dockerfile | 36 +- .../FSH.Starter.Api/FSH.Starter.Api.csproj | 3 +- src/Host/FSH.Starter.Api/Program.cs | 19 +- .../Requests/Identity/identity-token.http | 2 +- .../appsettings.Development.json | 16 +- .../appsettings.Production.json | 2 +- src/Host/FSH.Starter.Api/appsettings.json | 16 +- src/Host/FSH.Starter.AppHost/AppHost.cs | 110 +- .../FSH.Starter.AppHost.csproj | 2 +- .../DemoSeed/DemoSeeder.cs | 770 ++ src/Host/FSH.Starter.DbMigrator/Dockerfile | 28 + .../FSH.Starter.DbMigrator.csproj | 3 +- .../FSH.Starter.DbMigrator/MigratorCommand.cs | 7 +- src/Host/FSH.Starter.DbMigrator/Program.cs | 99 +- src/Host/FSH.Starter.DbMigrator/README.md | 27 +- ...gPlanIntervalAndInvoicePurpose.Designer.cs | 299 + ...20_BillingPlanIntervalAndInvoicePurpose.cs | 107 + .../Billing/BillingDbContextModelSnapshot.cs | 26 +- ...7105359_CatalogTenantIsolation.Designer.cs | 310 + .../20260517105359_CatalogTenantIsolation.cs | 174 + .../Catalog/CatalogDbContextModelSnapshot.cs | 38 +- ...0517105429_ChatTenantIsolation.Designer.cs | 381 + .../20260517105429_ChatTenantIsolation.cs | 196 + .../Chat/ChatDbContextModelSnapshot.cs | 55 +- ...517105451_FilesTenantIsolation.Designer.cs | 126 + .../20260517105451_FilesTenantIsolation.cs | 57 + .../Files/FilesDbContextModelSnapshot.cs | 18 +- ...0517022531_ImpersonationGrants.Designer.cs | 827 +++ .../20260517022531_ImpersonationGrants.cs | 72 + ...105546_IdentityTenantIsolation.Designer.cs | 839 +++ .../20260517105546_IdentityTenantIsolation.cs | 44 + .../IdentityDbContextModelSnapshot.cs | 108 +- ...8151105_AddTenantExpiryNotices.Designer.cs | 355 + .../20260528151105_AddTenantExpiryNotices.cs | 46 + .../TenantDbContextModelSnapshot.cs | 33 +- ...0_NotificationsTenantIsolation.Designer.cs | 88 + ...0517105440_NotificationsTenantIsolation.cs | 31 + .../NotificationsDbContextModelSnapshot.cs | 8 +- ...7105416_TicketsTenantIsolation.Designer.cs | 176 + .../20260517105416_TicketsTenantIsolation.cs | 70 + .../Tickets/TicketsDbContextModelSnapshot.cs | 23 +- .../Modules.Auditing.Contracts/AuditEnums.cs | 6 +- .../Modules.Auditing.Contracts.csproj | 3 +- .../NumericEnumConverter.cs | 41 + .../Auditing/Modules.Auditing/Core/Audit.cs | 14 +- .../GetAuditById/GetAuditByIdQueryHandler.cs | 3 + .../v1/GetAudits/GetAuditsQueryHandler.cs | 3 +- .../GetExceptionAuditsQueryHandler.cs | 9 +- .../GetSecurityAuditsQueryHandler.cs | 5 +- .../Modules.Auditing/Modules.Auditing.csproj | 3 +- .../Persistence/AuditDbContext.cs | 28 +- .../Persistence/AuditJsonbFunctions.cs | 32 + .../Modules.Billing.Contracts/BillingEnums.cs | 15 + .../Dtos/BillingPlanDto.cs | 4 +- .../Dtos/InvoiceDto.cs | 5 +- .../Events/InvoiceIssuedIntegrationEvent.cs | 22 + .../Modules.Billing.Contracts.csproj | 2 +- .../v1/Plans/CreatePlanCommand.cs | 4 +- .../v1/Plans/GetPlanTermQuery.cs | 18 + .../v1/Plans/UpdatePlanCommand.cs | 4 +- .../Billing/Modules.Billing/BillingModule.cs | 7 + .../Data/BillingDbInitializer.cs | 20 +- .../BillingPlanConfiguration.cs | 2 + .../Configurations/InvoiceConfiguration.cs | 9 +- .../Modules.Billing/Domain/BillingPlan.cs | 49 +- .../Billing/Modules.Billing/Domain/Invoice.cs | 33 + .../Modules.Billing/Domain/Subscription.cs | 19 + .../GenerateInvoicesCommandHandler.cs | 17 +- .../GetInvoiceByIdQueryHandler.cs | 18 +- .../GetInvoicePdf/GetInvoicePdfEndpoint.cs | 28 + .../GetInvoicePdf/GetInvoicePdfQuery.cs | 9 + .../GetInvoicePdfQueryHandler.cs | 40 + .../GetInvoices/GetInvoicesEndpoint.cs | 2 +- .../GetInvoices/GetInvoicesQueryHandler.cs | 19 +- .../GetMyInvoices/GetMyInvoicesEndpoint.cs | 2 +- .../Features/v1/Invoices/InvoiceMappings.cs | 5 +- .../CreatePlan/CreatePlanCommandHandler.cs | 3 +- .../CreatePlan/CreatePlanCommandValidator.cs | 2 + .../GetPlanTerm/GetPlanTermQueryHandler.cs | 32 + .../v1/Plans/GetPlans/GetPlansQueryHandler.cs | 2 +- .../UpdatePlan/UpdatePlanCommandHandler.cs | 2 +- .../UpdatePlan/UpdatePlanCommandValidator.cs | 2 + .../AssignSubscriptionCommandHandler.cs | 18 +- .../GetSubscriptionQueryHandler.cs | 9 +- .../CaptureUsageSnapshotsCommandHandler.cs | 16 +- .../GetUsageSnapshotsQueryHandler.cs | 18 +- .../TenantRenewedIntegrationEventHandler.cs | 48 + ...TenantSubscribedIntegrationEventHandler.cs | 38 + .../TenantSubscriptionMaintenance.cs | 53 + .../Modules.Billing/Modules.Billing.csproj | 9 +- .../Services/BillingService.cs | 138 +- .../Services/IBillingService.cs | 12 + .../Services/IInvoicePdfRenderer.cs | 9 + .../Services/InvoicePdfRenderer.cs | 116 + .../Services/MonthlyInvoiceJob.cs | 6 +- .../Modules.Catalog.Contracts.csproj | 1 - .../Modules.Catalog/Data/CatalogDbContext.cs | 4 +- .../Data/CatalogDbInitializer.cs | 51 +- .../Modules.Catalog/Data/CatalogSeedData.cs | 17 +- .../Configurations/ProductConfiguration.cs | 4 + .../Modules.Catalog/Modules.Catalog.csproj | 1 - .../Modules.Chat.Contracts.csproj | 1 - .../v1/DTOs/ChannelDto.cs | 2 +- .../v1/DTOs/ChannelMemberDto.cs | 2 +- .../v1/DTOs/ChannelMemberRole.cs | 12 + .../v1/DTOs/ChannelType.cs | 13 + .../Chat/Modules.Chat/Data/ChatDbContext.cs | 4 +- .../Chat/Modules.Chat/Domain/ChannelMember.cs | 5 +- .../Modules.Chat/Domain/ChannelMemberRole.cs | 7 - .../Chat/Modules.Chat/Domain/ChannelType.cs | 8 - .../Chat/Modules.Chat/Domain/ChatChannel.cs | 18 + .../Events/ChannelCreatedDomainEvent.cs | 1 + .../AddChannelMembersCommandHandler.cs | 3 +- .../ArchiveChannelCommandHandler.cs | 6 +- .../FindOrCreateDmCommandHandler.cs | 23 + .../ListMyChannelsQueryHandler.cs | 28 +- .../v1/Internal/ChannelAuthorization.cs | 1 + .../Features/v1/Internal/ChatMappers.cs | 4 +- .../Chat/Modules.Chat/Modules.Chat.csproj | 1 - .../IFileAccessPolicy.cs | 9 + .../Modules.Files.Contracts.csproj | 1 - .../Commands/ChangeFileVisibilityCommand.cs | 12 + .../v1/Commands/RequestUploadUrlCommand.cs | 2 +- .../v1/DTOs/FileAssetDto.cs | 13 +- .../v1/DTOs/FileAssetStatus.cs | 13 + .../v1/DTOs/Visibility.cs | 13 + .../v1/Queries/ListSharedFilesQuery.cs | 15 + .../Modules.Files/Data/FilesDbContext.cs | 4 +- .../Domain/Events/FileFinalizedDomainEvent.cs | 1 + .../Files/Modules.Files/Domain/FileAsset.cs | 20 + .../Modules.Files/Domain/FileAssetStatus.cs | 8 - .../Files/Modules.Files/Domain/Visibility.cs | 7 - .../ChangeFileVisibilityCommandHandler.cs | 57 + .../ChangeFileVisibilityCommandValidator.cs | 16 + .../ChangeFileVisibilityEndpoint.cs | 33 + .../Features/v1/Internal/FileAssetMapper.cs | 4 +- .../v1/ListMyFiles/ListMyFilesQueryHandler.cs | 13 +- .../ListSharedFilesEndpoint.cs | 20 + .../ListSharedFilesQueryHandler.cs | 48 + .../ListSharedFilesQueryValidator.cs | 13 + .../RequestUploadUrlCommandHandler.cs | 2 +- .../RequestUploadUrlCommandValidator.cs | 2 +- .../Files/Modules.Files/FilesModule.cs | 20 +- .../Jobs/PurgeOrphanedFilesJob.cs | 1 + .../Files/Modules.Files/Modules.Files.csproj | 1 - .../Authorization/IdentityPermissions.cs | 12 + .../DTOs/PermissionCatalogEntryDto.cs | 16 + .../DTOs/UserDto.cs | 3 + .../Modules.Identity.Contracts.csproj | 3 +- .../Services/IImpersonationGrantService.cs | 54 + .../Services/IRoleService.cs | 7 +- .../Services/ITokenService.cs | 5 +- .../Services/IUserPasswordService.cs | 2 +- .../Services/IUserProfileService.cs | 8 +- .../Services/IUserRegistrationService.cs | 4 +- .../Services/IUserService.cs | 16 +- .../Services/IUserStatusService.cs | 2 +- .../GetImpersonationGrantsQuery.cs | 14 + .../v1/Impersonation/ImpersonationGrantDto.cs | 39 + .../RevokeImpersonationGrantCommand.cs | 8 + .../StartImpersonationCommand.cs | 6 +- .../GetPermissionCatalogQuery.cs | 12 + .../v1/Roles/GetRoles/GetRolesQuery.cs | 13 +- .../Jwt/ConfigureJwtBearerOptions.cs | 53 + .../Authorization/Jwt/JwtOptions.cs | 12 + .../RequiredPermissionAuthorizationHandler.cs | 8 + .../Data/IdentityDbContext.cs | 10 + .../Data/IdentityDbInitializer.cs | 51 +- .../Data/ImpersonationGrantConfig.cs | 53 + .../Domain/ImpersonationGrant.cs | 106 + .../AddUsersToGroupCommandHandler.cs | 13 +- .../DeleteGroup/DeleteGroupCommandHandler.cs | 20 +- .../RemoveUserFromGroupCommandHandler.cs | 9 +- .../UpdateGroup/UpdateGroupCommandHandler.cs | 20 +- .../EndImpersonationCommandHandler.cs | 37 +- .../GetImpersonationGrantsEndpoint.cs | 30 + .../GetImpersonationGrantsQueryHandler.cs | 38 + .../GetImpersonationGrantsQueryValidator.cs | 17 + .../RevokeImpersonationGrantCommandHandler.cs | 76 + ...evokeImpersonationGrantCommandValidator.cs | 17 + .../RevokeImpersonationGrantEndpoint.cs | 35 + .../StartImpersonationCommandHandler.cs | 79 +- .../StartImpersonationCommandValidator.cs | 13 + .../GetPermissionCatalogEndpoint.cs | 26 + .../GetPermissionCatalogQueryHandler.cs | 41 + .../v1/Roles/GetRoles/GetRolesEndpoint.cs | 16 +- .../v1/Roles/GetRoles/GetRolesQueryHandler.cs | 14 +- .../Roles/GetRoles/GetRolesQueryValidator.cs | 16 + .../Features/v1/Roles/RoleService.cs | 93 +- .../ChangePasswordCommandHandler.cs | 2 +- .../DeleteUser/DeleteUserCommandHandler.cs | 2 +- .../GetUserPermissionsEndpoint.cs | 16 +- .../SearchUsers/SearchUsersQueryHandler.cs | 26 +- .../UpdateUser/UpdateUserCommandHandler.cs | 3 +- .../IRequiredPermissionMetadata.cs | 6 - .../Modules.Identity/IdentityModule.cs | 31 +- .../Modules.Identity/Modules.Identity.csproj | 3 +- .../Services/IdentityService.cs | 44 +- .../Services/ImpersonationGrantService.cs | 254 + .../Services/SessionCleanupHostedService.cs | 6 +- .../Modules.Identity/Services/TokenService.cs | 17 +- .../Services/UserPasswordService.cs | 19 +- .../Services/UserProfileService.cs | 31 +- .../Services/UserRegistrationService.cs | 10 +- .../Services/UserRoleService.cs | 8 +- .../Modules.Identity/Services/UserService.cs | 32 +- .../Services/UserStatusService.cs | 4 +- .../Modules.Identity/TenantGraceOptions.cs | 12 + .../Dtos/TenantStatusDto.cs | 9 + .../TenantEnteredGraceIntegrationEvent.cs | 20 + .../Events/TenantExpiredIntegrationEvent.cs | 20 + .../TenantNearingExpiryIntegrationEvent.cs | 21 + .../Events/TenantRenewedIntegrationEvent.cs | 20 + .../TenantSubscribedIntegrationEvent.cs | 19 + .../ITenantService.cs | 18 +- .../Modules.Multitenancy.Contracts.csproj | 4 +- .../AdjustTenantValidityCommand.cs | 11 + .../AdjustTenantValidityCommandResponse.cs | 3 + .../v1/CreateTenant/CreateTenantCommand.cs | 4 +- .../v1/RenewTenant/RenewTenantCommand.cs | 10 + .../RenewTenant/RenewTenantCommandResponse.cs | 7 + .../v1/UpgradeTenant/UpgradeTenantCommand.cs | 6 - .../UpgradeTenantCommandResponse.cs | 3 - .../TenantExpiryNoticeConfiguration.cs | 28 + .../Data/TenantDbContext.cs | 2 + .../Domain/TenantExpiryNotice.cs | 44 + .../AdjustTenantValidityCommandHandler.cs | 21 + .../AdjustTenantValidityCommandValidator.cs | 23 + .../AdjustTenantValidityEndpoint.cs | 41 + .../ChangeTenantActivationEndpoint.cs | 5 +- .../CreateTenantCommandHandler.cs | 51 +- .../CreateTenantCommandValidator.cs | 16 + .../v1/CreateTenant/CreateTenantEndpoint.cs | 5 +- .../GetMyTenantStatusEndpoint.cs | 36 + .../RenewTenant/RenewTenantCommandHandler.cs | 50 + .../RenewTenantCommandValidator.cs | 17 + .../v1/RenewTenant/RenewTenantEndpoint.cs | 41 + .../GetTenantProvisioningStatusEndpoint.cs | 5 +- .../RetryTenantProvisioningEndpoint.cs | 5 +- .../UpgradeTenantCommandHandler.cs | 16 - .../UpgradeTenantCommandValidator.cs | 13 - .../v1/UpgradeTenant/UpgradeTenantEndpoint.cs | 42 - .../Modules.Multitenancy.csproj | 5 +- .../MultitenancyModule.cs | 165 +- .../Services/FinbuckleEventTenantScope.cs | 66 + .../Services/TenantExpiryScanJob.cs | 149 + .../Services/TenantInitialPasswordBuffer.cs | 19 + .../Services/TenantService.cs | 114 +- .../TenantBillingOptions.cs | 20 + .../Modules.Notifications.Contracts.csproj | 1 - .../Data/NotificationsDbContext.cs | 5 +- .../BillingEmailBodies.cs | 72 + .../BillingEmailSender.cs | 32 + .../InvoiceIssuedEmailHandler.cs | 37 + ...ntionedInChannelIntegrationEventHandler.cs | 18 + .../TenantEnteredGraceEmailHandler.cs | 22 + .../TenantExpiredEmailHandler.cs | 21 + .../TenantNearingExpiryEmailHandler.cs | 22 + .../Modules.Notifications.csproj | 4 +- .../Authorization/TicketsPermissions.cs | 2 + .../Modules.Tickets.Contracts.csproj | 1 - .../v1/Tickets/CloseTicketCommand.cs | 5 + .../v1/Tickets/DeleteTicketCommand.cs | 5 + .../v1/Tickets/UpdateTicketCommand.cs | 10 + .../TicketCommentConfiguration.cs | 10 + .../Modules.Tickets/Data/TicketsDbContext.cs | 4 +- .../Tickets/Modules.Tickets/Domain/Ticket.cs | 44 + .../Modules.Tickets/Domain/TicketMappings.cs | 18 - .../CloseTicket/CloseTicketCommandHandler.cs | 25 + .../CloseTicketCommandValidator.cs | 12 + .../CloseTicket/CloseTicketEndpoint.cs | 24 + .../DeleteTicketCommandHandler.cs | 27 + .../DeleteTicketCommandValidator.cs | 12 + .../DeleteTicket/DeleteTicketEndpoint.cs | 25 + .../ListTicketCommentsQueryHandler.cs | 12 + .../UpdateTicketCommandHandler.cs | 25 + .../UpdateTicketCommandValidator.cs | 15 + .../UpdateTicket/UpdateTicketEndpoint.cs | 28 + .../Modules.Tickets/Modules.Tickets.csproj | 1 - .../Tickets/Modules.Tickets/TicketsModule.cs | 6 + .../Authorization/WebhooksPermissions.cs | 23 + .../Modules.Webhooks.Contracts.csproj | 1 - .../Modules.Webhooks/Data/WebhookDbContext.cs | 4 +- ...CreateWebhookSubscriptionCommandHandler.cs | 9 +- ...eateWebhookSubscriptionCommandValidator.cs | 4 +- .../CreateWebhookSubscriptionEndpoint.cs | 3 + .../DeleteWebhookSubscriptionEndpoint.cs | 3 + .../GetWebhookDeliveriesEndpoint.cs | 5 +- .../GetWebhookDeliveriesQueryValidator.cs | 14 + .../GetWebhookSubscriptionsEndpoint.cs | 5 +- .../GetWebhookSubscriptionsQueryValidator.cs | 15 + .../TestWebhookSubscriptionCommandHandler.cs | 5 +- .../TestWebhookSubscriptionEndpoint.cs | 5 +- .../Modules.Webhooks/Modules.Webhooks.csproj | 1 - .../Services/WebhookDispatchJob.cs | 8 +- .../Services/WebhookFanoutHandler.cs | 113 + .../Services/WebhookSecretProtector.cs | 32 + .../Modules.Webhooks/WebhooksModule.cs | 13 + .../BuildingBlocksIndependenceTests.cs | 11 +- .../EndpointConventionTests.cs | 2 + .../HandlerValidatorPairingTests.cs | 4 +- .../TenantIsolationTests.cs | 140 + src/Tests/Billing.Tests/Billing.Tests.csproj | 26 + .../Billing.Tests/Domain/BillingPlanTests.cs | 191 + .../Domain/InvoiceLineItemTests.cs | 87 + .../Billing.Tests/Domain/InvoiceTests.cs | 271 + .../Billing.Tests/Domain/SubscriptionTests.cs | 103 + .../Domain/UsageSnapshotTests.cs | 82 + src/Tests/Billing.Tests/GlobalUsings.cs | 2 + .../Services/InvoicePdfRendererTests.cs | 74 + .../Services/MonthlyInvoiceJobTests.cs | 56 + .../Validators/BillingValidatorsTests.cs | 138 + src/Tests/Catalog.Tests/Catalog.Tests.csproj | 25 + src/Tests/Catalog.Tests/Domain/BrandTests.cs | 116 + .../Catalog.Tests/Domain/CategoryTests.cs | 130 + src/Tests/Catalog.Tests/Domain/MoneyTests.cs | 133 + .../Catalog.Tests/Domain/ProductTests.cs | 562 ++ src/Tests/Catalog.Tests/GlobalUsings.cs | 2 + .../Chat.Tests/Domain/ChatChannelTests.cs | 1 + .../Files.Tests/Domain/FileAssetTests.cs | 1 + .../Framework.Tests/Caching/CacheKeysTests.cs | 59 + src/Tests/Framework.Tests/Core/DomainTests.cs | 128 + .../Framework.Tests/Core/ExceptionsTests.cs | 145 + .../InMemoryEventBusTenantScopeTests.cs | 81 + .../Eventing/JsonEventSerializerTests.cs | 99 + .../Framework.Tests/Framework.Tests.csproj | 34 + src/Tests/Framework.Tests/GlobalUsings.cs | 3 + .../Mailing/MailRequestTests.cs | 63 + .../Mailing/MailingExtensionsTests.cs | 76 + .../ConnectionStringValidatorTests.cs | 100 + .../Persistence/PaginationExtensionsTests.cs | 141 + .../Persistence/SpecificationTests.cs | 190 + .../Persistence/TestAsyncQueryable.cs | 61 + .../Storage/FileTypeMetadataTests.cs | 58 + .../Storage/LocalPresignTokenStoreTests.cs | 69 + .../Storage/LocalStorageServiceTests.cs | 209 + .../Web/GlobalExceptionHandlerTests.cs | 64 + .../Web/OptionsDefaultsTests.cs | 74 + .../Web/SecurityHeadersMiddlewareTests.cs | 152 + .../Authorization/JwtOptionsTests.cs | 25 + .../Handlers/DeleteUserCommandHandlerTests.cs | 9 +- .../Services/DeviceTypeClassifierTests.cs | 99 + .../Services/RequestContextServiceTests.cs | 267 + .../Services/TokenServiceTests.cs | 236 + .../GlobalUsings.cs | 8 + .../Infrastructure/AuthHelper.cs | 71 + .../Infrastructure/Dtos.cs | 9 + .../MiddlewareCollectionDefinition.cs | 7 + .../MiddlewareWebApplicationFactory.cs | 373 + .../Infrastructure/NoOpMailService.cs | 13 + .../Infrastructure/TestConstants.cs | 19 + .../Integration.Middleware.Tests.csproj | 39 + .../Tests/GlobalExceptionHandlerTests.cs | 67 + .../Tests/RateLimitingTests.cs | 97 + .../Tests/SecurityHeadersTests.cs | 47 + src/Tests/Integration.Tests/GlobalUsings.cs | 1 + .../DetailedTestExceptionHandler.cs | 8 + .../Extensions/HttpResponseExtensions.cs | 6 +- .../FshWebApplicationFactory.cs | 1 + .../Infrastructure/NoOpMailService.cs | 18 +- .../Auditing/AuditExceptionAndFilterTests.cs | 456 ++ .../Tests/Auditing/AuditPayloadFilterTests.cs | 270 + .../Tests/Auditing/AuditQueryByKeyTests.cs | 211 + .../AuditQueryTenantIsolationTests.cs | 137 + .../Auditing/AuditTenantIsolationTests.cs | 1 + .../Tests/Auditing/AuditTestHelper.cs | 156 + .../Authentication/ChangePasswordTests.cs | 122 + .../Authentication/PasswordResetTests.cs | 153 + .../Tests/Billing/BillingDomainEdgeTests.cs | 316 + .../Tests/Billing/BillingEndpointTests.cs | 15 +- .../Billing/BillingTenantIsolationTests.cs | 502 ++ .../Tests/Billing/InvoicePdfTests.cs | 140 + .../Tests/Billing/MonthlyInvoiceJobTests.cs | 175 + .../Billing/TenantBillingLifecycleTests.cs | 125 + .../Tests/Billing/UsageSnapshotQueryTests.cs | 350 + .../Tests/Caching/HybridCacheRedisTests.cs | 11 +- .../Catalog/CatalogTenantIsolationTests.cs | 220 + .../Catalog/ProductFileAccessPolicyTests.cs | 240 + .../Catalog/ProductImageRemoveReorderTests.cs | 374 + .../Tests/Catalog/ProductImagesTests.cs | 35 +- .../Tests/Catalog/ProductsEndpointTests.cs | 36 +- .../Tests/Catalog/UpdateProductBranchTests.cs | 219 + .../Tests/Chat/ChatChannelsTests.cs | 2 +- .../Tests/Chat/ChatSendMessageTests.cs | 2 +- .../Tests/Chat/ChatTenantIsolationTests.cs | 305 + .../Tests/Chat/JoinChannelTests.cs | 230 + .../Files/FileAccessPolicyExtensionsTests.cs | 76 + .../Tests/Files/FileAssetDomainTests.cs | 225 + .../Tests/Files/FileTenantIsolationTests.cs | 212 + .../Files/FileVisibilityAndSharingTests.cs | 327 + .../Tests/Files/PurgeJobsTests.cs | 250 + .../Files/RequestAndFinalizeUploadTests.cs | 4 +- .../Tests/Files/StorageFlowTests.cs | 133 + .../Tests/Groups/GroupTenantIsolationTests.cs | 222 + .../Tests/Impersonation/ImpersonationTests.cs | 962 +++ .../Multitenancy/AdjustTenantValidityTests.cs | 244 + .../ChangeTenantActivationValidationTests.cs | 269 + .../Tests/Multitenancy/MissingTenantTests.cs | 63 + .../Tests/Multitenancy/MyTenantStatusTests.cs | 133 + .../Tests/Multitenancy/RenewTenantTests.cs | 374 + .../RetryTenantProvisioningTests.cs | 212 + .../Multitenancy/TenantActivationTests.cs | 52 + .../Tests/Multitenancy/TenantCreationTests.cs | 2 + .../TenantExpiryEnforcementTests.cs | 142 + .../Multitenancy/TenantExpiryScanJobTests.cs | 120 + .../Multitenancy/TenantHeaderOverrideTests.cs | 217 + .../Multitenancy/TenantIsolationTests.cs | 1 + .../Multitenancy/TenantMigrationsTests.cs | 199 + .../TenantProvisioningFailureTests.cs | 230 + .../TenantProvisioningStatusTests.cs | 1 + .../Tests/Multitenancy/TenantSeedDataTests.cs | 1 + .../Tests/Multitenancy/TenantThemeTests.cs | 462 ++ .../Roles/PermissionCacheInvalidationTests.cs | 263 + .../Tests/Roles/PermissionCatalogTests.cs | 95 + .../Tests/Roles/RoleManagementTests.cs | 8 +- .../Tests/Roles/RolePermissionTests.cs | 4 +- .../Roles/RolePrivilegeEscalationTests.cs | 153 + .../Tests/Roles/RoleTenantIsolationTests.cs | 172 + .../Tests/Roles/SystemRoleProtectionTests.cs | 4 +- .../Sessions/AdminSessionManagementTests.cs | 312 + .../Tests/Sessions/IdentityUserSeeder.cs | 71 + .../Sessions/SessionTenantIsolationTests.cs | 147 + .../Tickets/TicketCommentsEndpointTests.cs | 234 + .../Tests/Tickets/TicketCrudAndCloseTests.cs | 301 + .../Tickets/TicketSearchAndLifecycleTests.cs | 414 ++ .../Tickets/TicketTenantIsolationTests.cs | 191 + .../Tests/Tickets/TicketsEndpointTests.cs | 12 +- .../Tests/Users/ForgotPasswordRequestTests.cs | 87 + .../Tests/Users/UserManagementGuardTests.cs | 4 +- .../Tests/Users/UserProfileTests.cs | 180 + .../Tests/Users/UserQueryTests.cs | 246 + .../Tests/Webhooks/WebhookDeliveryTests.cs | 441 ++ .../Webhooks/WebhookDispatchOutcomeTests.cs | 202 + .../Tests/Webhooks/WebhookFanoutTests.cs | 228 + .../Tests/Webhooks/WebhookSignatureTests.cs | 255 + .../WebhookSubscriptionDomainTests.cs | 154 + .../Webhooks/WebhookSubscriptionTests.cs | 13 + .../Webhooks/WebhookTenantIsolationTests.cs | 1 + .../UpgradeTenantCommandHandlerTests.cs | 133 - .../FinbuckleEventTenantScopeTests.cs | 78 + .../Services/TenantServiceRenewClockTests.cs | 68 + .../TenantServiceStatusBoundaryTests.cs | 58 + .../Domain/WebhookDeliveryTests.cs | 91 + .../Domain/WebhookSubscriptionTests.cs | 162 + src/Tests/Webhooks.Tests/GlobalUsings.cs | 2 + .../Services/WebhookFanoutHandlerTests.cs | 228 + .../Services/WebhookPayloadSignerTests.cs | 107 + .../Services/WebhookSecretProtectorTests.cs | 50 + .../Validators/WebhookValidatorTests.cs | 117 + .../Webhooks.Tests/Webhooks.Tests.csproj | 26 + src/Tools/CLI/Commands/DoctorCommand.cs | 21 +- src/Tools/CLI/Commands/InfoCommand.cs | 25 +- src/Tools/CLI/Commands/NewCommand.cs | 215 +- src/Tools/CLI/FSH.CLI.csproj | 8 + src/Tools/CLI/Infrastructure/NuGetClient.cs | 39 +- .../CLI/Infrastructure/VersionComparer.cs | 55 + src/Tools/CLI/Program.cs | 9 +- .../2026-06-01-frontend-prerelease-audit.md | 67 + ...28-tenant-billing-lifecycle-phase1-core.md | 720 ++ .../2026-05-28-tenant-billing-production.md | 182 + ...6-05-28-tenant-billing-lifecycle-design.md | 246 + ...ant-billing-production-hardening-design.md | 282 + templates/FullStackHero.NET.StarterKit.csproj | 61 + 1049 files changed, 82478 insertions(+), 51219 deletions(-) create mode 100644 .agents/rules/caching.md create mode 100644 .agents/rules/database.md create mode 100644 .agents/rules/eventing.md create mode 100644 .agents/rules/frontend/admin.md create mode 100644 .agents/rules/frontend/dashboard.md create mode 100644 .agents/rules/frontend/shared.md create mode 100644 .agents/rules/integration-testing.md create mode 100644 .agents/rules/jobs.md create mode 100644 .agents/rules/logging.md delete mode 100644 .agents/rules/modules.md create mode 100644 .agents/rules/modules/auditing.md create mode 100644 .agents/rules/modules/billing.md create mode 100644 .agents/rules/modules/catalog.md create mode 100644 .agents/rules/modules/chat.md create mode 100644 .agents/rules/modules/files.md create mode 100644 .agents/rules/modules/identity.md create mode 100644 .agents/rules/modules/multitenancy.md create mode 100644 .agents/rules/modules/notifications.md create mode 100644 .agents/rules/modules/tickets.md create mode 100644 .agents/rules/modules/webhooks.md delete mode 100644 .agents/rules/persistence.md create mode 100644 .agents/rules/realtime.md create mode 100644 .agents/rules/resilience.md create mode 100644 .agents/rules/security.md create mode 100644 .agents/rules/storage.md delete mode 100644 .agents/rules/testing-rules.md create mode 100644 .agents/rules/testing.md create mode 100644 .agents/skills/add-full-slice/SKILL.md create mode 100644 .agents/skills/add-integration-event/SKILL.md create mode 100644 .agents/skills/add-permission/SKILL.md create mode 100644 .agents/skills/add-react-page/SKILL.md create mode 100644 .agents/skills/create-migration/SKILL.md create mode 100644 .gitattributes create mode 100644 .github/workflows/backend.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/frontend.yml create mode 100644 .github/workflows/template-smoke.yml delete mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 clients/admin/.dockerignore create mode 100644 clients/admin/Dockerfile create mode 100644 clients/admin/docker/config.json.template create mode 100644 clients/admin/docker/docker-entrypoint.sh create mode 100644 clients/admin/docker/nginx.conf create mode 100644 clients/admin/playwright.config.ts create mode 100644 clients/admin/public/config.json create mode 100644 clients/admin/public/logo-fullstackhero.png create mode 100644 clients/admin/src/api/audits.ts create mode 100644 clients/admin/src/api/billing.ts create mode 100644 clients/admin/src/api/files.ts create mode 100644 clients/admin/src/api/health.ts create mode 100644 clients/admin/src/api/impersonation-grants.ts create mode 100644 clients/admin/src/api/impersonation.ts create mode 100644 clients/admin/src/api/notifications.ts create mode 100644 clients/admin/src/api/sessions.ts create mode 100644 clients/admin/src/api/two-factor.ts create mode 100644 clients/admin/src/api/webhooks.ts create mode 100644 clients/admin/src/auth/route-guard.tsx create mode 100644 clients/admin/src/components/auth/auth-shell.tsx create mode 100644 clients/admin/src/components/auth/demo-accounts-dialog.tsx create mode 100644 clients/admin/src/components/billing/plan-form-dialog.tsx create mode 100644 clients/admin/src/components/brand-mark.tsx create mode 100644 clients/admin/src/components/empty-state.tsx create mode 100644 clients/admin/src/components/file/image-input.tsx create mode 100644 clients/admin/src/components/impersonation/active-grants-card.tsx create mode 100644 clients/admin/src/components/impersonation/impersonate-dialog.tsx create mode 100644 clients/admin/src/components/impersonation/revoke-grant-dialog.tsx create mode 100644 clients/admin/src/components/kpi-tile.tsx create mode 100644 clients/admin/src/components/layout/mobile-nav.tsx create mode 100644 clients/admin/src/components/layout/nav-items.ts create mode 100644 clients/admin/src/components/layout/sidebar-content.tsx create mode 100644 clients/admin/src/components/list/entity-page-header.tsx create mode 100644 clients/admin/src/components/list/error-band.tsx create mode 100644 clients/admin/src/components/list/field.tsx create mode 100644 clients/admin/src/components/list/filter-bar.tsx create mode 100644 clients/admin/src/components/list/form-shell.tsx create mode 100644 clients/admin/src/components/list/index.ts create mode 100644 clients/admin/src/components/list/loading.tsx create mode 100644 clients/admin/src/components/list/page-header.tsx create mode 100644 clients/admin/src/components/list/pagination.tsx create mode 100644 clients/admin/src/components/list/select.tsx create mode 100644 clients/admin/src/components/list/settings-field.tsx create mode 100644 clients/admin/src/components/list/settings-section.tsx create mode 100644 clients/admin/src/components/list/stat.tsx create mode 100644 clients/admin/src/components/list/tone-icon-tile.tsx create mode 100644 clients/admin/src/components/notifications/notification-bell.tsx create mode 100644 clients/admin/src/components/roles/create-role-dialog.tsx create mode 100644 clients/admin/src/components/sessions/user-sessions-card.tsx create mode 100644 clients/admin/src/components/tenants/adjust-validity-dialog.tsx create mode 100644 clients/admin/src/components/tenants/create-tenant-dialog.tsx create mode 100644 clients/admin/src/components/tenants/renew-tenant-dialog.tsx create mode 100644 clients/admin/src/components/tenants/tenant-branding-card.tsx create mode 100644 clients/admin/src/components/theme/theme-provider.tsx create mode 100644 clients/admin/src/components/theme/theme-toggle.tsx create mode 100644 clients/admin/src/components/ui/avatar.tsx create mode 100644 clients/admin/src/components/ui/badge.tsx create mode 100644 clients/admin/src/components/ui/confirm-dialog.tsx create mode 100644 clients/admin/src/components/ui/dialog.tsx create mode 100644 clients/admin/src/components/ui/dropdown-menu.tsx create mode 100644 clients/admin/src/components/ui/select.tsx create mode 100644 clients/admin/src/components/ui/skeleton.tsx create mode 100644 clients/admin/src/components/ui/switch.tsx create mode 100644 clients/admin/src/components/users/create-user-dialog.tsx create mode 100644 clients/admin/src/components/webhooks/create-webhook-dialog.tsx create mode 100644 clients/admin/src/hooks/use-file-upload.ts create mode 100644 clients/admin/src/lib/permissions.ts create mode 100644 clients/admin/src/pages/audits/detail.tsx create mode 100644 clients/admin/src/pages/audits/list.tsx create mode 100644 clients/admin/src/pages/auth/confirm-email.tsx create mode 100644 clients/admin/src/pages/auth/forgot-password.tsx create mode 100644 clients/admin/src/pages/auth/reset-password.tsx create mode 100644 clients/admin/src/pages/billing/invoice-detail.tsx create mode 100644 clients/admin/src/pages/billing/invoices-list.tsx create mode 100644 clients/admin/src/pages/billing/layout.tsx create mode 100644 clients/admin/src/pages/billing/plans-list.tsx create mode 100644 clients/admin/src/pages/health/page.tsx create mode 100644 clients/admin/src/pages/impersonation/list.tsx create mode 100644 clients/admin/src/pages/login.demo-accounts.ts create mode 100644 clients/admin/src/pages/notifications/inbox.tsx create mode 100644 clients/admin/src/pages/roles/create.tsx create mode 100644 clients/admin/src/pages/roles/detail.tsx create mode 100644 clients/admin/src/pages/roles/list.tsx create mode 100644 clients/admin/src/pages/settings/appearance.tsx create mode 100644 clients/admin/src/pages/settings/layout.tsx create mode 100644 clients/admin/src/pages/settings/profile.tsx create mode 100644 clients/admin/src/pages/settings/security.tsx create mode 100644 clients/admin/src/pages/settings/sessions.tsx create mode 100644 clients/admin/src/pages/webhooks/detail.tsx create mode 100644 clients/admin/src/pages/webhooks/list.tsx create mode 100644 clients/admin/src/realtime/realtime-context.tsx create mode 100644 clients/admin/tests/audits/audits.spec.ts create mode 100644 clients/admin/tests/auth/confirm-email.spec.ts create mode 100644 clients/admin/tests/auth/forgot-password.spec.ts create mode 100644 clients/admin/tests/auth/login.spec.ts create mode 100644 clients/admin/tests/auth/reset-password.spec.ts create mode 100644 clients/admin/tests/auth/session-restore.spec.ts create mode 100644 clients/admin/tests/billing/invoice-detail.spec.ts create mode 100644 clients/admin/tests/billing/invoices.spec.ts create mode 100644 clients/admin/tests/billing/plans.spec.ts create mode 100644 clients/admin/tests/dashboard/dashboard.spec.ts create mode 100644 clients/admin/tests/health/health.spec.ts create mode 100644 clients/admin/tests/helpers/api-mocks.ts create mode 100644 clients/admin/tests/helpers/auth-seed.ts create mode 100644 clients/admin/tests/helpers/shell-mocks.ts create mode 100644 clients/admin/tests/impersonation/impersonation.spec.ts create mode 100644 clients/admin/tests/notifications/notifications.spec.ts create mode 100644 clients/admin/tests/roles/roles.spec.ts create mode 100644 clients/admin/tests/settings/appearance.spec.ts create mode 100644 clients/admin/tests/settings/profile.spec.ts create mode 100644 clients/admin/tests/settings/security.spec.ts create mode 100644 clients/admin/tests/settings/sessions.spec.ts create mode 100644 clients/admin/tests/tenants/branding.spec.ts create mode 100644 clients/admin/tests/tenants/tenant-billing.spec.ts create mode 100644 clients/admin/tests/tenants/tenant-create.spec.ts create mode 100644 clients/admin/tests/tenants/tenant-detail.spec.ts create mode 100644 clients/admin/tests/tenants/tenants-list.spec.ts create mode 100644 clients/admin/tests/users/users.spec.ts create mode 100644 clients/admin/tests/webhooks/webhooks.spec.ts create mode 100644 clients/dashboard/.dockerignore create mode 100644 clients/dashboard/Dockerfile create mode 100644 clients/dashboard/docker/config.json.template create mode 100644 clients/dashboard/docker/docker-entrypoint.sh create mode 100644 clients/dashboard/docker/nginx.conf create mode 100644 clients/dashboard/playwright.config.ts create mode 100644 clients/dashboard/public/config.json create mode 100644 clients/dashboard/public/logo-fullstackhero.png create mode 100644 clients/dashboard/src/auth/impersonation-handoff.ts create mode 100644 clients/dashboard/src/components/auth/auth-shell.tsx create mode 100644 clients/dashboard/src/components/auth/demo-accounts-dialog.tsx create mode 100644 clients/dashboard/src/components/command-palette/command-palette-dialog.tsx delete mode 100644 clients/dashboard/src/components/file/file-gallery.tsx create mode 100644 clients/dashboard/src/components/identity/user-picker.tsx create mode 100644 clients/dashboard/src/components/layout/expiry-banner.tsx delete mode 100644 clients/dashboard/src/components/list/density-toggle.tsx delete mode 100644 clients/dashboard/src/components/list/empty-state.tsx create mode 100644 clients/dashboard/src/components/list/entity-detail.tsx create mode 100644 clients/dashboard/src/components/list/entity-shell.tsx delete mode 100644 clients/dashboard/src/components/list/list-hero.tsx delete mode 100644 clients/dashboard/src/components/list/pagination.tsx delete mode 100644 clients/dashboard/src/components/list/sort-chips.tsx delete mode 100644 clients/dashboard/src/components/list/stat.tsx create mode 100644 clients/dashboard/src/components/list/tone-icon-tile.tsx delete mode 100644 clients/dashboard/src/components/sse/live-feed.tsx delete mode 100644 clients/dashboard/src/components/sse/sse-status-badge.tsx delete mode 100644 clients/dashboard/src/components/theme/theme-toggle.tsx create mode 100644 clients/dashboard/src/lib/ticket-enums.ts create mode 100644 clients/dashboard/src/pages/auth/confirm-email.tsx create mode 100644 clients/dashboard/src/pages/auth/forgot-password.tsx create mode 100644 clients/dashboard/src/pages/auth/reset-password.tsx create mode 100644 clients/dashboard/src/pages/chat/chat.css create mode 100644 clients/dashboard/src/pages/invoice-detail.tsx delete mode 100644 clients/dashboard/src/pages/login.demo-panel.tsx create mode 100644 clients/dashboard/src/pages/subscription.tsx create mode 100644 clients/dashboard/tests/auth/confirm-email.spec.ts create mode 100644 clients/dashboard/tests/auth/forgot-password.spec.ts create mode 100644 clients/dashboard/tests/auth/login.spec.ts create mode 100644 clients/dashboard/tests/auth/reset-password.spec.ts create mode 100644 clients/dashboard/tests/auth/session-restore.spec.ts create mode 100644 clients/dashboard/tests/billing/subscription.spec.ts create mode 100644 clients/dashboard/tests/catalog/catalog.spec.ts create mode 100644 clients/dashboard/tests/chat/chat.spec.ts create mode 100644 clients/dashboard/tests/files/files.spec.ts create mode 100644 clients/dashboard/tests/helpers/api-mocks.ts create mode 100644 clients/dashboard/tests/helpers/auth-seed.ts create mode 100644 clients/dashboard/tests/helpers/shell-mocks.ts create mode 100644 clients/dashboard/tests/identity/groups.spec.ts create mode 100644 clients/dashboard/tests/identity/roles.spec.ts create mode 100644 clients/dashboard/tests/identity/users.spec.ts create mode 100644 clients/dashboard/tests/overview/overview.spec.ts create mode 100644 clients/dashboard/tests/settings/api-keys.spec.ts create mode 100644 clients/dashboard/tests/settings/appearance.spec.ts create mode 100644 clients/dashboard/tests/settings/notifications.spec.ts create mode 100644 clients/dashboard/tests/settings/profile.spec.ts create mode 100644 clients/dashboard/tests/settings/security.spec.ts create mode 100644 clients/dashboard/tests/system/audits.spec.ts create mode 100644 clients/dashboard/tests/system/health.spec.ts create mode 100644 clients/dashboard/tests/system/sessions.spec.ts create mode 100644 clients/dashboard/tests/system/trash.spec.ts create mode 100644 clients/dashboard/tests/tickets/tickets-list.spec.ts create mode 100644 clients/dashboard/tests/tickets/user-picker.spec.ts create mode 100644 coverage.runsettings create mode 100644 deploy/docker/.env.example create mode 100644 deploy/docker/README.md create mode 100644 deploy/docker/docker-compose.yml create mode 100644 deploy/docker/postgres-init/01-create-databases.sql create mode 100644 deploy/terraform/apps/starter/app_stack/.terraform.lock.hcl create mode 100644 deploy/terraform/apps/starter/app_stack/backend.tf create mode 100644 deploy/terraform/apps/starter/app_stack/providers.tf create mode 100644 deploy/terraform/apps/starter/deploy.ps1 create mode 100755 deploy/terraform/apps/starter/deploy.sh delete mode 100644 deploy/terraform/apps/starter/shared/backend.tf delete mode 100644 deploy/terraform/apps/starter/shared/main.tf delete mode 100644 deploy/terraform/apps/starter/shared/outputs.tf delete mode 100644 deploy/terraform/apps/starter/shared/variables.tf create mode 100644 deploy/terraform/modules/static_site/main.tf create mode 100644 deploy/terraform/modules/static_site/outputs.tf create mode 100644 deploy/terraform/modules/static_site/variables.tf create mode 100644 deploy/terraform/modules/static_site/versions.tf delete mode 100644 docker-compose.yml delete mode 100644 docs/.gitignore delete mode 100644 docs/.vscode/extensions.json delete mode 100644 docs/.vscode/launch.json delete mode 100644 docs/.wrangler/deploy/config.json delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/05ed23cd2f9d938660bcd3a9b5327dfea3d9b048b3e8089669e0eefcaee6437c0000019d2bc2d79a delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/06b4b9847cfa9433fd85181f4439f62f54868e6f8ab3f1f466fbd34204285e9e0000019d2d779f24 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/2af75e6c15d9631e1e579d1c78bfe27a34560af4c70edb2a356577476b55dd0e0000019d2b787b4c delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/59500f06509d47f888ba826d2d6e867f16a49b268ec678187f6b9edd273622920000019d2b787b37 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/799b9ff4e6deca1bc101a155c6f76cb6a805b8cd8f76b98bb3e14b24200906000000019d2b787b49 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/b6adda5ef8833dd4745181798942c60cab03b9d1af2e5fc19a403e8361420dbd0000019d2b6f6b34 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/b7b8dce2242c9ee20864b0ef5322f4868eceb78d2669fe5f72696db8c0c039610000019d2d882fb4 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/b934939b8485eff0a7509ea1a177ca8bca77661890ec35b3fe419b0f5a9a70530000019d2b787b3e delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/c389cac491accf22c1abb0bf9b7a0fcb03c81e08bf76c8397cee5a255cfd5e6c0000019d2d7557d5 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/cbc5380df21002512f877388af29dc43bc631678ad423c786c0b2be9db5d41bf0000019d2b787b46 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/df071845a215131e82876d3c2537f1dea4fc2b09ae9d9c5746c6fead4b0d46be0000019d2b9161e3 delete mode 100644 docs/.wrangler/state/v3/cache/default/blobs/f6e660227fb82e9cec79d07282b502662f5766480006eb7d9cbd8f62b3a684630000019d2b787b42 delete mode 100644 docs/.wrangler/state/v3/cache/miniflare-CacheObject/9f458c07675338a7426a7b81ac4fb1baf92d034efbcaaf4336379640ed744ded.sqlite delete mode 100644 docs/ISSUE_TRIAGE_2026-05-14.md delete mode 100644 docs/README.md delete mode 100644 docs/astro.config.mjs delete mode 100644 docs/package-lock.json delete mode 100644 docs/package.json delete mode 100644 docs/public/apple-touch-icon.png delete mode 100644 docs/public/favicon-96x96.png delete mode 100644 docs/public/favicon.ico delete mode 100644 docs/public/favicon.svg delete mode 100644 docs/public/llms-full.txt delete mode 100644 docs/public/llms.txt delete mode 100644 docs/public/robots.txt delete mode 100644 docs/public/site.webmanifest delete mode 100644 docs/public/web-app-manifest-192x192.png delete mode 100644 docs/public/web-app-manifest-512x512.png delete mode 100644 docs/src/assets/apple-touch-icon.png delete mode 100644 docs/src/assets/favicon-96x96.png delete mode 100644 docs/src/assets/favicon.ico delete mode 100644 docs/src/assets/favicon.svg delete mode 100644 docs/src/assets/icon.png delete mode 100644 docs/src/assets/mukesh_murugan.png delete mode 100644 docs/src/assets/site.webmanifest delete mode 100644 docs/src/assets/web-app-manifest-192x192.png delete mode 100644 docs/src/assets/web-app-manifest-512x512.png delete mode 100644 docs/src/components/Aside.astro delete mode 100644 docs/src/components/Badge.astro delete mode 100644 docs/src/components/Breadcrumbs.astro delete mode 100644 docs/src/components/DocsPagination.astro delete mode 100644 docs/src/components/DocsSidebar.astro delete mode 100644 docs/src/components/DocsToc.astro delete mode 100644 docs/src/components/FileTree.astro delete mode 100644 docs/src/components/FileTreeNode.astro delete mode 100644 docs/src/components/Footer.astro delete mode 100644 docs/src/components/Nav.astro delete mode 100644 docs/src/components/SearchModal.astro delete mode 100644 docs/src/components/Steps.astro delete mode 100644 docs/src/components/TabPanel.astro delete mode 100644 docs/src/components/Tabs.astro delete mode 100644 docs/src/config/docs.config.ts delete mode 100644 docs/src/content.config.ts delete mode 100644 docs/src/content/docs/adding-a-feature.mdx delete mode 100644 docs/src/content/docs/adding-a-module.mdx delete mode 100644 docs/src/content/docs/architecture.mdx delete mode 100644 docs/src/content/docs/auditing.mdx delete mode 100644 docs/src/content/docs/authentication-and-authorization.mdx delete mode 100644 docs/src/content/docs/authentication.mdx delete mode 100644 docs/src/content/docs/aws-terraform-deployment.mdx delete mode 100644 docs/src/content/docs/background-jobs.mdx delete mode 100644 docs/src/content/docs/building-blocks-overview.mdx delete mode 100644 docs/src/content/docs/caching.mdx delete mode 100644 docs/src/content/docs/ci-cd-pipelines.mdx delete mode 100644 docs/src/content/docs/configuration-reference.mdx delete mode 100644 docs/src/content/docs/contributing.mdx delete mode 100644 docs/src/content/docs/core-building-block.mdx delete mode 100644 docs/src/content/docs/cqrs.mdx delete mode 100644 docs/src/content/docs/deployment-overview.mdx delete mode 100644 docs/src/content/docs/docker.mdx delete mode 100644 docs/src/content/docs/domain-events.mdx delete mode 100644 docs/src/content/docs/dotnet-aspire.mdx delete mode 100644 docs/src/content/docs/eventing.mdx delete mode 100644 docs/src/content/docs/exception-handling.mdx delete mode 100644 docs/src/content/docs/feature-flags.mdx delete mode 100644 docs/src/content/docs/file-storage.mdx delete mode 100644 docs/src/content/docs/http-resilience.mdx delete mode 100644 docs/src/content/docs/idempotency.mdx delete mode 100644 docs/src/content/docs/identity-module.mdx delete mode 100644 docs/src/content/docs/introduction.mdx delete mode 100644 docs/src/content/docs/mailing.mdx delete mode 100644 docs/src/content/docs/module-system.mdx delete mode 100644 docs/src/content/docs/multitenancy-deep-dive.mdx delete mode 100644 docs/src/content/docs/multitenancy.mdx delete mode 100644 docs/src/content/docs/observability.mdx delete mode 100644 docs/src/content/docs/outbox-inbox-pattern.mdx delete mode 100644 docs/src/content/docs/persistence-building-block.mdx delete mode 100644 docs/src/content/docs/prerequisites.mdx delete mode 100644 docs/src/content/docs/project-structure.mdx delete mode 100644 docs/src/content/docs/querying-audits.mdx delete mode 100644 docs/src/content/docs/quick-start.mdx delete mode 100644 docs/src/content/docs/rate-limiting.mdx delete mode 100644 docs/src/content/docs/roles-and-permissions.mdx delete mode 100644 docs/src/content/docs/security-headers.mdx delete mode 100644 docs/src/content/docs/server-sent-events.mdx delete mode 100644 docs/src/content/docs/sessions-and-groups.mdx delete mode 100644 docs/src/content/docs/shared-library.mdx delete mode 100644 docs/src/content/docs/specification-pattern.mdx delete mode 100644 docs/src/content/docs/tenant-provisioning.mdx delete mode 100644 docs/src/content/docs/testing.mdx delete mode 100644 docs/src/content/docs/user-management.mdx delete mode 100644 docs/src/content/docs/web-building-block.mdx delete mode 100644 docs/src/content/docs/webhooks.mdx delete mode 100644 docs/src/layouts/Docs.astro delete mode 100644 docs/src/layouts/Landing.astro delete mode 100644 docs/src/pages/404.astro delete mode 100644 docs/src/pages/dotnet-starter-kit/[...slug].astro delete mode 100644 docs/src/pages/index.astro delete mode 100644 docs/src/styles/docs.css delete mode 100644 docs/superpowers/plans/2026-05-12-files-module-phase-a.md delete mode 100644 docs/superpowers/plans/2026-05-13-chat-module-implementation.md delete mode 100644 docs/superpowers/plans/2026-05-13-chat-slice-4-deferred-ui-implementation.md delete mode 100644 docs/superpowers/specs/2026-05-12-files-module-design.md delete mode 100644 docs/superpowers/specs/2026-05-13-chat-module-design.md delete mode 100644 docs/superpowers/specs/2026-05-13-chat-slice-4-deferred-ui.md delete mode 100644 docs/superpowers/specs/2026-05-14-backend-health-audit.md delete mode 100644 docs/superpowers/specs/2026-05-14-remove-api-auto-migration-design.md create mode 100644 docs/superpowers/specs/2026-05-28-admin-dashboard-design-unification-design.md delete mode 100644 docs/tsconfig.json create mode 100644 global.json delete mode 100644 requirements/frontend-and-platform.md delete mode 100644 scripts/openapi/README.md delete mode 100644 scripts/openapi/check-openapi-drift.ps1 delete mode 100644 scripts/openapi/generate-api-clients.ps1 delete mode 100644 scripts/openapi/nswag-playground.json delete mode 100644 scripts/test-cli.ps1 create mode 100644 src/BuildingBlocks/Core/Domain/IGlobalEntity.cs create mode 100644 src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs create mode 100644 src/BuildingBlocks/Eventing/NullEventTenantScope.cs create mode 100644 src/BuildingBlocks/Persistence/TenantIsolationExtensions.cs create mode 100644 src/BuildingBlocks/Shared/Multitenancy/ITenantInitialPasswordBuffer.cs delete mode 100644 src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs create mode 100644 src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs create mode 100644 src/Host/FSH.Starter.DbMigrator/Dockerfile create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260528071320_BillingPlanIntervalAndInvoicePurpose.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260528071320_BillingPlanIntervalAndInvoicePurpose.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260517105359_CatalogTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260517105359_CatalogTenantIsolation.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260517105429_ChatTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260517105429_ChatTenantIsolation.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Files/20260517105451_FilesTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Files/20260517105451_FilesTenantIsolation.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260517022531_ImpersonationGrants.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260517022531_ImpersonationGrants.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260517105546_IdentityTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260517105546_IdentityTenantIsolation.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/MultiTenancy/20260528151105_AddTenantExpiryNotices.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/MultiTenancy/20260528151105_AddTenantExpiryNotices.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Notifications/20260517105440_NotificationsTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Notifications/20260517105440_NotificationsTenantIsolation.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Tickets/20260517105416_TicketsTenantIsolation.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Tickets/20260517105416_TicketsTenantIsolation.cs create mode 100644 src/Modules/Auditing/Modules.Auditing.Contracts/NumericEnumConverter.cs create mode 100644 src/Modules/Auditing/Modules.Auditing/Persistence/AuditJsonbFunctions.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/Events/InvoiceIssuedIntegrationEvent.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Plans/GetPlanTermQuery.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQuery.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantRenewedIntegrationEventHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscribedIntegrationEventHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscriptionMaintenance.cs create mode 100644 src/Modules/Billing/Modules.Billing/Services/IInvoicePdfRenderer.cs create mode 100644 src/Modules/Billing/Modules.Billing/Services/InvoicePdfRenderer.cs create mode 100644 src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelMemberRole.cs create mode 100644 src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelType.cs delete mode 100644 src/Modules/Chat/Modules.Chat/Domain/ChannelMemberRole.cs delete mode 100644 src/Modules/Chat/Modules.Chat/Domain/ChannelType.cs create mode 100644 src/Modules/Files/Modules.Files.Contracts/v1/Commands/ChangeFileVisibilityCommand.cs create mode 100644 src/Modules/Files/Modules.Files.Contracts/v1/DTOs/FileAssetStatus.cs create mode 100644 src/Modules/Files/Modules.Files.Contracts/v1/DTOs/Visibility.cs create mode 100644 src/Modules/Files/Modules.Files.Contracts/v1/Queries/ListSharedFilesQuery.cs delete mode 100644 src/Modules/Files/Modules.Files/Domain/FileAssetStatus.cs delete mode 100644 src/Modules/Files/Modules.Files/Domain/Visibility.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityEndpoint.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ListSharedFiles/ListSharedFilesEndpoint.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ListSharedFiles/ListSharedFilesQueryHandler.cs create mode 100644 src/Modules/Files/Modules.Files/Features/v1/ListSharedFiles/ListSharedFilesQueryValidator.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/DTOs/PermissionCatalogEntryDto.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/Services/IImpersonationGrantService.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQuery.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/v1/Impersonation/ImpersonationGrantDto.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommand.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/v1/Permissions/GetPermissionCatalog/GetPermissionCatalogQuery.cs create mode 100644 src/Modules/Identity/Modules.Identity/Data/ImpersonationGrantConfig.cs create mode 100644 src/Modules/Identity/Modules.Identity/Domain/ImpersonationGrant.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsEndpoint.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandValidator.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantEndpoint.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Permissions/GetPermissionCatalog/GetPermissionCatalogEndpoint.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Permissions/GetPermissionCatalog/GetPermissionCatalogQueryHandler.cs create mode 100644 src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs delete mode 100644 src/Modules/Identity/Modules.Identity/IRequiredPermissionMetadata.cs create mode 100644 src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs create mode 100644 src/Modules/Identity/Modules.Identity/TenantGraceOptions.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Events/TenantEnteredGraceIntegrationEvent.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Events/TenantExpiredIntegrationEvent.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Events/TenantNearingExpiryIntegrationEvent.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Events/TenantRenewedIntegrationEvent.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Events/TenantSubscribedIntegrationEvent.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/AdjustTenantValidity/AdjustTenantValidityCommand.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/AdjustTenantValidity/AdjustTenantValidityCommandResponse.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/RenewTenant/RenewTenantCommand.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/RenewTenant/RenewTenantCommandResponse.cs delete mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs delete mode 100644 src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantExpiryNoticeConfiguration.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantExpiryNotice.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandHandler.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityEndpoint.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetMyTenantStatus/GetMyTenantStatusEndpoint.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandHandler.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantEndpoint.cs delete mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs delete mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs delete mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Services/FinbuckleEventTenantScope.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantExpiryScanJob.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantInitialPasswordBuffer.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/TenantBillingOptions.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailSender.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/InvoiceIssuedEmailHandler.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/TenantEnteredGraceEmailHandler.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/TenantExpiredEmailHandler.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/TenantNearingExpiryEmailHandler.cs create mode 100644 src/Modules/Tickets/Modules.Tickets.Contracts/v1/Tickets/CloseTicketCommand.cs create mode 100644 src/Modules/Tickets/Modules.Tickets.Contracts/v1/Tickets/DeleteTicketCommand.cs create mode 100644 src/Modules/Tickets/Modules.Tickets.Contracts/v1/Tickets/UpdateTicketCommand.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandValidator.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketEndpoint.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandValidator.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketEndpoint.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandValidator.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketEndpoint.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks.Contracts/Authorization/WebhooksPermissions.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Features/v1/GetWebhookDeliveries/GetWebhookDeliveriesQueryValidator.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Features/v1/GetWebhookSubscriptions/GetWebhookSubscriptionsQueryValidator.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Services/WebhookSecretProtector.cs create mode 100644 src/Tests/Architecture.Tests/TenantIsolationTests.cs create mode 100644 src/Tests/Billing.Tests/Billing.Tests.csproj create mode 100644 src/Tests/Billing.Tests/Domain/BillingPlanTests.cs create mode 100644 src/Tests/Billing.Tests/Domain/InvoiceLineItemTests.cs create mode 100644 src/Tests/Billing.Tests/Domain/InvoiceTests.cs create mode 100644 src/Tests/Billing.Tests/Domain/SubscriptionTests.cs create mode 100644 src/Tests/Billing.Tests/Domain/UsageSnapshotTests.cs create mode 100644 src/Tests/Billing.Tests/GlobalUsings.cs create mode 100644 src/Tests/Billing.Tests/Services/InvoicePdfRendererTests.cs create mode 100644 src/Tests/Billing.Tests/Services/MonthlyInvoiceJobTests.cs create mode 100644 src/Tests/Billing.Tests/Validators/BillingValidatorsTests.cs create mode 100644 src/Tests/Catalog.Tests/Catalog.Tests.csproj create mode 100644 src/Tests/Catalog.Tests/Domain/BrandTests.cs create mode 100644 src/Tests/Catalog.Tests/Domain/CategoryTests.cs create mode 100644 src/Tests/Catalog.Tests/Domain/MoneyTests.cs create mode 100644 src/Tests/Catalog.Tests/Domain/ProductTests.cs create mode 100644 src/Tests/Catalog.Tests/GlobalUsings.cs create mode 100644 src/Tests/Framework.Tests/Caching/CacheKeysTests.cs create mode 100644 src/Tests/Framework.Tests/Core/DomainTests.cs create mode 100644 src/Tests/Framework.Tests/Core/ExceptionsTests.cs create mode 100644 src/Tests/Framework.Tests/Eventing/InMemoryEventBusTenantScopeTests.cs create mode 100644 src/Tests/Framework.Tests/Eventing/JsonEventSerializerTests.cs create mode 100644 src/Tests/Framework.Tests/Framework.Tests.csproj create mode 100644 src/Tests/Framework.Tests/GlobalUsings.cs create mode 100644 src/Tests/Framework.Tests/Mailing/MailRequestTests.cs create mode 100644 src/Tests/Framework.Tests/Mailing/MailingExtensionsTests.cs create mode 100644 src/Tests/Framework.Tests/Persistence/ConnectionStringValidatorTests.cs create mode 100644 src/Tests/Framework.Tests/Persistence/PaginationExtensionsTests.cs create mode 100644 src/Tests/Framework.Tests/Persistence/SpecificationTests.cs create mode 100644 src/Tests/Framework.Tests/Persistence/TestAsyncQueryable.cs create mode 100644 src/Tests/Framework.Tests/Storage/FileTypeMetadataTests.cs create mode 100644 src/Tests/Framework.Tests/Storage/LocalPresignTokenStoreTests.cs create mode 100644 src/Tests/Framework.Tests/Storage/LocalStorageServiceTests.cs create mode 100644 src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs create mode 100644 src/Tests/Framework.Tests/Web/OptionsDefaultsTests.cs create mode 100644 src/Tests/Framework.Tests/Web/SecurityHeadersMiddlewareTests.cs create mode 100644 src/Tests/Identity.Tests/Services/DeviceTypeClassifierTests.cs create mode 100644 src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs create mode 100644 src/Tests/Identity.Tests/Services/TokenServiceTests.cs create mode 100644 src/Tests/Integration.Middleware.Tests/GlobalUsings.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/AuthHelper.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/Dtos.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareCollectionDefinition.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/NoOpMailService.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Infrastructure/TestConstants.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj create mode 100644 src/Tests/Integration.Middleware.Tests/Tests/GlobalExceptionHandlerTests.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs create mode 100644 src/Tests/Integration.Middleware.Tests/Tests/SecurityHeadersTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Auditing/AuditExceptionAndFilterTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Auditing/AuditPayloadFilterTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Auditing/AuditQueryByKeyTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Auditing/AuditTestHelper.cs create mode 100644 src/Tests/Integration.Tests/Tests/Authentication/ChangePasswordTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Authentication/PasswordResetTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/BillingDomainEdgeTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/MonthlyInvoiceJobTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/TenantBillingLifecycleTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/UsageSnapshotQueryTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Catalog/CatalogTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Catalog/ProductFileAccessPolicyTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Catalog/ProductImageRemoveReorderTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Catalog/UpdateProductBranchTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Chat/ChatTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Chat/JoinChannelTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/FileAccessPolicyExtensionsTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/FileAssetDomainTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/FileTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/FileVisibilityAndSharingTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/PurgeJobsTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Files/StorageFlowTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Groups/GroupTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/ChangeTenantActivationValidationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/MissingTenantTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/MyTenantStatusTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantExpiryEnforcementTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantExpiryScanJobTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Roles/PermissionCacheInvalidationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Roles/PermissionCatalogTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Roles/RoleTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Sessions/AdminSessionManagementTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Sessions/IdentityUserSeeder.cs create mode 100644 src/Tests/Integration.Tests/Tests/Sessions/SessionTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Tickets/TicketCommentsEndpointTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Tickets/TicketCrudAndCloseTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Tickets/TicketSearchAndLifecycleTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Tickets/TicketTenantIsolationTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Users/UserQueryTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Webhooks/WebhookDeliveryTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchOutcomeTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Webhooks/WebhookFanoutTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Webhooks/WebhookSignatureTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Webhooks/WebhookSubscriptionDomainTests.cs delete mode 100644 src/Tests/Multitenancy.Tests/Handlers/UpgradeTenantCommandHandlerTests.cs create mode 100644 src/Tests/Multitenancy.Tests/Services/FinbuckleEventTenantScopeTests.cs create mode 100644 src/Tests/Multitenancy.Tests/Services/TenantServiceRenewClockTests.cs create mode 100644 src/Tests/Multitenancy.Tests/Services/TenantServiceStatusBoundaryTests.cs create mode 100644 src/Tests/Webhooks.Tests/Domain/WebhookDeliveryTests.cs create mode 100644 src/Tests/Webhooks.Tests/Domain/WebhookSubscriptionTests.cs create mode 100644 src/Tests/Webhooks.Tests/GlobalUsings.cs create mode 100644 src/Tests/Webhooks.Tests/Services/WebhookFanoutHandlerTests.cs create mode 100644 src/Tests/Webhooks.Tests/Services/WebhookPayloadSignerTests.cs create mode 100644 src/Tests/Webhooks.Tests/Services/WebhookSecretProtectorTests.cs create mode 100644 src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs create mode 100644 src/Tests/Webhooks.Tests/Webhooks.Tests.csproj create mode 100644 src/Tools/CLI/Infrastructure/VersionComparer.cs create mode 100644 superpowers/audits/2026-06-01-frontend-prerelease-audit.md create mode 100644 superpowers/plans/2026-05-28-tenant-billing-lifecycle-phase1-core.md create mode 100644 superpowers/plans/2026-05-28-tenant-billing-production.md create mode 100644 superpowers/specs/2026-05-28-tenant-billing-lifecycle-design.md create mode 100644 superpowers/specs/2026-05-28-tenant-billing-production-hardening-design.md create mode 100644 templates/FullStackHero.NET.StarterKit.csproj diff --git a/.agents/rules/api-conventions.md b/.agents/rules/api-conventions.md index 11eedf5d7d..0c9550b14b 100644 --- a/.agents/rules/api-conventions.md +++ b/.agents/rules/api-conventions.md @@ -1,65 +1,66 @@ ---- -paths: - - "src/Modules/**/Features/**/*" - - "src/Modules/**/*Endpoint*.cs" ---- +# API conventions -# API Conventions +Read before adding endpoints, commands/queries, validators, or error handling. -Rules for API endpoints in FSH. +## Endpoints -## Endpoint Requirements - -Every endpoint MUST have: +Static extension methods on `IEndpointRouteBuilder`, returning `RouteHandlerBuilder`. The handler delegates to Mediator. Gate with `.RequirePermission(...)`. ```csharp -endpoints.MapPost("/", handler) - .WithName(nameof(CommandOrQuery)) // Required: Unique name - .WithSummary("Description") // Required: OpenAPI description - .RequirePermission(Permission) // Required: Or .AllowAnonymous() +public static class RegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) => + endpoints.MapPost("/register", (RegisterUserCommand command, + IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(command, cancellationToken)) + .WithName("RegisterUser") + .WithSummary("Register user") + .RequirePermission(IdentityPermissionConstants.Users.Create); +} ``` -## HTTP Method Mapping +- **Always accept and forward `CancellationToken`** to `mediator.Send`. ASP.NET injects it. +- Wire each endpoint in the module's `MapEndpoints()`. Endpoints group under `api/v{version:apiVersion}/{module}`. +- Use `TypedResults` / `.Produces<T>(...)` for accurate OpenAPI. Add `.WithIdempotency()` on POSTs that must be replay-safe. -| Operation | Method | Return | -|-----------|--------|--------| -| Create | `MapPost` | `TypedResults.Created(...)` | -| Read single | `MapGet` | `TypedResults.Ok(...)` | -| Read list | `MapGet` | `TypedResults.Ok(...)` | -| Update | `MapPut` | `TypedResults.Ok(...)` or `NoContent()` | -| Delete | `MapDelete` | `TypedResults.NoContent()` | +## CQRS -## Route Patterns +- **Commands/Queries** live in `Modules.{Name}.Contracts` — implement `ICommand<TResponse>` / `IQuery<TResponse>`. Records preferred. +- **Handlers** live in `Modules.{Name}/Features/` — `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on awaits. +- Paginated queries implement `IPagedQuery` (`PageNumber`, `PageSize`, `Sort`) and return `PagedResponse<T>`. -``` -/api/v1/{module}/{entities} # Collection -/api/v1/{module}/{entities}/{id} # Single item -/api/v1/{module}/{entities}/{id}/sub # Sub-resource -``` +## Validation -## Response Types +FluentValidation, auto-registered by `ModuleLoader`. Name `{Command}Validator`. Live in the same feature folder. -Always use `TypedResults`: -- `TypedResults.Ok(data)` -- `TypedResults.Created($"/path/{id}", data)` -- `TypedResults.NoContent()` -- `TypedResults.NotFound()` -- `TypedResults.BadRequest(errors)` +- **Every command handler needs a validator; every paginated query handler needs one too.** Enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`). A handler legitimately without rules can be added to that test's known-missing allowlist, but prefer writing the validator. +- Validators run via the `ValidationBehavior<,>` Mediator pipeline before the handler. -Never return raw objects or use `Results.Ok()`. +## Exceptions → ProblemDetails -## Permission Format +Throw framework exception types; the global handler converts to RFC 9457 `ProblemDetails`: -```csharp -.RequirePermission({Module}Permissions.{Entity}.{Action}) -``` +| Throw | HTTP | +|---|---| +| `NotFoundException` | 404 | +| `ForbiddenException` | 403 | +| `UnauthorizedException` | 401 | +| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) | -Actions: `View`, `Create`, `Update`, `Delete` +Don't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`). -## Query Parameters +## Permissions -Use `[AsParameters]` for complex queries: +Constants in `Shared/Identity/*Permissions.cs` (e.g. `IdentityPermissionConstants`). Apply with `.RequirePermission(...)` on the endpoint. `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata` — never let a duplicate of that interface appear; it silently disables **all** `.RequirePermission()` gates. -```csharp -endpoints.MapGet("/", async ([AsParameters] GetProductsQuery query, ...) => ...) -``` +## Specifications + +Use `Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) for query composition. Default `AsNoTracking = true` — see `database.md` for when tracking is required instead. + +## Adding a feature (checklist) + +1. Command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`. +2. Handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/`. +3. Validator in the same folder. +4. Endpoint in the same folder; wire in module `MapEndpoints()`. +5. Tests in `Tests/{Name}.Tests/` (+ integration test if it touches DB/IO). diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md index ba8205deeb..cf3fa6b6ef 100644 --- a/.agents/rules/architecture.md +++ b/.agents/rules/architecture.md @@ -1,246 +1,99 @@ ---- -paths: - - "src/**" ---- +# Architecture rules -# Architecture Rules +Modular Monolith + Vertical Slice Architecture (VSA). Read this before adding/moving modules or touching wiring. -FSH is a **Modular Monolith** — NOT microservices, NOT a traditional layered architecture. - -## Core Principles - -### 1. Modular Monolith +## Layers & dependency direction ``` -Single deployment unit - ↓ -Multiple bounded contexts (modules) - ↓ -Each module is self-contained - ↓ -Communication via Contracts (interfaces/DTOs) +Host (composition root) → Modules.{Name} (runtime) → Modules.{Name}.Contracts (public API) + → BuildingBlocks (shared framework) ``` -**Modules:** -- Identity (users, roles, permissions) -- Multitenancy (tenants, subscriptions) -- Auditing (audit trails) -- Your business modules (e.g., Catalog, Orders) - -**Rules:** -- Modules CANNOT reference other module internals -- Modules CAN reference other module Contracts -- Modules share BuildingBlocks (framework code) +- **BuildingBlocks** (`src/BuildingBlocks/`) — Core, Persistence, Web, Caching, Eventing, Storage, Quota, Jobs, Mailing, Shared. Consumed by all modules. **Do not modify without explicit approval.** +- **Modules** (`src/Modules/{Name}/`) — bounded contexts. Each = a runtime project (internal) + a `.Contracts` project (public API: commands, queries, events, DTOs, service interfaces). +- A module **MUST NOT** reference another module's runtime project — only its `.Contracts`. Enforced by `Architecture.Tests` (NetArchTest). -### 2. CQRS (Mediator Library) +## Module = runtime + Contracts -**Commands** (write operations): -```csharp -public record CreateUserCommand(string Email) : ICommand<Guid>; - -public class CreateUserHandler : ICommandHandler<CreateUserCommand, Guid> -{ - public async ValueTask<Guid> Handle(CreateUserCommand cmd, CancellationToken ct) - { - // Write to database - return user.Id; - } -} ``` - -**Queries** (read operations): -```csharp -public record GetUserQuery(Guid Id) : IQuery<UserDto>; - -public class GetUserHandler : IQueryHandler<GetUserQuery, UserDto> -{ - public async ValueTask<UserDto> Handle(GetUserQuery query, CancellationToken ct) - { - // Read from database - return userDto; - } -} +Modules.Identity/ ← runtime (internal): handlers, services, domain, data +Modules.Identity.Contracts/ ← public: ICommand/IQuery types, DTOs, events, service interfaces ``` -⚠️ **NOT MediatR:** FSH uses `Mediator` library (different interfaces!) +Cross-module communication: through Contracts service interfaces or integration events only. -### 3. Domain-Driven Design +## Feature folder layout (VSA) -**Entities** inherit `BaseEntity`: -```csharp -public class Product : BaseEntity, IAuditable -{ - public string Name { get; private set; } = default!; - public Money Price { get; private set; } = default!; - - public static Product Create(string name, Money price) - { - // Factory method, enforce invariants - return new Product { Name = name, Price = price }; - } -} -``` +Each feature is a vertical slice in `Features/v{version}/{Area}/{Feature}/`: -**Value Objects** (immutable): -```csharp -public record Money(decimal Amount, string Currency); +``` +Features/v1/Users/RegisterUser/ +├── RegisterUserEndpoint.cs # minimal API endpoint +├── RegisterUserCommandHandler.cs # CQRS handler (public sealed) +└── RegisterUserCommandValidator.cs # FluentValidation ``` -**Aggregates:** -- Root entity controls access to child entities -- Enforce business rules -- Transaction boundary +Module support folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`. -### 4. Multi-Tenancy +## IModule registration -**Finbuckle.MultiTenant:** -- Shared database, tenant isolation via TenantId -- Automatic query filtering -- Tenant resolution from HTTP header or claim +Each module implements `IModule`, declared via an **assembly-level** `[FshModule]` attribute (positional `(Type moduleType, int order = 0)`) — **not** a class-level `[FshModule(Order = n)]`: ```csharp -// Tenant-aware entity -public class Order : BaseEntity, IMustHaveTenant -{ - public Guid TenantId { get; set; } // Auto-filtered -} -``` - -**Tenant Resolution Order:** -1. HTTP header: `X-Tenant` -2. JWT claim: `tenant` -3. Host/route strategy (optional) - -### 5. Vertical Slice Architecture +[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 1)] // above the namespace -Each feature = complete slice (command/handler/validator/endpoint in one folder). +namespace FSH.Modules.Identity; -``` -Features/v1/CreateProduct/ -├── CreateProductCommand.cs -├── CreateProductHandler.cs -├── CreateProductValidator.cs -└── CreateProductEndpoint.cs +public sealed class IdentityModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) { ... } + public void ConfigureMiddleware(IApplicationBuilder app) { ... } // optional, runs AFTER UseAuthentication + public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... } +} ``` -**Benefits:** -- High cohesion (related code together) -- Low coupling (features don't depend on each other) -- Easy to find/modify +`ModuleLoader.AddModules` (`src/BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers `[FshModule]` attributes, orders by `Order` then name, instantiates each, and calls `ConfigureServices`. Endpoints map under `api/v{version:apiVersion}/{module}`. -### 6. BuildingBlocks (Shared Kernel) +## ⚠️ The four-place registration footgun -11 packages providing cross-cutting concerns: +Adding a module requires editing **four** lists. Miss one and it fails *silently*: -| Package | Purpose | -|---------|---------| -| Core | Base entities, interfaces, exceptions | -| Persistence | EF Core, repositories, specifications | -| Caching | Redis/memory caching | -| Mailing | Email templates, MailKit integration | -| Jobs | Hangfire background jobs | -| Storage | File storage (AWS S3, local) | -| Web | API conventions, filters, middleware | -| Eventing | Domain events, message bus | +| Place | File | Symptom if missed | +|---|---|---| +| Mediator `o.Assemblies` (two markers: Contracts type **and** module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered | +| `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded | +| Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module | +| module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module | -| Shared | DTOs, constants | -| Eventing.Abstractions | Event contracts | +After wiring, the fastest sanity check is: build, hit the endpoint, confirm the handler runs. -**Protected:** BuildingBlocks should NOT be modified without approval. See `.claude/rules/buildingblocks-protection.md`. +## DI & handler conventions -### 7. Dependency Flow - -``` -API Layer (Minimal APIs) - ↓ -Application Layer (Commands/Queries/Handlers) - ↓ -Domain Layer (Entities/Value Objects) - ↓ -Infrastructure Layer (Persistence/External Services) -``` +- Mediator handlers: `public sealed`, implement `ICommandHandler<T,TResponse>` / `IQueryHandler<T,TResponse>`, return `ValueTask<T>`, `.ConfigureAwait(false)` on every await. `ServiceLifetime.Scoped`. +- Validators auto-register via `ModuleLoader` (`AddValidatorsFromAssemblies`). Name them `{Command}Validator`. +- Prefer constructor injection / primary constructors. Watch DI lifetimes: stateful singletons must be thread-safe (use `ConcurrentDictionary` / immutable snapshots). -**Rules:** -- Domain CANNOT depend on infrastructure -- Application CANNOT depend on infrastructure directly -- Infrastructure implements domain interfaces +## Middleware ordering (critical) -### 8. Persistence Strategy - -**DbContext per Module:** -- IdentityDbContext -- MultitenancyDbContext -- AuditingDbContext -- Your module DbContexts - -**Repository Pattern:** -```csharp -public interface IRepository<T> where T : BaseEntity -{ - Task<T?> GetByIdAsync(Guid id, CancellationToken ct); - Task<List<T>> ListAsync(Specification<T> spec, CancellationToken ct); - Task<T> AddAsync(T entity, CancellationToken ct); - Task UpdateAsync(T entity, CancellationToken ct); - Task DeleteAsync(T entity, CancellationToken ct); -} -``` +In `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`): -**Specification Pattern** (queries): -```csharp -public class ActiveProductsSpec : Specification<Product> -{ - public ActiveProductsSpec() - { - Query.Where(p => !p.IsDeleted && p.IsActive); - } -} -``` +1. ExceptionHandler → ResponseCompression +2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected) +3. HttpsRedirection → SecurityHeaders → static files → Routing +4. **`UseAuthentication`** +5. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth +6. RateLimiting → Quotas → `UseAuthorization` → `MapModules` -## Architectural Tests +`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`** — so tenant resolution is header-driven, not claim-driven. See `modules/multitenancy.md`. -`Architecture.Tests` project enforces rules: +## Static/global state -```csharp -[Fact] -public void Modules_Should_Not_Reference_Other_Modules() -{ - // Fails if Module A references Module B directly -} +No global mutable static collections enumerated under concurrency. `Audit` (Auditing module) swaps an immutable `IAuditEnricher[]` atomically; `ModuleLoader` guards with a lock. Follow that pattern if you must hold process-global state. -[Fact] -public void Domain_Should_Not_Depend_On_Infrastructure() -{ - // Fails if domain entities reference EF Core -} -``` +## Configuration & options -## Technology Stack - -- **.NET 10** (latest LTS) -- **EF Core 10** (PostgreSQL provider) -- **Mediator** (CQRS) -- **FluentValidation** (input validation) -- **Hangfire** (background jobs) -- **Finbuckle.MultiTenant** (multi-tenancy) -- **MailKit** (email) -- **Scalar** (OpenAPI docs) -- **Serilog** (logging) -- **OpenTelemetry** (observability) -- **Aspire** (orchestration) - -## Key Takeaways - -1. **Modular Monolith** ≠ Microservices. Modules share process, database, infrastructure. -2. **CQRS** separates reads/writes. Use `ICommand`/`IQuery`, not `IRequest`. -3. **DDD** enforces business rules in domain. Entities control their state. -4. **Multi-Tenancy** is built-in. Every entity is either tenant-aware or shared. -5. **Vertical Slices** keep features independent. No shared "services" layer. -6. **BuildingBlocks** provide infrastructure. Don't reinvent, reuse. -7. **Tests enforce architecture**. Violate rules → build fails. - ---- - -For implementation details, see: -- `ARCHITECTURE_ANALYSIS.md` (deep dive) -- `.claude/rules/modules.md` (module patterns) -- `.claude/rules/persistence.md` (data access patterns) +- `appsettings.json` (+ `.Development`/`.Production`) live in `src/Host/FSH.Starter.Api/`. DbMigrator links the same files. +- Bind config with the Options pattern: `AddOptions<T>().BindConfiguration(nameof(T))`, section name == type name (e.g. `JwtOptions`, `DatabaseOptions`, `CachingOptions`, `CorsOptions`, `QuotaOptions`, `RateLimitingOptions`; **storage section is `Storage`**, not `StorageOptions`). Add `.ValidateDataAnnotations().ValidateOnStart()` for fail-fast. +- Validate critical options via `IValidatableObject` — `JwtOptions` requires `SigningKey` ≥32 chars and **rejects placeholder strings containing `"replace-with"`**; `DatabaseOptions` rejects empty connection strings. +- **Production fail-fast** (`Program.cs`, before service registration): missing `DatabaseOptions:ConnectionString`, `CachingOptions:Redis`, or `JwtOptions:SigningKey` throws. Dev secrets via `dotnet user-secrets` (AppHost has a `UserSecretsId`); MinIO creds are Aspire secret parameters. +- Platform composition is one call each: `builder.AddHeroPlatform(o => { o.Enable... })` (DI) and `app.UseHeroPlatform(...)` (middleware). Feature flags toggle Caching/Jobs/Mailing/Quotas/Sse/Realtime/OpenTelemetry/CORS/Idempotency. diff --git a/.agents/rules/caching.md b/.agents/rules/caching.md new file mode 100644 index 0000000000..cc26b8898d --- /dev/null +++ b/.agents/rules/caching.md @@ -0,0 +1,30 @@ +# Caching + +`src/BuildingBlocks/Caching/`. Read before adding cached reads or invalidation. + +## What's registered + +`AddHeroCaching(config)` always registers **`HybridCache`** (L1 in-memory + optional L2 Redis). Inject `HybridCache`, not `IDistributedCache`. + +- `CachingOptions.Redis` empty → in-memory only (dev fallback). Set → a **single shared `ConnectionMultiplexer`** (singleton `IConnectionMultiplexer`) backs both the L2 cache and the DataProtection key ring. +- Defaults: total expiration 1h, L1 (local) expiration 2min (`CachingOptions`). +- `ObservableHybridCache` transparently decorates `HybridCache` to emit OpenTelemetry (hits/misses/factory-duration/invalidations). You don't reference it — just inject `HybridCache`. + +## Pattern + +```csharp +var perms = await cache.GetOrCreateAsync( + CacheKeys.UserPermissions(userId), + async ct => await LoadPermissionsAsync(userId, ct), + tags: [CacheKeys.Tags.Permissions, CacheKeys.Tags.User(userId)], + cancellationToken: ct); +``` + +- **Keys & tags live in `CacheKeys.cs`** — add new keys/tags there, don't inline strings. Existing: `UserPermissions(userId)`, `TenantTheme(tenantId)`, `IdempotencyEntry(tenantId,key)`, `ImpersonationGrantStatus(jti)`; tags `Permissions`, `Themes`, `Idempotency`, `Tenant(id)`, `User(id)`. +- Invalidate with `RemoveAsync(key)` or `RemoveByTagAsync(tag)` in the relevant mutation handler. +- `GetOrCreateAsync` gives **stampede protection** for free (factory runs once per key). + +## Gotchas + +- **No L1 backplane.** `RemoveByTagAsync` on one node does **not** evict L1 on peer nodes — cross-node staleness is bounded only by the 2-min local expiration. Don't rely on instant cross-node invalidation; keep local expiration short for hot, mutable data. +- Don't reach for `IDistributedCache` directly except where the framework already does so deliberately (idempotency probe-read) — prefer `HybridCache`. diff --git a/.agents/rules/database.md b/.agents/rules/database.md new file mode 100644 index 0000000000..864b096215 --- /dev/null +++ b/.agents/rules/database.md @@ -0,0 +1,48 @@ +# Database & EF Core conventions + +Read before touching entities, DbContexts, migrations, or query filters. + +## Entities + +- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId`. +- `AggregateRoot` — `BaseEntity` + domain events (`IHasDomainEvents`, `_domainEvents` list). +- Marker interfaces: `IHasTenant`, `IAuditableEntity`, `ISoftDeletable`, `IGlobalEntity`. +- Domain events inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Integration events implement `IIntegrationEvent`; handlers `IIntegrationEventHandler<T>`. + +## Tenant isolation (default-ON) + +- `BaseDbContext` auto-applies a tenant query filter to every entity. **Isolation is on by default.** +- Opt out **only** via `IGlobalEntity` (e.g. `BillingPlan`, `ImpersonationGrant`, `Outbox`/`InboxMessage`). +- A subclass DbContext that overrides `OnModelCreating` **must call `base.OnModelCreating(modelBuilder)` LAST**, or the auto-applied filters are lost. +- Cross-tenant reads use `IgnoreQueryFilters()` **plus an explicit re-filter** — never rely on the absence of the filter. +- **Query-filter naming:** SoftDelete filter is *named*; the tenant filter stays *anonymous* (Finbuckle owns it). Don't rename the tenant filter. + +## AsNoTracking — and when NOT to + +- Read-only queries: add `.AsNoTracking()` (Specifications default to it). +- **Do NOT add `AsNoTracking()` to a read-then-mutate-then-`SaveChanges` query** — the entity must stay tracked or your changes won't persist. The analyzer (AP010) flags these as a smell, but for mutate-and-save flows it is a false positive — leave them tracked. +- `AnyAsync(...)` materializes no entity, so `AsNoTracking()` there is a no-op — skip it. + +## Value generation for nav-collection children + +A child entity reached **only** through a parent's navigation collection needs `Property(x => x.Id).ValueGeneratedNever()` in its EF config — otherwise EF treats it as `Modified` instead of `Added` and the insert silently misbehaves. + +## Migrations + +All migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized **per-module by folder** (`Identity/`, `Catalog/`, `Chat/`, …), each with its own `{Module}DbContextModelSnapshot`. + +```bash +dotnet ef migrations add {Name} \ + --project src/Host/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Host/FSH.Starter.Api \ + --context {Module}DbContext +``` + +- **`migrations remove` operates on the snapshot** — run a full build *before* `migrations add` so the snapshot is current, or you can lose the previous migration. +- The DB is **not** migrated at API startup. The `DbMigrator` host is a separate step: `apply` (default), `seed`, `seed-demo` (dev only), `list-pending`; flags `--tenant <id>`, `--catalog-only`, `--seed`. It migrates the tenant catalog first, then each tenant's per-module schema, serialized by a Postgres advisory lock. +- `dotnet-ef` is pinned in `.config/dotnet-tools.json` — run `dotnet tool restore` first. + +## Tests + EF + +- Integration tests use Testcontainers (real PostgreSQL) — **Docker must be running**. +- In integration tests, set the Finbuckle tenant context **inline in the same method** as the `UserManager`/`DbContext` call; an awaited-helper set is lost (AsyncLocal) and the tenant query filter NREs. diff --git a/.agents/rules/eventing.md b/.agents/rules/eventing.md new file mode 100644 index 0000000000..2da17f7d11 --- /dev/null +++ b/.agents/rules/eventing.md @@ -0,0 +1,39 @@ +# Eventing — domain events, integration events, Outbox/Inbox + +Read before publishing/handling cross-module events. `src/BuildingBlocks/Eventing/`. + +## Two tiers + +- **Domain events** (in-process, pre-commit) — inherit `DomainEvent` (record: `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`). Raised on aggregates (`IHasDomainEvents`). +- **Integration events** (cross-module, async) — implement `IIntegrationEvent` (`Id`, `OccurredOnUtc`, `TenantId`, `CorrelationId`, `Source`). Handlers implement `IIntegrationEventHandler<T>` (single `HandleAsync(T, ct)`), are `sealed`, live in `Events/` or `IntegrationEventHandlers/`. + +## The Outbox is the only way to publish + +**Do not call `IEventBus` directly from a handler.** Publish via the outbox so it commits in the same transaction and survives crashes: + +```csharp +await _outboxStore.AddAsync(integrationEvent, ct).ConfigureAwait(false); +``` + +`EfCoreOutboxStore.AddAsync` serializes + `SaveChanges` immediately. `OutboxDispatcherHostedService` polls every `OutboxDispatchIntervalSeconds` (default 10), `OutboxDispatcher` pulls a batch (`OutboxBatchSize`, default 100), publishes via `IEventBus`, and dead-letters after `OutboxMaxRetries` (default 5) → `IsDead`. `OutboxMessage`/`InboxMessage` are `IGlobalEntity` (no tenant filter — the dispatcher has no tenant context; `TenantId` is an explicit column). + +## Idempotency is free (in-memory bus) + +`InMemoryEventBus` resolves handlers in a fresh DI scope and applies the **Inbox**: skips if `IInboxStore.HasProcessedAsync(eventId, handlerName)`, marks processed after success. Composite key `{Id, HandlerName}`; concurrent-insert race is swallowed. Don't hand-roll dedup. + +## Wiring (3 calls in the module's `ConfigureServices`) + +```csharp +services.AddEventingCore(builder.Configuration); // serializer + bus + hosted dispatcher +services.AddEventingForDbContext<MyDbContext>(); // outbox/inbox stores (scoped) +services.AddIntegrationEventHandlers(typeof(MyModule).Assembly); // scans IIntegrationEventHandler<> +``` + +Bus = `EventingOptions.Provider`: `"RabbitMQ"` → `RabbitMqEventBus` (durable topic exchange); else `InMemoryEventBus` (default). + +## Gotchas + +- **Renaming/moving an integration event type breaks deserialization** — the outbox stores the assembly-qualified type name; `Type.GetType()` returns null → the message dead-letters. Keep event type names/namespaces stable, or migrate dead rows. +- **Background handlers carry no HTTP/tenant context.** An open-generic or background handler that reads a tenant-filtered DbContext must restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`, `modules/webhooks.md`). +- In-memory bus runs handlers **synchronously in the publisher's scope** — keep handler work minimal; exceptions surface to the originating request (relevant for Notifications consuming Chat events). +- Set `UseHostedServiceDispatcher=false` to drive the outbox via Hangfire instead of the hosted service. diff --git a/.agents/rules/frontend/admin.md b/.agents/rules/frontend/admin.md new file mode 100644 index 0000000000..1f1ce132b8 --- /dev/null +++ b/.agents/rules/frontend/admin.md @@ -0,0 +1,38 @@ +# Frontend — admin app (`clients/admin`) + +Operator/SuperAdmin-facing console. Read `frontend/shared.md` first; this file is only the divergences. + +- **Port** 5173 · dev proxy target `http://localhost:5030` (HTTP) · localStorage prefix `fsh.admin.*` · login header `X-FSH-App: admin`. +- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, dashboardUrl }`. `dashboardUrl` is used for the one-way impersonation handoff into the dashboard app. + +## Forms — react-hook-form + zod + +Admin is the **only** app with `react-hook-form` + `zod` + `@hookform/resolvers`. Use them: + +```ts +const form = useForm<Schema>({ resolver: zodResolver(schema) }); +``` + +Form layout primitives live in `src/components/list/` (`PageHeader`, `Field`, `FormShell`, `FormSection`, `FormActions`, `Pagination`, `ErrorBand`). + +## Permissions — fetched, hydrated, gated + +- The JWT carries only role names. Admin fetches the permission set separately: `GET /api/v1/identity/permissions` (`getMyPermissions`), cached under `fsh.admin.permissions`. +- `AuthProvider` hydrates them in an effect keyed on subject change and exposes `permissionsHydrated` to avoid a 403 flash on first paint. +- **Route gating:** wrap gated route elements in `<RouteGuard perms={[IdentityPermissions.Users.View]}>…</RouteGuard>`. It renders a "Resolving permissions" state while `!permissionsHydrated`, else `<ForbiddenView missing={…}/>`. (`ProtectedRoute` also accepts a `permissions?` prop.) +- **Mirror server permissions by hand** in `src/lib/permissions.ts` (`IdentityPermissions`, `MultitenancyPermissions`, … frozen objects + `PERMISSION_CATALOG` driving the role editor). There is intentionally **no** runtime catalog fetch — when the server adds a permission, mirror the constant here. + +## Routing & realtime + +- Routes wrap elements in `<RouteGuard perms={…}>` (no per-route Suspense wrapper). +- `RealtimeProvider` is mounted in `App.tsx` and wires only `["NotificationCreated"]`. + +## Theme + +Cool-cast neutrals (hue 240, small non-zero chroma — **not** chroma 0), a single fixed chartreuse "signal" accent (`--accent-signal` / `--signal-500`), Geist / Geist Mono fonts. Defined in `src/styles/globals.css`. + +## Add-a-page deltas (on top of shared steps) + +- Use RHF + zod for any form. +- If the endpoint requires a permission, mirror the constant in `src/lib/permissions.ts` (and `PERMISSION_CATALOG` if it belongs in the role editor) and wrap the route in `<RouteGuard perms={[…]}>`. +- Playwright: `seedAuthedSession` here also pre-seeds `fsh.admin.permissions` so `RouteGuard` passes on first paint. diff --git a/.agents/rules/frontend/dashboard.md b/.agents/rules/frontend/dashboard.md new file mode 100644 index 0000000000..d0cb78a032 --- /dev/null +++ b/.agents/rules/frontend/dashboard.md @@ -0,0 +1,42 @@ +# Frontend — dashboard app (`clients/dashboard`) + +Tenant-facing application. Read `frontend/shared.md` first; this file is only the divergences. + +- **Port** 5174 · dev proxy target `https://localhost:7030` (HTTPS, with `ws: true` for the SignalR hub) · localStorage prefix `fsh.dashboard.*` · login header `X-FSH-App: dashboard`. +- **Env** (`src/env.ts`): `{ apiBase, defaultTenant, demoMode }`. +- Dev-proxy is HTTPS on purpose: routing the bearer token through an HTTP→HTTPS 307 redirect stripped the `Authorization` header. + +## No RHF/zod — hand-rolled forms + +The dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin. + +## Permissions — straight from the JWT + +`auth-context.tsx` reads `claims.permissions` off the decoded JWT — no separate fetch, no `permissionsHydrated`, no permissions cache key. `ProtectedRoute` is **auth-only** (no permission gating). Don't add `RouteGuard`-style gating here. + +## Routing & realtime/SSE + +- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards. +- `RealtimeProvider` **and** `SseProvider` are mounted inside `AppShell` (authenticated routes only), under a `CommandPaletteProvider` (cmdk). +- SignalR provider pre-wires ~11 chat/notification events. +- **SSE** (`src/sse/`, dashboard-only): two-step token — `POST /api/v1/sse/token`, then `GET /api/v1/sse/stream?token=<guid>` consumed via fetch streaming (`parseSseStream` async generator; EventSource can't send auth headers). **Two split contexts:** `useSseStatus()` (stable, for status dots) vs `useSseEvents()` (mutates per event) to avoid cascading re-renders; `useSse()` is the composite. + +## Impersonation + +`token-store.ts` has `beginImpersonation` / `endImpersonationWithFreshTokens` / `restoreStashedActor` that stash the operator's tokens under `fsh.dashboard.impersonation.*`. `AuthProvider` exposes `beginImpersonation`/`stopImpersonation` and derives `ImpersonationInfo` from `act_sub` / `act_tenant` / `act_name` claims. Admin triggers the handoff one-way via its `dashboardUrl`. + +## Performance + +- `@tanstack/react-virtual` for long lists — use it for any large collection (chat history, big tables). +- `cmdk` powers the command palette. + +## Theme + +**Chroma-0 neutrals** (`--neutral-*: oklch(L 0 0)` — untinted; the warm-paper tint was deliberately removed). Rose default brand with **swappable accent themes** via `.accent-{rose,indigo,violet,sky,emerald,amber}` classes that override the `--brand-*` oklch stops; saffron secondary; Figtree font. Defined in `src/styles/globals.css`. Keep neutrals at chroma 0. + +## Add-a-page deltas (on top of shared steps) + +- Hand-roll forms (no RHF/zod). +- Wrap the route element in `withSuspense(<X/>)`; no permission guard. +- If it consumes pushes: `useRealtimeEvent("EventName", handler)` (register the name in `realtime-context.tsx`) or `useSseEvents()` for SSE. +- Use `react-virtual` for long lists; keep neutrals chroma 0. diff --git a/.agents/rules/frontend/shared.md b/.agents/rules/frontend/shared.md new file mode 100644 index 0000000000..9974e44e35 --- /dev/null +++ b/.agents/rules/frontend/shared.md @@ -0,0 +1,90 @@ +# Frontend — shared conventions + +Applies to **both** `clients/admin` and `clients/dashboard`. Read this for any React work, then read +the app-specific file (`admin.md` / `dashboard.md`) for divergences. + +Stack: React 19 · Vite 7 · TypeScript · TanStack Query v5 · React Router 7 · Radix UI · Tailwind v4 · +class-variance-authority (shadcn-style) · `@microsoft/signalr`. Path alias `@` → `src` (`vite.config.ts`). + +## API client (`src/lib/api-client.ts`, `src/api/*`) + +- One fetch wrapper: `apiFetch<T>(path, init)`. No axios. +- **Types are hand-written**, not generated. (`openapi-typescript` is declared in admin's devDeps but unused — there is no codegen step.) Define DTO `type`s and a `const BASE = "/api/v1/..."` in each `src/api/{feature}.ts`, with thin async functions calling `apiFetch`. +- Auth header `Authorization: Bearer <access>` from `tokenStore.getAccessToken()` (unless `skipAuth:true`). +- Tenant header (lowercase `tenant`) = `tokenStore.getTenant() ?? env.defaultTenant`, unless overridden per request. +- Errors: non-OK parses RFC 9457 `application/problem+json` and throws `ApiRequestError(status, message, problem)`. 204/empty → `undefined`. +- **Single-flight refresh:** on 401 with a refresh token, `POST /api/v1/identity/token/refresh` with `{token, refreshToken}`; a module-level `refreshPromise` dedupes concurrent refreshes; rotated token returns on `token` (not `accessToken`); the original request retries once. +- Search endpoints: build `URLSearchParams` with **PascalCase** keys (`PageNumber`, `PageSize`, …) to match the API. + +## Env (`src/env.ts`) — runtime, not build-time + +`loadRuntimeConfig()` fetches `/config.json` once at boot (awaited in `main.tsx` before React mounts); `env` is a getter that throws if read too early. One built image promotes across environments (operator writes `config.json`). The only `VITE_*` var, `VITE_API_BASE_URL`, configures the **Vite dev proxy target only** — it is not the runtime apiBase (`config.json` ships `apiBase: ""`, relative). + +## Data fetching (TanStack Query v5) + +- Shared `queryClient` (`src/lib/query-client.ts`): `staleTime: 30_000`, `refetchOnWindowFocus:false`, no retry on 401/403 else `failureCount < 2`. +- **Query keys are inline literal arrays**, hierarchical, params object last: `["users", {pageNumber, searchTerm}]`, `["user", id]`, `["user", id, "roles"]`. No central key factory. +- `useQuery`/`useMutation` live inline in page components. Invalidate in `onSuccess`: `queryClient.invalidateQueries({ queryKey: ["users"] })`. Pagination: `placeholderData: keepPreviousData`. + +### ⚠️ The `mutate(arg)` race-safe pattern (golden rule #9) + +`useMutation` reads its options at execute time, so values produced at call time (e.g. a fresh +`crypto.randomUUID()` client id) must ride **through `mutate(arg)`** and be read from the `variables` +argument of `onMutate`/`onSuccess`/`onError` — never from component state the callbacks close over, +or two rapid calls collide. + +```ts +mutation.mutate({ text, clientId: crypto.randomUUID() }); +// onMutate: ({ clientId }) => insert optimistic `temp:${clientId}` +// onSuccess: (real, { clientId }) => swap temp → real +// onError: (_e, { clientId }) => rollback +``` + +## Routing (`routes.tsx`, `App.tsx`) + +- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports. +- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`. +- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`. + +## Auth (`src/auth/`) + +`token-store.ts` (localStorage + pub/sub), `jwt.ts` (`decodeJwt`), `AuthProvider`/`useAuth()`, `ProtectedRoute`. +Login `POST /api/v1/identity/token/issue` with header `X-FSH-App: "admin"|"dashboard"`. **localStorage keys are namespaced per app** (`fsh.admin.*` / `fsh.dashboard.*`) so both run side-by-side. Permission *source* differs per app — see the app files. + +## Design system (Tailwind v4, shadcn-style) + +- **`cn()` is at `src/lib/cn.ts`** (`twMerge(clsx(...))`) — not `lib/utils.ts`. `components.json`: `style:new-york`, `baseColor:slate`, `cssVariables:true`, `iconLibrary:lucide`. +- UI primitives in `src/components/ui/` are cva-based: `cva(base, { variants, defaultVariants })` + Radix `Slot`/`asChild` + `cn(buttonVariants({...}))`. Layout primitives in `src/components/list/` (admin) / similar (dashboard), re-exported from `index.ts`. +- **Tailwind v4 is CSS-first — there is NO `tailwind.config`.** Configured via the `@tailwindcss/vite` plugin and one entrypoint `src/styles/globals.css` (imported in `main.tsx`). Tokens: `:root` oklch primitives → semantic vars → an `@theme inline { --color-*: var(--…) }` block exposing them as utilities. `@custom-variant dark (&:is(.dark *))`. +- Add a new token in `globals.css` (primitive → semantic → `@theme inline`), then use the utility. Don't hard-code colors in components. + +### Design language (both apps differ on purpose) + +| | admin (operator) | dashboard (tenant) | +|---|---|---| +| Neutrals | cool-cast, hue 240, small non-zero chroma | **chroma 0** (untinted — the warm tint was removed) | +| Accent | single fixed chartreuse "signal" (`--accent-signal`) | rose brand + **swappable** `.accent-{rose,indigo,violet,sky,emerald,amber}` | +| Font | Geist / Geist Mono | Figtree (+ saffron secondary) | + +So "neutrals must be chroma 0" is a **dashboard** rule. Admin neutrals are intentionally cool — match the file you're editing. + +## Realtime (SignalR) + +`src/realtime/realtime-context.tsx`: one `HubConnection` to `/api/v1/realtime/hub`. `@microsoft/signalr` is **dynamically imported** (lazy ~37KB) only when an authed session opens the hub. Auth via `accessTokenFactory`; a `tokenEpoch` (bumped by `tokenStore.subscribe`) forces reconnect on login/refresh/impersonation. Consume with `useRealtimeEvent("EventName", handler, deps)` (handler kept in a ref). Pre-register new event names in the provider's event list. + +## Testing (Playwright, route-mocked) + +- `playwright.config.ts`: `testDir: ./tests`, chromium, auto-boots `npm run dev`, no real backend. +- Tests in `tests/{area}/{name}.spec.ts`; helpers in `tests/helpers/`. +- **JWT seeding:** `seedAuthedSession(page, TEST_USER)` builds a fake JWT and `addInitScript`-writes `fsh.{app}.*` to localStorage before React boots (server isn't called, so signature is junk). +- **Route mocking:** `mockJsonResponse(page, urlGlob, body)` / `mockProblemDetails(...)`. `installShellMocks(page)` stubs every call `AppShell` fires and **aborts** SSE/SignalR. Playwright matches most-recently-registered first → broad shell mocks in `beforeEach`, page-specific mocks after (they win). +- `beforeEach`: `seedAuthedSession(page, TEST_USER)` → `installShellMocks(page)`. + +## Add a page/feature (shared steps) + +1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls. +2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`. +3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage")` and a child route under `AppShell`. +4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks. + +Then apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.). diff --git a/.agents/rules/integration-testing.md b/.agents/rules/integration-testing.md new file mode 100644 index 0000000000..63d0b7a264 --- /dev/null +++ b/.agents/rules/integration-testing.md @@ -0,0 +1,23 @@ +# Integration testing + +`src/Tests/Integration.Tests/` + `Integration.Middleware.Tests/`. Read before writing tests that touch the DB/HTTP pipeline. See `testing.md` for unit conventions. + +## Harness + +`WebApplicationFactory` over **real** infra via Testcontainers — PostgreSQL + Redis + MinIO. **Docker must be running**; if it isn't, tests fail fast with `DockerUnavailableException` (environmental, not a regression — run the unit projects instead). + +`FshWebApplicationFactory` (`Integration.Tests/Infrastructure/`) boots the containers, overlays in-memory config, swaps `IMailService` → `NoOpMailService`, and rewires storage to MinIO. + +## Must-know gotchas + +- **Tenant context is AsyncLocal — set it inline.** Set the Finbuckle tenant context **in the same method** as the `UserManager`/`DbContext` call. Setting it in an awaited helper loses it across the async boundary → NRE in the tenant query filter. +- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at MinIO. Follow that when a test needs real object storage. (See `storage.md`.) +- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly. +- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect. + +## Coverage + +```bash +dotnet test --collect "XPlat Code Coverage" --settings coverage.runsettings +``` +Cobertura; includes `[FSH.Modules.*]` + `[FSH.Framework.*]`; excludes tests, the Migrations project, and `*HostedService`. diff --git a/.agents/rules/jobs.md b/.agents/rules/jobs.md new file mode 100644 index 0000000000..9630614c27 --- /dev/null +++ b/.agents/rules/jobs.md @@ -0,0 +1,36 @@ +# Background jobs (Hangfire) + +`src/BuildingBlocks/Jobs/`. Read before enqueuing or scheduling work. + +## Fire-and-forget / scheduled — `IJobService` + +Inject `IJobService` (`Jobs/Services/IJobService.cs`) and use it; don't call Hangfire's `BackgroundJob` directly in feature code. + +```csharp +jobService.Enqueue(() => mailService.SendAsync(req, CancellationToken.None)); // default queue +jobService.Enqueue("email", () => mailService.SendAsync(req, CancellationToken.None)); +jobService.Schedule(() => DoLater(), TimeSpan.FromMinutes(5)); +``` + +Queues: `default`, `email` (5 workers, 30s poll). Storage auto-selected from `DatabaseOptions.Provider` (Postgres/MSSQL). + +## Recurring jobs — `IRecurringJobManager` + +`IJobService` has **no** recurring API. Register recurring jobs in the module's `MapEndpoints` with `IRecurringJobManager.AddOrUpdate<T>(...)`, always `TimeZoneInfo.Utc`: + +```csharp +recurringJobs.AddOrUpdate<PurgeOrphanedFilesJob>("files:purge-orphaned", + j => j.RunAsync(CancellationToken.None), Cron.Hourly(), new() { TimeZone = TimeZoneInfo.Utc }); +``` + +Examples in the tree: `PurgeOrphanedFiles`/`PurgeDeletedFiles` (Files), `MonthlyInvoiceJob` (Billing), `AuditRetentionJob` (Auditing), `WebhookDispatchJob` (Webhooks). + +## Dashboard & config + +`/jobs` (default), behind `HangfireOptions.UserName`/`Password` basic auth — both `[Required]`, password `[MinLength(12)]`, so **startup fails in non-dev if unset**. + +## Gotchas + +- Jobs run on the server with **no HTTP/tenant context** — restore Finbuckle tenant context inside the job (fresh scope + `IMultiTenantContextSetter`) before touching a tenant-filtered DbContext. +- The DbMigrator registers `NoOpJobService` whose methods **throw** — surfaces any accidental enqueue during migration. Don't enqueue from migration/seed paths. +- A job class is a normal DI-resolved type (scope-per-job via `FshJobActivator`); inject what you need. diff --git a/.agents/rules/logging.md b/.agents/rules/logging.md new file mode 100644 index 0000000000..22228a909c --- /dev/null +++ b/.agents/rules/logging.md @@ -0,0 +1,31 @@ +# Logging & observability + +`src/BuildingBlocks/Web/Observability/`. Read before adding logs, traces, or metrics. + +## Structured logging only + +**No string interpolation in log messages.** Use message templates with named placeholders, or `[LoggerMessage]` source-gen for hot paths. + +```csharp +// good +_logger.LogInformation("Cleaned up {Count} expired sessions for tenant {TenantId}", count, tenantId); +// also good (hot path) — see OutboxDispatcher, InMemoryEventBus, AppHub +[LoggerMessage(Level = LogLevel.Warning, Message = "Outbox message {MessageId} dead-lettered")] +private partial void LogDeadLettered(Guid messageId); +// NEVER +_logger.LogInformation($"Cleaned up {count} sessions"); // breaks structured logging + analyzers +``` + +Build runs with `TreatWarningsAsErrors` — interpolated log calls won't even compile clean under analysis. + +## Serilog + +`AddHeroLogging()` reads the `Serilog` config section (Console sink by default), attaches `HttpRequestContextEnricher` (adds `RequestMethod`/`RequestPath`/`UserAgent` + `UserId`/`Tenant`/`UserEmail` when authenticated), overrides Microsoft/EF/Hangfire/Finbuckle to higher levels, and excludes the `ExceptionHandlerMiddleware` source (the global handler logs exceptions itself — don't double-log). + +## Correlation + +`X-Correlation-ID` request header (falls back to `HttpContext.TraceIdentifier`), surfaced in every ProblemDetails and pushed to the Serilog `LogContext`. `CurrentUserMiddleware` tags the current `Activity` with `fsh.user_id` / `fsh.tenant_id` / `fsh.correlation_id`. + +## OpenTelemetry + +`AddHeroOpenTelemetry()` no-ops unless `OpenTelemetryOptions.Enabled`. Metrics + traces for AspNetCore/HttpClient/Npgsql/EFCore/Redis/Runtime, plus caching + auditing meters and Mediator pipeline spans (`MediatorTracingBehavior`). **OTLP exporter is off by default** (`Exporter.Otlp.Enabled=false`); endpoint `http://localhost:4317` (the Aspire/compose collector). Add a new meter/source name to `OpenTelemetryOptions` config, not by editing the extension. diff --git a/.agents/rules/modules.md b/.agents/rules/modules.md deleted file mode 100644 index b8358a4bd5..0000000000 --- a/.agents/rules/modules.md +++ /dev/null @@ -1,375 +0,0 @@ ---- -paths: - - "src/Modules/**" ---- - -# Module Rules - -Modules are **bounded contexts** in the modular monolith. Each module is self-contained. - -## Module Structure - -``` -Modules/{ModuleName}/ -├── {ModuleName}.Contracts/ # Public interface (DTOs, events) -│ ├── {Entity}Dto.cs -│ ├── I{Module}Service.cs -│ └── {Module}Events.cs -├── {ModuleName}/ # Implementation (internal) -│ ├── Features/ # CQRS features -│ │ └── v1/{Feature}/ -│ │ ├── {Action}Command.cs -│ │ ├── {Action}Handler.cs -│ │ ├── {Action}Validator.cs -│ │ └── {Action}Endpoint.cs -│ ├── Entities/ # Domain models -│ ├── Persistence/ # DbContext, configurations -│ ├── Permissions/ # Permission constants -│ └── Extensions.cs # DI registration -``` - -## Module Independence - -### ✅ Allowed - -```csharp -// Reference Contracts project -using FSH.Modules.Identity.Contracts; - -public record UserDto(Guid Id, string Email); // Public DTO -``` - -```csharp -// Use BuildingBlocks -using FSH.BuildingBlocks.Core; -using FSH.BuildingBlocks.Persistence; -``` - -### ❌ Forbidden - -```csharp -// Direct reference to another module's internals -using FSH.Modules.Identity; // ❌ NO! Use .Contracts instead - -using FSH.Modules.Identity.Entities; // ❌ Domain models are internal -``` - -## Communication Between Modules - -### Option 1: Contracts (Preferred) - -**Identity.Contracts:** -```csharp -public interface IUserService -{ - Task<UserDto?> GetUserByIdAsync(Guid userId); -} -``` - -**Identity implementation:** -```csharp -internal class UserService : IUserService -{ - public async Task<UserDto?> GetUserByIdAsync(Guid userId) - { - // Query database - return userDto; - } -} -``` - -**Other module uses it:** -```csharp -public class OrderHandler(IUserService userService) -{ - public async ValueTask Handle(...) - { - var user = await userService.GetUserByIdAsync(userId); - } -} -``` - -### Option 2: Domain Events - -**Identity module raises event:** -```csharp -public record UserCreatedEvent(Guid UserId, string Email) : DomainEvent; - -// In handler -await eventBus.PublishAsync(new UserCreatedEvent(user.Id, user.Email)); -``` - -**Other module subscribes:** -```csharp -public class UserCreatedEventHandler : IEventHandler<UserCreatedEvent> -{ - public async Task Handle(UserCreatedEvent evt, CancellationToken ct) - { - // React to user creation (e.g., send welcome email) - } -} -``` - -## Creating a New Module - -### 1. Create Projects - -```bash -# Contracts (public interface) -dotnet new classlib -n FSH.Modules.Catalog.Contracts -o src/Modules/Catalog/Modules.Catalog.Contracts - -# Implementation (internal) -dotnet new classlib -n FSH.Modules.Catalog -o src/Modules/Catalog/Modules.Catalog -``` - -### 2. Add to Solution - -```bash -dotnet sln src/FSH.Starter.slnx add \ - src/Modules/Catalog/Modules.Catalog.Contracts/Modules.Catalog.Contracts.csproj \ - src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj -``` - -### 3. Reference BuildingBlocks - -```xml -<!-- In Modules.Catalog.csproj --> -<ItemGroup> - <ProjectReference Include="..\..\BuildingBlocks\Core\Core.csproj" /> - <ProjectReference Include="..\..\BuildingBlocks\Persistence\Persistence.csproj" /> - <ProjectReference Include="..\Modules.Catalog.Contracts\Modules.Catalog.Contracts.csproj" /> -</ItemGroup> -``` - -### 4. Create Entities - -```csharp -namespace FSH.Modules.Catalog.Entities; - -public class Product : BaseEntity, IAuditable, IMustHaveTenant -{ - public string Name { get; private set; } = default!; - public string Description { get; private set; } = default!; - public Money Price { get; private set; } = default!; - public Guid TenantId { get; set; } - - public static Product Create(string name, string description, Money price) - { - return new Product - { - Name = name, - Description = description, - Price = price - }; - } - - public void Update(string name, string description, Money price) - { - Name = name; - Description = description; - Price = price; - } -} -``` - -### 5. Create DbContext - -```csharp -namespace FSH.Modules.Catalog.Persistence; - -public class CatalogDbContext(DbContextOptions<CatalogDbContext> options) - : BaseDbContext(options) -{ - public DbSet<Product> Products => Set<Product>(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema("catalog"); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); - base.OnModelCreating(modelBuilder); - } -} -``` - -### 6. Create Entity Configuration - -```csharp -namespace FSH.Modules.Catalog.Persistence.Configurations; - -public class ProductConfiguration : IEntityTypeConfiguration<Product> -{ - public void Configure(EntityTypeBuilder<Product> builder) - { - builder.ToTable("products", "catalog"); - - builder.Property(p => p.Name) - .IsRequired() - .HasMaxLength(200); - - builder.OwnsOne(p => p.Price, price => - { - price.Property(m => m.Amount).HasColumnName("price_amount"); - price.Property(m => m.Currency).HasColumnName("price_currency"); - }); - } -} -``` - -### 7. Register Module (Extensions.cs) - -```csharp -namespace FSH.Modules.Catalog; - -public static class Extensions -{ - public static IServiceCollection AddCatalogModule(this IServiceCollection services) - { - // Register DbContext - services.AddDbContext<CatalogDbContext>(); - - // Register repositories - services.AddScoped<IRepository<Product>, Repository<Product>>(); - - // Register services (if any) - // services.AddScoped<ICatalogService, CatalogService>(); - - return services; - } - - public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder endpoints) - { - var group = endpoints.MapGroup("/api/v1/catalog") - .WithTags("Catalog"); - - // Map feature endpoints here - // group.MapCreateProductEndpoint(); - - return endpoints; - } -} -``` - -### 8. Wire Up in Program.cs - -```csharp -// In FSH.Starter.Api/Program.cs -builder.Services.AddCatalogModule(); - -// ... - -app.MapCatalogEndpoints(); -``` - -## Module Boundaries - -### Namespace Convention - -- **Public:** `FSH.Modules.{Module}.Contracts` -- **Internal:** `FSH.Modules.{Module}.*` - -### Assembly Internals - -Mark module types as `internal` unless explicitly needed externally: - -```csharp -internal class ProductService { } // ✅ Internal by default -public record ProductDto { } // ✅ Public DTO in Contracts -``` - -### Dependency Direction - -``` -Other Modules → Module.Contracts - ↑ - Module (implements Contracts) - ↑ - BuildingBlocks -``` - -**Never:** -- Module A → Module B (direct reference) -- Module → Host (implementation referencing host) - -## Testing Modules - -**Architecture Test:** -```csharp -[Fact] -public void Catalog_Module_Should_Not_Reference_Identity_Module() -{ - var catalog = Types.InAssembly(typeof(CatalogDbContext).Assembly); - var identity = Types.InAssembly(typeof(IdentityDbContext).Assembly); - - catalog.Should().NotHaveDependencyOn(identity.Assemblies); -} -``` - -**Unit Test:** -```csharp -public class ProductTests -{ - [Fact] - public void Create_Should_Set_Properties() - { - var product = Product.Create("Test", "Description", new Money(100, "USD")); - - product.Name.Should().Be("Test"); - product.Price.Amount.Should().Be(100); - } -} -``` - -## Common Patterns - -### Permissions - -```csharp -namespace FSH.Modules.Catalog.Permissions; - -public static class CatalogPermissions -{ - public static class Products - { - public const string View = "catalog.products.view"; - public const string Create = "catalog.products.create"; - public const string Update = "catalog.products.update"; - public const string Delete = "catalog.products.delete"; - } -} -``` - -### DTOs (in Contracts) - -```csharp -namespace FSH.Modules.Catalog.Contracts; - -public record ProductDto( - Guid Id, - string Name, - string Description, - decimal Price, - string Currency, - DateTime CreatedAt); -``` - -### Events (in Contracts) - -```csharp -namespace FSH.Modules.Catalog.Contracts; - -public record ProductCreatedEvent(Guid ProductId, string Name) : DomainEvent; -public record ProductUpdatedEvent(Guid ProductId) : DomainEvent; -public record ProductDeletedEvent(Guid ProductId) : DomainEvent; -``` - -## Key Rules - -1. **Contracts are public**, internals are `internal` -2. **Modules communicate via Contracts or events**, never direct references -3. **Each module has its own DbContext** -4. **Features are vertical slices** within modules -5. **BuildingBlocks are shared**, modules are independent - ---- - -For scaffolding help: Use `/add-module` skill or `module-creator` agent. diff --git a/.agents/rules/modules/auditing.md b/.agents/rules/modules/auditing.md new file mode 100644 index 0000000000..75e581b857 --- /dev/null +++ b/.agents/rules/modules/auditing.md @@ -0,0 +1,15 @@ +# Module: Auditing + +Append-only audit trail (entity changes, security events, exceptions, HTTP activity) with async channel-buffered persistence + DLQ. Module `Order = 300`. + +**Entities / DbContext:** `AuditRecord`, `AuditDbContext`. `AuditEnvelope` is the in-flight event. Rich Contracts surface: `IAuditClient`, `ISecurityAudit`, `IAuditPublisher`, `IAuditSink`, `IAuditDlqSink`, `IAuditEnricher`, `NoAuditAttribute`, payload records. +**Areas:** read-only query side — GetAudits / ByCorrelation / ByTrace / Summary / Exception / Security. Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **Static `Audit` fluent API** — `Audit.ForSecurity(...).WithUser(...).WriteAsync(ct)` (also `ForEntityChange`/`ForActivity`/`ForException`). Configured once at startup via `Audit.Configure(publisher, serializer, enrichers)`. Enrichers are held in a **volatile immutable array swapped atomically** — never mutate a live enricher list (it'd race the enrich loop). +- **Two interceptors, don't confuse them:** `AuditingSaveChangesInterceptor` (this module) captures EF entity diffs → EntityChange events and **skips `AuditDbContext`** (no recursive self-audit). `AuditableEntitySaveChangesInterceptor` (BuildingBlocks) stamps audit/soft-delete fields — different file, different job. +- **Channel-buffered, never blocks the request** — `ChannelAuditPublisher` has two lanes: default (`DropOldest` under pressure) and a **security lane that back-pressures and never drops** (login/permission/impersonation ride here). `AuditBackgroundWorker` drains both (security first), batches, writes via `IAuditSink`; on sink failure it retries then spills to `IAuditDlqSink` (file) so events survive a Postgres outage. +- `SqlAuditSink` groups a batch by `TenantId` and sets tenant context per group in a fresh scope (null → Root) — background writer has no ambient tenant. +- **JSON masking** redacts fields by keyword (password/secret/token/apiKey/connectionString…) → `****`. Add sensitive keys there. +- Exclude an endpoint from activity auditing with `[NoAudit]` / the `NoAudit` endpoint extension. diff --git a/.agents/rules/modules/billing.md b/.agents/rules/modules/billing.md new file mode 100644 index 0000000000..dae081b572 --- /dev/null +++ b/.agents/rules/modules/billing.md @@ -0,0 +1,13 @@ +# Module: Billing + +Plans, subscriptions, usage metering, monthly invoicing. **Manual payment marking — no payment provider.** Module `Order = 500`. + +**Entities / DbContext:** `BillingPlan`, `Subscription`, `Invoice` (+ `InvoiceLineItem`), `UsageSnapshot`. **`BillingDbContext : DbContext`** (NOT `BaseDbContext`) — billing lives in the main DB with an explicit `TenantId` column for cross-tenant admin visibility, filtered in query services. Contracts = DTOs; `IBillingService`/`IUsageReporter` are internal. +**Areas:** Plans, Subscriptions, Invoices (generate/issue/mark-paid/void), Usage (capture/get). Monthly invoice job (`5 0 1 * *`). Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **`BillingPlan` is `IGlobalEntity`** — platform-wide catalogue rows, **not tenant-scoped** (opts out of tenant isolation). A plan's `Key` matches the quota config key (e.g. `"pro"`): limits come from `QuotaOptions`, prices/overage from the plan. +- **`BillingDbContext` is a plain `DbContext`** — tenant filtering is done explicitly in query services, not by the `BaseDbContext` auto-filter. Don't assume the global tenant filter applies here. +- **Invoice state machine** — `Draft → Issued → Paid | Void`. Line items only addable in Draft; a Paid invoice can't be voided; totals recompute on add; Issue defaults due = +14 days. +- **Usage metering is idempotent** — `IUsageReporter.CaptureForPeriodAsync` reads `IQuotaService` and persists one `UsageSnapshot` per `QuotaResource` per (tenant, period), so invoicing math is reproducible even after a mid-period plan change. diff --git a/.agents/rules/modules/catalog.md b/.agents/rules/modules/catalog.md new file mode 100644 index 0000000000..32b1efbc95 --- /dev/null +++ b/.agents/rules/modules/catalog.md @@ -0,0 +1,14 @@ +# Module: Catalog + +Product catalog — products, categories (tree), brands — with soft-delete/restore/trash and search. Module `Order = 600`. This is the **reference module** for the soft-delete + image patterns; copy from here. + +**Entities / DbContext:** `Product` (aggregate, soft-deletable) + `ProductImage`, `Brand`, `Category` (self-referencing tree), `Money` (owned value object). `CatalogDbContext`. Domain events (`ProductCreated`/`PriceChanged`/`StockAdjusted`) are **internal**, not integration events. +**Areas:** Products (+ price/stock/images), Categories (+ tree), Brands — each with Create/Update/Delete/Search/ListTrashed/Restore. Full list: `Features/v1/` or `/scalar`. + +## Gotchas / patterns to copy + +- **Soft-delete + restore + trashed-listing** is the standard pattern. Unique indexes are **filtered on `"IsDeleted" = FALSE`** so SKU/Slug stay unique-per-tenant among *live* rows only (a deleted SKU can be reused). Replicate this on any soft-deletable unique field. +- **EF value-generation for nav children** — `ProductImageConfiguration` sets `Id.ValueGeneratedNever()` (same nav-child footgun as Chat — see `database.md`). +- **Single-thumbnail invariant** is enforced by the **aggregate**, not a partial unique index (Postgres non-deferrable partial unique indexes can't handle the demote/promote ordering in one transaction). Enforce such invariants in the domain. +- Registers `ProductFileAccessPolicy` (OwnerType `"Product"`) for product images via the Files module. +- Route ordering: literal segments (`/trash`, `/tree`, `/restore`) are registered **before** `/{id:guid}` catch-alls. diff --git a/.agents/rules/modules/chat.md b/.agents/rules/modules/chat.md new file mode 100644 index 0000000000..d07cea0cf8 --- /dev/null +++ b/.agents/rules/modules/chat.md @@ -0,0 +1,16 @@ +# Module: Chat + +Slack-style messaging: 1:1 DMs, group DMs, named channels, threads, reactions, mentions, pins. Module `Order = 800` (after Notifications 750, so Notifications' handlers register first). + +**Entities / DbContext:** `ChatChannel` (aggregate, soft-deletable) + `ChannelMember`; `Message` (aggregate) + `MessageAttachment`/`MessageMention`/`MessageReaction`. `ChatDbContext : BaseDbContext`, schema `chat`. Publishes `MentionedInChannelIntegrationEvent`. +**Areas:** Channels, Messages (incl. pin/edit/delete/threads), Reactions, Search. Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **EF value-generation for nav children** — `MessageConfiguration` sets `Property(x => x.Id).ValueGeneratedNever()` for child collections (attachments/mentions/reactions). The domain assigns `Guid.CreateVersion7()` in factories; without this EF treats nav-collection children as `Modified` → 0-row UPDATE instead of INSERT. See `database.md`. +- `ChatDbContext` calls **`base.OnModelCreating` LAST** so tenant auto-apply sees the configured child types. +- **`ChannelAuthorization`** (`Features/v1/Internal/`): `RequireMember` throws **NotFound (404)** (not 403) so non-members can't probe channel existence; `RequireAdmin` throws `ForbiddenException`. Use these in every channel/message handler. +- **SignalR via `IHubContext<AppHub>`** (the shared hub in BuildingBlocks), groups `channel:{id}`. The hub reads the user via `Context.User`, not `ICurrentUser` (see `realtime.md`). Chat registers `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups. +- SendMessage publishes `MentionedInChannelIntegrationEvent` **per distinct mentioned user**; Notifications consumes it. +- DMs use a sorted `DirectKey` (`"{lo}:{hi}"`) for find-or-create; **threads are single-level only**. +- Chat attachments register `ChatChannelFileAccessPolicy` (OwnerType `"ChatChannel"`): attach/read require membership, delete is uploader-only (see `modules/files.md`). diff --git a/.agents/rules/modules/files.md b/.agents/rules/modules/files.md new file mode 100644 index 0000000000..fe1cea2ccb --- /dev/null +++ b/.agents/rules/modules/files.md @@ -0,0 +1,15 @@ +# Module: Files + +Presigned-URL file lifecycle (upload → finalize → serve → delete) shared by Catalog images, Chat attachments, avatars. Module `Order = 350` (loads before consumer modules). + +**Entities / DbContext:** `FileAsset` (aggregate, soft-deletable): status `PendingUpload → Available | Quarantined`, `Visibility` (Public/Private), `ScanStatus`. `FilesDbContext`. Publishes `FileFinalizedIntegrationEvent`. +**Areas:** RequestUploadUrl, FinalizeUpload, GetFileDownloadUrl/Metadata, ChangeVisibility, Delete/Restore, ListMy/Shared/Trashed. Purge jobs (orphaned hourly, deleted daily). Full list: `Features/v1/` or `/scalar`. Storage mechanics: `storage.md`. + +## Gotchas + +- **Presigned flow** — never stream uploads through the API. RequestUploadUrl validates category/extension/size + quota **pre-check** and persists a `PendingUpload`; client uploads directly to storage; **FinalizeUpload debits the quota** (not at request time) and flips to Available/Quarantined. +- **`FileAccessPolicyRegistry`** resolves `IFileAccessPolicy` by **OwnerType** — case-insensitive, **closed by default** (unknown OwnerType → forbidden), **last-write-wins** on duplicates (intentional, for test substitution). Each owning module registers its own policy in its `ConfigureServices` (Catalog/Tickets load after Files). Files ships `DefaultUploaderOnlyPolicy` for built-in OwnerTypes `"MyFiles"`/`"User"`. +- `CanChangeVisibilityAsync` defaults to the delete rule (uploader-only); domain-bound files (e.g. product images) may override to forbid visibility flips. +- Tenant scoping is implicit via `BaseDbContext` (no explicit `TenantId` on `FileAsset`). + +To support uploads for a new owner type: implement `IFileAccessPolicy`, register it in the owning module, and use that OwnerType in RequestUploadUrl. diff --git a/.agents/rules/modules/identity.md b/.agents/rules/modules/identity.md new file mode 100644 index 0000000000..968ca33f1c --- /dev/null +++ b/.agents/rules/modules/identity.md @@ -0,0 +1,37 @@ +# Module: Identity + +Auth (JWT + ASP.NET Identity), users, roles, permissions, sessions, impersonation, 2FA. + +## Service shape + +`IUserService` is a **facade** that delegates to focused single-responsibility services — change behavior in the specific service, not the facade: + +| Interface | Concern | +|---|---| +| `IUserRegistrationService` | register, external-principal create, email/phone confirm | +| `IUserProfileService` | get/list/count, update profile, image, existence checks | +| `IUserStatusService` | activate/deactivate (`DeleteAsync` == deactivate), audited toggles | +| `IUserRoleService` | role assignment, admin-role guards | +| `IUserPasswordService` | forgot/reset/change password, history + expiry | +| `IUserPermissionService` | effective permissions, cache invalidation | + +`ChangePassword`/`Update`/`Delete` etc. flow facade → service → EF/UserManager. `CancellationToken` is `= default` on these interfaces and propagated into EF sinks (note: `UserManager`/`RoleManager` have no CT overloads, so private helpers that only call them don't take one). + +## Permission gating footgun + +`RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. **Never let a second/duplicate `IRequiredPermissionMetadata` appear** — it silently disables **all** `.RequirePermission()` gates across the app. Permission constants live in `Shared/Identity/*Permissions.cs`. + +## Hosted services (background) + +- `RolePermissionSyncHostedService` — best-effort sync of the permission catalog; loops, catches `Exception` *with* an `OperationCanceledException` filter, logs and continues. +- `SessionCleanupHostedService` — hourly expired-session purge; OCE handled by a preceding catch. + +These are the model for background loops: stay alive, log with context, never swallow cancellation. See `api-conventions.md`. + +## Tokens / sessions + +Login `POST /api/v1/identity/token/issue` (header `X-FSH-App` enforces the operator/tenant app boundary). Refresh `POST /api/v1/identity/token/refresh` cross-checks subject. Session rows are written best-effort during login — failures log a warning and login still succeeds. Admin can't demote/deactivate the last admin or the root-tenant seed admin (guards in `UserRoleService`/`UserStatusService`). + +## Tests + +`Identity.Tests` is the largest unit suite. When asserting a forwarded `CancellationToken`, assert the specific token (see `testing.md`). diff --git a/.agents/rules/modules/multitenancy.md b/.agents/rules/modules/multitenancy.md new file mode 100644 index 0000000000..189933c848 --- /dev/null +++ b/.agents/rules/modules/multitenancy.md @@ -0,0 +1,16 @@ +# Module: Multitenancy + +Tenant catalog, provisioning, activation/upgrade, per-tenant theming (Finbuckle.MultiTenant). Foundational — registered early. + +**Entities / DbContext:** `AppTenantInfo` (catalog), `TenantProvisioning` + `TenantProvisioningStep`, `TenantTheme`. `TenantDbContext` holds the tenant catalog in the main DB. +**Areas:** CreateTenant, ChangeTenantActivation, UpgradeTenant, Get(Tenants/Status/Migrations), TenantProvisioning (status/retry), TenantTheme (get/update/reset). Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **Finbuckle pipeline ordering** — strategy chain Claim → Header → `?tenant=` → DistributedCache → EFCoreStore, but `UseMultiTenant()` runs **before `UseAuthentication()`**, so the claim strategy no-ops (User is anonymous at resolution time). Resolution is effectively **header-driven** (`MultitenancyConstants.Identifier`). +- **Root-operator cross-tenant override is a post-auth middleware** in `ConfigureMiddleware` (not a Finbuckle strategy). Gate: caller's JWT tenant claim == `MultitenancyConstants.Root.Id` **and** a `tenant` header != root; it re-resolves via `IMultiTenantContextSetter`. Claim-aware tenant logic must go here, never in a strategy. +- **`ITenantInitialPasswordBuffer`** (singleton) — the tenant admin password is **operator-supplied**, not a constant. `CreateTenantCommandHandler` calls `Store(tenantId, password)` **before** kicking off provisioning; the background seed step `TryConsume`s it (`ConcurrentDictionary`, consume = remove). +- **Provisioning** runs 4 steps (Database → Migrations → Seeding → CacheWarm) via a Hangfire `TenantProvisioningJob`, falling back to inline execution if Hangfire storage is unavailable. **Activation is gated on `Status == Completed`.** +- `ITenantService.MigrateTenantAsync`/`SeedTenantAsync` create a fresh scope and set `IMultiTenantContext` **first**, then run the `IDbInitializer`s. + +Tenant **isolation** mechanics (default-on filter, `IGlobalEntity` opt-out, `base.OnModelCreating` last) live in `database.md`. diff --git a/.agents/rules/modules/notifications.md b/.agents/rules/modules/notifications.md new file mode 100644 index 0000000000..8347ac1cb1 --- /dev/null +++ b/.agents/rules/modules/notifications.md @@ -0,0 +1,13 @@ +# Module: Notifications + +Per-user in-app inbox (bell icon) driven by cross-module integration events, with live SignalR push. Module `Order = 750` (**before Chat 800** so its handlers are registered before Chat publishes). + +**Entities / DbContext:** `Notification` (aggregate: `UserId`, `Type`, `Title`/`Body`/`Link`, `Source`, `MetadataJson`, `ReadAtUtc`). `NotificationsDbContext`. Consumes integration events from other modules (e.g. Chat's `MentionedInChannelIntegrationEvent`). +**Areas:** List, GetUnreadCount, MarkRead, MarkAllRead. Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **It's a consumer.** New notification types come from **handling another module's integration event** (`AddIntegrationEventHandlers`), not from new endpoints. The handler writes an inbox row **and** pushes `"NotificationCreated"` to SignalR group `user:{userId}` via `IHubContext<AppHub>`. +- **Order matters** — Notifications (750) must load before any publisher whose events it consumes (Chat 800). If a new module publishes events Notifications should react to, mind the `Order`. +- In-memory bus runs handlers **synchronously in the publisher's request scope** — keep the handler minimal; an exception surfaces to the originating request. See `eventing.md`. +- Inbox rows are **denormalized** (Title/Body/Link/MetadataJson copied in) so rendering never calls back into the source module. `MarkRead` is idempotent (`ReadAtUtc ??= now`). diff --git a/.agents/rules/modules/tickets.md b/.agents/rules/modules/tickets.md new file mode 100644 index 0000000000..be2cfd4271 --- /dev/null +++ b/.agents/rules/modules/tickets.md @@ -0,0 +1,12 @@ +# Module: Tickets + +Support ticket lifecycle with comments. Module `Order = 700`. + +**Entities / DbContext:** `Ticket` (aggregate, soft-deletable, state machine) + `TicketComment`. `TicketsDbContext`. `TicketStatus`/`TicketPriority` enums in Contracts; domain events internal. +**Areas:** Create, Assign, Resolve, Reopen, Restore, AddComment, ListComments, GetById, Search, ListTrashed. Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **State machine** (`Domain/Ticket.cs`): `Open → InProgress → Resolved → Closed`. Illegal transitions throw **`CustomException` with `HttpStatusCode.Conflict` (409)** — not a generic 400. Assigning auto-starts (Open→InProgress); unassigning an InProgress ticket reverts to Open; creating with an assignee starts at InProgress. Closed tickets reject comments/resolve until reopened. Keep all transition guards in the aggregate. +- Soft-delete/restore/trash pattern is identical to Catalog (filtered unique indexes — see `modules/catalog.md`). +- Endpoints are mapped on the bare `api/v{version}` group (no `/tickets` sub-path); literal routes precede `{ticketId:guid}`. diff --git a/.agents/rules/modules/webhooks.md b/.agents/rules/modules/webhooks.md new file mode 100644 index 0000000000..8bc3f95097 --- /dev/null +++ b/.agents/rules/modules/webhooks.md @@ -0,0 +1,13 @@ +# Module: Webhooks + +Tenant-scoped outbound webhook subscriptions with HMAC-signed delivery and retries. Module `Order = 400`. + +**Entities / DbContext:** `WebhookSubscription` (`Url`, `EventsCsv`, `SecretHash`, `IsActive`), `WebhookDelivery` (per-attempt log). `WebhookDbContext` (tenant-filtered). Contracts expose **DTOs only** — `IWebhookDispatcher`/`IWebhookDeliveryService` are internal. +**Areas:** Create/Delete/Get subscriptions, GetDeliveries, Test. Full list: `Features/v1/` or `/scalar`. + +## Gotchas + +- **Fan-out is an open-generic handler** — `WebhookFanoutHandler<TEvent>` is registered as an open generic, so it handles **every** `IIntegrationEvent` with no per-event wiring. It skips events with null `TenantId` (subscriptions are tenant-only) and matches event-type name against each subscription's `EventsCsv` (`*` wildcard supported). +- **Restore tenant context in the background** — both the fan-out handler and `WebhookDispatchJob` set `IMultiTenantContext` in a fresh scope before reading the tenant-filtered DbContext (background pumps/Hangfire carry no HTTP context). This is the canonical pattern for any background reader of tenant data — see `eventing.md`, `jobs.md`. +- **HMAC signing** — `X-Webhook-Signature: sha256=<hex HMACSHA256>` (`WebhookPayloadSigner.Sign`), plus `X-Webhook-Event` and `X-Webhook-Delivery-Id` headers. +- **Delivery** — `WebhookDispatcher.EnqueueAsync` enqueues a Hangfire `WebhookDispatchJob` per subscription; `[AutomaticRetry(Attempts=4, DelaysInSeconds={30,120,600,3600})]`. Transient (5xx/408/429) throws to reschedule; permanent 4xx completes silently. Each attempt persists a `WebhookDelivery` row. The `"Webhooks"` HttpClient uses `AddHeroResilience` (see `resilience.md`). diff --git a/.agents/rules/persistence.md b/.agents/rules/persistence.md deleted file mode 100644 index d7b6ac66ac..0000000000 --- a/.agents/rules/persistence.md +++ /dev/null @@ -1,431 +0,0 @@ ---- -paths: - - "src/**/Persistence/**" - - "src/**/Entities/**" ---- - -# Persistence Rules - -EF Core patterns and repository usage in FSH. - -## DbContext Pattern - -### One DbContext Per Module - -```csharp -namespace FSH.Modules.Catalog.Persistence; - -public class CatalogDbContext(DbContextOptions<CatalogDbContext> options) - : BaseDbContext(options) -{ - public DbSet<Product> Products => Set<Product>(); - public DbSet<Category> Categories => Set<Category>(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema("catalog"); // ✅ Module-specific schema - modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); - base.OnModelCreating(modelBuilder); - } -} -``` - -### BaseDbContext Features - -Inherited from `BuildingBlocks.Persistence`: -- Automatic tenant filtering -- Audit trail (Created/Modified timestamps) -- Soft delete support -- Domain event publishing - -## Entity Configuration - -### Use Fluent API (NOT Data Annotations) - -```csharp -namespace FSH.Modules.Catalog.Persistence.Configurations; - -public class ProductConfiguration : IEntityTypeConfiguration<Product> -{ - public void Configure(EntityTypeBuilder<Product> builder) - { - builder.ToTable("products", "catalog"); - - // Primary key - builder.HasKey(p => p.Id); - - // Properties - builder.Property(p => p.Name) - .IsRequired() - .HasMaxLength(200); - - builder.Property(p => p.Description) - .HasMaxLength(2000); - - // Value object (owned type) - builder.OwnsOne(p => p.Price, price => - { - price.Property(m => m.Amount) - .HasColumnName("price_amount") - .HasPrecision(18, 2); - - price.Property(m => m.Currency) - .HasColumnName("price_currency") - .HasMaxLength(3); - }); - - // Relationships - builder.HasOne(p => p.Category) - .WithMany() - .HasForeignKey(p => p.CategoryId); - - // Indexes - builder.HasIndex(p => p.Name); - builder.HasIndex(p => p.TenantId); // ✅ For multi-tenancy - } -} -``` - -## Repository Pattern - -### Generic Repository (Provided by BuildingBlocks) - -```csharp -public interface IRepository<T> where T : BaseEntity -{ - Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default); - Task<List<T>> ListAsync(CancellationToken ct = default); - Task<List<T>> ListAsync(Specification<T> spec, CancellationToken ct = default); - Task<T> AddAsync(T entity, CancellationToken ct = default); - Task UpdateAsync(T entity, CancellationToken ct = default); - Task DeleteAsync(T entity, CancellationToken ct = default); - Task<int> CountAsync(Specification<T> spec, CancellationToken ct = default); - Task<bool> AnyAsync(Specification<T> spec, CancellationToken ct = default); -} -``` - -### Usage in Handlers - -```csharp -public class CreateProductHandler(IRepository<Product> productRepo) - : ICommandHandler<CreateProductCommand, Guid> -{ - public async ValueTask<Guid> Handle(CreateProductCommand cmd, CancellationToken ct) - { - var product = Product.Create(cmd.Name, cmd.Description, cmd.Price); - - await productRepo.AddAsync(product, ct); - - return product.Id; - } -} -``` - -## Specification Pattern - -### Creating Specifications - -```csharp -namespace FSH.Modules.Catalog.Specifications; - -public class ProductsByNameSpec : Specification<Product> -{ - public ProductsByNameSpec(string searchTerm) - { - Query - .Where(p => p.Name.Contains(searchTerm)) - .OrderBy(p => p.Name); - } -} - -public class ActiveProductsSpec : Specification<Product> -{ - public ActiveProductsSpec() - { - Query - .Where(p => !p.IsDeleted && p.IsActive) - .Include(p => p.Category) - .OrderByDescending(p => p.CreatedAt); - } -} -``` - -### Using Specifications - -```csharp -public class GetProductsHandler(IRepository<Product> repo) - : IQueryHandler<GetProductsQuery, List<ProductDto>> -{ - public async ValueTask<List<ProductDto>> Handle(GetProductsQuery query, CancellationToken ct) - { - var spec = new ActiveProductsSpec(); - var products = await repo.ListAsync(spec, ct); - - return products.Select(p => p.ToDto()).ToList(); - } -} -``` - -### Pagination Specification - -```csharp -public class ProductsPaginatedSpec : Specification<Product> -{ - public ProductsPaginatedSpec(int pageNumber, int pageSize) - { - Query - .Where(p => !p.IsDeleted) - .OrderBy(p => p.Name) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize); - } -} -``` - -## Entity Base Classes - -### BaseEntity - -```csharp -public abstract class BaseEntity -{ - public Guid Id { get; set; } - public DateTime CreatedAt { get; set; } - public Guid? CreatedBy { get; set; } - public DateTime? ModifiedAt { get; set; } - public Guid? ModifiedBy { get; set; } -} -``` - -### IAuditable - -```csharp -public interface IAuditable -{ - DateTime CreatedAt { get; set; } - Guid? CreatedBy { get; set; } - DateTime? ModifiedAt { get; set; } - Guid? ModifiedBy { get; set; } -} -``` - -### IMustHaveTenant - -```csharp -public interface IMustHaveTenant -{ - Guid TenantId { get; set; } // ✅ Automatically filtered by Finbuckle -} -``` - -### ISoftDelete - -```csharp -public interface ISoftDelete -{ - bool IsDeleted { get; set; } - DateTime? DeletedAt { get; set; } - Guid? DeletedBy { get; set; } -} -``` - -## Multi-Tenancy - -### Tenant-Aware Entities - -```csharp -public class Order : BaseEntity, IAuditable, IMustHaveTenant -{ - public Guid TenantId { get; set; } // ✅ Required for tenant isolation - public string OrderNumber { get; private set; } = default!; - public decimal Total { get; private set; } - - // ... -} -``` - -### Global Query Filter (Automatic) - -BaseDbContext automatically applies: -```csharp -modelBuilder.Entity<Order>() - .HasQueryFilter(e => e.TenantId == currentTenantId); -``` - -**Result:** All queries automatically filter by current tenant. No need to add `.Where(x => x.TenantId == ...)` everywhere. - -### Shared Entities (No Tenant) - -```csharp -public class Country : BaseEntity // ❌ No IMustHaveTenant -{ - public string Name { get; private set; } = default!; - public string Code { get; private set; } = default!; -} -``` - -## Migrations - -### Creating Migrations - -```bash -# From solution root -dotnet ef migrations add InitialCatalog \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --context CatalogDbContext \ - --output-dir Migrations/Catalog -``` - -### Applying Migrations - -```bash -# Automatic on startup (FSH.Starter.Api) -# Or manually: -dotnet ef database update \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --context CatalogDbContext -``` - -### Migration Project Pattern - -FSH uses a separate migrations project (`Migrations.PostgreSQL`) to: -- Keep migrations out of module code -- Support multiple database providers -- Simplify deployment - -## Transactions - -### Implicit Transactions - -Commands automatically run in a transaction: -```csharp -public async ValueTask<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct) -{ - var order = Order.Create(...); - await orderRepo.AddAsync(order, ct); - - var payment = Payment.Create(...); - await paymentRepo.AddAsync(payment, ct); - - // ✅ Both saved in one transaction automatically - return order.Id; -} -``` - -### Explicit Transactions - -```csharp -await using var transaction = await dbContext.Database.BeginTransactionAsync(ct); - -try -{ - await orderRepo.AddAsync(order, ct); - await paymentRepo.AddAsync(payment, ct); - - await transaction.CommitAsync(ct); -} -catch -{ - await transaction.RollbackAsync(ct); - throw; -} -``` - -## Performance Patterns - -### Projection (DTO Mapping) - -```csharp -// ❌ Bad: Load full entity, map in memory -var products = await repo.ListAsync(spec, ct); -return products.Select(p => new ProductDto(...)).ToList(); - -// ✅ Good: Project in database -var query = dbContext.Products - .Where(p => !p.IsDeleted) - .Select(p => new ProductDto(p.Id, p.Name, p.Price.Amount)); -return await query.ToListAsync(ct); -``` - -### AsNoTracking for Read-Only - -```csharp -public class ProductsReadOnlySpec : Specification<Product> -{ - public ProductsReadOnlySpec() - { - Query - .AsNoTracking() // ✅ Faster for queries - .Where(p => !p.IsDeleted); - } -} -``` - -### Batch Operations - -```csharp -// ✅ Good: Batch delete -await dbContext.Products - .Where(p => p.CategoryId == categoryId) - .ExecuteDeleteAsync(ct); - -// ✅ Good: Batch update -await dbContext.Products - .Where(p => p.CategoryId == categoryId) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false), ct); -``` - -## Common Pitfalls - -### ❌ Tracking Issues - -```csharp -// ❌ Don't detach entities manually -dbContext.Entry(product).State = EntityState.Detached; - -// ✅ Use repository pattern -await repo.UpdateAsync(product, ct); -``` - -### ❌ N+1 Queries - -```csharp -// ❌ Bad: N+1 -var orders = await repo.ListAsync(ct); -foreach (var order in orders) -{ - var customer = await customerRepo.GetByIdAsync(order.CustomerId, ct); // N queries! -} - -// ✅ Good: Eager loading -var spec = new OrdersWithCustomersSpec(); // Includes .Include(o => o.Customer) -var orders = await repo.ListAsync(spec, ct); -``` - -### ❌ Lazy Loading - -```csharp -// ❌ Lazy loading is DISABLED in FSH -var order = await repo.GetByIdAsync(orderId, ct); -var customer = order.Customer; // ❌ NULL! Not loaded - -// ✅ Explicit loading via specification -var spec = new OrderByIdWithCustomerSpec(orderId); -var order = await repo.FirstOrDefaultAsync(spec, ct); -var customer = order.Customer; // ✅ Loaded -``` - -## Key Rules - -1. **One DbContext per module**, separate schemas -2. **Fluent API for configuration**, not data annotations -3. **Repository pattern for writes**, direct DbContext for complex reads -4. **Specifications for reusable queries** -5. **Tenant isolation is automatic** (via IMustHaveTenant) -6. **Migrations in separate project** (Migrations.PostgreSQL) -7. **AsNoTracking for read-only queries** -8. **Project to DTOs in database** (avoid loading full entities) - ---- - -For migration help: Use `migration-helper` agent or see EF Core docs. diff --git a/.agents/rules/realtime.md b/.agents/rules/realtime.md new file mode 100644 index 0000000000..a541fb284a --- /dev/null +++ b/.agents/rules/realtime.md @@ -0,0 +1,26 @@ +# Realtime — SignalR & SSE (backend) + +`src/BuildingBlocks/Web/Realtime/` + `Sse/`. For the frontend side see `frontend/shared.md` + `frontend/dashboard.md`. + +## SignalR (`AppHub`) + +`[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`. + +- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes. +- **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers). +- Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group. +- Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica. +- Push to a user from a module via `IHubContext<AppHub>` to group `user:{userId}` (e.g. Notifications' `"NotificationCreated"`). +- `IPresenceTracker` (in-memory, **per-host** — single-replica only for presence). Modules supply `IChannelMembershipChecker`/`IUserChannelLookup` adapters so the shared hub can authorize channel groups without depending on Chat. + +## SSE (`Web/Sse/`) — two-step token + +EventSource can't send `Authorization`, so SSE uses a token handshake: +1. `POST /api/v1/sse/token` (authorized) → opaque Guid, **single-use, 30s TTL** in `IDistributedCache`. +2. `GET /api/v1/sse/stream?token={guid}` (anonymous, consumes the token) → `text/event-stream`, `X-Accel-Buffering: no`, 15s heartbeat. + +`SseConnectionManager` (singleton, `ConcurrentDictionary`, bounded channel cap 100 `DropOldest`): `TrySend(userId)` (all tabs), `Broadcast(tenantId)`, `BroadcastAll()`. + +## Tests + +SignalR hub tests force **long-polling** (TestServer has no WebSocket). See `integration-testing.md`. diff --git a/.agents/rules/resilience.md b/.agents/rules/resilience.md new file mode 100644 index 0000000000..a02c6c1b0e --- /dev/null +++ b/.agents/rules/resilience.md @@ -0,0 +1,19 @@ +# HTTP resilience + +`src/BuildingBlocks/Web/HttpResilience/`. Uses `Microsoft.Extensions.Http.Resilience` (Polly v8). + +## Pattern — opt-in per HttpClient + +`AddHeroResilience(config)` is an `IHttpClientBuilder` extension that adds `AddStandardResilienceHandler` configured from `HttpResilienceOptions` (retry, total-request timeout, attempt timeout, circuit breaker). It is **NOT global** — chain it onto the specific outbound client that needs it: + +```csharp +builder.Services.AddHttpClient("Webhooks", ...) + .AddHeroResilience(builder.Configuration); +``` + +Defaults (`HttpResilienceOptions`): 3 retries, 30s total, 10s per attempt, 50% failure ratio, throughput 10. No-ops when `Enabled=false`. + +## Notes + +- Only outbound integrations need this. The only current caller is the Webhooks delivery client (`WebhooksModule.cs`). Add it to any new typed/named `HttpClient` that calls a flaky external service. +- For internal ret/timeout of *background* work, prefer Hangfire's `[AutomaticRetry]` on the job (see `modules/webhooks.md`) — that's durable across restarts; the resilience handler only covers the in-flight HTTP call. diff --git a/.agents/rules/security.md b/.agents/rules/security.md new file mode 100644 index 0000000000..b3fb38404b --- /dev/null +++ b/.agents/rules/security.md @@ -0,0 +1,26 @@ +# Web security & request governance + +CORS, security headers, rate limiting, idempotency, quota enforcement. `src/BuildingBlocks/Web/` + `Quota/`. +For auth/JWT/permissions see `modules/identity.md`; for the global exception handler see `api-conventions.md`. + +## CORS (`Web/Cors/`) — the SignalR gotcha + +Policy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never "simplify" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected. + +## Security headers (`Web/Security/`) + +`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `["/scalar","/openapi"]` (they manage their own scripts) — keep those excluded. + +## Rate limiting (`Web/RateLimiting/`) + +Chained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1000 / 200 / 300 per 60s) + a stricter named `"auth"` policy (10/60s). Health paths are unlimited. Rejection → 429 + ProblemDetails + `Retry-After`. `RateLimitingOptions.Enabled` is read **eagerly** — when false the middleware is skipped entirely (tests set it via env var before host build). + +## Idempotency (`Web/Idempotency/`) + +Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` header (max 128 chars, 24h TTL); replays return the cached response with `Idempotency-Replayed: true`. Cache key is tenant-scoped (`CacheKeys.IdempotencyEntry`). Put it on POSTs that must be replay-safe (e.g. CreateTenant). + +## Quota enforcement (`Quota/`) + +`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based. + +`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled. diff --git a/.agents/rules/storage.md b/.agents/rules/storage.md new file mode 100644 index 0000000000..edb483c9ce --- /dev/null +++ b/.agents/rules/storage.md @@ -0,0 +1,26 @@ +# Storage & file uploads + +`src/BuildingBlocks/Storage/`. Read before working with files/blobs. + +## `IStorageService` + +`UploadAsync<T>(FileUploadRequest, FileType, ct)`, `RemoveAsync(path, ct)`, `DownloadAsync`, `ExistsAsync`, `GetSizeAsync` (0 if absent), `GenerateUploadUrlAsync`/`GenerateDownloadUrlAsync` (presigned), `HeadObjectAsync`, `BuildPublicUrl(key)→string` (string, not Uri — local storage returns a server-relative path). + +`FileType`: `Image` (5MB), `Document`, `Pdf` (10MB) — `FileTypeMetadata.GetRules` enforces extension + size. **Always propagate `CancellationToken`.** + +## Providers + +`AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `"s3"` → `S3StorageService` (supports MinIO via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`). + +## Presigned upload flow (preferred for user uploads) + +Don't stream large files through the API. The pattern (see Files module): +1. `RequestUploadUrl` — server validates category/extension/size + quota pre-check, returns a presigned PUT URL, persists a `PendingUpload` record. +2. Client uploads **directly** to storage. +3. `FinalizeUpload` — flips to `Available`, **debits the quota here** (not at request time), publishes `FileFinalizedIntegrationEvent`. + +Local/dev without MinIO uses `LocalPresignTokenStore` (in-memory one-shot tokens). + +## Test gotcha + +`AddHeroStorage` reads `Storage:Provider` **before** a test factory's in-memory config overlay applies, so it wires `LocalStorageService`. Integration tests that need MinIO must **remove the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-register the S3 stack** pointed at the MinIO container (see `FshWebApplicationFactory`). See `integration-testing.md`. diff --git a/.agents/rules/testing-rules.md b/.agents/rules/testing-rules.md deleted file mode 100644 index c7018746e7..0000000000 --- a/.agents/rules/testing-rules.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -paths: - - "src/Tests/**/*" ---- - -# Testing Rules - -Rules for tests in FSH. - -## Test Organization - -``` -src/Tests/ -├── Architecture.Tests/ # Layering enforcement (mandatory) -├── {Module}.Tests/ # Module-specific tests -└── Generic.Tests/ # Shared utilities -``` - -## Naming Conventions - -| Type | Pattern | -|------|---------| -| Test class | `{ClassUnderTest}Tests` | -| Test method | `{Method}_{Scenario}_{ExpectedResult}` | -| Test file | Same as class name | - -## Test Structure - -Always use Arrange-Act-Assert: - -```csharp -[Fact] -public async Task Handle_ValidCommand_ReturnsId() -{ - // Arrange - var command = new CreateProductCommand("Test", 10m); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Id.Should().NotBeEmpty(); -} -``` - -## Required Tests - -### For Handlers -- Happy path with valid input -- Edge cases (empty, null, boundary values) -- Repository interactions verified - -### For Validators -- Each validation rule has a test -- Valid input passes -- Invalid input fails with correct property - -### For Entities -- Factory method creates valid entity -- Invalid input throws appropriate exception -- Domain events raised correctly - -## Libraries - -- **xUnit** - Test framework -- **FluentAssertions** - `.Should()` assertions -- **Moq** - `Mock<T>` for dependencies - -## Architecture Tests - -Architecture tests in `Architecture.Tests/` are mandatory and enforce: -- Module boundary isolation -- No cross-module internal dependencies -- Handlers/validators are sealed -- Contracts don't depend on implementations - -These run on every build and PR. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md new file mode 100644 index 0000000000..e63c1523e9 --- /dev/null +++ b/.agents/rules/testing.md @@ -0,0 +1,47 @@ +# Testing conventions + +Read before writing or changing tests. + +## Stack + +xUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration). + +## Naming & shape + +- Method name: `MethodName_Should_ExpectedBehavior_When_Condition`. +- Arrange-Act-Assert, grouped with `#region` (Happy Path / Exception / Edge Cases). +- Assert on observable behavior. When verifying a forwarded `CancellationToken`, assert the **specific** token (`Received(1).XAsync(arg, ct)`), not the implicit default — NSubstitute fills optional params with `default`, so `Received(1).XAsync(arg)` silently asserts `CancellationToken.None`. + +## Test projects (`src/Tests/`) + +| Project | Scope | Docker? | +|---|---|---| +| `{Module}.Tests` | Unit: handlers, services, domain | no | +| `Framework.Tests`, `Generic.Tests`, `Caching.Tests` | BuildingBlocks units | no | +| `Architecture.Tests` | NetArchTest: module boundaries + tenant-isolation rules + handler↔validator pairing | no | +| `Integration.Tests` | `WebApplicationFactory` over real PostgreSQL/Redis/MinIO | **yes** | +| `Integration.Middleware.Tests` | Real middleware wiring | **yes** | + +```bash +dotnet test src/FSH.Starter.slnx # all (integration needs Docker) +dotnet test src/Tests/{Module}.Tests # one project +dotnet test --collect "XPlat Code Coverage" --settings coverage.runsettings +``` + +If Docker is down, integration tests fail fast with `DockerUnavailableException` — that is environmental, not a code regression. Run the unit projects to validate logic. + +## Architecture tests (must stay green) + +- Modules reference other modules only via `.Contracts`. +- Tenant-isolation rules on entities. +- Every command handler + paginated query handler has a `{Name}Validator` (`HandlerValidatorPairingTests`). Validator names accepted: `{Cmd}Validator`, `{Name}CommandValidator`, `{Name}Validator`. + +## Integration-test gotchas + +- Set the Finbuckle tenant context **inline** in the test method (AsyncLocal — an awaited helper loses it → NRE in the tenant filter). +- `AddHeroStorage` reads config eagerly; rewire `IStorageService` **after** registration in the factory. +- SignalR hub tests force **long-polling** (TestServer has no WebSocket). + +## Frontend tests + +Playwright, route-mocked (no real backend) — see `frontend/shared.md`. `cd clients/{app} && npm run test:e2e`. diff --git a/.agents/skills/add-entity/SKILL.md b/.agents/skills/add-entity/SKILL.md index 9096d4eaef..e3fbcd0d96 100644 --- a/.agents/skills/add-entity/SKILL.md +++ b/.agents/skills/add-entity/SKILL.md @@ -1,164 +1,125 @@ --- name: add-entity -description: Create a domain entity with multi-tenancy, auditing, soft-delete, and domain events. Use when adding new database entities to a module. +description: Add a domain entity/aggregate with EF configuration and a migration to an existing FSH module. Use when adding a new database-backed entity. Pairs with add-feature and create-migration. argument-hint: [ModuleName] [EntityName] --- # Add Entity -Create a domain entity following FSH patterns with full multi-tenancy support. +Rich domain model: `sealed` aggregate, private EF ctor, static factory, behavior via methods, domain +events. DB conventions: `.agents/rules/database.md`. -## Entity Template +## Entity — `AggregateRoot<Guid>` (or `BaseEntity<Guid>`) + +`BaseEntity<TId>` gives only `Id` + domain-event machinery. Audit/tenant/soft-delete are **opt-in via +marker interfaces** (the base does NOT carry those fields). New ids use **`Guid.CreateVersion7()`**. ```csharp public sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable { - // Domain properties - public string Name { get; private set; } = null!; - public decimal Price { get; private set; } - public string? Description { get; private set; } - - // IHasTenant - automatic tenant isolation - public string TenantId { get; private set; } = null!; + public string Name { get; private set; } = default!; + public Money Price { get; private set; } = default!; - // IAuditableEntity - automatic audit trails - public DateTimeOffset CreatedAt { get; set; } + // IHasTenant + public string TenantId { get; private set; } = default!; + // IAuditableEntity + public DateTimeOffset CreatedOnUtc { get; set; } public string? CreatedBy { get; set; } - public DateTimeOffset? LastModifiedAt { get; set; } + public DateTimeOffset? LastModifiedOnUtc { get; set; } public string? LastModifiedBy { get; set; } - - // ISoftDeletable - automatic soft deletes - public DateTimeOffset? DeletedAt { get; set; } + // ISoftDeletable + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedOnUtc { get; set; } public string? DeletedBy { get; set; } - // Private constructor for EF Core - private {Entity}() { } + private {Entity}() { } // EF - // Factory method - the only way to create - public static {Entity} Create(string name, decimal price, string tenantId) + public static {Entity} Create(string name, Money price) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price); + ArgumentNullException.ThrowIfNull(price); - var entity = new {Entity} - { - Id = Guid.NewGuid(), - Name = name, - Price = price, - TenantId = tenantId - }; - - entity.AddDomainEvent(new {Entity}CreatedEvent(entity.Id)); + var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price }; + entity.AddDomainEvent(DomainEvent.Create((id, ts) => + new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts))); return entity; } - // Domain methods for state changes - public void UpdateDetails(string name, decimal price, string? description) + public void Rename(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price); - - Name = name; - Price = price; - Description = description; - - AddDomainEvent(new {Entity}UpdatedEvent(Id)); + Name = name.Trim(); } } ``` -## Domain Events +Notes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework +(interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`. + +## Domain event — inherit `DomainEvent` (abstract record) ```csharp -public sealed record {Entity}CreatedEvent(Guid {Entity}Id) : IDomainEvent; -public sealed record {Entity}UpdatedEvent(Guid {Entity}Id) : IDomainEvent; -public sealed record {Entity}DeletedEvent(Guid {Entity}Id) : IDomainEvent; +public sealed record {Entity}CreatedDomainEvent( + Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc) + : DomainEvent(EventId, OccurredOnUtc); ``` -## EF Core Configuration +Raise with the `DomainEvent.Create((id, ts) => …)` helper + `AddDomainEvent(...)` (not `QueueDomainEvent`). + +## EF configuration ```csharp public sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}> { public void Configure(EntityTypeBuilder<{Entity}> builder) { - builder.ToTable("{entities}"); - + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("{Entities}"); // schema is set once on the DbContext builder.HasKey(x => x.Id); + builder.Property(x => x.Name).IsRequired().HasMaxLength(200); - builder.Property(x => x.Name) - .IsRequired() - .HasMaxLength(200); - - builder.Property(x => x.Price) - .HasPrecision(18, 2); + // soft-deletable unique field → filter on live rows only + builder.HasIndex(x => x.Name).IsUnique().HasFilter("\"IsDeleted\" = FALSE"); - builder.Property(x => x.TenantId) - .IsRequired() - .HasMaxLength(64); - - builder.HasIndex(x => x.TenantId); + // owned value object + builder.OwnsOne(x => x.Price, m => + { + m.Property(p => p.Amount).HasColumnName("PriceAmount").HasPrecision(18, 4); + m.Property(p => p.Currency).HasColumnName("PriceCurrency").HasMaxLength(3); + }); - // Global query filter for soft-delete - builder.HasQueryFilter(x => x.DeletedAt == null); + builder.Ignore(x => x.DomainEvents); } } ``` -## Register in DbContext +- **Do NOT add a manual `HasQueryFilter` for soft-delete or tenant** — `BaseDbContext` applies both automatically. +- A child entity reached only via a parent nav-collection needs `builder.Property(x => x.Id).ValueGeneratedNever()` in **its** config, or EF inserts it as `Modified` → 0-row UPDATE. See `database.md`. -```csharp -public sealed class {Module}DbContext : DbContext -{ - public DbSet<{Entity}> {Entities} => Set<{Entity}>(); +## Register in the module DbContext - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema("{module}"); - modelBuilder.ApplyConfigurationsFromAssembly(typeof({Module}DbContext).Assembly); - } -} +Add a `DbSet`; configurations are picked up by `ApplyConfigurationsFromAssembly`: + +```csharp +public DbSet<{Entity}> {Entities} => Set<{Entity}>(); ``` -## Add Migration +The DbContext already extends `BaseDbContext` and calls `base.OnModelCreating` **last** — don't change that. + +## Migration + +Use the **create-migration** skill (build first, correct `--context`): ```bash dotnet ef migrations add Add{Entity} \ --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api - -dotnet ef database update \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api + --startup-project src/Host/FSH.Starter.Api \ + --context {X}DbContext ``` -## Interfaces Reference - -| Interface | Purpose | Auto-Handled | -|-----------|---------|--------------| -| `IHasTenant` | Tenant isolation | Query filtering | -| `IAuditableEntity` | Created/Modified tracking | SaveChanges interceptor | -| `ISoftDeletable` | Soft delete support | Delete interceptor | -| `AggregateRoot<T>` | Domain events support | Event dispatcher | - -## Key Rules - -1. **Private constructor** - EF Core needs it, but users use factory methods -2. **Factory methods** - All creation goes through `Create()` static method -3. **Domain methods** - State changes through methods, not property setters -4. **Domain events** - Raise events for significant state changes -5. **Validation in methods** - Validate in factory/domain methods, not entity -6. **No public setters** - Properties are `private set` - ## Checklist -- [ ] Implements `AggregateRoot<Guid>` -- [ ] Implements `IHasTenant` for tenant isolation -- [ ] Implements `IAuditableEntity` for audit trails -- [ ] Implements `ISoftDeletable` for soft deletes -- [ ] Has private constructor -- [ ] Has static factory method -- [ ] Domain events raised for state changes -- [ ] EF configuration created -- [ ] Added to DbContext -- [ ] Migration created +- [ ] `sealed`, `AggregateRoot<Guid>` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()` +- [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent` +- [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children +- [ ] `DbSet` added; build green; migration created with `--context {X}DbContext` diff --git a/.agents/skills/add-feature/SKILL.md b/.agents/skills/add-feature/SKILL.md index 671cde2164..35d051d17f 100644 --- a/.agents/skills/add-feature/SKILL.md +++ b/.agents/skills/add-feature/SKILL.md @@ -1,117 +1,113 @@ --- name: add-feature -description: Create a new API endpoint with Command, Handler, Validator, and Endpoint following FSH vertical slice architecture. Use when adding any new feature, API endpoint, or business operation. -argument-hint: [ModuleName] [FeatureName] +description: Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists. +argument-hint: [ModuleName] [Area] [FeatureName] --- # Add Feature -Create a complete vertical slice feature with all required files. +A feature is a vertical slice **split across two projects**: the request/response types live in the +module's `.Contracts` project (public API); the handler, validator, and endpoint live in the runtime +project. Full conventions: `.agents/rules/api-conventions.md`. -## File Structure +## Layout (real) ``` -src/Modules/{Module}/Features/v1/{FeatureName}/ -├── {Action}{Entity}Command.cs # or Get{Entity}Query.cs -├── {Action}{Entity}Handler.cs -├── {Action}{Entity}Validator.cs # Commands only -└── {Action}{Entity}Endpoint.cs +src/Modules/{X}/Modules.{X}.Contracts/v1/{Area}/{Feature}Command.cs # ICommand<T>/IQuery<T> +src/Modules/{X}/Modules.{X}.Contracts/Dtos/{Entity}Dto.cs # response DTOs (if any) +src/Modules/{X}/Modules.{X}/Features/v1/{Area}/{Feature}/ +├── {Feature}CommandHandler.cs # public sealed, injects the DbContext directly +├── {Feature}CommandValidator.cs # required for commands + paginated queries +└── {Feature}Endpoint.cs # internal static extension ``` -## Step 1: Create Command or Query +## Step 1 — Command/Query (Contracts project) -**For state changes (POST/PUT/DELETE):** -```csharp -public sealed record Create{Entity}Command( - string Name, - decimal Price) : ICommand<Create{Entity}Response>; -``` +`Mediator` interfaces (`using Mediator;`). Records. A create command can return the raw `Guid`. -**For reads (GET):** ```csharp -public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>; +namespace FSH.Modules.{X}.Contracts.v1.{Area}; + +public sealed record Create{Entity}Command(string Name, decimal PriceAmount, string PriceCurrency) + : ICommand<Guid>; ``` -## Step 2: Create Handler +Read/list DTOs go in `Modules.{X}.Contracts/Dtos/`. Paginated queries return `PagedResponse<T>` +(`FSH.Framework.Shared.Persistence`) — see `query-patterns`. + +## Step 2 — Handler (runtime `Features/`) — inject the DbContext, NOT a repository + +There is **no generic `IRepository<T>`**. Inject the module's `{X}DbContext`. `public sealed`, primary +ctor, `ValueTask<T>`, `.ConfigureAwait(false)`, guard first. Tenant/audit fields are auto-stamped — only +inject `ICurrentUser` if you need the acting user (`GetUserId()` / `GetTenant()`). ```csharp -public sealed class Create{Entity}Handler( - IRepository<{Entity}> repository, - ICurrentUser currentUser) : ICommandHandler<Create{Entity}Command, Create{Entity}Response> +public sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext) + : ICommandHandler<Create{Entity}Command, Guid> { - public async ValueTask<Create{Entity}Response> Handle( - Create{Entity}Command command, - CancellationToken ct) + public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken) { - var entity = {Entity}.Create(command.Name, command.Price, currentUser.TenantId); - await repository.AddAsync(entity, ct); - return new Create{Entity}Response(entity.Id); + ArgumentNullException.ThrowIfNull(command); + + var entity = {Entity}.Create(command.Name, new Money(command.PriceAmount, command.PriceCurrency)); + dbContext.{Entities}.Add(entity); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return entity.Id; } } ``` -## Step 3: Create Validator (Commands Only) +Throw `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails. + +## Step 3 — Validator (required; same folder) ```csharp -public sealed class Create{Entity}Validator : AbstractValidator<Create{Entity}Command> +public sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command> { - public Create{Entity}Validator() + public Create{Entity}CommandValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.Price).GreaterThan(0); + RuleFor(x => x.PriceCurrency).NotEmpty().Length(3); } } ``` -## Step 4: Create Endpoint +`Architecture.Tests` fails the build if a command/paginated-query handler has no `{Name}Validator`. + +## Step 4 — Endpoint (same folder) ```csharp public static class Create{Entity}Endpoint { - public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => - endpoints.MapPost("/", async ( - Create{Entity}Command command, - IMediator mediator, - CancellationToken ct) => TypedResults.Created( - $"/{entities}/{(await mediator.Send(command, ct)).Id}")) - .WithName(nameof(Create{Entity}Command)) - .WithSummary("Create a new {entity}") - .RequirePermission({Module}Permissions.{Entities}.Create); + internal static RouteHandlerBuilder MapCreate{Entity}Endpoint(this IEndpointRouteBuilder endpoints) => + endpoints.MapPost("/{entities}", + async (Create{Entity}Command command, IMediator mediator, CancellationToken ct) => + Results.Ok(await mediator.Send(command, ct))) + .WithName("Create{Entity}") + .WithSummary("Create a {entity}") + .RequirePermission({X}Permissions.{Entities}.Create) + .WithIdempotency(); // on replay-safe POSTs } ``` -## Step 5: Add DTOs to Contracts - -In `src/Modules/{Module}/Modules.{Module}.Contracts/`: - -```csharp -public sealed record Create{Entity}Response(Guid Id); -public sealed record {Entity}Dto(Guid Id, string Name, decimal Price); -``` - -## Step 6: Wire Endpoint in Module - -In `{Module}Module.cs` MapEndpoints method: +## Step 5 — Wire it in `{X}Module.MapEndpoints` ```csharp -var entities = endpoints.MapGroup("/{entities}").WithTags("{Entities}"); -entities.Map{Action}{Entity}Endpoint(); +group.MapCreate{Entity}Endpoint(); // group = endpoints.MapGroup("api/v{version:apiVersion}/{x}") … ``` -## Step 7: Verify +## Step 6 — Verify ```bash -dotnet build src/FSH.Starter.slnx # Must be 0 warnings -dotnet test src/FSH.Starter.slnx +dotnet build src/FSH.Starter.slnx # 0 warnings (TreatWarningsAsErrors) +dotnet test src/Tests/{X}.Tests # + add a handler/validator test (see testing-guide) ``` ## Checklist -- [ ] Command/Query uses `ICommand<T>` or `IQuery<T>` (NOT MediatR's IRequest) -- [ ] Handler uses `ICommandHandler<T,R>` or `IQueryHandler<T,R>` -- [ ] Handler returns `ValueTask<T>` (NOT `Task<T>`) -- [ ] Validator exists for commands -- [ ] Endpoint has `.RequirePermission()` or `.AllowAnonymous()` -- [ ] Endpoint has `.WithName()` and `.WithSummary()` -- [ ] DTOs in Contracts project, not internal -- [ ] Build passes with 0 warnings +- [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/` +- [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask<T>` + `.ConfigureAwait(false)` +- [ ] `{Name}Validator` exists +- [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary` +- [ ] Wired in `{X}Module.MapEndpoints` +- [ ] Build 0 warnings; test added diff --git a/.agents/skills/add-full-slice/SKILL.md b/.agents/skills/add-full-slice/SKILL.md new file mode 100644 index 0000000000..00bb5d2e75 --- /dev/null +++ b/.agents/skills/add-full-slice/SKILL.md @@ -0,0 +1,50 @@ +--- +name: add-full-slice +description: Build a capability end-to-end — backend vertical slice (Contracts→handler→validator→endpoint) AND the React page wired to it. Use when delivering a user-facing feature across API + UI. Composes add-feature + add-react-page. +argument-hint: [ModuleName] [admin|dashboard] [FeatureName] +--- + +# Add Full Slice (backend → frontend) + +The kit's accelerator: deliver a feature from database to UI in one pass. This skill **composes** +`add-feature` (backend) and `add-react-page` (frontend) — follow each for the detailed code; this file is +the order of operations and the **contract** that keeps the two halves in sync. + +## Order of operations + +1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed): + - Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`). + - Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`). + - Wire in `{X}Module.MapEndpoints`. Build + test backend green. +2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly. +3. **Frontend page** — `add-react-page` in the chosen app: + - API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase). + - Page (`useQuery`/`useMutation`), route (`RouteGuard` for admin / `withSuspense` for dashboard). +4. **Permission** — if the endpoint is gated, mirror the constant into admin's `lib/permissions.ts` and gate the route (`add-permission`). Dashboard relies on the server 403. +5. **Tests both sides** — backend handler/validator test (xUnit/Shouldly/NSubstitute) + frontend Playwright spec (route-mocked). + +## The contract (the thing that breaks if you're sloppy) + +| Backend | Frontend must match | +|---|---| +| Endpoint route `api/v{n}/{module}/{resources}` | `apiFetch` path | +| Request: the `Command`/`Query` record fields | request body / query-param keys (admin params PascalCase; **body JSON camelCase**) | +| Response: the DTO record (camelCase JSON) | the hand-written TS `type` | +| `.RequirePermission({X}Permissions...)` | (admin) `RouteGuard perms` + the mirrored constant | +| Paginated → `PagedResponse<T>` | `PagedResponse<T>` (admin: `@/lib/api-types`; dashboard: inline) | + +## Verify end-to-end + +```bash +dotnet build src/FSH.Starter.slnx && dotnet test src/Tests/{X}.Tests +cd clients/{app} && npm run lint && npm run test:e2e +# optional manual check: dotnet run --project src/Host/FSH.Starter.AppHost (brings up API + both apps) +``` + +## Checklist + +- [ ] Backend slice complete + green (`add-feature`) +- [ ] Contract locked: route, request shape, response DTO field names +- [ ] Frontend api module path + TS types match the contract (body JSON camelCase) +- [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`) +- [ ] Backend test + Playwright test added; both suites green diff --git a/.agents/skills/add-integration-event/SKILL.md b/.agents/skills/add-integration-event/SKILL.md new file mode 100644 index 0000000000..71f3809085 --- /dev/null +++ b/.agents/skills/add-integration-event/SKILL.md @@ -0,0 +1,94 @@ +--- +name: add-integration-event +description: Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md. +argument-hint: [SourceModule] [EventName] [ConsumerModule] +--- + +# Add Integration Event + +Cross-module communication goes through **integration events + the Outbox** (transactional, crash-safe) — +never a direct in-process call into another module's runtime, and never `IEventBus.PublishAsync` from a +handler. Full model: `.agents/rules/eventing.md`. + +## Step 1 — Define the event (source module's Contracts) + +`Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs` — implement `IIntegrationEvent`: + +```csharp +public sealed record {Event}IntegrationEvent( + Guid Id, + DateTime OccurredOnUtc, + string? TenantId, + string CorrelationId, + string Source, + Guid {Entity}Id, + string SomePayload) : IIntegrationEvent; +``` + +⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes +`Type.GetType()` return null and the message dead-letters. Keep the type name + namespace stable. + +## Step 2 — Publish via the Outbox (source handler) + +The source module must have eventing wired (`add-module` Step 1): `AddEventingCore` + `AddEventingForDbContext<{Source}DbContext>`. Inject `IOutboxStore` and add the event in the same unit of work: + +```csharp +public sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxStore outbox) + : ICommandHandler<Do{Thing}Command, Unit> +{ + public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken) + { + // … mutate entities, db.SaveChangesAsync … + var evt = new {Event}IntegrationEvent( + Id: Guid.CreateVersion7(), + OccurredOnUtc: DateTime.UtcNow, + TenantId: /* current tenant */, + CorrelationId: Guid.NewGuid().ToString(), + Source: "{Source}", + {Entity}Id: entity.Id, + SomePayload: "…"); + await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false); + return Unit.Value; + } +} +``` + +The `OutboxDispatcherHostedService` later publishes it via `IEventBus`. + +## Step 3 — Handle it (consumer module) + +`Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cs` — `sealed`, implement `IIntegrationEventHandler<T>`: + +```csharp +public sealed class {Event}IntegrationEventHandler({Consumer}DbContext db /*, IHubContext<AppHub> hub */) + : IIntegrationEventHandler<{Event}IntegrationEvent> +{ + public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(@event); + // … write to the consumer's tables / push a notification … + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +Register the consumer's handlers in its `ConfigureServices`: + +```csharp +builder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly); +``` + +## Gotchas + +- **Idempotency is free** with the in-memory bus (the Inbox dedups by `{eventId, handlerName}`) — don't hand-roll it. +- The in-memory bus runs handlers **synchronously in the publisher's scope** — keep the handler lean; a throw surfaces to the originating request. +- If the handler reads a **tenant-filtered** DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via `IMultiTenantContextSetter` (see `WebhookFanoutHandler`). +- **Module load order:** the consumer must load before the publisher if it must react (`Order` in `[assembly: FshModule]`) — e.g. Notifications (750) before Chat (800). + +## Checklist + +- [ ] Event in source Contracts, implements `IIntegrationEvent`, stable type name +- [ ] Source module has `AddEventingCore` + `AddEventingForDbContext<T>`; published via `IOutboxStore.AddAsync` (not the bus) +- [ ] Consumer handler `sealed : IIntegrationEventHandler<T>`; `AddIntegrationEventHandlers(assembly)` registered +- [ ] Background readers restore tenant context; module `Order` lets the consumer load first +- [ ] Build + tests green diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md index 8887f52cef..6383fb9d2e 100644 --- a/.agents/skills/add-module/SKILL.md +++ b/.agents/skills/add-module/SKILL.md @@ -1,176 +1,135 @@ --- name: add-module -description: Create a new module (bounded context) with proper project structure, permissions, DbContext, and registration. Use when adding a new business domain that needs its own entities and endpoints. +description: Create a new module (bounded context) — runtime + Contracts projects, IModule, DbContext, permissions, migrations, and the four registration sites. Use when adding a distinct business domain. For a feature in an existing module, use add-feature. argument-hint: [ModuleName] --- # Add Module -Create a new bounded context with full project structure. +High-ceremony. The part people get wrong is **registration — a module must be wired in FOUR places** +(see Step 6). Architecture rules: `.agents/rules/architecture.md`. -## When to Create a New Module - -- Has its own domain entities -- Could be deployed independently -- Represents a distinct business domain - -If it's just a feature in an existing domain, use `add-feature` instead. - -## Project Structure +## Projects ``` src/Modules/{Name}/ -├── Modules.{Name}/ -│ ├── Modules.{Name}.csproj -│ ├── {Name}Module.cs -│ ├── {Name}PermissionConstants.cs -│ ├── {Name}DbContext.cs -│ ├── Domain/ -│ │ └── {Entity}.cs -│ └── Features/v1/ -│ └── {Feature}/ -└── Modules.{Name}.Contracts/ - ├── Modules.{Name}.Contracts.csproj - └── DTOs/ +├── Modules.{Name}/ ← runtime (internal): Domain/, Data/, Features/v1/, {Name}Module.cs +└── Modules.{Name}.Contracts/ ← public API: v1/ (commands/queries), Dtos/, Authorization/, Events/ ``` -## Step 1: Create Projects - -### Main Module Project -`src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj`: -```xml -<Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net10.0</TargetFramework> - </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\BuildingBlocks\Core\Core.csproj" /> - <ProjectReference Include="..\..\BuildingBlocks\Persistence\Persistence.csproj" /> - <ProjectReference Include="..\..\BuildingBlocks\Web\Web.csproj" /> - <ProjectReference Include="..\Modules.{Name}.Contracts\Modules.{Name}.Contracts.csproj" /> - </ItemGroup> -</Project> -``` +**Copy an existing module's two `.csproj` files** (e.g. `Modules.Catalog`) and rename — don't hand-write +project references. The runtime project references its Contracts project + the BuildingBlocks it needs; +the Contracts project references `Mediator` + shared contracts. -### Contracts Project -`src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj`: -```xml -<Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net10.0</TargetFramework> - </PropertyGroup> -</Project> -``` +## Step 1 — `[FshModule]` is an ASSEMBLY attribute (not class-level) -## Step 2: Implement IModule +In `{Name}Module.cs`, above the namespace: ```csharp +[assembly: FshModule(typeof(FSH.Modules.{Name}.{Name}Module), 900)] // (Type, order) + +namespace FSH.Modules.{Name}; + public sealed class {Name}Module : IModule { public void ConfigureServices(IHostApplicationBuilder builder) { - // Register DbContext - builder.Services.AddDbContext<{Name}DbContext>((sp, options) => - { - var dbOptions = sp.GetRequiredService<IOptions<DatabaseOptions>>().Value; - options.UseNpgsql(dbOptions.ConnectionString); - }); - - // Register repositories - builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); - builder.Services.AddScoped(typeof(IReadRepository<>), typeof(Repository<>)); + ArgumentNullException.ThrowIfNull(builder); + PermissionConstants.Register({Name}Permissions.All); + builder.Services.AddHeroDbContext<{Name}DbContext>(); + builder.Services.AddScoped<IDbInitializer, {Name}DbInitializer>(); + + // Only if the module publishes/handles integration events: + // builder.Services.AddEventingCore(builder.Configuration); + // builder.Services.AddEventingForDbContext<{Name}DbContext>(); + // builder.Services.AddIntegrationEventHandlers(typeof({Name}Module).Assembly); + + builder.Services.AddHealthChecks() + .AddDbContextCheck<{Name}DbContext>(name: "db:{name}"); } + public void ConfigureMiddleware(IApplicationBuilder app) { } // optional, runs after auth + public void MapEndpoints(IEndpointRouteBuilder endpoints) { - var group = endpoints.MapGroup("/api/v1/{name}"); - // Map feature endpoints here + ArgumentNullException.ThrowIfNull(endpoints); + var versionSet = endpoints.NewApiVersionSet().HasApiVersion(new ApiVersion(1)).ReportApiVersions().Build(); + var group = endpoints.MapGroup("api/v{version:apiVersion}/{name}") + .WithTags("{Name}").WithApiVersionSet(versionSet).RequireAuthorization(); + // group.MapCreate{Entity}Endpoint(); … } } ``` -## Step 3: Add Permission Constants +`Order` controls load sequence (Auditing 300, Files 350, Webhooks 400, Billing 500, Catalog 600, Tickets 700, Notifications 750, Chat 800). If your module consumes another's events, load after it. -```csharp -public static class {Name}PermissionConstants -{ - public static class {Entities} - { - public const string View = "{Entities}.View"; - public const string Create = "{Entities}.Create"; - public const string Update = "{Entities}.Update"; - public const string Delete = "{Entities}.Delete"; - } -} -``` +## Step 2 — Permissions (Contracts/Authorization) -## Step 4: Create DbContext +`{Name}Permissions` with nested resource classes and an `All` collection registered via `PermissionConstants.Register({Name}Permissions.All)`. Mirror the shape of `CatalogPermissions`. + +## Step 3 — DbContext (extends `BaseDbContext`) ```csharp -public sealed class {Name}DbContext : DbContext +public sealed class {Name}DbContext : BaseDbContext { - public {Name}DbContext(DbContextOptions<{Name}DbContext> options) : base(options) { } + public const string Schema = "{name}"; + + public {Name}DbContext( + IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor, + DbContextOptions<{Name}DbContext> options, + IOptions<DatabaseOptions> settings, + IHostEnvironment environment) + : base(multiTenantContextAccessor, options, settings, environment) { } public DbSet<{Entity}> {Entities} => Set<{Entity}>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.HasDefaultSchema("{name}"); + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly); + base.OnModelCreating(modelBuilder); // MUST be last — applies tenant + soft-delete filters } } ``` -## Step 5: Register in Program.cs - -```csharp -// Add to moduleAssemblies array -var moduleAssemblies = new Assembly[] -{ - typeof(IdentityModule).Assembly, - typeof(MultitenancyModule).Assembly, - typeof(AuditingModule).Assembly, - typeof({Name}Module).Assembly, // Add here -}; - -// Add Mediator assemblies -builder.Services.AddMediator(o => -{ - o.Assemblies = [ - // ... existing - typeof({Name}Module).Assembly, - ]; -}); -``` - -## Step 6: Add to Solution +## Step 4 — Solution + project references ```bash dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj ``` -## Step 7: Reference from API +Add a `<ProjectReference>` to the runtime module from **both** `FSH.Starter.Api` and `FSH.Starter.DbMigrator`, and reference the runtime project from `FSH.Starter.Migrations.PostgreSQL`. -In `src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj`: -```xml -<ProjectReference Include="..\..\Modules\{Name}\Modules.{Name}\Modules.{Name}.csproj" /> -``` +## Step 5 — Migrations folder + +Add a `{Name}/` folder in `src/Host/FSH.Starter.Migrations.PostgreSQL`, then create the initial migration (see **create-migration**) with `--context {Name}DbContext`. + +## Step 6 — ⚠️ Register in ALL FOUR places (the footgun) + +Identical edits in **both** `FSH.Starter.Api/Program.cs` **and** `FSH.Starter.DbMigrator/Program.cs`: + +1. Mediator `o.Assemblies` — add **two** markers: a Contracts type (e.g. `typeof(FSH.Modules.{Name}.Contracts.{Name}ContractsMarker)`) **and** the module type (`typeof({Name}Module)`). +2. `moduleAssemblies` array — add `typeof({Name}Module).Assembly`. + +Miss the Mediator marker → handlers silently undiscovered. Miss the assembly entry → module never loads. Miss the DbMigrator pair → migrate/seed skips the module. -## Step 8: Verify +## Step 7 — Verify ```bash -dotnet build src/FSH.Starter.slnx # Must be 0 warnings +dotnet build src/FSH.Starter.slnx # 0 warnings +dotnet test src/Tests/Architecture.Tests # boundary + tenant-isolation rules must pass dotnet test src/FSH.Starter.slnx ``` ## Checklist -- [ ] Both projects created (main + contracts) -- [ ] IModule implemented with ConfigureServices and MapEndpoints -- [ ] Permission constants defined -- [ ] DbContext created with proper schema -- [ ] Registered in Program.cs moduleAssemblies -- [ ] Added to solution file -- [ ] Referenced from FSH.Starter.Api -- [ ] Build passes with 0 warnings +- [ ] Two projects (copied csproj), added to `.slnx`, referenced from Api + DbMigrator (+ Migrations) +- [ ] `[assembly: FshModule(typeof({Name}Module), order)]` (assembly-level, positional) +- [ ] `IModule`: `AddHeroDbContext<T>()`, `PermissionConstants.Register`, version-set group, eventing trio if needed +- [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last +- [ ] `{Name}Permissions` in Contracts/Authorization +- [ ] Migrations folder + initial migration (`--context {Name}DbContext`) +- [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies) +- [ ] Build + Architecture.Tests green diff --git a/.agents/skills/add-permission/SKILL.md b/.agents/skills/add-permission/SKILL.md new file mode 100644 index 0000000000..8f5e637ce1 --- /dev/null +++ b/.agents/skills/add-permission/SKILL.md @@ -0,0 +1,72 @@ +--- +name: add-permission +description: Add a new permission end-to-end — server constant + endpoint gate, and (admin app) mirror it into the permissions catalog + route guard. Use when a new endpoint needs authorization. See modules/identity.md + frontend/admin.md. +argument-hint: [ModuleName] [Resource] [Action] +--- + +# Add Permission + +A permission spans server + the admin app. The dashboard app does **not** mirror permissions — it reads +them from the JWT and relies on the server's 403. + +## Step 1 — Server constant (`Modules.{X}.Contracts/Authorization/{X}Permissions.cs`) + +Add the constant to the resource group and ensure it's in the module's `All` collection. Convention: +`Permissions.{Resource}.{Action}`. + +```csharp +public static class {X}Permissions +{ + public static class {Resources} + { + public const string View = "Permissions.{Resources}.View"; + public const string Create = "Permissions.{Resources}.Create"; // ← new + } + public static IReadOnlyList<FshPermission> All { get; } = [ /* … include the new one … */ ]; +} +``` + +The module already calls `PermissionConstants.Register({X}Permissions.All)` in `ConfigureServices`, so a new entry in `All` is picked up automatically. + +## Step 2 — Gate the endpoint + +```csharp +.RequirePermission({X}Permissions.{Resources}.Create); +``` + +⚠️ `RequiredPermissionAttribute` implements `IRequiredPermissionMetadata`. **Never let a second/duplicate of that interface exist** — it silently disables **all** `.RequirePermission()` gates app-wide. (See `.agents/rules/modules/identity.md`.) + +## Step 3 — (admin only) mirror it + +`clients/admin/src/lib/permissions.ts` — add the matching string to the frozen tree (no runtime catalog endpoint exists; mirror by hand): + +```ts +export const {Module}Permissions = Object.freeze({ + {Resources}: { View: "Permissions.{Resources}.View", Create: "Permissions.{Resources}.Create" }, +} as const); +``` + +If it should appear in the Role editor UI, add a `PERMISSION_CATALOG` entry (`{ name, description, root?, basic? }` under the right category group). + +## Step 4 — (admin only) gate the route + +```tsx +{ path: "{resources}/new", + element: <RouteGuard perms={[{Module}Permissions.{Resources}.Create]}><Create{Resource}Page /></RouteGuard> }, +``` + +## Step 5 — (admin only) seed it in tests + +So `RouteGuard` passes on first paint, add the new permission to the test seed set (`ADMIN_PERMS` in `clients/admin/tests/helpers/shell-mocks.ts`, used by `seedAuthedSession`). + +## Dashboard + +No mirror, no `RouteGuard`. The permission rides in the JWT (`claims.permissions`) and the server enforces it; a missing permission yields a 403 the UI surfaces. Nothing to add client-side beyond consuming the gated endpoint. + +## Checklist + +- [ ] Server constant added to `{X}Permissions` **and** its `All` collection +- [ ] Endpoint gated with `.RequirePermission(...)`; no duplicate `IRequiredPermissionMetadata` +- [ ] (admin) mirrored in `lib/permissions.ts` (+ `PERMISSION_CATALOG` if role-editor-visible) +- [ ] (admin) route wrapped in `<RouteGuard perms={[…]}>`; permission added to `ADMIN_PERMS` test seed +- [ ] Build green; admin `test:e2e` green diff --git a/.agents/skills/add-react-page/SKILL.md b/.agents/skills/add-react-page/SKILL.md new file mode 100644 index 0000000000..4dd83d1837 --- /dev/null +++ b/.agents/skills/add-react-page/SKILL.md @@ -0,0 +1,121 @@ +--- +name: add-react-page +description: Add a list+create page to a React app (clients/admin or clients/dashboard) — API module, page, lazy route, (admin) permission gate, Playwright test. Use when adding any frontend screen. See .agents/rules/frontend/. +argument-hint: [admin|dashboard] [Area] [Resource] +--- + +# Add React Page + +The frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (`frontend/admin.md` / +`frontend/dashboard.md`) — the two apps **deliberately diverge**: + +| | **admin** (operator) | **dashboard** (tenant) | +|---|---|---| +| Query params | PascalCase (`PageNumber`, `Search`) | camelCase (`pageNumber`, `search`) | +| `PagedResponse<T>` | import from `@/lib/api-types` | re-declare inline in the api module | +| Path constant | `const BASE = "/api/v1/..."` | inline the full path per call | +| Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) | +| List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `<Dialog>` editors | +| Route wrapper | `<RouteGuard perms={[…]}>` | `withSuspense(<X/>)` (no permission gate) | +| Permissions | mirror in `src/lib/permissions.ts` | none — JWT claims + server 403 | + +Shared everywhere: types are **hand-written** (no codegen); `apiFetch<T>` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5). + +## Step 1 — API module (`src/api/{resource}.ts`) + +Hand-write the DTO/param/input types and thin `apiFetch` functions. + +```ts +// admin +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; +const BASE = "/api/v1/{module}/{resources}"; + +export type {Resource}Dto = { id: string; name: string; /* … */ }; + +export async function search{Resources}(p: { pageNumber?: number; search?: string } = {}) { + const q = new URLSearchParams(); + q.set("PageNumber", String(p.pageNumber ?? 1)); + q.set("PageSize", "10"); + if (p.search?.trim()) q.set("Search", p.search.trim()); + return apiFetch<PagedResponse<{Resource}Dto>>(`${BASE}/search?${q}`); +} +export async function create{Resource}(input: Create{Resource}Input) { + return apiFetch<{ id: string }>(BASE, { method: "POST", body: JSON.stringify(input) }); +} +``` + +(dashboard: inline `type PagedResponse<T> = …`, inline the path, camelCase params, mutations often return `Promise<string>`.) + +## Step 2 — Page (`src/pages/{area}/...tsx`, named export) + +```tsx +export function {Resource}ListPage() { + const [search, setSearch] = useState(""); // debounce → reset page to 1 on change + const [pageNumber, setPage] = useState(1); + const query = useQuery({ + queryKey: ["{resources}", { pageNumber, search }], // hierarchical; params object last + queryFn: () => search{Resources}({ pageNumber, search: search || undefined }), + placeholderData: keepPreviousData, + }); + // render with components/ui/* + components/list/* (admin: PageHeader/Field…; dashboard: Entity* family) +} +``` + +## Step 3 — Mutation (race-safe `mutate(arg)`) + +Pass per-call data through `mutate(arg)`; read it from the callback `variables` — never from a closed-over render variable. + +```tsx +const qc = useQueryClient(); +const createMut = useMutation({ + mutationFn: (input: Create{Resource}Input) => create{Resource}(input), + onSuccess: () => { toast.success("Created"); qc.invalidateQueries({ queryKey: ["{resources}"] }); }, + onError: (e) => toast.error(e instanceof ApiRequestError ? e.message : "Failed"), +}); +// admin: const form = useForm({ resolver: zodResolver(schema) }); form.handleSubmit(v => createMut.mutate(v)) +// dashboard: controlled useState fields; onSubmit(e){ e.preventDefault(); createMut.mutate(payload); } +``` + +If you need to track the in-flight item (e.g. a per-row busy state), use `onMutate: (arg) => setBusyId(arg)` reading the `mutate(arg)` value (pattern: `admin/src/pages/settings/sessions.tsx`). + +## Step 4 — Register the route (`routes.tsx`) + +```tsx +const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage"); +// admin — under AppShell.children, gated: +{ path: "{resources}", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> }, +// dashboard — under AppShell.children, suspense only: +{ path: "{area}/{resources}", element: withSuspense(<{Resource}ListPage />) }, +``` + +## Step 5 — (admin only) mirror the permission + +Add the constant to `src/lib/permissions.ts` (`{Module}Permissions.{Resources}.View` = `"Permissions.{Resources}.View"`), and a `PERMISSION_CATALOG` entry if it belongs in the Role editor. See `add-permission`. + +## Step 6 — Playwright test (`tests/{area}/{resource}.spec.ts`) + +```ts +test.beforeEach(async ({ page }) => { + // admin: seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page); + // dashboard: await seedAuthedSession(page, TEST_USER); await installShellMocks(page); + await mockJsonResponse(page, "**/api/v1/{module}/{resources}**", paged([SAMPLE])); // page mocks AFTER shell mocks +}); +``` + +Use `mockProblemDetails(...)` for error states. Dashboard: scope row assertions with `.last()` / dialog scoping (lists render mobile + desktop copies → strict-mode double match). + +## Step 7 — Verify + +```bash +cd clients/{app} && npm run lint && npm run test:e2e +``` + +## Checklist + +- [ ] API module: hand-written types, `apiFetch`, correct param casing per app (Pascal=admin, camel=dashboard) +- [ ] Page is a **named export**; `useQuery` key hierarchical + `placeholderData: keepPreviousData` +- [ ] Mutation passes data via `mutate(arg)`, invalidates in `onSuccess` +- [ ] Route via `lazyNamed`; admin wraps in `<RouteGuard perms>`, dashboard in `withSuspense` +- [ ] (admin) permission mirrored in `lib/permissions.ts` +- [ ] Playwright test: seed + shell mocks + page mocks; `lint` + `test:e2e` green diff --git a/.agents/skills/create-migration/SKILL.md b/.agents/skills/create-migration/SKILL.md new file mode 100644 index 0000000000..399a8872c3 --- /dev/null +++ b/.agents/skills/create-migration/SKILL.md @@ -0,0 +1,77 @@ +--- +name: create-migration +description: Create and apply an EF Core migration for a module's DbContext the FSH way (central Migrations project, per-module folder, correct --context). Use after changing entities/EF config. See .agents/rules/database.md. +argument-hint: [ModuleName] [MigrationName] +--- + +# Create Migration + +All migrations live in **one** project — `src/Host/FSH.Starter.Migrations.PostgreSQL` — but are foldered +**per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. The DB +is **not** migrated at API startup; the `DbMigrator` host applies it. + +## Step 0 — restore the pinned tool (first time) + +```bash +dotnet tool restore # dotnet-ef is pinned in .config/dotnet-tools.json +``` + +## Step 1 — BUILD FIRST (snapshot footgun) + +`dotnet ef migrations add` reads the **current snapshot**, which is regenerated from a build. If you skip +the build after editing entities/config, you can generate against a stale snapshot and lose changes. Also, +`migrations remove` rewrites the snapshot — only remove the latest, and rebuild after. + +```bash +dotnet build src/FSH.Starter.slnx +``` + +## Step 2 — add the migration + +Specify **all three** of `--project` (the Migrations project), `--startup-project` (the API host), and +`--context {X}DbContext`. Use `--output-dir {X}` so it lands in that module's folder (match the existing +folder for the context). + +```bash +dotnet ef migrations add {MigrationName} \ + --project src/Host/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Host/FSH.Starter.Api \ + --context {X}DbContext \ + --output-dir {X} +``` + +## Step 3 — review the generated SQL before applying + +```bash +dotnet ef migrations script --idempotent \ + --project src/Host/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Host/FSH.Starter.Api \ + --context {X}DbContext +``` + +Check for: unintended table/column drops, non-nullable columns added without a default to an existing +table, and renames surfacing as drop+add (data loss). Adjust the model or hand-edit the migration if needed. + +## Step 4 — apply + +Preferred (the canonical path — migrates the tenant catalog then each tenant's per-module schema): + +```bash +dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply +dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # to preview first +``` + +(Or, single-context local dev, `dotnet ef database update --context {X}DbContext --project … --startup-project …`.) + +## Notes + +- A **new module** also needs a `{X}/` folder in the Migrations project and the runtime project referenced from it — see `add-module`. +- `dotnet ef` against a `BaseDbContext` works because the 4-arg ctor is satisfied by the startup host's DI. + +## Checklist + +- [ ] `dotnet tool restore` done (first time) +- [ ] Built **before** `migrations add` +- [ ] `--context {X}DbContext` + `--output-dir {X}` (lands in the right folder) +- [ ] Reviewed the generated SQL for data loss +- [ ] Applied via DbMigrator `apply` (or `ef database update` for one context locally) diff --git a/.agents/skills/mediator-reference/SKILL.md b/.agents/skills/mediator-reference/SKILL.md index 295fccc3dd..04ae1ef766 100644 --- a/.agents/skills/mediator-reference/SKILL.md +++ b/.agents/skills/mediator-reference/SKILL.md @@ -1,129 +1,69 @@ --- name: mediator-reference -description: Mediator library patterns and interfaces for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers. +description: CQRS interface reference for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers. user-invocable: false --- # Mediator Reference -⚠️ **FSH uses the `Mediator` source generator library, NOT `MediatR`.** +⚠️ **FSH uses the `Mediator` source-generator package (`using Mediator;`), NOT `MediatR`.** Different +interfaces — MediatR types won't compile. The CQRS interfaces below are the library's own (no FSH wrapper). -These are different libraries with different interfaces. Using MediatR interfaces will cause build errors. +## Interfaces -## Interface Comparison - -| Purpose | ✅ Mediator (Use This) | ❌ MediatR (Don't Use) | -|---------|------------------------|------------------------| +| Purpose | ✅ Mediator (use) | ❌ MediatR (don't) | +|---|---|---| | Command | `ICommand<TResponse>` | `IRequest<TResponse>` | | Query | `IQuery<TResponse>` | `IRequest<TResponse>` | -| Command Handler | `ICommandHandler<TCommand, TResponse>` | `IRequestHandler<TRequest, TResponse>` | -| Query Handler | `IQueryHandler<TQuery, TResponse>` | `IRequestHandler<TRequest, TResponse>` | -| Notification | `INotification` | `INotification` | -| Notification Handler | `INotificationHandler<T>` | `INotificationHandler<T>` | +| Command handler | `ICommandHandler<TCommand, TResponse>` | `IRequestHandler<…>` | +| Query handler | `IQueryHandler<TQuery, TResponse>` | `IRequestHandler<…>` | +| Notification / domain event | `INotification` (`IDomainEvent : INotification`) | `INotification` | -## Command Pattern +## Pattern ```csharp -// ✅ Correct - Mediator -public sealed record CreateUserCommand(string Email, string Name) : ICommand<Guid>; - -public sealed class CreateUserHandler : ICommandHandler<CreateUserCommand, Guid> -{ - public async ValueTask<Guid> Handle(CreateUserCommand command, CancellationToken ct) - { - // Implementation - } -} +// Command/Query → the Contracts project (Modules.{X}.Contracts/v1/{Area}/) +public sealed record Create{Entity}Command(string Name) : ICommand<Guid>; -// ❌ Wrong - MediatR -public sealed record CreateUserCommand(string Email, string Name) : IRequest<Guid>; - -public sealed class CreateUserHandler : IRequestHandler<CreateUserCommand, Guid> +// Handler → the runtime project (Modules.{X}/Features/v1/{Area}/{Feature}/), public sealed +public sealed class Create{Entity}CommandHandler({X}DbContext db) + : ICommandHandler<Create{Entity}Command, Guid> { - public async Task<Guid> Handle(CreateUserCommand request, CancellationToken ct) + public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken) { - // This won't work! + // … } } ``` -## Query Pattern - -```csharp -// ✅ Correct - Mediator -public sealed record GetUserQuery(Guid Id) : IQuery<UserDto>; - -public sealed class GetUserHandler : IQueryHandler<GetUserQuery, UserDto> -{ - public async ValueTask<UserDto> Handle(GetUserQuery query, CancellationToken ct) - { - // Implementation - } -} -``` +Rules: handlers return **`ValueTask<T>`** (not `Task<T>`); parameter named `command`/`query` (not `request`); +`public sealed`; `.ConfigureAwait(false)` on awaits. Send via `mediator.Send(command, ct)` (the `IMediator` +interface name matches MediatR's — that part is fine). -## Key Differences +## Registration — the four places -| Aspect | Mediator | MediatR | -|--------|----------|---------| -| Return type | `ValueTask<T>` | `Task<T>` | -| Parameter name | `command` / `query` | `request` | -| Registration | Source generated | Runtime reflection | -| Performance | Faster (compile-time) | Slower (runtime) | - -## Sending Commands/Queries - -```csharp -// In endpoint -public static async Task<IResult> Handle( - CreateUserCommand command, - IMediator mediator, // Same interface name as MediatR - CancellationToken ct) -{ - var result = await mediator.Send(command, ct); - return TypedResults.Created($"/users/{result}"); -} -``` - -## Registration +The source generator only scans assemblies listed in `o.Assemblies`, and that list exists in **two host +files**. A new module needs **two markers** (a Contracts type **and** the module type) added to the Mediator +list **plus** an entry in the `moduleAssemblies` array — in **both** `FSH.Starter.Api/Program.cs` **and** +`FSH.Starter.DbMigrator/Program.cs`: ```csharp -// In Program.cs -builder.Services.AddMediator(options => +builder.Services.AddMediator(o => { - options.Assemblies = - [ - typeof(IdentityModule).Assembly, - typeof(MultitenancyModule).Assembly, - // Add your module assemblies here - ]; + o.ServiceLifetime = ServiceLifetime.Scoped; + o.Assemblies = [ + /* … */ + typeof(FSH.Modules.{X}.Contracts.{X}ContractsMarker), // Contracts assembly + typeof(FSH.Modules.{X}.{X}Module)]; // runtime assembly }); ``` -## Common Errors +See `add-module` for the full procedure. -### Error: `IRequest<T>` not found -**Cause:** Using MediatR interface -**Fix:** Change to `ICommand<T>` or `IQuery<T>` +## Common errors -### Error: `IRequestHandler<T,R>` not found -**Cause:** Using MediatR interface -**Fix:** Change to `ICommandHandler<T,R>` or `IQueryHandler<T,R>` - -### Error: Handler not found at runtime -**Cause:** Assembly not registered in AddMediator -**Fix:** Add assembly to `options.Assemblies` array - -### Error: `Task<T>` vs `ValueTask<T>` -**Cause:** Using MediatR return type -**Fix:** Change handler return type to `ValueTask<T>` - -## Namespaces - -```csharp -// ✅ Correct -using Mediator; - -// ❌ Wrong -using MediatR; -``` +| Symptom | Cause → fix | +|---|---| +| `IRequest<T>` / `IRequestHandler<,>` not found | MediatR interface → use `ICommand`/`IQuery` + `ICommandHandler`/`IQueryHandler` | +| `Task<T>` vs `ValueTask<T>` mismatch | Handler must return `ValueTask<T>` | +| Handler not invoked at runtime | Assembly missing from `o.Assemblies` (in one or both Program.cs files) | diff --git a/.agents/skills/query-patterns/SKILL.md b/.agents/skills/query-patterns/SKILL.md index f2ce63cacb..17db664d12 100644 --- a/.agents/skills/query-patterns/SKILL.md +++ b/.agents/skills/query-patterns/SKILL.md @@ -1,176 +1,112 @@ --- name: query-patterns -description: Query patterns including pagination, search, filtering, and specifications for FSH. Use when implementing GET endpoints that return lists or need filtering. +description: Implement read queries — paginated lists, search/filter/sort, and single-entity fetches — the FSH way (DbContext LINQ + PagedResponse). Use when adding GET endpoints. See also add-feature. --- # Query Patterns -Reference for implementing queries with pagination, search, and filtering. +The dominant pattern is **raw `IQueryable` on the module DbContext** (`AsNoTracking`) with manual +pagination. There is **no generic repository** and **no `PaginatedListAsync`/`EntitiesByPaginationFilterSpec`/ +`PaginationFilter`**. Paged results are `PagedResponse<T>` (`FSH.Framework.Shared.Persistence`) — there is no `PagedList<T>`. -## Basic Paginated Query +## Paginated search query ```csharp -// Query -public sealed record Get{Entities}Query( - string? Search, +// Contracts/v1/{Area}/ +public sealed record Search{Entities}Query( + string? Search = null, + bool? IsActive = null, int PageNumber = 1, - int PageSize = 10) : IQuery<PagedList<{Entity}Dto>>; - -// Handler -public sealed class Get{Entities}Handler( - IReadRepository<{Entity}> repository) : IQueryHandler<Get{Entities}Query, PagedList<{Entity}Dto>> -{ - public async ValueTask<PagedList<{Entity}Dto>> Handle( - Get{Entities}Query query, - CancellationToken ct) - { - var spec = new {Entity}SearchSpec(query.Search, query.PageNumber, query.PageSize); - return await repository.PaginatedListAsync(spec, ct); - } -} + int PageSize = 20, + string? SortBy = null, + string? SortDir = null) : IQuery<PagedResponse<{Entity}Dto>>; ``` -## Specification Pattern - ```csharp -public sealed class {Entity}SearchSpec : EntitiesByPaginationFilterSpec<{Entity}, {Entity}Dto> +// Features/v1/{Area}/Search{Entities}/ +public sealed class Search{Entities}QueryHandler({X}DbContext dbContext) + : IQueryHandler<Search{Entities}Query, PagedResponse<{Entity}Dto>> { - public {Entity}SearchSpec(string? search, int pageNumber, int pageSize) - : base(new PaginationFilter(pageNumber, pageSize)) + public async ValueTask<PagedResponse<{Entity}Dto>> Handle( + Search{Entities}Query query, CancellationToken cancellationToken) { - Query - .OrderByDescending(x => x.CreatedAt) - .Where(x => string.IsNullOrEmpty(search) || - x.Name.Contains(search) || - x.Description!.Contains(search)); - } -} -``` + ArgumentNullException.ThrowIfNull(query); + int page = query.PageNumber < 1 ? 1 : query.PageNumber; + int size = Math.Clamp(query.PageSize, 1, 100); -## Get Single Entity + var q = dbContext.{Entities}.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(query.Search)) + q = q.Where(x => EF.Functions.ILike(x.Name, $"%{query.Search}%")); + if (query.IsActive is { } active) + q = q.Where(x => x.IsActive == active); -```csharp -// Query -public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>; + q = ApplySort(q, query.SortBy, query.SortDir); -// Handler -public sealed class Get{Entity}Handler( - IReadRepository<{Entity}> repository) : IQueryHandler<Get{Entity}Query, {Entity}Dto> -{ - public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken ct) - { - var spec = new {Entity}ByIdSpec(query.Id); - var entity = await repository.FirstOrDefaultAsync(spec, ct); + long total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false); + var items = await q.Skip((page - 1) * size).Take(size) + .ToListAsync(cancellationToken).ConfigureAwait(false); - return entity ?? throw new NotFoundException($"{Entity} {query.Id} not found"); + return new PagedResponse<{Entity}Dto> + { + Items = items.Select(x => x.ToDto()).ToList(), + PageNumber = page, PageSize = size, + TotalCount = total, + TotalPages = (int)Math.Ceiling(total / (double)size) + }; } -} -// Specification -public sealed class {Entity}ByIdSpec : Specification<{Entity}, {Entity}Dto>, ISingleResultSpecification<{Entity}> -{ - public {Entity}ByIdSpec(Guid id) + private static IQueryable<{Entity}> ApplySort(IQueryable<{Entity}> q, string? by, string? dir) { - Query.Where(x => x.Id == id); + bool desc = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase); + return by?.ToLowerInvariant() switch + { + "name" => desc ? q.OrderByDescending(x => x.Name) : q.OrderBy(x => x.Name), + _ => q.OrderByDescending(x => x.CreatedOnUtc) + }; } } ``` -## Advanced Filtering +Tenant + soft-delete filters apply automatically — don't re-filter them. Project to a DTO (`.ToDto()` mapper); never return entities. + +## Single-entity query ```csharp -public sealed record Get{Entities}Query( - string? Search, - Guid? CategoryId, - decimal? MinPrice, - decimal? MaxPrice, - DateTimeOffset? CreatedAfter, - bool? IsActive, - string? SortBy, - bool SortDescending = false, - int PageNumber = 1, - int PageSize = 10) : IQuery<PagedList<{Entity}Dto>>; +public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>; -public sealed class {Entity}FilterSpec : EntitiesByPaginationFilterSpec<{Entity}, {Entity}Dto> +public sealed class Get{Entity}QueryHandler({X}DbContext dbContext) + : IQueryHandler<Get{Entity}Query, {Entity}Dto> { - public {Entity}FilterSpec(Get{Entities}Query query) - : base(new PaginationFilter(query.PageNumber, query.PageSize)) + public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken cancellationToken) { - Query - .Where(x => string.IsNullOrEmpty(query.Search) || x.Name.Contains(query.Search)) - .Where(x => !query.CategoryId.HasValue || x.CategoryId == query.CategoryId) - .Where(x => !query.MinPrice.HasValue || x.Price >= query.MinPrice) - .Where(x => !query.MaxPrice.HasValue || x.Price <= query.MaxPrice) - .Where(x => !query.IsActive.HasValue || x.IsActive == query.IsActive); - - ApplySorting(query.SortBy, query.SortDescending); - } - - private void ApplySorting(string? sortBy, bool descending) - { - switch (sortBy?.ToLowerInvariant()) - { - case "name": - if (descending) Query.OrderByDescending(x => x.Name); - else Query.OrderBy(x => x.Name); - break; - case "price": - if (descending) Query.OrderByDescending(x => x.Price); - else Query.OrderBy(x => x.Price); - break; - default: - Query.OrderByDescending(x => x.CreatedAt); - break; - } + ArgumentNullException.ThrowIfNull(query); + var entity = await dbContext.{Entities}.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false) + ?? throw new NotFoundException($"{Entity} {query.Id} not found"); + return entity.ToDto(); } } ``` -## Endpoint Patterns - -### List Endpoint -```csharp -public static RouteHandlerBuilder MapGet{Entities}Endpoint(this IEndpointRouteBuilder endpoints) => - endpoints.MapGet("/", async ( - [AsParameters] Get{Entities}Query query, - IMediator mediator, - CancellationToken ct) => TypedResults.Ok(await mediator.Send(query, ct))) - .WithName(nameof(Get{Entities}Query)) - .WithSummary("Get paginated list of {entities}") - .RequirePermission({Module}Permissions.{Entities}.View); -``` +## Endpoints -### Single Entity Endpoint ```csharp -public static RouteHandlerBuilder MapGet{Entity}Endpoint(this IEndpointRouteBuilder endpoints) => - endpoints.MapGet("/{id:guid}", async ( - Guid id, - IMediator mediator, - CancellationToken ct) => TypedResults.Ok(await mediator.Send(new Get{Entity}Query(id), ct))) - .WithName(nameof(Get{Entity}Query)) - .WithSummary("Get {entity} by ID") - .RequirePermission({Module}Permissions.{Entities}.View); +// list — bind the query with [AsParameters] +endpoints.MapGet("/{entities}", async ([AsParameters] Search{Entities}Query query, + IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(query, ct))) + .WithName("Search{Entities}").RequirePermission({X}Permissions.{Entities}.View); + +// single +endpoints.MapGet("/{entities}/{id:guid}", async (Guid id, + IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new Get{Entity}Query(id), ct))) + .WithName("Get{Entity}").RequirePermission({X}Permissions.{Entities}.View); ``` -## Response Types - -```csharp -// In Contracts project -public sealed record {Entity}Dto( - Guid Id, - string Name, - decimal Price, - string? Description, - DateTimeOffset CreatedAt); - -// PagedList<T> is from BuildingBlocks -// Returns: Items, PageNumber, PageSize, TotalCount, TotalPages -``` +A paginated query **needs a validator** (`Search{Entities}QueryValidator`: `PageNumber >= 1`, `PageSize` in `[1,100]`) — enforced by `Architecture.Tests`. -## Key Points +## When to use a Specification instead -1. **Use specifications** - Don't write raw LINQ in handlers -2. **Tenant filtering is automatic** - Framework handles `IHasTenant` -3. **Soft delete filtering is automatic** - DeletedAt != null filtered out -4. **Use `[AsParameters]`** - For query parameters in endpoints -5. **Project to DTOs** - Never return entities directly +`Specification<T>` (`src/BuildingBlocks/Persistence/Specifications/`) is for **composing reusable query +shapes** (`protected Where(...)`/`Include(...)`/`OrderBy(...)` in a derived spec's ctor; `AsNoTracking` +defaults true; specs never paginate). Reach for it when the same filter/include set is shared across +handlers; otherwise inline LINQ is the norm here. diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md index 67cc6bae52..1108634094 100644 --- a/.agents/skills/testing-guide/SKILL.md +++ b/.agents/skills/testing-guide/SKILL.md @@ -1,223 +1,106 @@ --- name: testing-guide -description: Write unit tests, integration tests, and architecture tests for FSH features. Use when adding tests or understanding the testing strategy. +description: Write tests for an FSH feature — xUnit + Shouldly + NSubstitute + AutoFixture, with naming and AAA conventions. Use when adding unit/handler/validator/entity tests. Full rules in .agents/rules/testing.md + integration-testing.md. --- # Testing Guide -FSH uses a layered testing strategy with architecture tests as guardrails. +Stack: **xUnit** + **Shouldly** (`.ShouldBe`) + **NSubstitute** (`Substitute.For<>`) + **AutoFixture** +(`new Fixture()`). **Not** Moq, **not** FluentAssertions. Detailed conventions + integration-test gotchas +live in `.agents/rules/testing.md` and `.agents/rules/integration-testing.md`. -## Test Project Structure +## Conventions -``` -src/Tests/ -├── Architecture.Tests/ # Enforces layering rules -├── Generic.Tests/ # Shared test utilities -├── Identity.Tests/ # Identity module tests -├── Multitenancy.Tests/ # Multitenancy module tests -└── Auditing.Tests/ # Auditing module tests -``` - -## Architecture Tests - -Architecture tests enforce module boundaries and layering. They run on every build. - -```csharp -public class ArchitectureTests -{ - [Fact] - public void Modules_ShouldNot_DependOnOtherModules() - { - var result = Types.InAssembly(typeof(IdentityModule).Assembly) - .ShouldNot() - .HaveDependencyOn("Modules.Multitenancy") - .GetResult(); - - result.IsSuccessful.Should().BeTrue(); - } - - [Fact] - public void Contracts_ShouldNot_DependOnImplementation() - { - var result = Types.InAssembly(typeof(UserDto).Assembly) - .ShouldNot() - .HaveDependencyOn("Modules.Identity") - .GetResult(); - - result.IsSuccessful.Should().BeTrue(); - } - - [Fact] - public void Handlers_ShouldBe_Sealed() - { - var result = Types.InAssembly(typeof(IdentityModule).Assembly) - .That() - .ImplementInterface(typeof(ICommandHandler<,>)) - .Or() - .ImplementInterface(typeof(IQueryHandler<,>)) - .Should() - .BeSealed() - .GetResult(); - - result.IsSuccessful.Should().BeTrue(); - } -} -``` - -## Unit Test Patterns +- Test class: `public sealed class {Sut}Tests`; SUT field named `_sut`. +- Method name: **`MethodName_Should_ExpectedBehavior[_When_Condition]`**. +- Arrange-Act-Assert with `// Arrange` / `// Act` / `// Assert`; group with `#region` (Happy Path / Guards / Edge Cases). +- Mocks via `Substitute.For<IService>()`; assert calls with `.Received(1).X(arg, Arg.Any<CancellationToken>())`. +- When asserting a forwarded `CancellationToken`, assert the **specific** token, not the default (NSubstitute fills optional params with `default`). -### Handler Tests +## Handler test ```csharp -public class Create{Entity}HandlerTests +public sealed class Create{Entity}CommandHandlerTests { - private readonly Mock<IRepository<{Entity}>> _repositoryMock; - private readonly Mock<ICurrentUser> _currentUserMock; - private readonly Create{Entity}Handler _handler; + private readonly {X}DbContext _db; // or Substitute.For<IService>() for service deps + private readonly Create{Entity}CommandHandler _sut; + private readonly IFixture _fixture = new Fixture(); - public Create{Entity}HandlerTests() + public Create{Entity}CommandHandlerTests() { - _repositoryMock = new Mock<IRepository<{Entity}>>(); - _currentUserMock = new Mock<ICurrentUser>(); - _currentUserMock.Setup(x => x.TenantId).Returns("test-tenant"); - - _handler = new Create{Entity}Handler( - _repositoryMock.Object, - _currentUserMock.Object); + _db = /* in-memory or test DbContext */; + _sut = new Create{Entity}CommandHandler(_db); } [Fact] - public async Task Handle_ValidCommand_Returns{Entity}Id() + public async Task Handle_Should_PersistEntity_And_ReturnId() { // Arrange - var command = new Create{Entity}Command("Test", 99.99m); - _repositoryMock - .Setup(x => x.AddAsync(It.IsAny<{Entity}>(), It.IsAny<CancellationToken>())) - .Returns(Task.CompletedTask); + var command = new Create{Entity}Command(_fixture.Create<string>(), 9.99m, "USD"); // Act - var result = await _handler.Handle(command, CancellationToken.None); + var id = await _sut.Handle(command, CancellationToken.None); // Assert - result.Id.Should().NotBeEmpty(); - _repositoryMock.Verify(x => x.AddAsync( - It.Is<{Entity}>(e => e.Name == "Test" && e.Price == 99.99m), - It.IsAny<CancellationToken>()), Times.Once); + id.ShouldNotBe(Guid.Empty); } } ``` -### Validator Tests +Service-dependency example (NSubstitute): ```csharp -public class Create{Entity}ValidatorTests -{ - private readonly Create{Entity}Validator _validator = new(); - - [Fact] - public void Validate_EmptyName_Fails() - { - var command = new Create{Entity}Command("", 99.99m); - var result = _validator.Validate(command); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().Contain(e => e.PropertyName == "Name"); - } +_userService = Substitute.For<IUserService>(); +// Act … then: +await _userService.Received(1).ToggleStatusAsync(true, command.UserId, Arg.Any<CancellationToken>()); +``` - [Fact] - public void Validate_NegativePrice_Fails() - { - var command = new Create{Entity}Command("Test", -1m); - var result = _validator.Validate(command); +## Validator test - result.IsValid.Should().BeFalse(); - result.Errors.Should().Contain(e => e.PropertyName == "Price"); - } +```csharp +public sealed class Create{Entity}CommandValidatorTests +{ + private readonly Create{Entity}CommandValidator _sut = new(); [Theory] - [InlineData("Valid Name", 10)] - [InlineData("Another", 0.01)] - public void Validate_ValidCommand_Passes(string name, decimal price) + [InlineData("")] + public void Validate_Should_Fail_When_NameInvalid(string name) { - var command = new Create{Entity}Command(name, price); - var result = _validator.Validate(command); - - result.IsValid.Should().BeTrue(); + var result = _sut.Validate(new Create{Entity}Command(name, 1m, "USD")); + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == nameof(Create{Entity}Command.Name)); } } ``` -### Entity Tests +## Entity / domain test (no mocks) ```csharp -public class {Entity}Tests +[Fact] +public void Create_Should_RaiseCreatedEvent() { - [Fact] - public void Create_ValidInput_Creates{Entity}WithEvent() - { - var entity = {Entity}.Create("Test", 99.99m, "tenant-1"); - - entity.Id.Should().NotBeEmpty(); - entity.Name.Should().Be("Test"); - entity.Price.Should().Be(99.99m); - entity.TenantId.Should().Be("tenant-1"); - entity.DomainEvents.Should().ContainSingle(e => e is {Entity}CreatedEvent); - } - - [Fact] - public void Create_EmptyName_ThrowsArgumentException() - { - var act = () => {Entity}.Create("", 99.99m, "tenant-1"); + var entity = {Entity}.Create("Test", Money.Zero()); + entity.Id.ShouldNotBe(Guid.Empty); + entity.DomainEvents.ShouldContain(e => e is {Entity}CreatedDomainEvent); +} +``` - act.Should().Throw<ArgumentException>(); - } +## Architecture tests (guardrails — keep green) - [Fact] - public void UpdateDetails_ValidInput_UpdatesAndRaisesEvent() - { - var entity = {Entity}.Create("Original", 50m, "tenant-1"); - entity.ClearDomainEvents(); +`Architecture.Tests` (NetArchTest) enforce: module boundaries (cross-module refs only via `.Contracts`), +tenant-isolation rules, handlers `sealed`, and **every command/paginated-query handler has a validator**. +Don't weaken these to make a change pass — fix the code. - entity.UpdateDetails("Updated", 75m, "New description"); +## Integration tests - entity.Name.Should().Be("Updated"); - entity.Price.Should().Be(75m); - entity.Description.Should().Be("New description"); - entity.DomainEvents.Should().ContainSingle(e => e is {Entity}UpdatedEvent); - } -} -``` +`Integration.Tests` runs over real Postgres/Redis/MinIO via Testcontainers — **Docker required**. Set the +Finbuckle tenant context inline, rewire `IStorageService` post-registration for MinIO, force long-polling +for SignalR. All detailed in `.agents/rules/integration-testing.md`. -## Running Tests +## Run ```bash -# Run all tests -dotnet test src/FSH.Starter.slnx - -# Run specific test project +dotnet test src/Tests/{X}.Tests dotnet test src/Tests/Architecture.Tests - -# Run with coverage -dotnet test src/FSH.Starter.slnx --collect:"XPlat Code Coverage" - -# Run specific test -dotnet test --filter "FullyQualifiedName~Create{Entity}HandlerTests" +dotnet test src/FSH.Starter.slnx --collect "XPlat Code Coverage" --settings coverage.runsettings ``` - -## Test Conventions - -| Convention | Example | -|------------|---------| -| Test class name | `{ClassUnderTest}Tests` | -| Test method name | `{Method}_{Scenario}_{ExpectedResult}` | -| Structure | Always Arrange-Act-Assert | -| Assertions | Multiple asserts OK if same concept | - -## Key Rules - -1. **Architecture tests are mandatory** - They enforce module boundaries -2. **Validators need tests** - Cover edge cases -3. **Handlers need tests** - Mock dependencies -4. **Entities need tests** - Test factory methods and domain logic -5. **Use FluentAssertions** - `.Should()` syntax -6. **Use Moq for mocking** - `Mock<T>` pattern diff --git a/.agents/workflows/architecture-guard.md b/.agents/workflows/architecture-guard.md index 091fc00159..9269cce0ba 100644 --- a/.agents/workflows/architecture-guard.md +++ b/.agents/workflows/architecture-guard.md @@ -1,118 +1,67 @@ --- -description: Verify changes don't violate architecture rules. Run architecture tests, check module boundaries, verify BuildingBlocks aren't modified. Use before commits or PRs. +description: Verify changes don't violate architectural integrity — module boundaries, BuildingBlocks protection, the four-place module registration, and the architecture-test suite. Run before commit/PR. READ-ONLY. --- -You are an architecture guardian for FullStackHero .NET Starter Kit. Your job is to verify architectural integrity. You are READ-ONLY — never modify files. +You are the architecture guardian for FullStackHero. You verify integrity and report — **READ-ONLY, never +modify files.** The authoritative enforcement is `Architecture.Tests` (NetArchTest); the greps below are +fast heuristics that point you at things to confirm against the tests + `.agents/rules/architecture.md`. -## Verification Steps - -### 1. Check for BuildingBlocks Modifications +## Steps +### 1. BuildingBlocks guard ```bash git diff --name-only | grep -E "^src/BuildingBlocks/" ``` +Any hit → **STOP and flag**: BuildingBlocks changes need explicit approval (wide blast radius). -If any files listed: **STOP** - BuildingBlocks changes require explicit approval. - -### 2. Run Architecture Tests - +### 2. Architecture tests (the real enforcement) ```bash -dotnet test src/Tests/Architecture.Tests --no-build +dotnet test src/Tests/Architecture.Tests ``` +Covers: cross-module references only via `.Contracts`, tenant-isolation rules on entities, handlers `sealed`, and **every command/paginated-query handler has a validator**. All must pass. -All tests must pass. - -### 3. Verify Build Has 0 Warnings - +### 3. Build clean ```bash -dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" +dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" # expect none (TreatWarningsAsErrors) ``` -Must show no warnings or errors. - -### 4. Check Module Boundaries - -Verify no cross-module internal dependencies: - +### 4. Module boundary heuristic ```bash -# Check if any module references another module's internal types -grep -r "using Modules\." src/Modules/ --include="*.cs" | grep -v "\.Contracts" +grep -rn "using FSH.Modules\." src/Modules --include="*.cs" | grep -v "\.Contracts" ``` +Cross-module `using`s should resolve only to `*.Contracts` namespaces (same-module internal usings are fine — confirm the module name differs). -Should only show references to `.Contracts` namespaces. - -### 5. Verify Mediator Usage - +### 5. Mediator, not MediatR ```bash -# Check for MediatR usage (should be empty) -grep -r "MediatR\|IRequest<\|IRequestHandler<" src/Modules/ --include="*.cs" +grep -rn "MediatR\|IRequest<\|IRequestHandler<" src/Modules --include="*.cs" # must be empty ``` -Must be empty - all should use Mediator interfaces. - -### 6. Check Validator Coverage - -For each command, verify a validator exists: - +### 6. New-module registration (the four-place footgun) +If a new `*Module` was added, confirm it appears in **all four**: Mediator `o.Assemblies` (Contracts marker **and** module type) + `moduleAssemblies` array, in **both** `FSH.Starter.Api/Program.cs` and `FSH.Starter.DbMigrator/Program.cs`. ```bash -# List commands -find src/Modules -name "*Command.cs" -type f - -# List validators -find src/Modules -name "*Validator.cs" -type f +grep -rn "{New}Module\|{New}ContractsMarker" src/Host/FSH.Starter.Api/Program.cs src/Host/FSH.Starter.DbMigrator/Program.cs ``` -Every command needs a corresponding validator. - -### 7. Check Endpoint Authorization - +### 7. Permission-gate integrity +Confirm exactly one `IRequiredPermissionMetadata` implementation exists — a duplicate silently disables **all** `.RequirePermission()` gates. ```bash -# Find endpoints without authorization -grep -r "\.Map\(Get\|Post\|Put\|Delete\)" src/Modules/ --include="*.cs" -A 5 | \ -grep -v "RequirePermission\|AllowAnonymous" -``` - -Every endpoint must have explicit authorization. - -## Output Format - +grep -rn "IRequiredPermissionMetadata" src --include="*.cs" ``` -## Architecture Verification Report - -### BuildingBlocks -✅ No modifications | ⚠️ MODIFIED - Requires approval - -### Architecture Tests -✅ All passed | ❌ {count} failed - -### Build Warnings -✅ 0 warnings | ❌ {count} warnings -### Module Boundaries -✅ Clean | ❌ Cross-module dependencies found +### 8. Tenant-isolation sanity +New module DbContexts extend `BaseDbContext` and call `base.OnModelCreating` **last**; opt-outs use `IGlobalEntity`. (Detailed rules: `database.md`, `modules/multitenancy.md`.) -### Mediator Usage -✅ Correct | ❌ MediatR interfaces detected - -### Validators -✅ All commands have validators | ❌ Missing: {list} - -### Authorization -✅ All endpoints authorized | ❌ Missing: {list} - ---- -**Overall:** ✅ PASS | ❌ FAIL - Fix issues before commit +## Output ``` +## Architecture Verification -## Quick Commands - -```bash -# Full verification -dotnet build src/FSH.Starter.slnx && dotnet test src/FSH.Starter.slnx - -# Architecture tests only -dotnet test src/Tests/Architecture.Tests +BuildingBlocks : ✅ untouched | ⚠️ MODIFIED — needs approval +Architecture.Tests : ✅ pass | ❌ {n} failed: {names} +Build : ✅ 0 warnings | ❌ {n} +Module boundaries : ✅ clean | ❌ {cross-module refs} +Mediator usage : ✅ | ❌ MediatR detected at {file:line} +Module registration : ✅ 4/4 places | ❌ missing in {file} +Permission metadata : ✅ single | ❌ duplicate at {file:line} -# Check for common issues -git diff --name-only | xargs grep -l "IRequest<\|MediatR" +Overall: ✅ PASS | ❌ FAIL — fix before commit ``` diff --git a/.agents/workflows/code-reviewer.md b/.agents/workflows/code-reviewer.md index e0e3cd522c..cfe56325ee 100644 --- a/.agents/workflows/code-reviewer.md +++ b/.agents/workflows/code-reviewer.md @@ -1,93 +1,66 @@ --- -description: Review code changes against FSH patterns and conventions. Run after any code modifications to catch violations before commit. +description: Review the current diff against FSH conventions and emit a structured report. Run after code changes, before commit. Read-only review (do not fix unless asked). --- -You are a code reviewer for the FullStackHero .NET Starter Kit. Your job is to review code changes and ensure they follow FSH patterns, outputting a structured report. +You review code changes for FullStackHero against its conventions and output a structured report. The +conventions are defined in `.agents/rules/` and `AGENTS.md` — treat those as the source of truth; this +playbook is the review procedure, not a second copy of the rules. -## Review Process +## Procedure +1. `git diff HEAD` (and `git status`) to see what changed; group by area (backend module / BuildingBlocks / frontend). +2. For each changed file, check it against the relevant rule file (`api-conventions.md`, `database.md`, `eventing.md`, `frontend/*`, …) and the checklist below. +3. If the Roslyn navigator MCP is available, run `detect_antipatterns` and `get_diagnostics` (solution scope) for machine-found issues (broad `catch`, missing `CancellationToken`, EF `AsNoTracking`, logging interpolation) and fold them in — noting false positives (mutate-then-save queries don't want `AsNoTracking`; hosted-service `catch(Exception)` that logs + filters OCE is fine). +4. Report with `file:line` refs and a concrete fix per finding. -1. Run `git diff` to see recent changes -2. Identify which files were modified -3. Check each change against the rules below -4. Report violations with specific file:line references +## Checklist (high-signal) +**Boundaries / structure** +- Cross-module references go only through `.Contracts` (never another module's runtime). Enforced by `Architecture.Tests`. +- `src/BuildingBlocks/**` not modified without explicit approval (flag if it is). +- New module → registered in **all four** places (Mediator + `moduleAssemblies` in Api **and** DbMigrator). -## Critical Rules to Check +**CQRS / Mediator (not MediatR)** +- Command/Query in the Contracts project; `using Mediator;` (`ICommand<T>`/`IQuery<T>`). +- Handler `public sealed`, `ICommandHandler<,>`/`IQueryHandler<,>`, returns `ValueTask<T>`, `.ConfigureAwait(false)`, injects the `{X}DbContext` (no generic repository). +- Every command + paginated query has a `{Name}Validator` (Architecture.Tests enforces). -### Architecture -- [ ] Features are in `Modules/{Module}/Features/v1/{Name}/` structure -- [ ] DTOs are in Contracts project, not internal -- [ ] No cross-module dependencies (modules only use Contracts) -- [ ] BuildingBlocks not modified without explicit approval +**Endpoints** +- `internal static …Map{Feature}Endpoint`; `.RequirePermission(...)` (or deliberate `.AllowAnonymous()`); `.WithName`/`.WithSummary`. Returns `Results.Ok(...)`/`TypedResults`. `.WithIdempotency()` on replay-safe POSTs. No duplicate `IRequiredPermissionMetadata`. -### Mediator (NOT MediatR!) -- [ ] Commands use `ICommand<T>` not `IRequest<T>` -- [ ] Queries use `IQuery<T>` not `IRequest<T>` -- [ ] Handlers use `ICommandHandler<T,R>` or `IQueryHandler<T,R>` -- [ ] Handler methods return `ValueTask<T>` not `Task<T>` -- [ ] Using `Mediator` namespace, not `MediatR` +**Data** +- Entities: `sealed`, `Guid.CreateVersion7()`, private ctor + factory, behavior via methods. Marker interfaces use `CreatedOnUtc`/`IsDeleted`/`DeletedOnUtc`. +- DbContext extends `BaseDbContext`, `base.OnModelCreating` last; **no manual tenant/soft-delete query filter**. Nav-collection children need `ValueGeneratedNever()`. `AsNoTracking` on read-only queries only (not read-then-save). -### Validation -- [ ] Every command has a matching `AbstractValidator<TCommand>` -- [ ] Validators use FluentValidation rules +**Cross-cutting** +- **Structured logging only** — no `$"..."` interpolation in log calls. +- `CancellationToken` propagated into EF/IO calls. +- Cross-module events go via the Outbox (`IOutboxStore.AddAsync`), not a direct bus publish. -### Endpoints -- [ ] Has `.RequirePermission()` or `.AllowAnonymous()` -- [ ] Has `.WithName()` matching the command/query name -- [ ] Has `.WithSummary()` with description -- [ ] Returns TypedResults, not raw objects - -### Entities -- [ ] Implements required interfaces (IHasTenant, IAuditableEntity, ISoftDeletable) -- [ ] Has private constructor for EF Core -- [ ] Uses factory method for creation -- [ ] Properties have `private set` -- [ ] Domain events raised for state changes - -### Naming -- [ ] Commands: `{Action}{Entity}Command` -- [ ] Queries: `Get{Entity}Query` or `Get{Entities}Query` -- [ ] Handlers: `{CommandOrQuery}Handler` -- [ ] Validators: `{Command}Validator` -- [ ] DTOs: `{Entity}Dto`, `{Entity}Response` - -## Commands to Run +**Frontend** (`frontend/*` rules) +- Hand-written types + `apiFetch`; mutation data passed via `mutate(arg)`; query keys hierarchical; admin gates routes with `RouteGuard` + mirrors the permission; dashboard uses `withSuspense`. +## Commands ```bash -# Review staged/uncommitted changes git diff HEAD - -# Check for MediatR usage (must be empty) -grep -r "MediatR\|IRequest<\|IRequestHandler<" src/Modules/ --include="*.cs" - -# Check build -dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" +grep -rn "MediatR\|IRequest<\|IRequestHandler<" src/Modules/ --include="*.cs" # must be empty +dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" # 0 expected ``` -## Output Format - +## Output ``` -## Code Review Summary - -### ✅ Passed -- [List what's correct] +## Code Review -### ❌ Violations Found -1. **{Rule}** - {file}:{line} - - Issue: {description} - - Fix: {how to fix} +### Passed +- … -### ⚠️ Warnings -- [Optional suggestions] +### Violations (file:line) +1. {rule} — {file}:{line} + Issue: … + Fix: … -### Build Verification -Run: `dotnet build src/FSH.Starter.slnx` -Expected: 0 warnings -``` +### Warnings / suggestions +- … -## After Review - -Always suggest running: -```bash -dotnet build src/FSH.Starter.slnx # Verify 0 warnings -dotnet test src/FSH.Starter.slnx # Run tests +### Verification +dotnet build src/FSH.Starter.slnx → expect 0 warnings +dotnet test src/FSH.Starter.slnx (integration tests need Docker) ``` diff --git a/.agents/workflows/feature-scaffolder.md b/.agents/workflows/feature-scaffolder.md index dcc50a2908..b67266aef7 100644 --- a/.agents/workflows/feature-scaffolder.md +++ b/.agents/workflows/feature-scaffolder.md @@ -1,107 +1,32 @@ --- -description: Generate complete feature slices (Command/Handler/Validator/Endpoint) from requirements. Use when creating new API endpoints or features. +description: Orchestrate delivering a feature end-to-end. Sequences the scaffolding skills and verifies. Use when asked to "add a feature/endpoint/screen". Delegates the code recipes to skills — does not restate them. --- -You are a feature scaffolder for FullStackHero .NET Starter Kit. Your job is to generate complete vertical slice features. +You orchestrate feature delivery for FullStackHero. **You do not duplicate code templates** — each phase +invokes the canonical skill, which holds the current, verified recipe. Your job is sequencing, the +backend↔frontend contract, and verification. -## Required Information +## Clarify first +1. Module (existing? if not → `module-creator`). +2. Operation: command (state change) or query (read)? +3. Does it need a new entity? (→ Phase 0) +4. UI surface: backend-only, `admin`, or `dashboard`? +5. Request fields + response shape + permission. -Before generating, confirm: -1. **Module name** - Which module? (e.g., Identity, Catalog) -2. **Feature name** - What action? (e.g., CreateProduct, GetUser) -3. **Entity name** - What entity? (e.g., Product, User) -4. **Operation type** - Command (state change) or Query (read)? -5. **Properties** - What fields does the command/query need? +## Phases (delegate each recipe to its skill) +- **Phase 0 — entity (if new):** follow the **`add-entity`** skill, then **`create-migration`**. +- **Phase 1 — backend slice:** follow the **`add-feature`** skill (Command/Query in Contracts → handler injecting the `{X}DbContext` → validator → endpoint → wire in `MapEndpoints`). Add a handler/validator test per **`testing-guide`**. Build + test green before moving on. +- **Phase 2 — frontend (if a UI surface):** lock the contract (route, request shape, **response DTO field names — JSON is camelCase**), then follow the **`add-react-page`** skill for the chosen app. For the whole flow at once, use the **`add-full-slice`** skill. +- **Phase 3 — permission (if gated):** follow the **`add-permission`** skill (server constant + admin mirror/guard). -## Generation Process - -### Step 1: Create Feature Folder - -``` -src/Modules/{Module}/Features/v1/{FeatureName}/ -``` - -### Step 2: Generate Files - -For **Commands** (POST/PUT/DELETE), create 4 files: -1. `{Action}{Entity}Command.cs` -2. `{Action}{Entity}Handler.cs` -3. `{Action}{Entity}Validator.cs` -4. `{Action}{Entity}Endpoint.cs` - -For **Queries** (GET), create 3 files: -1. `Get{Entity}Query.cs` or `Get{Entities}Query.cs` -2. `Get{Entity}Handler.cs` -3. `Get{Entity}Endpoint.cs` - -### Step 3: Add DTOs to Contracts - -Create response/DTO types in: -``` -src/Modules/{Module}/Modules.{Module}.Contracts/ -``` - -### Step 4: Wire Endpoint - -Show where to add endpoint mapping in the module's `MapEndpoints` method. - -## Template: Command - -```csharp -// {Action}{Entity}Command.cs -public sealed record {Action}{Entity}Command( - {Properties}) : ICommand<{Action}{Entity}Response>; - -// {Action}{Entity}Handler.cs -public sealed class {Action}{Entity}Handler( - IRepository<{Entity}> repository, - ICurrentUser currentUser) : ICommandHandler<{Action}{Entity}Command, {Action}{Entity}Response> -{ - public async ValueTask<{Action}{Entity}Response> Handle( - {Action}{Entity}Command command, - CancellationToken ct) - { - // Implementation - } -} - -// {Action}{Entity}Validator.cs -public sealed class {Action}{Entity}Validator : AbstractValidator<{Action}{Entity}Command> -{ - public {Action}{Entity}Validator() - { - // Validation rules - } -} - -// {Action}{Entity}Endpoint.cs -public static class {Action}{Entity}Endpoint -{ - public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => - endpoints.Map{HttpMethod}("/", async ( - {Action}{Entity}Command command, - IMediator mediator, - CancellationToken ct) => TypedResults.{Result}(await mediator.Send(command, ct))) - .WithName(nameof({Action}{Entity}Command)) - .WithSummary("{Summary}") - .RequirePermission({Module}Permissions.{Entities}.{Action}); -} -``` - -## Checklist Before Completion - -- [ ] All files use `Mediator` interfaces (NOT MediatR) -- [ ] Handler returns `ValueTask<T>` -- [ ] Validator exists for commands -- [ ] Endpoint has `.RequirePermission()` and `.WithName()` and `.WithSummary()` -- [ ] DTOs in Contracts project -- [ ] Shown where to wire endpoint in module - -## Verification - -After generation, run: +## Verify ```bash -dotnet build src/FSH.Starter.slnx +dotnet build src/FSH.Starter.slnx && dotnet test src/Tests/{X}.Tests +# if a UI surface: cd clients/{app} && npm run lint && npm run test:e2e ``` +Then run the **`code-reviewer`** and **`architecture-guard`** workflows before commit. -Must show 0 warnings. +## Guardrails (the skills enforce these; confirm them) +- CQRS types live in the **Contracts** project; handlers are `public sealed`, return `ValueTask<T>`, `.ConfigureAwait(false)`. +- Every command + paginated query has a `{Name}Validator` (Architecture.Tests fails otherwise). +- Endpoints gated with `.RequirePermission(...)`; structured logging only; `CancellationToken` propagated. diff --git a/.agents/workflows/migration-helper.md b/.agents/workflows/migration-helper.md index f291a3ef94..ad71f1fb54 100644 --- a/.agents/workflows/migration-helper.md +++ b/.agents/workflows/migration-helper.md @@ -1,126 +1,39 @@ --- -description: Handle EF Core migrations safely. Create, apply, and manage database migrations for the FSH multi-tenant setup. Use when adding entities or changing database schema. +description: Safely manage EF Core migrations for FSH's central per-module Migrations project. Use when adding entities or changing schema. The create-migration skill holds the canonical add/apply recipe. --- -You are a migration helper for FullStackHero .NET Starter Kit. Your job is to safely manage EF Core migrations. +You help manage EF Core migrations safely. The canonical add/review/apply recipe is the **`create-migration`** +skill — follow it. This playbook covers the surrounding facts and troubleshooting. -## Project Paths +## Facts (read before running commands) +- All migrations live in **one** project, `src/Host/FSH.Starter.Migrations.PostgreSQL`, foldered **per module/context** (`Catalog/`, `Identity/`, …), each with its own `{X}DbContextModelSnapshot`. +- Startup project is `src/Host/FSH.Starter.Api`. Always pass `--context {X}DbContext` and `--output-dir {X}`. +- `dotnet-ef` is pinned — `dotnet tool restore` first. +- **The DB is NOT migrated on API startup.** The `DbMigrator` host applies it: it migrates the tenant catalog (`TenantDbContext`) first, then each tenant's per-module schema, serialized by a Postgres advisory lock. (`UseHeroMultiTenantDatabases()` only registers Finbuckle's tenant resolution — it does not run migrations.) +- **Build before `migrations add`** — it reads the snapshot, which regenerates from a build; a stale snapshot silently loses changes. `migrations remove` rewrites the snapshot, so only ever remove the latest and rebuild after. -- **Migrations project:** `src/Host/FSH.Starter.Migrations.PostgreSQL` -- **Startup project:** `src/Host/FSH.Starter.Api` -- **DbContexts:** Each module has its own DbContext - -## Common Operations - -### Add Migration - -```bash -dotnet ef migrations add {MigrationName} \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {DbContextName} -``` - -**Context names:** -- `IdentityDbContext` - Identity module -- `MultitenancyDbContext` - Multitenancy module -- `AuditingDbContext` - Auditing module -- `{Module}DbContext` - Custom modules - -### Apply Migrations - -```bash -dotnet ef database update \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {DbContextName} -``` - -### List Migrations - -```bash -dotnet ef migrations list \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {DbContextName} -``` - -### Remove Last Migration - -```bash -dotnet ef migrations remove \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {DbContextName} -``` - -### Generate SQL Script +## Context names (real) +`IdentityDbContext`, `TenantDbContext` (the tenant catalog — **not** "MultitenancyDbContext"), `AuditDbContext`, `BillingDbContext`, `CatalogDbContext`, `TicketsDbContext`, `FilesDbContext`, `ChatDbContext`, `NotificationsDbContext`, `WebhookDbContext`. +## Apply (canonical path) ```bash -dotnet ef migrations script \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {DbContextName} \ - --output migrations.sql +dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # preview +dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed] ``` +(`dotnet ef database update --context {X}DbContext …` works for a single context in local dev.) -## Multi-Tenant Considerations - -FSH uses per-tenant databases. Migrations apply to: -1. **Host database** - Tenant registry, shared data -2. **Tenant databases** - Tenant-specific data - -The framework handles tenant database migrations automatically on startup via `UseHeroMultiTenantDatabases()`. - -## Migration Naming Conventions - -Use descriptive names: -- `Add{Entity}` - Adding new entity -- `Add{Property}To{Entity}` - Adding column -- `Remove{Property}From{Entity}` - Removing column -- `Create{Index}Index` - Adding index -- `Rename{Old}To{New}` - Renaming - -## Pre-Migration Checklist +## Naming +`Add{Entity}`, `Add{Property}To{Entity}`, `Create{Index}Index`, `Rename{Old}To{New}`. -- [ ] Entity configuration exists (`IEntityTypeConfiguration<T>`) -- [ ] Entity added to DbContext (`DbSet<T>`) -- [ ] Build succeeds with 0 warnings -- [ ] Backup database if production - -## Post-Migration Checklist - -- [ ] Review generated migration file -- [ ] Check Up() and Down() methods are correct -- [ ] Test migration on development database -- [ ] Verify rollback works (Down method) +## Review the generated migration +- `dotnet ef migrations script --idempotent --context {X}DbContext …` and scan for: dropped tables/columns, non-nullable columns added to existing tables without a default, renames surfacing as drop+add (data loss). +- Check `Up()` **and** `Down()`. ## Troubleshooting - -### "No DbContext was found" -Specify context explicitly with `--context {Name}DbContext` - -### "Build failed" -Run `dotnet build src/FSH.Starter.slnx` first - -### "Pending migrations" -Apply pending migrations or remove them if not needed - -### "Migration already applied" -Check `__EFMigrationsHistory` table in database - -## Example: Adding a New Entity - -1. Create entity in `Domain/` folder -2. Create configuration (`IEntityTypeConfiguration<T>`) -3. Add `DbSet<T>` to DbContext -4. Build: `dotnet build src/FSH.Starter.slnx` -5. Add migration: - ```bash - dotnet ef migrations add Add{Entity} \ - --project src/Host/FSH.Starter.Migrations.PostgreSQL \ - --startup-project src/Host/FSH.Starter.Api \ - --context {Module}DbContext - ``` -6. Review migration file -7. Apply: `dotnet ef database update ...` +| Symptom | Cause → fix | +|---|---| +| "No DbContext was found" / multiple contexts | Always pass `--context {X}DbContext` | +| "Build failed" | `dotnet build src/FSH.Starter.slnx` first | +| Migration landed in the wrong folder | Add `--output-dir {X}` (match the context's existing folder) | +| Changes missing from the migration | You didn't build before `migrations add` (stale snapshot) | +| New module's context not found by ef | The Migrations project must reference the module's runtime project | diff --git a/.agents/workflows/module-creator.md b/.agents/workflows/module-creator.md index d91734be89..2f8019ee23 100644 --- a/.agents/workflows/module-creator.md +++ b/.agents/workflows/module-creator.md @@ -1,150 +1,28 @@ --- -description: Create new modules (bounded contexts) with complete project structure, DbContext, permissions, and registration. Use when adding a new business domain. +description: Orchestrate bringing up a new module (bounded context) end-to-end and verifying it loads. Use when adding a new business domain. Delegates the recipe to the add-module skill — does not restate it. --- -You are a module creator for FullStackHero .NET Starter Kit. Your job is to scaffold complete new modules. +You orchestrate a full module bring-up for FullStackHero. **The code recipe lives in the `add-module` +skill** — follow it; this playbook adds the decision gate, sequencing, and verification. -## When to Create a New Module +## Decide: is this really a new module? +A new module has its own domain entities and is a distinct bounded context. If it's just an operation in +an existing domain → use `feature-scaffolder` instead. -Ask these questions: -- Does it have its own domain entities? → Yes = new module -- Could it be deployed independently? → Yes = new module -- Is it just a feature in an existing domain? → No = use existing module - -## Required Information - -Before generating, confirm: -1. **Module name** - PascalCase (e.g., Catalog, Inventory, Billing) -2. **Initial entities** - What domain entities? -3. **Permissions** - What operations need permissions? - -## Generation Process - -### Step 1: Create Project Structure - -``` -src/Modules/{Name}/ -├── Modules.{Name}/ -│ ├── Modules.{Name}.csproj -│ ├── {Name}Module.cs -│ ├── {Name}PermissionConstants.cs -│ ├── {Name}DbContext.cs -│ ├── Domain/ -│ └── Features/v1/ -└── Modules.{Name}.Contracts/ - ├── Modules.{Name}.Contracts.csproj - └── DTOs/ -``` - -### Step 2: Generate Core Files - -**Modules.{Name}.csproj:** -```xml -<Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net10.0</TargetFramework> - </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\BuildingBlocks\Core\Core.csproj" /> - <ProjectReference Include="..\..\BuildingBlocks\Persistence\Persistence.csproj" /> - <ProjectReference Include="..\..\BuildingBlocks\Web\Web.csproj" /> - <ProjectReference Include="..\Modules.{Name}.Contracts\Modules.{Name}.Contracts.csproj" /> - </ItemGroup> -</Project> -``` - -**{Name}Module.cs:** -```csharp -public sealed class {Name}Module : IModule -{ - public void ConfigureServices(IHostApplicationBuilder builder) - { - // DbContext, repositories, services - builder.Services.AddDbContext<{Name}DbContext>((sp, options) => - { - var dbOptions = sp.GetRequiredService<IOptions<DatabaseOptions>>().Value; - options.UseNpgsql(dbOptions.ConnectionString); - }); - builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); - builder.Services.AddScoped(typeof(IReadRepository<>), typeof(Repository<>)); - } - - public void MapEndpoints(IEndpointRouteBuilder endpoints) - { - var group = endpoints.MapGroup("/api/v1/{name}"); - // Map feature endpoints - } -} -``` - -**{Name}PermissionConstants.cs:** -```csharp -public static class {Name}PermissionConstants -{ - public static class {Entities} - { - public const string View = "{Entities}.View"; - public const string Create = "{Entities}.Create"; - public const string Update = "{Entities}.Update"; - public const string Delete = "{Entities}.Delete"; - } -} -``` - -**{Name}DbContext.cs:** -```csharp -public sealed class {Name}DbContext : DbContext -{ - public {Name}DbContext(DbContextOptions<{Name}DbContext> options) : base(options) { } - - public DbSet<{Entity}> {Entities} => Set<{Entity}>(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema("{name}"); - modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly); - } -} -``` - -### Step 3: Create Contracts Project - -**Modules.{Name}.Contracts.csproj:** -```xml -<Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net10.0</TargetFramework> - </PropertyGroup> -</Project> -``` - -### Step 4: Register Module - -Show changes needed in: -1. `src/Host/FSH.Starter.Api/Program.cs` - Add to moduleAssemblies -2. `src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj` - Add ProjectReference -3. Solution file - Add both projects - -### Step 5: Add to Solution +## Sequence (each step → its skill) +1. **Scaffold the module** — follow **`add-module`**: copy an existing module's two `.csproj` files; `[assembly: FshModule(typeof({X}Module), order)]` (assembly-level); `IModule` with `AddHeroDbContext<{X}DbContext>()`, `PermissionConstants.Register({X}Permissions.All)`, a version-set endpoint group, and the eventing trio if it publishes/handles events; `{X}DbContext : BaseDbContext` with `base.OnModelCreating` **last**. +2. **First entity** — follow **`add-entity`**. +3. **First feature** — follow **`add-feature`** (and `add-react-page` if it has UI). +4. **Migration** — follow **`create-migration`** with `--context {X}DbContext --output-dir {X}`; add the `{X}/` folder in the Migrations project. +5. **⚠️ Register in ALL FOUR places** — Mediator `o.Assemblies` (Contracts marker **and** module type) + `moduleAssemblies` array, in **both** `FSH.Starter.Api/Program.cs` **and** `FSH.Starter.DbMigrator/Program.cs`. Add to `.slnx`; reference the runtime project from Api, DbMigrator, and the Migrations project. +## Verify it actually loaded (not just compiled) ```bash -dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj -dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj +dotnet build src/FSH.Starter.slnx # 0 warnings +dotnet test src/Tests/Architecture.Tests # boundary + tenant-isolation rules +dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # new context shows up ``` +Then hit one endpoint and confirm the handler runs — a missing Mediator marker compiles fine but the handler is silently undiscovered. Finish with the `architecture-guard` workflow. -## Checklist - -- [ ] Both projects created (main + contracts) -- [ ] IModule implemented -- [ ] Permission constants defined -- [ ] DbContext created with schema -- [ ] Registered in Program.cs -- [ ] Added to solution -- [ ] Referenced from FSH.Starter.Api -- [ ] Build passes with 0 warnings - -## Verification - -```bash -dotnet build src/FSH.Starter.slnx # Must be 0 warnings -``` +## The footgun, restated +Four registration edits (2 lists × 2 host files). Miss the Mediator marker → handler not found at runtime. Miss the `moduleAssemblies` entry → module never loads. Miss the DbMigrator pair → migrate/seed skips it. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index ba55c4179f..8e70a91709 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,13 +2,6 @@ "version": 1, "isRoot": true, "tools": { - "nswag.consolecore": { - "version": "14.6.3", - "commands": [ - "nswag" - ], - "rollForward": false - }, "dotnet-ef": { "version": "10.0.2", "commands": [ diff --git a/.dockerignore b/.dockerignore index 9df01736b5..989d7d312a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,31 @@ +# .NET build artifacts **/bin **/obj **/.vs +**/TestResults + +# Node / frontend build artifacts **/node_modules +**/dist +**/.vite +**/coverage +**/playwright-report +**/test-results + +# Test projects (not needed in any runtime image) +src/Tests/ + +# VCS / editor / docs / local dev .git +.github +.idea +.vscode *.md -src/Tests/ +LICENSE +**/.env +**/.env.* +!**/.env.example +deploy/ +docs/ +clients/dashboard/public/config.json +clients/admin/public/config.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..2b15509289 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Default: normalize line endings to LF in the repo +* text=auto + +# Shell scripts must always be LF (Docker containers run on Linux) +*.sh text eol=lf + +# Dockerfiles +Dockerfile text eol=lf +*.dockerfile text eol=lf diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000000..fc19ed12f5 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,409 @@ +name: Backend CI + +# Backend pipeline. Only does real work when backend code changed (src/**, +# central build props, global.json, coverage settings, or this file). A +# client-only PR still triggers the workflow but every heavy job is skipped — +# the always-running "Backend CI" gate job reports green so required status +# checks resolve. SDK is pinned via global.json (GA, no preview channel). + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g., 10.0.0-rc.1)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + packages: write + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + # On any non-PR event (push to main, tags, manual release) run the + # full pipeline. On PRs, run only when backend-relevant paths changed. + backend: ${{ github.event_name != 'pull_request' || steps.filter.outputs.backend == 'true' }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'src/**' + - 'global.json' + - 'coverage.runsettings' + - '.github/workflows/backend.yml' + + test: + name: Unit Tests + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore src/FSH.Starter.slnx + + - name: Build + run: dotnet build src/FSH.Starter.slnx -c Release --no-restore -warnaserror + + # Gate on DIRECT vulnerable packages (the ones we control). Transitive + # advisories are surfaced but not blocking — they often can't be fixed + # without an upstream bump. + - name: Audit dependencies for known vulnerabilities + run: | + if dotnet list src/FSH.Starter.slnx package --vulnerable 2>&1 | tee vuln-direct.txt | grep -q 'has the following vulnerable'; then + echo "::error::Direct package(s) with known vulnerabilities detected — see log." + cat vuln-direct.txt + exit 1 + fi + echo "No direct vulnerable packages." + echo "::group::Transitive advisories (informational)" + dotnet list src/FSH.Starter.slnx package --vulnerable --include-transitive 2>&1 | tee vuln-transitive.txt || true + echo "::endgroup::" + + - name: Run unit tests with coverage + run: | + for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do + echo "::group::${proj}.Tests" + dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \ + --collect:"XPlat Code Coverage" --settings coverage.runsettings \ + --results-directory ./TestResults \ + --logger "trx;LogFileName=${proj}.trx" + echo "::endgroup::" + done + + - name: Upload unit coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-unit + path: ./TestResults/**/coverage.cobertura.xml + retention-days: 1 + + - name: Upload unit test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-unit + path: '**/*.trx' + retention-days: 7 + + integration-test: + name: Integration Tests + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + # Testcontainers requires Docker — ubuntu-latest has it pre-installed. + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore src/FSH.Starter.slnx + + # Integration tests build a host with WebApplicationFactory + Testcontainers. + # Middleware tests live in a separate assembly (own process) because a 2nd + # production-middleware host resets the static ModuleLoader. + - name: Run integration tests with coverage + run: | + dotnet test src/Tests/Integration.Tests -c Release \ + --collect:"XPlat Code Coverage" --settings coverage.runsettings \ + --results-directory ./TestResults --logger "trx;LogFileName=Integration.Tests.trx" + dotnet test src/Tests/Integration.Middleware.Tests -c Release \ + --collect:"XPlat Code Coverage" --settings coverage.runsettings \ + --results-directory ./TestResults --logger "trx;LogFileName=Integration.Middleware.Tests.trx" + + - name: Upload integration coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-integration + path: ./TestResults/**/coverage.cobertura.xml + retention-days: 1 + + - name: Upload integration test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-integration + path: '**/*.trx' + retention-days: 7 + + migrator-smoke: + name: DbMigrator Container Smoke + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + # Catches container-publish regressions (Dockerfile-less SDK container) and DI-graph + # breakage in the migrator. Publishes the image locally, runs `apply --catalog-only` + # against an ephemeral Postgres, asserts exit 0. + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: migrator_smoke_pwd + POSTGRES_DB: fsh_migrator_smoke + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Publish DbMigrator container (local daemon) + run: | + dotnet publish src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj \ + -c Release -r linux-x64 \ + /t:PublishContainer \ + -p:ContainerRepository=fsh-db-migrator \ + -p:ContainerImageTags=smoke + + - name: Run DbMigrator against ephemeral Postgres + run: | + docker run --rm --network host \ + -e DatabaseOptions__Provider=POSTGRESQL \ + -e DatabaseOptions__ConnectionString="Host=localhost;Port=5432;Database=fsh_migrator_smoke;Username=postgres;Password=migrator_smoke_pwd" \ + -e DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.PostgreSQL \ + fsh-db-migrator:smoke apply --catalog-only \ + | tee migrator.log + grep -q "finished successfully" migrator.log + + coverage: + name: Coverage Gate + needs: [changes, test, integration-test] + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + # Merges the coverage already collected by the test + integration-test jobs + # (no test re-runs) and fails if line coverage regresses below the floor. + # Ratchet: bump MIN_LINE upward as coverage improves. + env: + MIN_LINE: '80' + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Download unit coverage + uses: actions/download-artifact@v8 + with: + name: coverage-unit + path: ./coverage-in/unit + + - name: Download integration coverage + uses: actions/download-artifact@v8 + with: + name: coverage-integration + path: ./coverage-in/integration + + - name: Install ReportGenerator + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + - name: Merge coverage and generate report + run: reportgenerator -reports:"./coverage-in/**/coverage.cobertura.xml" -targetdir:./coverage-report -reporttypes:"TextSummary;Html;Cobertura" + + - name: Upload coverage report + uses: actions/upload-artifact@v7 + if: always() + with: + name: coverage-report + path: ./coverage-report + retention-days: 7 + + - name: Enforce coverage floor + run: | + LINE=$(grep -oP 'Line coverage:\s*\K[0-9.]+' coverage-report/Summary.txt | head -1) + echo "Line coverage: ${LINE}% (floor: ${MIN_LINE}%)" + awk "BEGIN { exit !(${LINE} >= ${MIN_LINE}) }" || { echo "::error::Line coverage ${LINE}% is below the ${MIN_LINE}% floor"; exit 1; } + + publish-dev-containers: + name: Publish Dev Containers + needs: [changes, test, integration-test] + if: needs.changes.outputs.backend == 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish API container image + run: | + dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ + -c Release -r linux-x64 \ + /t:PublishContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ + -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' + + publish-release: + name: Publish Release (NuGet + Containers) + needs: [changes, test, integration-test] + if: | + (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch') || + startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + echo "No version specified and not a tag push" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version: $VERSION" + + - name: Restore and Build with version + run: | + dotnet restore src/FSH.Starter.slnx + dotnet build src/FSH.Starter.slnx -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + # Distribution model is source-ownership: consumers get the FULL source via the + # `dotnet new fsh` template (below), NOT per-module/BuildingBlocks NuGet packages. + # Only two artifacts publish to NuGet — the `fsh` global CLI tool and the template. + # (The previous per-module packs were incomplete — 4 of 10 modules — and contradicted + # the locked source-ownership model, so they were removed.) + - name: Pack CLI Tool + run: dotnet pack src/Tools/CLI/FSH.CLI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + # The dotnet new template package — the primary distribution artifact. Packs the + # repo (with its root .template.config) so consumers can `dotnet new install` then + # `dotnet new fsh -n MyApp`. Scaffolded output is fully owned, detached source. + - name: Pack Template + run: dotnet pack templates/FullStackHero.NET.StarterKit.csproj -c Release -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Push to NuGet.org + run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push API container + run: | + dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ + -c Release -r linux-x64 \ + /t:PublishContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ + -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' + + # Single required status check. Always runs (even when the heavy jobs are + # skipped on a client-only PR) so branch protection resolves. Fails only if a + # job it depends on actually failed or was cancelled — skipped is fine. + backend-ci: + name: Backend CI + needs: [changes, test, integration-test, migrator-smoke, coverage] + if: always() + runs-on: ubuntu-latest + steps: + - name: Fail if a required backend job failed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: | + echo "::error::A required backend job failed or was cancelled." + exit 1 + - name: Success + run: echo "Backend CI passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 108f973383..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,362 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: - - main - - develop - tags: - - 'v*' - paths: - - 'src/**' - pull_request: - branches: - - main - - develop - paths: - - 'src/**' - workflow_dispatch: - inputs: - version: - description: 'Package version (e.g., 10.0.0-rc.1)' - required: false - type: string - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - packages: write - -env: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - DOTNET_NOLOGO: true - - -jobs: - build: - name: Build - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Restore dependencies - run: dotnet restore src/FSH.Starter.slnx - - - name: Build - run: dotnet build src/FSH.Starter.slnx -c Release --no-restore -warnaserror - - - name: Check for vulnerable packages - run: dotnet list src/FSH.Starter.slnx package --vulnerable --include-transitive 2>&1 | tee vulnerability-report.txt - continue-on-error: true - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: build-output - path: | - src/**/bin/Release - src/**/obj/Release - retention-days: 1 - - test: - name: Test - ${{ matrix.test-project.name }} - runs-on: ubuntu-latest - needs: build - - strategy: - fail-fast: false - matrix: - test-project: - - name: Architecture.Tests - path: src/Tests/Architecture.Tests - - name: Auditing.Tests - path: src/Tests/Auditing.Tests - - name: Caching.Tests - path: src/Tests/Caching.Tests - - name: Generic.Tests - path: src/Tests/Generic.Tests - - name: Identity.Tests - path: src/Tests/Identity.Tests - - name: Multitenancy.Tests - path: src/Tests/Multitenancy.Tests - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Download build artifacts - uses: actions/download-artifact@v8 - with: - name: build-output - path: src - - - name: Run ${{ matrix.test-project.name }} - run: dotnet test ${{ matrix.test-project.path }} -c Release --no-build --verbosity normal --logger "trx;LogFileName=${{ matrix.test-project.name }}.trx" - - - name: Upload test results - uses: actions/upload-artifact@v7 - if: always() - with: - name: test-results-${{ matrix.test-project.name }} - path: '**/*.trx' - retention-days: 7 - - migrator-smoke: - name: DbMigrator Container Smoke - runs-on: ubuntu-latest - needs: build - # Catches container-publish regressions (Dockerfile-less SDK container) and DI-graph - # breakage in the migrator. Publishes the image locally, runs `apply --catalog-only` - # against an ephemeral Postgres, asserts exit 0. - - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_PASSWORD: migrator_smoke_pwd - POSTGRES_DB: fsh_migrator_smoke - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Publish DbMigrator container (local daemon) - run: | - dotnet publish src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj \ - -c Release -r linux-x64 \ - /t:PublishContainer \ - -p:ContainerRepository=fsh-db-migrator \ - -p:ContainerImageTags=smoke - - - name: Run DbMigrator against ephemeral Postgres - run: | - docker run --rm --network host \ - -e DatabaseOptions__Provider=POSTGRESQL \ - -e DatabaseOptions__ConnectionString="Host=localhost;Port=5432;Database=fsh_migrator_smoke;Username=postgres;Password=migrator_smoke_pwd" \ - -e DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.PostgreSQL \ - fsh-db-migrator:smoke apply --catalog-only \ - | tee migrator.log - grep -q "finished successfully" migrator.log - - integration-test: - name: Integration Tests - runs-on: ubuntu-latest - needs: build - # Testcontainers requires Docker — ubuntu-latest has it pre-installed. - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - # Integration tests use WebApplicationFactory + Testcontainers. - # They must build from source (can't use pre-built artifacts). - - name: Run Integration Tests - run: dotnet test src/Tests/Integration.Tests -c Release --verbosity normal --logger "trx;LogFileName=Integration.Tests.trx" - - - name: Upload test results - uses: actions/upload-artifact@v7 - if: always() - with: - name: test-results-Integration.Tests - path: '**/*.trx' - retention-days: 7 - - publish-dev-containers: - name: Publish Dev Containers - runs-on: ubuntu-latest - needs: [test, integration-test] - if: github.ref == 'refs/heads/develop' && github.event_name == 'push' - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Login to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish API container image - run: | - dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ - -c Release -r linux-x64 \ - /t:PublishContainer \ - -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ - -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' - - - publish-release: - name: Publish Release (NuGet + Containers) - runs-on: ubuntu-latest - needs: [test, integration-test] - if: | - (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch') || - startsWith(github.ref, 'refs/tags/v') - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' - - - name: Cache NuGet packages - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Determine version - id: version - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then - VERSION="${{ github.event.inputs.version }}" - elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then - VERSION="${GITHUB_REF#refs/tags/v}" - else - echo "No version specified and not a tag push" - exit 1 - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Publishing version: $VERSION" - - - name: Restore and Build with version - run: | - dotnet restore src/FSH.Starter.slnx - dotnet build src/FSH.Starter.slnx -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} - - - name: Pack BuildingBlocks - run: | - dotnet pack src/BuildingBlocks/Core/Core.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Shared/Shared.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Persistence/Persistence.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Caching/Caching.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Mailing/Mailing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Jobs/Jobs.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Storage/Storage.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Eventing/Eventing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/BuildingBlocks/Web/Web.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - - - name: Pack Modules - run: | - dotnet pack src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Identity/Modules.Identity/Modules.Identity.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - - - name: Pack CLI Tool - run: dotnet pack src/Tools/CLI/FSH.CLI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} - - - name: Push to NuGet.org - run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - - - name: Login to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push API container - run: | - dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ - -c Release -r linux-x64 \ - /t:PublishContainer \ - -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ - -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' - diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index be7949966c..168528711c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,7 +1,7 @@ name: CodeQL Analysis on: pull_request: - branches: [main, develop] + branches: [main] paths: - 'src/**' schedule: @@ -21,7 +21,6 @@ jobs: languages: csharp - uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' + global-json-file: global.json - run: dotnet build src/FSH.Starter.slnx -c Release - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000000..13440884e6 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,127 @@ +name: Frontend CI + +# Frontend pipeline for the two React apps (clients/admin, clients/dashboard). +# Only does real work when clients/** changed; a backend-only PR still triggers +# the workflow but the heavy jobs skip and the always-running "Frontend CI" gate +# reports green so required status checks resolve. Playwright suites are +# route-mocked (no backend needed) — the configs boot a Vite dev server. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + frontend: ${{ github.event_name != 'pull_request' || steps.filter.outputs.frontend == 'true' }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + frontend: + - 'clients/**' + - '.github/workflows/frontend.yml' + + lint-build: + name: Lint & Build (${{ matrix.app }}) + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: [admin, dashboard] + defaults: + run: + working-directory: clients/${{ matrix.app }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: clients/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + # `build` is `tsc -b && vite build` — this is the typecheck + bundle gate. + - name: Build + run: npm run build + + e2e: + name: E2E (${{ matrix.app }}) + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: [admin, dashboard] + defaults: + run: + working-directory: clients/${{ matrix.app }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: clients/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + # The Playwright config starts the Vite dev server itself and mocks all + # API calls via page.route(), so no backend is required. + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report-${{ matrix.app }} + path: clients/${{ matrix.app }}/playwright-report + retention-days: 7 + + # Single required status check — see backend.yml for the rationale. + frontend-ci: + name: Frontend CI + needs: [changes, lint-build, e2e] + if: always() + runs-on: ubuntu-latest + steps: + - name: Fail if a required frontend job failed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: | + echo "::error::A required frontend job failed or was cancelled." + exit 1 + - name: Success + run: echo "Frontend CI passed." diff --git a/.github/workflows/template-smoke.yml b/.github/workflows/template-smoke.yml new file mode 100644 index 0000000000..ac2e7e4f80 --- /dev/null +++ b/.github/workflows/template-smoke.yml @@ -0,0 +1,116 @@ +name: Template Smoke Test + +# Guards the distribution path: scaffolding a project from the template must +# always produce a solution that builds (backend + both React apps) and passes +# architecture tests. Catches regressions like the .slnx referencing an excluded +# project, the Aspire `Projects.*` rename breaking, or clients/** dropping out. + +on: + push: + branches: [main] + paths: + - '.template.config/**' + - 'templates/**' + - 'clients/**' + - 'src/**' + - '.github/workflows/template-smoke.yml' + pull_request: + branches: [main] + paths: + - '.template.config/**' + - 'templates/**' + - 'clients/**' + - 'src/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + scaffold-full: + name: Scaffold (Aspire + React) and build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + # Pack then install from the nupkg — exercises the exact artifact shipped to + # consumers, not just the in-repo folder. + - name: Pack template + run: dotnet pack templates/FullStackHero.NET.StarterKit.csproj -c Release -o "$RUNNER_TEMP/pkg" + + - name: Install template + run: dotnet new install "$RUNNER_TEMP"/pkg/FullStackHero.NET.StarterKit.*.nupkg + + - name: Scaffold project + run: dotnet new fsh -n Smoke.App -o "$RUNNER_TEMP/smoke" --aspire true --frontend true --skipRestore true + + - name: Build backend (warnings as errors) + run: dotnet build "$RUNNER_TEMP/smoke/src/Smoke.App.slnx" -c Release -warnaserror + + - name: Build admin app + working-directory: ${{ runner.temp }}/smoke/clients/admin + run: npm ci && npm run build + + - name: Build dashboard app + working-directory: ${{ runner.temp }}/smoke/clients/dashboard + run: npm ci && npm run build + + - name: Run Architecture tests on scaffolded output + run: dotnet test "$RUNNER_TEMP/smoke/src/Tests/Architecture.Tests" -c Release --no-build + + scaffold-minimal: + name: Scaffold (no Aspire, no React) and build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Install template + run: dotnet new install . + + # Backend-only path must still compile — guards the //#if (frontend) and + # (!aspire) conditional gating in AppHost.cs and the solution. + - name: Scaffold backend-only project + run: dotnet new fsh -n Smoke.Api -o "$RUNNER_TEMP/min" --aspire false --frontend false --skipRestore true + + - name: Build backend (warnings as errors) + run: dotnet build "$RUNNER_TEMP/min/src/Smoke.Api.slnx" -c Release -warnaserror diff --git a/.gitignore b/.gitignore index f819c3f1a2..23798fd6b1 100644 --- a/.gitignore +++ b/.gitignore @@ -159,6 +159,10 @@ coverage*.info *.coverage *.coveragexml +# ReportGenerator output + test result dirs (generated by the coverage workflow) +coverage-report/ +**/TestResults/ + # NCrunch _NCrunch_* .*crunch*.local.xml @@ -490,15 +494,15 @@ $RECYCLE.BIN/ /.bmad team/ -docs/node_modules/ -docs/dist/ -docs/.astro/ spec-os/ /PLAN.md **/nul **/wwwroot/uploads/* /.claude/settings.local.json +/.claude/worktrees/ +/.claude/scheduled_tasks.lock +/.claude/last30days.env tmpclaude** # Auto Claude data directory diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index a0868ce17d..0000000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "shadcn": { - "type": "http", - "url": "https://mcp.shadcn.com" - } - } -} \ No newline at end of file diff --git a/.template.config/template.json b/.template.config/template.json index 6a1ab57f3e..f98401aff2 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -7,9 +7,10 @@ "Vertical Slice", "Boilerplate", "ASP.NET Core", - "Starter Kit", "Cloud", - "Web" + "Web", + "React", + "Full Stack" ], "tags": { "language": "C#", @@ -17,61 +18,129 @@ }, "identity": "FullStackHero.NET.StarterKit", "name": "FullStackHero .NET Starter Kit", - "description": "A production-ready modular .NET 10 framework — Vertical Slice Architecture, CQRS, Multitenancy, Identity, Aspire.", + "description": "A production-ready full-stack starter: modular .NET 10 monolith (VSA, CQRS, multitenancy, Identity, Aspire) + two React 19 apps + Docker/Terraform.", "shortName": "fsh", "sourceName": "FSH.Starter", "preferNameDirectory": true, "symbols": { - "db": { - "type": "parameter", - "datatype": "choice", - "description": "Database provider for migrations and persistence.", - "defaultValue": "postgresql", - "choices": [ - { - "choice": "postgresql", - "description": "PostgreSQL (default)" - }, - { - "choice": "sqlserver", - "description": "SQL Server" - } - ] - }, "aspire": { "type": "parameter", "datatype": "bool", "description": "Include the .NET Aspire AppHost project for orchestration.", "defaultValue": "true" }, + "frontend": { + "type": "parameter", + "datatype": "bool", + "description": "Include the React admin + dashboard client apps (clients/).", + "defaultValue": "true" + }, "skipRestore": { "type": "parameter", "datatype": "bool", "description": "Skip dotnet restore after project creation.", "defaultValue": "false" + }, + "includeTools": { + "type": "parameter", + "datatype": "bool", + "description": "Internal: keep the fsh CLI project in the solution (FSH repo only).", + "defaultValue": "false" + }, + "contactEmail": { + "type": "parameter", + "datatype": "string", + "description": "Contact email written into OpenAPI metadata.", + "defaultValue": "noreply@example.com", + "replaces": "mukesh@codewithmukesh.com" + }, + "contactUrl": { + "type": "parameter", + "datatype": "string", + "description": "Contact / company URL written into OpenAPI metadata.", + "defaultValue": "https://example.com", + "replaces": "https://codewithmukesh.com" + }, + "mailFrom": { + "type": "parameter", + "datatype": "string", + "description": "Default 'from' address for outbound mail.", + "defaultValue": "noreply@example.com", + "replaces": "mukesh@fullstackhero.net" + }, + "sendgridFrom": { + "type": "generated", + "generator": "constant", + "parameters": { "value": "noreply@example.com" }, + "replaces": "sendgrid@fullstackhero.net" + }, + "nameUnderscore": { + "type": "derived", + "valueSource": "name", + "valueTransform": "underscoreForm", + "replaces": "FSH_Starter" + }, + "issuerSlug": { + "type": "derived", + "valueSource": "name", + "valueTransform": "kebabForm", + "replaces": "mukesh.murugan" + }, + "displayBrand": { + "type": "derived", + "valueSource": "name", + "valueTransform": "displayForm", + "replaces": "FullStackHero" + }, + "authorName": { + "type": "derived", + "valueSource": "name", + "valueTransform": "displayForm", + "replaces": "Mukesh Murugan" + }, + "openApiTitle": { + "type": "derived", + "valueSource": "name", + "valueTransform": "displayForm", + "replaces": "FSH PlayGround" } }, + "forms": { + "lc": { "identifier": "lowerCaseInvariant" }, + "dashNorm": { "identifier": "replace", "pattern": "[^a-zA-Z0-9]+", "replacement": "-" }, + "kebabForm": { "identifier": "chain", "steps": ["lc", "dashNorm"] }, + "underscoreForm": { "identifier": "replace", "pattern": "\\.", "replacement": "_" }, + "displayForm": { "identifier": "replace", "pattern": "[._-]+", "replacement": " " } + }, "sources": [ { "source": "./", "target": "./", "exclude": [ ".template.config/**", - ".idea/**", - ".vscode/**", - ".vs/**", + "**/.idea/**", + "**/.vscode/**", + "**/.vs/**", ".github/**", ".agents/**", ".claude/**", ".devcontainer/**", ".git/**", "templates/**/*", - "clients/**", "demo/**", "docs/**", "nupkgs/**", "scripts/**", "src/Tools/**", + "clients/**/node_modules/**", + "clients/**/dist/**", + "clients/**/build/**", + "clients/**/test-results/**", + "clients/**/playwright-report/**", + "clients/**/.turbo/**", + "clients/**/coverage/**", + "clients/**/.env", + "clients/**/.env.*", "**/*.filelist", "**/*.user", "**/images", @@ -80,7 +149,7 @@ "**/bin/**", "**/obj/**", ".mcp.json", - ".gitignore", + "global.json", "CLAUDE.md", "GEMINI.md", "README.md", @@ -95,6 +164,12 @@ "exclude": [ "src/Host/FSH.Starter.AppHost/**" ] + }, + { + "condition": "(!frontend)", + "exclude": [ + "clients/**" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..cbe60e9e1f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# FullStackHero .NET Starter Kit + +> A production-ready modular .NET 10 monolith + two React 19 apps, built for enterprise SaaS. + +This file is the canonical guide for **all** AI coding tools (Claude Code, Gemini CLI, Cursor, Codex, …). +`CLAUDE.md` and `GEMINI.md` are thin bridges that import this file — edit conventions **here**, not there. + +This file is the map. Detailed conventions live in `.agents/rules/` and are read on demand — **read the +relevant rule file before working in that area** (see the index below). Keep this file lean. + +## What this is + +A **modular monolith** (Vertical Slice Architecture) backend that ships with two **React + Vite** +front-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and more are first-class. + +- **Backend** — .NET 10, EF Core 10, PostgreSQL, Redis, JWT + ASP.NET Identity, Finbuckle multitenancy, + Hangfire, OpenAPI/Scalar, Serilog + OpenTelemetry, .NET Aspire. +- **Frontends** — `clients/admin` (operator-facing) and `clients/dashboard` (tenant-facing): React 19, + Vite 7, TypeScript, TanStack Query v5, React Router 7, Radix + Tailwind v4 (shadcn-style), SignalR/SSE. + +## Repo map + +| Path | What | +|------|------| +| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…). **Protected — see below.** | +| `src/Modules/{Name}/` | Bounded contexts. Each has a runtime project + a `.Contracts` project (its only public API). | +| `src/Host/FSH.Starter.Api` | Composition-root Web API host. | +| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Redis, MinIO, migrator, API, **both React apps**). | +| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner. DB is **not** migrated at API startup. | +| `src/Host/FSH.Starter.Migrations.PostgreSQL` | All EF migrations, organized per-module by folder. | +| `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). | +| `src/Tools/CLI` | The `fsh` CLI (Spectre.Console). | +| `clients/admin`, `clients/dashboard` | The two React apps. | +| `deploy/` | Infra (docker, terraform, dokploy). | + +## Tech stack + +| Backend | | Frontend | | +|---|---|---|---| +| Framework | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5.x | +| CQRS | Mediator 3.x (source-gen) | Data | TanStack Query v5 | +| Validation | FluentValidation 12.x | Routing | React Router 7 | +| ORM / DB | EF Core 10 / PostgreSQL (Npgsql) | UI | Radix + Tailwind v4 + CVA (shadcn) | +| Auth | JWT Bearer + ASP.NET Identity | Forms | react-hook-form + zod (**admin only**) | +| Multitenancy | Finbuckle 10.x | Realtime | `@microsoft/signalr`, SSE (dashboard) | +| Cache / Jobs | Redis, Hangfire | Tests | Playwright (route-mocked) | +| Docs | OpenAPI + Scalar | API client | hand-written `apiFetch` (no codegen) | +| Hosting | .NET Aspire | Env | runtime `/config.json` (not `VITE_*`) | +| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest, Testcontainers | | | + +## Build & run + +```bash +# Whole stack (Postgres + pgAdmin + Redis + MinIO + migrator + API + both React apps) +dotnet run --project src/Host/FSH.Starter.AppHost # one-time: npm install in clients/admin & clients/dashboard + +dotnet build src/FSH.Starter.slnx # build backend +dotnet run --project src/Host/FSH.Starter.Api # API only → https://localhost:7030 (/scalar) +dotnet test src/FSH.Starter.slnx # tests — integration tests REQUIRE Docker + +cd clients/admin && npm install && npm run dev # → http://localhost:5173 +cd clients/dashboard && npm install && npm run dev # → http://localhost:5174 +``` + +Migrations / seed (DbMigrator, separate step): +```bash +dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed] +dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending +``` + +**Ports:** API 7030 (https)/5030 (http) · admin 5173 · dashboard 5174 · Postgres 5432 · pgAdmin 5050 · Valkey 6379 · MinIO 9000/9001. + +## Branching & PRs + +Single long-lived branch: **`main`** (the default) — there is **no `develop`**. Branch from and target `main`; stable releases are cut from `v*` tags. CI is split into path-scoped **Backend CI** (`src/**`) and **Frontend CI** (`clients/**`) workflows; branch protection requires only those two gate checks — never the individual jobs, which are skipped on the other side's PRs. + +## Golden rules (do not break) + +1. **Module boundaries** — a module references another module only through its `.Contracts` project, never its runtime project. Enforced by `Architecture.Tests`. +2. **Registering a module touches FOUR places** — `Program.cs` Mediator `o.Assemblies` (two markers each) + `moduleAssemblies` array, **and the identical pair in `DbMigrator/Program.cs`**. A missing Mediator marker = handlers silently undiscovered. See `architecture.md`. +3. **Tenant isolation is default-ON** via `BaseDbContext`. Opt out only via `IGlobalEntity`. Subclass DbContexts call `base.OnModelCreating` **last**. See `database.md`. +4. **Do NOT modify `src/BuildingBlocks`** without explicit approval — shared by every module, wide blast radius. +5. **Mediator handlers must be `public sealed`**, return `ValueTask<T>`, and `.ConfigureAwait(false)` every await. +6. **Structured logging only** — no string interpolation in log messages; use message templates / `[LoggerMessage]`. +7. **Propagate `CancellationToken`** into every EF/IO call; add as `= default` on public service methods. +8. **Every command handler + paginated query handler needs a validator** (`{Name}Validator`). Enforced by `Architecture.Tests`. +9. **Frontend: pass per-call data through `mutate(arg)`**, never via state the mutation callbacks close over (execute-time race). See `frontend/shared.md`. +10. **Docs + changelog travel with the change** — a user-facing change (feature, endpoint, config, infra, breaking change) isn't done until the **separate docs repo** (`github.com/fullstackhero/docs`, the Astro site) is updated to match **and** a changelog entry is added (`src/content/docs/changelog/`). Don't let the docs drift from the code. + +## Rules index — read the relevant file before you work + +**Backend / cross-cutting** (`.agents/rules/`) + +| Working on… | Read | +|---|---| +| Module structure, boundaries, registration, DI, middleware order, config | `architecture.md` | +| Endpoints, CQRS, validation, exceptions, permissions, versioning | `api-conventions.md` | +| EF Core, entities, migrations, tenant isolation, query filters | `database.md` | +| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` | +| Caching (HybridCache/Redis), keys, invalidation | `caching.md` | +| Background jobs (Hangfire), recurring jobs | `jobs.md` | +| Outbound HTTP resilience (Polly) | `resilience.md` | +| Files/blobs, presigned uploads, providers | `storage.md` | +| CORS, security headers, rate limiting, idempotency, quotas | `security.md` | +| SignalR / SSE backend | `realtime.md` | +| Logging, correlation, OpenTelemetry | `logging.md` | +| Unit test conventions, NetArchTest | `testing.md` | +| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` | +| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` | +| A specific module's quirks | `modules/{module}.md` (identity, multitenancy, chat, files, webhooks, auditing, billing, catalog, tickets, notifications) | + +**Frontend** (`.agents/rules/frontend/`) + +| Working on… | Read | +|---|---| +| Any React work (shared stack, API client, Query, Tailwind, design language) | `frontend/shared.md` | +| The operator app (`clients/admin`) | `frontend/admin.md` | +| The tenant app (`clients/dashboard`) | `frontend/dashboard.md` | + +## Coding style (backend) + +File-scoped namespaces · 4-space indent · explicit types (`var` only when RHS-obvious) · `is null` / +`is not null` · pattern matching + switch expressions · `ArgumentNullException.ThrowIfNull` guards · +records for DTOs/events/value objects · `default!` for required non-nullable strings. Build runs with +`TreatWarningsAsErrors` — warnings fail the build. + +## Adding things (quick pointers) + +- **Feature** — Contracts command/query → handler → validator → endpoint → wire in module `MapEndpoints()` → tests. Details: `api-conventions.md`. +- **Module** — new `Modules.{Name}` + `.Contracts`, implement `IModule` w/ assembly-level `[assembly: FshModule(typeof(XModule), order)]`, register in **all four places**, add migration folder + tests. Details: `architecture.md`. +- **React page** — API module (`src/api/`) → page → register lazy route → (admin) mirror permission + RouteGuard → Playwright test. Details: `frontend/shared.md`. + +## AI tooling resources + +- **Rules** — `.agents/rules/*.md` (indexed above). Read on demand. +- **Skills** — `.agents/skills/*/SKILL.md`: step-by-step task recipes. Scaffolders: `add-feature`, `add-entity`, `add-module`, `add-react-page`, `add-full-slice`. Ops: `create-migration`, `add-integration-event`, `add-permission`. Reference: `query-patterns`, `testing-guide`, `mediator-reference`. +- **Workflows** — `.agents/workflows/*.md`: task playbooks (`code-reviewer`, `feature-scaffolder`, `module-creator`, `architecture-guard`, `migration-helper`). diff --git a/CLAUDE.md b/CLAUDE.md index f8b5c9a0cb..408f420f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,191 +1,5 @@ -# FullStackHero .NET Starter Kit +# Claude Code -> A production-ready modular .NET framework for building enterprise applications. +The canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here. -## Architecture - -**Modular Monolith + Vertical Slice Architecture (VSA)** - -- **BuildingBlocks** (`src/BuildingBlocks/`) — shared framework libraries (Core, Persistence, Web, Caching, Eventing, etc.) -- **Modules** (`src/Modules/`) — bounded contexts (Identity, Multitenancy, Auditing) -- **Host** (`src/Host/`) — composition-root host applications (API, AppHost) -- **Tests** (`src/Tests/`) — per-module test projects + architecture tests - -### Module Boundaries - -Modules communicate through **Contracts** projects only. A module MUST NOT reference another module's runtime project. - -``` -Modules.Identity/ ← runtime (internal) -Modules.Identity.Contracts/ ← public API (commands, queries, events, DTOs, service interfaces) -``` - -### Feature Folder Layout - -Each feature is a vertical slice inside `Features/v{version}/{Area}/{FeatureName}/`: - -``` -Features/v1/Users/RegisterUser/ -├── RegisterUserEndpoint.cs # Minimal API endpoint -├── RegisterUserCommandHandler.cs # CQRS handler -└── RegisterUserCommandValidator.cs # FluentValidation -``` - -Additional module folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`. - -## Tech Stack - -| Concern | Technology | -|---------|-----------| -| Framework | .NET 10 / C# latest | -| Solution format | `.slnx` (XML-based) | -| Package management | Central (`Directory.Packages.props`) | -| CQRS / Mediator | Mediator 3.0.1 (source generator) | -| Validation | FluentValidation 12.x | -| ORM | Entity Framework Core 10.x | -| Database | PostgreSQL (Npgsql) | -| Auth | JWT Bearer + ASP.NET Identity | -| Multitenancy | Finbuckle.MultiTenant 10.x (claim/header/query strategies) | -| Caching | Redis (StackExchange) | -| Jobs | Hangfire | -| Resilience | Microsoft.Extensions.Http.Resilience (Polly v8) | -| Feature Flags | Microsoft.FeatureManagement with tenant overrides | -| Idempotency | Idempotency-Key header with cache-based replay | -| Webhooks | Tenant-scoped subscriptions with HMAC signing | -| Real-time | Server-Sent Events (SSE) | -| Logging | Serilog + OpenTelemetry (OTLP) | -| API docs | OpenAPI + Scalar | -| API versioning | Asp.Versioning | -| Hosting | .NET Aspire (AppHost) | -| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest | - -## Build & Run - -```bash -# Build -dotnet build src/FSH.Starter.slnx - -# Run API (from repo root) -dotnet run --project src/Host/FSH.Starter.Api - -# Run with Aspire -dotnet run --project src/Host/FSH.Starter.AppHost - -# Run tests -dotnet test src/FSH.Starter.slnx -``` - -## Key Conventions - -### Endpoints - -Static extension methods on `IEndpointRouteBuilder`. Return `RouteHandlerBuilder`. - -```csharp -public static class RegisterUserEndpoint -{ - internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/register", (RegisterUserCommand command, - IMediator mediator, CancellationToken cancellationToken) => - mediator.Send(command, cancellationToken)) - .WithName("RegisterUser") - .WithSummary("Register user") - .RequirePermission(IdentityPermissionConstants.Users.Create); - } -} -``` - -### CQRS - -- **Commands/Queries** → defined in `Modules.{Name}.Contracts` (implement `ICommand<TResponse>` / `IQuery<TResponse>`) -- **Handlers** → defined in `Modules.{Name}/Features/` (implement `ICommandHandler<T, TResponse>` / `IQueryHandler<T, TResponse>`) -- Handlers return `ValueTask<T>` and use `.ConfigureAwait(false)` - -### Validation - -FluentValidation validators are auto-registered by `ModuleLoader`. Name them `{Command}Validator`. - -### Domain Events - -- Inherit from `DomainEvent` (abstract record with `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`) -- Entities implement `IHasDomainEvents` with `_domainEvents` list -- Integration events implement `IIntegrationEvent`, handlers implement `IIntegrationEventHandler<T>` - -### Domain Entities - -- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId` -- `AggregateRoot` — extends `BaseEntity` with domain events -- `IHasTenant`, `IAuditableEntity`, `ISoftDeletable` — marker interfaces - -### Module Registration - -Each module implements `IModule` with `[FshModule(Order = n)]` attribute: - -```csharp -[FshModule(Order = 1)] -public class IdentityModule : IModule -{ - public void ConfigureServices(IHostApplicationBuilder builder) { ... } - public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... } -} -``` - -Endpoints are grouped under versioned API paths: `api/v{version:apiVersion}/{module}`. - -### Exceptions - -Use framework exception types: `CustomException` (with `HttpStatusCode`), `NotFoundException`, `ForbiddenException`, `UnauthorizedException`. Global handler converts to `ProblemDetails` (RFC 9457). - -### Permissions - -Constants in `Shared/Identity/IdentityPermissionConstants.cs`. Applied via `.RequirePermission()` on endpoints. - -### Specifications - -Use `Specification<T>` base class from `Persistence/Specifications/` for query composition. Default `AsNoTracking = true`. - -## Coding Style - -- **Namespace style**: File-scoped (`namespace X;`) -- **Indentation**: 4 spaces -- **Var usage**: Prefer explicit types; `var` only when type is apparent from RHS -- **Null checks**: `is null` / `is not null` (not `== null`) -- **Pattern matching**: Preferred over `is`/`as` casts -- **Switch expressions**: Preferred -- **Async**: `ValueTask<T>` for handlers, `.ConfigureAwait(false)` on all awaits -- **Guard clauses**: `ArgumentNullException.ThrowIfNull(param)` at method entry -- **Properties**: Prefer auto-properties, `default!` for required non-nullable strings -- **Records**: Use for DTOs, events, and value objects - -## Testing Conventions - -- **Naming**: `MethodName_Should_ExpectedBehavior_When_Condition` -- **Pattern**: Arrange-Act-Assert with `#region` grouping (Happy Path, Exception, Edge Cases) -- **Assertions**: Shouldly (`result.ShouldBe(...)`, `result.ShouldNotBeNull()`) -- **Mocking**: NSubstitute (`Substitute.For<IService>()`) -- **Test data**: AutoFixture (`_fixture.Create<string>()`) -- **Architecture tests**: NetArchTest enforces module boundary rules - -## Protected Directories - -**DO NOT modify BuildingBlocks** without explicit approval. These are shared framework libraries consumed by all modules. Changes here have wide blast radius. - -## Adding a New Feature - -1. Add command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/` -2. Add handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/` -3. Add validator in the same feature folder -4. Add endpoint in the same feature folder -5. Wire endpoint in the module's `MapEndpoints()` method -6. Add tests in `Tests/{Name}.Tests/` - -## Adding a New Module - -1. Create `Modules.{Name}/` and `Modules.{Name}.Contracts/` projects under `src/Modules/{Name}/` -2. Implement `IModule` with `[FshModule(Order = n)]` -3. Add DbContext extending from framework base -4. Register in `Program.cs` module assemblies array -5. Add migration project if needed -6. Add test project in `src/Tests/` -7. Add architecture test rules +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6fa439bf4..71155e33c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ Client apps live under `clients/admin` and `clients/dashboard` — `npm install ## Pull requests -- Branch from and target `develop`, not `main`. +- Branch from and target `main`. - Follow [Conventional Commits](https://www.conventionalcommits.org) — match the existing history (`feat(chat): ...`, `fix(identity): ...`). - Add tests. The build runs with `TreatWarningsAsErrors=true`; analyzer warnings must be fixed. - Don't touch `src/BuildingBlocks/` without prior discussion — wide blast radius. diff --git a/GEMINI.md b/GEMINI.md index 6c09e02a0e..90b8c24546 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,139 +1,5 @@ -# FSH .NET Starter Kit — Gemini AI Assistant Guide +# Gemini CLI -> Modular Monolith · CQRS · DDD · Multi-Tenant · .NET 10 +The canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here. -## Quick Start - -```bash -dotnet build src/FSH.Starter.slnx # Build (0 warnings required) -dotnet test src/FSH.Starter.slnx # Run tests -dotnet run --project src/Host/FSH.Starter.AppHost # Run with Aspire -``` - -## Project Layout - -``` -src/ -├── BuildingBlocks/ # Framework (11 packages) — ⚠️ Protected -├── Modules/ # Business features — Add code here -│ ├── Identity/ # Auth, users, roles, permissions -│ ├── Multitenancy/ # Tenant management (Finbuckle) -│ └── Auditing/ # Audit logging -├── Host/ # Composition-root host applications -└── Tests/ # Architecture + unit tests -``` - -## The Pattern - -Every feature = vertical slice: - -``` -Modules/{Module}/Features/v1/{Feature}/ -├── {Action}{Entity}Command.cs # ICommand<T> -├── {Action}{Entity}Handler.cs # ICommandHandler<T,R> -├── {Action}{Entity}Validator.cs # AbstractValidator<T> -└── {Action}{Entity}Endpoint.cs # MapPost/Get/Put/Delete -``` - -## Critical Rules - -| ⚠️ Rule | Why | -|---------|-----| -| Use **Mediator** not MediatR | Different library, different interfaces | -| `ICommand<T>` / `IQuery<T>` | NOT `IRequest<T>` | -| `ValueTask<T>` return type | NOT `Task<T>` | -| Every command needs validator | FluentValidation, no exceptions | -| `.RequirePermission()` on endpoints | Explicit authorization | -| Zero build warnings | CI blocks merges | - -## Available Skills - -Invoke with `/skill-name` in your prompt. - -| Skill | Purpose | -|-------|---------| -| `/add-feature` | Create complete CQRS feature (command/handler/validator/endpoint) | -| `/add-entity` | Add domain entity with base class inheritance | -| `/add-module` | Scaffold new bounded context module | -| `/query-patterns` | Implement paginated/filtered queries | -| `/testing-guide` | Write architecture + unit tests | -| `/mediator-reference` | Mediator vs MediatR interface reference | - -## Available Workflows - -Delegate complex tasks to specialized workflows. - -| Workflow | Expertise | -|----------|-----------| -| `/code-reviewer` | Review changes against FSH patterns + architecture rules | -| `/feature-scaffolder` | Generate complete feature slices from requirements | -| `/module-creator` | Create new modules with contracts, persistence, DI setup | -| `/architecture-guard` | Verify layering, dependencies, module boundaries | -| `/migration-helper` | Generate and apply EF Core migrations | - -## Example: Create Feature - -```csharp -// Command -public sealed record CreateProductCommand(string Name, decimal Price) - : ICommand<Guid>; - -// Handler -public sealed class CreateProductHandler(IRepository<Product> repo) - : ICommandHandler<CreateProductCommand, Guid> -{ - public async ValueTask<Guid> Handle(CreateProductCommand cmd, CancellationToken ct) - { - var product = Product.Create(cmd.Name, cmd.Price); - await repo.AddAsync(product, ct); - return product.Id; - } -} - -// Validator -public sealed class CreateProductValidator : AbstractValidator<CreateProductCommand> -{ - public CreateProductValidator() - { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.Price).GreaterThan(0); - } -} - -// Endpoint -public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => - endpoints.MapPost("/", async (CreateProductCommand cmd, IMediator mediator, CancellationToken ct) => - TypedResults.Created($"/api/v1/products/{await mediator.Send(cmd, ct)}")) - .WithName(nameof(CreateProductCommand)) - .WithSummary("Create a new product") - .RequirePermission(CatalogPermissions.Products.Create); -``` - -## Architecture - -- **Pattern:** Modular Monolith (not microservices) -- **CQRS:** Mediator library (commands/queries) -- **DDD:** Rich domain models, aggregates, value objects -- **Multi-Tenancy:** Finbuckle.MultiTenant (shared DB, tenant isolation) -- **Modules:** 3 core (Identity, Multitenancy, Auditing) + your features -- **BuildingBlocks:** 11 packages (Core, Persistence, Caching, Jobs, Web, etc.) - -Details: See `.agents/rules/architecture.md` - -## Before Committing - -```bash -dotnet build src/FSH.Starter.slnx # Must pass with 0 warnings -dotnet test src/FSH.Starter.slnx # All tests must pass -``` - -## Documentation - -- **Architecture:** See `ARCHITECTURE_ANALYSIS.md` (19KB deep-dive) -- **Rules:** See `.agents/rules/*.md` (API conventions, testing, modules) -- **Skills:** See `.agents/skills/*/SKILL.md` (step-by-step guides) -- **Workflows:** See `.agents/workflows/*.md` (specialized assistants) - ---- - -**Philosophy:** This is a production-ready starter kit. Every pattern is battle-tested. Follow the conventions, and you'll ship faster. +@AGENTS.md diff --git a/README-template.md b/README-template.md index 223064904e..1d91f9d2f2 100644 --- a/README-template.md +++ b/README-template.md @@ -1,63 +1,121 @@ # FSH.Starter -Built with [FullStackHero .NET Starter Kit](https://github.com/fullstackhero/dotnet-starter-kit) — a production-ready modular .NET 10 framework. +Your application, generated from the **FSH .NET Starter Kit** — a production-ready modular +.NET 10 monolith with two React 19 apps, multitenancy, identity, background jobs, and +cloud-native deploy. + +You **own all of this source**. There are no framework NuGet packages to track or upgrade — +the shared code lives in `src/BuildingBlocks` and is yours to change. ## Prerequisites - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -- [Docker](https://www.docker.com/) (for PostgreSQL, Redis, and Aspire) +- [Node.js 20+](https://nodejs.org) — for the React apps +- [Docker](https://www.docker.com/) — Postgres, Redis, MinIO (orchestrated by Aspire) - .NET Aspire workload: `dotnet workload install aspire` -## Quick Start +## Quick start + +### Everything at once (recommended) — .NET Aspire ```bash -# Start everything with Aspire (recommended) dotnet run --project src/Host/FSH.Starter.AppHost +``` + +Aspire starts Postgres, Redis, and MinIO, runs database migrations, then launches the API +**and both React apps**. + +| Surface | URL | +|---|---| +| Aspire dashboard | https://localhost:15888 | +| API + Scalar docs | https://localhost:7030/scalar | +| Admin console | http://localhost:5173 | +| Tenant dashboard | http://localhost:5174 | + +### Backend only -# Or run the API standalone (requires external Postgres + Redis) -dotnet run --project src/Host/FSH.Starter.Api +```bash +dotnet run --project src/Host/FSH.Starter.Api # needs external Postgres + Redis +``` + +### Frontend only (against a running API) + +```bash +cd clients/admin && npm install && npm run dev # → http://localhost:5173 +cd clients/dashboard && npm install && npm run dev # → http://localhost:5174 ``` -The Aspire dashboard opens at `https://localhost:15888`. The API serves at `https://localhost:7030` with Scalar docs at `/scalar`. +The React apps read their API URL at runtime from `public/config.json` — no rebuild to repoint. -## Project Structure +## Project structure ``` src/ - BuildingBlocks/ # Shared framework libraries (do not modify unless necessary) - Modules/ # Bounded contexts (Identity, Multitenancy, Auditing, Webhooks) + BuildingBlocks/ Shared framework libraries — yours to modify + Modules/ Bounded contexts: Identity, Multitenancy, Auditing, Billing, + Catalog, Chat, Files, Notifications, Tickets, Webhooks Host/ - FSH.Starter.Api/ # API host - FSH.Starter.AppHost/ # .NET Aspire orchestrator -FSH.Starter.Migrations.PostgreSQL/ # EF Core migrations - Tests/ # Unit, integration, and architecture tests + FSH.Starter.Api/ API composition root + FSH.Starter.AppHost/ .NET Aspire orchestrator + FSH.Starter.DbMigrator/ One-shot migrate / seed runner + FSH.Starter.Migrations.PostgreSQL/ EF Core migrations + Tests/ Unit, integration (Testcontainers), and architecture tests +clients/ + admin/ Operator console (React 19 + Vite + Tailwind) + dashboard/ Tenant app (React 19 + Vite + Tailwind, SSE live feed) +deploy/ + docker/ Production docker-compose + .env + terraform/ AWS infrastructure (ECS, RDS, ElastiCache, S3) ``` -## Adding Your First Feature +## Database -1. Define command/query in `src/Modules/{Module}.Contracts/v1/{Area}/{Feature}/` -2. Add handler in `src/Modules/{Module}/Features/v1/{Area}/{Feature}/` -3. Add FluentValidation validator in the same folder -4. Add endpoint in the same folder -5. Wire the endpoint in the module's `MapEndpoints()` method +Migrations run automatically under Aspire. To apply them yourself: + +```bash +dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed +``` + +## Make it yours — first-run checklist + +This project shipped with sensible defaults. Before production: + +- [ ] **Secrets** — set strong values in `deploy/docker/.env` (the `fsh` CLI generates these + for you; otherwise `cp deploy/docker/.env.example deploy/docker/.env` and fill them in). + Never commit `.env`. +- [ ] **Logo** — replace `clients/admin/public/logo-fullstackhero.png` and + `clients/dashboard/public/logo-fullstackhero.png` with your own. +- [ ] **Mail** — configure SMTP / SendGrid under `MailOptions` in + `src/Host/FSH.Starter.Api/appsettings.json`. +- [ ] **OpenAPI contact** — update `OpenApiOptions.Contact` in `appsettings.json`. +- [ ] **Container registry & infra** — set your registry and review bucket / database names + in `deploy/terraform/apps/starter/**/variables.tf` and `terraform.tfvars`. + +## Production (Docker Compose) + +```bash +cd deploy/docker +# .env is generated for you by the fsh CLI; otherwise: cp .env.example .env && edit +docker compose up -d --build +``` -## Removing Unwanted Modules +Sign in to the admin console as `admin@root.com` using the `SEED_ADMIN_PASSWORD` from your +`.env`, then rotate it from Settings → Security. -To remove a module (e.g., Webhooks): +## Adding a feature -1. Delete `src/Modules/Webhooks/` folders -2. Remove its references from `src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj` -3. Remove its assembly from `Program.cs` (both `AddMediator` and `moduleAssemblies`) -4. Remove its migration folder from `src/Host/FSH.Starter.Migrations.PostgreSQL/` -5. Remove from `src/FSH.Starter.slnx` +1. Contracts command/query in `src/Modules/{Module}.Contracts/v1/{Area}/{Feature}/` +2. Handler + FluentValidation validator in `src/Modules/{Module}/Features/...` +3. Endpoint, wired into the module's `MapEndpoints()` +4. Tests -## Running Tests +## Running tests ```bash -dotnet test src/FSH.Starter.slnx +dotnet test src/FSH.Starter.slnx # integration tests require Docker ``` -## Learn More +## Learn more -- [FullStackHero Documentation](https://fullstackhero.net) -- [GitHub Repository](https://github.com/fullstackhero/dotnet-starter-kit) +- [FSH Documentation](https://fullstackhero.net) +- [Source & issues](https://github.com/fullstackhero/dotnet-starter-kit) diff --git a/README.md b/README.md index f3b0113106..c2aa14df50 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,193 @@ -# FullStackHero .NET 10 Starter Kit +<div align="center"> -[![NuGet](https://img.shields.io/nuget/v/FullStackHero.CLI?label=fsh%20cli)](https://www.nuget.org/packages/FullStackHero.CLI) -[![NuGet](https://img.shields.io/nuget/v/FullStackHero.Framework.Web?label=framework)](https://www.nuget.org/packages/FullStackHero.Framework.Web) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +# ⚡ FullStackHero .NET 10 Starter Kit -An opinionated, production-first starter for building multi-tenant SaaS and enterprise APIs on .NET 10. You get ready-to-ship Identity, Multitenancy, Auditing, Webhooks, caching, mailing, jobs, storage, health, OpenAPI, and OpenTelemetry — wired through Minimal APIs, Mediator, and EF Core. +**A production-ready, modular .NET 10 monolith + two React 19 apps — the fastest way to ship a multi-tenant SaaS.** -## Quick Start +Identity, multitenancy, billing, auditing, webhooks, files, chat, real-time, caching, jobs, storage, OpenAPI and OpenTelemetry — already wired, fully tested, and **100% yours as source** (no black-box packages). -You get the complete source code — BuildingBlocks, Modules, and Host — with full project references. No black-box NuGet packages; you own and can modify everything. +[![fsh CLI](https://img.shields.io/nuget/v/FullStackHero.CLI?label=fsh%20cli&color=512BD4)](https://www.nuget.org/packages/FullStackHero.CLI) +[![template](https://img.shields.io/nuget/v/FullStackHero.NET.StarterKit?label=dotnet%20new%20fsh&color=512BD4)](https://www.nuget.org/packages/FullStackHero.NET.StarterKit) +[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-fullstackhero.net-2563eb)](https://fullstackhero.net) +[![Stars](https://img.shields.io/github/stars/fullstackhero/dotnet-starter-kit?style=social)](https://github.com/fullstackhero/dotnet-starter-kit) -### Option 1: FSH CLI (recommended) +### [📖 Documentation](https://fullstackhero.net) · [🚀 Get Started](https://fullstackhero.net/docs/getting-started/introduction/) · [🧩 Modules](https://fullstackhero.net/docs/modules/) · [🏗️ Architecture](https://fullstackhero.net/docs/architecture/) · [📦 Changelog](https://fullstackhero.net/docs/changelog/) + +</div> + +--- + +## Why FullStackHero? + +Most starter kits give you a login page and a TODO list. This one gives you the **boring, hard parts already done right** — multitenancy, auth, billing, auditing, background jobs, real-time, file storage, observability — across a clean **Vertical Slice** backend *and* two polished **React 19** front-ends, orchestrated locally with one command via **.NET Aspire**, and deployable to Docker or AWS. + +You scaffold with the `fsh` CLI and get the **complete, detached source** — every BuildingBlock, Module, and Host project with real project references. No hidden NuGet runtime, nothing to "eject" later. Own it, read it, change it. ```bash dotnet tool install -g FullStackHero.CLI fsh new MyApp cd MyApp -dotnet run --project src/Host/MyApp.AppHost +dotnet run --project src/Host/MyApp.AppHost # 🎉 whole stack up: API + 2 React apps + Postgres + Valkey + MinIO ``` -The interactive wizard lets you pick your database provider and whether to include Aspire. Run `fsh doctor` to verify your environment first. +> Then open the **Aspire dashboard** at `https://localhost:15888`, the **API + Scalar docs** at `https://localhost:7030/scalar`, the **admin** app at `http://localhost:5173`, and the **dashboard** app at `http://localhost:5174`. Sign in with a seeded demo account (e.g. `admin@acme.com` / `Password123!`). + +--- + +## ✨ What's inside + +### Backend — modular monolith, vertical slices +- **.NET 10 · C# latest · Minimal APIs · [Mediator](https://github.com/martinothamar/Mediator) (source-generated CQRS) · FluentValidation** +- **EF Core 10** on **PostgreSQL** (Npgsql), with domain events, the specification pattern, soft-delete + audit interceptors, and tenant-isolated `DbContext`s. +- **JWT auth + ASP.NET Identity** — issuance/refresh, roles & fine-grained permissions, rate-limited auth, password policies, sessions, impersonation. +- **Multitenancy** via [Finbuckle](https://www.finbuckle.com/) — tenant resolution, provisioning, per-tenant migrations & seeding, isolation enforced by default. +- **Cross-cutting**: HybridCache on **Valkey** (Redis-compatible), **Hangfire** jobs, presigned S3/**MinIO** storage, mailing, idempotency, quotas, rate limiting, API versioning, RFC 9457 `ProblemDetails`. +- **Observability**: Serilog structured logging + **OpenTelemetry** traces/metrics/logs, health probes, security/exception auditing. +- **Docs**: **OpenAPI** + the **Scalar** API reference UI. + +### Front-ends — two React 19 apps +- **`clients/admin`** (operator console) and **`clients/dashboard`** (tenant app): **React 19 + Vite 7 + TypeScript**, **TanStack Query v5**, **React Router 7**, **Radix + Tailwind v4** (shadcn-style), real-time via **SignalR**/**SSE**. +- Runtime config (`/config.json`, no rebuild per environment), hand-written typed API client, and **Playwright** E2E suites. + +### Modules (bounded contexts) +**Identity · Multitenancy · Billing · Catalog · Tickets · Chat · Files · Webhooks · Auditing · Notifications** — each a runtime project plus a `.Contracts` project (its only public surface), boundaries enforced by architecture tests. + +### Cloud-native & DevOps +- **.NET Aspire** orchestrates the entire stack locally with one command (Postgres + pgAdmin, Valkey + RedisInsight, MinIO, migrator, demo-seeder, API, and both React apps). +- **Docker Compose** production stack (`deploy/docker`) and **Terraform** for AWS (`deploy/terraform`); API image published to GHCR. +- A one-shot **DbMigrator** (migrations are never run at API startup), and the **`fsh` CLI** + `dotnet new` template for distribution. + +### Quality +**1,600+ backend tests** (xUnit, Shouldly, NSubstitute, AutoFixture, **NetArchTest** boundaries, **Testcontainers** integration) and **200+ front-end E2E tests** (Playwright). Path-scoped CI for backend and frontend; warnings-as-errors. + +--- -### Option 2: dotnet new template +## 🚀 Getting started + +### Option 1 — the `fsh` CLI (recommended) + +```bash +dotnet tool install -g FullStackHero.CLI +fsh doctor # verify your environment (SDK, Docker, Aspire, ports) +fsh new MyApp # interactive wizard +``` + +The wizard asks what to include (Aspire AppHost, the React apps). Non-interactive: + +```bash +fsh new MyApp --non-interactive # full stack, Postgres +fsh new MyApp --no-frontend # backend-only +fsh new MyApp --no-aspire --no-frontend # minimal API + migrator +``` + +### Option 2 — the `dotnet new` template ```bash dotnet new install FullStackHero.NET.StarterKit dotnet new fsh -n MyApp -cd MyApp -dotnet run --project src/Host/MyApp.AppHost ``` -### Option 3: Clone the repository +### Option 3 — clone the repo ```bash -git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp -cd MyApp -dotnet restore src/FSH.Starter.slnx +git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp && cd MyApp dotnet run --project src/Host/FSH.Starter.AppHost ``` -### Option 4: GitHub Codespaces +> **Prerequisites:** [.NET 10 SDK](https://dotnet.microsoft.com/download) · [Docker](https://www.docker.com/) (Postgres/Valkey/MinIO via Aspire) · [Node 20+](https://nodejs.org/) (for the React apps). -Click **"Use this template"** on GitHub, or open in Codespaces for a zero-install experience with .NET 10, Docker, and Aspire pre-configured. +**`fsh` commands:** `new` · `doctor` · `info` · `update` · `--version`. Full reference → [fullstackhero.net/docs/cli](https://fullstackhero.net/docs/cli/). -> Prerequisites: [.NET 10 SDK](https://dotnet.microsoft.com/download), [Docker](https://www.docker.com/) (for Postgres/Redis via Aspire) +--- -## FSH CLI Commands +## 🧱 Tech stack -| Command | Description | -|---------|------------| -| `fsh new [name]` | Create a new project with interactive wizard | -| `fsh doctor` | Check your environment (SDK, Docker, Aspire, ports) | -| `fsh info` | Show CLI/template versions and available updates | -| `fsh update` | Update CLI tool and template to latest | +| Backend | | Frontend | | +|---|---|---|---| +| Runtime | .NET 10 / C# latest | Framework | React 19 + Vite 7 + TS 5 | +| API | Minimal APIs + Mediator (CQRS) | Data | TanStack Query v5 | +| Validation | FluentValidation | Routing | React Router 7 | +| ORM / DB | EF Core 10 / PostgreSQL | UI | Radix + Tailwind v4 (shadcn) | +| Auth | JWT + ASP.NET Identity | Realtime | SignalR · SSE | +| Multitenancy | Finbuckle 10 | Tests | Playwright | +| Cache / Jobs | Valkey · Hangfire | | | +| Storage | S3 / MinIO (presigned) | **Infra** | | +| Docs | OpenAPI + Scalar | Orchestration | .NET Aspire | +| Observability | Serilog + OpenTelemetry | Deploy | Docker Compose · Terraform | +| Testing | xUnit · Testcontainers · NetArchTest | | | + +--- + +## 🗺️ Repository layout + +| Path | What | +|---|---| +| `src/BuildingBlocks/` | Shared framework libraries (Core, Persistence, Web, Caching, Eventing, Storage, Quota…) | +| `src/Modules/{Name}/` | Bounded contexts — each with a runtime project + a `.Contracts` project (its public API) | +| `src/Host/FSH.Starter.Api` | Composition-root Web API host | +| `src/Host/FSH.Starter.AppHost` | .NET Aspire orchestrator (Postgres, Valkey, MinIO, migrator, API, both React apps) | +| `src/Host/FSH.Starter.DbMigrator` | One-shot migrate/seed runner (DB is **not** migrated at API startup) | +| `src/Tools/CLI` | The `fsh` CLI (Spectre.Console) | +| `clients/admin`, `clients/dashboard` | The two React apps | +| `deploy/` | Docker Compose, Terraform (AWS), Dokploy | +| `src/Tests/` | Unit, Architecture (NetArchTest), Integration (Testcontainers) | + +Architecture deep-dive → [fullstackhero.net/docs/architecture](https://fullstackhero.net/docs/architecture/). + +--- + +## ☁️ Deploy + +**Single-host via Docker Compose:** ```bash -# Non-interactive with options -fsh new MyApp --db sqlserver --no-aspire --no-git +cd deploy/docker +cp .env.example .env # fsh new pre-generates this with strong secrets +docker compose up -d --build +``` + +**AWS via Terraform** (ECS Fargate + RDS + ElastiCache + S3/CloudFront) lives in `deploy/terraform`. + +Guides → [Local orchestration](https://fullstackhero.net/docs/deployment/aspire/) · [Docker](https://fullstackhero.net/docs/deployment/) · [AWS / Terraform](https://fullstackhero.net/docs/deployment/aws-terraform/) · [Database migrations](https://fullstackhero.net/docs/deployment/database-migrations/). -# Dry run (preview without creating) -fsh new MyApp --dry-run +--- + +## 🧪 Testing + +```bash +dotnet test src/FSH.Starter.slnx # backend: unit + architecture + Testcontainers integration +cd clients/admin && npm run test:e2e # Playwright (operator app) +cd clients/dashboard && npm run test:e2e # Playwright (tenant app) ``` -## Why teams pick this -- Modular vertical slices: drop `Modules.Identity`, `Modules.Multitenancy`, `Modules.Auditing`, `Modules.Webhooks` into any API and let the module loader wire endpoints. -- Battle-tested building blocks: persistence + specifications, distributed caching, mailing, jobs via Hangfire, storage abstractions, and web host primitives (auth, rate limiting, versioning, CORS, exception handling). -- Cloud-ready out of the box: Aspire AppHost spins up Postgres + Redis + the API host with OTLP tracing enabled. -- Multi-tenant from day one: Finbuckle-powered tenancy across Identity and your module DbContexts; helpers to migrate and seed tenant databases on startup. -- Observability baked in: OpenTelemetry traces/metrics/logs, structured logging, health checks, and security/exception auditing. - -## Stack highlights -- .NET 10, C# latest, Minimal APIs, Mediator for commands/queries, FluentValidation. -- EF Core 10 with domain events + specifications; Postgres by default, SQL Server ready. -- ASP.NET Identity with JWT issuance/refresh, roles/permissions, rate-limited auth endpoints. -- Hangfire for background jobs; Redis-backed distributed cache; pluggable storage. -- API versioning, rate limiting, CORS, security headers, OpenAPI (Swagger) + Scalar docs. - -## Repository map -- `src/BuildingBlocks` — Core abstractions (DDD primitives, exceptions), Persistence, Caching, Mailing, Jobs, Storage, Web host wiring. -- `src/Modules` — `Identity`, `Multitenancy`, `Auditing`, `Webhooks` runtime + contracts projects. -- `src/Host` — Composition-root host (`FSH.Starter.Api`), Aspire app host (`FSH.Starter.AppHost`), Postgres migrations. -- `src/Tools/CLI` — The `fsh` CLI tool source code. -- `src/Tests` — Architecture tests that enforce layering and module boundaries. -- `deploy` — Docker, Dokploy, and Terraform deployment scaffolding. - -## Run it now (Aspire) -Prereqs: .NET 10 SDK, Aspire workload, Docker running (for Postgres/Redis). - -1. Restore: `dotnet restore src/FSH.Starter.slnx` -2. Start everything: `dotnet run --project src/Host/FSH.Starter.AppHost` - - Aspire brings up Postgres + Redis containers, wires env vars, launches the API host, and enables OTLP export on https://localhost:4317. -3. Hit the API: `https://localhost:5285` (Swagger/Scalar and module endpoints under `/api/v1/...`). - -### Run the API only -- Set env vars or appsettings for `DatabaseOptions__Provider`, `DatabaseOptions__ConnectionString`, `DatabaseOptions__MigrationsAssembly`, `CachingOptions__Redis`, and JWT options. -- Run: `dotnet run --project src/Host/FSH.Starter.Api` -- The host applies migrations/seeding via `UseHeroMultiTenantDatabases()` and maps module endpoints via `UseHeroPlatform`. - -## Bring the framework into your API -- Reference the building block and module projects you need. -- In `Program.cs`: - - Register Mediator with assemblies containing your commands/queries and module handlers. - - Call `builder.AddHeroPlatform(...)` to enable auth, OpenAPI, caching, mailing, jobs, health, OTel, rate limiting. - - Call `builder.AddModules(moduleAssemblies)` and `app.UseHeroPlatform(p => p.MapModules = true);`. -- Configure connection strings, Redis, JWT, CORS, and OTel endpoints via configuration. Example wiring lives in `src/Host/FSH.Starter.Api/Program.cs`. - -## Included modules -- **Identity** — ASP.NET Identity + JWT issuance/refresh, user/role/permission management, profile image storage, login/refresh auditing, health checks. -- **Multitenancy** — Tenant provisioning, migrations, status/upgrade APIs, tenant-aware EF Core contexts, health checks. -- **Auditing** — Security/exception/activity auditing with queryable endpoints; plugs into global exception handling and Identity events. -- **Webhooks** — Tenant-scoped webhook subscriptions with HMAC-signed delivery, retry policies, and delivery logs. - -## Development notes -- Target framework: `net10.0`; nullable enabled; analyzers on. -- Tests: `dotnet test src/FSH.Starter.slnx` (includes architecture guardrails). -- Want the deeper story? Browse the docs site under `docs/` (Astro Starlight) — start with [`docs/src/content/docs/architecture.mdx`](docs/src/content/docs/architecture.mdx), [`project-structure.mdx`](docs/src/content/docs/project-structure.mdx), and the [`adding-a-feature.mdx`](docs/src/content/docs/adding-a-feature.mdx) walkthrough. Run `cd docs && npm install && npm run dev` to read it locally. - -Built and maintained by Mukesh Murugan for teams that want to ship faster without sacrificing architecture discipline. +> Integration tests require Docker (Testcontainers spins real Postgres). Architecture tests enforce module boundaries. + +--- + +## 📖 Documentation + +Full guides, module references, and architecture decisions live at **[fullstackhero.net](https://fullstackhero.net)**: + +- [Getting started](https://fullstackhero.net/docs/getting-started/introduction/) — scaffold, run, and the default credentials +- [Architecture](https://fullstackhero.net/docs/architecture/) — modular monolith + vertical slices, multitenancy deep-dive +- [Modules](https://fullstackhero.net/docs/modules/) — Identity, Catalog, Tickets, Chat, and more +- [Local orchestration with Aspire](https://fullstackhero.net/docs/deployment/aspire/) +- [CLI reference](https://fullstackhero.net/docs/cli/) · [Changelog](https://fullstackhero.net/docs/changelog/) + +--- + +## 🤝 Contributing + +Issues and PRs are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). Branch from and target **`main`**; CI runs path-scoped backend + frontend pipelines, and stable releases are cut from `v*` tags. + +## 📄 License + +MIT — see [`LICENSE`](LICENSE). Built and maintained by [**Mukesh Murugan**](https://codewithmukesh.com) and the FullStackHero community, for teams that want to ship fast without sacrificing architectural discipline. + +<div align="center"> + +**[⭐ Star us on GitHub](https://github.com/fullstackhero/dotnet-starter-kit)** if this saves you time — it genuinely helps. + +</div> diff --git a/SECURITY.md b/SECURITY.md index ea5fec6b0b..3667f1de23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -This is a starter kit. Only the current `develop` branch receives security fixes from upstream. Forks, downstream projects, and tagged releases are owned by their maintainers — pull fixes in on your own cadence. +This is a starter kit. Only the current `main` branch receives security fixes from upstream. Forks, downstream projects, and tagged releases are owned by their maintainers — pull fixes in on your own cadence. ## Reporting a vulnerability @@ -23,7 +23,7 @@ Please include: - Triage decision within 7 days. - Coordinated disclosure window of ~90 days from triage, longer for changes that need careful migration paths. -Fixes ship as a patched commit on `develop` plus a GitHub Security Advisory. Reporters are credited with permission. +Fixes ship as a patched commit on `main` plus a GitHub Security Advisory. Reporters are credited with permission. ## Scope diff --git a/clients/admin/.dockerignore b/clients/admin/.dockerignore new file mode 100644 index 0000000000..1aef886f93 --- /dev/null +++ b/clients/admin/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.vite +coverage +playwright-report +test-results +tests +*.log +.env +.env.* +!.env.example +public/config.json diff --git a/clients/admin/Dockerfile b/clients/admin/Dockerfile new file mode 100644 index 0000000000..ff97828574 --- /dev/null +++ b/clients/admin/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1.7 + +# Stage 1: build the SPA bundle +FROM node:22-alpine AS build +WORKDIR /app + +# Install deps with a cached layer +COPY package.json package-lock.json ./ +RUN npm ci + +# Build +COPY . . +RUN npm run build + +# Stage 2: serve via nginx +FROM nginx:alpine AS runtime +WORKDIR /usr/share/nginx/html + +# nginx:alpine needs `envsubst` from gettext (it ships busybox without it). +RUN apk add --no-cache gettext + +# Replace the default nginx site +RUN rm -rf ./* /etc/nginx/conf.d/default.conf +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy the built bundle, the runtime config template, and the entrypoint +COPY --from=build /app/dist/ ./ +COPY docker/config.json.template ./config.json.template +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Non-root: nginx:alpine includes the `nginx` user (uid 101). +# We do NOT switch to it here because the upstream image's master process +# needs root to bind :80; nginx will fork workers as the `nginx` user on +# its own per its default config. + +EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/clients/admin/docker/config.json.template b/clients/admin/docker/config.json.template new file mode 100644 index 0000000000..e3d5dc73d3 --- /dev/null +++ b/clients/admin/docker/config.json.template @@ -0,0 +1,5 @@ +{ + "apiBase": "${FSH_API_URL}", + "defaultTenant": "${FSH_DEFAULT_TENANT}", + "dashboardUrl": "${FSH_DASHBOARD_URL}" +} diff --git a/clients/admin/docker/docker-entrypoint.sh b/clients/admin/docker/docker-entrypoint.sh new file mode 100644 index 0000000000..3908a3444a --- /dev/null +++ b/clients/admin/docker/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +# Fail fast on missing required values rather than serve a broken bundle. +: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}" +: "${FSH_DASHBOARD_URL:?FSH_DASHBOARD_URL is required (e.g. https://app.example.com)}" + +# Defaults for non-required values. +: "${FSH_DEFAULT_TENANT:=root}" + +export FSH_API_URL FSH_DASHBOARD_URL FSH_DEFAULT_TENANT + +# Render the runtime config from the template, writing into nginx's web root. +envsubst < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json + +# Drop the template so it isn't served accidentally. +rm /usr/share/nginx/html/config.json.template + +exec nginx -g 'daemon off;' diff --git a/clients/admin/docker/nginx.conf b/clients/admin/docker/nginx.conf new file mode 100644 index 0000000000..aa432f7bfe --- /dev/null +++ b/clients/admin/docker/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Long-lived caching for hashed asset bundles + location ~* \.(?:js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Runtime config — must be re-fetched on every deploy + location = /config.json { + add_header Cache-Control "no-store"; + add_header X-Content-Type-Options "nosniff"; + } + + # SPA fallback with security headers + location / { + try_files $uri /index.html; + add_header X-Frame-Options "DENY"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + } + + # Block dotfiles + location ~ /\. { + deny all; + } +} diff --git a/clients/admin/eslint.config.js b/clients/admin/eslint.config.js index fdd39034f2..083fdcba77 100644 --- a/clients/admin/eslint.config.js +++ b/clients/admin/eslint.config.js @@ -1,6 +1,7 @@ import globals from 'globals'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; +import jsxA11y from 'eslint-plugin-jsx-a11y'; import tseslint from 'typescript-eslint'; export default tseslint.config( @@ -15,10 +16,19 @@ export default tseslint.config( plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, + 'jsx-a11y': jsxA11y, }, rules: { ...reactHooks.configs.recommended.rules, + ...jsxA11y.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + // autofocus is intentional on dialog search inputs (impersonate / add-members) + // and the login email field — the first field IS the dialog's purpose. + 'jsx-a11y/no-autofocus': 'off', + // The permission-editor checkbox labels nest their text one level deeper + // than the rule's default search depth; the control + text are both + // present (e.g. roles/detail.tsx), so allow the extra nesting level. + 'jsx-a11y/label-has-associated-control': ['error', { depth: 3 }], }, }, ); diff --git a/clients/admin/index.html b/clients/admin/index.html index dd72e62969..f3ece6e87f 100644 --- a/clients/admin/index.html +++ b/clients/admin/index.html @@ -8,7 +8,7 @@ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="stylesheet" - href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=JetBrains+Mono:wght@400;500;600&display=swap" + href="https://fonts.googleapis.com/css2?family=Figtree:wght@300..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Outfit:wght@100..900&display=swap" /> <title>FullStackHero — Admin diff --git a/clients/admin/package-lock.json b/clients/admin/package-lock.json index 4520cb58cc..932f9456d2 100644 --- a/clients/admin/package-lock.json +++ b/clients/admin/package-lock.json @@ -9,13 +9,17 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.1", + "@microsoft/signalr": "^10.0.0", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.5", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.66.0", + "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -25,6 +29,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.0.6", "@types/node": "^22.13.1", "@types/react": "^19.0.8", @@ -32,6 +37,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "eslint": "^9.19.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", @@ -1089,6 +1095,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1192,6 +1227,60 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2516,12 +2605,20 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2858,6 +2955,18 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2918,11 +3027,19 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2953,6 +3070,133 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -2990,6 +3234,42 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3055,6 +3335,56 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3065,6 +3395,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001788", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", @@ -3122,6 +3461,17 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3135,7 +3485,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3148,7 +3497,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -3207,6 +3555,67 @@ "devOptional": true, "license": "MIT" }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3225,6 +3634,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3232,22 +3650,79 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.340", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", @@ -3255,6 +3730,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -3269,6 +3750,155 @@ "node": ">=10.13.0" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3394,6 +4024,43 @@ } } }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", @@ -3511,6 +4178,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3550,6 +4235,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3601,6 +4296,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3630,6 +4341,57 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3640,6 +4402,40 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3649,6 +4445,38 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3675,6 +4503,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3682,6 +4540,19 @@ "dev": true, "license": "ISC" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3692,6 +4563,77 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3756,14 +4698,193 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-glob": { @@ -3779,6 +4900,201 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3873,6 +5189,22 @@ "node": ">=6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3883,6 +5215,26 @@ "json-buffer": "3.0.1" } }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4210,6 +5562,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4256,6 +5618,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -4263,13 +5645,95 @@ "dev": true, "license": "MIT" }, - "node_modules/openapi-typescript": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", - "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", - "dependencies": { + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", @@ -4315,6 +5779,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4347,6 +5829,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4382,7 +5873,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4418,6 +5908,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -4428,6 +5965,25 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -4474,16 +6030,50 @@ "node": ">= 0.8.0" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -4638,6 +6228,59 @@ } } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4648,6 +6291,18 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4703,6 +6358,61 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4719,12 +6429,67 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4748,6 +6513,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4768,6 +6609,120 @@ "node": ">=0.10.0" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4842,6 +6797,27 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4887,6 +6863,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4925,13 +6979,40 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4980,6 +7061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -5098,6 +7189,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5114,6 +7221,101 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5124,6 +7326,47 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5138,6 +7381,28 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -5148,6 +7413,71 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/clients/admin/package.json b/clients/admin/package.json index c7edb29812..94035a5899 100644 --- a/clients/admin/package.json +++ b/clients/admin/package.json @@ -7,17 +7,23 @@ "dev": "vite --port 5173", "build": "tsc -b && vite build", "preview": "vite preview --port 4173", - "lint": "eslint ." + "lint": "eslint .", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@microsoft/signalr": "^10.0.0", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.5", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.66.0", + "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -27,6 +33,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.0.6", "@types/node": "^22.13.1", "@types/react": "^19.0.8", @@ -34,6 +41,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "eslint": "^9.19.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", diff --git a/clients/admin/playwright.config.ts b/clients/admin/playwright.config.ts new file mode 100644 index 0000000000..31ecc38abc --- /dev/null +++ b/clients/admin/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright config for the admin app. Mirrors the dashboard's setup — + * tests run against a Vite dev server on port 5173 with API calls + * intercepted via `page.route()`. See clients/admin/tests/ for specs. + */ +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + actionTimeout: 10_000, + navigationTimeout: 15_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/clients/admin/public/config.json b/clients/admin/public/config.json new file mode 100644 index 0000000000..82f2ddb547 --- /dev/null +++ b/clients/admin/public/config.json @@ -0,0 +1,5 @@ +{ + "apiBase": "", + "defaultTenant": "root", + "dashboardUrl": "http://localhost:5174" +} diff --git a/clients/admin/public/logo-fullstackhero.png b/clients/admin/public/logo-fullstackhero.png new file mode 100644 index 0000000000000000000000000000000000000000..456a97d0a64531b954f9a616065314c33d0d5770 GIT binary patch literal 36399 zcmd>l1yhx6wD#U~hje!%Al;oxcb9Z`#|CLcx*GxM?vU>8l9Fx^kcRK^{pOrMac0;9 z!#uI>71z2}>_}xr8B`=fBoGLMDkm$c3Iag^e?ox};DBE@Uh|J25IIOrQcT?|>ognQ zPhTtfE$~7ix!7tx+I*yQ$XQNFK1VM65XQ?=Cvjc#@CMDL`R#> zBq$E_f%b!QTP>CVi%awButt+vGQk4FY|`Cp?vsTy;Yb4W2*>v)i^}nf=l!R zk3(;PhZ=wSb3w{926~<;SqfMvS<>Zgyb|^hj;(U9CV5T-wnsUSDyL_BX&ExRod>&G_vGO(E*0)K}3`T zqYSkr5`~ST5`!)mG}O9UgdPWhf6zXk;@_@DQEXJfUVb4Bbb5nHb0d{mY767f9Y1Gf z^2 zjp-n)(mw4i@U-}!CEBKxk;u^{3HeIHRZ@Fti0of$HS)Bj{%i{e({#6g#BD%U1K&Z!;xx z$G@#C1*hnkt}Dy+PkahoDw$Lc63HXn%>AaOX3!~FsD1E6-Fj!khNKMKk;$v!cRz~$ zWCeSw3eqmor`y?npOuyU(HW#hq(eok>7=96y6CR=yQ`MdYFc@Sd^Qvi#I$d5m3Li_ z15|_C;wVK4rQv)(-qU3B`!fCONJV9k%8xa~ts$Y;?nvI1k|WO&7D5lnZ_f&(g zG0wb`+Yvcaoyi_ce_&pLNK?fq1FOTA(h_r^cf-dM1`!Z1bG(}aJnjMsh#MYdtNVT2+TX<;?LdR@T4!<78~|E%H57v_AgLsxOM) zC|W)Dgi`%AV3Aw&WLpC{jRb0hVDjIeROvDhU5ca3Ax9V&NkQeb!!v~MneSfTKplX4 zvbM&>V0j4#(uL!|PYLAyGvl2s-K-!eZWa^Lqd#KiLPZE&0JSIz)7sKvMut~@;Z?GcE1zI?PRk<72nW3 z2Z$w@`_I6XVQ#LVf}4@ufUa|`T*5ze_mS}CNWqtmsq29gWVu2&^k-i0oe zC&G&M)CiP1g&)5P4#;W2lYdMv!89^(eO|f=0{lklz&Twl#f>>+RFQJh%?U@~jzLvFTK+){EeBmXw7)C$u z)&jIV+Aau193zC;wQ5h~C1_euE}>GNfCoW^HE%0QUcOTVxmeY~{3X3+I<`+-VseeQ zCLB=OnON{1BwSRehe#(SCMb_n4LgLTQqxr$BZL%foba803S6RNW49|eWPhFXUO=2! zx3s}}L)F^z6}4OIpn6hhFh~KgmGvi85)jwEgLs`CDs1pIXTfs8g3Tl%D7BucPra;f zK7qMG>uxf73oin{hgVZak**>AQ;pxN_K!^F)ygSPbL z65b20m3=^>ZyO9@xU-_`g6X7Ygoz^kEXH*LLIy6WX;Opd>B@3KyWl;C*%dY}{??2A)TYh1kTWVB(D$6%DES=+t$MH{@m zWP#xW;l<)3$qjVAl9!_o$D89)Y`NA1fca7hab&vrW7fK1Gw-`bRynmQ-G%g}fH2MH zfA@ky{TrGO8rg2@CHkkA2(N^F z0tht^)W`4BG1v*$&-upRq^Y>AxlFO9WOBL2Kx69A`r-ao0>7Yjeow0JmsG)A9*s6u zy}?}0J(Rn?5HCw%z~t*D5Q4WB);?}we4!`}!9fb6UKzYrpge{=7ZIdAisCZw@f%K+ z5X3EkUCfKty^RPrlsK)Z{R;6qHdN$%&VQ`m=77uy!bI6P*U1nj63o(1jy*ax`U^AP zk1mS&H-$k*hlPw#I9AjNY?jpt95`D6egktC2OTF#t8CG^Wc~E{jNsM+%fe@^Wx(lKvqo5vn=meXu8H1^03ZQotj23_^~u6ez_~>byTt%6 zikGB^&qF#48m0iuo$nIJ#;cXEvf&vpV}U^5Hu;s|&Wz$aWNk2f0CQ#A0C0Paf2V-? zuuaVcEBbA1Ibs-X3KuqCfhj5m3S(;lhkMsX*&`Kpo$lFc{Y0w!y|eLxw?63%t`}^# znrrTO<-xdcuq>wfp9ZCCxkz1bIY=(W^;vbe7&FuYHAFLKs?a=xbl;_ORWI@R3+|oX@^^~l;0K}uUk}YCl9}1}UWUHGNaOmh@ zN)W?WW0-zBG4DMBDG#q4%sJAA9`01zm2En0f6>PE2d~EU5&LBzvzl@OA$A-AF~%h~ zLxy}isQ^iS*7j)e3q?&I_|=5-Z4~XIvPmG-6`&$c@aCX-FdxV|xceA-xk;=%ZyxFL zE2V&6I}Qzxj#mnag%Gs%1tey{!;z9HfvK@z89~E>$$uw{Os-(!56uZD`e!uGmrod;GS0OWb5RG5 zmr3x@#mSoar1z2&lDuG>T@LCH^mHd(Ka(WJ2DnA7AniXQQ_%)f_2Zb<({=&R9>=vBIU z&ir3l6md-s4>|lSs%OF?aQnBA)}vj z>tYIzNk$JPih_DVW{g8_hWN3RehU44rJUrI0&dcVNvN>aSb6rGx$$R74=;*!cbE@X zmyhzFaCbo%RJ{ctX2QI%N66O#0F8RMXsy2fA{@(rKPzt%$gQURcT!pm7f^&s&nlvA zod&tUc;WLte%~z^xh0vzVhx`(wgKn$Bj5=Sq$mJ4#RM*$0kD#m;uN7o)PwgTN)9AxT>~q6-jSlPSb1lJ+8cIU7w~yjJax`UfJ2?#Dd2G%0C#U zM9ou#^tYJYg6-CfTPoS!>iUYG>Zc7CpqIoP%)>8WE=O#9Po}U}wlyB81>`XhX*%g> zqCgmUF|(T5uyuVHux6~o-AVBF!x{FwjY~oQ(Syz^ck|me&Bt%=%BVETcGa>Ar9p?i zMQjy;5IN-x#1VNTh|)GGY^93mb|bCX#E0(IHROv{5WHr#3>bn6dR1!dytQ@JJaF6r zU<@CeEO<8Or%K5sO8L`D=M4nvgffdoaU!rr|2#g!Sl74yJNUZM=gQTD&Q*ei|?;V*0rK;Vq#tkwMRfg>Kw!^{m^UK+r z<@rs$Fk<~+KyCf`4LIL;q2PW_am-;2cXC3joi{V^Lud8UzB)=12Zh_7Doz?O1(SOL z?n!x7)6|<%^Ha1gQSq~sHB-m0;JWsD>^(6>2Ixz6HR6g|0lYa9d5nH9z)59ESDo}s zdYDCVAI|zF!YBYLRWm-`e(EF@v)-B?W)_b4EzbaS3;`Z{R8m^$v?%{Yl`O84O=3lQ zIKQ59wEH6;v%s??;0fNb(!pEshIr&7!Ned_Q)M+h6Iua39okW{Ekh44A;?Esf&WN| z6WT49oEBF>+XEjg>MT+7)Q zOU&t`&(z0xq_i!lpj&if`%vHBm=Ld@TI|v8^hWV9ldGgHXkM7d+jW86mGWjKfXbOb zn;G?tT`|y^5pF>_w`CAj5R6vMBS;0-J`@(SW`c`x4Q{2p`+$dm5ulItRkv3e?v?-i zNsDq;m@v~`s7;JrW@s&<)vy?;cO2*MIUk@mlFoQA=ztO#m{W!Wi z+kq5E3<@@)1blXwFrcg4Mcw0G6FJJUPSkSmukCQVKBBe*>ZT!8^moK(c7k>3zwG_O0Oz&p5}rIqE0{em!FN zOGMNr9A>v)QFi_aJcf}id1l)QSmsIKy215x(Z^iyaX~c#Y-2chIrTVxzE1eN{4WaPhr3 zXwgD$W`sC~5E3w;&=c5QgPc-cD4=#)Ls}#MEM!JpS=+oJy-8|nrJg{ebWI5YqV`4< zIzm&7VEWR(pPyFXSq}N5!~dphuD_bx`&Ty9Via|~^BRvEjbs%H|c40|I*GmH13!(O=|65}4{TxA*`cGt+hch|QrPNiax%R3!@ zcnaD@s-#BhjzHX(*HBP0g6jp6Ss4oGF_}avC)S>DivW zCjvfu9fWciM*uZ}!4!)pX^I@J`lp=C;=>OQKkbUj$q(xK78?JiSlXPu{})+V@n(_i zjHnd6GS*DQXsw?}30zpa_M>#ce9`PC+2$eTyl2Q(u?eCYo5ysT<3LgmDkgux zgL`0)0;p(tpt^0_Lg8Y1sAe_Oir~^R$FCV?&7$b% z$!Ef4`r$uh<#UQOB)sN)zfkSg^=oP^~^ow}GC%Q&uI!n6a0NIX5Qzioi zylf_!O?D7ekuLB*3@e;>;=b;wzR5u@%@P{lw8PIW0x0* zi-_8H_Dti%U#11jpVB^n~o$j{4hXNo&Kn08jJRNIMFoFwH z^~E9|#RGw0GfsfuFU^)crpCXxlb@OUl?w-N(jEa9$GC#Pg5@z~M5jFQ zC}zkhFFsQv)3Wdr$z%mU3lh=szF;|GU^(avDTfo`(|TxnhzDH_)uB;unp> z&(u7~Ub_7rF#81X0a`Q?7z`3F&0y8jFj>5$M@YnJO)PN3qNaBEnCAV9+ewvXYS;pdT%=0JFhBEL8GX?ruG;k%ridX?vtK;_j;>i;=~XR5@4J>Kth= ziBGWxw;&uMOJYP8S69{`h%sI2(a?vVK>w-c`hMd;^i_)AaDq802$fw~rM+s;r!A0A zl12m45rC5jl)^Ca)Q@K2@1e}%H^b419`)aUbeIy~zQwgt$Qnbdw)JlHF!=PZ(8p9Y z50-bAMU5j*_-YJlh;QocSb|Ucab&_c$M8N>$JT^lIJ=fv=L%>UH~!rx19>_8J%+Mj z65KA?)ydrtwZ;a_9Z3$blk%`fP$lG4Dm~3Fkb8U%g;<-D8sL|i6~cLo8-JVtaCvIVyjq0mWy$k3B-;r}q*Oh&BNif*R)D6df?1)LRcsw~{-uVV_kgis;OXo7 z7=es6jJEk%qQ~7L$2`0mj_F-Pvt3DJbKEJL7z%g7Lod7LDm$)tK$$Ic2PP2gA<97HpDV<53dB$ zqB{$!3!C~`^$0Zwb4rPC?(e4r78`dj0pO%2bO8bLhX`q~t|qhvyHtKQxZe5ZK0z&zmEx4C{|O0DR!9a$pM5BfKR6r7EWDcg`^^TiS<<1m z|NQ<)_cc4f{FnTw04S8DJQ8|I1l+t#GM;2IY837I5g!>nTS46{Jldf~+;Zm9W?yjF zt7~ziqjqsazOD~1=3}v}VHdYta1;1lNX85@#%%_`t*ZNPQ<+_(NYfMD4@++HcjC}h zA)h|4umlO%o=u3rT%J}egaT!|D#=ZS7W_X?!4@3{_zlVpmZV7FvO6I z>Q#qpy)@=P;7U%04ueW5r~s zZQn>>k?X)2C@{Yg9s`!LmaRCg=?CD#I|Va+&!>UxZ+`jcQ2FWQcMxfX!O}BOadwo& znT2bJwoS?XCjtpcup95v&1~|a3?cXqSkVpPQd|}yX}{+w{gz%CXPSfS9WPd z{!IOCVX2!6`UiYRiY=8jB`AL_b7pk8=-UROe(P0CueES_0A)CuF9*@}zb0x}2*3!* zv0*wv3g6%gban&Q$N~R!bL4hm;Srm6eZv2&0{H@%$0xjVWiUucj!Fu5m;oICB{39H zYalZOp-eWTMgh17M=OG#Lb5+^Z8DNf2b$+Qorndvbuitb1vSENKkO9~6`Z=h41m)W zw%ZTW&t7FLPeAV;fLnV_9Casqtf{bP+Ytc%9!wq<%5{L)jI-Y*+#Of0;RF9KAQ#8a zzQ+KAgX0<=d$;AxQNEgR+0O~>sBAe4nLx>gE<}DTIs9|DN$gwKXt4`4`={GO!DFc* z%qsJ6N7v=dwA0Is^m3q8C@cIAB~0DeBT03&vekiC8i%yJXw7RdKPz{Zm~pnn>}-W?v(&wtjNs z*)+SeReRmD=wsBbSEqR*&|?LL8`wZ@ud`Q1t?T1Jq3;wS(n00`$8_)18^r}pjY0ZM z90LHard`>FN!Y&@`FBS@xTunCjk{#q&_jD3aoD7;ucFc^JqKtw_MU|G9RW@O#RO~B7;^CEP9EVq{b^La0G zwtH$rssHyrm1{>1Ej&IxwrFL(e`=>RL9t>QKQWD_*?{fl7l1B8H|yhPm9XX*Cn}Jp zvx$L4pUkSiY3P`_>E!oSQ^j-oOvwc}o05wod7BeVd8p@^>;|`ONWHPO=9I-~VW$Ye0v49S+h7LCOaJ1w&2@sP5%$S}qfH zKE^;MM}GjaYFdY>k52pj&n|%ZXt|fr-A)tgPd?%_$r2O*w}WLFZ4yW(v+TMHaRkTE zkf#Y;sz+Hfe6a&39yi!*&q>V-G^;TiLvu6vhTN)aO0RWf)~{|kv~U!)xu}GW7Qj-#`_gzR?L!+^ z#0x9gx{`x4P{7_x87eNRsDn@>sTRj>7Uhe))@NVSQ|E7A#axH_4YZTh!GC<(X%JW8 zWHI{tzYn7!$N8Fv{PJ@onm@2nag)Inr1P24+?JkB)c(BZoQ0D8!8SwMafNtw{%=6` z_s3|!LJM^LyvbBQ9x5b(v_{?%Y4CKNS6TQVApeUHm$d=Z;=n&;?cV8 zi0op#All&L%5t-xJC$YrD5B)I^B@y=`770f`q(qxGas!46GQB4g6U@#i}W$J3H_*% zeN!_l6#XoIF%08{^kkK92|w@BKTj{NMHZ9N5q4Jc9$EKW8(~qW@4!4^AgnsYldmy! zQ$h(Y!Q&&j(YFfdtuRanbbpCU9ct=bapmMc34goF?Rv@VQ+Zjo?Fu|6hPS}P0s&5B zioeVlm{AIYTxVh2;!7Z=QXVm*j>M=YPc+7qj-!_cMr#JjsK&C%SPmrfUDvW%a@+ZQ`j~By zT{56PMs5B}-4CY?nsnYhTUz4)QN3M>PLLm?`aOGSW*Vp4 zOZtWqZTO(AArY~ohz=eT)U&@GohZHi5m^+vW@Y^_6fcWzMYIK*S256^FkY~!Q#_)r zS%c3}VLo~+LShUX^~vRcp%3p=%>6))*s5AA`@l~8o3P;$`zOai|9Xf1gx`E#?GALr z_K~$bq&1qousjw$OVj0t$oB$+x;5$dj*ty2hTxe$ztuKhUQ$WlRv!aj2V#E)ux)oG zA_@0h5_)aM5d@^WlTLM0jK?Xcl^S zd$nAz{XCTGd$xCR-CP;eVg>kdyocEd^Zj#DdyUH=QpPhYM04I)Ws>;$=`{Q*l)=;F zqXlMjQ{7o2PpVOtw3@$(^%& zFZgil_`KBFkuw%eF(Cj>#K-6nxO)0n)A4%i9qs)2D%s!nEOLfJz_yItMnK7Qtd+#- z*na2F=^kPI<@7H-XYUa|yV55Fy48pEJ)k0sc9;lm{)u;nLkiP&Z+lqCVU{fJxPG(i z8Stdys`m1o_2tizF4{QdZy-b>8puCc4t}0H@3T*=`abeTX`vO^1;*KNE0YSMZRQ%L5%)y&3Hpd$HGDU}9X? zhsarR<^ooy#iky*{67)6uKO=JP`RkW$w3_F!sPpdk*l(DA?Q5RnuX`Ygh8 zMw;=wGd{T}QC7zzVcUH+SJjue^ooUxGr6T}CYzWqcFu*1z3K3U!bl)U1Js9JDLhxq zlUxD4Yj79%x3qjxV7p8%;fE)VC@}T@*5!)jM-Z{yl4k4aIC9(4=|&)FWA{#ru=73d znXTsDT`3y}-dn?^gP_L>Qj+d9UyYU5%>zI7dOWJPra;~S_o@ruHT=cfsT%9%+sY4G zV2bhq&WeaU-lfL0ODTCHqR9LD=r=87ee!#mNEjcV+J}R((&ZH=j7(e4Ji1{=Jbk`R zc)n)%ULB4QI1X~>gK73Jq;O7mFS-6UsJ2^(kj;HeNwOp;a@B9}wCkdYfx*V>P zh2vNH9M+gWYfq82h21}F*2<~`>o3Au%W>@LlT}+}Q0x#4my{aJU0Q4OTX=Ww$^p$@ z%A%ImEf@WqE(nWFFz>QvG$cz`$RsV~T8JZcC(`y6bN{RS&jTn4gg;r_vZ=fjnJqE8 zNno!=4~3n@Rj!z54MCA&V33Z%&?kO^@=M<(KwrU&H7OO>B_Y+yt`B3i2;@1BwZ zBW%=eACL?n%SLeqjZ2h-OE(_myfN}Xe974R)ig;}=`CdT=wfiYY6tJdf9vJRZR5EB zGjC*3^kxjf{Zrf*Vlpvlj6Z^S(+&_PM&wJ{oh#w}>knAnDXWdBj$kj-zln)u?>;YC z$z9O-B?y4aF3rkk_LIa(uLyS!nOjN^N8Lo^5P$UQ!19sSxpxHS?&jNLyFs}1B!}s! zcH>)ecy><>hnsM>vIu??aA54RPx}%J~k-d#(}jJ*9Z0X zuM#x{Ur2UUU#TzTduS0XJM5t=7X}A!HJ8bLatpmdEzy~dOvf9G7|Tky9=)L4N< z?rFayv%?UqvZDU9@X(d|Lnzv`vcFh|Xg+F>_(LHG=06}TG9e{N4bJ-HdB|cp8)()h z$Y!4$pWC5^hQ@(+_d)nyoFqA{fe$zLu?{d2`<`|qMtd-oDb%Hr-d{%^>x^mn;dKH>qV)|p!dx;7Fp zoH~S7uAC6Xh`GY-ytg9>gb4?U%UN966jIrpd!t^vlQ*MUl!rU^-E2V9&W_jVFi%6b zU1nl7kdWgvbCysI%L3$7W3*?@=L+Al{)Mg|cu;P{?shKX)RF?z{V8Z??VI4>Ldo!~8yP9{zGe zvt=6D+8TLx1Q_gESYBQ;zpwetQJ!mfmA(LU_zGiDa4D%SSX(|F zW_Gf0dg)EC&VBm^AtHHo@Ddk>DI|YZ?P@bD9t6eY1<7UROY+(AbJX47nw`8K=CPn! zvikJ#tgrN3I_tXi-K%d4m6)8xKB#6Dgo_&I!xw;ILp{7_5Qp4>p{PUA@|76hozw#8 zi}FD;3D^7F7HgdbZjTEuh%$i0HO#CP=!b{5H_UWWRMxzad<`^8j`<_=1luaLa+C{G zeJ`d1t8qB#x%Ug0>y@2DM#l$+xwUt!yweOS8*W)Q5D3Lq*y(GVdL3{YaB|k*TIWF9 zUbrEJ4LGD693VS=;NXGqm&pp%%SXi|Q~-FD75q}~?`%EjsJx#$x^o&_dUrQv@eSrl zpWCD58~ug($#~l5>|k%5IkpK^pP6jTc4$)K!;0v{fqrAjPEh;;ycZ3OP`;Byc6^$w zNY5&AdL#;0I{Bg=q&x2ri%fXj1@xpT*zl+Y`q_D;(e66`#y;_VRt^`sfLOG{`l=D2 z`bw2wqJlz)K3`-eeDy=CAk5h@u)I9k$&~K=9Sd(CC$ zJ+Zu?b;nnp)F8$Gu5wusPP`ao5XPN_d+DN2Gt7Q5Kq~uSSR|`#HHvcKyNk@7DulLG z&qS&V^n$yVZ_0#*GvhmesY`PG!>Nb5d+x`~o%g%E&liY~mzjx~iTn@fVJ{BFt3zQ` zhS38}>DmbbT9GJUi#4~Zk-JtLJIn_DMj>i4)6lL*{;7y0k zU+>;|2Q&=cV7j|1ysQ?mG7Syx7KcT0HlkI5`ccDV+v|x;kIz@4kNScR0wM0t`D0yK4xx$DOE6Jye4PP6?t$m$dw8c36 z&92KCJAzgXyu$)yw03jc_L+FOpQHgbP8mn=;!R{@!f{NWyR>WLp5mwHZs29=99iCo zfLn$e9nPBqJUSs*$Cnqmldz6 zmb{KZJzO&Fid%Hwv@XThX$LTucLWOr@glqpOV`FmUk{cle3(xNPEl8*!vO z_q&yyoZFS($x;B*ZpL$etA*R6fWRO^a17Ljy`smsRq#yoFJb)}UQ6!3O&YGoBg;TI z6`@cNjPX1Q$Dk5?+>8!C*oYd7KB?WicgQ*-d|_!e`#v>OaM@(%rFfh7IhTk6J+++( znA-c2>D~hKa1fAY=R!nnAjEs02fye}9kqmyU*M!CbHOg{zgItNO{OH;I(+J1ieT^4 z3}a<|!6r>}C1Gz2ixg79>*gGYNWf{WG0Z6C6#b21G2h4h9<!MPG7QIgCQ&Ds;(L9ndbg}!!7*JS0|pMyw9exiys&hhnd z`6BENMnu5O)EMin86@~F60V~FN`%Wn85NYz=#{IkgZ~niz;rqPEaUXKM>UO8|F0^Y z`Zu}Mp!Q;;@M?oA2IgU=gGXadT2z-*^Zc#9b~IAm4CWw1xEiViN?GK8xh#}*IIu{F zYA7yLfR44VnbjZVjr%%)c}%b5bbv0xOVxCAh${70

WM4s5!Pey*M-v9^~MnoCtJ zV>>bLo{c6oJ_9cE{=%@A*guqWgB6UT%+Z}jptR5)ORA-uZA))N7Ty<*Zae3A`7HwD zj5$7){jHTx?MkIj%#QCTo>=6Z<|I_oSulax%_0A8X!|O>&Tp3AlO2i4AYs%k0Z_nT zT`ci3j%UIts8GR%CbA^Ifib}CX}zo|SHrE|*?qfcG*G~IRpmlBe2$bm!)bBpV+H{h zlH=x&nYCVBp}+z+L|mNlUicqOH9PVe;_ub)4@%!PQrdl*V4Gqu_}+`UNx*WTMJv!j z+T8`EjY_^}7R~@AT7p#kBlqJjL49&9wPTKpI+y`MTaApmVUcy)u+?x!JbBv?uNFuB zllMe;f$WQh<6u%?x3zY9v`s~~h2XqwIu8X8Lw67AmhZShJkUV7v1vxq z#8-1vkS8r#DoU8Wq?S{x@~m#tbMx{;o0fs#h|eKfD#;*oz5g+^3qm&AOcr=y6ip2YqKuOb zM%VQ}8=l%~m+>huG1-9S<4Ec`M7;g&cE`uduf+5|K+jh|_2Dw|Loekgo@ApSsnGh% z^AVvEUag@shIxD#Gb4g3bgRuBX-BNoTd+4M2mX=n)^JIf~}Z|Ehd-k8E{G2tx2#-oyAWJtyxG{SJ3K9z_psS6@Z96A(f}&$PJG0>s3oO@qEPkW6M+^sv#8^6Y}=#{v5o7N z(~>g`^@22WB<}|yTlSv~ZtQ=#HGVs~x6hp2#hiReoJn8fU5V&@2i;cQ;Y4;Tj^vbn zY`XB>6Y8D#!Dj7Y;AG3DhEo>F;E}RCJrOKjmSf?(eV(v@UWkj;B zEUX!^p${BacaQ#F2mJvKN^o;nHHPo3nf$t;FFvq5SsqfIg^)#JBmOLuwR1vohoV*= z3Hb4-e&+?j!Dg4)n$fx2K7uvsMuq*9hi6R^H^~NnwOFdPGg3}AND0iWKA46{A5m*N z$#CC1{9^oqE5nZnj7%BPP6=>V;k>|J%@2!mOU63e@6ebDL>#$KyR9#lUE8;dR2l3i0+HXIpA~koCH`{`9gVO&%`B~qxJM| zz4@i|x_#N11(>~C^{tG+#+H)ChjJiG-WqGr?qhkDs#^YWtcmaSvPA589=bpV`tQL6 zrgx@l{5>J&=V=`%T6p?VODE0S#y`rA(;|2|=q!=;^*_K05Tco0*RCI;q8V|p&oC5z zErr9UCE5}`7V|8^bBPeZ;{XP>JPM3fyvP!5u2SeOb=J7YGuCy#<*PTf0!&hPnpEq^ZVz{5=XOO-Az8j+dzvY{43J%J(t<8Legg` zG*G^eVPzot?fSu?I4$HPnzgTMvV#mRel5zDGLm^XGe|=#5ch8KC!wxb-)$++#H8rV z3t!FnSO~exQGeaToeIbuGd{mii<52vyfueO3wkIJ;#{lH`b{K zPWe@oM`6sU)63h$)ZeW{qLXbahfZVWB$TUMEl98bx%gx;MH`da7v_)a0t5MKwhGm8tW@$H|aP`)YZdKF#2? znEzRrM!-IeqD$SIlCeLqKD95G%l<++`GsrM=Z6E{Yrr!Gj7mrz=E^e}fjBQx_XrG; zTllofOlbo=n$lyisqV6*uw9b*gPmjSfxz14(>mEpr|^Eh6=gi=FH92m+k=cado~Ez zO=Li3Qub-L<~eRVxmvMqreLMYWbq(wZT4#|pdVgk$0~(ox?J zOrHWibQI$j;nRug_$!5Ak;6lEI%WwwTPaM;kXTZc``dHwlD(ua4{hx)1%#L>qyj^# zS*T){=F)A6Q#aBcp`tM-xpVopOJ`&2SHTW<2fWqi^)itZw}Y&9oIs%l9ACw-qNe1% z5_+)&$p|}~2@5~5;Bgy}nNGSk9a2|4UL_gd%-rvbW-gml48>BWZ-@nxk%Cr}3W5pP zWo}LLfu2*}6nrTi2lgrU(k{F3y%l`qX-3dM1E7d}SvQot+>{NE=c%hDl|^lcQi^^l zHe!@I8JmMP=icIPrYra;s1jVq8u*LF(|)5nM|3iAw`5`glG=rz*egmR#+@0zEPaNt z#Md@F-1ypL(&qMp!su3{byD(Nsi)&EnXe~Xja4LSSC=&~@gh#9F|H z*COVbEbd99Y&<664WmND4{)XKuTdJrohkBO?64 z_`QfNCqdPtnMO~}s^`=I-=I|W(OdJECg?r7!?rY5`D-`clnnF%zN@)`am}WHoqgB_ zueHm;ZTtISXL`HS8mz$aU9#(gOYZK=!i+;M_dFrc(h!T+l%z*GiojL<+i`RB9)oE! znDRF^HIy9ELSy@+(B0Kv5yxo1XD6(;tq~CqU~P`vi(-*LR7lxsc(7=Na7ltsICTgU3kvnfB@fLaVKZHtVI_H0U_yZbjpKQBM zu39DHV@ZGQcmkzeNn~L6Zr}m5k%CR&z^((dx(R9?p}i&%`@unL=?@ZxgTGs6uD;kh zzxYzv#~V^iGTh&Nlz&i{9LAP!fRi6%mdCgRsq4sBG%?Z2uJOEzN)AHW)uJUnj zc$WZcF}CW@cdV}I+&h`v!m0}SpAmPrN#<@E?%yj544k1b1g^$Dcn6>$-6797b^XS< zseb(aP1NtAHLh#vDSWjoHc&nAkF2_zS&87=md4wbQ{d@ttiZ)vSr$l-72)4V4CUq{ znD|(*3Pn0>5W^XP_H$H`}mQ-Gu@kLe2tZRDuW3f1K1~OvyJVKal3YAl2H6P_$pr zz(Aj6HhR#FRM7bY*-5ajgjZ z(PTL?@G>e;IY;C#K304}ocND+e%+5rU+k0njBv=hUQenNE!9gM z0Xu{uJ-1}ufB!dxMI1c32%gi3j5SCeWi{=UoKv9rxC+6UQsBnx%bO{EW!Jv7K&y~d zLdz-dDfivzX+_hzzyI9(Tf9(~g}Z?jP4|V$;*Zj9|5S1Jxh~Ht<7!f|Fn-5zZ2EmT z+FkUAH(Ha0m%7c(>(#5(&J(5iv)JP(bU^Cw%$JE?S^r-IIa=yZFDk*(^im-l@-*6U zLL~KdqshJET&6RYWv`l#Mne|!K9><~|?5s379HEI$r!%+w<|3ZO`R%?JQ@2~zoA9U5 z<=oh=?0n@*QQPYy%fDxmPtVz5CzLYoL1LbUQe`-6QN~%Xfb(WKvz|7Nk;V^7w-SS`c z)=s1?xnSpJdWTc?jBzkSP2+4?!U|c?7Lu(Y^e?L}_z(xKUZW{CaEEP1l%As>4DAgs z3QIQK+{QWq0o<0-m*`mW^@5k@W~}d7Ug@%lv*5vi@*zoJ& z-Pq`+4D3tTlfe!R{$-c{@qNz)UzsPD^hC5bi4qmr!?&kb1Ja|8OSpeixZT0uV{CN3 zj48WQ1DiyQmxgnOFuQR!=#6D3*stvtRImQ8mU&o5wwa2YZF*OO`CXqa*S9ZK(2+vX ze@&5%T+6%>k6a_i=YDc~3+S4wmVf>^==8Mly2(D<^mF;;S#D)f>&Qmv>2xDIa2?0n zR`AUCv(~deR@Z7CUF7IP%ns_j+m%`aC+V``qnE1PxC(8cbn#+@z-V=!`#^MUWi8a2 zE*j&6-#oB7gXNJW5V4oYva|RIDKw;u<|)hz`Any@Q@Sp4R?pmYrC@|F(iCij#qof? zx`;=EDHV`Uig@O`I<2v`o_i4c@-|~c00^x0Y+8tAlJMvGjMVc9-P7S|hb$Nz*kE~U zwR#5!-zh>qRlPU@d>^5NY;k9eiHIo*ArrDhmQaf9`!X{mX|c74vP||ZrjjMhj3rBn$-d9n$JmC3 z*_Ze9Jn!%KzSre1*IZ}L-1m3+EZ_Sa!+uYTA4R{sqEG4ff58gacv+>m?9ia>$H&H` z6;|Xtm{gTWXXLR#=dgu#BvRpaNyK?)X3?#z|73lUjB=IKOA2vMjM)JvCI76@i|=GL zTCaFZ)aig*u0!AWSn<8lSdvv1T#%@!$3GTfPoo2MLY0OT`f`os8&=YC=^5*>0u^je zWv%OT4LD7 z9}f_?1#X`F?c6(XwWir!bbBr_d*!oLY^LmQht7*>(z1POYmW>b`}Y;9D*FkG*zZo! zpD|wMk5GakpDGVYKPmm<90ZbK?q?ez$`D0BCfD!$^yh2!ktnji@}7OMmx6o{pie_j zgZ&SoEXnDUOq+*)MS%Fc_1N!9Q{#i#thBPRw@FPmkS&V$MhogVGJ6^i>HY~iy-_Q= z<$j%d+8^bZN$8E2T`}5o-Mox2y0TvRBQ0ZD;wAN&XAcGFw_IH-hhR{8s_$OCGfzu9 ztd|&XrZDwA#C2v=M;D6MIX!*2*A!@V5Dymq)_+TV(Qb#OKI4&V-wN-gJX%_`bm%NC z-fI<}+tE**9~-rQxo6LckeI}{IrFiDQ>@?AM5oee9N91UR0mvMGk%X#Z2E-J@UapKDMQvyl4zE#s7D$B@FGg`iol2!WNx73<-!gL%h^ zjT!GgpCey>I+8v_(BFTsxeB-?X})^!##41cm^EpVn;QA7H^bDPey~bec@&(-W=+zb zMhr^I^reBnT3r3?^knLQr&7!-Pg{xhC#(zuw$^&gV)vKMDD``1(Y+~MNP`A)?yoB< zcc;#xBR)N|WUJ=66`Nd^d;I!cg?rt-;I@gSuObQ-(w~EF^cR~?dMxeD**Ra>Klq{s zzdz^i`7VQdEk%hMUv6}03yEv#cst#J=u73S7)H#-Xr*AGuOWgFo^sLp?S1pz0N#9p zx(;W#j;kDTERG*K|6nfd33aiXoGJ2+yp99*a~6Ja$#Fi)=iY6fwUr03aAFGcIeIp}L8(0SVv!g?s3cgF*K?|d@eYw>R`Dq63{Bc2W5+0oOHJ{ z(F?Aqv>q2W+g(&zXnK3cBK|=he~Oz65H5%AX9YsUce-BCcb64pdfKyt4Euvkb*FHy zN}bfn)7z$sf=BNUB;B8>fb_E%#;pl`?*<8zSXQt2&+R=9GZC(N$dpNk*ozx=6`}wo zuTVDjY#DeQ6)L5Y{!ymwutV6yVZ`Z2#pH)8CQxStM3j%1#A4Yqv>Mq3x`8+Z0jG2& zitEak?<7%F^QG@6V-{}xtQ>Nad&o29H%{5SWOUx2*V;A7+nzW%K{HWd%7SDC|euiww`54Uz8cJz_gFv`GaCKnyQw zKMwL6m0o?4Dth_kS(VO{;>RjZKQlXDfHHfv$Y$}Hcw+_NL!%e9{|wGEc3yGFytZ*0 zh+|2_Hz7Fj=XPFzS2T<7pUadu40dyFex^PnNNN54;+rAdewb|7H9>n?xl}Mndlm)n zZ80%?rYiR1<0ZtMQ^ahcCq9DRd0bgTVN1600z+y%W$&q}H!XL7$C4meYorzJVN=6P zirV^1VV6>3AE1Ub0Tf#Ht|xe@$gf{fBPU3wwI6ojOq#)%^erldHEkY|L$PPeAHxv{ zoe|4{2s%OKV_tFgB15RV`js+#_wFs@XQH&13Cdi0_Q~t{dJewr^`{Ksu;#O9WB=8{ zpNPo+`2T-j7l&>sXX&g)6isW@^uh9${ONb_@}x<#v1aHu75fJ7G)n$S>Doox_2m?|L7ijFbtUu+o7VAW?if!$@QU^nXFts#70dFLf%)2ebAPqj zdKI`{3zdxx5B8lnggQ1jIaXXooi&<;_kO%@*w*3)d!9*>-TO&Ob{J!*B=T(c4iLB# zVoIGON834DwHZIRFXV+?a)^4c8tE0C^oU>~%^Uj{l8RB$r__JuUXk`@RrN_3>yqES$d+Tl#_W{cl;JT7oVqUuw-U+pcy0IY=tFu@{va z3Esi)SNCgjvv(=uxw8WCz@_;A4w=24VDxVpetya3<}zTi=_DDKfV&d=YJq{ple zxZMBH+EkO0h%$aGfe{d8?;rwEL?(UUBE+{6egYb6i?k= zT?_N1%vOzk(FHf^E|z;!)m1MBfw3nL`&j>;VNxEdgjeOmZr9>E$bu;vWnR zS&wkr(3ji-PDmY>s^GKS{V-UXaMLmcq0E1QfsI`2rL*r_Z44U|eYRMN0W()EjOi&^ zH(_pa&E0|!FEST{{;PFN_xW2=mFy9g@D0Kvt2CMR7gA{_J`hc>wy1m{X7(dL#04Li z^YmQ^Ry&K{QAzt`U|-V|G~m2rddpU-n)tz>T(I7VJ{xRrNuilnQH8gcozH3=PHc_ ziG0rhg{iRVS;o&;%vEySCG1~^5Avo*fcrt9nkp+fL%4bjXHIG0A&jh0vowmw`QaznmE3 zyF4E*oO5y{^F`c~)7$gy3YA7XyqwKuDRaxz5xPU*%F#44^CQ-i%*R&{-J`G#6!{Tj zIwJdEYDx)=Tm}RJh3y>r8#ndKzg39LQ{ZC=`>AZC{apsf0>SAi(8v4j2rlpGM zS^m%%%U4z}G@K9&34(K`FlgmvYo4C-6 zD7fmAQHAh1bP24Xc0nw*z)R~Qe^7zSLRWSvq^*faEjJ_(=7R3dV`!Tjubx$`9U)DM zFEZY!4k|p5{ODOPjj6#bYmj0{(4Ci@O0MYG$msm4?r3P;-}hKQCi2Xy`nds2eZXH8 z<=y`$$5bxD$w`;>yeR`O+A`5F7s4gj;y zX;3iGv-O+w+XA-#iHAOrk`yv%Hg9Yr@>O@-Uk%d%A{g@MtM?l?5p%)JAi-IpN9&>R zyWnNX9m(GmaoEz%xLOx(@PN3v9dH4vhHrU5y$3d-Z_=V|O=j^@NXKVOe%fidZ~R}VaxDjica6gEtA{# z`Q9z{Sr;W({ANr|=9!JvVLsHoi7dUVh#1|L< z1zkj6-~nM!n4k*X&fG>{B?f+n)&{tPd7pDWE3;`vYmgUi=fEZ?RN?UO9dF($lDDdT z6@uY~O+|4vs8vkH@o8`_V3XMFS9WUebnAO$ns-$EaS^YGdcH80dSbDj zogV3S%=>k`47)-F0tE^S77k8lKIW$y_YD-;j3l^|?d8-7mOnwty09`$5rezKDx< zE~u5^)fE$+n|8}a2ncbv!YAQI@WJV@)oC)CP^r`DevQ@s^-MHr#Cbb$H1Fsj)$8FG zBV;MFqu~2=DECPP>wRlkc&&Mg@32c4*F8{@p$?y-;COnk;M_X=L;qOPciXhs5^D`o zEJek*W zzmef$3l_6iEoOzZS-AxsJC5&xUa`L%n=JqN%$tW*RSs^5#UbPw4Ofy*NrWTIHJQ|V z@`1oqVvyT8nYjKhIiZoB)0{3wCV>juRw8T}Z?_Y+a3m1|54O!APz|;>l*qq+kO#H2 z>puoy8qIYoEWVB44eAo1VEry z3g@R=zH6^@u^~oWIWp@o(}B$M-WDLUT$$VeR_Vs^HZmL+F~B11U<6`*u0+55<&!az zJ^5|o^dB3~HzWET#`D7(bIByw#)@y~O0L7>LL+A+Bm(ja1-X|?s3oIs7ElYu%*R!x zH3T{CuiYc7FYn7e{dA&i*xUjUwbCPLfcLgUJtFA8?MsV9x&-ol7Wbx*|EwmGzPKO< zr6uROM9Zw)!?-`2Y28gE5j<;p8%<`YGpi%8Nbb)jY%Yc}O|T+kSJBmPXR@Txv})QP zmtRu#BQw=gE}=ha9%KZ)pzNLu?hfI<7}7{i(akwHoSBUO3l?jQ<0os z^#;E!*3W4nC)#LbXw+^e+Hm1Ba^Wm3 z+mF6O!Txs0aks)qcJg&qQ2bV!y}zLK5$P5Y5Gc@ntmH!7>(CeIaQkT zXCmS_(QWtUbNF01@#jdla%**!zGo~N!Vu%OIJATR&wd_uk$)L+HEGY-4Kk# zEPyrb4wu}L)xcQN3@*T5>>$#PKnXa6|N?VPP0HXC;3@x9M@F_tY1iGx|_~Cj= z%F`IW%L4omRrDDcTmzwQM1&rpXWO+8GwcwS0T0TJW+Z(i2SymRR#l%}vhbPL`TpICkUTy3SKkdiq~#ca$O>0Al)w$?+`FS?h(x#+fMLsnZ$?6pKl%xX67 z&eq|3GP#go%Yb9?1QA2f?f+gkaCht>C2-Zm`YW(+F7~0#j?uXGwiMDhe9zi_wP0>6 z_k0+DaDL*0?t=3rD%^#}{vmLchAze0>O-I0@JOckx9#J}v4@;q-l%Zd+qo?^dLa3v z4UzUuthFWSk}YyJIo9OKfv4fo_ed+HnE_C_W^hIr{K85WJ}s9L&Q{G$xGVTp;s_rI zM19hKq3pZ0QsFR^C^s=59G~E}%(Gn~j@D^cyzq=x8syAgPk4b<#teG5RUW<1Y(>xk5G*3_k*)33}Rj;gOI31+z2aRCGQs`XJVQI9r;{RGMUF zHAtjM)_7XODlDsnS}{7urpc>pcH0TNT%=!p_VBzMfypdCx)u|at`jlHkUUSH|EeTi z6w4PEAq2YJQA?f~{G~)N%*_u5Hi$@08DwamOCZbu?GkkN5D_zS zzdh`)l2$^s;FST5xi^pDeZJ(?%6}JdwWuxEaw!?PWAK@5ckHVSH>g9Bizaa!)?(T< zJJoW-Iq?>9c?ct0=VnGAfrG|UqgabaqtgDlzu;fG zHO{v~T7leB&et9d^xmdup*@axUvYl?2MacnvNWTqjE#7cqKR9K_w}g(e(DnEN+NE? zBMqqW1OdOeagZT!%b6Ql_z#eh(Jo{F#Dt1oRjhE?)wxZ5G$E@|OqOG85tL4_Tt}M- zx(?kMhqIfuIv;;)G?=8pw|6|tsT8uJ8IX<#Kp^Eq5tadlpUFF00$AN|rC3h4WW?!GYl#%mOV=R)}{8bdh3xz9;OsCnlyzsXZ6(OY9flQ_(A13EX9J6dDA zpkps2DiALS0%cv!vAcM(}PHI4e%UmJ;;fBgwI&a#O@J`wg>45Ie(is(s!qFJ#6%OBbq&qW6nTHB z=-YePWqjSj4+I3=A8R`44UblM)uAPP%8Xoxm`Ov7_a7-;Y`)QYy9$g8aNAEK*vG$Z zsKW0-7!=0f0HfDJ-?;~wgQhZ*BOavpf$e5S3n_>n;_UZ^ZuhSVT$TWVWhHtQbm^(=bb%kwn~l4A#k8M#{MUXw+x1re$5H~H?+ z_BC(Jv<)XelxLd00X;ATZ15h{)IPE!t*-7M28NQSzbDR_TI<8H)40kUXk!)bYk5?a zNw05Tmg~4r){_>nqonC7`)v^nRZ(7*gmR18nT-yMfp|t6Q_)FhHs{rp72NlOKuI#S zQ~8}H)6<-$%}~g^D|={|8c{=DY(0Ne37kZ=d2`VAUIa~Jppf!1-q0l%`5q{5urMlD zUp$k-&I;83mF3FiY`+>M8{}X(H$i9lfV2F%Ub>#qtJN?KFsw)5nrT1)wNLd-!734ngYG$ zS@`@=E@6wGcRWW#CDt?dKsq1jNc+Dv@YOzB3m_1&XC8mjg<)O z@WXuxZ94nK*mtWSQ+U(YeNfSdocngAo%$yRM)c7UFAP|7sd&^H?t9bqybbTREWB>S z`@SrEbQo(aqdXvovZup?pkLk!Ii(GHlULp%1OgpsyS}B5;Z&`cfD0bGV8?J8@-#Dj z-r-zG{t8^BgFHs73m7>T3cuTXl)xTfl{7cQOUQwM>% z`IF0P2SXFEz=5wHaVLM*N!oB-TOj)`Y;6_C35v6G{Onlv(-K_E+Gw(5?G9tbxD1JW z5{lSoVmPgZUXkvp*MOwEAyH;f&2G$SGB6>x%sz7J-|tV>axyMt`eY=h>M-r466*Ki zNkB~(9;YdnQ{?o9blr~C6zt%~z1^XxKIroe=9!*LSeJ$nTGe|UPldskEnr~{lMDZT ztmg~cF(RGHqM{4>pf-F+-7?eWz3E}_B@#O*W=?0f&z;k68VAG(M}*w2e zXG&KuWT*Ba`OlGD0uY5>-VlhzUP1V0h3*$#0(SuJ5a;-Gaf7a307E4qFVmb^MQ^dk zQb>!C_U7l1pjEbF&pOc|Q48Jr=I?D*8|Ww|^2Dg(So@QBgS_b;*Ff;{Uh5|{V?+dYxEN=krs*%#Gi_F?mM4O#>p-AXvcv}K&1 zE?nACdv$c2_F@lQfJmviIYEe7(9JCOaboo|#_O$anMc93JCH3v4UoJ?c-ey0`AR9c z0z_DmS?NIjgp;=XV9#tq%#$M;tL(WoxcWzxiIMt&T5_%P*FHj5w`m>@`m>aE!xr+! zEnB~_Ir4;$CD}DOHhqJj5xH}g?o z0r;#1Ji=)5?YXYcS*TzEMSs|Q7Oyaf%AxH^nHW(a)5im1;=x2UO;LchUvAUwgYBez zSY1#t*$)|pw*AR2n<3ZkyrOeNcPNt;*Q!p*uKX6IqCN0=LL0Gg9R!lWZkx(;?6B{2 zEFP>zP=5{Sr(E9ugWsf9*rsI3sP7{^V8J_9zbaIPc{QNAnh8tSc$}l59&niNcSK)C#Q%34VBY zB|o=n5uHj}f4It8cW7=&qu9oJo#3Xm9^A9f3j{I))t1=!0YGW-i5Pof8naIjfdy$M z))~UkH+o;5i10zal%Yo`jv4=4WxurMlIwoeL&5!)&Bo+Ni4-2MWS({1h6>b1} zfr;gC(TYY#6aIC&ngW>Pdc4a3t>cEe-|io=7Dzw* zUUfI0yP%W)8J%fd*LE=QQx$FsVY%sk0SBq*9ml&yRx<;1OULiCRLh?S^;X$YQD(wb(-liyLBs+ z$=ESU`XFcS^$xxNhargzC^jdBSgt*n+$L@}(MHy?&*Af}L%H2r=u4Xdpy*a0bCl-f z>ALY)mkl4{3wg^9e#m%BQreoV6F2r@T+{)2!z9y5SzBZOb!X~Yj1qtSrO%}ih7h+C z(s09(I!tV(HHP+U=zV~hBt|0_+{&Bjwz{vl?{f6a{I0XfgK7?hPu7Kd$7pJ=9S`f$ zx5VQDo!vAG-KL&&@mk%V-dJC?^T7e#@ca4vUC1jxyOY2a76YgOmJNZ=LduhCwX0wr z{!7kj7gnyhY9A=3qO)$R|12>-`$a39!UJwS)<2Sob^e!n!;vI82+st2<@nC6x+W8Wij@yzR zMEAkhzdBjlG_m6#knl`0vFg(;vw-kg$`OIX`IMw^_CPEg({+DVji;;iLQPvk=I6G~ z;H-+6*ltZTu#rnxn-=TvhJ*+Qb67DrSk|;g= zx8zbu#27y|PYccC6hp%XIl86EssJA5PX~cEmv2pMWVv?r;;!v})~ucCuw@}^Xy6$w zZADu>b;idB2d~8DiyOP<_CwX?uqM<4+i50QyqVAjjS=A`D*GqBA%rYx22APEjb~UHiwX zG*LfTQ0DJ*ldffU^&5Er%y#YCHYs6Av1^ROUx0vwJ4oMt9Mq;b*yLJVqFa(levt_0 z28z2?d(*(*c1{rIhycyf3j=LR346u?{HpZ&&W@8iL1AL2XFmqkxvggOA=esc>)EdwZ$>0w zAtm*uL`i*7wcr^QSCMqfnFwFyu$?XadJRPlf7G7;5;PQr*ZGnb`BOAU=c_Q+YaFx> zly!1S3tgGvZiyn0`yPWnC_xs%6QmwL7$(H^thc_mxtnjrLJ6mR7YTTDmH|*sMaTKb zt68|;0E(NV{`%6vcqxw0hfUI$@nEm4PcnQ){ALod6|GlhWvCa0;8yv_m-~uVVUln+ zoFtS~cqg(8+0f^vQwA%)I^lC_4sR$BV>ZJ0eqD+&3$c=~arlt(_lv@y26n;V(P=$i zl3TM#c9$_IIqoapSu&bM|c9#1+~*459LEUw!;WYmZ(?JbT2o3mJWx=kZwslIynZ29~$JJvE_ z*Uwsww(cYwGn&3<^kmfvN2j_uc%tsi#pRcMu}#HHV-i;~mvekAMpB^O zZ{lgbNS~8SiEZ;^<<}YVB0u?tb#41E1`x1yu}T^S@y+w#&h9RB8{^M=s1Z@bQ|x%$ zPzw?KBN0DR-sp59Ws(*x*yNcvKx|??o5o^ml>L(@Pi%xDZWmH-Jx?|=Z8UwW@`Qs$ zCIXQ9Xr&b8pxB2bLP5@osCgaw{?bQLZ1h;?PqnQ5*LxQGf^^ z0WAlp9H>+74k*rYCY3bp(_p}UV@l+|MBapM14EWm zs_?R}PFr2u!mauREl{Z`R`AqtTHCcyOHnbl2i)SIf}fAuX)(`efDx257qC7O!ai>6 z(t>ob4PQ>r7w5M9{gP39Dxq^b)nlQZyQ;IXQNr3fAaY=(I(s|f)LD@GBS0Lhw!g!J zzu3jj&%b1J*Uih>tqo8MN^Dsk&y!=F8%={ZR|_c=w^P@1ky9(m@b0H`H$wutVlpzZTWn=Sc3=YsjUjl}(iTGJYR%g~8>!uvm^u2t!J z4w-cZ=F5*yoG;f{gu{>7JLvb~@XbLRS}v3-VHSyPoJP7fBf@D>P*MP>a8A_vos{75->g_uT>=zjK%p;zwEpd81K{kiLw^?bq((50&wmN#2Iqtuni6#;Tv+&< zGjfBR*L~nMcH)eh&zh+Fqw=Bej>l_`P14Nuu;f$en7)9?5yn8@#B{{|5)Xwz)~d!- z3LG9-mbG1f_ETJIyoXQ8Bo3TRTE}auZn6@h*lmWz^+?EUD0VtL;K(g~72( zSIlT42e`*4nQIyLHPM8%VF%n_V7R{Aqp$T%Uw{6d@{XpbP!)jeyLoajsd zc56U{&cOVm3CIx@Ho{y84u~&1CK=zxKX?%o@+m#QKO6uzB=*%8guJ8kpq`3{W7oR915ai>&fb0^uPY7pZt2c!KFs3AcsVufgwzzg%>74Wd z4G5IJeclPQNT;9xm+*c@Oy_n<8O2#&Ev&@cYybD*&xh!;kk7P(j3>72>%}a;$q&_b zb*bvV(FW^^2a70^M)AnCHKMmDe4PQkOcZf90v2Fj*Enf(L~Tf2Y$EF9H261Bk{@Pe z#wg9&AUkff9QG?eddSXb7I|F{QPz?c`Sjo5&+5SglPlkRyZl{a&;&r9!bF_RFv2#N3*uz(BX3!ZD zHwAZjv<7Wdq8waQAUyKv=t*%5r?n4R$_lv>kCdlJvF=Re(Lb!~(I1KzzychJw3quj z>M*xhjN8}jvbzFYV>s7)CjQrAL*;OF+lhOR@4^_PHyoZCS2C-Z{2+ zBoY3TtM$~a7VRx|(!?0P82S9Q4yXkV6x=~FNp^CU?HJ?up zk~l~b{Y{L}q$sZ3QF~=KY}{q4IHrV9RH?g{cS?3NwfeNn?d`+iGAz7VE;t^1NYL!u zJprIMzS6Qws5o_x#@|w`vI#JsIibsM`=CG9m}j~$qx}q>HXz7;uHwA|DjkHeGV8%FK0ccB* zC}{UJ-_q;DwQA#l7@NK)GlptVMF8X5iPX}3ihgRsO9`9Omp^XZCYI)b$TUi+e#z(; zGA!6Lj7skt`CH~0y~qz~QrQGFNH1c_vx7jJ9aoLfU5~BqBmDgqa^leOc0KHX=VayP z4Dx9ow)nN4-SL{W6q!BKs~728eEe-Kc^_MFEJL#IyB4!6Ky20KU}=5p7XQoqunRzJ zVOr?vDG~EUDn9{5@|9yFU?Li=zt&~iXGK6|4tDhrDHJsX;wp-MPPBGs{LGsn(%Az& zH|`dy?^djb`4*b_~<5`UDBxrdJ2>9B^NrHCGj?qRac0MvIty!)= zQv&|{$urAE?t5PT!_ox!s)*{PN!rFiI_nlqrHoP^Ru$p_hz0%jHNREFuaVe=YfMsR z-l86Y-$EEj0KhwVi{lW=0Ed2>?=u2ugvy7|#kTi~ z*V0H5(8>8km{C4bj(vR>_qb(?r|1N2;$F&7#0zeg>un-Idm3p1!TQ}8t(-KKBNE2_ z0Ox@pdpH*<0tbBs;XjQ7cBS-g@Zjn#dPrmIo5Y0U8p|PE#h*e~hLJdpC)P`{jE*Ww z<$jWbT9L+xaaylgx7Cx!-~R-)Jkdg*ZrJtAxzsD!&)0`S2Xsj*iMxcJgy!$reQ7h2 zXH89BfRxepM_oigrC+|ZkY)q+uiV0k0BU^`e7w69)rT}{B;2p=M7c==5bsAW#^J-s zb-HS9DE97huBrXI>b$bixd=46?$Gj`pTgx&`SxE20RssTqA7S#(H)?`KJ^o^OMeh~QXqFJyESE<_UTFk_ zG!2gkqz4_6Mlzi`w0DA}=I*8&%2hA`pQQ{*VC4f+-l60UVUmOS5B9`G%uU?aQH@Os zlNzM~k!CVFJh1fka=`i>8d;j#T5p@Hqo0=2=jN}+AP(!pHG{m0ckR^%k>ZNxtq*|yev zHgB<*bhmTcM3%m@%!cr4AzPvFW^$`ne0?l{($cj~51w6_tX;P+?*F)-Q`Qunr|0mv zs(J1J?W6}7F+t+KyFL;6+!8RJ>Ti-&1*DHbdQp*KA~My+yM~Yd_-@kq*QXTDYwOj4 zPS%|ffdPf3{ubioG%v)BC7mO{Cm*8BIVUE1L`k!A&A#|{MZl=J>qi>^Ek%dHJ3=TH zO;xylM(_6u{fhc7jDuA4HVf!p+fR*@2a}pZi&oaa%4Bz}>hA>=P)r z#HrHHPQ1iFD_N@}Ee~Jjth~0;PDLAKAe^Y^o*QRNEWGM}Zz^*55lj*snEL3W{^ca5 zI7h)P8x-$ZYVhEKaFF2U-EQCsJ@THrdv8|i58tRQue;g>Un!yA{)0E2J&&-`q|Fk* zOl|3`5r>iM*gK&trZH|=pT329?|@hFI_jwT*_5l-mw5I*?)*we%)9;SR$BlL*7SoJ z=<#r{j8*oRGtzNuZ)Ev)UyCEfhb#n+z4IG>xnoo_Q1%4gB0=q|%v$wd`i_7=X<*S# zs*SlGr2N7|2XikVBGm?LUknIb1Js%94WehX+!9{z-ffI>KMv!d{sD)X7wfU=>_H)aLeGh~D0eMxX!A{gL7}K#ZVLn zG&TT`UL49hq6Fr#UQh2hzhl^ZZwNn)y`cZVk_=#?)4SLpP&(`g-yzKsf1ldiiTkDf zK{1Pu8DXvJnHvz?2H-V{{eLE%Cv((!Et>X-#C(C`h zCs=ItAWc^YA*O12a(O*MWA{nfT*CD&1)X1BQx&3~(I-}2A?*lFJ&07LuMdkp$4=~$ za7pEg(q6?J{dlul|8v)M zp$5$N4O-w^LBrtWSE@jF{Y{6hy6$_yxyo{djGUW2c7%J+siYiYJ(h*|cC4`GrB>iV^ZF$ z9SE^IHi$Bo@dpghp;-979fY}87r9KOu{y7zCU8sYhF=H35=)U>rDb5jo(mj^d$N39 ze|ROl*8RYK+7H=qjw_RGroQX1sSK^HWqKz***~d_ zo1S3q+g`6fGpJkZ;&=&D6IS|v?ZLbjNf1bS*96G3^G|YkSd-14i4UWvN+aV5s1)%5 zwiuLlYBm(ncdqAW)cf-%o`ro?kc}6LJGRWeGz>0c6~o~d*VdJ|`uZ@A&-+isCb&-wk2w%XqVxLIE4N@rS6%0X zUXXA=2bUePnjfqu5)69_y0*U_wf#3Q0lhmadHL^*?K@F-p=KzM{KNL;7+b;@ziiqs z(I)tpuSgo@l>Z0FA-1X+&DMsBzHlH!`>eOxMz`zx%cI_}`*4{*1alz=l)&DNad*qo zs!>zA{bh3B|3794HQ*%uTvh?a?M^t>GNk*+-p9wHYP8N*$e6YkBVch!!*)ZEr8LbS z1tpSQL7`!Abv{87cY2aZW$x3n;{Y_OL`qLxj?QpnWpIG;swXZGiy5(wufNX~uV)lG zXNy~VM$`U@L=*>Zx*U`rWOM8kxc)li%w_&DffE0U;q{f037cuxix)SODrgZFG~&sA z7p%+h&%jV_mDO@2^d3aftC5iR@WL;eIw4OFCn;Lr{%z-{J{$tjHPAC*Igrwf<+ATv z1?S%2nzr>A*=qnG7J{ozgAdS?dW+H=1zOH$C$9o>&r^58DM9#aHP&JsCNz}$qPjL! z1Fy$h*b59@%mkQs8S6nFLqH z^N!EzduJ6l)?{0@P9=|O3ub-<({DF?g$#`lp&LD6Veot;vSVBg*Y2VtIN|hVOIhEA zPWugp5l&2Wn>K*VhA|F^VBe70$nOn?YyYvV0K@T$#wL_0A{Q4V9DD}=PBT{z_C}U& zE=`qs^EQ58l0x^0lIN-+S^ebq%*}X%J1mFPq7sU;BCIade`$+-PVO&niNK-r=DLzZ z*;h$L|LDZd1TcKoGkU+-xNw>M$}<|WOr`^i^P}}DRKV+9ZosFt*rhp@!!fNFPItr@ zuERfal9hR^D7@~v7&NYFY8 z$-3ir6!l%7tA2@ovhNaH88C{4{9wP|2xNVvgrE(=R2KZmPxcv%y3;%nW()b|Z6wnNt;-qlf)wUs;9zLYYW;lju!Q-PbQgcKcj;*lWS~ zI2*#KLTI(0?$Jfpgb^KsVxlafWv_Z;UUhDBe`MxU1kxXF&h`pQ4+^;}j98L>nmQIKY~Up=LGUJkPLL$#*r+Ia7aN=j}(Fxk+fbbS){$bLjD zL*q>Bk}t|h@no|1OV!0EBX}yee(qAmc_M-Vh!BceqR8oUacK zjh0gzTEDdne>ITh+O9cd`nR%1j)O6LHjA=BnY1L6j$>}m?rY&Cbk{}PJAl@o9ub?*7nwo8ynyyiUmrX@R z6CE*yE$`mb9m)3Xj ztKCKZJp=Ed$yleY43hxMhr#LLs&w_i>)PIt8=r_*+Hy`T)i z;^yiX9LRlvUe2jtNnG$lw5R4Nto7!daG`RnMigml_`xFIhGZiq^2XZ;=rC==68p&0 zQk)P*<3CPN8?X)}Rt5ip%}}cJ?F!1sj(bza`LDq-T)pi|!uW*0eqpBh<o96ELw$;) zY>O0FX?Zp*UrZ&>N6uDd2awaWjqQJIEpv}SbRn49u8*NpYH;rZNg6M{K}jaMej5Ht z{?kH9hJ{h|2uj>)4raOTjhnu-7#tsDG{T{3a`ks(h6APqLQUTsYD@8o(=HczVXto* z)O$xTWL#ByND%F>WQKfR&cWTSKj9I}R1TQ{TJ$)l4UHawKYT1M26P$J(GVxde2episI?x zSKB!#HFB+{LG=87Ax|wb0!mlUKZpgnT>`RnoqGK4(jnfL8V`unfvV`;!S*c`w^F5M7w6-lAId>YG@DgUhni((tWr2nA2X$p)N0bv#7)a%&DR^JQtG+>pmrA24dlv7tRBqjHxzcMW21gOZ!Q3=qXw`CaY^$|0LW*SS0=mop7M#V$x+oI_SOcL*-46U6U2at zi>lUAhK;+DJOrJTJW?Q|n~-tgO(U@2@a@^3nc79D<f07x46cZbbE zix9({kB!_}dtI*rM$W3|l%oze-zqHtKUq8?q_>mD=nmTIuVH2_+Twi^OW1S%GTHCy z&z-#Y#;4n;c&Tu>AfPU8cv`9~DbOOb!MjIVlq;TuYru%^?c%wmZ(5;AxFszWvl0#c zs#=X#SY_HLL`+f>PmgA7Di+5Q5kZ};9||{8G|~+U@{_%ao@&K%yey46iqJ(;+Gcjo zoYqP{etoG&m!St7zhC%I*W+49*|I@SjTtR5Te3)oXZ=UyKKqg=7GsKHJ`~%wo~0+t zk;=E)B^UAr?_ToNK#}%}8)=$AkC*hu+LGez5S)|u`c+B@;Jg3({HQyDaG+cH4=Bm* zhZ~8w2)CAf(tgWLL_YoHjCKya(UU{p&sFzseltj)tw`U(hHk$vP-t>-?;Bf89Q9W2 znuAS7@$JkqM-K538-XkHIfaf~_d1i+B~v97pKDBsszTJ#qs5>(;L$Gb2=Mn^4>T)C z${y{C{_eo_Fj0gP`~{ZVgcyCnn38mWR1EBb0Oi-X4B*Q`qM zb1M46UMs*z0H$XjcLR@{@P2_AZpbpvoJiYR%`1+kydbQS@|>AtpFmgatA=iQ2F0GJ1|-s<#tHtdF$jq ziG%QxJ_$Btm4r=XXgK}WSJs>c2-gqnQd;wl1Sx*PYIi)sZhI6U8i#oo zon3`tmf)6U{IvIGdt84j7D~PVt&Ch8g690(A6)&HlyRvS#hY;0tn9_Nw7;1NcTAK& ySugM4Gaw-l - - - - - + + + + + {/* Top-level boundary so the public lazy routes (login, password + reset, confirm-email) have a Suspense ancestor on cold chunk + fetch — the protected routes also have AppShell's own. */} + + } + > + + + + + + + + ); +} + +/** + * Console toaster — a refined card with a per-type tone (left accent stripe + + * tinted icon chip), display-face title, and a body description lifted toward + * the foreground so it stays readable on the near-black dark surface. Theme is + * sourced from the in-app ThemeProvider (not sonner's "system") so the toast + * tracks the console's own light/dark toggle, not the OS preference. All + * surface styling lives in globals.css under the `.fsh-toast` selectors. + */ +function FshToaster() { + const { theme } = useTheme(); + return ( + , + error: , + warning: , + info: , + loading: , + }} + toastOptions={{ + duration: 4200, + classNames: { + toast: "fsh-toast", + title: "fsh-toast-title", + description: "fsh-toast-description", + closeButton: "fsh-toast-close", + actionButton: "fsh-toast-action", + cancelButton: "fsh-toast-cancel", + }, + }} + /> ); } diff --git a/clients/admin/src/api/audits.ts b/clients/admin/src/api/audits.ts new file mode 100644 index 0000000000..731ae4be66 --- /dev/null +++ b/clients/admin/src/api/audits.ts @@ -0,0 +1,200 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; + +export type AuditEventType = "None" | "EntityChange" | "Security" | "Activity" | "Exception"; + +export type AuditSeverity = + | "None" + | "Trace" + | "Debug" + | "Information" + | "Warning" + | "Error" + | "Critical"; + +export type AuditTag = + | "None" + | "PiiMasked" + | "OutOfQuota" + | "Sampled" + | "RetainedLong" + | "HealthCheck" + | "Authentication" + | "Authorization"; + +export const AUDIT_EVENT_TYPES: AuditEventType[] = [ + "EntityChange", + "Security", + "Activity", + "Exception", +]; + +export const AUDIT_SEVERITIES: AuditSeverity[] = [ + "Trace", + "Debug", + "Information", + "Warning", + "Error", + "Critical", +]; + +export type AuditSummaryDto = { + id: string; + occurredAtUtc: string; + eventType: AuditEventType; + severity: AuditSeverity; + tenantId?: string | null; + userId?: string | null; + userName?: string | null; + traceId?: string | null; + correlationId?: string | null; + requestId?: string | null; + source?: string | null; + tags: AuditTag | number; +}; + +export type AuditDetailDto = AuditSummaryDto & { + receivedAtUtc: string; + spanId?: string | null; + payload: unknown; +}; + +export type AuditSummaryAggregateDto = { + eventsByType: Partial>; + eventsBySeverity: Partial>; + eventsBySource: Record; + eventsByTenant: Record; +}; + +export type ListAuditsParams = { + pageNumber?: number; + pageSize?: number; + sort?: string; + fromUtc?: string; + toUtc?: string; + tenantId?: string; + userId?: string; + eventType?: AuditEventType; + severity?: AuditSeverity; + source?: string; + correlationId?: string; + traceId?: string; + search?: string; +}; + +const ROOT = "/api/v1/audits"; + +// ────────────────────────────────────────────────────────────────────── +// Enum normalization +// +// The server serializes audit enums as INTEGERS by default (System.Text.Json +// has no JsonStringEnumConverter registered for these). The client surface +// types them as string unions, so every code path that does +// `eventType.toUpperCase()` / `severity === "Warning"` would explode at +// runtime. Rather than fix every call site or change the server-side +// contract (which other consumers may also rely on), we normalize at the +// API boundary — one place, one fix. +// +// Index mirrors the C# enum declarations in +// `src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs`. +// ────────────────────────────────────────────────────────────────────── + +const EVENT_TYPE_BY_INT: readonly AuditEventType[] = [ + "None", + "EntityChange", + "Security", + "Activity", + "Exception", +]; + +const SEVERITY_BY_INT: readonly AuditSeverity[] = [ + "None", + "Trace", + "Debug", + "Information", + "Warning", + "Error", + "Critical", +]; + +function coerceEventType(raw: unknown): AuditEventType { + if (typeof raw === "string") return raw as AuditEventType; + if (typeof raw === "number") return EVENT_TYPE_BY_INT[raw] ?? "None"; + return "None"; +} + +function coerceSeverity(raw: unknown): AuditSeverity { + if (typeof raw === "string") return raw as AuditSeverity; + if (typeof raw === "number") return SEVERITY_BY_INT[raw] ?? "None"; + return "None"; +} + +function normalizeSummary(dto: T): T { + return { + ...dto, + eventType: coerceEventType((dto as { eventType: unknown }).eventType), + severity: coerceSeverity((dto as { severity: unknown }).severity), + }; +} + +export async function listAudits(params: ListAuditsParams = {}): Promise> { + const q = new URLSearchParams(); + q.set("PageNumber", String(params.pageNumber ?? 1)); + q.set("PageSize", String(params.pageSize ?? 25)); + if (params.sort) q.set("Sort", params.sort); + if (params.fromUtc) q.set("FromUtc", params.fromUtc); + if (params.toUtc) q.set("ToUtc", params.toUtc); + if (params.tenantId) q.set("TenantId", params.tenantId); + if (params.userId) q.set("UserId", params.userId); + if (params.eventType) q.set("EventType", params.eventType); + if (params.severity) q.set("Severity", params.severity); + if (params.source) q.set("Source", params.source); + if (params.correlationId) q.set("CorrelationId", params.correlationId); + if (params.traceId) q.set("TraceId", params.traceId); + if (params.search?.trim()) q.set("Search", params.search.trim()); + const page = await apiFetch>(`${ROOT}/?${q.toString()}`); + return { ...page, items: page.items.map(normalizeSummary) }; +} + +export async function getAudit(id: string): Promise { + const dto = await apiFetch(`${ROOT}/${encodeURIComponent(id)}`); + return normalizeSummary(dto); +} + +export async function getAuditSummary(params: { + fromUtc?: string; + toUtc?: string; + tenantId?: string; +} = {}): Promise { + const q = new URLSearchParams(); + if (params.fromUtc) q.set("FromUtc", params.fromUtc); + if (params.toUtc) q.set("ToUtc", params.toUtc); + if (params.tenantId) q.set("TenantId", params.tenantId); + const qs = q.toString(); + const raw = await apiFetch<{ + eventsByType: Record; + eventsBySeverity: Record; + eventsBySource: Record; + eventsByTenant: Record; + }>(`${ROOT}/summary${qs ? `?${qs}` : ""}`); + + // The server keys the histograms by the same integer enum form. Translate + // them to the string union so the rest of the UI can index them by name. + const eventsByType: Partial> = {}; + for (const [k, v] of Object.entries(raw.eventsByType ?? {})) { + const key = coerceEventType(/^\d+$/.test(k) ? Number(k) : k); + eventsByType[key] = (eventsByType[key] ?? 0) + v; + } + const eventsBySeverity: Partial> = {}; + for (const [k, v] of Object.entries(raw.eventsBySeverity ?? {})) { + const key = coerceSeverity(/^\d+$/.test(k) ? Number(k) : k); + eventsBySeverity[key] = (eventsBySeverity[key] ?? 0) + v; + } + + return { + eventsByType, + eventsBySeverity, + eventsBySource: raw.eventsBySource ?? {}, + eventsByTenant: raw.eventsByTenant ?? {}, + }; +} diff --git a/clients/admin/src/api/billing.ts b/clients/admin/src/api/billing.ts new file mode 100644 index 0000000000..2a0941fd77 --- /dev/null +++ b/clients/admin/src/api/billing.ts @@ -0,0 +1,254 @@ +import { apiFetch, ApiRequestError } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; +import { env } from "@/env"; +import { tokenStore } from "@/auth/token-store"; + +// ─── shared enums ──────────────────────────────────────────────────── + +export type InvoiceStatus = "Draft" | "Issued" | "Paid" | "Void" | (string & {}); + +export type SubscriptionStatus = "Active" | "Suspended" | "Cancelled" | (string & {}); + +export type InvoiceLineItemKind = "BaseFee" | "Overage" | "Adjustment" | (string & {}); + +export type PlanInterval = "Monthly" | "Yearly" | (string & {}); + +export type InvoicePurpose = "Usage" | "Subscription" | (string & {}); + +export type QuotaResource = + | "ApiCalls" + | "StorageBytes" + | "Users" + | "ActiveFeatureFlags" + | (string & {}); + +// ─── plans ─────────────────────────────────────────────────────────── + +export type BillingPlanDto = { + id: string; + key: string; + name: string; + currency: string; + monthlyBasePrice: number; + overageRates: Partial>; + isActive: boolean; + interval: PlanInterval; + annualPrice?: number | null; +}; + +export type CreatePlanInput = { + key: string; + name: string; + currency: string; + monthlyBasePrice: number; + overageRates?: Partial> | null; + interval?: PlanInterval; + annualPrice?: number | null; +}; + +export type UpdatePlanInput = { + planId: string; + name: string; + monthlyBasePrice: number; + overageRates?: Partial> | null; + interval?: PlanInterval; + annualPrice?: number | null; +}; + +/** Price charged for one billing term: annual price (or 12x monthly) for yearly plans, else monthly. */ +export function planTermPrice(plan: Pick): number { + return plan.interval === "Yearly" + ? plan.annualPrice ?? plan.monthlyBasePrice * 12 + : plan.monthlyBasePrice; +} + +export function getPlans(includeInactive = false): Promise { + const query = new URLSearchParams({ includeInactive: includeInactive ? "true" : "false" }); + return apiFetch(`/api/v1/billing/plans?${query.toString()}`); +} + +export function createPlan(input: CreatePlanInput): Promise { + return apiFetch(`/api/v1/billing/plans`, { + method: "POST", + body: JSON.stringify({ + key: input.key, + name: input.name, + currency: input.currency, + monthlyBasePrice: input.monthlyBasePrice, + overageRates: input.overageRates ?? null, + interval: input.interval ?? "Monthly", + annualPrice: input.annualPrice ?? null, + }), + }); +} + +export function updatePlan(input: UpdatePlanInput): Promise { + return apiFetch(`/api/v1/billing/plans/${encodeURIComponent(input.planId)}`, { + method: "PUT", + body: JSON.stringify({ + planId: input.planId, + name: input.name, + monthlyBasePrice: input.monthlyBasePrice, + overageRates: input.overageRates ?? null, + interval: input.interval ?? "Monthly", + annualPrice: input.annualPrice ?? null, + }), + }); +} + +// ─── subscriptions ─────────────────────────────────────────────────── + +export type SubscriptionDto = { + id: string; + tenantId: string; + planId: string; + planKey: string; + startUtc: string; + endUtc?: string | null; + status: SubscriptionStatus; +}; + +export type AssignSubscriptionInput = { + tenantId: string; + planKey: string; +}; + +export function getSubscription(tenantId?: string): Promise { + const query = new URLSearchParams(); + if (tenantId) query.set("tenantId", tenantId); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return apiFetch(`/api/v1/billing/subscriptions${suffix}`); +} + +export function assignSubscription(input: AssignSubscriptionInput): Promise { + return apiFetch(`/api/v1/billing/subscriptions`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +// ─── invoices ──────────────────────────────────────────────────────── + +export type InvoiceLineItemDto = { + id: string; + kind: InvoiceLineItemKind; + resource?: QuotaResource | null; + description: string; + quantity: number; + unitPrice: number; + amount: number; +}; + +export type InvoiceDto = { + id: string; + tenantId: string; + invoiceNumber: string; + periodYear: number; + periodMonth: number; + currency: string; + subtotalAmount: number; + status: InvoiceStatus; + createdAtUtc: string; + issuedAtUtc?: string | null; + dueAtUtc?: string | null; + paidAtUtc?: string | null; + voidedAtUtc?: string | null; + notes?: string | null; + lineItems: InvoiceLineItemDto[]; + purpose: InvoicePurpose; + periodStartUtc?: string | null; + periodEndUtc?: string | null; +}; + +export type ListInvoicesParams = { + tenantId?: string; + status?: InvoiceStatus; + periodYear?: number; + periodMonth?: number; + pageNumber?: number; + pageSize?: number; +}; + +export function listInvoices(params: ListInvoicesParams = {}): Promise> { + const query = new URLSearchParams(); + if (params.tenantId) query.set("tenantId", params.tenantId); + if (params.status) query.set("status", params.status); + if (params.periodYear) query.set("periodYear", String(params.periodYear)); + if (params.periodMonth) query.set("periodMonth", String(params.periodMonth)); + query.set("pageNumber", String(params.pageNumber ?? 1)); + query.set("pageSize", String(params.pageSize ?? 20)); + return apiFetch>(`/api/v1/billing/invoices?${query.toString()}`); +} + +export function getInvoice(invoiceId: string): Promise { + return apiFetch(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}`); +} + +/** + * Fetch the invoice PDF as a blob and trigger a browser download named + * `{invoiceNumber}.pdf`. The endpoint streams `application/pdf`, so it can't + * go through `apiFetch` (which only parses JSON). We replicate apiFetch's + * auth + tenant headers by hand so cross-tenant viewing works identically to + * how the detail page loads the invoice via `getInvoice`. + */ +export async function downloadInvoicePdf(invoiceId: string, invoiceNumber: string): Promise { + const headers = new Headers({ Accept: "application/pdf" }); + + const accessToken = tokenStore.getAccessToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + const tenant = tokenStore.getTenant() ?? env.defaultTenant; + if (tenant) { + headers.set("tenant", tenant); + } + + const response = await fetch( + `${env.apiBase}/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/pdf`, + { headers }, + ); + + if (!response.ok) { + throw new ApiRequestError(response.status, `Failed to download invoice PDF (${response.status})`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `${invoiceNumber}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +export function generateInvoices(periodYear: number, periodMonth: number): Promise<{ generated: number }> { + return apiFetch<{ generated: number }>(`/api/v1/billing/invoices/generate`, { + method: "POST", + body: JSON.stringify({ periodYear, periodMonth }), + }); +} + +export function issueInvoice(invoiceId: string, dueAtUtc: string | null): Promise { + return apiFetch(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/issue`, { + method: "POST", + body: JSON.stringify({ dueAtUtc }), + }); +} + +export function markInvoicePaid(invoiceId: string): Promise { + return apiFetch(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/pay`, { + method: "POST", + }); +} + +export function voidInvoice(invoiceId: string, reason: string | null): Promise { + return apiFetch(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/void`, { + method: "POST", + body: JSON.stringify({ reason }), + }); +} diff --git a/clients/admin/src/api/files.ts b/clients/admin/src/api/files.ts new file mode 100644 index 0000000000..56bcfbb13f --- /dev/null +++ b/clients/admin/src/api/files.ts @@ -0,0 +1,139 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; + +// Mirrors FSH.Modules.Files.Domain.Visibility — the server serializes this +// enum as its string name (JsonStringEnumConverter) on the FileAssetDto and +// accepts the same string names on request bodies. +export const Visibility = { + Public: "Public", + Private: "Private", +} as const; +export type VisibilityValue = (typeof Visibility)[keyof typeof Visibility]; + +// Mirrors FSH.Modules.Files.Domain.FileAssetStatus — serialized as string name. +export const FileAssetStatus = { + PendingUpload: "PendingUpload", + Available: "Available", + Quarantined: "Quarantined", +} as const; +export type FileAssetStatusValue = (typeof FileAssetStatus)[keyof typeof FileAssetStatus]; + +// Mirrors FSH.Modules.Files.Contracts.v1.DTOs.FileAssetDto. +export type FileAssetDto = { + id: string; + ownerType: string; + ownerId?: string | null; + originalFileName: string; + contentType: string; + sizeBytes: number; + visibility: VisibilityValue; + status: FileAssetStatusValue; + scanStatus: number; + createdAtUtc: string; + publicUrl?: string | null; + /** The user that uploaded the file. Use with useUserDisplay to resolve a name. + * Older server versions before the field was added send "" — guard against it + * when deciding whether to render an "uploaded by" attribution row. */ + createdByUserId: string; + deletedOnUtc?: string | null; + deletedBy?: string | null; +}; + +export type PresignedUploadResponse = { + fileAssetId: string; + uploadUrl: string; + requiredHeaders: Record; + expiresAt: string; +}; + +export type PresignedDownloadResponse = { + url: string; + expiresAt: string; +}; + +export type RequestUploadUrlInput = { + ownerType: string; + ownerId?: string | null; + fileName: string; + contentType: string; + sizeBytes: number; + visibility: VisibilityValue; + category: string; +}; + +export function requestUploadUrl(input: RequestUploadUrlInput): Promise { + return apiFetch("/api/v1/files/upload-url", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function finalizeUpload(fileAssetId: string): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/finalize`, + { method: "POST" }, + ); +} + +export function getFileMetadata(fileAssetId: string): Promise { + return apiFetch(`/api/v1/files/${encodeURIComponent(fileAssetId)}`); +} + +export function getFileDownloadUrl( + fileAssetId: string, + options: { inline?: boolean } = {}, +): Promise { + const qs = options.inline ? "?inline=true" : ""; + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/url${qs}`, + ); +} + +export function listMyFiles(page = 1, pageSize = 20): Promise { + return apiFetch( + `/api/v1/files/mine?page=${page}&pageSize=${pageSize}`, + ); +} + +export function listSharedFiles(page = 1, pageSize = 20): Promise { + return apiFetch( + `/api/v1/files/shared?page=${page}&pageSize=${pageSize}`, + ); +} + +/** Flip a file's visibility. Server returns the refreshed DTO so the client can patch + * its preview/list without a follow-up GET. */ +export function changeFileVisibility( + fileAssetId: string, + visibility: VisibilityValue, +): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/visibility`, + { + method: "PATCH", + body: JSON.stringify({ visibility }), + }, + ); +} + +export function deleteFile(fileAssetId: string): Promise { + return apiFetch(`/api/v1/files/${encodeURIComponent(fileAssetId)}`, { + method: "DELETE", + }); +} + +export function listTrashedFiles( + pageNumber = 1, + pageSize = 20, +): Promise> { + return apiFetch>( + `/api/v1/files/trash?pageNumber=${pageNumber}&pageSize=${pageSize}`, + ); +} + +export function restoreFile(fileAssetId: string): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/restore`, + { method: "POST" }, + ); +} diff --git a/clients/admin/src/api/health.ts b/clients/admin/src/api/health.ts new file mode 100644 index 0000000000..a5c295a6b3 --- /dev/null +++ b/clients/admin/src/api/health.ts @@ -0,0 +1,63 @@ +import { env } from "@/env"; + +export type HealthStatus = "Healthy" | "Degraded" | "Unhealthy" | string; + +export type HealthEntry = { + name: string; + status: HealthStatus; + description?: string | null; + durationMs: number; + details?: Record | null; +}; + +export type HealthResult = { + status: HealthStatus; + results: HealthEntry[]; +}; + +/** + * Health probes are anonymous — bypass the apiClient so we don't drag the + * tenant header / auth token into a public endpoint, and so we can read + * the body on a 503 (apiClient would throw before parsing). + */ +async function fetchHealth(path: string, timeoutMs = 8_000): Promise { + const url = `${env.apiBase}${path}`; + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + headers: { Accept: "application/json" }, + }); + + // /health/live returns 200; /health/ready returns 200 OR 503 with body. + if (!response.ok && response.status !== 503) { + return { + status: "Unhealthy", + results: [ + { + name: "probe", + status: "Unhealthy", + description: `Probe failed: ${response.status} ${response.statusText}`, + durationMs: 0, + }, + ], + }; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("json")) { + return { + status: response.ok ? "Healthy" : "Unhealthy", + results: [], + }; + } + + return (await response.json()) as HealthResult; +} + +export function getLiveness(): Promise { + return fetchHealth("/health/live"); +} + +export function getReadiness(): Promise { + return fetchHealth("/health/ready"); +} diff --git a/clients/admin/src/api/impersonation-grants.ts b/clients/admin/src/api/impersonation-grants.ts new file mode 100644 index 0000000000..588973b55b --- /dev/null +++ b/clients/admin/src/api/impersonation-grants.ts @@ -0,0 +1,56 @@ +import { apiFetch } from "@/lib/api-client"; + +export type ImpersonationGrantStatus = "Active" | "Ended" | "Revoked" | "Expired"; + +export type ImpersonationGrantDto = { + id: string; + jti: string; + actorUserId: string; + actorUserName?: string | null; + actorTenantId: string; + impersonatedUserId: string; + impersonatedUserName?: string | null; + impersonatedTenantId: string; + reason: string; + startedAtUtc: string; + expiresAtUtc: string; + endedAtUtc?: string | null; + revokedAtUtc?: string | null; + revokedByUserId?: string | null; + revokedByUserName?: string | null; + revokeReason?: string | null; + status: ImpersonationGrantStatus; +}; + +export type ListGrantsParams = { + status?: ImpersonationGrantStatus; + impersonatedTenantId?: string; + actorUserId?: string; + take?: number; +}; + +export async function listImpersonationGrants( + params: ListGrantsParams = {}, +): Promise { + const q = new URLSearchParams(); + if (params.status) q.set("Status", params.status); + if (params.impersonatedTenantId) q.set("ImpersonatedTenantId", params.impersonatedTenantId); + if (params.actorUserId) q.set("ActorUserId", params.actorUserId); + q.set("Take", String(params.take ?? 100)); + return apiFetch( + `/api/v1/identity/impersonation/grants?${q.toString()}`, + ); +} + +export async function revokeImpersonationGrant( + id: string, + reason?: string, +): Promise { + return apiFetch( + `/api/v1/identity/impersonation/grants/${encodeURIComponent(id)}/revoke`, + { + method: "POST", + body: JSON.stringify({ reason: reason ?? null }), + }, + ); +} diff --git a/clients/admin/src/api/impersonation.ts b/clients/admin/src/api/impersonation.ts new file mode 100644 index 0000000000..ca6d04b5cd --- /dev/null +++ b/clients/admin/src/api/impersonation.ts @@ -0,0 +1,64 @@ +import { apiFetch } from "@/lib/api-client"; + +export type StartImpersonationInput = { + targetUserId: string; + targetTenantId: string; + reason: string; + /** 1..60 inclusive; null lets the server use its configured default. */ + durationMinutes?: number; +}; + +export type ImpersonationResponse = { + accessToken: string; + accessTokenExpiresAt: string; + actorUserId: string; + actorTenantId: string; + impersonatedUserId: string; + impersonatedTenantId: string; +}; + +/** + * Issues a short-lived impersonation access token representing the target + * user. The admin app never installs this token locally — it hands it off + * to the dashboard via a URL hash so the dashboard can swap into the + * impersonated session in a fresh tab. + * + * Note: the admin's apiFetch attaches the operator's current tenant header + * by default, which the server uses for the cross-tenant authorization + * check (root operators may impersonate any tenant; tenant admins only + * their own). We do NOT override the tenant header here for that reason. + * + * --- + * Why there is no `endImpersonation()` in this file (compare dashboard): + * + * The admin never holds an impersonation session — the impersonation + * token lives in the dashboard tab the operator opened with the hash + * handoff. So "ending impersonation" from the admin's perspective is + * actually server-side REVOCATION of the grant, not a session swap. + * That's covered by: + * + * POST /api/v1/identity/impersonation/grants/{id}/revoke + * + * which is wired via `revokeImpersonationGrant` in impersonation-grants.ts + * and rendered on the /impersonation page + the tenant-detail inline + * active-grants card. After revoke, the JWT validation hook short-circuits + * any further requests carrying that impersonation token via the + * HybridCache-backed revocation lookup — the dashboard tab effectively + * loses the session on its next API call. + * + * If the admin ever installs impersonation tokens locally (e.g. an + * in-place "view as user" mode), this file should pick up the + * `endImpersonation()` call from dashboard's identity.ts and wire it to + * a "Stop impersonating" button in the topbar. + */ +export function startImpersonation(input: StartImpersonationInput): Promise { + return apiFetch(`/api/v1/identity/impersonation/start`, { + method: "POST", + body: JSON.stringify({ + targetUserId: input.targetUserId, + targetTenantId: input.targetTenantId, + reason: input.reason, + durationMinutes: input.durationMinutes ?? null, + }), + }); +} diff --git a/clients/admin/src/api/notifications.ts b/clients/admin/src/api/notifications.ts new file mode 100644 index 0000000000..7d14be0d1f --- /dev/null +++ b/clients/admin/src/api/notifications.ts @@ -0,0 +1,36 @@ +import { apiFetch } from "@/lib/api-client"; + +export type NotificationDto = { + id: string; + type: string; + title: string; + body?: string | null; + link?: string | null; + source: string; + metadataJson: string; + readAtUtc?: string | null; + createdAtUtc: string; +}; + +const ROOT = "/api/v1/notifications"; + +export function listNotifications(params: { unreadOnly?: boolean; page?: number; pageSize?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.unreadOnly) qs.set("unreadOnly", "true"); + if (params.page) qs.set("page", String(params.page)); + if (params.pageSize) qs.set("pageSize", String(params.pageSize)); + const q = qs.toString(); + return apiFetch(`${ROOT}/${q ? `?${q}` : ""}`); +} + +export function getUnreadCount(): Promise { + return apiFetch(`${ROOT}/unread-count`); +} + +export function markNotificationRead(notificationId: string): Promise { + return apiFetch(`${ROOT}/${encodeURIComponent(notificationId)}/read`, { method: "POST" }); +} + +export function markAllNotificationsRead(): Promise<{ updated: number }> { + return apiFetch<{ updated: number }>(`${ROOT}/read-all`, { method: "POST" }); +} diff --git a/clients/admin/src/api/roles.ts b/clients/admin/src/api/roles.ts index 7618ba237e..d7d0e5b3cf 100644 --- a/clients/admin/src/api/roles.ts +++ b/clients/admin/src/api/roles.ts @@ -7,6 +7,58 @@ export type RoleDto = { permissions?: string[] | null; }; +export type UpsertRoleInput = { + /** Pass empty string to create a new role; existing GUID to update. */ + id: string; + name: string; + description?: string | null; +}; + +export type UpdateRolePermissionsInput = { + roleId: string; + permissions: string[]; +}; + +const ROOT = "/api/v1/identity"; + export async function listRoles(): Promise { - return apiFetch(`/api/v1/identity/roles`); + // The endpoint is paged (`PagedResponse` → `{ items, … }`), but every + // caller here wants the flat list. Unwrap defensively so a bare array still works. + const result = await apiFetch(`${ROOT}/roles`); + if (Array.isArray(result)) return result; + return result.items ?? []; +} + +export function getRole(id: string): Promise { + return apiFetch(`${ROOT}/roles/${encodeURIComponent(id)}`); +} + +export function getRoleWithPermissions(id: string): Promise { + // Note: this endpoint is mapped at `/{id:guid}/permissions` under the + // identity group, NOT under `/roles/`. Server-side asymmetry preserved. + return apiFetch(`${ROOT}/${encodeURIComponent(id)}/permissions`); +} + +export function upsertRole(input: UpsertRoleInput): Promise { + return apiFetch(`${ROOT}/roles`, { + method: "POST", + body: JSON.stringify({ + id: input.id, + name: input.name, + description: input.description ?? null, + }), + }); +} + +export function deleteRole(id: string): Promise { + return apiFetch(`${ROOT}/roles/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function updateRolePermissions(input: UpdateRolePermissionsInput): Promise { + return apiFetch(`${ROOT}/${encodeURIComponent(input.roleId)}/permissions`, { + method: "PUT", + body: JSON.stringify({ roleId: input.roleId, permissions: input.permissions }), + }); } diff --git a/clients/admin/src/api/sessions.ts b/clients/admin/src/api/sessions.ts new file mode 100644 index 0000000000..e70c4c8a98 --- /dev/null +++ b/clients/admin/src/api/sessions.ts @@ -0,0 +1,56 @@ +import { apiFetch } from "@/lib/api-client"; + +export type UserSessionDto = { + id: string; + userId?: string | null; + userName?: string | null; + userEmail?: string | null; + ipAddress?: string | null; + deviceType?: string | null; + browser?: string | null; + browserVersion?: string | null; + operatingSystem?: string | null; + osVersion?: string | null; + createdAt: string; + lastActivityAt: string; + expiresAt: string; + isActive: boolean; + isCurrentSession: boolean; +}; + +const ROOT = "/api/v1/identity"; + +export async function getMySessions(): Promise { + return apiFetch(`${ROOT}/sessions/me`); +} + +export async function revokeMySession(sessionId: string): Promise { + await apiFetch(`${ROOT}/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }); +} + +export async function revokeAllMySessions(): Promise<{ revokedCount: number }> { + return apiFetch<{ revokedCount: number }>(`${ROOT}/sessions/revoke-all`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function getUserSessions(userId: string): Promise { + return apiFetch(`${ROOT}/users/${encodeURIComponent(userId)}/sessions`); +} + +export async function adminRevokeUserSession(userId: string, sessionId: string): Promise { + await apiFetch( + `${ROOT}/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ); +} + +export async function adminRevokeAllUserSessions(userId: string): Promise<{ revokedCount: number }> { + return apiFetch<{ revokedCount: number }>( + `${ROOT}/users/${encodeURIComponent(userId)}/sessions/revoke-all`, + { method: "POST", body: JSON.stringify({}) }, + ); +} diff --git a/clients/admin/src/api/tenants.ts b/clients/admin/src/api/tenants.ts index 95f04cda27..bce9ca0042 100644 --- a/clients/admin/src/api/tenants.ts +++ b/clients/admin/src/api/tenants.ts @@ -3,6 +3,8 @@ import type { PagedResponse } from "@/lib/api-types"; export type { PagedResponse } from "@/lib/api-types"; +export type TenantExpiryState = "Active" | "InGrace" | "Expired" | (string & {}); + export type TenantDto = { id: string; name: string; @@ -10,6 +12,10 @@ export type TenantDto = { isActive: boolean; validUpto: string; issuer?: string; + /** Present on the status endpoint (TenantStatusDto); absent on the list projection. */ + plan?: string | null; + expiryState?: TenantExpiryState; + graceEndsUtc?: string; }; export type ListTenantsParams = { @@ -22,8 +28,23 @@ export type CreateTenantInput = { id: string; name: string; adminEmail: string; + adminPassword: string; issuer: string; connectionString?: string | null; + /** Plan key to subscribe the tenant to. Omitted → server falls back to the default/trial plan. */ + planKey?: string | null; +}; + +export type RenewTenantResponse = { + tenantId: string; + validUpto: string; + planKey: string; + planChanged: boolean; +}; + +export type AdjustTenantValidityResponse = { + tenantId: string; + validUpto: string; }; export type CreateTenantResponse = { @@ -80,12 +101,34 @@ export async function createTenant(input: CreateTenantInput): Promise { + return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/renew`, { + method: "POST", + body: JSON.stringify({ tenantId: id, planKey: planKey ?? null }), + }); +} + +/** + * Operator override: set a tenant's ValidUpto directly with NO invoice + * (comp/correction). Backdating is allowed server-side. Root-operator only — + * gated by MultitenancyPermissions.Tenants.UpgradeSubscription, same as renew. + */ +export async function adjustTenantValidity(id: string, validUpto: string): Promise { + return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/adjust-validity`, { + method: "POST", + body: JSON.stringify({ tenantId: id, validUpto }), + }); +} + export async function changeTenantActivation(id: string, isActive: boolean): Promise { return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/activation`, { method: "POST", @@ -98,3 +141,106 @@ export async function retryTenantProvisioning(id: string): Promise` to operate on a different tenant. The server's +// root-operator override middleware permits this for root callers. +// ───────────────────────────────────────────────────────────────────────── + +export type PaletteDto = { + primary: string; + secondary: string; + tertiary: string; + background: string; + surface: string; + error: string; + warning: string; + success: string; + info: string; +}; + +export type BrandAssetsDto = { + logoUrl?: string | null; + logoDarkUrl?: string | null; + faviconUrl?: string | null; + deleteLogo?: boolean; + deleteLogoDark?: boolean; + deleteFavicon?: boolean; +}; + +export type TypographyDto = { + fontFamily: string; + headingFontFamily: string; + fontSizeBase: number; + lineHeightBase: number; +}; + +export type LayoutDto = { + borderRadius: string; + defaultElevation: number; +}; + +export type TenantThemeDto = { + lightPalette: PaletteDto; + darkPalette: PaletteDto; + brandAssets: BrandAssetsDto; + typography: TypographyDto; + layout: LayoutDto; + isDefault: boolean; +}; + +export const DEFAULT_LIGHT_PALETTE: PaletteDto = { + primary: "#2563EB", + secondary: "#0F172A", + tertiary: "#6366F1", + background: "#F8FAFC", + surface: "#FFFFFF", + error: "#DC2626", + warning: "#F59E0B", + success: "#16A34A", + info: "#0284C7", +}; + +export const DEFAULT_DARK_PALETTE: PaletteDto = { + primary: "#38BDF8", + secondary: "#94A3B8", + tertiary: "#818CF8", + background: "#0B1220", + surface: "#111827", + error: "#F87171", + warning: "#FBBF24", + success: "#22C55E", + info: "#38BDF8", +}; + +/** Fetch a tenant's theme. Caller needs MultitenancyPermissions.Tenants.ViewTheme. */ +export async function getTenantTheme(tenantId: string): Promise { + return apiFetch(`/api/v1/tenants/theme`, { + headers: { tenant: tenantId }, + }); +} + +/** Save a tenant's theme. Caller needs MultitenancyPermissions.Tenants.UpdateTheme. */ +export async function updateTenantTheme( + tenantId: string, + theme: TenantThemeDto, +): Promise { + await apiFetch(`/api/v1/tenants/theme`, { + method: "PUT", + headers: { tenant: tenantId }, + body: JSON.stringify(theme), + }); +} + +/** Reset a tenant's theme to framework defaults. */ +export async function resetTenantTheme(tenantId: string): Promise { + await apiFetch(`/api/v1/tenants/theme/reset`, { + method: "POST", + headers: { tenant: tenantId }, + }); +} diff --git a/clients/admin/src/api/two-factor.ts b/clients/admin/src/api/two-factor.ts new file mode 100644 index 0000000000..6110d65546 --- /dev/null +++ b/clients/admin/src/api/two-factor.ts @@ -0,0 +1,31 @@ +import { apiFetch } from "@/lib/api-client"; + +export type TwoFactorEnrollmentResponse = { + sharedKey: string; + authenticatorUri: string; +}; + +const ROOT = "/api/v1/identity"; + +/** + * Begin (or rotate) TOTP enrollment. The user has NOT yet enabled 2FA until + * they confirm with a code via verifyEnrollTwoFactor — this just hands back + * the secret + otpauth:// URI so the QR can render. + */ +export async function enrollTwoFactor(): Promise { + return apiFetch(`${ROOT}/2fa/enroll`, { method: "POST" }); +} + +export async function verifyEnrollTwoFactor(code: string): Promise<{ success: boolean }> { + return apiFetch<{ success: boolean }>(`${ROOT}/2fa/verify`, { + method: "POST", + body: JSON.stringify({ code }), + }); +} + +export async function disableTwoFactor(currentPassword: string): Promise<{ success: boolean }> { + return apiFetch<{ success: boolean }>(`${ROOT}/2fa/disable`, { + method: "POST", + body: JSON.stringify({ currentPassword }), + }); +} diff --git a/clients/admin/src/api/users.ts b/clients/admin/src/api/users.ts index f7ea9d097c..ff0706f22c 100644 --- a/clients/admin/src/api/users.ts +++ b/clients/admin/src/api/users.ts @@ -11,6 +11,7 @@ export type UserDto = { emailConfirmed: boolean; phoneNumber?: string | null; imageUrl?: string | null; + twoFactorEnabled?: boolean; }; export type UserRoleDto = { @@ -28,6 +29,12 @@ export type SearchUsersParams = { isActive?: boolean; emailConfirmed?: boolean; roleId?: string; + /** + * When set, sends a `tenant` header overriding the operator's active tenant + * for this request only. Used by impersonation flows so a root operator can + * browse another tenant's users without flipping their global session. + */ + tenantId?: string; }; export type RegisterUserInput = { @@ -46,6 +53,39 @@ export type RegisterUserResponse = { }; const BASE = "/api/v1/identity/users"; +const IDENTITY = "/api/v1/identity"; + +/** + * Returns the permission strings the current user holds. The JWT only carries + * role names — permissions are resolved server-side per role on this endpoint, + * so client-side route guards must call it after login (and after a refresh + * if grants may have changed). + */ +export async function getMyPermissions(): Promise { + return (await apiFetch(`${IDENTITY}/permissions`)) ?? []; +} + +export async function getMyProfile(): Promise { + return apiFetch(`${IDENTITY}/profile`); +} + +export async function setProfileImage(imageUrl: string | null): Promise { + await apiFetch(`${IDENTITY}/profile/image`, { + method: "PUT", + body: JSON.stringify({ imageUrl }), + }); +} + +export async function changePassword(input: { + password: string; + newPassword: string; + confirmNewPassword: string; +}): Promise { + return apiFetch(`${BASE}/change-password`, { + method: "POST", + body: JSON.stringify(input), + }); +} export async function searchUsers(params: SearchUsersParams = {}): Promise> { const q = new URLSearchParams(); @@ -56,7 +96,9 @@ export async function searchUsers(params: SearchUsersParams = {}): Promise>(`${BASE}/search?${q.toString()}`); + return apiFetch>(`${BASE}/search?${q.toString()}`, { + headers: params.tenantId ? { tenant: params.tenantId } : undefined, + }); } export async function getUser(id: string): Promise { @@ -87,3 +129,70 @@ export async function assignUserRoles(id: string, roles: UserRoleDto[]): Promise body: JSON.stringify({ userId: id, userRoles: roles }), }); } + +// ----------------------------- +// Anonymous password-reset trio +// forgot-password → reset-password → confirm-email +// ----------------------------- + +/** + * Step 1 of the forgot-password flow. Server resolves the user by + * (email, tenant), generates a reset token, and emails them the link. + * Server always returns 200 regardless of whether the email exists — + * never leak account presence to the UI. + */ +export async function requestPasswordReset(input: { + email: string; + tenant: string; +}): Promise { + await apiFetch(`${IDENTITY}/forgot-password`, { + method: "POST", + skipAuth: true, + headers: { tenant: input.tenant }, + body: JSON.stringify({ email: input.email }), + }); +} + +/** + * Step 2 — caller carries (token, email, tenant) from the emailed link + * plus a new password from the form. Existing JWTs stay valid until + * natural expiry; the UI should bounce to /login after success. + */ +export async function resetPassword(input: { + email: string; + password: string; + token: string; + tenant: string; +}): Promise { + await apiFetch(`${IDENTITY}/reset-password`, { + method: "POST", + skipAuth: true, + headers: { tenant: input.tenant }, + body: JSON.stringify({ + email: input.email, + password: input.password, + token: input.token, + }), + }); +} + +/** + * Confirm-email link landing. Server expects (userId, code, tenant) as + * query parameters from the registration email. + */ +export async function confirmEmail(input: { + userId: string; + code: string; + tenant: string; +}): Promise { + const qs = new URLSearchParams({ + userId: input.userId, + code: input.code, + tenant: input.tenant, + }).toString(); + return apiFetch(`${IDENTITY}/confirm-email?${qs}`, { + method: "GET", + skipAuth: true, + headers: { tenant: input.tenant }, + }); +} diff --git a/clients/admin/src/api/webhooks.ts b/clients/admin/src/api/webhooks.ts new file mode 100644 index 0000000000..f6d631b6d5 --- /dev/null +++ b/clients/admin/src/api/webhooks.ts @@ -0,0 +1,95 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; + +export type WebhookSubscriptionDto = { + id: string; + url: string; + events: string[]; + isActive: boolean; + createdAtUtc: string; +}; + +export type WebhookDeliveryDto = { + id: string; + subscriptionId: string; + eventType: string; + httpStatusCode: number; + success: boolean; + attemptCount: number; + attemptedAtUtc: string; + errorMessage?: string | null; +}; + +export type CreateWebhookSubscriptionInput = { + url: string; + events: string[]; + secret?: string; +}; + +const ROOT = "/api/v1/webhooks"; + +export function listWebhookSubscriptions( + pageNumber = 1, + pageSize = 50, +): Promise> { + const q = new URLSearchParams({ + pageNumber: String(pageNumber), + pageSize: String(pageSize), + }); + return apiFetch>(`${ROOT}/subscriptions?${q.toString()}`); +} + +export function createWebhookSubscription(input: CreateWebhookSubscriptionInput): Promise { + return apiFetch(`${ROOT}/subscriptions`, { + method: "POST", + body: JSON.stringify({ + url: input.url, + events: input.events, + secret: input.secret?.trim() ? input.secret : null, + }), + }); +} + +export function deleteWebhookSubscription(id: string): Promise { + return apiFetch(`${ROOT}/subscriptions/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function testWebhookSubscription(id: string): Promise<{ success: boolean }> { + return apiFetch<{ success: boolean }>( + `${ROOT}/subscriptions/${encodeURIComponent(id)}/test`, + { method: "POST" }, + ); +} + +export function listWebhookDeliveries( + subscriptionId: string, + pageNumber = 1, + pageSize = 50, +): Promise> { + const q = new URLSearchParams({ + pageNumber: String(pageNumber), + pageSize: String(pageSize), + }); + return apiFetch>( + `${ROOT}/subscriptions/${encodeURIComponent(subscriptionId)}/deliveries?${q.toString()}`, + ); +} + +/** + * Curated list of event names commonly emitted by FSH modules. Webhook + * subscriptions accept arbitrary strings — these just power the chip + * picker in the create dialog so operators don't have to remember the + * canonical kebab-case names. + */ +export const SUGGESTED_EVENT_TYPES: readonly string[] = [ + "tenant.created", + "tenant.activation.changed", + "user.registered", + "user.role.assigned", + "billing.invoice.issued", + "billing.invoice.paid", + "billing.subscription.created", + "billing.subscription.cancelled", +]; diff --git a/clients/admin/src/auth/auth-context.tsx b/clients/admin/src/auth/auth-context.tsx index 3f32f4d22b..30738f2632 100644 --- a/clients/admin/src/auth/auth-context.tsx +++ b/clients/admin/src/auth/auth-context.tsx @@ -1,8 +1,10 @@ -import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { tokenStore } from "@/auth/token-store"; -import { decodeJwt, type JwtClaims } from "@/auth/jwt"; +import { decodeJwt, isTokenExpired, type JwtClaims } from "@/auth/jwt"; import { issueToken } from "@/auth/api"; +import { refreshAccessToken } from "@/lib/api-client"; +import { getMyPermissions } from "@/api/users"; export type AuthUser = { id: string; @@ -15,19 +17,31 @@ export type AuthUser = { export type AuthContextValue = { user: AuthUser | null; isAuthenticated: boolean; + /** + * True while the provider resolves a stored session at boot — the access + * token was missing/expired but a refresh token was present, so a silent + * refresh is in flight. Routes render a loader (not the page, not a redirect) + * while this is true, so a stale token never flashes a protected surface. + */ + isInitializing: boolean; + /** + * True once permissions have been fetched at least once for the current + * user (or no user is signed in). Route guards check this before rendering + * a 403 — without it, the first paint flashes "access denied" while the + * permissions request is still in flight. + */ + permissionsHydrated: boolean; login: (input: { email: string; password: string; tenant: string }) => Promise; logout: () => void; + /** Re-fetch the permission set for the signed-in user. Call after a role + * assignment changes for the current user. */ + refreshPermissions: () => Promise; }; export const AuthContext = createContext(null); -function claimsToUser(claims: JwtClaims | null): AuthUser | null { +function claimsToUser(claims: JwtClaims | null, permissions: string[]): AuthUser | null { if (!claims?.sub) return null; - const permissions = Array.isArray(claims.permissions) - ? claims.permissions - : typeof claims.permissions === "string" - ? [claims.permissions] - : []; return { id: claims.sub, email: claims.email, @@ -37,21 +51,104 @@ function claimsToUser(claims: JwtClaims | null): AuthUser | null { }; } +// Read the stored session, treating an EXPIRED access token as "not usable +// yet": the boot effect attempts a silent refresh before we trust it. A +// decodable-but-expired token must not flip the app to authenticated, or it +// renders protected surfaces that 401 in a loop instead of refreshing. +function readStoredSession(): { claims: JwtClaims | null; usable: boolean } { + const claims = decodeJwt(tokenStore.getAccessToken()); + return { claims, usable: claims !== null && !isTokenExpired(claims) }; +} + export function AuthProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); - const [user, setUser] = useState(() => - claimsToUser(decodeJwt(tokenStore.getAccessToken())), + const [user, setUser] = useState(() => { + const { claims, usable } = readStoredSession(); + return usable ? claimsToUser(claims, tokenStore.getPermissions()) : null; + }); + // When the stored access token is missing/expired but a refresh token is + // present, attempt one silent refresh at boot before rendering — keeps + // long-lived sessions alive AND stops a stale token from flashing a doomed + // protected surface that fires 401-ing requests. + const [isInitializing, setIsInitializing] = useState( + () => !readStoredSession().usable && tokenStore.getRefreshToken() !== null, ); + // Cold-start: if we already have a cached permissions list, treat as hydrated + // so route guards don't flash 403. Otherwise, wait for the effect. + const [permissionsHydrated, setPermissionsHydrated] = useState(() => { + if (!tokenStore.getAccessToken()) return true; + return tokenStore.getPermissions().length > 0; + }); + const lastHydratedSubject = useRef(user?.id ?? null); + + useEffect(() => { + if (!isInitializing) return; + let cancelled = false; + void (async () => { + try { + await refreshAccessToken(); + } catch { + // Refresh token dead (expired, revoked, or DB reseeded) — drop the + // stale session so routing falls through to /login cleanly. + tokenStore.clear(); + } finally { + if (!cancelled) setIsInitializing(false); + } + })(); + return () => { + cancelled = true; + }; + // Boot-only: isInitializing only ever flips false, never back on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Hydrate (or re-hydrate) the permissions list from the server whenever the + // signed-in subject changes — covers cold-start, login, and account swap. + useEffect(() => { + if (!user) { + lastHydratedSubject.current = null; + setPermissionsHydrated(true); + return; + } + if (lastHydratedSubject.current === user.id && permissionsHydrated) { + return; + } + lastHydratedSubject.current = user.id; + let cancelled = false; + void (async () => { + try { + const perms = await getMyPermissions(); + if (cancelled) return; + tokenStore.setPermissions(perms); + // setPermissions emits, the subscribe listener will rebuild `user` + // with the new list. + setPermissionsHydrated(true); + } catch { + // Permissions fetch failure shouldn't sign the user out — the route + // guards will treat them as zero-permission until the next refresh. + if (!cancelled) setPermissionsHydrated(true); + } + })(); + return () => { + cancelled = true; + }; + }, [user, permissionsHydrated]); useEffect(() => { return tokenStore.subscribe(() => { - setUser(claimsToUser(decodeJwt(tokenStore.getAccessToken()))); + const next = claimsToUser(decodeJwt(tokenStore.getAccessToken()), tokenStore.getPermissions()); + setUser(next); }); }, []); const login = useCallback( async (input: { email: string; password: string; tenant: string }) => { tokenStore.setTenant(input.tenant); + // Stale permissions from a previous user must not leak into the new + // session — clear before issuing the token so the hydration effect + // re-fetches from scratch. + tokenStore.setPermissions([]); + setPermissionsHydrated(false); const tokens = await issueToken(input); tokenStore.setTokens(tokens.accessToken, tokens.refreshToken); }, @@ -63,14 +160,26 @@ export function AuthProvider({ children }: { children: ReactNode }) { queryClient.clear(); }, [queryClient]); + const refreshPermissions = useCallback(async () => { + try { + const perms = await getMyPermissions(); + tokenStore.setPermissions(perms); + } catch { + /* swallow — see hydration effect */ + } + }, []); + const value = useMemo( () => ({ user, isAuthenticated: user !== null, + isInitializing, + permissionsHydrated, login, logout, + refreshPermissions, }), - [user, login, logout], + [user, isInitializing, permissionsHydrated, login, logout, refreshPermissions], ); return {children}; diff --git a/clients/admin/src/auth/jwt.ts b/clients/admin/src/auth/jwt.ts index f6d81770e0..4390ba4a3d 100644 --- a/clients/admin/src/auth/jwt.ts +++ b/clients/admin/src/auth/jwt.ts @@ -21,3 +21,18 @@ export function decodeJwt(token: string | null | undefined): JwtClaims | null { return null; } } + +/** + * True when the token is expired, or within `skewMs` of expiring (so we refresh + * proactively rather than send a request the server will reject for an + * about-to-die token). A token with no `exp` claim is treated as non-expiring. + * + * Used at boot to decide whether a stored access token still represents a + * usable session: a decodable-but-expired token must NOT count as "signed in", + * or the app renders protected surfaces that 401 in a loop instead of + * refreshing or routing to /login. + */ +export function isTokenExpired(claims: JwtClaims | null, skewMs = 10_000): boolean { + if (!claims || typeof claims.exp !== "number") return false; + return claims.exp * 1000 <= Date.now() + skewMs; +} diff --git a/clients/admin/src/auth/protected-route.tsx b/clients/admin/src/auth/protected-route.tsx index 5c589fe8fd..1ebe746496 100644 --- a/clients/admin/src/auth/protected-route.tsx +++ b/clients/admin/src/auth/protected-route.tsx @@ -12,9 +12,28 @@ type ProtectedRouteProps = { }; export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) { - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated, isInitializing, user } = useAuth(); const location = useLocation(); + // Resolving a stored session (silent token refresh) — hold rendering so we + // neither flash a protected surface with a stale/expired token nor bounce to + // /login before the refresh has had a chance to restore the session. + if (isInitializing) { + return ( +

+ Restoring your session… + +
+ ); + } + if (!isAuthenticated) { return ; } diff --git a/clients/admin/src/auth/route-guard.tsx b/clients/admin/src/auth/route-guard.tsx new file mode 100644 index 0000000000..a801d9f484 --- /dev/null +++ b/clients/admin/src/auth/route-guard.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from "react"; +import { useAuth } from "@/auth/use-auth"; +import { ForbiddenView } from "@/components/forbidden-view"; + +type RouteGuardProps = { + /** + * Permission strings the current user must hold to view the wrapped + * content. Missing any one renders ForbiddenView instead of the route. + * Use this on the route's `element` to gate by permission per-page — + * ProtectedRoute itself only handles the auth-vs-anonymous question. + */ + perms: readonly string[]; + children: ReactNode; +}; + +/** + * RouteGuard — per-route permission wrapper. Layered inside ProtectedRoute, + * not as a replacement: ProtectedRoute decides "are you signed in?", this + * decides "do you hold these specific permissions for this surface?". + * + * Note: the JWT only carries role names; permissions are resolved server-side + * per role and fetched into AuthContext after sign-in. While that fetch is in + * flight (permissionsHydrated=false), we render a quiet loading slug instead + * of 403 to avoid a flash of "access denied" on first paint. + */ +export function RouteGuard({ perms, children }: RouteGuardProps) { + const { user, permissionsHydrated } = useAuth(); + + if (!permissionsHydrated) { + return ( +
+ Resolving permissions + +
+ ); + } + + const granted = user?.permissions ?? []; + const missing = perms.filter((p) => !granted.includes(p)); + + if (missing.length > 0) { + return ; + } + + return <>{children}; +} diff --git a/clients/admin/src/auth/token-store.ts b/clients/admin/src/auth/token-store.ts index c6c3275e0b..8c36ae061f 100644 --- a/clients/admin/src/auth/token-store.ts +++ b/clients/admin/src/auth/token-store.ts @@ -1,6 +1,7 @@ const ACCESS_KEY = "fsh.admin.accessToken"; const REFRESH_KEY = "fsh.admin.refreshToken"; const TENANT_KEY = "fsh.admin.tenant"; +const PERMS_KEY = "fsh.admin.permissions"; type Listener = () => void; @@ -15,6 +16,27 @@ export const tokenStore = { getRefreshToken: () => localStorage.getItem(REFRESH_KEY), getTenant: () => localStorage.getItem(TENANT_KEY), + /** + * Permissions are fetched separately from the JWT (the token only carries + * role names — see GetCurrentUserPermissionsEndpoint server-side). Cached + * here so route guards can read them synchronously; refreshed on each login. + */ + getPermissions(): string[] { + try { + const raw = localStorage.getItem(PERMS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === "string") : []; + } catch { + return []; + } + }, + + setPermissions(permissions: string[]) { + localStorage.setItem(PERMS_KEY, JSON.stringify(permissions)); + emit(); + }, + setTokens(accessToken: string, refreshToken: string) { localStorage.setItem(ACCESS_KEY, accessToken); localStorage.setItem(REFRESH_KEY, refreshToken); @@ -29,6 +51,7 @@ export const tokenStore = { clear() { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(PERMS_KEY); emit(); }, diff --git a/clients/admin/src/components/auth/auth-shell.tsx b/clients/admin/src/components/auth/auth-shell.tsx new file mode 100644 index 0000000000..68981ec30c --- /dev/null +++ b/clients/admin/src/components/auth/auth-shell.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { BrandMarkXL } from "@/components/brand-mark"; +import { cn } from "@/lib/cn"; + +// ──────────────────────────────────────────────────────────────────────── +// AuthShell — shared chrome for unauthenticated admin surfaces +// (login already inlines its own variant; this is for forgot-password, +// reset-password, confirm-email). +// +// Mirrors login.tsx's editorial split-screen aesthetic: +// left pane (lg+): brand stage — canvas-mesh, chartreuse vignette, +// corner ticks, BrandMarkXL hero monogram. +// right pane: focused form column with the // SECTION-RULE chip. +// +// The brand stage stays consistent across all auth pages so the operator +// always knows they're on the FSH Console surface. The right pane carries +// the page-specific content. +// ──────────────────────────────────────────────────────────────────────── + +function CornerTicks() { + const TICK = "h-3 w-3 border-[var(--color-accent-signal)]"; + return ( + <> + + + + + + ); +} + +export function AuthShell({ + crumbLeft, + crumbRight, + blurb, + children, +}: { + /** Section-rule left crumb, e.g. "// RECOVER ACCOUNT" */ + crumbLeft: string; + /** Section-rule right crumb (muted), e.g. "issue reset token" */ + crumbRight: string; + /** One-line description under the section-rule. */ + blurb: ReactNode; + /** Form area below the blurb. */ + children: ReactNode; +}) { + return ( +
+ {/* ─── Left pane — brand stage ───────────────────────────────── */} + + + {/* ─── Right pane — page content ─────────────────────────────── */} +
+
+ +
+ {/* Mobile-only brand (lg+ uses the left pane). */} +
+ +
+ +
+
+ {crumbLeft} + {crumbRight} +
+

{blurb}

+
+ + {children} +
+
+
+ ); +} diff --git a/clients/admin/src/components/auth/demo-accounts-dialog.tsx b/clients/admin/src/components/auth/demo-accounts-dialog.tsx new file mode 100644 index 0000000000..e0f8b25c84 --- /dev/null +++ b/clients/admin/src/components/auth/demo-accounts-dialog.tsx @@ -0,0 +1,173 @@ +import { useEffect } from "react"; +import { ArrowUpRight, ShieldCheck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { ADMIN_DEMO_ACCOUNTS, type DemoAccount } from "@/pages/login.demo-accounts"; + +// ──────────────────────────────────────────────────────────────────────── +// DemoAccountsDialog — dev-only demo account picker for the admin app. +// +// Admin surfaces a single root/superadmin account (vs. the dashboard's +// multi-tenant tenant-picker). The layout is a single-pane account list +// rather than the dashboard's two-pane tenant-rail, since there's only +// one operator tenant. Tapping an account signs in instantly and closes +// the dialog. Gating (import.meta.env.DEV) is the caller's responsibility. +// ──────────────────────────────────────────────────────────────────────── + +interface DemoAccountsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Fired when an account row is tapped — caller signs in with these creds. */ + onPick: (account: DemoAccount) => void; +} + +export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) { + // Nothing to reset on re-open (single account list, no tenant rail). + useEffect(() => {}, [open]); + + const handlePick = (account: DemoAccount) => { + onOpenChange(false); + onPick(account); + }; + + return ( + + + Demo accounts + + Pick a demo account to sign in to the admin console. + + + {/* Atmospheric gradient wash */} +
+
+ + {/* Header */} +
+
+ + + + + + Dev · demo + +
+

+ Sign in as operator. +

+

+ Tap an account below — we'll fill the credentials and sign you in instantly. +

+
+ + {/* Account list */} +
+
+ + Operator accounts + +
+ + tap to sign in + +
+ +
+ {ADMIN_DEMO_ACCOUNTS.map((account, i) => ( + + ))} +
+
+ + {/* Footer */} +
+

+ + + dev only + · + Not visible in production. + +

+ + esc + +
+ +
+ ); +} + +// ─── AccountRow ────────────────────────────────────────────────────────────── + +function AccountRow({ + account, + delay, + onPick, +}: { + account: DemoAccount; + delay: number; + onPick: (account: DemoAccount) => void; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/billing/plan-form-dialog.tsx b/clients/admin/src/components/billing/plan-form-dialog.tsx new file mode 100644 index 0000000000..08c603731c --- /dev/null +++ b/clients/admin/src/components/billing/plan-form-dialog.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { CreditCard, Gauge } from "lucide-react"; +import { toast } from "sonner"; +import { + createPlan, + updatePlan, + type BillingPlanDto, + type PlanInterval, + type QuotaResource, +} from "@/api/billing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, Select, type SelectOption } from "@/components/list"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; + +// A money/rate field is a free-text decimal string. These refinements run +// client-side so a negative price is rejected before any network call (the +// server also rejects it, but we don't rely on that). +const NON_NEGATIVE_MSG = "Must be a non-negative number."; + +/** Required non-negative decimal (e.g. monthly base price). */ +const requiredNonNegative = z + .string() + .trim() + .min(1, "Required.") + .refine((v) => Number.isFinite(Number(v)) && Number(v) >= 0, NON_NEGATIVE_MSG); + +/** Optional non-negative decimal (blank allowed → omitted). */ +const optionalNonNegative = z + .string() + .trim() + .refine((v) => v === "" || (Number.isFinite(Number(v)) && Number(v) >= 0), NON_NEGATIVE_MSG); + +const INTERVAL_OPTIONS: SelectOption[] = [ + { value: "Monthly", label: "Monthly", hint: "billed every month" }, + { value: "Yearly", label: "Yearly", hint: "billed every 12 months" }, +]; + +const OVERAGE_RESOURCES: { key: QuotaResource; label: string; placeholder: string }[] = [ + { key: "ApiCalls", label: "API calls", placeholder: "0.0010" }, + { key: "StorageBytes", label: "Storage bytes", placeholder: "0.00000001" }, + { key: "Users", label: "Users", placeholder: "5.00" }, + { key: "ActiveFeatureFlags", label: "Feature flags", placeholder: "1.00" }, +]; + +type OverageState = Record; + +function toOverageNumbers(state: OverageState): Record | null { + const out: Record = {}; + let any = false; + for (const { key } of OVERAGE_RESOURCES) { + const raw = state[key]; + if (raw === undefined || raw.trim() === "") continue; + const n = Number(raw); + // Submission is blocked upstream when a value is invalid, so anything that + // reaches here is a non-negative finite number. + if (!Number.isFinite(n) || n < 0) continue; + out[key] = n; + any = true; + } + return any ? out : null; +} + +/** First validation message for a value against a schema, or undefined when valid. */ +function fieldError(schema: z.ZodTypeAny, value: string): string | undefined { + const result = schema.safeParse(value); + return result.success ? undefined : result.error.issues[0]?.message; +} + +function describe(err: unknown, fallback: string): string { + if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; + if (err instanceof Error) return err.message; + return fallback; +} + +function SectionLabel({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}) { + return ( +
+ + + +
+

{title}

+

{description}

+
+
+ ); +} + +/** + * Create or edit a billing plan in a dialog. Pass `plan` to edit (key + currency are immutable then), + * omit it to create. On success it invalidates the plans cache and closes. + */ +export function PlanFormDialog({ + open, + onOpenChange, + plan, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + plan?: BillingPlanDto; +}) { + const queryClient = useQueryClient(); + const isEdit = !!plan; + + const [key, setKey] = useState(""); + const [name, setName] = useState(""); + const [currency, setCurrency] = useState("USD"); + const [monthlyBasePrice, setMonthlyBasePrice] = useState(""); + const [interval, setInterval] = useState("Monthly"); + const [annualPrice, setAnnualPrice] = useState(""); + const [overage, setOverage] = useState({}); + + // Reset/populate whenever the dialog opens (or the target plan changes). + useEffect(() => { + if (!open) return; + setKey(plan?.key ?? ""); + setName(plan?.name ?? ""); + setCurrency(plan?.currency ?? "USD"); + setMonthlyBasePrice(plan ? String(plan.monthlyBasePrice) : ""); + setInterval(plan?.interval === "Yearly" ? "Yearly" : "Monthly"); + setAnnualPrice(plan?.annualPrice != null ? String(plan.annualPrice) : ""); + const next: OverageState = {}; + for (const [resource, rate] of Object.entries(plan?.overageRates ?? {})) { + if (rate !== undefined && rate !== null) next[resource] = String(rate); + } + setOverage(next); + }, [open, plan]); + + const keyInvalid = !isEdit && key.length > 0 && !PLAN_KEY_PATTERN.test(key); + const priceNum = Number(monthlyBasePrice); + // Only surface the price error once something's been typed; submit-time + // validation (onSubmit) still blocks an empty required field. + const priceError = + monthlyBasePrice.length > 0 ? fieldError(requiredNonNegative, monthlyBasePrice) : undefined; + const annualNum = Number(annualPrice); + const annualError = fieldError(optionalNonNegative, annualPrice); + const annualPricePayload = interval === "Yearly" && annualPrice.trim().length > 0 ? annualNum : null; + + // Per-resource overage validation — a negative or non-numeric rate blocks submit. + const overageErrors = useMemo(() => { + const out: Partial> = {}; + for (const { key: resKey } of OVERAGE_RESOURCES) { + const err = fieldError(optionalNonNegative, overage[resKey] ?? ""); + if (err) out[resKey] = err; + } + return out; + }, [overage]); + const hasOverageError = Object.keys(overageErrors).length > 0; + // Aggregate validity for disabling submit. Monthly price is required + non-negative. + const pricingInvalid = + !!fieldError(requiredNonNegative, monthlyBasePrice) || !!annualError || hasOverageError; + + const onClose = () => onOpenChange(false); + + const createMutation = useMutation({ + mutationFn: createPlan, + onSuccess: () => { + toast.success(`Plan "${name}" created`); + queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); + onClose(); + }, + onError: (err) => toast.error("Create failed", { description: describe(err, "Could not create plan.") }), + }); + + const updateMutation = useMutation({ + mutationFn: updatePlan, + onSuccess: () => { + toast.success(`Plan "${name}" updated`); + queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); + onClose(); + }, + onError: (err) => toast.error("Update failed", { description: describe(err, "Could not update plan.") }), + }); + + const pending = createMutation.isPending || updateMutation.isPending; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + if (pricingInvalid) return; + const overageRates = toOverageNumbers(overage); + + if (isEdit && plan) { + updateMutation.mutate({ + planId: plan.id, + name: name.trim(), + monthlyBasePrice: priceNum, + overageRates, + interval, + annualPrice: annualPricePayload, + }); + return; + } + if (keyInvalid) return; + createMutation.mutate({ + key: key.trim(), + name: name.trim(), + currency: currency.trim().toUpperCase(), + monthlyBasePrice: priceNum, + overageRates, + interval, + annualPrice: annualPricePayload, + }); + }; + + return ( + + + +
+ + + + {isEdit ? "Edit plan" : "New plan"} +
+ + {isEdit + ? "Update name, pricing, interval, or overage rates. Key and currency are immutable." + : "Plan keys are canonical slugs used by tenant subscriptions and quota configuration."} + +
+ +
+ + {/* ── Details ── */} +
+ +
+
+ + setKey(e.target.value)} + placeholder="pro" + className="font-mono" + disabled={isEdit} + autoComplete="off" + /> + + + setName(e.target.value)} placeholder="Pro" /> + + + setCurrency(e.target.value.toUpperCase())} + placeholder="USD" + className="font-mono" + disabled={isEdit} + autoComplete="off" + /> + + + setMonthlyBasePrice(e.target.value)} + inputMode="decimal" + placeholder="29.00" + /> + + + + id="pf-interval" + value={interval} + onValueChange={(v) => setInterval(v === "Yearly" ? "Yearly" : "Monthly")} + options={INTERVAL_OPTIONS} + /> + + {interval === "Yearly" && ( + + setAnnualPrice(e.target.value)} + inputMode="decimal" + placeholder={monthlyBasePrice ? String(Number(monthlyBasePrice) * 12) : "290.00"} + /> + + )} +
+
+ + {/* ── Overage rates ── */} +
+ +
+
+ {OVERAGE_RESOURCES.map((res) => ( + + setOverage((s) => ({ ...s, [res.key]: e.target.value }))} + inputMode="decimal" + placeholder={res.placeholder} + /> + + ))} +
+
+ + + + + + + + +
+ ); +} diff --git a/clients/admin/src/components/brand-mark.tsx b/clients/admin/src/components/brand-mark.tsx new file mode 100644 index 0000000000..cbfbcc14d6 --- /dev/null +++ b/clients/admin/src/components/brand-mark.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/lib/cn"; + +/** + * BrandMark — the compact inline lockup used in the sidebar brand row and + * any surface that needs a sub-header-sized reference to the product. + * + * Matches the dashboard's brand treatment: a small gradient square carrying + * the "F" initial, paired with the "fullstackhero" wordmark with a tinted + * accent on "hero", and a small "Admin" sub-label. + * + * The chartreuse signal colour from the old Console identity is retired here. + * Colour-identity is now driven purely by the shared `--color-primary` token. + */ +export function BrandMark({ className }: { className?: string }) { + return ( +
+ + F + +
+ + fullstackhero + + + Admin + +
+
+ ); +} + +/** + * BrandMarkXL — splash version for the Login page. Leads with the FSH logo + * mark + "fullstackhero" wordmark, then a display monogram and a one-line + * system blurb. + */ +export function BrandMarkXL({ className }: { className?: string }) { + return ( +
+
+ FullStackHero + + fullstackhero + + + · platform admin + +
+

+ Admin. +

+

+ Operate every tenant on this instance — identity, multitenancy, billing, + and the rest of the system surface, from one place. +

+
+ ); +} diff --git a/clients/admin/src/components/empty-state.tsx b/clients/admin/src/components/empty-state.tsx new file mode 100644 index 0000000000..baee73de4d --- /dev/null +++ b/clients/admin/src/components/empty-state.tsx @@ -0,0 +1,59 @@ +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type EmptyStateProps = { + icon?: LucideIcon; + /** Mono crumb shown above the headline. */ + kicker?: string; + title: string; + description?: React.ReactNode; + action?: React.ReactNode; + className?: string; +}; + +/** + * EmptyState — used wherever a list/query returns no results. Pulls the + * Console language together in one place: hairline icon container, mono + * kicker, display headline, single CTA. Pages should reach for this + * instead of inline "No results" copy. + */ +export function EmptyState({ + icon: Icon, + kicker, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( +
+ {Icon && ( + + + + + )} + {kicker && ( + {kicker} + )} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/clients/admin/src/components/file/image-input.tsx b/clients/admin/src/components/file/image-input.tsx new file mode 100644 index 0000000000..7a9c9c1f74 --- /dev/null +++ b/clients/admin/src/components/file/image-input.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/cn"; +import { useFileUpload, formatBytes } from "@/hooks/use-file-upload"; +import { getFileMetadata, Visibility } from "@/api/files"; +import { ApiRequestError } from "@/lib/api-client"; + +type Props = { + /** Current image URL (or empty). The component is fully controlled. */ + value: string; + onChange: (next: string) => void; + /** + * Owner binding for the upload. The Files module's per-OwnerType IFileAccessPolicy + * decides who can attach what. For user avatars, ownerType="User" + the user id. + */ + ownerType: string; + ownerId?: string | null; + /** Allowed extensions (lower-case w/ leading dot). Server enforces too. */ + allowedExtensions?: string[]; + maxBytes?: number; + /** Visual treatment for the preview tile — "square" for general images, "circle" for avatars. */ + shape?: "square" | "circle"; + className?: string; +}; + +const IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]; + +/** + * ImageInput — composite control that lets a user either upload a new image + * (presigned PUT to S3/MinIO) OR paste an external URL. After a successful + * upload the component fetches the FileAsset metadata to retrieve the durable + * `publicUrl` and forwards it through `onChange`. + */ +export function ImageInput({ + value, + onChange, + ownerType, + ownerId, + allowedExtensions = IMAGE_EXTS, + maxBytes = 10 * 1024 * 1024, + shape = "square", + className, +}: Props) { + const [mode, setMode] = useState<"upload" | "url">("upload"); + const { upload, progress, isUploading, reset } = useFileUpload({ + ownerType, + ownerId, + category: "Image", + visibility: Visibility.Public, // public so we get a durable URL we can persist on the entity + allowedExtensions, + maxBytes, + }); + + // After upload+finalize, fetch metadata so we get the durable publicUrl. + const resolveUrl = useMutation({ + mutationFn: async (fileAssetId: string) => { + const dto = await getFileMetadata(fileAssetId); + if (!dto.publicUrl) { + throw new Error("Server returned no publicUrl for this file."); + } + return dto.publicUrl; + }, + }); + + const handlePick = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const asset = await upload(file); + const url = await resolveUrl.mutateAsync(asset.id); + onChange(url); + toast.success("Image uploaded"); + // Clear progress so the dropzone re-arms for another upload. + setTimeout(reset, 1500); + } catch (e) { + const message = + e instanceof ApiRequestError + ? (e.problem?.detail ?? e.problem?.title ?? e.message) + : e instanceof Error + ? e.message + : "Upload failed"; + toast.error(message); + } + }; + input.click(); + }; + + const hasImage = value.length > 0; + const isWorking = isUploading || resolveUrl.isPending; + const tileClass = shape === "circle" ? "rounded-full" : "rounded-xl"; + + return ( +
+ {/* Mode toggle */} +
+ setMode("upload")} icon={}> + Upload + + setMode("url")} icon={}> + Paste URL + +
+ + {/* Preview + controls row */} +
+
+ {hasImage ? ( + + ) : isWorking ? ( + + ) : ( + + )} +
+ +
+ {mode === "upload" ? ( +
+ + {hasImage && !isWorking && ( + + )} + {isUploading && progress && ( + + {progress.percent}% · {formatBytes(progress.loaded)} / {formatBytes(progress.totalBytes)} + + )} +
+ ) : ( + onChange(e.target.value)} + placeholder="https://…" + maxLength={512} + /> + )} + +

+ {mode === "upload" + ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}` + : "Direct link to an image you host elsewhere."} +

+
+
+
+ ); +} + +function ModeChip({ + active, + onClick, + icon, + children, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/forbidden-view.tsx b/clients/admin/src/components/forbidden-view.tsx index 3326d0975c..782a7f69c7 100644 --- a/clients/admin/src/components/forbidden-view.tsx +++ b/clients/admin/src/components/forbidden-view.tsx @@ -1,33 +1,56 @@ import { Link } from "react-router-dom"; +import { ShieldOff } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; type ForbiddenViewProps = { /** Permission strings the caller required that the user doesn't hold. Shown for operator clarity. */ missing?: string[]; }; +/** + * ForbiddenView — 403 surface in the Console language. Hairline-bordered + * mono crumb, single chartreuse accent rule, missing permissions printed + * as code-chips so the operator can paste them into a permission grant. + */ export function ForbiddenView({ missing }: ForbiddenViewProps) { return (
- - - Access denied - - Your account doesn't have permission to view this area. - - - - {missing && missing.length > 0 && ( -
- Missing: {missing.join(", ")} -
- )} - -
-
+
+ ); } diff --git a/clients/admin/src/components/impersonation/active-grants-card.tsx b/clients/admin/src/components/impersonation/active-grants-card.tsx new file mode 100644 index 0000000000..b938e09298 --- /dev/null +++ b/clients/admin/src/components/impersonation/active-grants-card.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ShieldOff, UserCog } from "lucide-react"; +import { + listImpersonationGrants, + type ImpersonationGrantDto, +} from "@/api/impersonation-grants"; +import type { UserDto } from "@/api/users"; +import { useAuth } from "@/auth/use-auth"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { SettingsSection } from "@/components/list"; +import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog"; +import { RevokeGrantDialog } from "@/components/impersonation/revoke-grant-dialog"; +import { IdentityPermissions } from "@/lib/permissions"; + +const REFRESH_INTERVAL_MS = 5_000; + +/** + * ActiveGrantsCard — tenant-detail inline view of active impersonation + * sessions targeting users in this tenant. Polls every 5s. Renders nothing + * if the caller can't see impersonation grants (perm-gated upstream). + */ +export function ActiveGrantsCard({ tenantId }: { tenantId: string }) { + const { user } = useAuth(); + const canView = (user?.permissions ?? []).includes(IdentityPermissions.Impersonation.View); + const canRevoke = (user?.permissions ?? []).includes(IdentityPermissions.Impersonation.Revoke); + const canImpersonate = (user?.permissions ?? []).includes(IdentityPermissions.Users.Impersonate); + const currentUserId = user?.id ?? null; + + const query = useQuery({ + queryKey: ["impersonation-grants", "tenant-active", tenantId], + queryFn: () => + listImpersonationGrants({ + status: "Active", + impersonatedTenantId: tenantId, + take: 50, + }), + enabled: canView, + refetchInterval: REFRESH_INTERVAL_MS, + }); + + const [targetGrant, setTargetGrant] = useState(null); + const [reopenGrant, setReopenGrant] = useState(null); + + // Minimal UserDto sufficient for ImpersonateDialog's ConfigureStep render. + const reopenPrefillUser: UserDto | undefined = reopenGrant + ? { + id: reopenGrant.impersonatedUserId, + userName: reopenGrant.impersonatedUserName ?? undefined, + firstName: null, + lastName: null, + email: null, + isActive: true, + emailConfirmed: true, + } + : undefined; + + // Quiet hide when there's nothing to show — busy operators don't need + // an empty box on every tenant page. + if (!canView) return null; + if (query.isLoading) return null; + const items = query.data ?? []; + if (items.length === 0) return null; + + return ( + <> + +
    + {items.map((g) => ( + setTargetGrant(g)} + onReopen={() => setReopenGrant(g)} + /> + ))} +
+
+ + !open && setTargetGrant(null)} + /> + + !open && setReopenGrant(null)} + tenantId={reopenGrant?.impersonatedTenantId ?? ""} + tenantName={reopenGrant?.impersonatedTenantId} + prefillUser={reopenPrefillUser} + /> + + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// GrantRow — a single active impersonation session row +// ───────────────────────────────────────────────────────────────────────── + +function GrantRow({ + grant: g, + canRevoke, + canReopen, + onRevoke, + onReopen, +}: { + grant: ImpersonationGrantDto; + canRevoke: boolean; + canReopen: boolean; + onRevoke: () => void; + onReopen: () => void; +}) { + return ( +
  • + {/* Live-session pulse dot */} + + + {/* Session detail */} +
    +
    + + + {g.actorUserName ?? g.actorUserId} + + + + {g.impersonatedUserName ?? g.impersonatedUserId} + + + + Active + +
    +
    + started {new Date(g.startedAtUtc).toLocaleTimeString()} · expires{" "} + {new Date(g.expiresAtUtc).toLocaleTimeString()} + {g.reason && <> · {truncate(g.reason, 80)}} +
    +
    + + {/* Row actions */} + +
  • + ); +} + +function RowActions({ + canRevoke, + canReopen, + onRevoke, + onReopen, +}: { + canRevoke: boolean; + canReopen: boolean; + onRevoke: () => void; + onReopen: () => void; +}) { + if (!canRevoke && !canReopen) { + return ( + + view-only + + ); + } + return ( +
    + {canReopen && ( + + )} + {canRevoke && ( + + )} +
    + ); +} + +function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} diff --git a/clients/admin/src/components/impersonation/impersonate-dialog.tsx b/clients/admin/src/components/impersonation/impersonate-dialog.tsx new file mode 100644 index 0000000000..0cbe613033 --- /dev/null +++ b/clients/admin/src/components/impersonation/impersonate-dialog.tsx @@ -0,0 +1,466 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, keepPreviousData } from "@tanstack/react-query"; +import { ArrowLeft, ArrowRight, Check, Search, ShieldAlert, UserCog } from "lucide-react"; +import { toast } from "sonner"; +import { searchUsers, type UserDto } from "@/api/users"; +import { startImpersonation, type ImpersonationResponse } from "@/api/impersonation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Monogram } from "@/components/monogram"; +import { ApiRequestError } from "@/lib/api-client"; +import { env } from "@/env"; +import { cn } from "@/lib/cn"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + tenantId: string; + tenantName?: string; + /** Pre-select a user — skips the picker and jumps straight to the form. */ + prefillUser?: UserDto; +}; + +type DurationOption = { minutes: number; label: string }; +const DURATION_OPTIONS: DurationOption[] = [ + { minutes: 10, label: "10 min" }, + { minutes: 15, label: "15 min" }, + { minutes: 30, label: "30 min" }, +]; + +/** + * ImpersonateDialog — two-step modal flow: + * 1. Pick a user inside the target tenant (skipped if `prefillUser` is set) + * 2. Enter reason + pick session duration → start + * + * On success, opens the dashboard origin in a NEW TAB with the impersonation + * token in the URL hash. The dashboard's bootstrap reads the hash, installs + * the token, and strips it from the URL before any render. Hash params are + * never sent to the server and don't leak via referrer/HTTP logs. + */ +export function ImpersonateDialog({ + open, + onOpenChange, + tenantId, + tenantName, + prefillUser, +}: Props) { + const [step, setStep] = useState<"pick" | "configure">(prefillUser ? "configure" : "pick"); + const [selected, setSelected] = useState(prefillUser ?? null); + + // Reset on close + when prefill changes so reopening is idempotent. + useEffect(() => { + if (open) { + setStep(prefillUser ? "configure" : "pick"); + setSelected(prefillUser ?? null); + } + }, [open, prefillUser]); + + return ( + + + +
    + + + + Impersonate user +
    + + Tenant{" "} + {tenantName ?? tenantId} ·{" "} + {step === "pick" + ? "pick a user to impersonate." + : "session details. Token will be issued and opened in the dashboard."} + +
    + + {step === "pick" ? ( + { + setSelected(user); + setStep("configure"); + }} + onCancel={() => onOpenChange(false)} + /> + ) : ( + selected && ( + { + setSelected(null); + setStep("pick"); + } + } + onDone={() => onOpenChange(false)} + /> + ) + )} +
    +
    + ); +} + +// ─── Step 1: pick user ────────────────────────────────────────────────── + +function PickStep({ + tenantId, + onPick, + onCancel, +}: { + tenantId: string; + onPick: (user: UserDto) => void; + onCancel: () => void; +}) { + const [search, setSearch] = useState(""); + const [debounced, setDebounced] = useState(""); + + useEffect(() => { + const handle = setTimeout(() => setDebounced(search.trim()), 250); + return () => clearTimeout(handle); + }, [search]); + + const query = useQuery({ + queryKey: ["impersonation", "users", tenantId, debounced], + queryFn: () => + searchUsers({ + tenantId, + search: debounced || undefined, + pageSize: 25, + // Skip disabled accounts — impersonating a deactivated user is a footgun + // (the impersonation token would be valid, but the user's normal sign-in + // is disabled — confusing to debug). + isActive: true, + }), + placeholderData: keepPreviousData, + }); + + const users = query.data?.items ?? []; + + return ( + <> + +
    + + setSearch(e.target.value)} + placeholder="Search by name, email, or username…" + aria-label="Search users to impersonate" + className="pl-9" + /> +
    + +
    + {query.isError && ( +
    + {query.error instanceof ApiRequestError + ? query.error.problem?.detail ?? query.error.message + : "Failed to load users."} +
    + )} + + {query.isLoading && ( +
    + Loading + +
    + )} + + {!query.isLoading && users.length === 0 && ( +
    + No users match{debounced ? ` “${debounced}”` : ""}. +
    + )} + +
      + {users.map((user) => ( +
    • + +
    • + ))} +
    +
    +
    + + + + + + ); +} + +// ─── Step 2: configure ────────────────────────────────────────────────── + +function ConfigureStep({ + tenantId, + tenantName, + user, + onBack, + onDone, +}: { + tenantId: string; + tenantName?: string; + user: UserDto; + onBack?: () => void; + onDone: () => void; +}) { + const [reason, setReason] = useState(""); + const [minutes, setMinutes] = useState(15); + + const trimmedReason = reason.trim(); + const reasonValid = trimmedReason.length >= 4; + + const mutation = useMutation({ + mutationFn: () => + startImpersonation({ + targetUserId: user.id, + targetTenantId: tenantId, + reason: trimmedReason, + durationMinutes: minutes, + }), + onSuccess: (response) => { + handoffToDashboard(response, tenantId); + toast.success(`Impersonation started · ${minutes} min`, { + description: `Opened the dashboard as ${labelFor(user)}. End impersonation from inside the dashboard tab.`, + }); + onDone(); + }, + onError: (err) => { + const detail = + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : err.message; + toast.error("Impersonation failed", { description: detail }); + }, + }); + + return ( + <> + + + +
    + // Duration +
    + {DURATION_OPTIONS.map((opt) => { + const active = minutes === opt.minutes; + return ( + + ); + })} +
    +
    + +
    + +