Microsoft Entra sign-in, and a self-contained Windows installer - #6
Open
sameerk27 wants to merge 119 commits into
Open
Microsoft Entra sign-in, and a self-contained Windows installer#6sameerk27 wants to merge 119 commits into
sameerk27 wants to merge 119 commits into
Conversation
- MSAL browser login gate (AuthGate component) - User avatar + sign-out menu in header - SQL connection string: Encrypt=True - Generic error responses (no internal detail leaks) - Swagger dev-only - CORS restricted to explicit methods - Security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy - Microsoft.Identity.Web + Azure.Identity packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Validate Entra ID Bearer tokens (audience api://{clientId})
- App-managed roles (Admin/Analyst/Viewer) in AppUsers table, not Entra App Roles
- RoleClaimsTransformation attaches role claim after token validation
- Role policies: reads=any authed, ack/snooze/resolve/policy-edit=Analyst+, settings/collector=Admin
- Bootstrap admin (configured email, else first-login-wins)
- Admin-only user management endpoints (list/add/change-role/remove) with last-admin guards
- Capture real signed-in identity in AcknowledgedBy/SnoozedBy
Frontend:
- useAuth() role context from /api/auth/me; role badge in header
- Role-aware UI: hide Run Collection + actions from non-privileged roles
- User Management page (Admin only) with add/change-role/remove
- apiFetch (Bearer token) used for all data calls
Adds ROADMAP.md tracking auth + hosting phases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationSender.SendInviteEmailAsync sends a sign-in-link email via existing SMTP
- POST /api/admin/users/{email}/invite (Admin) resends the access email
- Add User accepts optional sendInvite flag to email on creation
- Frontend: 'Send invite email' checkbox on Add form; Send/Resend invite button per row
- Gracefully reports when SMTP is not configured
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AuditEntry model + AuditEntries table (idempotent schema) - AuditLogger service resolves actor from validated token; never blocks the action - Wired into user add/role-change/remove/invite and notification-settings update - GET /api/admin/audit-log (Admin only) - Frontend: Activity Log card on User Management page Addresses SOC2/ISO logging controls (ROADMAP Phase 3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HSTS + HTTPS redirection active outside Development (dev stays HTTP) - README: reverse-proxy (Caddy/Nginx/IIS) and Kestrel-cert TLS options - Production appsettings template updated: Encrypt=True, AzureAd, Auth sections - Credential-hygiene note (cert auth, vault, rotate exposed secrets) ROADMAP Phase 5 (HTTPS portion). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduces install friction from ~7 manual steps to: run install.ps1 -> sign in -> fill wizard. - install.ps1: checks prereqs, builds frontend, publishes API, optional Windows service install - GraphConfig table stores Graph creds (secret DPAPI-encrypted); loaded over the GraphOptions singleton at startup, so DB-entered creds work with no JSON editing - GET/POST /api/setup/graph (Admin): view status, save+apply+test connection live - Mutating the IOptions<GraphOptions> singleton applies new creds without restart - Frontend: Setup page (Admin only) with Save & Test Connection + status badge - Audit: setup.graph entry on credential update ROADMAP: install-simplicity track. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dance - Compute repo root in body (PSScriptRoot is empty in param() defaults) so publish lands in the project folder, not the drive root - Replace em-dashes with ASCII to avoid PowerShell parser/encoding errors - Manual-run guidance now cds into the publish folder so config + wwwroot resolve (Windows service handles working directory automatically) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
deploy.ps1 generates appsettings.Production.json (DB + AzureAd login + bootstrap admin), trusts a local HTTPS cert, and launches the published app over HTTPS in Production. Collapses manual production setup to one command + the irreducible Entra redirect-URI step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Automates the last manual step: creates the app registration with read-only Graph permissions, SPA redirect URI, exposed access_as_user scope, client secret, and admin consent via Azure CLI. Outputs TenantId/ClientId/secret and the deploy.ps1 command. User runs it themselves (creates an identity + grants consent in their tenant). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enables running on Linux and via Docker (previously Windows-only). - SecretProtector: switch to ASP.NET Core Data Protection (cross-platform); still reads legacy Windows DPAPI values for backward compatibility. Key ring persisted to disk (DataProtection:KeyPath), mountable as a Docker volume. - Program.cs: configure persisted Data Protection; DB-readiness retry (~60s) so the app waits for the SQL container; Security:RequireHttps flag so a TLS proxy can front the container without in-app redirect loops. - Dockerfile: multi-stage (node frontend build -> dotnet publish -> aspnet runtime). - docker-compose.yml: app + SQL Server, volumes for DB and key ring; one-command up. - .env.example, .dockerignore; ignore .env. Note: image build/run not exercised in this environment; Windows path verified. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… HTTPS - Production Kestrel does not auto-use the dev cert; export a PFX and configure Kestrel:Endpoints:Https so https actually binds - -Hostname: generate a self-signed cert for an internal name, trust it for the current user, add a hosts entry (needs admin), and serve https on it - HTTP urls set Security:RequireHttps=false (TLS handled by a proxy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In Production, user secrets don't load and Graph creds come from the Setup wizard, so /api/auth/config returned placeholder tenant/client and MSAL login failed (AADSTS900023). Now prefer AzureAd:ClientId/TenantId (set by deploy.ps1), falling back to Graph for older setups; ignore YOUR_* placeholders. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Planning docs for the weekend build: - ENTERPRISE_BACKLOG.md: features + UI/UX gaps (incl. Zero Trust score, SharePoint/OneDrive/Teams monitoring, error boundary, a11y, trends, compliance) - ORG_READINESS.md: personal -> organizational product, 6 robustness pillars, adoption checklist, optional SQLite backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…raphic - ENTERPRISE_BACKLOG: section 0 perfect-existing-tabs (affected-entity on alerts), alert coverage gap analysis, section F (alert workflow, notifications, sovereign clouds, MSP, integrations, supply-chain, session/quality) - docs/assets: roadmap hero graphic (svg + png) for announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace fully-manual setup with 3 clear paths: Docker, Windows one-script, manual - Fix broken clone URL (YOUR_ORG -> sameerk27/vigil365) - Document register-app.ps1 for Entra app registration - Fix Entra steps: SPA redirect URI + Expose-an-API scope are required for sign-in - Prerequisites split by install path (Docker needs only Docker) - Production section points at deploy.ps1; manual kept as fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s, tests Frontend refactored into pages/components/services (from single-file main.tsx). - Trends & history page (metric snapshots, charts, PDF export) - Compliance framework engine (16 CIS/NIST/ISO/GDPR controls) + configurable thresholds - RecommendationsEngine + SecurityRecommendation/AlertBaselineRule models + page - Alert affected-entities capture + detail table; copy buttons; KPI-tile drill-through; alert->entity cross-links; per-tab tooltips/skeletons - Overview: data-trust banner (last collection freshness + source failures) - Sidebar overflow/zoom fix (flex column: pinned logo, scrolling nav, pinned collapse) - SecretProtector/RoleClaimsTransformation/NotificationSender/GraphCollector tests (24 total) - Planning docs: MSP multi-tenant plan; backlog framed as Edition 1 vs Edition 2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ache, logging - /health endpoint: DB connectivity, Graph config, last-collection freshness (200 healthy/degraded, 503 when DB unreachable) for Docker/k8s probes - Audit hardening: capture client IP + User-Agent, SHA-256 tamper-evident hash chain, CSV export + chain-verify endpoints, sign-in audit events; UI: IP column, Export CSV + Verify integrity on the Activity Log card - Data retention: nightly prune worker with configurable Retention section (resolved alerts, triggered alerts, notification/collection/trend/audit) - Role-claim caching: 60s IMemoryCache in RoleClaimsTransformation, evicted on role change/user removal/first sign-in - Structured logging: JSON console outside Development + X-Correlation-Id middleware with per-request logging scope - Favicon (shield SVG) + description/theme-color/Open Graph meta - Fix pre-existing tsc errors in AlertCenterPage + RecommendationsPage (swapped showToast args, invalid Badge/KpiTile/EmptyState props) - Tests: 34 passing (hash chain, retention pruning, role cache TTL/eviction) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ention, logging, cache, favicon) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CRITICAL: authorization was non-functional. RequireAdmin/RequireAnalyst were RequireAssertion(_ => true) stubs (passing even for anonymous callers) and FallbackPolicy was null, leaving ~35 endpoints fully anonymous (user mgmt, audit export, notification settings with decrypted webhook URLs, all dashboard/tenant data). - Role policies now RequireAuthenticatedUser + RequireRole (Analyst policy admits Admin); FallbackPolicy requires an authenticated user everywhere; only /health, /api/auth/config and the SPA fallback stay anonymous - /api/collector/run: Analyst+, 400 when Graph unconfigured (no more fabricated Completed runs feeding /health), 409 on concurrent run via SemaphoreSlim gate in GraphCollector - /api/alert-policies/evaluate: Analyst+ (dispatches real notifications) - Demo-data honesty: securescore / security-incidents / defender-alerts return configured=false instead of fabricated payloads; sample-alert seeding gated behind Seed:DemoData (default false) and only when Graph is unconfigured; configured installs purge lingering seed alerts by ExternalId prefix at startup (fixes fake alerts commingled with real tenant data, skewed MFA %, and demo-driven notification firing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redesign — "easy to see what is happening in M365": - Overview leads with a Needs Attention triage feed: open critical/high security alerts + unacknowledged policy alerts, ranked by severity then recency, one click to the alert or Alert Center - Service-health advisories fully separated from security signal: excluded from /api/dashboard/overview counts/trends (new serviceAdvisories field), own "M365 Service Advisories" card on Overview, removed from the incidents sidebar badge, and the repeated every-page yellow banner is gone - Removed redundant Top Active Alerts / Recent High Alerts cards; collection health moved up; single app-wide severity->tone mapping (utils.sevTone) Fixed broken pages (were written in Tailwind classes with no Tailwind installed, or hardcoded dark-mockup styles): - RecommendationsPage rebuilt on the styles.css design system (KPI row, category filters, expandable guidance rows, keyboard accessible) - Alert Center Coverage tab rebuilt (was white-on-white in light mode, dead data-table class) Honesty + bug fixes: - recApi no longer swallows API failures as "all healthy"; error states added - Removed fake claims: 99.9% SLA uptime column, "Real-time from Graph API", "All 12 data sources", 5-minute cadence text, mail-flow-derived "Defender Status" KPI; Service Health healthy-count math fixed; one shared service- advisory matcher (grid and table no longer disagree) - Overview banner no longer says "No collection yet" while a run is in progress or failed (branches on run status) - sevClass() now emits the sev-dot base class (severity dots were invisible) - Dead CSS classes replaced (btn-primary/secondary, filter-input, ctrl-status, borderless search-input misuse) + new .form-input class - Trends: float-rounding garbage in deltas, undefined --color-bg-alt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend hardening: - GraphApiClient: bound 429 retries (max 3 per page) so a throttling tenant fails cleanly instead of hanging the request - CORS origins now config-driven (Cors:AllowedOrigins) with localhost fallback - Added fixed-window rate limiter (300 req/min/IP) on the API Trust / credibility: - Compliance no longer goes green on missing data: each control declares hasData; unconfigured sources render "NOT ASSESSED" and are excluded from framework scores (frameworks show N/A when nothing is measurable, plus an "X of N controls assessed" line). This was the biggest demo-killer. - Removed storage-implementation leak in the alert UI: "DB CRITICAL/HIGH" -> "CRITICAL/HIGH ALERTS", "Security DB" tab -> "Vigil365 Alerts" - Frontend defaults to Viewer (not Admin) when /api/auth/me fails, so Admin-only controls never flash for unprivileged users - Version chip (Vigil365 v1.0.0) in the sidebar Analyst triage loop: - Incidents & Alerts unified feed now sorts by severity then recency, so the queue leads with the worst item regardless of source - Alert detail modal shows "Related open alerts for this user/device" — click to pivot between alerts touching the same entity (investigation context) Accessibility: - DetailModal: role=dialog, aria-modal, focus-move-in, Tab focus trap, focus return on close - Toasts: role=status aria-live=polite; error icon now colored - Sidebar nav: aria-current on active page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 8 sections
Routing (URLs are now first-class):
- Hash router: #/{pageId} with optional ?alert={id}; page state initialises
from the URL, back/forward navigate pages, refresh keeps you where you were
- Alert permalinks: the open alert detail is reflected in the URL and a shared
link reopens that alert once data loads — makes email/Teams notification
links actually useful
Information architecture (veteran-critique #2/#7):
- Sidebar: 17 flat items -> 8 sections: Overview · Alerts (Queue + Alert
Center) · Identity (Overview + Sign-in Locations + Conditional Access +
Audit Log) · Devices · Email · Posture (Compliance + Recommendations +
Trends) · M365 Health (Service Health + Connectivity) · Administration
(Licenses & Users + User Management + Setup)
- Multi-page sections render a tab bar (reuses ac-tab styling, role=tablist);
section badge aggregates member-page unread counts (capped at 99+);
sections remember the last tab you were on
- All original page ids survive as tabs, so crossNavigate deep links and the
seen-count logic work unchanged; header title shows "Section · Tab"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cure-score cliff - Needs Attention showed one row per policy FIRING (513 rows of the same 3 policies) — now one row per policy with the latest firing time and an open- occurrence count, so real M365 alerts are visible again - Secure Score trend painted a cliff to 0 at the end: Graph trend points with maxScore=0 (missing data) were mapped to 0% — they are now treated as gaps and filtered out Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Foundations: - Defined 7 missing CSS variables that were silently dropping declarations (--color-surface, --color-bg-alt, --font-mono) and fixed call sites using undefined tokens (--border, --accent, --color-good/error) — repairs real rendering defects: transparent Trends buttons, unstyled StateMessage background, harsh currentColor borders, Setup banner in dark mode - Sticky header: fixed min-height + subtle elevation shadow; hdr-sub no longer wraps (kept every below-header sticky offset honest); global scroll-margin-top so anchored scrolls clear the header (fixes clipped card titles) - :focus-visible outlines + :disabled states for all button classes; new .btn-danger for destructive actions; deleted the unused shimmer skeleton system and dead .page-title Shared sweep: - Removed ~55 hardcoded icon hex props (#d1d5db empty states, #94a3b8 search icons) across 14 files — icons now inherit themed colors, fixing ~40 dark-mode defects in one pass; .search-box svg themed centrally - Date formatting unified: fmtDate/fmtShort add the year when not current, en-US pinned everywhere; relTime fallback no longer system-locale Pages: - Overview: fabricated "+N pts" improvement badge replaced with honest open count; dead act-clickable hover class fixed to al-clickable; footer grid auto-fits (no more orphaned half-width card) - Alert Center: donut center readable in dark mode (was white-on-white); Clear button now also clears the date filter (was unremovable); raw "auto_resolved" enum humanized; KPI sub no longer raw toDateString() - User Management: Remove is now a red danger button with confirmation (was green, no confirm); Add is primary / Cancel secondary (was inverted); failure toasts show as errors (were green success) - Compliance: 3 duplicated wrong severity mappings -> shared sevTone() - FilterPresets: text ✕ glyphs -> X icons with aria-labels; Save is primary, cancel secondary (was inverted) - Setup: Save & Test is now the primary button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…try chips, mobile notice - Login page rebuilt on scoped .login-* CSS classes (keeps the deliberate dark brand look): no more inline-style hover mutation, system-ui font, or lock emoji (lucide Lock icon); focus-visible on the sign-in button; version shown in the footer; brand panel hides below 900px instead of squeezing - Toasts follow the app theme (card surface + border + status-token accents) instead of always-dark slate in light mode - Country flag emoji (render as bare letters on Windows) replaced with neutral ISO-code chips (.flag-emoji restyled); emoji removed from selects - Permanent "sign-in map" placeholder gradient deleted (was advertising an unbuilt feature) - Mobile: below 600px a notice explains navigation is limited instead of the sidebar silently disappearing - Sign out uses the real LogOut icon (was a rotated LogIn) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t domain - Needs Attention card removed from Overview per owner decision — the KPI pulse row leads the page again; Alerts section remains the triage surface - LineChart domain now pads below the data minimum so a real dip (e.g. Secure Score 51% -> 38%) reads as a dip, not a crash to zero: the lowest point never touches the plot floor; removed stale "as per Microsoft" comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass Alert engine (behavior change, owner-approved): - AlertEvaluator now keeps ONE open alert per policy: while the breach persists the open alert is updated in place (current metric value, affected entities, last-evaluated) instead of stacking a new row every cycle; legacy duplicates are collapsed automatically on the next evaluation (newest kept, rest retired as auto_resolved); a fresh alert + notification only fires after the previous one reached a terminal state. 3 new tests lock in the contract (37 total). Card UX QA: - Incidents: severity pills unified to tinted classes (defender/incident/ advisory rows all render the same pill; advisories no longer hardcoded "medium"); icon-only portal links got aria-labels + tooltips - Identity: "#EXT#" implementation string no longer injected into the search box from the Guest KPI; doubled badge+count in MFA card head merged into one badge - Email: KPI tiles scroll to their card instead of injecting magic search strings that silently zeroed every list - User menu: outside-click + Escape close, aria-haspopup/expanded, CSS classes instead of inline hover mutation - Error boundary: theme-aware friendly card; stack trace behind a "Technical details" disclosure instead of dumped raw - Card titles are now h2 (heading semantics; styled identically); Card accepts id for scroll anchors; th scope="col" across 12 files; remaining #dc2626/#22c55e/#6b7280 icon hexes -> status tokens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edupe, UX passes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third QA batch. - An Admin could demote themselves with a single change on the role dropdown. It applied instantly and removed the admin UI needed to undo it. The server already blocks removing the LAST admin, but with other admins present this was one mis-click. Self-demotion now confirms, naming exactly what disappears. - Vigil365's own collected alerts were called three different things across three surfaces: "Vigil365 Alerts" in the queue filter, "SecurityDB" in the CSV export, "In-App DB" in the coverage scorecard. One name everywhere. tsc clean; 49 frontend + 199 backend tests; build green; deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A notification deep-link for an alert not in the loaded set was consumed
silently: click through from a Teams card or email for an alert that has since
been resolved and you landed on the queue with no explanation. It now says the
alert is no longer active and shows the queue.
- countryFlag() had a hardcoded ~25-country map, so a sign-in from Poland,
Kenya, Malaysia or anywhere else rendered "—" beside the country name —
visually identical to missing data when the data was fine. Coverage is now
generated from Intl across all ISO 3166-1 alpha-2 codes, with aliases for the
forms Intl does not use (USA, UK, UAE). Unresolvable countries render no chip
at all rather than a dash, since the country name is already shown next to it.
The generated table needed two corrections, both caught by tests rather than
review:
- Intl resolves the non-standard "UK" to "United Kingdom" as well as "GB",
and the loop overwrote GB with UK — not a valid ISO code.
- "First writer wins" then failed the same way in reverse: "DD" (East
Germany) also resolves to "Germany" and sorts before "DE".
Deprecated aliases are now rejected by canonicalising the region subtag —
und-DD canonicalises to und-DE, so a code that does not survive
canonicalisation is not the current one for that country. Verified against
DD/UK/SU/CS.
73 frontend tests (was 49); tsc + build clean; deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lete - The Email page had one search input sitting in the MDO card header that also filtered the Quarantined, Mail flow and Malware cards. Typing to narrow MDO alerts silently emptied three unrelated cards. Scoped to MDO, matching the per-card pattern Devices already uses. Nothing deep-links a search into this page, so no navigation depended on the shared state. - Exports on paged or capped tables silently contained a subset while the card badge advertised the full count — a CSV that looks complete and is not is worse than no export. ExportDropdown takes an optional scopeTotal; when the loaded rows are fewer, the menu says what will actually be exported and the toast reports "N of M". Wired to Tenant Activity (server-paged, 50/page) and the unified queue (Vigil365 alerts capped at 200 — the total there is the filtered set plus the alerts the cap hid, since the other sources are fully loaded). Exporting the full server-side set needs a dedicated endpoint per page; this states the limit honestly rather than pretending it is not there. tsc clean; 73 frontend + 199 backend tests; build green; deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roke
An automated inline-style extraction had replaced every style={{...}} with a
data-inline-style attribute backed by a generated CSS rule. That is correct for
static styles, but 51 of them carried per-render values and a static class
cannot reproduce a computed one.
The damage:
- 20 had no generated rule at all, so the styling was simply gone. ProgressBar
(used across many pages) and the setup-checklist progress bar rendered
permanently blank — a computed width became an attribute with no width.
- The other 31 were worse: frozen to one hardcoded value, so gauges, chart
geometry, Trends metric colours and Compliance score bars rendered a single
wrong value rather than an obviously missing one.
Restored all 51 from HEAD via a hunk-based revert, keeping the 351 legitimate
static extractions. Line counts had shifted in five files because the tool
collapsed multi-line style objects, so positional mapping was unsafe — only
hunks that introduced a dynamic- reference were reverted. Then removed the 43
CSS rules left orphaned (31 dynamic, 12 unused static).
Verified: every one of the 197 remaining attributes resolves to a rule, no
dynamic- reference survives in any TSX, tsc clean, 74 frontend + 199 backend
tests, build and smoke green, deployed.
Note this does NOT get the app to a CSP without style-src 'unsafe-inline':
per-render values legitimately need the style attribute. Reaching that would
mean moving each dynamic value to a CSS custom property, which is still set
inline — so the goal itself needs rethinking rather than more extraction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy.ps1 targets a local install — hosts alias, self-signed cert, high port. This is the public counterpart for running on a real domain over 443. Preflight refuses to change anything unless it passes: elevation (443 and firewall rules both need it), the hostname's A record actually resolving to this connection's public IP, and 443 having no LISTENER — checked via Get-NetTCPConnection -State Listen, because netstat also lists outbound :443 connections and would false-positive. Then: writes appsettings.Production.json binding 0.0.0.0 (127.0.0.1 would be unreachable from outside) while preserving the existing connection string and AzureAd block, sets Auth:RedirectUri and CORS to the public URL, and adds an inbound firewall rule. Accepts a real -PfxPath; falls back to self-signed only so the plumbing can be verified, and says loudly that visitors will get a trust warning. Points at win-acme/certbot rather than downloading anything itself. Prints the three things the script cannot do: router port-forward, adding the SPA redirect URI in Entra (sign-in fails with AADSTS50011 without it), and supplying a real certificate. Also warns that a residential IP will drift and strand the A record. The header states plainly that publishing this changes the threat model — the README's "no inbound exposure by default" stops holding, /health and /api/auth/config answer anonymously, and there has been no third-party pen test. Verified by dry run: correctly failed on elevation and changed nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This connection is behind carrier-grade NAT on IPv4 — the traceroute shows the RFC 7335 service-continuity address (192.0.0.1) for four hops, so 152.59.38.32 is a shared carrier address and no inbound IPv4 can reach this machine. IPv6 is natively routable here and is the only path that accepts inbound connections. Kestrel now binds [::], which is dual-stack on Windows, instead of 0.0.0.0 which would have listened on the one protocol that cannot receive traffic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ime claim Found while verifying the public deployment. tsc had 13 errors and three test files were failing to load — the Vite build stayed green throughout because it does not type-check, so none of this was visible from a successful build. - services/utils.ts read localStorage at MODULE LOAD for the new timezone preference. utils is imported almost everywhere, so in any environment without storage that is a blank application rather than a missing preference — it had already taken down 3 test files (51 tests). Guarded with the same defensive shape as public/display-prefs.js; the toggle still works for the session when storage is blocked. - MdiAlertsData had been renamed to MdoAlertsData, which conflated Defender for IDENTITY with Defender for Office 365 — two different products. The renamed type was orphaned (no consumer) and referenced an MdoAlert that does not exist, while main.tsx and IdentityPage both wanted MDI. Restored. MY MISTAKE: this entered the tree in 56cc8c4, one of my own commits — I used git add -A and only dry-ran the deploy script that turn instead of re-running tsc, so I committed a regression I had not authored and did not notice. - IncidentsPage referenced EmptyState without importing it: a ReferenceError the moment that branch rendered, not merely a type error. - Badge was being passed an icon it did not accept. Supported it rather than stripping the icons — granted/missing/unknown reads faster with a glyph — plus the .badge-icon style, marked aria-hidden since the label carries the meaning. - Login page still advertised "Real-time visibility" and "Live Security Monitoring". Collection is a scheduled poll (15 min default); this is the same overclaim already removed from the README, spotted in the deployed UI. 74 frontend + 199 backend tests, tsc 0 errors, build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads the PFX password out of appsettings.Production.json so it does not have to be handled by hand, verifies the certificate is actually self-signed before touching the Root store (an issued cert that still warns has a chain problem, not a trust problem — installing it would hide that), and picks LocalMachine or CurrentUser based on elevation. States plainly that this only makes ONE machine stop warning, and that leaving a self-signed certificate on a security product trains people to click through TLS warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy-public.ps1 falls back to a self-signed certificate, which makes every
visitor click through a TLS warning on a security product. Nothing in the repo
actually walked you from that placeholder to a trusted certificate.
request-cert.ps1 drives lego and hands deploy-public.ps1 the resulting .pfx.
DNS-01 is the default because this deployment sits behind CGNAT with no A
record — it needs no inbound ports and no firewall changes. -Method http is
there for when port 80 is reachable and renewals should be automatable.
Captures two things that are easy to get wrong:
- lego 5.x flag placement. Only --help/--version/--log.*/--config are global;
--email, --domains, --dns and friends belong to the `run` subcommand.
`lego --email ... run` fails with "flag provided but not defined: -email".
- --pfx.format defaults to RC2, which modern Windows resists loading, so the
script pins SHA256.
Also drops the win-acme/certbot pointers from deploy-public.ps1: the winget
package name no longer resolves, so that guidance dead-ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first real run failed with NXDOMAIN for _acme-challenge.vigil365.in. Two separate problems, only one of which was visible. The TXT record never reached the zone — both authoritative nameservers returned NXDOMAIN, not an empty answer, so this was not propagation. The likeliest cause is pasting the FQDN into GoDaddy's Name field, which appends the zone and yields _acme-challenge.vigil365.in.vigil365.in. The second problem would have bitten even with a correctly saved record: lego's manual provider polls for 60s by default, while GoDaddy's minimum TTL is 600s. The default loses the race and reports "time limit exceeded", which reads as a DNS fault rather than a too-short timeout. Now raised to 600s, tunable via -PropagationTimeout. Adds -CheckTxt, which queries the zone's authoritative nameservers directly so "not saved" is distinguishable from "not propagated yet" — a recursive resolver caches NXDOMAIN and makes those two look identical. Run it in a second terminal to know when to press Enter instead of guessing. Adds -Method godaddy, which drives GoDaddy's API so there is no manual step and renewals are unattended. Gated on GODADDY_API_KEY/GODADDY_API_SECRET, and it fails fast when they are missing rather than spending a failed validation to find out. GoDaddy limits that API to accounts with 10+ domains or a Discount Domain Club plan, so -Method dns stays the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard asked for a public URL, wrote it into the Entra redirect URI and the CORS origin, and then bound the service to http://127.0.0.1:8080 regardless. So any hostname a customer typed was advertised everywhere and served nowhere. The only address that actually worked was the literal loopback URL — the one case Entra exempts from its HTTPS rule. There was no certificate step at all, and Security:RequireHttps was hardcoded false, so sign-in ran in the clear. Nothing in the product got a customer from "installed" to "reachable over HTTPS"; the README told them to go put a proxy in front of it. The wizard now asks where the certificate comes from, with three options that match how organisations actually hold them: one already in LocalMachine\My (listed, with hostname matches flagged), a .pfx file, or one Vigil365 generates and trusts on that server. The generated option is the default so nobody has to make a decision to get started, and the finish screen says plainly that other machines will still warn until it is replaced. The certificate is resolved before anything is installed — a wrong .pfx password discovered after the service is registered leaves a half-built install and an event-log entry to go hunting for. Also grants the service account read access to the private key. Windows keeps key ACLs separate from the certificate, and an admin importing one grants themselves access, not LOCAL SERVICE. Without this the service installs cleanly and then dies on startup, which is the most common way a perfectly good certificate still produces a dead site. And opens the firewall for the port, without which the site is reachable from the server itself and nowhere else. Verified by building the config in all four shapes (self-signed on 443, self-signed on a non-default port, store certificate, http loopback) and parsing each: the bind URL matches the advertised one, RedirectUri omits :443 but keeps :8443 since Entra matches redirect URIs by exact string, RequireHttps tracks the scheme, and the generated certificate carries the right subject, a SAN, and a usable private key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stions Auditing every step turned up three faults that each produce an install that looks successful and is not. The service runs as LOCAL SERVICE with Trusted_Connection, but SQL Express was installed granting sysadmin to BUILTIN\ADMINISTRATORS only. No login existed for the service account at all, so it could never open a connection. The installer now creates the login and the database while it still holds administrator rights, and grants db_owner on that one database rather than server-wide dbcreator. DataProtection keys were written under C:\Program Files\Vigil365\keys, which LOCAL SERVICE cannot write. The keyring could never persist, so every restart invalidated anything protected with it — including the Graph client secret saved on the Setup page. Keys now live in ProgramData with an explicit ACL. az ad app create ran unconditionally, minting a new Entra registration on every run. Since re-running the wizard is the documented way to replace a certificate, following our own instructions littered the tenant and stranded whichever registration was configured last. It now reuses an existing Vigil365 app. On the UX the user asked for: the wizard now opens with "who needs to reach this". Evaluating on one box binds loopback, skips certificates entirely (Entra allows http for loopback redirect URIs), and touches no firewall rule — so that path asks nothing beyond confirming the administrator. The hostname and certificate questions only appear for a network install, where they are load bearing. The remaining answerable questions are now answered: the first administrator is prefilled from the Azure CLI session, and an existing SQL instance is found in the registry and reused instead of installing a second one over it. Verified by generating the config for all four shapes and parsing each, with a regression assertion that KeyPath is never under Program Files, plus a runtime smoke test that the reworked XAML loads without a parse exception. Not fixed, and worth knowing: this still builds from the source tree with npm and dotnet publish, so it is a guided build rather than a self-contained installer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… server The wizard ran npm install, npm run build and dotnet publish against the source tree at install time. That made it a guided build, not an installer: the customer needed the repo, Node and the .NET SDK on their production server, and got whatever the toolchain resolved that day rather than the build we tested. The build moves to release time. scripts/build-installer.ps1 builds the SPA from the lockfile, publishes the API self-contained for win-x64, compresses it and embeds it in the installer, which is itself published self-contained and single-file. The result is one 121 MB Vigil365-Setup.exe that needs nothing on the target — no source, no Node, no .NET, not even the runtime. Install is now extraction. The service is stopped first, because an upgrade over a running service holds locks on the files being replaced and the resulting failure reads as corruption rather than "it is still running". Entries are checked against the install root before extraction; a zip is an untrusted format even when we produced it. Prerequisites drop from four to one. .NET and Node are gone because nothing is compiled on the server. Azure CLI stays, and only because the wizard registers the Entra application. The step now also verifies the payload is present, so a mis-built installer says so before installing SQL Express and registering an application. Removes GetRepoRoot and RunCommandAsync, dead once nothing shells out to a build. Verified end to end: the script produces a single file, the payload is embedded and readable at 50.8 MB, publish output contains wwwroot and hostfxr (so the SPA ships and the runtime travels with it), and the shipped exe launches. Not addressed: Azure CLI remains a dependency for Entra registration. Removing it means device-code auth and Graph REST calls from the installer, and would also let installs proceed where the operator lacks rights to create app registrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
request-cert.ps1 writes the ACME account key and issued certificates here. Committing either would publish a private key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems, all found by actually running the installer. The database step failed with "Incorrect syntax near 'QUOTENAME'". That was mine: EXEC() concatenates only string literals and variables, never function calls, so hardening the earlier working concatenation with QUOTENAME turned it into a syntax error. Rebuilt on sp_executesql, which takes a prepared variable and so combines with QUOTENAME. The statements move into constants purely so their syntax can be checked against a real server without executing them; they now pass SET PARSEONLY on SQL Express. The certificate list offered an Entra MS-Organization-P2P-Access certificate. Those carry Server Authentication and a private key, so every structural test passed, and they are still useless — the subject is a device GUID, no browser trusts the issuer, and they rotate about daily. Windows issues several such certificates to any Entra-joined or Intune-managed machine. Server Authentication is now required rather than merely not-prohibited (an absent EKU no longer counts as unrestricted), machine identity certificates are rejected by issuer and GUID subject, and a certificate that does not cover the address is labelled as such and never preselected. Failure left the wizard stuck on step three showing a raw exception. It now names what failed in plain language, gives specific steps for the database, certificate, Entra and payload cases, and offers Back to Configuration — which resets the run so a retry starts clean rather than resuming half-applied state. Configuration gains a Back button to Prerequisites, and there is a Copy details button for the log. Verified: all three SQL batches parse on the local SQL Express under PARSEONLY, the real certificate store now yields no device certificates, config still generates correctly for all four shapes, and the rebuilt 121 MB installer launches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An install reported "Installation Complete!" and left nothing running. The
service had never been created.
sc was invoked through cmd.exe, which re-parses quotes, so the quoted executable
path inside the quoted binPath value came apart. sc received:
argv[3] = <C:\Program>
argv[4] = <Files\Vigil365\M365SecurityDashboard.Api.exe --environment Production>
binPath=C:\Program, plus a stray argument. It fails, and RunCommand discards
exit codes, so nothing noticed and the wizard advanced to the success screen.
The firewall rule and the ProgramData key directory from the same step were both
created, which is what makes this a silent failure rather than an obvious one.
sc.exe is now invoked directly with the inner quotes escaped, giving
argv[3] = <"C:\Program Files\...\M365SecurityDashboard.Api.exe" --environment Production>
and its exit code is checked. stop and delete stay unchecked because they
legitimately fail on a clean machine.
Creating the service is still not proof of anything, so the install now waits
for it to report RUNNING and fails with the likely causes if it does not. A
service that starts and dies a second later returns success from sc start, so
the exit code alone never established that this worked.
Verified with a stand-in binary that prints its argv, since sc opens the service
manager before parsing arguments and so cannot be used to test quoting without
administrator rights.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service installed and then died on startup:
SqlException: Cannot insert explicit value for identity column in table
'NotificationSettings' when IDENTITY_INSERT is set to OFF.
NotificationSettings and GraphConfig are singleton rows whose model defaults the
key to 1, but InitialCreate made both keys identity columns by EF convention.
Assigning a key EF believes the store generates makes it send that key in the
INSERT, and SQL Server refuses. NotificationSettings is seeded during startup, so
every fresh install crashed before serving a request; GraphConfig would have
failed the moment anyone saved Graph credentials on the setup page. Existing
installs were unaffected because the legacy DDL creates those columns without
identity and their rows already exist — which is why this only ever appeared on
new machines.
Both keys are now configured ValueGeneratedNever, matching the model and the
legacy DDL. The migration is hand-written: the scaffolded AlterColumn emits a
plain ALTER COLUMN and SQL Server rejects it with "To change the IDENTITY
property of a column, the column needs to be dropped and recreated". It rebuilds
via a temporary column rather than from a column list, so later migrations adding
columns cannot silently rot it.
Verified against a real SQL Express database: migrations apply with no retries
and /health returns 200 with database ok. The first attempt looked like it had
worked because the process stayed alive — it was actually spinning in the
30-attempt "database not ready" loop.
Also, from the same install attempt:
Begin Installation appeared to hang on Configuration and then jump to a
part-finished progress bar. Every step ran on the UI thread, so the panel switch
could not paint until the first await. The step now yields before starting and
the blocking work runs on background threads, with the values it needs captured
from the controls first. The same fix applies to the Azure CLI lookup that
stalled the move to Configuration.
The administrator email is no longer pre-filled. Who gets full access is a real
decision, and a pre-filled value is the one people click past.
build-installer.ps1 failed on a second consecutive run because Move-Item -Force
does not reliably replace an existing destination; it now clears the target
first, and names the still-running installer instead of reporting access denied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sign-in button did nothing and the page showed "interaction_in_progress" with a link to the MSAL docs. MSAL records that an interactive sign-in is underway in sessionStorage the moment a redirect starts, and clears it when the redirect returns. If it never returns — the tab was reloaded mid-flight, the user backed out of the Microsoft page, or a TLS warning interrupted the round trip — the flag stays set and every later attempt throws rather than navigating anywhere. Nothing in the app cleared it, so the only way out was clearing site data by hand, which is not something to ask of someone signing in to a dashboard. Sign-in now clears the stale flag and retries once. Startup also clears it when handleRedirectPromise has settled with no account, since at that point nothing can genuinely be in flight — so a reload fixes it too, and the button works on the first click rather than the second. Re-running the installer can register a different application, which leaves the previous client id's keys behind; the cleanup matches on the interaction-status key rather than a specific client id so that debris goes as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one The wizard registered the Entra application in whichever tenant the Azure CLI happened to be signed into, and never mentioned which that was. On the machine this was found on, three tenants were in play: the administrator's (infrassistsandbox.onmicrosoft.com), the operator's Azure sign-in (softvan.in), and the one the CLI was actually pointed at — a third, unrelated directory. The application was therefore created in the wrong directory as a single-tenant registration, so the people it was installed for could never sign in. The install reported success and the failure only surfaced at the login page. The tenant is now part of the configuration, defaulted from the administrator's email domain and editable for tenants whose mail domain differs. Before anything else runs it is resolved to a directory id through the public OpenID discovery document, which needs no sign-in — so a misspelt tenant fails immediately rather than after SQL Server has been touched. The CLI is then signed in to that specific tenant, with --allow-no-subscriptions because a Microsoft 365 tenant often has no Azure subscription and the CLI otherwise refuses a valid sign-in. Afterwards the directory id is verified, and a mismatch stops the install and says which tenant it landed in. Registration reads that verified id rather than re-reading the ambient context, which is what allowed the two to diverge. Verified against the live endpoint: the two named tenants resolve to distinct directory ids, both different from the CLI's current one, and an invalid tenant is rejected with a 400 rather than being accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two executables in there (146 MB and 64 MB) exceeded GitHub's 100 MB file limit and blocked every push. They are build artifacts, superseded by dist/, which was already ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tenant check worked — it correctly caught the CLI being signed in to a different directory — and then the install hung on "Signing in to …" with an empty log and nothing to click. RunCommand hides the console and redirects its streams. That is right for every other command it runs and fatal for this one: az login is interactive, opening a browser and sometimes falling back to printing a device code. With the window hidden there is nothing to read and nothing to answer, so it blocked indefinitely. Sign-in now runs in a visible console, and the log says a window has opened before it appears. A non-zero exit gives the exact az command to run by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
master fixed three real problems in the manual build instructions — the clone URL still said YOUR_ORG, a Copy-Item step copied a dist folder Vite no longer produces, and the production connection string lacked Encrypt/TrustServerCertificate. This branch had rewritten that whole part of the README around the new setup wizard, and in doing so dropped the Setup, Development and Production Deployment sections entirely — while Prerequisites still advertised a "Building from source (Option 3)" path that no longer existed. Resolving in favour of either side would have lost something real. Both are kept: the wizard remains the primary install, and master's sections return as "Build from source (Option 3)" with all three of its fixes intact. SECURITY.md, docs/THREAT_MODEL.md and docs/PROJECT_SUMMARY.md merged cleanly and are taken from master unchanged.
The installer registered only the sign-in application. A new install could
authenticate people and then show them an empty dashboard: every collector
failed on authorization because the app had no Graph permissions, and there was
no client secret for app-only collection, so the Setup page demanded one the
operator had to mint in the portal by hand. The README pointed at a
register-app.ps1 for this; that script does not exist.
The wizard now, against the tenant it just signed in to:
- Adds the 14 required Graph application permissions (plus attack-simulation
as optional). Names are resolved against the tenant's own Graph service
principal rather than hard-coded GUIDs, so a permission the tenant does not
offer is reported instead of failing as an opaque "invalid value".
- Grants admin consent, retrying with a backoff. Consent routinely fails on
the first try because the permissions and service principal we just created
have not replicated yet; the old code reported consent as granted even when
it failed, because RunCommand discarded the exit code.
- Creates a client secret and writes it into the Graph config, so collection
starts on first run with nothing further to do.
Adds RunCommandChecked, which actually reports success. Several az steps
branched on RunCommand, which only tells you the process ran, not that it
worked. The app PATCH that sets permissions and the SPA redirect URI now stops
the install on failure instead of continuing into a broken sign-in.
Config values that can contain arbitrary characters (the secret, the connection
string) are emitted through JsonSerializer rather than hand-escaping two
characters, so every character class round-trips.
CI: the solution now includes the WPF installer, which does not build on Linux
(NETSDK1100). The Linux job builds the API and tests directly and only restores
the solution for the vulnerability audit (EnableWindowsTargeting makes that
resolve); a new windows-latest job compiles the installer so a break there fails
CI.
Verified: all 15 permissions resolve against a real 707-role Graph SP and
produce valid PATCH JSON (15 unique Role GUIDs); the full appsettings parses in
every install shape; the secret round-trips through JSON across quote,
backslash, control and unicode characters; installer builds clean; ci.yml is
valid YAML with both jobs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Once the CI build was fixed, the NuGet vulnerability audit ran to completion and flagged four High-severity advisories in transitively-pulled packages across all three projects: API System.Security.Cryptography.Xml 8.0.3 GHSA-g8r8-53c2-pm3f Installer System.Formats.Asn1 5.0.0 GHSA-447r-wph3-92pm Tests System.Net.Http 4.3.0 GHSA-7jgj-8wvc-jh57 Tests System.Text.RegularExpressions 4.3.0 GHSA-cmhx-cq75-c4mj Each is pinned to a patched version via an explicit top-level PackageReference — the standard NuGet remediation for a vulnerable transitive dependency. The test project ships nothing, but a security product should not carry known-vulnerable packages even in its test tree, and the audit covers the whole solution. Verified: the solution audit reports no vulnerable packages, all three projects build, and all 199 .NET tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Certificate-thumbprint auth threw a cryptic CryptographicException on Linux instead of the clear "thumbprint not found" message. Opening LocalMachine\My read-only succeeds on Windows but throws on Linux, where that store does not exist — and the exception escaped the loop, so it also masked a certificate that was present in CurrentUser\My. This is the Docker deployment path, not a test-only concern: a Linux host using certificate auth would have hit the cryptic error. Each store open is now guarded, so an unavailable store is skipped and lookup continues to the other one and then to the clear error. Surfaced by CI: the test asserting the clear error only ran on Linux once the build and audit steps were fixed, and it caught this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-running the wizard against an existing Vigil365 registration failed with: CannotDeleteOrUpdateEnabledEntitlement: Permission (scope or role) cannot be deleted or updated unless disabled first. The PATCH always sent an api.oauth2PermissionScopes array carrying a freshly generated GUID for access_as_user. On a reused app that already exposes that scope (enabled), Entra reads a new GUID as replacing an enabled entitlement and refuses — so the whole registration PATCH failed, taking Graph permissions and the redirect URI down with it, and the install stopped at "could not register with Microsoft Entra". The scope is now defined only when it is genuinely absent: a new app always, a reused app only if it does not already expose access_as_user. When it is already present the PATCH omits the api block entirely and just sets the SPA redirect URI and the requested Graph permissions, which carry no enabled-entitlement constraint. Verified both PATCH shapes are valid JSON: scope-defined (new app) includes the api block, scope-omitted (reused app) does not, and both always carry spa.redirectUris and requiredResourceAccess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Setup page used <EmptyState/> but never imported it, so opening localhost:8080/#/setup threw "ReferenceError: EmptyState is not defined" and the error boundary took over the page. It shipped because the build does not type-check: vite/rollup bundles an undefined identifier without complaint, and the tsc step that would have caught it runs in CI against committed code — while this usage lived in an uncommitted change CI never saw. tsc now passes across the whole client, and the Setup page renders the Graph-credentials form (verified in a browser: form present, console clean). Committing so CI type-checks this file going forward. This also carries the setup-form validation work that was uncommitted alongside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rolls up the post-checkpoint working-tree changes and makes the whole tree
type-check and build, so it is releasable as one unit.
Features:
- Executive digest now carries each alert's Category, Status and Assigned To in
both the HTML and CSV, so a digest reader can triage without opening the app
(DigestBuilder, ReportsPage preview, types).
- Policy dry-run gains a look-back window selector and a clear action
(PolicyDryRun).
- Licenses page distinguishes "request timed out / API unreachable" from a
permission error, instead of showing one generic message (LicensesPage).
Fixes required to build the checkpointed work (it was never type-checked — the
checkpoint commit was never pushed, so CI never ran tsc on it):
- AlertCenterPage used lowercase policy severities ("medium") against the
capitalised AlertPolicy.severity union. The app deliberately keeps two
conventions — policies/alerts are capitalised (the AlertSeverity enum),
triggered alerts are lowercased by the backend (PolicyBuilder .ToLowerInvariant)
— so only the policy-side literals and the severity <select> options were
capitalised; the triggered-alert filters stay lowercase, which is correct.
- EntityPage now accepts the fromAlertId prop that main.tsx passes for
drill-down-from-alert navigation.
- DigestPdfRendererTests constructed TopAlert without the new Category/Status/
AssignedTo fields, breaking the test build.
Verified: tsc clean, 74 client tests, 199 .NET tests, client and .NET builds,
and both the NuGet and npm vulnerability audits pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folds the Unreleased notes and the old 1.0.0 stub into a single dated 1.0.0 section, and adds this release's installer, Entra-provisioning, digest- categorisation, vulnerability-patch and first-run-fix entries. README now links the exe to the GitHub Releases page rather than saying "download" with no source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Vigil365 gains Microsoft Entra sign-in, a large amount of dashboard and QA work, and — most of the recent commits — a real installer. It is a big branch (109 commits, ~220 files); the sections below are ordered by what a reviewer most needs to look at.
A self-contained installer
The wizard used to run
npm install,npm run buildanddotnet publishagainst the source tree on the customer's server. That made it a guided build rather than an installer: it needed the repo, Node and the .NET SDK on a production box, and produced whatever that machine's toolchain resolved that day instead of the build we tested.The build moved to release time.
scripts/build-installer.ps1builds the SPA from the lockfile, publishes the API self-contained forwin-x64, compresses it into a payload embedded in the installer, and emits a single ~121 MBdist/Vigil365-Setup.exethat needs nothing on the target — no source, no Node, no .NET, not even the runtime. Install is now extraction, with the service stopped first and every zip entry checked against the install root.Prerequisites went from four to one. Azure CLI remains, only because the wizard registers the Entra application.
Bugs found by running it, not reading it
Each of these produced an install that reported success and did not work:
LOCAL SERVICEwithTrusted_Connection, but SQL Express grants sysadmin toBUILTIN\ADMINISTRATORSonly, so no login existed for it. The installer now creates the login and database while it still holds administrator rights, scoped todb_owneron that one database.Program Files, whichLOCAL SERVICEcannot write. The keyring never persisted, so every restart invalidated anything protected with it — including the Graph secret saved on the Setup page. Moved toProgramDatawith an explicit ACL.scwas invoked throughcmd.exe, which re-parsed the quotes and split the path (binPath=C:\Programplus a stray argument).RunCommanddiscarded exit codes, so nothing noticed.sc.exeis now called directly, its exit code checked, and the install waits for the service to reportRUNNING.NotificationSettingsandGraphConfigare singleton rows whose model fixes the key at 1, butInitialCreatemade both keys identity columns by EF convention, so the explicit key was rejected. Existing installs were unaffected because the legacy DDL creates those columns without identity — which is why this only ever appeared on new machines. Both are nowValueGeneratedNever, with a hand-written migration (the scaffoldedAlterColumndoes not work; SQL Server cannot change IDENTITY in place).HTTPS and certificates
Sign-in goes through Entra, which refuses plain
http://redirect URIs for anything but loopback, so HTTPS is not optional once the app is reachable by name. The wizard opens with who needs to reach this: a loopback evaluation install binds127.0.0.1, needs no certificate and touches no firewall rule; a network install asks for a hostname and takes a certificate from the Windows store, a.pfx, or one it generates and trusts locally.The store list initially offered Entra
MS-Organization-P2P-Accesscertificates — they carry Server Authentication and a private key, so they passed every structural test while being useless (device GUID subject, untrusted issuer, daily rotation). Machine-identity certificates are now excluded, and a certificate that does not cover the address is labelled and never preselected.scripts/request-cert.ps1is new, for publicly-reachable installs that want a real Let's Encrypt certificate.Reviewer notes
installer-bin/held two committed executables (146 MB and 64 MB) that exceeded GitHub's file limit and blocked every push. They are build output, superseded by the ignoreddist/. Stripped from the branch and added to.gitignore. The pre-rewrite history is tagged locally asbackup/pre-filter-installer-bin.HTTP 200on/health; the shipped exe launches. A full unattended end-to-end install on a clean machine has not been run.🤖 Generated with Claude Code