From 973d0eb994fb553a19729b9c24c26ba762339f57 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 14:59:07 -0400 Subject: [PATCH 001/197] docs: add ADR-006 FIPS password hashing (pre-review draft) Draft ADR for replacing bcryptjs with PBKDF2 via node:crypto. Committed before adversarial review revisions so the research and industry survey are preserved. Known issues addressed in the following commit: - bcrypt fallback not gated on FIPS mode (STIG V-222571 finding) - CMVP certificate numbers incorrect - STIG mapping table defects - Admin seeder omitted as a hash call site Authored by: Aaron Lippold --- ...adr-006-fips-compliant-password-hashing.md | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/adr-006-fips-compliant-password-hashing.md diff --git a/docs/adr-006-fips-compliant-password-hashing.md b/docs/adr-006-fips-compliant-password-hashing.md new file mode 100644 index 0000000000..50080419a6 --- /dev/null +++ b/docs/adr-006-fips-compliant-password-hashing.md @@ -0,0 +1,348 @@ +# ADR-006: FIPS 140-3 Compliant Password Hashing + +**Status:** Proposed +**Date:** 2026-07-29 +**Author:** Aaron Lippold +**Branch:** `feature/fips-compliant-password-hashing` +**Base:** `master` @ `2e1649c9e` + +## Context + +Heimdall2 stores user passwords hashed with bcrypt via `bcryptjs` (pure JavaScript, cost factor 14). API keys are stored as bcrypt hashes of JWT signatures. This is a FIPS compliance gap: + +1. **bcrypt is not FIPS-approved.** Bcrypt uses Blowfish, which is not listed in SP 800-140C. Neither scrypt nor Argon2 are approved either — Argon2 is planned for a revised SP 800-132 but no draft exists. + +2. **bcryptjs bypasses the FIPS boundary entirely.** It is pure JavaScript — it never calls `node:crypto` or OpenSSL. On a FIPS-enabled host it runs *undetected and unblocked*, outside the validated module. This failure mode is well known: Chainguard's `node-fips` image documentation flags bcryptjs by name, and SafeLogic's Node.js FIPS guidance warns that "even if Node.js is configured correctly at the core level, parts of the application may still operate outside the FIPS boundary," naming bcrypt explicitly. + +3. **Customers on STIG-hardened systems cannot pass ASD STIG checks.** V-222542 (High) requires cryptographic password storage via FIPS-validated algorithms; V-222571 (Medium) requires FIPS-validated modules for hashing. + +4. **FedRAMP deadline.** All FIPS 140-2 certificates move to the NIST Historical List in **September 2026**; only 140-3 modules are accepted for new federal procurement thereafter. Drivers include FISMA, OMB Circular A-130, NIST SP 800-53 and SP 800-171. + +### Prior art in this repository + +**`fips_compliance` branch (2023, Amndeep Singh Mann / George Dias):** +- `--force-fips` Node startup (`cmd.sh`, `start:fips` script) and Postgres `scram-sha-256` +- `libs/common/src/crypto/crypto.ts` — PBKDF2-SHA256, 600k iterations, 32-byte salt, `useBCrypt` fallback flag +- Defects: synchronous `pbkdf2Sync` blocks the event loop; no self-describing format (iterations unrecoverable from the hash); `===` string comparison rather than `timingSafeEqual`; iterations hardcoded in two places + +**Heimdall v3 (`a52f6ceb`, 2026-03-13, `mitre/heimdall` repo):** +- PBKDF2-SHA512 in `apps/backend/src/auth/password.ts`, format `pbkdf2-sha512$iterations$salt$key` +- Async `pbkdf2`, `timingSafeEqual`, env-configurable algorithm and iterations +- **Hard-rejects** non-FIPS hashes; returns a bare boolean because better-auth's `emailAndPassword.password.verify` contract requires it (`auth.ts:44-45`) + +### Industry survey + +Seven comparable projects were surveyed to validate the approach: + +| Project | Algorithm / iterations | Migration strategy | Params in hash? | +|---|---|---|---| +| **GitLab** | PBKDF2-SHA512 @ 20,000 | Lazy rehash on sign-in | Yes | +| **Keycloak** | PBKDF2-SHA512 @ 210,000 | Lazy rehash on login (`rehashPasswordIfRequired`) | Yes | +| **Mattermost** | PBKDF2-SHA256 @ 600,000 | Lazy rehash on login (`App.migratePassword`) | Yes — PHC | +| **Django** | PBKDF2-SHA256 @ 600k–1.8M by version | Lazy rehash via `setter` callback | Yes | +| **Spring Security** | PBKDF2-SHA256 @ 310,000 | Opt-in via `UserDetailsPasswordService` | Yes — `{id}` prefix | +| **Grafana** | PBKDF2-SHA256 @ **10,000** | **None** | **No** | +| **Vault** | bcrypt, **no FIPS path** | N/A | bcrypt native | + +Two conclusions drive this ADR. First, **lazy rehash-on-login is the industry standard** — five of seven implement it; it is not a novel approach. Second, **Grafana is the cautionary tale**: PBKDF2 from day one, technically FIPS-shaped, still stuck at 10,000 iterations (well below OWASP's 600,000) with no upgrade path, precisely *because* the parameters were never encoded in the stored hash. Encoding parameters is not a nicety; it is what makes the scheme maintainable. + +## Decision + +### 1. Replace bcrypt with PBKDF2 via `node:crypto` + +**Algorithm:** PBKDF2-HMAC-SHA-512 default; `sha256` and `sha384` selectable +**Iterations:** 600,000 default, configurable, floor of 100,000 enforced +**Salt:** 32 bytes from `crypto.randomBytes()` — exceeds SP 800-132's 128-bit minimum +**Derived key:** matches digest width (64 / 48 / 32 bytes) + +**Why PBKDF2:** the only NIST-approved password KDF (SP 800-132). Approved PRFs are HMAC with SHA-2 (FIPS 180-4) or SHA-3 (FIPS 202). + +**Why `node:crypto`:** it delegates to OpenSSL, the FIPS-validated module on RHEL (CMVP #4985). The operation therefore runs *inside* the validated boundary, satisfying V-222571. TuxCare's guidance to federal agencies is explicit that vendors must use validated modules rather than build proprietary implementations. + +**On the iteration count.** OWASP 2024 gives 210,000 for PBKDF2-SHA512 and 600,000 for SHA256. We use 600,000 for SHA512 — roughly 3× the OWASP floor and 30× GitLab's production value. This is deliberate: the cost is one-time per login (~200-400 ms, comparable to bcrypt cost 14), and the self-describing format means it can be lowered later without invalidating stored hashes. + +### 2. PHC string format + +``` +$pbkdf2-sha512$i=600000$$ +``` + +This follows the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) (the spec moved from `P-H-C/phc-string-format` to C2SP) and matches the `phc-pbkdf2` npm implementation. Base64 uses the standard alphabet with padding stripped. + +Note PHC is a rigorously-defined **subset** of Modular Crypt Format, not a superset. bcrypt's `$2b$14$...` is valid MCF but invalid PHC. That is fine — both live in the same column and are distinguished on parse. + +Three reasons this beats v3's `pbkdf2-sha512$600000$salt$key`: + +1. **The leading `$` unifies the namespace.** `$` appears in no base64 alphabet (standard uses `A-Za-z0-9+/=`) nor in bcrypt's radix-64, so splitting is unambiguous. Dispatch becomes a single lookup on `parts[1]` — `'2b'` versus `'pbkdf2-sha512'` — instead of two parsers for two shapes. +2. **Named parameters** (`i=600000`) are self-documenting and extensible. +3. **Argon2 slots in unchanged.** `$argon2id$v=19$m=...$...` parses with the same code the day NIST approves it. + +Note there is no *registered* PHC identifier for PBKDF2 — the spec defines only the argon2 variants. Three de-facto formats exist in the wild (npm `phc-pbkdf2` uses `i=`; Mattermost uses `$pbkdf2$f=SHA256,w=600000,l=32$`; Python passlib uses bare rounds with a non-standard `ab64` alphabet). We follow `phc-pbkdf2` as the JavaScript-ecosystem convention. + +### 3. Graceful migration — unconditional lazy rehash on login + +`verifyPassword` dispatches on the stored format: + +| Stored prefix | Path | Returns | +|---|---|---| +| `$pbkdf2-sha*$` | PBKDF2, params read from the hash | `{valid, needsRehash: false}` | +| `$2a$` / `$2b$` / `$2y$` | `bcryptjs.compare()` fallback | `{valid, needsRehash: valid}` | +| anything else | reject without throwing | `{valid: false, needsRehash: false}` | + +Verification always reads parameters **from the stored hash**, never from configuration — so changing `PASSWORD_HASH_ITERATIONS` never invalidates existing hashes. + +**The rehash must be unconditional in the login path.** We follow Django's model (a `setter` callback invoked inline by `check_password`) rather than Spring's (`UserDetailsPasswordService`, which must be separately wired). Spring's design **silently no-ops when unwired** — a migration that quietly does nothing is precisely the failure mode to avoid in compliance work. + +Consequences: +- No forced password resets; users migrate transparently on next login +- Dormant accounts keep bcrypt hashes indefinitely — reporting query and eventual forced reset required +- `bcryptjs` stays installed until migration completes +- API keys migrate on next validation by the same mechanism + +**NIST 112-bit floor:** SP 800-132's strength requirement translates to a 14-character minimum, enforced by Keycloak strict mode and Mattermost's FIPS build. Heimdall's existing **15-character** STIG default already clears this — no change needed, but it is a compliance checkpoint worth recording. + +### 4. Environment variables + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | PBKDF2 digest | +| `PASSWORD_HASH_ITERATIONS` | integer ≥ 100000 | `600000` | PBKDF2 iterations | +| `FIPS_MODE` | boolean, optional | unset | Startup assertion override (see §7). Name follows GitLab's `Labkit::FIPS` convention. | + +**Relationship to `libs/password-complexity`:** that library is plain JavaScript with **hardcoded, non-configurable** rules — 15-character minimum, all four character classes, no 4+ consecutive characters of one class. It has no environment variables. Password *complexity* is orthogonal to password *hashing*; this ADR does not change it, and making it configurable is out of scope. + +### 5. Node.js FIPS mode + +Add opt-in `--force-fips` support: a `start:fips` script, `FIPS_ENABLED` handling in `cmd.sh`, and `NODE_OPTIONS` support for the RPM systemd unit. + +Facts verified against Node's `BUILDING.md`, `doc/api/cli.md`, and `src/node_options.cc`: + +- **A custom Node build is not required.** BUILDING.md states plainly: *"It is not necessary to rebuild Node.js to enable support for FIPS."* Under OpenSSL 3, FIPS is a runtime-loadable provider. Stock nodejs.org binaries need `openssl fipsinstall` → `fipsmodule.cnf` plus `OPENSSL_CONF` and `OPENSSL_MODULES`; without a configured provider, startup fails. +- **Our base image needs none of that.** `Dockerfile:1` already uses `registry.access.redhat.com/ubi9/nodejs-22-minimal:1`. RHEL's Node is a `--shared-openssl` build, so it inherits FIPS from the system OpenSSL automatically. Had heimdall2 been on a Debian or Alpine `node:` image with statically-bundled OpenSSL, `--force-fips` would have been compliance theater. +- **Node never reads `/proc/sys/crypto/fips_enabled`.** Host inheritance is a property of the build, not a runtime check. +- `--enable-fips` can be undone by `crypto.setFips(false)`; `--force-fips` cannot. Under `--force-fips`, `setFips()` triggers a native `CHECK()` that **aborts the process** — it does not throw. Never call it. +- `--openssl-legacy-provider` restores MD4/RC4/DES and will **not** rescue a genuine FIPS denial; its interaction with FIPS mode is undocumented. + +When debugging, do not conflate the two error families: `ERR_OSSL_EVP_UNSUPPORTED` is an OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for FIPS` is a real denial. + +### 6. Dependency audit for incidental non-approved hashing + +MD5 and SHA-1 commonly appear in *non-security* roles — ETags, cache keys, checksums, fingerprints — and still fail under FIPS. GitLab hit this repeatedly (S3 content-MD5, SSH fingerprints, Maven/Gradle checksums). Python solved its equivalent with `usedforsecurity=False`; **Node has no such escape hatch**, so offending calls must be replaced outright. + +Audit results for heimdall2: + +| Component | Status | +|---|---| +| `apps/backend/src`, `apps/backend/config`, `libs/common`, `libs/password-complexity` | **Clean** — no `md5`/`sha1`/`createHash` | +| `uuid` | **Safe** — only `v4` (random) is used; `v3`/`v5` would use MD5/SHA-1 | +| `@aws-sdk/*` | **Not in the backend** — S3/STS are browser-side (`apps/frontend`), `client-config-service` is in `libs/hdf-converters`, which the backend does not import | +| **Express ETag** | **Breaks.** We run `NestExpressApplication` (`main.ts:3`). Express generates ETags by default and the `etag` package uses MD5 ([jshttp/etag#17](https://github.com/jshttp/etag/issues/17)). Must be disabled or replaced with a SHA-256 generator. | +| **`pg` MD5 auth** | **Breaks** against Postgres configured for md5 ([node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706)) — see §8 | + +A runtime audit under `--force-fips` is required, since static analysis cannot see into transitive dependencies. + +### 7. Startup FIPS assertion + +When FIPS is expected, the application must **verify** it rather than assume it. GitLab's Workhorse shipped without the required build tag and `fips.Enabled()` returned **false with no error** — the system reported healthy while operating outside the boundary. Silent boundary bypass is the defining failure mode of FIPS work. + +On boot, when `FIPS_MODE` is truthy (or `--force-fips` is set), assert `crypto.getFips() === 1` and fail fast with a clear message otherwise. `crypto.getFips()` is the only reliable runtime check. + +### 8. PostgreSQL FIPS compatibility + +Postgres configured for `md5` authentication breaks the `pg` driver under FIPS, because OpenSSL refuses MD5. Postgres **14+ defaults to `scram-sha-256`** and needs no change. + +Scope is narrower than the 2023 branch suggests: `docker-compose.yml:3` already pins **`postgres:17`**, so the default stack is unaffected. The remaining exposure is RHEL 8 AppStream (Postgres 13) and pre-existing customer databases. This is therefore **documentation plus RPM setup detection**, not a compose change. For those deployments: + +``` +POSTGRES_HOST_AUTH_METHOD=scram-sha-256 +POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 +``` + +## STIG and Compliance Mapping + +| Rule | Severity | Requirement | Satisfied by | +|---|---|---|---| +| **V-222542** (ASD) | High | Passwords stored as salted iterated hash via FIPS-validated algorithm; MD5 prohibited | PBKDF2-SHA512, 600k iterations, 32-byte salt, via OpenSSL FIPS provider | +| **V-222543** (ASD) | High | Passwords transmitted only cryptographically protected | Unchanged — TLS enforced via Helmet | +| **V-222571** (ASD) | Medium | FIPS-validated modules for hashing | `crypto.pbkdf2` in-boundary; bcryptjs phased out | +| **V-222570** (ASD) | Medium | FIPS-validated modules for signing | JWT HMAC-SHA256 — already compliant | +| **V-230223** (RHEL 8) | High | System-wide FIPS crypto policy | UBI9 base + `--force-fips` + startup assertion | +| **V-258241** (RHEL 9) | High | As RHEL 8, plus SHAKE-256 | Same | + +**FedRAMP:** SC-13, SC-28, IA-7. Deadline September 2026. + +## Implementation Plan + +### Phase 1: Core module + +**Configuration split.** Only `hashPassword` needs configuration — `verifyPassword` reads its parameters from the self-describing hash. This permits pure functions with a thin injectable on top: + +- `apps/backend/src/crypto/password.ts` — pure functions, options as parameters with FIPS defaults. Usable from seeders and scripts with no DI container. +- `apps/backend/src/crypto/password.service.ts` — NestJS injectable reading the existing `ConfigService`. + +This avoids module-scope `AppConfig` instantiation, which would read `.env` from disk at import time and repeat a mutation pattern this codebase has deliberately moved away from. + +```ts +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export interface PasswordHashOptions { + algorithm?: PasswordHashAlgorithm; // default 'sha512' + iterations?: number; // default 600000 +} + +export interface PasswordVerifyResult { + valid: boolean; + needsRehash: boolean; +} + +export function hashPassword( + password: string, + options?: PasswordHashOptions +): Promise; + +export function verifyPassword(args: { + hash: string; + password: string; +}): Promise; +``` + +**Implementation landmines — each requires a test:** + +1. **`crypto.timingSafeEqual` throws on length mismatch** — `RangeError` / `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`. Guard lengths and return `false`; never let it throw. Length is not secret here (it is fixed by the stored parameters). +2. **`Buffer.from(str, 'base64')` is lenient** — it silently discards invalid characters, so a corrupt hash decodes into plausible-looking garbage. Validate by re-encoding and comparing. +3. **Parse defensively** — assert `parts[0] === ''` (leading `$`) and an exact field count. Never index blindly. +4. **Never call `crypto.setFips()`** — aborts the process under `--force-fips`. + +**Tests:** format conforms to PHC; verify correct/incorrect password; unique salt per hash; iteration floor enforced; bcrypt hash verified via fallback; `needsRehash` true only for bcrypt; malformed/truncated/empty hash rejected without throwing; length-mismatch guard; base64 leniency guard; service applies env vars and defaults. + +### Phase 2: Call-site migration + +Seven sites, line numbers verified against `master` @ `2e1649c9e`. + +**`users.service.ts`** (import line 9): + +| Line | Function | Change | +|---|---|---| +| 66 | `create()` | `hash(password, 14)` → `passwordService.hash(password)` | +| 89 | `update()` | `hash(password, 14)` → `passwordService.hash(password)` | +| 126 | `remove()` | `compare()` → `verifyPassword()`; `.valid` only (account is being deleted) | + +**`authn.service.ts`** (import line 7): + +| Line | Function | Change | +|---|---|---| +| 53 | `validateUser()` | `compare()` → `verifyPassword()`; **rehash + save when `needsRehash`** — primary migration path | +| 75 | `validateApiKey()` | `compare(JWTSignature, ...)` → `verifyPassword()`; rehash + save `matchingKey.apiKey` | +| 208 | `testPassword()` | `compare()` → `verifyPassword()`; `.valid` only (caller sets a new hash anyway) | + +**`apikey.service.ts`** (import line 3): + +| Line | Function | Change | +|---|---|---| +| 43 | `create()` | `hash(JWTSignature, 14)` → `passwordService.hash(JWTSignature)` | + +#### Constraint: rehash must not touch password lifecycle fields + +`user.model.ts` declares `passwordChangedAt` (line 68) and `forcePasswordChange` (line 55). A transparent rehash changes only the *stored representation* — **the password itself has not changed.** + +The update MUST write `encryptedPassword` only. It must NOT touch `passwordChangedAt` or `forcePasswordChange`. Writing `passwordChangedAt` would silently reset the password-expiry clock for every migrating user — a security regression introduced by a compliance fix. Required AC with a dedicated regression test. + +### Phase 3: Deployment support + +- `start:fips` script in `apps/backend/package.json` (`node --force-fips dist/src/main`) +- `cmd.sh`: conditional on `FIPS_ENABLED` (currently `yarn backend start` at line 5) +- Startup FIPS assertion (§7) +- Express ETag fix (§6) — disable or replace with SHA-256 +- `ENVIRONMENT_VARIABLES.md`: new variables and a FIPS deployment guide, including the Postgres ≤13 note +- RPM: systemd `NODE_OPTIONS`, setup-script FIPS detection + +### Phase 4: Validation + +- **Migration progress query** (there is no `apps/cli` on master — only `backend` and `frontend`): + ```sql + SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_remaining, + count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%') AS pbkdf2_migrated + FROM "Users"; + ``` + Plus the equivalent for `ApiKeys."apiKey"`. Both belong in the deployment guide. +- Integration test: login against a PBKDF2 hash +- Integration test: bcrypt → PBKDF2 upgrade on login, asserting `passwordChangedAt` and `forcePasswordChange` unchanged +- Integration test: API key upgrades its hash on first use +- **Runtime dependency audit** — boot and exercise auth under `node --force-fips`; catches transitive MD5 that static analysis misses +- Document `bcryptjs` removal criteria (zero rows from the query above, across all deployments) + +## Scope + +### In scope +Core module and service; migration at all seven call sites; two new env vars; `--force-fips` support; startup FIPS assertion; Express ETag fix; Postgres documentation and RPM detection; tests and documentation. + +### Explicitly NOT in scope + +**1. Changing what API keys hash.** `apikey.service.ts:41` notes *"Since BCrypt has a 72 byte limit only hash the JWT signature"* — that limit is why only the signature is hashed. PBKDF2 has no such limit, making full-JWT hashing possible. **Do not change it here.** It would alter the verification contract and invalidate every existing key. Hashing the signature remains sound. Revisiting needs its own ADR and a key-rotation plan. + +**2. Migrating heimdall2 to better-auth.** Heimdall2 stays on Passport + Sequelize. The better-auth migration is a large separate effort (`izw` epic) and folding it in would balloon this change. + +Forward-compatibility note: v3's `verifyPassword` returns a bare boolean because better-auth's `verify` contract requires it. Heimdall2 has no such constraint, which is exactly why it can return `{valid, needsRehash}` and support graceful migration. A future better-auth adoption will need a thin adapter — it discards `needsRehash` on better-auth's call path while an outer hook performs the rehash. Flagged so that migration is not surprised; no work now. + +**3. Making password complexity configurable.** `libs/password-complexity` stays hardcoded. + +**4. Removing `bcryptjs`.** Required to verify legacy hashes during migration. Removal only after deployments confirm zero bcrypt hashes remain. + +**5. Elastic-style `pbkdf2_stretch`.** Elasticsearch pre-hashes with SHA-512 before PBKDF2 because raw PBKDF2 over a short password can miss the FIPS strength floor. Heimdall's 15-character minimum makes this unnecessary. + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| **Rollback breaks migrated logins** | Medium | **High** | Lazy migration is one-way — a user rehashed to PBKDF2 cannot authenticate against code that only reads bcrypt. GitLab split this across two flags precisely here, noting the *write* flag is safely reversible but the *read* flag is not; Mattermost's PR carries the same warning. **Deploy read support first, enable writes in a subsequent release.** Document that reverting past the read-support release requires password resets. | +| Silent FIPS bypass (system reports compliant, isn't) | Medium | **High** | Startup assertion on `crypto.getFips()` (§7); runtime audit under `--force-fips` | +| Transitive dependency uses MD5/SHA-1 | Medium | Medium | Express ETag already identified; Phase 4 runtime audit for the rest | +| Timing side-channel reveals migration state | Low | Low | bcrypt cost 14 and PBKDF2 600k have different latencies, so response time can leak which accounts are migrated. Django addresses this with `harden_runtime()`. Low value to an attacker (it reveals staleness, not credentials); documented, not mitigated in this phase. | +| Dormant accounts never migrate | Medium | Low | Reporting query; bcrypt remains cryptographically strong meanwhile — the gap is compliance, not security | +| Performance regression | Low | Low | PBKDF2-SHA512 @ 600k ≈ bcrypt cost 14 (~200-400 ms); async, does not block the event loop | +| API key migration disrupts service | Low | Medium | Transparent on next use | + +## Alternatives Considered + +**1. Argon2id.** Won the Password Hashing Competition and is OWASP's first recommendation, but is **not FIPS-approved** — no SP 800-132 revision draft exists. Keycloak made Argon2id its default in v25 and must *override* it in FIPS mode. Adopting it would fail V-222571. The PHC format lets us add it later with no parser change. + +**2. Hard cutover (v3's approach).** Clean and FIPS-pure, but forces a password reset for every user. Five of the seven surveyed projects chose lazy migration instead. + +**3. Keep bcrypt, add `--force-fips` only.** Compliance theater. The process would report FIPS-enabled while a non-approved algorithm runs in pure JS where neither OpenSSL nor the OS can see it. V-222571 requires the *application* to use validated modules, not merely to run on a FIPS-enabled host. + +**4. Adopt an npm package instead of implementing.** No viable candidate. `@phc/pbkdf2` is the only real match — last published **2018**, repo dead since 2021, no TypeScript types, 13 stars. `pbkdf2-password` defaults to SHA-1. Everything actively maintained (`argon2`, `@node-rs/argon2`, `secure-password`) uses a non-approved KDF, and native/WASM bindings bypass OpenSSL — self-defeating. We borrow the format spec and implement roughly 80 lines against `node:crypto`. + +**5. Spring-style opt-in rehash service.** Rejected: `UserDetailsPasswordService` silently no-ops when unwired. Django's inline setter fails loudly instead. + +## Guiding Principle + +GitLab's stated tiebreaker, adopted here: **when security and compliance cannot both be satisfied, favor security.** Nothing in this ADR requires that trade — PBKDF2 at 600,000 iterations is both compliant and strong — but it governs any future conflict. + +## References + +**Standards** +- [NIST SP 800-132 — Password-Based Key Derivation](https://csrc.nist.gov/pubs/sp/800/132/final) +- [NIST FIPS 180-4 — Secure Hash Standard](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) +- [PHC string format (C2SP)](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) +- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) +- [Red Hat FIPS 140-3 Security Policy (CMVP #4754)](https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp4754.pdf) + +**STIGs** +- [ASD STIG V-222542](https://www.stigviewer.com/stigs/application_security_and_development/2025-02-12/finding/V-222542) · [V-222571](https://www.stigviewer.com/stigs/application_security_and_development/2025-09-09/finding/V-222571) +- [RHEL 8 STIG V-230223](https://www.stigviewer.com/stigs/red_hat_enterprise_linux_8/2024-11-25/finding/V-230223) + +**Implementations surveyed** +- [GitLab FIPS development guide](https://docs.gitlab.com/development/fips_gitlab/) · [password storage](https://docs.gitlab.com/17.9/security/password_storage/) · [MR !91622](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91622) · [epic #7939](https://gitlab.com/groups/gitlab-org/-/epics/7939) +- [Keycloak FIPS 140-2 guide](https://www.keycloak.org/server/fips) +- [Mattermost PR #33830](https://github.com/mattermost/mattermost/pull/33830) +- [Django `hashers.py`](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) +- [Spring Security password storage](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html) +- [`phc-pbkdf2` (npm)](https://github.com/simonepri/phc-pbkdf2) + +**Known breakage** +- [jshttp/etag#17 — MD5 under FIPS](https://github.com/jshttp/etag/issues/17) +- [node-postgres#1706 — md5 auth under FIPS](https://github.com/brianc/node-postgres/issues/1706) + +**In-repo prior art** +- Heimdall v3: `a52f6ceb` (`mitre/heimdall`) +- `fips_compliance` branch: `cbfa40946`, `b384fd335`, `310c24a3c` From d98b26951fd82d8b3ee86fb49adf7999a8c4c0c6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 15:06:56 -0400 Subject: [PATCH 002/197] =?UTF-8?q?docs:=20rewrite=20ADR-006=20after=20adv?= =?UTF-8?q?ersarial=20review=20=E2=80=94=20verified=20sources=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-agent adversarial review found ~30 defects. One reviewer fabricated its citations and retracted; independent sub-agents verified the substance against primary sources and refuted one central argument. Renamed: "FIPS 140-3 Compliant" -> "via a FIPS 140-3 Validated Module". FedRAMP FRR8 prohibits the term "FIPS compliant". Design changes: - bcrypt fallback now GATED on FIPS mode, with a defined cutover and forced-reset endgame. The prior unconditional fallback was weaker than both Keycloak (refuses) and GitLab (gates on FIPS mode). - Eight call sites, not seven. The admin bootstrap seeder was omitted; it runs on every container start, so fresh installs would provision the administrator with a bcrypt hash. - Compare-and-swap rehash writes. A bare save() races the existing un-awaited updateLoginMetadata and can silently revert a password change. - Strict algorithm allowlist. The prior "$pbkdf2-sha*$" wildcard accepted md5 and sha1 (verified against node:crypto). - Iterations parsed by regex only; parseInt('6e5') is 6. - 128-character password cap (Django CVE-2013-1443; policy 140sp4857). - PASSWORD_HASH_WRITE_ENABLED gate + durable format marker for rolling deploys and downgrade protection. Corrections: - CMVP certs were both wrong. #4985 is the OpenSSL Project's module, not Red Hat's; #4754 is libgcrypt and Historical. Correct: #4746 / #4857. - IG 2.4.A argument withdrawn — it scopes to functions within the module boundary and does not reach bcryptjs. The ASD STIG basis (V-222571 check text) stands on its own. - SP 800-132 approval is storage-scoped per IG D.N; the claim now rests on the HMAC-SHA-512 primitive executing inside the validated module. - V-222542 never mentions FIPS validation — bcrypt already satisfies it. Real exposure is V-222571/V-222572, both CAT II, not CAT I. - V-222543 was claimed satisfied via Helmet; main.ts explicitly removes upgrade-insecure-requests. - etag uses SHA-1, not MD5 — verified from source against two reviewers. - Measured performance inverts the risk rating: bcryptjs cost 14 is 1120ms, so this is a 7.7x improvement, not a regression. Documents the libuv threadpool ceiling the prior draft missed. - ENVIRONMENT_VARIABLES.md does not exist here; that filename came from Vulcan. Docs land in docs/fips-deployment.md so air-gapped customers can read them. Every normative claim is marked [V] verified or [U] unverified. Authored by: Aaron Lippold --- ...adr-006-fips-compliant-password-hashing.md | 348 ------------ ...adr-006-fips-validated-password-hashing.md | 496 ++++++++++++++++++ 2 files changed, 496 insertions(+), 348 deletions(-) delete mode 100644 docs/adr-006-fips-compliant-password-hashing.md create mode 100644 docs/adr-006-fips-validated-password-hashing.md diff --git a/docs/adr-006-fips-compliant-password-hashing.md b/docs/adr-006-fips-compliant-password-hashing.md deleted file mode 100644 index 50080419a6..0000000000 --- a/docs/adr-006-fips-compliant-password-hashing.md +++ /dev/null @@ -1,348 +0,0 @@ -# ADR-006: FIPS 140-3 Compliant Password Hashing - -**Status:** Proposed -**Date:** 2026-07-29 -**Author:** Aaron Lippold -**Branch:** `feature/fips-compliant-password-hashing` -**Base:** `master` @ `2e1649c9e` - -## Context - -Heimdall2 stores user passwords hashed with bcrypt via `bcryptjs` (pure JavaScript, cost factor 14). API keys are stored as bcrypt hashes of JWT signatures. This is a FIPS compliance gap: - -1. **bcrypt is not FIPS-approved.** Bcrypt uses Blowfish, which is not listed in SP 800-140C. Neither scrypt nor Argon2 are approved either — Argon2 is planned for a revised SP 800-132 but no draft exists. - -2. **bcryptjs bypasses the FIPS boundary entirely.** It is pure JavaScript — it never calls `node:crypto` or OpenSSL. On a FIPS-enabled host it runs *undetected and unblocked*, outside the validated module. This failure mode is well known: Chainguard's `node-fips` image documentation flags bcryptjs by name, and SafeLogic's Node.js FIPS guidance warns that "even if Node.js is configured correctly at the core level, parts of the application may still operate outside the FIPS boundary," naming bcrypt explicitly. - -3. **Customers on STIG-hardened systems cannot pass ASD STIG checks.** V-222542 (High) requires cryptographic password storage via FIPS-validated algorithms; V-222571 (Medium) requires FIPS-validated modules for hashing. - -4. **FedRAMP deadline.** All FIPS 140-2 certificates move to the NIST Historical List in **September 2026**; only 140-3 modules are accepted for new federal procurement thereafter. Drivers include FISMA, OMB Circular A-130, NIST SP 800-53 and SP 800-171. - -### Prior art in this repository - -**`fips_compliance` branch (2023, Amndeep Singh Mann / George Dias):** -- `--force-fips` Node startup (`cmd.sh`, `start:fips` script) and Postgres `scram-sha-256` -- `libs/common/src/crypto/crypto.ts` — PBKDF2-SHA256, 600k iterations, 32-byte salt, `useBCrypt` fallback flag -- Defects: synchronous `pbkdf2Sync` blocks the event loop; no self-describing format (iterations unrecoverable from the hash); `===` string comparison rather than `timingSafeEqual`; iterations hardcoded in two places - -**Heimdall v3 (`a52f6ceb`, 2026-03-13, `mitre/heimdall` repo):** -- PBKDF2-SHA512 in `apps/backend/src/auth/password.ts`, format `pbkdf2-sha512$iterations$salt$key` -- Async `pbkdf2`, `timingSafeEqual`, env-configurable algorithm and iterations -- **Hard-rejects** non-FIPS hashes; returns a bare boolean because better-auth's `emailAndPassword.password.verify` contract requires it (`auth.ts:44-45`) - -### Industry survey - -Seven comparable projects were surveyed to validate the approach: - -| Project | Algorithm / iterations | Migration strategy | Params in hash? | -|---|---|---|---| -| **GitLab** | PBKDF2-SHA512 @ 20,000 | Lazy rehash on sign-in | Yes | -| **Keycloak** | PBKDF2-SHA512 @ 210,000 | Lazy rehash on login (`rehashPasswordIfRequired`) | Yes | -| **Mattermost** | PBKDF2-SHA256 @ 600,000 | Lazy rehash on login (`App.migratePassword`) | Yes — PHC | -| **Django** | PBKDF2-SHA256 @ 600k–1.8M by version | Lazy rehash via `setter` callback | Yes | -| **Spring Security** | PBKDF2-SHA256 @ 310,000 | Opt-in via `UserDetailsPasswordService` | Yes — `{id}` prefix | -| **Grafana** | PBKDF2-SHA256 @ **10,000** | **None** | **No** | -| **Vault** | bcrypt, **no FIPS path** | N/A | bcrypt native | - -Two conclusions drive this ADR. First, **lazy rehash-on-login is the industry standard** — five of seven implement it; it is not a novel approach. Second, **Grafana is the cautionary tale**: PBKDF2 from day one, technically FIPS-shaped, still stuck at 10,000 iterations (well below OWASP's 600,000) with no upgrade path, precisely *because* the parameters were never encoded in the stored hash. Encoding parameters is not a nicety; it is what makes the scheme maintainable. - -## Decision - -### 1. Replace bcrypt with PBKDF2 via `node:crypto` - -**Algorithm:** PBKDF2-HMAC-SHA-512 default; `sha256` and `sha384` selectable -**Iterations:** 600,000 default, configurable, floor of 100,000 enforced -**Salt:** 32 bytes from `crypto.randomBytes()` — exceeds SP 800-132's 128-bit minimum -**Derived key:** matches digest width (64 / 48 / 32 bytes) - -**Why PBKDF2:** the only NIST-approved password KDF (SP 800-132). Approved PRFs are HMAC with SHA-2 (FIPS 180-4) or SHA-3 (FIPS 202). - -**Why `node:crypto`:** it delegates to OpenSSL, the FIPS-validated module on RHEL (CMVP #4985). The operation therefore runs *inside* the validated boundary, satisfying V-222571. TuxCare's guidance to federal agencies is explicit that vendors must use validated modules rather than build proprietary implementations. - -**On the iteration count.** OWASP 2024 gives 210,000 for PBKDF2-SHA512 and 600,000 for SHA256. We use 600,000 for SHA512 — roughly 3× the OWASP floor and 30× GitLab's production value. This is deliberate: the cost is one-time per login (~200-400 ms, comparable to bcrypt cost 14), and the self-describing format means it can be lowered later without invalidating stored hashes. - -### 2. PHC string format - -``` -$pbkdf2-sha512$i=600000$$ -``` - -This follows the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) (the spec moved from `P-H-C/phc-string-format` to C2SP) and matches the `phc-pbkdf2` npm implementation. Base64 uses the standard alphabet with padding stripped. - -Note PHC is a rigorously-defined **subset** of Modular Crypt Format, not a superset. bcrypt's `$2b$14$...` is valid MCF but invalid PHC. That is fine — both live in the same column and are distinguished on parse. - -Three reasons this beats v3's `pbkdf2-sha512$600000$salt$key`: - -1. **The leading `$` unifies the namespace.** `$` appears in no base64 alphabet (standard uses `A-Za-z0-9+/=`) nor in bcrypt's radix-64, so splitting is unambiguous. Dispatch becomes a single lookup on `parts[1]` — `'2b'` versus `'pbkdf2-sha512'` — instead of two parsers for two shapes. -2. **Named parameters** (`i=600000`) are self-documenting and extensible. -3. **Argon2 slots in unchanged.** `$argon2id$v=19$m=...$...` parses with the same code the day NIST approves it. - -Note there is no *registered* PHC identifier for PBKDF2 — the spec defines only the argon2 variants. Three de-facto formats exist in the wild (npm `phc-pbkdf2` uses `i=`; Mattermost uses `$pbkdf2$f=SHA256,w=600000,l=32$`; Python passlib uses bare rounds with a non-standard `ab64` alphabet). We follow `phc-pbkdf2` as the JavaScript-ecosystem convention. - -### 3. Graceful migration — unconditional lazy rehash on login - -`verifyPassword` dispatches on the stored format: - -| Stored prefix | Path | Returns | -|---|---|---| -| `$pbkdf2-sha*$` | PBKDF2, params read from the hash | `{valid, needsRehash: false}` | -| `$2a$` / `$2b$` / `$2y$` | `bcryptjs.compare()` fallback | `{valid, needsRehash: valid}` | -| anything else | reject without throwing | `{valid: false, needsRehash: false}` | - -Verification always reads parameters **from the stored hash**, never from configuration — so changing `PASSWORD_HASH_ITERATIONS` never invalidates existing hashes. - -**The rehash must be unconditional in the login path.** We follow Django's model (a `setter` callback invoked inline by `check_password`) rather than Spring's (`UserDetailsPasswordService`, which must be separately wired). Spring's design **silently no-ops when unwired** — a migration that quietly does nothing is precisely the failure mode to avoid in compliance work. - -Consequences: -- No forced password resets; users migrate transparently on next login -- Dormant accounts keep bcrypt hashes indefinitely — reporting query and eventual forced reset required -- `bcryptjs` stays installed until migration completes -- API keys migrate on next validation by the same mechanism - -**NIST 112-bit floor:** SP 800-132's strength requirement translates to a 14-character minimum, enforced by Keycloak strict mode and Mattermost's FIPS build. Heimdall's existing **15-character** STIG default already clears this — no change needed, but it is a compliance checkpoint worth recording. - -### 4. Environment variables - -| Variable | Type | Default | Notes | -|---|---|---|---| -| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | PBKDF2 digest | -| `PASSWORD_HASH_ITERATIONS` | integer ≥ 100000 | `600000` | PBKDF2 iterations | -| `FIPS_MODE` | boolean, optional | unset | Startup assertion override (see §7). Name follows GitLab's `Labkit::FIPS` convention. | - -**Relationship to `libs/password-complexity`:** that library is plain JavaScript with **hardcoded, non-configurable** rules — 15-character minimum, all four character classes, no 4+ consecutive characters of one class. It has no environment variables. Password *complexity* is orthogonal to password *hashing*; this ADR does not change it, and making it configurable is out of scope. - -### 5. Node.js FIPS mode - -Add opt-in `--force-fips` support: a `start:fips` script, `FIPS_ENABLED` handling in `cmd.sh`, and `NODE_OPTIONS` support for the RPM systemd unit. - -Facts verified against Node's `BUILDING.md`, `doc/api/cli.md`, and `src/node_options.cc`: - -- **A custom Node build is not required.** BUILDING.md states plainly: *"It is not necessary to rebuild Node.js to enable support for FIPS."* Under OpenSSL 3, FIPS is a runtime-loadable provider. Stock nodejs.org binaries need `openssl fipsinstall` → `fipsmodule.cnf` plus `OPENSSL_CONF` and `OPENSSL_MODULES`; without a configured provider, startup fails. -- **Our base image needs none of that.** `Dockerfile:1` already uses `registry.access.redhat.com/ubi9/nodejs-22-minimal:1`. RHEL's Node is a `--shared-openssl` build, so it inherits FIPS from the system OpenSSL automatically. Had heimdall2 been on a Debian or Alpine `node:` image with statically-bundled OpenSSL, `--force-fips` would have been compliance theater. -- **Node never reads `/proc/sys/crypto/fips_enabled`.** Host inheritance is a property of the build, not a runtime check. -- `--enable-fips` can be undone by `crypto.setFips(false)`; `--force-fips` cannot. Under `--force-fips`, `setFips()` triggers a native `CHECK()` that **aborts the process** — it does not throw. Never call it. -- `--openssl-legacy-provider` restores MD4/RC4/DES and will **not** rescue a genuine FIPS denial; its interaction with FIPS mode is undocumented. - -When debugging, do not conflate the two error families: `ERR_OSSL_EVP_UNSUPPORTED` is an OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for FIPS` is a real denial. - -### 6. Dependency audit for incidental non-approved hashing - -MD5 and SHA-1 commonly appear in *non-security* roles — ETags, cache keys, checksums, fingerprints — and still fail under FIPS. GitLab hit this repeatedly (S3 content-MD5, SSH fingerprints, Maven/Gradle checksums). Python solved its equivalent with `usedforsecurity=False`; **Node has no such escape hatch**, so offending calls must be replaced outright. - -Audit results for heimdall2: - -| Component | Status | -|---|---| -| `apps/backend/src`, `apps/backend/config`, `libs/common`, `libs/password-complexity` | **Clean** — no `md5`/`sha1`/`createHash` | -| `uuid` | **Safe** — only `v4` (random) is used; `v3`/`v5` would use MD5/SHA-1 | -| `@aws-sdk/*` | **Not in the backend** — S3/STS are browser-side (`apps/frontend`), `client-config-service` is in `libs/hdf-converters`, which the backend does not import | -| **Express ETag** | **Breaks.** We run `NestExpressApplication` (`main.ts:3`). Express generates ETags by default and the `etag` package uses MD5 ([jshttp/etag#17](https://github.com/jshttp/etag/issues/17)). Must be disabled or replaced with a SHA-256 generator. | -| **`pg` MD5 auth** | **Breaks** against Postgres configured for md5 ([node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706)) — see §8 | - -A runtime audit under `--force-fips` is required, since static analysis cannot see into transitive dependencies. - -### 7. Startup FIPS assertion - -When FIPS is expected, the application must **verify** it rather than assume it. GitLab's Workhorse shipped without the required build tag and `fips.Enabled()` returned **false with no error** — the system reported healthy while operating outside the boundary. Silent boundary bypass is the defining failure mode of FIPS work. - -On boot, when `FIPS_MODE` is truthy (or `--force-fips` is set), assert `crypto.getFips() === 1` and fail fast with a clear message otherwise. `crypto.getFips()` is the only reliable runtime check. - -### 8. PostgreSQL FIPS compatibility - -Postgres configured for `md5` authentication breaks the `pg` driver under FIPS, because OpenSSL refuses MD5. Postgres **14+ defaults to `scram-sha-256`** and needs no change. - -Scope is narrower than the 2023 branch suggests: `docker-compose.yml:3` already pins **`postgres:17`**, so the default stack is unaffected. The remaining exposure is RHEL 8 AppStream (Postgres 13) and pre-existing customer databases. This is therefore **documentation plus RPM setup detection**, not a compose change. For those deployments: - -``` -POSTGRES_HOST_AUTH_METHOD=scram-sha-256 -POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 -``` - -## STIG and Compliance Mapping - -| Rule | Severity | Requirement | Satisfied by | -|---|---|---|---| -| **V-222542** (ASD) | High | Passwords stored as salted iterated hash via FIPS-validated algorithm; MD5 prohibited | PBKDF2-SHA512, 600k iterations, 32-byte salt, via OpenSSL FIPS provider | -| **V-222543** (ASD) | High | Passwords transmitted only cryptographically protected | Unchanged — TLS enforced via Helmet | -| **V-222571** (ASD) | Medium | FIPS-validated modules for hashing | `crypto.pbkdf2` in-boundary; bcryptjs phased out | -| **V-222570** (ASD) | Medium | FIPS-validated modules for signing | JWT HMAC-SHA256 — already compliant | -| **V-230223** (RHEL 8) | High | System-wide FIPS crypto policy | UBI9 base + `--force-fips` + startup assertion | -| **V-258241** (RHEL 9) | High | As RHEL 8, plus SHAKE-256 | Same | - -**FedRAMP:** SC-13, SC-28, IA-7. Deadline September 2026. - -## Implementation Plan - -### Phase 1: Core module - -**Configuration split.** Only `hashPassword` needs configuration — `verifyPassword` reads its parameters from the self-describing hash. This permits pure functions with a thin injectable on top: - -- `apps/backend/src/crypto/password.ts` — pure functions, options as parameters with FIPS defaults. Usable from seeders and scripts with no DI container. -- `apps/backend/src/crypto/password.service.ts` — NestJS injectable reading the existing `ConfigService`. - -This avoids module-scope `AppConfig` instantiation, which would read `.env` from disk at import time and repeat a mutation pattern this codebase has deliberately moved away from. - -```ts -export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; - -export interface PasswordHashOptions { - algorithm?: PasswordHashAlgorithm; // default 'sha512' - iterations?: number; // default 600000 -} - -export interface PasswordVerifyResult { - valid: boolean; - needsRehash: boolean; -} - -export function hashPassword( - password: string, - options?: PasswordHashOptions -): Promise; - -export function verifyPassword(args: { - hash: string; - password: string; -}): Promise; -``` - -**Implementation landmines — each requires a test:** - -1. **`crypto.timingSafeEqual` throws on length mismatch** — `RangeError` / `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`. Guard lengths and return `false`; never let it throw. Length is not secret here (it is fixed by the stored parameters). -2. **`Buffer.from(str, 'base64')` is lenient** — it silently discards invalid characters, so a corrupt hash decodes into plausible-looking garbage. Validate by re-encoding and comparing. -3. **Parse defensively** — assert `parts[0] === ''` (leading `$`) and an exact field count. Never index blindly. -4. **Never call `crypto.setFips()`** — aborts the process under `--force-fips`. - -**Tests:** format conforms to PHC; verify correct/incorrect password; unique salt per hash; iteration floor enforced; bcrypt hash verified via fallback; `needsRehash` true only for bcrypt; malformed/truncated/empty hash rejected without throwing; length-mismatch guard; base64 leniency guard; service applies env vars and defaults. - -### Phase 2: Call-site migration - -Seven sites, line numbers verified against `master` @ `2e1649c9e`. - -**`users.service.ts`** (import line 9): - -| Line | Function | Change | -|---|---|---| -| 66 | `create()` | `hash(password, 14)` → `passwordService.hash(password)` | -| 89 | `update()` | `hash(password, 14)` → `passwordService.hash(password)` | -| 126 | `remove()` | `compare()` → `verifyPassword()`; `.valid` only (account is being deleted) | - -**`authn.service.ts`** (import line 7): - -| Line | Function | Change | -|---|---|---| -| 53 | `validateUser()` | `compare()` → `verifyPassword()`; **rehash + save when `needsRehash`** — primary migration path | -| 75 | `validateApiKey()` | `compare(JWTSignature, ...)` → `verifyPassword()`; rehash + save `matchingKey.apiKey` | -| 208 | `testPassword()` | `compare()` → `verifyPassword()`; `.valid` only (caller sets a new hash anyway) | - -**`apikey.service.ts`** (import line 3): - -| Line | Function | Change | -|---|---|---| -| 43 | `create()` | `hash(JWTSignature, 14)` → `passwordService.hash(JWTSignature)` | - -#### Constraint: rehash must not touch password lifecycle fields - -`user.model.ts` declares `passwordChangedAt` (line 68) and `forcePasswordChange` (line 55). A transparent rehash changes only the *stored representation* — **the password itself has not changed.** - -The update MUST write `encryptedPassword` only. It must NOT touch `passwordChangedAt` or `forcePasswordChange`. Writing `passwordChangedAt` would silently reset the password-expiry clock for every migrating user — a security regression introduced by a compliance fix. Required AC with a dedicated regression test. - -### Phase 3: Deployment support - -- `start:fips` script in `apps/backend/package.json` (`node --force-fips dist/src/main`) -- `cmd.sh`: conditional on `FIPS_ENABLED` (currently `yarn backend start` at line 5) -- Startup FIPS assertion (§7) -- Express ETag fix (§6) — disable or replace with SHA-256 -- `ENVIRONMENT_VARIABLES.md`: new variables and a FIPS deployment guide, including the Postgres ≤13 note -- RPM: systemd `NODE_OPTIONS`, setup-script FIPS detection - -### Phase 4: Validation - -- **Migration progress query** (there is no `apps/cli` on master — only `backend` and `frontend`): - ```sql - SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_remaining, - count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%') AS pbkdf2_migrated - FROM "Users"; - ``` - Plus the equivalent for `ApiKeys."apiKey"`. Both belong in the deployment guide. -- Integration test: login against a PBKDF2 hash -- Integration test: bcrypt → PBKDF2 upgrade on login, asserting `passwordChangedAt` and `forcePasswordChange` unchanged -- Integration test: API key upgrades its hash on first use -- **Runtime dependency audit** — boot and exercise auth under `node --force-fips`; catches transitive MD5 that static analysis misses -- Document `bcryptjs` removal criteria (zero rows from the query above, across all deployments) - -## Scope - -### In scope -Core module and service; migration at all seven call sites; two new env vars; `--force-fips` support; startup FIPS assertion; Express ETag fix; Postgres documentation and RPM detection; tests and documentation. - -### Explicitly NOT in scope - -**1. Changing what API keys hash.** `apikey.service.ts:41` notes *"Since BCrypt has a 72 byte limit only hash the JWT signature"* — that limit is why only the signature is hashed. PBKDF2 has no such limit, making full-JWT hashing possible. **Do not change it here.** It would alter the verification contract and invalidate every existing key. Hashing the signature remains sound. Revisiting needs its own ADR and a key-rotation plan. - -**2. Migrating heimdall2 to better-auth.** Heimdall2 stays on Passport + Sequelize. The better-auth migration is a large separate effort (`izw` epic) and folding it in would balloon this change. - -Forward-compatibility note: v3's `verifyPassword` returns a bare boolean because better-auth's `verify` contract requires it. Heimdall2 has no such constraint, which is exactly why it can return `{valid, needsRehash}` and support graceful migration. A future better-auth adoption will need a thin adapter — it discards `needsRehash` on better-auth's call path while an outer hook performs the rehash. Flagged so that migration is not surprised; no work now. - -**3. Making password complexity configurable.** `libs/password-complexity` stays hardcoded. - -**4. Removing `bcryptjs`.** Required to verify legacy hashes during migration. Removal only after deployments confirm zero bcrypt hashes remain. - -**5. Elastic-style `pbkdf2_stretch`.** Elasticsearch pre-hashes with SHA-512 before PBKDF2 because raw PBKDF2 over a short password can miss the FIPS strength floor. Heimdall's 15-character minimum makes this unnecessary. - -## Risks and Mitigations - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| **Rollback breaks migrated logins** | Medium | **High** | Lazy migration is one-way — a user rehashed to PBKDF2 cannot authenticate against code that only reads bcrypt. GitLab split this across two flags precisely here, noting the *write* flag is safely reversible but the *read* flag is not; Mattermost's PR carries the same warning. **Deploy read support first, enable writes in a subsequent release.** Document that reverting past the read-support release requires password resets. | -| Silent FIPS bypass (system reports compliant, isn't) | Medium | **High** | Startup assertion on `crypto.getFips()` (§7); runtime audit under `--force-fips` | -| Transitive dependency uses MD5/SHA-1 | Medium | Medium | Express ETag already identified; Phase 4 runtime audit for the rest | -| Timing side-channel reveals migration state | Low | Low | bcrypt cost 14 and PBKDF2 600k have different latencies, so response time can leak which accounts are migrated. Django addresses this with `harden_runtime()`. Low value to an attacker (it reveals staleness, not credentials); documented, not mitigated in this phase. | -| Dormant accounts never migrate | Medium | Low | Reporting query; bcrypt remains cryptographically strong meanwhile — the gap is compliance, not security | -| Performance regression | Low | Low | PBKDF2-SHA512 @ 600k ≈ bcrypt cost 14 (~200-400 ms); async, does not block the event loop | -| API key migration disrupts service | Low | Medium | Transparent on next use | - -## Alternatives Considered - -**1. Argon2id.** Won the Password Hashing Competition and is OWASP's first recommendation, but is **not FIPS-approved** — no SP 800-132 revision draft exists. Keycloak made Argon2id its default in v25 and must *override* it in FIPS mode. Adopting it would fail V-222571. The PHC format lets us add it later with no parser change. - -**2. Hard cutover (v3's approach).** Clean and FIPS-pure, but forces a password reset for every user. Five of the seven surveyed projects chose lazy migration instead. - -**3. Keep bcrypt, add `--force-fips` only.** Compliance theater. The process would report FIPS-enabled while a non-approved algorithm runs in pure JS where neither OpenSSL nor the OS can see it. V-222571 requires the *application* to use validated modules, not merely to run on a FIPS-enabled host. - -**4. Adopt an npm package instead of implementing.** No viable candidate. `@phc/pbkdf2` is the only real match — last published **2018**, repo dead since 2021, no TypeScript types, 13 stars. `pbkdf2-password` defaults to SHA-1. Everything actively maintained (`argon2`, `@node-rs/argon2`, `secure-password`) uses a non-approved KDF, and native/WASM bindings bypass OpenSSL — self-defeating. We borrow the format spec and implement roughly 80 lines against `node:crypto`. - -**5. Spring-style opt-in rehash service.** Rejected: `UserDetailsPasswordService` silently no-ops when unwired. Django's inline setter fails loudly instead. - -## Guiding Principle - -GitLab's stated tiebreaker, adopted here: **when security and compliance cannot both be satisfied, favor security.** Nothing in this ADR requires that trade — PBKDF2 at 600,000 iterations is both compliant and strong — but it governs any future conflict. - -## References - -**Standards** -- [NIST SP 800-132 — Password-Based Key Derivation](https://csrc.nist.gov/pubs/sp/800/132/final) -- [NIST FIPS 180-4 — Secure Hash Standard](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) -- [PHC string format (C2SP)](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) -- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) -- [Red Hat FIPS 140-3 Security Policy (CMVP #4754)](https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp4754.pdf) - -**STIGs** -- [ASD STIG V-222542](https://www.stigviewer.com/stigs/application_security_and_development/2025-02-12/finding/V-222542) · [V-222571](https://www.stigviewer.com/stigs/application_security_and_development/2025-09-09/finding/V-222571) -- [RHEL 8 STIG V-230223](https://www.stigviewer.com/stigs/red_hat_enterprise_linux_8/2024-11-25/finding/V-230223) - -**Implementations surveyed** -- [GitLab FIPS development guide](https://docs.gitlab.com/development/fips_gitlab/) · [password storage](https://docs.gitlab.com/17.9/security/password_storage/) · [MR !91622](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91622) · [epic #7939](https://gitlab.com/groups/gitlab-org/-/epics/7939) -- [Keycloak FIPS 140-2 guide](https://www.keycloak.org/server/fips) -- [Mattermost PR #33830](https://github.com/mattermost/mattermost/pull/33830) -- [Django `hashers.py`](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) -- [Spring Security password storage](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html) -- [`phc-pbkdf2` (npm)](https://github.com/simonepri/phc-pbkdf2) - -**Known breakage** -- [jshttp/etag#17 — MD5 under FIPS](https://github.com/jshttp/etag/issues/17) -- [node-postgres#1706 — md5 auth under FIPS](https://github.com/brianc/node-postgres/issues/1706) - -**In-repo prior art** -- Heimdall v3: `a52f6ceb` (`mitre/heimdall`) -- `fips_compliance` branch: `cbfa40946`, `b384fd335`, `310c24a3c` diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md new file mode 100644 index 0000000000..c4c4a1c10e --- /dev/null +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -0,0 +1,496 @@ +# ADR-006: PBKDF2 Password Hashing via a FIPS 140-3 Validated Module + +**Status:** Proposed +**Date:** 2026-07-29 +**Author:** Aaron Lippold +**Branch:** `feature/fips-compliant-password-hashing` +**Base:** `master` @ `2e1649c9e` + +> **On the title.** This document deliberately avoids the phrase "FIPS compliant." +> FedRAMP *Policy for Cryptographic Module Selection and Use* v1.1.0 (approved +> 2025-01-16), rule **FRR8**, states that representations "must use terminology +> approved by NIST" and that CSPs "must not use ambiguous or CSP-defined terms +> such as 'FIPS compliant.'" The accurate claim is that password hashing is +> performed by a FIPS 140-3 **validated module**. That phrasing is used throughout. + +## Evidence standard + +Every normative claim below is marked: + +- **[V]** — verified this session against a primary source (NIST/CMVP PDF, DISA STIG API, vendor source or docs, or a direct read of this repository at `2e1649c9e`). +- **[U]** — plausible but **unverified**. Not load-bearing. Must be confirmed before it appears in any SSP, POA&M, or assessor-facing artifact. + +This standard exists because an earlier review pass of this ADR produced confident, well-formatted citations — STIG check text, CCI numbers, CMVP guidance sections, FedRAMP rule IDs — that had **never been read**. The reviewer subsequently retracted them. Independent verification against primary sources later confirmed most of the substance and **refuted one central argument** (see §3 on IG 2.4.A, which ran opposite to how it was first cited). + +The process failure is instructive and worth stating plainly: **an unsourced citation is worthless regardless of how correct it sounds, and a plausible-sounding one is worse than none — it survives review.** Nothing marked [V] below rests on recollection; each was read this session, and where two reviewers disagreed the primary source decided it. + +Two consequences for anyone extending this document: + +- **Do not promote a [U] to [V] without reading the source.** The [U] list is short and specific; it is not a formality. +- **Assessor-facing artifacts must cite only [V] items.** SP 800-53A Rev 5's SC-13 assessment objects explicitly include "cryptographic module validation certificates; list of FIPS-validated cryptographic modules," which is exactly the class of claim that was fabricated the first time. + +## Context + +Heimdall2 hashes passwords with bcrypt via `bcryptjs` (pure JavaScript, cost factor 14) and stores API keys as bcrypt hashes of JWT signatures. + +**The core problem is boundary, not strength.** bcrypt at cost 14 is cryptographically strong. But `bcryptjs` is pure JavaScript — it never calls `node:crypto` or OpenSSL, so on a FIPS-enabled host it executes *undetected and unblocked*, entirely outside the validated module. **[V]** (verified by reading the package; Chainguard's `node-fips` image documentation flags it by name). + +**What this actually costs us, stated precisely.** An earlier draft of this ADR overstated the exposure. Corrected: + +- **V-222542** ("must only store cryptographic representations of passwords", **CAT I**, CCI-004062 / CCI-000196) requires "strong cryptographic hash functions" with a random salt and prohibits MD5. **The phrase "FIPS-validated" does not appear in its title, description, check text, or fix text.** bcrypt already satisfies it today. **[V]** — DISA STIG API. +- **V-222571** ("must use FIPS-validated cryptographic modules when generating hashes", **CAT II**, CCI-002450) is the rule we actually fail — and its finding condition is **invocation-scoped**, not storage-scoped. **[V]** +- **V-222572** ("must utilize FIPS-validated cryptographic modules when protecting unclassified information that requires cryptographic protection", **CAT II**, CCI-002450) is the closest general FIPS-invocation rule. The prior draft omitted it entirely. **[V]** + +So the honest framing is: **two CAT II findings, not a CAT I failure.** That is still worth fixing — but the ADR must not overstate it to an assessor. + +**Supporting control.** SP 800-53A Rev 5 **IA-5(1)(d)**: "for password-based authentication, passwords are stored using an **approved salted key derivation function**, preferably using a keyed hash." **[V]** PBKDF2-HMAC fits this text more directly than bcrypt does. This is the strongest affirmative control for the change and the prior draft never cited it. + +### Prior art in this repository + +**`fips_compliance` branch (2023):** `--force-fips` startup, Postgres `scram-sha-256`, and `libs/common/crypto/crypto.ts` (**note: no `src/` segment** — the prior draft's path was wrong) implementing PBKDF2-SHA256 @ 600k with a `useBCrypt` flag. Defects: synchronous `pbkdf2Sync`, no self-describing format, `===` comparison rather than `timingSafeEqual`, iterations hardcoded twice. **[V]** + +**Heimdall v3 (`a52f6ceb`, `mitre/heimdall`):** PBKDF2-SHA512, format `pbkdf2-sha512$iterations$salt$key`, async, `timingSafeEqual`, env-configurable. Hard-rejects legacy hashes. Returns a bare boolean because better-auth's `verify` contract requires it. **[V]** + +### What comparable projects do **in FIPS mode** + +The prior draft surveyed seven projects, concluded "five of seven use lazy rehash — it is industry standard," and used that to justify an unconditional bcrypt fallback. **That survey measured non-FIPS migration behavior and imported the conclusion into a FIPS document.** Corrected, scoped to FIPS mode specifically: + +| Project | FIPS-mode behavior | Verified | +|---|---|---| +| **Keycloak** | **Refuses.** `Argon2PasswordHashProviderFactory.isSupported()` returns false under FIPS; the provider never registers, so `verify()` is never reached. Docs: affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." | **[V]** source + docs | +| **GitLab** | **Gates on FIPS mode**, migrates lazily, with a forced-reset endgame. Docs: "Bcrypt: Used by default. **PBKDF2+SHA512: Used when FIPS mode is enabled.**" Concedes "these passwords cannot be re-encrypted without user help." Issue **#360659** — "Force password resets for users with bcrypt login passwords." Ships `gitlab:password:check_hashes`. | **[V]** docs + issues | +| **Mattermost** | Migrates lazily, **and its documentation is inaccurate.** `bcrypt.go` carries no `//go:build` FIPS exclusion, so pure-Go bcrypt compiles into and runs inside the FIPS build — while its FIPS/STIG doc claims "All application-level code uses only FIPS-approved algorithms." | **[V]** source + docs | + +**The prior draft's §3 was weaker than both Keycloak and GitLab** — ungated, unconditional, no terminal state — i.e. it reproduced Mattermost's posture in a document asserting the opposite. That is the single most important correction in this revision. + +### The Grafana lesson (unchanged, and still the reason for the format choice) + +Grafana has used PBKDF2 since inception — technically the right algorithm — and remains at **10,000 iterations** with no upgrade path, because the parameters were never encoded in the stored hash. **[V]** Encoding parameters is structural, not cosmetic. + +## Decision + +### 1. PBKDF2 via `node:crypto`, with a correctly-scoped justification + +**Parameters:** PBKDF2-HMAC-SHA-512 (default; `sha256`/`sha384` selectable), 600,000 iterations, 32-byte salt from `crypto.randomBytes()`, derived key matching digest width. + +**Why this is an approved operation — the argument the prior draft got wrong.** + +The prior draft argued "PBKDF2 is the only NIST-approved password KDF, therefore compliant." That **overstates**, because SP 800-132's approval is scope-limited: + +- SP 800-132 §4: the derived Master Key "is used either 1) to generate one or more Data Protection Keys (DPKs) to protect data, or 2) to generate an intermediate key to protect one or more existing DPKs... **The MK shall not be used for other purposes.**" **[V]** +- FIPS 140-3 **IG §D.N**: "the vendor shall indicate in the module's Security Policy that keys derived from passwords, as shown in SP 800-132, **may only be used in storage applications.**" Every RHEL OpenSSL/libgcrypt/NSS security policy examined repeats this caveat verbatim. **[V]** + +Password *verification* by hash-and-compare is not "a storage application" in SP 800-132's sense. So the SP 800-132 lineage alone does not carry the claim. + +**But the module remains approved, and CMVP says so explicitly.** IG 2.4.A anticipates precisely this situation **[V]**: + +> "If the module operator (e.g., calling application) can do things outside of the module's control/visibility that can take an otherwise approved algorithm and use it in a non-approved way (**e.g., use PBKDF ... outside of storage applications**), the corresponding module service **may still be considered approved** ... and the Security Policy shall clarify how to use the service in an approved manner." + +So this is a **documentation obligation, not a design defect.** Two things follow: + +1. **The argument that actually satisfies V-222571** is that the underlying **HMAC-SHA-512 primitive executes inside the validated module**, and secure hashing is an approved security function. PBKDF2's iteration structure is a *construction over* an approved primitive, not an appeal to SP 800-132's key-derivation scope. +2. **The SSP must state this reasoning rather than assume it**, and disclose the storage-application scope limit. Do not cite SP 800-132 as blanket authorization for password verification. + +**Our parameters clear every bound the module actually enforces [V]** — security policy `140sp4857.pdf`: salt ≥ 128 bits generated by the SP 800-90Ar1 DRBG, iterations ≥ 1000, derived key ≥ 112 bits. We use a 256-bit salt from `crypto.randomBytes` (which routes through that DRBG under FIPS), 600,000 iterations, and a 512-bit key. Note the same policy lists "PBKDF2 (short password; short salt; insufficient iterations; < 112-bit keys)" as a **non-approved service** — the failure mode is under-parameterisation, which we are well clear of. + +**On password strength:** IG §D.N states that "**SP 800-132 does not impose any strictly defined requirements on the strength of a password**," only that passwords "should be strong enough so that it is infeasible for attackers to get access by guessing." **[V]** An earlier draft claimed a 112-bit floor implied a 14-character minimum. That was wrong — the "14" originates in a BouncyCastle-FIPS byte-length check, which Keycloak works around by *padding* short passwords, proving it mechanical rather than an entropy requirement. Heimdall's existing 15-character minimum is good practice; it is not a FIPS obligation and should not be presented as one. + +**On 600,000 iterations.** OWASP 2024 gives 210,000 for SHA-512. We use 600,000 — see the measured performance data in §11, which materially changes the trade-off the prior draft described. + +### 2. PHC string format (unchanged — this part was right) + +``` +$pbkdf2-sha512$i=600000$$ +``` + +Per the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md), matching npm `phc-pbkdf2`. Standard base64 alphabet, padding stripped. PHC is a strict *subset* of Modular Crypt Format; bcrypt's `$2b$14$…` is valid MCF but invalid PHC — both coexist in one column and are distinguished on parse. + +The leading `$` is load-bearing: `$` appears in no base64 alphabet (`A-Za-z0-9+/=`) nor in bcrypt's radix-64 (`./A-Za-z0-9`), so dispatch is an unambiguous lookup on `parts[1]` — `'2b'` vs `'pbkdf2-sha512'`. **[V]** It also admits `$argon2id$v=19$m=…` unchanged if NIST approves Argon2. + +**Storage width [V]:** the string is **154 characters** (24 + 43-char salt + 1 + 86-char key). Both `Users.encryptedPassword` (migration `20200417145649`) and `ApiKeys.apiKey` (migration `20210615141642`) are `Sequelize.STRING` = `VARCHAR(255)` in *both* model and migration — **101 characters of headroom**, no `VARCHAR(60)` anywhere. Postgres *errors* on overflow rather than truncating, so failure would be loud — but it would fire **inside the login path**. An AC asserts output ≤ 255 for all three digests. + +### 3. Migration: FIPS-gated fallback with a terminal state + +**This section replaces the prior draft's unconditional fallback, which was its central defect.** + +`verifyPassword` dispatches on stored format **and on FIPS state**: + +| Stored prefix | FIPS off | FIPS on (`getFips() === 1`) | +|---|---|---| +| `$pbkdf2-sha{256,384,512}$` | verify, `needsRehash: false` | verify, `needsRehash: false` | +| `$2a$` / `$2b$` / `$2y$` | `bcryptjs.compare()`; `needsRehash: valid` | **refuse — do not invoke bcryptjs**; return `{valid: false, requiresReset: true}` | +| anything else | reject without throwing | reject without throwing | + +**Why the gate is required — the ASD STIG, and only the ASD STIG.** + +An earlier version of this section rested on CMVP **IG 2.4.A** ("non-approved security functions shall not be used in the approved mode of operation"). **That argument is withdrawn — it was backwards.** IG 2.4.A scopes to functions "**within the scope of the module**," i.e. it governs what the validated module itself offers in approved mode. `bcryptjs` is not within OpenSSL's boundary at all, so 2.4.A never reaches it. IG 2.4.A *example 1* in fact lists "store authentication data using MD5 or using HMAC-SHA-1 with a weak HMAC key" among non-approved algorithms **permitted** in approved mode where no security is claimed of the module. **[V]** — direct PDF read. + +**The FIPS 140-3 standard does not, by itself, prohibit calling bcryptjs.** CMVP validates modules, not applications (FIPS 140-3 §9), and nothing the calling application does voids OpenSSL's certificate. + +**The prohibition is application-scoped and comes from the STIG**, which is sufficient on its own. V-222571's check text, verbatim **[V]**: + +> "If FIPS-validated cryptographic modules are **not used when generating hashes** or if the application is configured to use the MD5 or SHA1 hashing algorithm, this is a finding." + +`bcryptjs.compare()` *generates* a bcrypt hash of the candidate password, in pure JavaScript, inside no validated module. That meets the finding condition literally. V-222572 (CCI-002450) applies on the same basis. + +So the accurate claim is narrow and defensible: **an ungated bcrypt call in a deployment asserting FIPS is a CAT II STIG finding, not a FIPS 140-3 violation.** That is still worth designing around — and it is exactly why Keycloak refuses and GitLab gates on FIPS mode. + +**SP 800-131A does not apply either way.** Its "legacy use" doctrine covers verify-only continuation of algorithms that were *once* NIST-approved. Bcrypt never was. **[V]** — searched, zero hits for "bcrypt" or "password." + +**The migration therefore has three phases, and a defined end:** + +1. **Non-FIPS operation** — lazy rehash on login. Users migrate transparently, no disruption. +2. **Cutover** — a migration script sets `forcePasswordChange = true` on every remaining `$2%` row. This is GitLab's #360659 and it is what the prior draft's vague "eventual forced reset required" must become. +3. **FIPS enablement** — by this point no bcrypt hashes remain, so the gate never fires in normal operation. If one is encountered anyway, the user is told to reset. + +This gives the terminal state the prior draft lacked and makes the `bcryptjs` removal criterion satisfiable. + +**Fresh FIPS installs have no transition at all** — no bcrypt hash is ever written (see §4, the seeder). + +### 4. Eight call sites, not seven + +The prior draft enumerated seven and **omitted the admin bootstrap seeder** — the one that runs on every deployment. **[V]** All line numbers verified at `2e1649c9e`. + +| # | File | Line | Function | Change | +|---|---|---|---|---| +| 1 | `users.service.ts` | 66 | `create()` | `hash(pw,14)` → service hash | +| 2 | `users.service.ts` | 89 | `update()` | `hash(pw,14)` → service hash | +| 3 | `users.service.ts` | 126 | `remove()` | `compare()` → **pure** `verifyPassword`, `.valid` only | +| 4 | `authn.service.ts` | 53 | `validateUser()` | `compare()` → verify **+ CAS rehash** (primary migration path) | +| 5 | `authn.service.ts` | 75 | `validateApiKey()` | `compare()` → verify + CAS rehash | +| 6 | `authn.service.ts` | 208 | `testPassword()` | `compare()` → **pure** `verifyPassword` — see constraint below | +| 7 | `apikey.service.ts` | 43 | `create()` | `hash(sig,14)` → service hash | +| **8** | **`seeders/20200514154327-create-administrator.js`** | **56** | admin bootstrap | **`bcrypt.hashSync(pw,14)` → compiled pure function, awaited** | + +**Site 8 is the most consequential omission.** `cmd.sh:4` runs `db:seed:all` on **every container start**, and the RPM path runs the same seeder via `heimdall-db-setup.sh`. Left unchanged, **every fresh install provisions its administrator — the highest-privilege account — with a bcrypt hash on day one**, in a change whose purpose is to eliminate them. With `ADMIN_USES_EXTERNAL_AUTH=true` the local credential may never be used for a local login, so lazy rehash never fires and the hash persists indefinitely. + +It is also structurally awkward: CommonJS `.js`, run by `sequelize-cli` outside both Nest DI and the TypeScript build, and **synchronous**. It must `require()` the compiled pure function from `dist/` and `await` it (its `up` is already `async`). + +**AC:** a fresh install, zero logins, must yield `bcrypt_remaining = 0`. + +#### Two structural constraints the prior draft missed + +**`testPassword` is called unbound. [V]** `users.service.ts:79` does `await AuthnService.prototype.testPassword(updateUserDto, userToUpdate)`. This works *only because* `testPassword` uses the module-scope `compare` import and never touches `this`. If it becomes `this.passwordService.verify(...)` it throws `TypeError` — and `UsersService` cannot inject `AuthnService` (circular; `AuthnService` injects `UsersService` at line 42). **Site 6 must use the pure function.** This is why §5's pure-function/injectable split is a requirement, not a style preference. + +**No persistence method can honor the lifecycle constraint. [V]** `usersService.update()` unconditionally sets `passwordChangedAt` and `forcePasswordChange`; `apiKeyService.update()` writes only `name`. Neither can perform a narrow rehash. Two new methods are required — `UsersService.updateEncryptedPassword()` and an `ApiKeyService` equivalent — following the existing narrow-writer pattern (`updateLoginMetadata`, `updateUserSecret`). + +### 5. Module structure + +Only `hashPassword` needs configuration; `verifyPassword` reads its parameters from the self-describing hash. Hence: + +- `apps/backend/src/crypto/password.ts` — **pure functions**, options as parameters. Usable from the seeder and scripts with no DI container (§4 site 6 and site 8 both require this). +- `apps/backend/src/crypto/password.service.ts` — Nest injectable reading `ConfigService`. +- `apps/backend/src/crypto/crypto.module.ts` — **required**: `ConfigModule` is *not* `@Global()`, so `UsersModule`, `AuthnModule`, and `ApiKeyModule` each need an explicit import. **[V]** + +```ts +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export interface PasswordHashOptions { + algorithm?: PasswordHashAlgorithm; // default 'sha512' + iterations?: number; // default 600000 +} + +export interface PasswordVerifyResult { + valid: boolean; + needsRehash: boolean; + requiresReset?: boolean; // bcrypt encountered while FIPS on +} + +export function hashPassword(password: string, options?: PasswordHashOptions): Promise; +export function verifyPassword(args: {hash: string; password: string}): Promise; +``` + +### 6. Input validation — the exact sequence + +Each item below is a verified trap, not a precaution. **[V]** — all confirmed by execution on Node 24. + +1. Reject non-string or empty. **`''.split('$')` is `['']`**, so a `parts[0] === ''` check *passes* for the empty string; the field-count check is what catches it. These are `AND`, not alternatives. +2. `split('$')` must yield **exactly 5** parts, and `parts[0] === ''`. +3. **Algorithm from a strict allowlist** — `Set(['sha256','sha384','sha512'])`. **Never prefix-match `sha*`.** `crypto.pbkdf2` accepts `md5` and `sha1`, so a stored `$pbkdf2-md5$…` would verify happily. The prior draft's dispatch table literally specified `$pbkdf2-sha*$` — an algorithm-confusion downgrade in a document banning MD5. +4. **Iterations by regex only** — `/^i=([1-9][0-9]{0,8})$/`. Never `parseInt`/`Number`: **`parseInt('6e5')` is `6`** (a forged hash verifies at six iterations — a 100,000× work-factor downgrade that looks well-formed), `parseInt('600000abc')` is `600000`, `Number('0x10000')` is `65536`, `Number('')` is `0`. +5. Iterations within `[100_000, 10_000_000]`. **An upper bound is mandatory**: Node permits up to 2³¹−1, which is roughly 8.6 minutes of one libuv thread per verification. Four such rows would exhaust the default 4-thread pool and take authentication down. +6. Decode salt and key, **re-encode and compare** (padding stripped both sides). `Buffer.from(str,'base64')` is lenient — `'AA@@AA'` and `'A A A A'` both decode identically to `'AAAA'`. +7. **Assert key length equals the digest's expected width, and salt ≥ 16 bytes, *before* calling `pbkdf2`.** `crypto.pbkdf2` with `keylen=0` throws an **untyped** error (`e.code` undefined) *before* any downstream guard runs — so the ADR's own required test, "malformed hash rejected without throwing," cannot pass without this check. A hash claiming `sha512` but carrying a 32-byte key would otherwise verify happily: silent acceptance of a downgraded artifact. +8. Guard length before `timingSafeEqual`, which **throws** `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH` on mismatch. Return `false`; never let it throw. Length is not secret — it is fixed by the stored parameters. + +**Maximum password length: 128 characters, enforced on every path** (`validateUser`, `create`, `update`, `remove`, `testPassword`, and the seeder). + +Two independent reasons. **(a) DoS** — this is Django **CVE-2013-1443** exactly: "A password one megabyte in size... will require roughly one minute of computation to check when using the PBKDF2 hasher." **[V]** Django capped at 4096 bytes. Heimdall currently sets `json({limit: '50mb'})` (`main.ts:67`), rate-limits only `/authn/login` at 20/min/IP (`main.ts:101-112`), and `libs/password-complexity` enforces a *minimum* of 15 with **no maximum**. **[V]** **(b) Approved range** — RHEL 9 OpenSSL security policy `140sp4857.pdf` states PBKDF2 "8-128 characters with password strength between 10⁸ and 10¹²⁸." **[V]** Over 128 is outside the module's documented approved range. + +Note that removing bcrypt also removes its implicit 72-byte truncation, so a >72-character password will be validated in full after rehash where previously only its first 72 bytes mattered. Behaviorally correct; worth a test. + +### 7. Concurrency: compare-and-swap, not `save()` + +**[V]** `authn.service.ts:54` already calls `this.usersService.updateLoginMetadata(user)` **without `await`** — a floating promise ending in `user.save()`. Adding a rehash `save()` on the same Sequelize instance creates two concurrent unawaited writes to one row. + +**The damaging interleaving:** a user changes their password (writes H2) while an in-flight login rehashes the **old** password and writes H1′. Last-write-wins silently reverts the password change. **If that change was a response to compromise, the compliance fix reinstates the compromise.** + +A bare `.save()` also cannot honor the lifecycle constraint — Sequelize flushes *all* dirty attributes, which at that moment include `lastLogin` and `loginCount`, and `@UpdatedAt` always bumps `updatedAt`. + +**Required:** + +```sql +UPDATE "Users" SET "encryptedPassword" = :new +WHERE id = :id AND "encryptedPassword" = :originalHash +``` + +Zero rows affected means another writer won — do nothing. This makes concurrent logins idempotent and enforces the "write `encryptedPassword` only" constraint **in SQL rather than by convention**. Use `silent: true` to suppress the `updatedAt` bump, so a mass migration does not make every account look recently modified. Wrap in try/catch: **a failed rehash must never fail an otherwise-successful login** — it retries next time. A crash between verify and save is safe; the bcrypt hash survives. + +The same un-awaited pattern exists at `apikey.service.ts:44`. **[V]** + +#### Password lifecycle fields must not change + +`user.model.ts` declares `forcePasswordChange` (line 55) and `passwordChangedAt` (line 68). **[V]** A rehash changes only the stored representation — the password itself did not change. Writing `passwordChangedAt` would silently reset the password-expiry clock for every migrating user: a security regression introduced by a compliance fix. + +**Known wrinkle [V]:** migration `20200417145649` creates `passwordChangedAt` as `Sequelize.STRING`, while `user.model.ts` declares `DataType.DATE`. No migration reconciles them. Since `database.module.ts` returns `synchronize: true` outside test, a synchronize-built database gets `DATE` and a migration-built one gets `VARCHAR(255)`. The regression test must account for both. Pre-existing defect; noted, not fixed here. + +**Test must assert both directions** — `encryptedPassword` *changed* and now starts with `$pbkdf2-`, while `passwordChangedAt`, `forcePasswordChange`, `lastLogin`, `loginCount`, and `updatedAt` are unchanged — verified after `await user.reload()`, not against the in-memory instance. A no-op rehash otherwise passes. + +### 8. Known limitation: iteration upgrades do not propagate + +Per §3, a PBKDF2 hash always returns `needsRehash: false`, even when its stored `i=` is below current policy. **This is the Grafana failure mode this ADR criticizes**, and it is a deliberate trade — reading parameters from the hash is what makes iteration changes non-breaking. + +Recorded explicitly so a future maintainer does not "fix" it accidentally. If iteration upgrades become desirable, add a params-below-policy check to the dispatch, gated behind its own decision. Not in scope here. + +### 9. Environment variables + +| Variable | Type | Default | Purpose | +|---|---|---|---| +| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | PBKDF2 digest | +| `PASSWORD_HASH_ITERATIONS` | int ≥ 100000 | `600000` | Iterations | +| `PASSWORD_MAX_LENGTH` | int ≤ 128 | `128` | Input cap (§6) | +| `FIPS_MODE` | boolean | unset | Assertion + fallback gate (§3, §10) | +| `PASSWORD_HASH_WRITE_ENABLED` | boolean | `false` in release N, `true` in N+1 | Rollout gate (§12) | +| `UV_THREADPOOL_SIZE` | int | platform default 4 | Auth throughput ceiling (§11) | + +Floor/ceiling semantics must be explicit: out-of-range values **throw at startup**, they do not clamp silently. **The floor applies to hashing only, never to verification** — a stored hash with `i=50000` must remain verifiable or users are locked out. + +`libs/password-complexity` remains hardcoded (15-char minimum, four classes, no 4+ consecutive same-class) with **no** environment variables. **[V]** Complexity is orthogonal to hashing; making it configurable is out of scope. + +### 10. FIPS assertion and startup + +**The in-process assertion cannot be the only gate. [V]** Verified by execution: + +``` +$ NODE_OPTIONS="--force-fips" node -e "console.log('ok')" +node: OpenSSL error when trying to enable FIPS: +EXIT=1 +``` + +Node aborts **in bootstrap**, before `main.ts`, before Nest, before any logger — with an **empty error body**. The §10 assertion never runs on that path. Combined with the RPM unit's `Restart=on-failure` / `RestartSec=5` and no `StartLimitBurst` override, this produces a **permanent crash loop at 12 restarts/minute** with `systemctl status` showing `activating (auto-restart)` rather than `failed`. + +**Therefore, three layers:** + +1. **Launcher preflight** — never put `--force-fips` directly in the systemd unit or `NODE_OPTIONS`. Probe first, and on failure emit a real diagnostic (`/proc/sys/crypto/fips_enabled`, `update-crypto-policies --show`, `openssl list -providers`, `node -p process.versions.openssl`) and `exit 78` (`EX_CONFIG`). +2. **Unit hardening** — `StartLimitIntervalSec=60`, `StartLimitBurst=3`, so a misconfiguration reaches `failed` within a minute. +3. **In-process assertion** — still required, because it catches the *dangerous* case: `FIPS_MODE=true` set for compliance reporting **without** `--force-fips`, i.e. the system claims FIPS and is not. This is GitLab's Workhorse failure — it shipped without the `fips` build tag and `fips.Enabled()` returned false with no error. Must be an **exported, injectable** function (`assertFipsMode({fipsMode, getFips})`) so it is testable without booting the app; `bootstrap()` in `main.ts` is not exported. **[V]** + +**When `FIPS_MODE` is unset, log loudly at boot that no assertion was performed** — silence is how the Workhorse class of failure survives. + +**`crypto.getFips()` is necessary but not sufficient as evidence.** It proves the flag is set, not which provider loaded at what version, nor that the operational environment matches the certificate. Log module identity at startup as a durable artifact. Never silently degrade to a non-approved path. + +**Never call `crypto.setFips()`** — under `--force-fips` it triggers a native `CHECK()` that **aborts the process**; it does not throw. **[V]** + +### 11. Performance — measured, and it inverts the prior draft's risk rating + +The prior draft asserted "PBKDF2-SHA512 @ 600k ≈ bcrypt cost 14 (~200-400 ms)" and rated the change a Low/Low performance *regression*. **Both are wrong.** Measured on Node 24 (Apple Silicon; server vCPUs will be 2-4× slower): + +| Operation | Latency | Concurrent throughput | Event-loop lag | +|---|---|---|---| +| `bcryptjs` compare cost 14 (**current production**) | **1120 ms** | 0.9/sec | 788 ms | +| PBKDF2-SHA512 @ 600k (**this ADR**) | **145 ms** | 20/sec | 1.4 ms | +| PBKDF2-SHA512 @ 210k (OWASP) | 52 ms | ~55/sec | — | + +This is a **7.7× latency improvement and a 22× throughput improvement.** The prior draft buried its own strongest justification. + +**The real cost it failed to document:** `crypto.pbkdf2` dispatches to the **libuv threadpool (default 4 threads)**. Throughput pins at ~20 auth-ops/sec *regardless of concurrency*, and the pool is shared with `fs`, `dns.lookup`, and `zlib` — measured, `fs.readFile` went **1.16 ms → 337 ms** with 8 PBKDF2 operations queued. "Async, does not block the event loop" is true but materially misleading: the event loop stays responsive while all file I/O stalls. bcrypt today is far worse (0.9 ops/sec, 3232 ms `fs` stall), so this remains a large net win — but `UV_THREADPOOL_SIZE` must be set explicitly and the resulting ceiling documented. + +**On keeping 600,000.** SHA-512 is the right digest — fast per byte on 64-bit CPUs while GPUs are comparatively weaker at 64-bit operations, so the defender/attacker ratio favors it. The *count* is defensible at 210k (OWASP) or at 600k **only if** `UV_THREADPOOL_SIZE` is raised and a global KDF concurrency limit lands. Keeping 600k while addressing neither is the one indefensible combination. **Benchmark on the target RHEL container before finalizing.** + +**Login is a DoS amplification vector. [V]** The only protection is 20 req/min/IP on `/authn/login`; there is no global cap, no `@nestjs/throttler`, and **no account lockout** (`loginCount` increments only on success). A ~200-byte request buys 145 ms of CPU. Add a global KDF concurrency limiter and a per-account failed-attempt counter. + +**API keys: 600,000 iterations is cryptographically pointless there.** The hashed value is a JWT HS256 signature — 43 base64url characters, **256 bits of machine-generated entropy**. Iterated KDFs raise per-guess cost against *low-entropy human* inputs; against a 256-bit token, brute force from a stolen hash is infeasible at any iteration count. GitHub and Stripe store API tokens as a single SHA-256. Note this path is **not** reachable by unauthenticated attackers — `jwt.verify` gates it and is cheap **[V]** — and per-request cost still *drops* 1120 → 145 ms. Recorded as a known inefficiency; changing it is out of scope (§14). + +### 12. Rollout, rollback, and the mixed-version window + +**The prior draft named rollback asymmetry as a High risk and specified no mechanism.** Two distinct hazards: + +**(a) Rolling deploys — within a single release.** Old and new pods serve one database concurrently. A user rehashed by a new pod then hits an old pod: `bcryptjs.compare()` returns `false` on a PBKDF2 hash (it does not throw), so they get "Incorrect Username or Password" — *intermittent* auth failure that appears to self-resolve as the deploy completes, the hardest class to triage, amplified by the 20/min rate limit turning retries into 429s. + +**API keys make this materially worse.** `validateApiKey` serves CI pipelines and `saf` CLI uploads — no human to retry, silent pipeline failure. And an API key **cannot be recovered**: the server stores only a hash of a signature it never retains in plaintext. A bad rollback means regenerating every key by hand. + +**(b) Version skips — air-gapped RPM.** Forward skips are safe (pre-N → N+1 gets read+write together, having never run read-incapable code against PBKDF2 hashes). **Reverse is catastrophic** and `dnf downgrade` is one command. + +**Mechanism — all four parts required:** + +1. **`PASSWORD_HASH_WRITE_ENABLED`**, default `false` in release N, `true` in N+1. When false, `verifyPassword` still reports `needsRehash` but call sites skip the write. Two releases alone don't cover the intra-release rolling window; the flag alone doesn't cover operators who skip the read release. +2. **A durable format marker planted in release N** — the one thing that must not be deferred, because it is what makes both guards possible later. A DB row (not a file — container filesystems are ephemeral and the database is the only shared durable state) recording that PBKDF2 writes have begun. +3. **RPM `%pre` downgrade guard** — refuse installation below the recorded floor, with an explicit message naming the consequence and pointing at the recovery procedure. +4. **Graceful-degradation AC** — an integration test running the *old* verify path against a PBKDF2 hash, asserting a clean `false` rather than a throw or a 500. + +**Sequence (SaaS):** N read-only + marker → soak → canary the flag on one replica → fleet-wide as a *separate* rollout → N+1 default true → N+2 flag removed → N+3 `bcryptjs` removed, gated on telemetry not a date. + +**Sequence (air-gapped):** same artifacts, operator-timed; warning in the **upgrade** section of release notes, not the changelog; `%post` prints it to console; `%pre` guard enforces it. + +**Also unguarded:** a `pg_dump` taken post-migration and restored onto pre-N code locks out every migrated user. Same hazard, different door, and `%pre` does not catch it. Document the forced-reset recovery — including that **API keys must be regenerated**. + +**Read replicas.** The lazy rehash is a write on the login path. If reads were ever routed to a replica, a lagging read would return the stale bcrypt hash and rehash again — an unbounded loop burning a full KDF per login. Heimdall does not use read replicas today; recorded as an assumption to revisit. + +### 13. Dependency and platform audit + +**Our own code is clean. [V]** No `md5`/`sha1`/`createHash` in `apps/backend/src`, `apps/backend/config`, `libs/common`, or `libs/password-complexity`. Only `crypto.randomBytes` is used. `uuid` v4 only (v3/v5 would use MD5/SHA-1). No `@aws-sdk/*` or `hdf-converters` in the backend — AWS SDK is browser-side in `apps/frontend`. + +**Express ETag — the prior draft's diagnosis was wrong. [V]** `etag/index.js:47` uses **`createHash('sha1')`**, not MD5. Confirmed two ways: a direct read of the installed package source, and the empty-body fast-path constant `2jmj7l5rSw0yVb/vlWAYkK/YBwk`, which is exactly `sha1('')` in base64 (`md5('')` is `1B2M2Y8AsgTpgAmY7PhCfg==`). SHA-1 **is** an approved hash in the OpenSSL 3 FIPS provider, so this likely does **not** break under plain `--force-fips`. + +*Two separate reviewers asserted MD5 here.* Both were wrong; the source read is definitive. Recorded so this is not re-litigated. + +**But it may still break under `FIPS:STIG`**, whose permitted hash list is SHA-2/SHA-3 only. **Verify empirically under both `FIPS` and `FIPS:STIG` policies before spending any work here.** If it does break, choose the **SHA-256 custom generator**, never `app.set('etag', false)` — Heimdall serves large HDF JSON payloads (`json({limit:'50mb'})`), so losing 304 revalidation costs far more than rehashing. Note `app.set('etag', false)` would not disable `serve-static`'s ETag anyway, and `send` passes an `fs.Stats` object to `etag`'s `stattag()`, which uses no hash at all. + +**`pg` MD5 auth breaks under FIPS** ([node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706)) — see §15. + +**Runtime audit required.** Static analysis cannot see transitive dependencies. Boot and exercise auth under `--force-fips` on a real RHEL FIPS host. Do not conflate the two error families: `ERR_OSSL_EVP_UNSUPPORTED` is an OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for FIPS` is a real denial. + +### 14. Platform: what the base image does and does not give us + +`Dockerfile:1` sets `ARG BASE_CONTAINER=registry.access.redhat.com/ubi9/nodejs-22-minimal:1`, used by both stages. **[V]** RHEL's Node is a `--shared-openssl` build, so it uses system OpenSSL rather than a statically-bundled copy. + +**Four corrections to the prior draft's over-claim:** + +1. **It is an overridable `ARG`, not a fixed `FROM`.** `--build-arg BASE_CONTAINER=node:22-alpine` silently produces exactly the compliance theater this ADR warns against. State it as a constraint; consider failing the build if the base is not UBI. +2. **A UBI image carries no validation of its own.** Red Hat's position: products are not FIPS validated, cryptographic components are — and if the host OS is not in FIPS mode, containers are not either. **The FIPS-mode RHEL host is a hard requirement**, not an implementation detail. +3. **The prior draft contradicted itself.** It claimed both "UBI9 inherits FIPS from system OpenSSL automatically" and "Node never reads `/proc/sys/crypto/fips_enabled`." Both are true and compatible: Node does not read it, but RHEL's *OpenSSL* does — that runtime check **is** the inheritance mechanism. Stated correctly here. +4. **Since RHEL 9.2 the FIPS provider ships as a separate RPM.** The prior draft's "our base image needs none of that" was asserted for a `-minimal` image with no evidence the package is present. **AC:** run `openssl list -providers` and `node --force-fips -e "console.log(require('crypto').getFips())"` *inside* the actual image and record the output. + +Stock nodejs.org binaries **do** support FIPS — `BUILDING.md`: "It is not necessary to rebuild Node.js to enable support for FIPS" — but require `openssl fipsinstall`, `OPENSSL_CONF`, and `OPENSSL_MODULES`. **[V]** + +**Operational-environment binding.** CMVP **IG 2.3.A**: the tested operational environment "must consist of the Operating System, the platform, and the processor," and "a claim cannot be made that the implementation also runs on another operating system." **[V]** Customers running outside the certificate's tested OE set need an explicit conformance statement. (Deploy-time porting to an untested OE maps to Management Manual §7.9 — **[U]**, not independently verified.) + +### 15. PostgreSQL + +**Scope is narrower than the prior draft implied. [V]** `docker-compose.yml:3` pins **`postgres:17`**, and Postgres 14+ defaults to `scram-sha-256`. The default stack needs no change. Exposure is limited to RHEL 8 AppStream (Postgres 13) and pre-existing customer databases. + +**And the prior draft's remediation did not address the case it identified.** `POSTGRES_HOST_AUTH_METHOD` / `POSTGRES_INITDB_ARGS` are Docker-image variables that take effect **only during `initdb` on an empty data directory** — they do nothing for a pre-existing database. Even `password_encryption = 'scram-sha-256'` affects only passwords set *after* the change; existing roles keep their `md5…` verifier in `pg_authid` indefinitely. The role must be re-set: + +```sql +ALTER SYSTEM SET password_encryption = 'scram-sha-256'; +SELECT pg_reload_conf(); +ALTER ROLE heimdall WITH PASSWORD ''; -- rewrites the verifier +-- then flip pg_hba.conf md5 → scram-sha-256 and reload +SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = 'heimdall'; +``` + +Add the `pg_authid` check to the RPM setup script's FIPS detection so an operator is warned *before* the app fails to connect. + +### 16. Observability + +**The prior draft's `bcryptjs` removal criterion — "zero rows across all deployments" — is unsatisfiable as written.** MITRE ships to air-gapped customers; the vendor never sees their `Users` table. + +Required: + +- **Log every rehash** at `info` via the existing Winston logger (`authn.service.ts:26`): user id, `from: bcrypt`, `to: pbkdf2-sha512`, iterations. A rehash is a security-relevant state change. Without it a stalled migration is invisible and a *spurious* rehash loop is undetectable. Also the audit trail for the migration — since §7 forbids touching `passwordChangedAt`, nothing else records that a credential was converted or when. +- **A `/health` endpoint.** None exists today **[V]** — no `/health`, `/ready`, or `/livez`. Return `{status, version, fips: crypto.getFips() === 1, passwordHashWriteEnabled, bcryptRemaining}`. This is load-bearing four ways: rolling-deploy readiness gating (§12), continuous FIPS evidence rather than a one-time boot log (§10), migration progress, and a machine-readable artifact an assessor can collect without shell access. It is also what makes the removal criterion achievable — a customer sends one JSON blob instead of a DB dump. +- **Progress query**, shipped as an installed script (`/usr/bin/heimdall-server-hash-report`), not a wiki snippet air-gapped operators cannot reach: + +```sql +SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_remaining, + count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%') AS pbkdf2_migrated, + max(age(now(), "lastLogin")) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS oldest_unmigrated +FROM "Users"; +``` + +- **Admin UI affordance** — a per-user legacy-hash badge and a bulk "force password change for all users on legacy hashes" action. This is the operational close-out for the dormant-account tail and the mechanism behind §3's cutover. + +**Restated removal criterion:** no earlier than N+3, and only after `bcrypt_remaining = 0` is confirmed via the health endpoint across supported deployments **or** a forced-reset release has shipped. + +## STIG and control mapping — corrected + +The prior draft's table had four defects. All rule metadata below is **[V]** against the DISA STIG API. + +| Rule | Severity | What it actually requires | Status after this ADR | +|---|---|---|---| +| **V-222542** | CAT I | Store only cryptographic representations — "strong cryptographic hash functions" + random salt, MD5 prohibited. **Does not mention FIPS validation.** CCI-004062/000196 | **Already satisfied today** by bcrypt; remains satisfied | +| **V-222571** | **CAT II** | FIPS-validated modules **when generating hashes**. CCI-002450. Invocation-scoped | **Satisfied** once §3's gate lands and legacy hashes are retired | +| **V-222572** | **CAT II** | FIPS-validated modules when protecting unclassified information. CCI-002450. *Omitted from the prior draft* | **Satisfied** on the same condition | +| **V-222543** | CAT I | Transmit only cryptographically-protected passwords. CCI-000197 | **NOT satisfied — and the prior draft claimed it was.** `main.ts:39-45` *explicitly removes* `upgrade-insecure-requests` ("causes issues for users trying to run over http"), and the session cookie is `secure` only in production. Helmet emits headers; it cannot enforce transport. **[V]** Requires a TLS reverse proxy — a deployment requirement, not an application control | +| **V-222570** | CAT II | FIPS-validated modules when **signing application components** — i.e. *code signing*, not JWT signing. CCI-002450 | **Mapping itself is questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, the "already compliant" claim was false: `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** rather than an approved KDF. **[V]** Both are real defects, carded separately. Note the rule's own escape hatch: "If signing has been identified in the application security plan as not being required and if a documented acceptance of risk is provided, this is not a finding" — which requires an AoR artifact we do not have | +| **V-230223** (RHEL 8) | CAT I | System-wide FIPS crypto policy, verified with `update-crypto-policies --show`. CCI-000068 | **Customer host responsibility.** No application change can satisfy an OS crypto-policy rule. Note this is a **RHEL 8** rule while our base image is UBI **9** | +| **V-258241** (RHEL 9) | CAT I | RHEL 9 analog; required hash list adds SHAKE-256 | **Customer host responsibility** | + +**Supporting controls:** IA-5(1)(d) — "passwords are stored using an approved salted key derivation function, preferably using a keyed hash" **[V]** — is the affirmative control this change satisfies. SC-13 assessment objects explicitly include "cryptographic module validation certificates; list of FIPS-validated cryptographic modules" **[V]**, which is why §Certificates below must be exact. + +**[U] — asserted in review but not verified; do not cite without confirmation:** IA-7 applicability to authenticator verification; SI-6 as the control for startup self-verification; V-16793 memory-zeroization applicability (note `password: string` is an immutable GC-managed V8 string that cannot be zeroized — a `Buffer`-based API with explicit zero-fill is the available mitigation, documented as compensating); NIST IR 8547 deprecation dates. + +**SP 800-63B peppering:** the secret-salt step is **SHOULD, not SHALL**, in both Rev 3 §5.1.1.2 and Rev 4 §3.1.1.2. **[V]** We do not pepper. Recorded as a decision rather than an omission. Our 32-byte salt far exceeds 800-63B's 32-*bit* minimum. + +## Certificates + +The prior draft cited **two wrong certificates**, and this is the first thing an assessor checks. **[V]** — CMVP registry: + +| Cited | Actual | Verdict | +|---|---|---| +| #4985 "RHEL OpenSSL" | **OpenSSL FIPS Provider**, vendor *The OpenSSL Project* | Wrong vendor — upstream generic module, not Red Hat | +| #4754 "Red Hat FIPS 140-3 policy" | **RHEL 9 libgcrypt** v1.10.0 | Wrong library (Node does not use libgcrypt) and **Historical**, superseded by #5366 | + +**Correct:** RHEL 9 OpenSSL FIPS Provider — **#4746** (RHEL 9.0) and **#4857** (RHEL 9.2/9.4/9.5/9.6, **Active**, validated 2024-10-29, sunset 2029-10-28). Policy `140sp4857.pdf` lists PBKDF2 [SP 800-132] Option 1a with ACVP certs A4813/A4823-A4826/A5578/A5585 (SHA-1/2) and A4814/A5587 (SHA-3), password range 8-128 characters. + +The SSP must name the module, version, certificate, **and** the certificate's tested operational environments for what the deployment actually links. + +## Scope + +**In scope:** pure module + service + Nest module; migration at all **eight** sites; FIPS-gated fallback; the §6 validation sequence; CAS rehash writes; narrow persistence methods; new env vars; launcher preflight + unit hardening + injectable assertion; `/health`; rehash logging; progress script; Postgres documentation and RPM detection; forced-reset cutover script; tests. + +**NOT in scope:** + +1. **Changing what API keys hash.** `apikey.service.ts:41` notes bcrypt's 72-byte limit as the reason only the signature is hashed. PBKDF2 removes that limit, but changing it invalidates every existing key. Needs its own ADR and a rotation plan. §11's inefficiency finding is recorded, not acted on. +2. **Migrating to better-auth.** Stays on Passport + Sequelize (`izw` epic). Forward note: v3 returns a bare boolean because better-auth's `verify` contract requires it; our richer return is possible *because* heimdall2 has no such constraint, and a future adapter will discard `needsRehash` on better-auth's path while an outer hook performs the rehash. +3. **Configurable password complexity.** +4. **Removing `bcryptjs`** — required for legacy verification until §16's criterion is met. +5. **Fixing V-222570** (empty-string JWT key, concatenated secret) — real, verified, and separately carded. +6. **Fixing the `passwordChangedAt` column-type mismatch** — pre-existing; documented in §7. +7. **Elastic-style `pbkdf2_stretch`** — the 15-character minimum makes the SHA-512 pre-hash unnecessary. +8. **RPM packaging changes** — `packaging/` does not exist on this branch **[V]**; it lives on `feat/rpm-build` / `saf-packaging`. Cross-repo coordination required, and the §12 downgrade guard is packaging-side, making it a **prerequisite** for enabling writes. + +## Documentation target + +**`ENVIRONMENT_VARIABLES.md` does not exist in this repository. [V]** The prior draft imported that filename from Vulcan. Heimdall2 documents environment variables in the **GitHub wiki** (`README.md:178`). + +That is unusable for this audience: **air-gapped customers cannot read a wiki.** Create **`docs/fips-deployment.md`** in-repo, shipped inside the RPM at `/usr/share/doc/heimdall-server/`. It covers env vars, the FIPS host requirement, Postgres remediation, the migration query, rollout/rollback sequence, and recovery procedure. + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Rollback / mixed-version lockout | Medium | **High** | §12 — write gate, durable marker, `%pre` guard, graceful-degradation test. API keys unrecoverable — regeneration is the only recovery | +| Fresh install ships a bcrypt admin | **High if unfixed** | **High** | §4 site 8; AC asserts `bcrypt_remaining = 0` on a fresh install | +| Rehash reverts a password change | Medium | **High** | §7 compare-and-swap | +| Silent FIPS bypass | Medium | **High** | §10 three layers; loud log when `FIPS_MODE` unset | +| Auth throughput ceiling / threadpool starvation | Medium | Medium | §11 — `UV_THREADPOOL_SIZE`, global KDF limiter, benchmark on target hardware | +| DoS via long password or forged iterations | Medium | Medium | §6 — 128-char cap, iteration bounds | +| Transitive dependency uses a non-approved digest | Medium | Medium | §13 runtime audit under `--force-fips` | +| Dormant accounts never migrate | **High** | Low | §3 cutover + §16 admin bulk action. bcrypt remains strong meanwhile — the gap is compliance, not security | +| Timing side-channel | Low | Low | Measured ratio is 7.7× (1120 vs 145 ms), trivially separable; identifies dormant never-migrated accounts. Compounded by a pre-existing ~1000× enumeration oracle — `validateUser` performs **no hashing** for a nonexistent user **[V]**. Mitigation is a dummy-hash on the absent/unknown-format paths. **Note:** the prior draft cited Django's `harden_runtime()` for this — incorrectly. That method equalizes *intra-PBKDF2 iteration* differences and cannot bridge a bcrypt-vs-PBKDF2 gap | + +## Alternatives considered + +1. **Argon2id** — OWASP's first recommendation, **not FIPS-approved**; no revised SP 800-132 draft exists. Keycloak defaults to it and must override in FIPS mode. The PHC format admits it later with no parser change. +2. **Hard cutover** (v3's approach) — clean, but forces a reset for every user. §3's phased design achieves the same terminal state without the disruption. +3. **Keep bcrypt, add `--force-fips`** — compliance theater. The process reports FIPS while a non-approved algorithm runs in pure JS where neither OpenSSL nor the OS can observe it. +4. **Unconditional bcrypt fallback** (the prior draft) — rejected against IG 2.4.A, and weaker than both Keycloak and GitLab. +5. **Adopt an npm package** — no viable candidate. `@phc/pbkdf2` last published **2018**, repo dead since 2021, no types, 13 stars. `pbkdf2-password` defaults to **SHA-1**. Everything maintained uses a non-approved KDF or native/WASM bindings that bypass OpenSSL. We borrow the format spec and implement ~80 lines. +6. **Spring-style opt-in rehash service** — `UserDetailsPasswordService` silently no-ops when unwired. Django's inline setter fails loudly. We follow Django. + +## Guiding principle + +GitLab's stated tiebreaker, adopted: **when security and compliance cannot both be satisfied, favor security.** Nothing here requires that trade — PBKDF2 at 600k is both — but it governs any future conflict. + +## References + +**Standards (verified)** — [SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) · [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) · [FIPS 140-3 Implementation Guidance](https://csrc.nist.gov/CSRC/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS%20140-3%20IG.pdf) (IG 2.3.A, 2.4.A, D.N) · [SP 800-53A Rev 5](https://csrc.nist.gov/pubs/sp/800/53/a/r5/final) (SC-13, IA-5(1)(d)) · [SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html) · [FedRAMP Cryptographic Module Policy v1.1.0](https://www.fedramp.gov/resources/documents/FedRAMP_Policy_for_Cryptographic_Module_Selection_v1.1.0.pdf) (FRR8) · [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) · [OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) + +**Certificates** — [CMVP #4857](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4857) (RHEL 9 OpenSSL, Active) · [#4746](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4746) (RHEL 9.0) · [#4985](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4985) (OpenSSL Project — *not* Red Hat) · [#4754](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4754) (libgcrypt, Historical) + +**Implementations** — [GitLab FIPS](https://docs.gitlab.com/development/fips_gitlab/) · [GitLab password storage](https://docs.gitlab.com/security/password_storage/) · [GitLab #360659](https://gitlab.com/gitlab-org/gitlab/-/issues/360659) · [Keycloak FIPS](https://www.keycloak.org/server/fips) · [Django CVE-2013-1443](https://www.djangoproject.com/weblog/2013/sep/15/security/) · [Django hashers.py](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) · [phc-pbkdf2](https://github.com/simonepri/phc-pbkdf2) + +**Known breakage** — [node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706) + +**In-repo prior art** — Heimdall v3 `a52f6ceb` (`mitre/heimdall`) · `fips_compliance` `cbfa40946`, `b384fd335`, `310c24a3c` From 35d47dee3ce935dab02b2440238ce7e9decdd598 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:31:14 -0400 Subject: [PATCH 003/197] build: import RPM packaging from saf-packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the heimdall-server RPM packaging into the application repository. The spec's %files section enumerates the app's build output and Version: tracks the app version, so the two must change together. Excluded from the import: - rpmbuild/ working tree — generated; Makefile:69 copies the canonical heimdall-server.spec into rpmbuild/SPECS/ at build time - man/man1/heimdall-cli-*.1 — those document heimdall-cli, which now lives at github.com/mitre/heimdall-cli Server man pages (heimdall-server.8, backend.env.5, sysconfig.5) come with the packaging; CLI man pages stay with the CLI. Authored by: Aaron Lippold --- packaging/rpm/.gitignore | 8 + packaging/rpm/.rpmlintrc | 11 + packaging/rpm/INSTALL.md | 851 ++++++++++++++++++ packaging/rpm/Makefile | 172 ++++ packaging/rpm/README.md | 356 ++++++++ packaging/rpm/firewalld/heimdall-server.xml | 6 + packaging/rpm/heimdall-Caddyfile | 41 + packaging/rpm/heimdall-backend.env | 83 ++ packaging/rpm/heimdall-configure.sh | 167 ++++ packaging/rpm/heimdall-db-setup.sh | 87 ++ packaging/rpm/heimdall-logrotate.conf | 36 + packaging/rpm/heimdall-postgres-setup.sh | 177 ++++ packaging/rpm/heimdall-rsyslog.conf | 31 + packaging/rpm/heimdall-server-tmpfiles.conf | 1 + packaging/rpm/heimdall-server.repo | 10 + packaging/rpm/heimdall-server.service | 54 ++ packaging/rpm/heimdall-server.sh | 35 + packaging/rpm/heimdall-server.spec | 399 ++++++++ packaging/rpm/heimdall-setup.sh | 501 +++++++++++ packaging/rpm/heimdall-sysconfig | 44 + .../rpm/man/heimdall-server-backend.env.5.md | 489 ++++++++++ .../rpm/man/heimdall-server-sysconfig.5.md | 136 +++ packaging/rpm/man/heimdall-server.8.md | 225 +++++ packaging/rpm/security/40-heimdall.rules | 33 + packaging/rpm/security/SECURITY.md | 202 +++++ packaging/rpm/selinux/heimdall_server.fc | 20 + packaging/rpm/selinux/heimdall_server.if | 95 ++ packaging/rpm/selinux/heimdall_server.te | 152 ++++ packaging/rpm/setup-rpm-build-env.sh | 421 +++++++++ 29 files changed, 4843 insertions(+) create mode 100644 packaging/rpm/.gitignore create mode 100644 packaging/rpm/.rpmlintrc create mode 100644 packaging/rpm/INSTALL.md create mode 100644 packaging/rpm/Makefile create mode 100644 packaging/rpm/README.md create mode 100644 packaging/rpm/firewalld/heimdall-server.xml create mode 100644 packaging/rpm/heimdall-Caddyfile create mode 100644 packaging/rpm/heimdall-backend.env create mode 100644 packaging/rpm/heimdall-configure.sh create mode 100644 packaging/rpm/heimdall-db-setup.sh create mode 100644 packaging/rpm/heimdall-logrotate.conf create mode 100644 packaging/rpm/heimdall-postgres-setup.sh create mode 100644 packaging/rpm/heimdall-rsyslog.conf create mode 100644 packaging/rpm/heimdall-server-tmpfiles.conf create mode 100644 packaging/rpm/heimdall-server.repo create mode 100644 packaging/rpm/heimdall-server.service create mode 100644 packaging/rpm/heimdall-server.sh create mode 100644 packaging/rpm/heimdall-server.spec create mode 100644 packaging/rpm/heimdall-setup.sh create mode 100644 packaging/rpm/heimdall-sysconfig create mode 100644 packaging/rpm/man/heimdall-server-backend.env.5.md create mode 100644 packaging/rpm/man/heimdall-server-sysconfig.5.md create mode 100644 packaging/rpm/man/heimdall-server.8.md create mode 100644 packaging/rpm/security/40-heimdall.rules create mode 100644 packaging/rpm/security/SECURITY.md create mode 100644 packaging/rpm/selinux/heimdall_server.fc create mode 100644 packaging/rpm/selinux/heimdall_server.if create mode 100644 packaging/rpm/selinux/heimdall_server.te create mode 100755 packaging/rpm/setup-rpm-build-env.sh diff --git a/packaging/rpm/.gitignore b/packaging/rpm/.gitignore new file mode 100644 index 0000000000..a582ef9f6c --- /dev/null +++ b/packaging/rpm/.gitignore @@ -0,0 +1,8 @@ +# rpmbuild working tree — generated by `make` (Makefile copies the canonical +# heimdall-server.spec into rpmbuild/SPECS/ at build time). +rpmbuild/ + +# Downloaded/generated build inputs and outputs +*.rpm +*.tar.gz +*.src.rpm diff --git a/packaging/rpm/.rpmlintrc b/packaging/rpm/.rpmlintrc new file mode 100644 index 0000000000..d8055f9379 --- /dev/null +++ b/packaging/rpm/.rpmlintrc @@ -0,0 +1,11 @@ +# rpmlint configuration for heimdall-server.spec +# +# Suppress known false positives so CI / dev rpmlint runs are noise-free +# and real issues stand out. + +# firewalld services live in /usr/lib/firewalld/services per Fedora +# packaging guidelines for firewalld: +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Firewalld/ +# rpmlint's hardcoded-library-path check fires on any /usr/lib/* path +# without distinguishing arch-specific from noarch — false positive here. +addFilter(r"hardcoded-library-path in %\{_prefix\}/lib/firewalld/services") diff --git a/packaging/rpm/INSTALL.md b/packaging/rpm/INSTALL.md new file mode 100644 index 0000000000..53963b8be3 --- /dev/null +++ b/packaging/rpm/INSTALL.md @@ -0,0 +1,851 @@ +# Heimdall Server — RPM Installation Guide + +## Supported Platforms + +| OS | Architectures | +|---|---| +| RHEL 8 / Oracle Linux 8 / Rocky 8 / Alma 8 | x86_64, aarch64 | +| RHEL 9 / Oracle Linux 9 / Rocky 9 / Alma 9 | x86_64, aarch64 | + +## Prerequisites + +- Root or sudo access +- PostgreSQL 13+ (local or remote) +- Node.js 22 (bundled in the RPM — no separate install needed) +- 2 GB RAM minimum (4 GB recommended) +- 1 GB free disk space + +### PostgreSQL Setup (if not already installed) + +The RPM recommends PostgreSQL but does not hard-require it, so you can +bring your own (local or remote). For a local install using PGDG packages: + +**EL8:** +```bash +sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm +sudo dnf -qy module disable postgresql +sudo dnf install -y postgresql18-server postgresql18 +``` + +**EL9:** +```bash +sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm +sudo dnf install -y postgresql18-server postgresql18 +``` + +For aarch64, replace `x86_64` with `aarch64` in the repo URL. + +Any PostgreSQL version 13–18 from PGDG is supported. The setup scripts +auto-detect the installed version. + +## Install + +Download the RPM for your OS and architecture from the +[GitHub Releases](https://github.com/mitre/heimdall2/releases) page. + +```bash +sudo dnf install -y ./heimdall-server-*.rpm +``` + +### Building from Source RPM + +Each release also publishes a source RPM (`.src.rpm`) so you can rebuild +on your own infrastructure, apply local patches, or build for an +architecture not covered by the release binaries: + +```bash +# Install build dependencies and rebuild +sudo dnf install -y rpm-build +rpmbuild --rebuild ./heimdall-server-*.src.rpm + +# The binary RPM is produced in ~/rpmbuild/RPMS/$(uname -m)/ +sudo dnf install -y ~/rpmbuild/RPMS/$(uname -m)/heimdall-server-*.rpm +``` + +You will need Node.js 22 (NodeSource), Yarn, and a C++ compiler +installed. See [`setup-rpm-build-env.sh`](setup-rpm-build-env.sh) +for the full dependency list, or run it with `--skip-deps` if you already +have everything. + +## Setup + +After install, run the setup command: + +```bash +sudo heimdall-server-setup +``` + +This runs six steps: + +1. **Configuration** — generates `/etc/heimdall-server/backend.env` with + database credentials, JWT secrets, API key secrets, and EXTERNAL_URL. + Missing values are auto-generated securely. +2. **PostgreSQL bootstrap** — initializes the database cluster, starts + PostgreSQL, creates the database role with SCRAM-SHA-256 authentication. + Skipped automatically if `DATABASE_HOST` is not localhost. +3. **Database migrations** — creates the database, runs all schema + migrations, and seeds the initial admin user. +4. **TLS reverse proxy** — configures Caddy as an HTTPS reverse proxy on + port 443, proxying to the backend on localhost:3000. For hostname-based + deployments, Caddy can auto-provision Let's Encrypt certificates. For + IP-based or air-gapped deployments, a self-signed certificate is + generated automatically. Skipped if Caddy is not installed. +5. **Security policies** — registers the Heimdall port with SELinux, sets + `httpd_can_network_connect` for the reverse proxy, adds bundled binaries + to fapolicyd trust, and opens HTTPS (443) in firewalld. Each subsystem + is skipped if its tools are not installed. +6. **Service start** — enables and starts `heimdall-server.service`. + Detects cloud environments (EC2, Azure, GCP) and prints helpful + firewall hints. + +### Setup Options + +```bash +# Interactive (default when run from a terminal) +sudo heimdall-server-setup --interactive + +# Non-interactive (for automation — accepts all defaults) +sudo heimdall-server-setup --non-interactive + +# Reconfigure only (re-run step 1, then restart service) +sudo heimdall-server-setup --reconfigure + +# Skip database steps (for remote database setups) +sudo heimdall-server-setup --skip-db + +# Skip TLS proxy setup (if you manage your own reverse proxy) +sudo heimdall-server-setup --skip-tls +``` + +### Remote Database + +To use an existing PostgreSQL server instead of a local one: + +1. Run `sudo heimdall-server-setup --interactive` +2. Set `DATABASE_HOST` to your server's hostname or IP +3. Set `DATABASE_PORT`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` as needed +4. The PostgreSQL bootstrap step is automatically skipped + +Or edit `/etc/heimdall-server/backend.env` directly and run: +```bash +sudo heimdall-server-setup --skip-db +sudo heimdall-server-db-setup +``` + +## PostgreSQL Security + +### Local Database (default) + +The setup script automatically configures PostgreSQL with: + +- **scram-sha-256** password authentication (not md5 or trust) +- Password-authenticated connections for the `heimdall-server-production` database + in `pg_hba.conf` +- `password_encryption = scram-sha-256` in `postgresql.conf` +- Peer authentication retained for the `postgres` superuser (admin tasks only) +- Verification that the database role password is stored as SCRAM-SHA-256 + (setup exits with an error if not) + +### External Database (RDS, Azure DB, etc.) + +When using an external database, ensure: + +- TLS is enabled for all connections (`sslmode=require` or `verify-full`) +- The database user has minimal privileges (CONNECT, CREATE on the target database) +- Network access is restricted to the Heimdall server's IP/subnet +- Password meets your organization's complexity requirements + +Configure external database in `/etc/heimdall-server/backend.env`: + +``` +DATABASE_HOST=your-rds-endpoint.region.rds.amazonaws.com +DATABASE_PORT=5432 +DATABASE_USERNAME=heimdall +DATABASE_PASSWORD= +DATABASE_NAME=heimdall-server-production +DATABASE_SSL=true +``` + +## Initial Login + +After setup completes, the admin credentials are printed to the terminal: + +``` +New administrator email is: admin@heimdall.local +New administrator password is: +``` + +**Change this password on first login.** + +Access Heimdall at `https://` (if Caddy is configured) or +`http://localhost:3000` (direct, no TLS). + +## Logging + +By default, logs go to **journald** (the standard RHEL approach): + +```bash +# View recent logs +sudo journalctl -u heimdall-server -n 100 + +# Follow live +sudo journalctl -u heimdall-server -f + +# Or use heimdall-cli +heimdall-cli logs --lines 100 +heimdall-cli logs --follow +``` + +To write logs to a file instead, set `LOG_FILE` in `backend.env`: + +```bash +# Edit config +sudo vi /etc/heimdall-server/backend.env +# Add: LOG_FILE=/var/log/heimdall-server/server.log + +# Restart to apply +sudo systemctl restart heimdall-server +``` + +The directory `/var/log/heimdall-server/` is created automatically and +owned by the `heimdall` user. You can set `LOG_FILE` to any writable +path. Log rotation is your responsibility when using file-based logging +(configure via `/etc/logrotate.d/`). + +When `LOG_FILE` is unset (the default), journald handles log storage, +rotation, and cleanup automatically. + +## Service Management + +```bash +# Check status +sudo systemctl status heimdall-server + +# View logs +sudo journalctl -u heimdall-server -f + +# Stop +sudo systemctl stop heimdall-server + +# Start +sudo systemctl start heimdall-server + +# Restart (after config changes) +sudo systemctl restart heimdall-server + +# Disable (prevent start on boot) +sudo systemctl disable heimdall-server +``` + +## Configuration + +All configuration is in `/etc/heimdall-server/backend.env`. This file is +owned by `root:heimdall` with mode `0640` (not world-readable since it +contains secrets). + +For the complete list of environment variables, run `heimdall-cli config list` +or see the [Heimdall2 repository](https://github.com/mitre/heimdall2). + +### Key Settings + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | HTTP listen port | +| `DATABASE_HOST` | `localhost` | PostgreSQL host | +| `DATABASE_PORT` | `5432` | PostgreSQL port | +| `DATABASE_USERNAME` | `postgres` | Database role | +| `DATABASE_PASSWORD` | (auto-generated) | Database password | +| `DATABASE_NAME` | `heimdall-server-production` | Database name | +| `JWT_SECRET` | (auto-generated) | JWT signing key | +| `JWT_EXPIRE_TIME` | `1d` | JWT token lifetime | +| `API_KEY_SECRET` | (auto-generated) | API key signing secret | +| `EXTERNAL_URL` | `https://${NGINX_HOST}` | Public URL (required for HTTPS and OAuth) | +| `NGINX_HOST` | `localhost` | Public hostname / FQDN | +| `ADMIN_EMAIL` | `admin@heimdall.local` | Initial admin email | + +After editing, restart the service: +```bash +sudo systemctl restart heimdall-server +``` + +### Changing the Listen Port + +Edit `/etc/heimdall-server/backend.env`: +```bash +PORT=8443 +``` +Then: +```bash +sudo systemctl restart heimdall-server +``` + +## TLS Reverse Proxy (Caddy) + +Heimdall requires HTTPS in production (the app's security headers enforce it). +The setup script configures [Caddy](https://caddyserver.com) as a TLS reverse +proxy on port 443, proxying to the Node.js backend on localhost:3000. + +### Installing Caddy + +Caddy is in EPEL: +```bash +# EL8 +sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm +sudo dnf install -y caddy + +# EL9 +sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm +sudo dnf install -y caddy +``` + +Then re-run setup to configure TLS: +```bash +sudo heimdall-server-setup --skip-db +``` + +### TLS Certificate Strategies + +| Scenario | What happens | +|---|---| +| **Public hostname** (e.g., `heimdall.agency.mil`) | Caddy auto-provisions Let's Encrypt certs | +| **Private hostname** (`.internal`, `.local`, `.lan`, etc.) | Setup adds `tls internal` — Caddy issues cert from its internal CA | +| **Air-gapped** (private hostname, no internet) | Same as above — internal CA works without internet | +| **IP address** | Setup generates a self-signed cert with IP SAN | +| **BYO cert** | Edit Caddyfile: `tls /path/to/cert.pem /path/to/key.pem` | + +Caddy's internal CA root cert is at: +``` +/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt +``` +Import this into client trust stores to avoid browser warnings. + +### Private Hostname Deployments + +When using a private hostname (e.g., `heimdall.internal`, `heimdall.local`), clients +must be able to resolve the hostname. Options: + +#### Option 1: DNS (recommended) +Add an A record in your internal DNS pointing the hostname to the server IP. + +#### Option 2: Client /etc/hosts +Add to each client's `/etc/hosts`: +``` +192.168.1.100 heimdall.internal +``` + +#### Option 3: Use IP directly +Re-run setup with the server IP: +```bash +sudo heimdall-server-setup --external-url https://192.168.1.100 --skip-db +``` +This generates a self-signed certificate with the IP as SAN. + +#### Trusting the Caddy Internal CA + +For private hostname deployments, Caddy uses its internal CA. Import the root +certificate into client browsers: + +```bash +# Copy from server +scp server:/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt ./caddy-root.crt + +# Import into system trust store (RHEL/Fedora) +sudo cp caddy-root.crt /etc/pki/ca-trust/source/anchors/ +sudo update-ca-trust + +# macOS +sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt +``` + +Self-signed certs (IP-based) are stored at: +``` +/etc/pki/heimdall-server/server.crt +/etc/pki/heimdall-server/server.key +``` + +## Enterprise Deployment Patterns + +### Behind a Load Balancer (AWS ALB, F5, HAProxy) + +When TLS is terminated at the load balancer, the app receives plain HTTP. +Skip Caddy and let the LB handle certificates: + +```bash +sudo heimdall-server-setup \ + --external-url https://heimdall.agency.mil \ + --skip-tls +``` + +The app listens on port 3000 (configurable via `PORT` in `backend.env`). +Point the LB target group at port 3000. The setup script opens this port +in firewalld instead of 443. + +`EXTERNAL_URL` is still required — it's used for OAuth callback URLs, +email links, and the app's security headers. + +### Corporate PKI Certificates + +If your organization issues certificates from an internal CA: + +```bash +sudo heimdall-server-setup \ + --external-url https://heimdall.agency.mil \ + --tls-cert /etc/pki/tls/certs/heimdall.pem \ + --tls-key /etc/pki/tls/private/heimdall.key +``` + +Caddy uses these certificates directly — no Let's Encrypt, no self-signed. +When certs are renewed, reload Caddy: `sudo systemctl reload caddy`. + +### Behind an Existing Reverse Proxy (nginx, Apache, HAProxy) + +If your organization has a standard reverse proxy stack: + +```bash +sudo heimdall-server-setup \ + --external-url https://heimdall.agency.mil \ + --skip-tls +``` + +Then configure your existing proxy to forward to `http://127.0.0.1:3000`. + +### Alternative: nginx + +If you prefer nginx, run setup with `--skip-tls` and configure manually: + +```nginx +server { + listen 443 ssl; + server_name heimdall.example.com; + + ssl_certificate /etc/pki/tls/certs/heimdall.crt; + ssl_certificate_key /etc/pki/tls/private/heimdall.key; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable the SELinux boolean for proxy connections: +```bash +sudo setsebool -P httpd_can_network_connect on +``` + +Set `EXTERNAL_URL` in `backend.env` to match your public hostname. +`EXTERNAL_URL` is required for OAuth/OIDC callback URLs to work. + +## Firewall + +The setup script opens HTTPS (443) in firewalld automatically when Caddy +is configured. For manual setup: + +```bash +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload +``` + +When using a reverse proxy, avoid exposing port 3000 directly — restrict +access via firewalld or security groups so only the proxy reaches it. + +### Cloud Environments + +The setup script detects AWS EC2, Azure, and GCP VMs and prints hints +about opening external firewall ports (Security Groups, NSGs, VPC rules). +These cannot be configured from inside the VM. + +## Single Sign-On (SSO) and External Authentication + +Heimdall supports multiple authentication providers. Each is auto-enabled +when its client ID is configured in `/etc/heimdall-server/backend.env`. + +Edit the env file and restart the service to enable any provider: +```bash +sudo vi /etc/heimdall-server/backend.env +sudo systemctl restart heimdall-server +``` + +**Important:** All OAuth/OIDC providers require `EXTERNAL_URL` to be set +to the public URL users access Heimdall at (e.g., `https://heimdall.example.com`). +This is used to construct callback URLs. HTTPS is required for production +OAuth deployments. + +### Okta + +1. In Okta Admin Console, create a new **Web** application +2. Set the sign-in redirect URI to: `{EXTERNAL_URL}/authn/okta_callback` +3. Note the Client ID, Client Secret, and your Okta domain + +```bash +EXTERNAL_URL=https://heimdall.example.com +OKTA_DOMAIN=your-domain.okta.com +OKTA_CLIENTID= +OKTA_CLIENTSECRET= +``` + +Endpoints are auto-discovered from `OKTA_DOMAIN`. Override if needed: +```bash +OKTA_ISSUER_URL=https://your-domain.okta.com +OKTA_AUTHORIZATION_URL=https://your-domain.okta.com/oauth2/v1/authorize +OKTA_TOKEN_URL=https://your-domain.okta.com/oauth2/v1/token +OKTA_USER_INFO_URL=https://your-domain.okta.com/oauth2/v1/userinfo +``` + +### GitHub OAuth + +1. Go to GitHub → Settings → Developer settings → OAuth Apps → New +2. Set the callback URL to: `{EXTERNAL_URL}/authn/github/callback` + +```bash +EXTERNAL_URL=https://heimdall.example.com +GITHUB_CLIENTID= +GITHUB_CLIENTSECRET= +``` + +For GitHub Enterprise: +```bash +GITHUB_ENTERPRISE_INSTANCE_BASE_URL=https://github.company.com/ +GITHUB_ENTERPRISE_INSTANCE_API_URL=https://github.company.com/api/v3/ +``` + +### GitLab OAuth + +1. In GitLab, go to Admin → Applications → New Application +2. Set the callback URL to: `{EXTERNAL_URL}/authn/gitlab/callback` +3. Select scopes: `read_user` + +```bash +EXTERNAL_URL=https://heimdall.example.com +GITLAB_CLIENTID= +GITLAB_SECRET= +GITLAB_BASEURL=https://gitlab.com # or your self-hosted GitLab URL +``` + +### Google OAuth + +1. Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth Client ID +2. Set authorized redirect URI to: `{EXTERNAL_URL}/authn/google/callback` + +```bash +EXTERNAL_URL=https://heimdall.example.com +GOOGLE_CLIENTID=.apps.googleusercontent.com +GOOGLE_CLIENTSECRET= +``` + +### Generic OIDC + +For any OpenID Connect provider (Keycloak, Azure AD, Auth0, etc.): + +1. Create an application/client in your OIDC provider +2. Set the callback URL to: `{EXTERNAL_URL}/authn/oidc_callback` +3. Note the issuer URL, client ID, client secret, and endpoint URLs + +```bash +EXTERNAL_URL=https://heimdall.example.com +OIDC_NAME=My Identity Provider +OIDC_ISSUER=https://auth.example.com +OIDC_AUTHORIZATION_URL=https://auth.example.com/authorize +OIDC_TOKEN_URL=https://auth.example.com/token +OIDC_USER_INFO_URL=https://auth.example.com/userinfo +OIDC_CLIENTID= +OIDC_CLIENT_SECRET= +``` + +### LDAP / Active Directory + +```bash +LDAP_ENABLED=true +LDAP_HOST=ldap.example.com +LDAP_PORT=389 +LDAP_BINDDN=cn=admin,dc=example,dc=com +LDAP_PASSWORD= +LDAP_SEARCHBASE=OU=Users,DC=example,DC=com +LDAP_SEARCHFILTER=(sAMAccountName={{username}}) +``` + +For LDAPS (TLS): +```bash +LDAP_SSL=true +LDAP_SSL_CA=/etc/pki/tls/certs/ldap-ca.pem +# LDAP_SSL_INSECURE=true # Skip cert verification (not recommended) +``` + +### Disabling Local Login + +Once SSO is configured, you can disable local password login and +public registration: + +```bash +LOCAL_LOGIN_DISABLED=true +REGISTRATION_DISABLED=true +``` + +The initial admin account still works for emergency access. + +## SELinux + +The RPM ships a custom SELinux policy module (`heimdall_server_t`) that is +automatically loaded on install and removed on uninstall. No manual SELinux +configuration is needed for the default setup (port 3000, local PostgreSQL). + +### Custom Port + +If you change `PORT` in `backend.env` to something other than 3000, register +the new port with SELinux: + +```bash +sudo semanage port -a -t heimdall_server_port_t -p tcp 8443 +``` + +The setup script (`heimdall-server-setup`) does this automatically. + +### Troubleshooting SELinux + +Check for denials: +```bash +sudo ausearch -m avc -ts recent | grep heimdall +``` + +Temporarily set the domain to permissive for debugging: +```bash +sudo semanage permissive -a heimdall_server_t +# Test, then re-enforce: +sudo semanage permissive -d heimdall_server_t +``` + +### PostgreSQL Connection + +The policy includes a tunable for PostgreSQL access (enabled by default): +```bash +# Verify: +getsebool heimdall_server_connect_postgresql +# Toggle: +sudo setsebool -P heimdall_server_connect_postgresql on +``` + +## fapolicyd + +The RPM automatically registers bundled binaries (Node.js and native addons) +with fapolicyd's trust database at `/etc/fapolicyd/trust.d/heimdall-server` +on install. Entries are removed on uninstall. No manual configuration needed. + +If you reinstall or upgrade and fapolicyd blocks execution: +```bash +sudo /usr/libexec/heimdall-server/fapolicyd-trust.sh add +``` + +## Firewall + +The RPM ships a firewalld service definition. To open the Heimdall port: + +```bash +sudo firewall-cmd --permanent --add-service=heimdall-server +sudo firewall-cmd --reload +``` + +For a custom port (not 3000): +```bash +sudo firewall-cmd --permanent --add-port=8443/tcp +sudo firewall-cmd --reload +``` + +## Admin CLI + +The RPM includes `heimdall-cli`, a command-line tool for common admin tasks: + +```bash +# Service status, database, SELinux, fapolicyd, firewalld overview +sudo heimdall-cli status + +# View all config grouped by category with descriptions +heimdall-cli config list + +# Get/set individual config values (validates types) +heimdall-cli config get PORT +sudo heimdall-cli config set PORT 8443 + +# Reset admin password +sudo heimdall-cli reset_password admin@heimdall.local + +# Change listen port (updates config, SELinux, firewalld, restarts service) +sudo heimdall-cli set_port 8443 + +# Add organizational CA certificate +sudo heimdall-cli add_cert /path/to/ca.pem + +# Backup database + config to a timestamped archive +sudo heimdall-cli backup /root + +# Restore from archive +sudo heimdall-cli restore /root/heimdall-backup-20260226-143000.tar.gz + +# View logs +heimdall-cli logs --lines 100 +heimdall-cli logs --follow + +# Full diagnostic dump (for support tickets) +sudo heimdall-cli diag + +# Service control +sudo heimdall-cli restart +sudo heimdall-cli stop +sudo heimdall-cli start +``` + +Tab completion is available in bash (installed to `/etc/bash_completion.d/`). + +## Backup and Restore + +Using `heimdall-cli` (backs up both database and config to a single archive): + +```bash +sudo heimdall-cli backup /root +sudo heimdall-cli restore /root/heimdall-backup-20260226-143000.tar.gz +``` + +Or manually: + +### Database Backup + +```bash +sudo -u postgres pg_dump heimdall-server-production > heimdall-backup-$(date +%Y%m%d).sql +``` + +### Database Restore + +```bash +sudo -u postgres psql -d heimdall-server-production < heimdall-backup-YYYYMMDD.sql +``` + +### Configuration Backup + +```bash +sudo cp /etc/heimdall-server/backend.env /root/heimdall-backend.env.bak +``` + +## User Management + +### Creating Additional Users + +Users register through the web interface at `/signup`, or an admin can +create accounts via the API: + +```bash +# Get admin JWT token +TOKEN=$(curl -s -X POST http://localhost:3000/authn/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@heimdall.local","password":""}' \ + | jq -r '.accessToken') + +# Create a new user +curl -X POST http://localhost:3000/users \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@example.com", + "password": "SecurePassword123!", + "passwordConfirmation": "SecurePassword123!", + "role": "user", + "firstName": "First", + "lastName": "Last" + }' +``` + +### Changing the Admin Password + +Log in to the web interface and change it under account settings, or use +the API: + +```bash +curl -X PUT http://localhost:3000/users/ \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "currentPassword": "", + "password": "", + "passwordConfirmation": "" + }' +``` + +## Upgrading + +Back up before upgrading: +```bash +sudo heimdall-cli backup /root +``` + +Then upgrade: +```bash +sudo dnf upgrade -y ./heimdall-server-.rpm +sudo heimdall-server-db-setup # Run new migrations +sudo systemctl restart heimdall-server +``` + +The config file (`backend.env`) is preserved across upgrades +(`%config(noreplace)`). + +## Uninstall + +```bash +sudo systemctl stop heimdall-server +sudo dnf remove heimdall-server +``` + +This removes the application files but preserves: +- `/etc/heimdall-server/backend.env` (marked as config) +- The PostgreSQL database and data + +To fully clean up: +```bash +sudo rm -rf /etc/heimdall-server +sudo userdel heimdall +sudo groupdel heimdall +# Optionally drop the database: +sudo -u postgres psql -c "DROP DATABASE \"heimdall-server-production\";" +``` + +## Troubleshooting + +### Service won't start + +```bash +sudo journalctl -u heimdall-server -n 50 --no-pager +``` + +Common causes: +- `DATABASE_PASSWORD` not set → run `sudo heimdall-server-setup` +- PostgreSQL not running → `sudo systemctl start postgresql-18` +- Port already in use → change `PORT` in `backend.env` + +### Database connection refused + +```bash +# Check PostgreSQL is running +sudo systemctl status postgresql-18 + +# Test connection +PGPASSWORD= psql -h localhost -U postgres -d heimdall-server-production -c "SELECT 1;" +``` + +### Re-run setup from scratch + +```bash +sudo heimdall-server-setup --non-interactive +``` + +### Reset admin password + +```bash +# Using heimdall-cli (recommended) +sudo heimdall-cli reset_password admin@heimdall.local + +# Or manually +sudo -u postgres psql -d heimdall-server-production -c " + UPDATE \"Users\" SET \"encryptedPassword\" = '' WHERE email = 'admin@heimdall.local'; +" +sudo heimdall-server-db-setup # Re-seeds admin with new random password +``` diff --git a/packaging/rpm/Makefile b/packaging/rpm/Makefile new file mode 100644 index 0000000000..0f463ec544 --- /dev/null +++ b/packaging/rpm/Makefile @@ -0,0 +1,172 @@ +SPEC := heimdall-server.spec +NAME := $(shell rpmspec -q --qf '%{name}' $(SPEC) 2>/dev/null | head -1) +VERSION := $(shell rpmspec -q --qf '%{version}' $(SPEC) 2>/dev/null | head -1) +TOPDIR := $(shell pwd)/rpmbuild + +# Target architecture for cross-compilation (default: host arch) +GOARCH ?= $(shell go env GOARCH) +CLI_DIR := ../heimdall-cli + +.PHONY: srpm rpm rpm-install rpm-package clean lint lint-rpm sources heimdall-cli man stage deps check-upstream bump-version + +# Upstream repo for `make check-upstream` (override for forks/mirrors). +UPSTREAM_REPO ?= mitre/heimdall2 + +# Yarn cache for fast local rebuilds. Off by default so `make rpm` +# matches CI/mock/COPR (clean stateless build, deterministic). +# Enable with `make rpm CACHE=1` — yarn keeps its disk cache between +# builds so `yarn install` skips re-downloading every dep from npm. +CACHE ?= 0 +YARN_CACHE ?= $(HOME)/.cache/yarn-heimdall +ifeq ($(CACHE),1) + RPMBUILD_PREFIX := YARN_CACHE_FOLDER=$(YARN_CACHE) +else + RPMBUILD_PREFIX := +endif + +sources: + @echo "Downloading source tarball for $(NAME)-$(VERSION)..." + @mkdir -p $(TOPDIR)/SOURCES + ../scripts/fetch-source.sh \ + --package heimdall-server \ + --version $(VERSION) \ + --output-dir $(TOPDIR)/SOURCES + @# fetch-source.sh produces heimdall-server-VERSION.tar.gz; spec expects heimdall2-VERSION.tar.gz + mv $(TOPDIR)/SOURCES/heimdall-server-$(VERSION).tar.gz \ + $(TOPDIR)/SOURCES/heimdall2-$(VERSION).tar.gz + +# Build Go CLI binary for the target platform (static, no CGO). +# Produces a flat binary at rpmbuild/SOURCES/heimdall-cli (spec Source15). +# All three version fields (Version/Commit/Date) are injected so +# `heimdall-cli --version` shows real provenance — matches what the +# heimdall-cli/Makefile injects for its own build target. +CLI_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +CLI_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +heimdall-cli: + @mkdir -p $(TOPDIR)/SOURCES + cd $(CLI_DIR) && GOOS=linux GOARCH=$(GOARCH) CGO_ENABLED=0 \ + go build -trimpath \ + -ldflags="-s -w \ + -X 'github.com/mitre/heimdall-cli/internal/version.Version=$(VERSION)' \ + -X 'github.com/mitre/heimdall-cli/internal/version.Commit=$(CLI_COMMIT)' \ + -X 'github.com/mitre/heimdall-cli/internal/version.Date=$(CLI_DATE)'" \ + -o $(abspath $(TOPDIR)/SOURCES/heimdall-cli) \ + ./cmd/heimdall-cli + @echo "Built: $(TOPDIR)/SOURCES/heimdall-cli (linux/$(GOARCH), commit $(CLI_COMMIT))" + +# Generate man pages from CLI command tree. +man: + cd $(CLI_DIR) && go run ./cmd/gen-manpages $(abspath man/man1) + +# Install build dependencies (delegates to setup script). +deps: + ../scripts/setup-build-deps.sh + +# Stage all source files into rpmbuild tree (Sources 0-21). +# Single source of truth for which files go into the RPM. +stage: sources heimdall-cli man + @mkdir -p $(TOPDIR)/{SPECS,BUILD,RPMS,SRPMS} + cp $(SPEC) $(TOPDIR)/SPECS/ + @# Source 9-11: SELinux policy + cp selinux/* $(TOPDIR)/SOURCES/ + @# (fapolicyd helper script retired — handled by `heimdall-cli fapolicyd`) + @# Source 13: firewalld service definition + cp firewalld/* $(TOPDIR)/SOURCES/ + @# Sources 1-8, 14, 16-19: service, config, scripts + cp heimdall-backend.env heimdall-server.service heimdall-server.sh \ + heimdall-configure.sh heimdall-postgres-setup.sh heimdall-setup.sh \ + heimdall-db-setup.sh heimdall-server-tmpfiles.conf heimdall-server.repo \ + heimdall-Caddyfile heimdall-sysconfig \ + heimdall-rsyslog.conf heimdall-logrotate.conf \ + $(TOPDIR)/SOURCES/ + @# Sources 20-21: security samples + cp security/40-heimdall.rules security/SECURITY.md $(TOPDIR)/SOURCES/ + @# Man pages (generated by the man target above) + mkdir -p $(TOPDIR)/BUILD/man/man1 + cp man/man1/*.1 $(TOPDIR)/BUILD/man/man1/ 2>/dev/null || true + +srpm: stage + rpmbuild -bs --define "_topdir $(TOPDIR)" $(TOPDIR)/SPECS/$(SPEC) + @echo "SRPM: $$(ls $(TOPDIR)/SRPMS/*.src.rpm)" + +rpm: stage + @$(if $(filter 1,$(CACHE)),mkdir -p $(YARN_CACHE),true) + $(RPMBUILD_PREFIX) rpmbuild -ba --define "_topdir $(TOPDIR)" $(TOPDIR)/SPECS/$(SPEC) + @echo "RPMs: $$(ls $(TOPDIR)/RPMS/*/*.rpm)" + +# Iterate fast on spec edits — skips %prep + %build (no tarball +# extraction, no `yarn install`, no node build) and re-runs only +# %install + %check + %files + binary packaging against the BUILD +# tree from the previous `make rpm`. Use after editing %install, +# %files, %post/%postun scriptlets, or any spec metadata. +# Requires a prior successful `make rpm`. +rpm-install: + rpmbuild --short-circuit -bi --define "_topdir $(TOPDIR)" $(TOPDIR)/SPECS/$(SPEC) + @echo "RPMs: $$(ls $(TOPDIR)/RPMS/*/*.rpm)" + +# Fastest possible iteration — skips %prep + %build + %install, +# re-runs only %check + %files selection + binary packaging against +# the existing BUILDROOT. Use after editing only %files / %changelog +# / metadata when the BUILDROOT contents themselves don't need to +# change. If BUILDROOT has been cleaned (a full `make rpm` does this +# at the end of packaging on most rpmbuild versions), falls back to +# `rpm-install` automatically so the target always works. +rpm-package: + @buildroot_dir="$(TOPDIR)/BUILDROOT"; \ + if [ ! -d "$$buildroot_dir" ] || [ -z "$$(ls -A "$$buildroot_dir" 2>/dev/null)" ]; then \ + echo "BUILDROOT empty (cleaned by previous build) — running rpm-install first"; \ + $(MAKE) rpm-install; \ + else \ + rpmbuild --short-circuit -bb --define "_topdir $(TOPDIR)" $(TOPDIR)/SPECS/$(SPEC); \ + echo "RPMs: $$(ls $(TOPDIR)/RPMS/*/*.rpm)"; \ + fi + +lint: + rpmlint -f .rpmlintrc $(SPEC) + +# Lint built RPMs (run after 'make rpm') +lint-rpm: + @if ls $(TOPDIR)/RPMS/*/*.rpm >/dev/null 2>&1; then \ + rpmlint -f .rpmlintrc $(TOPDIR)/RPMS/*/*.rpm; \ + else \ + echo "No RPMs found — run 'make rpm' first"; exit 1; \ + fi + @if ls $(TOPDIR)/SRPMS/*.src.rpm >/dev/null 2>&1; then \ + rpmlint -f .rpmlintrc $(TOPDIR)/SRPMS/*.src.rpm; \ + fi + +clean: + rm -rf $(TOPDIR) + +# Compare the spec's Version against the latest GitHub release on UPSTREAM_REPO. +# Read-only: never touches the spec. Useful before `make bump-version`. +# Uses gh CLI if available, otherwise falls back to curl (always present on RHEL). +check-upstream: + @command -v rpmspec >/dev/null 2>&1 || { echo "rpmspec required (rpm-build package)"; exit 1; } + @spec_ver=$$(rpmspec -q --qf '%{version}\n' $(SPEC) 2>/dev/null | head -1); \ + if command -v gh >/dev/null 2>&1; then \ + gh_tag=$$(gh release view --repo $(UPSTREAM_REPO) --json tagName -q '.tagName' 2>/dev/null); \ + else \ + gh_tag=$$(curl -sfL -o /dev/null -w '%{url_effective}\n' \ + https://github.com/$(UPSTREAM_REPO)/releases/latest \ + | sed 's|.*/tag/||'); \ + fi; \ + gh_ver=$${gh_tag#v}; \ + echo "spec: $$spec_ver"; \ + echo "github: $$gh_ver ($(UPSTREAM_REPO))"; \ + if [ "$$spec_ver" = "$$gh_ver" ]; then \ + echo "✓ in sync"; \ + else \ + echo "✗ spec is behind — run: make bump-version VERSION=$$gh_ver"; \ + exit 1; \ + fi + +# Bump the spec to a new upstream version using rpmdev-bumpspec. +# Sets new Version, resets Release to 1, prepends a dated changelog entry. +# Stages a single edit you commit; does NOT build. +bump-version: + @command -v rpmdev-bumpspec >/dev/null 2>&1 || { echo "rpmdev-bumpspec required (rpmdevtools package)"; exit 1; } + @test -n "$(VERSION)" || { echo "Usage: make bump-version VERSION=X.Y.Z"; exit 1; } + rpmdev-bumpspec --new=$(VERSION) -c "Update to $(VERSION)" $(SPEC) + @echo + @echo "Spec bumped. Review: git diff $(SPEC)" diff --git a/packaging/rpm/README.md b/packaging/rpm/README.md new file mode 100644 index 0000000000..e2c471217d --- /dev/null +++ b/packaging/rpm/README.md @@ -0,0 +1,356 @@ +# Heimdall Server RPM Package + +RPM packaging for [Heimdall Server](https://github.com/mitre/heimdall2) on RHEL, Oracle Linux, Rocky Linux, and AlmaLinux (EL8 and EL9). + +## Quick Start + +```bash +# 1. Build the RPM (installs all build deps, fetches source, runs rpmbuild) +./setup-rpm-build-env.sh --build + +# 2. Install +sudo dnf install -y ~/rpmbuild/RPMS/$(uname -m)/heimdall-server-*.rpm + +# 3. Run post-install setup +sudo heimdall-cli setup + +# 4. Verify +sudo heimdall-cli status +``` + +## Building the RPM + +### Option A: Automated (recommended) + +The `setup-rpm-build-env.sh` script handles everything — repos, build deps, source download, and rpmbuild: + +```bash +# Full automated build +./setup-rpm-build-env.sh --build + +# Specify a version +./setup-rpm-build-env.sh --version 2.13.1 --build + +# Skip dependency installation (already set up) +./setup-rpm-build-env.sh --skip-deps --build + +# Air-gapped / mirror environments +./setup-rpm-build-env.sh --no-gpg-check --build +``` + +### Option B: Manual step-by-step + +#### 1. Install build dependencies + +**EL9 (Oracle Linux, RHEL, Rocky, Alma):** + +```bash +# Enable EPEL (provides yarnpkg) +sudo dnf install -y epel-release + +# Enable CodeReady Builder / CRB (provides selinux-policy-devel) +sudo dnf config-manager --set-enabled crb # RHEL/Rocky/Alma +# or: sudo dnf config-manager --set-enabled ol9_codeready_builder # Oracle Linux + +# Install Node.js 22 from NodeSource +curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - + +# Install build tools +sudo dnf install -y \ + gcc-c++ make git rpm-build rpmdevtools rpmlint \ + selinux-policy-devel systemd-rpm-macros \ + python3 curl openssl tar util-linux \ + nodejs yarnpkg + +# Install Go (for building heimdall-cli) +GO_VERSION=1.24.4 +curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \ + | sudo tar -C /usr/local -xzf - +echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/golang.sh +export PATH=$PATH:/usr/local/go/bin +``` + +**EL8 (Oracle Linux, RHEL, Rocky, Alma):** + +```bash +# Enable EPEL +sudo dnf install -y epel-release + +# Enable PowerTools / CRB +sudo dnf config-manager --set-enabled powertools # CentOS/Rocky/Alma +# or: sudo dnf config-manager --set-enabled ol8_codeready_builder # Oracle Linux + +# Install Node.js 22 from NodeSource +curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - + +# Install build tools (same as EL9) +sudo dnf install -y \ + gcc-c++ make git rpm-build rpmdevtools rpmlint \ + selinux-policy-devel systemd-rpm-macros \ + python3 curl openssl tar util-linux \ + nodejs yarnpkg + +# Install Go (same as EL9) +GO_VERSION=1.24.4 +curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \ + | sudo tar -C /usr/local -xzf - +echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/golang.sh +export PATH=$PATH:/usr/local/go/bin +``` + +#### 2. Build + +```bash +cd heimdall-server + +# Build just the Go CLI binary +make heimdall-cli GOARCH=amd64 + +# Build the full RPM (downloads upstream source, builds CLI, runs rpmbuild) +make rpm GOARCH=amd64 + +# Build source RPM only +make srpm GOARCH=amd64 + +# Lint the spec file +make lint +``` + +The RPM appears at `rpmbuild/RPMS/x86_64/heimdall-server-*.rpm`. + +### Build environment notes + +- **EPEL is required** for `yarnpkg`. The spec uses `BuildRequires: /usr/bin/yarn` which is satisfied by EPEL's `yarnpkg` package. +- **NodeSource is required** for Node.js >= 22. The distro-provided Node.js (16-20) is too old. +- **Go is required** to compile `heimdall-cli`. The Go binary is bundled into the RPM as a static binary — Go is NOT needed on the target install host. +- **Cross-compilation**: Set `GOARCH=amd64` or `GOARCH=arm64` to build the CLI for a different architecture. The Node.js app is architecture-independent. + +## Installing the RPM + +```bash +# Install (creates heimdall user, installs files, prints setup instructions) +sudo dnf install -y ./heimdall-server-2.13.1-1.el9.x86_64.rpm + +# Run post-install setup +sudo heimdall-cli setup +``` + +### Setup options + +```bash +# Interactive (prompts for all values) +sudo heimdall-cli setup --interactive + +# Non-interactive (auto-generate secrets, accept defaults) +sudo heimdall-cli setup --non-interactive + +# External database (skip local PostgreSQL bootstrap) +sudo heimdall-cli setup \ + --db-host db.example.com \ + --db-port 5432 \ + --db-user heimdall \ + --db-password "secretpassword" \ + --skip-tls + +# Behind a load balancer (skip Caddy TLS proxy) +sudo heimdall-cli setup \ + --external-url https://heimdall.example.com \ + --skip-tls + +# Bring your own TLS certificates +sudo heimdall-cli setup \ + --tls-cert /path/to/cert.pem \ + --tls-key /path/to/key.pem + +# Re-run configuration only (preserve database) +sudo heimdall-cli setup --reconfigure + +# Skip database and TLS (config + service start only) +sudo heimdall-cli setup --skip-db --skip-tls +``` + +### Setup steps + +The `heimdall-cli setup` command runs 7 steps: + +1. **Configuration** — generates `/etc/heimdall-server/backend.env` with DB credentials and secrets +2. **PostgreSQL bootstrap** — init, start, create role (skipped for remote DB or `--skip-db`) +3. **Connection test** — verifies database is reachable +4. **Database migrations** — create schema, run Sequelize migrations and seeds +5. **TLS reverse proxy** — configures Caddy on port 443 (skipped with `--skip-tls`) +6. **Security policies** — SELinux port registration, fapolicyd trust, firewalld rules +7. **Start service** — `systemctl enable --now heimdall-server` + +## Managing the Service + +```bash +# Status (service, database, SELinux, config overview) +sudo heimdall-cli status + +# Validate configuration (checks required env vars, DB connectivity) +sudo heimdall-cli validate + +# Start / stop / restart +sudo heimdall-cli start +sudo heimdall-cli stop +sudo heimdall-cli restart + +# View logs +sudo heimdall-cli logs +sudo heimdall-cli logs --lines 100 + +# Full diagnostic dump +sudo heimdall-cli diag + +# Backup database and config +sudo heimdall-cli backup -o /var/lib/heimdall-server/backups + +# Restore from backup +sudo heimdall-cli restore /path/to/backup.tar.gz + +# Reset a user's password +sudo heimdall-cli reset-password --email admin@example.com + +# View/modify configuration +sudo heimdall-cli config list +sudo heimdall-cli config get DATABASE_HOST +sudo heimdall-cli config set PORT 8080 + +# Change the listen port (updates config, SELinux, firewalld) +sudo heimdall-cli set-port 8443 + +# Add an organizational CA certificate to the system trust store +sudo heimdall-cli add-cert /path/to/internal-ca.pem +``` + +## Upgrading + +```bash +# Upgrade the package (pre-upgrade backup runs automatically) +sudo dnf upgrade -y ./heimdall-server-2.12.7-1.el9.x86_64.rpm + +# Run database migrations +sudo heimdall-cli setup --skip-tls + +# Verify +sudo heimdall-cli status +``` + +The RPM's `%pre` scriptlet automatically attempts a backup before upgrade. Backups are saved to `/var/lib/heimdall-server/backups/`. + +To control whether the service restarts automatically on upgrade, edit `/etc/sysconfig/heimdall-server`: + +```bash +RESTART_ON_UPGRADE=false # default: true +``` + +## Customizing Paths + +All paths follow FHS defaults and can be overridden without rebuilding the RPM. + +**Via `/etc/sysconfig/heimdall-server`** (persists across reboots and upgrades): + +```bash +HEIMDALL_APP_DIR=/opt/heimdall +HEIMDALL_DATA_DIR=/opt/heimdall/data +HEIMDALL_CONFIG_DIR=/opt/heimdall/config +HEIMDALL_LIBEXEC_DIR=/opt/heimdall/libexec +HEIMDALL_LOG_DIR=/opt/heimdall/logs +HEIMDALL_CERT_DIR=/opt/heimdall/certs +HEIMDALL_ENV_FILE=/opt/heimdall/config/backend.env +``` + +**Via environment variables** (same names as above, with `HEIMDALL_` prefix). + +**Via CLI flags** (one-time override): + +```bash +heimdall-cli status --app-dir=/opt/heimdall --data-dir=/opt/heimdall/data +``` + +**Priority**: CLI flag > environment variable > config file > compile-time default. + +### Default paths + +| Path | Purpose | +|------|---------| +| `/usr/share/heimdall-server/` | Application files (Node.js app) | +| `/etc/heimdall-server/backend.env` | Application configuration (secrets, DB) | +| `/etc/sysconfig/heimdall-server` | Service configuration (paths, restart behavior) | +| `/usr/bin/heimdall-cli` | Admin CLI tool (Go static binary) | +| `/usr/bin/heimdall-server` | Service entrypoint script | +| `/usr/lib/systemd/system/heimdall-server.service` | systemd unit | +| `/usr/libexec/heimdall-server/` | Helper scripts (configure, postgres-setup, fapolicyd, Caddyfile) | +| `/usr/share/selinux/packages/heimdall-server.pp` | SELinux policy module | +| `/usr/lib/firewalld/services/heimdall-server.xml` | firewalld service definition | +| `/var/lib/heimdall-server/` | Variable data (backups) | +| `/var/lib/heimdall-server/backups/` | Backup archives | +| `/var/log/heimdall-server/` | Log files | +| `/etc/pki/heimdall-server/` | TLS certificates | + +## Security + +### SELinux + +The RPM ships a custom SELinux policy module (`heimdall_server_t`) that: +- Confines the Node.js process to a dedicated domain +- Registers port 3000 as `heimdall_server_port_t` +- Sets file contexts for all application directories + +The policy is loaded automatically on install and removed on uninstall. + +### fapolicyd + +On systems with fapolicyd enabled, the RPM registers bundled Node.js binaries in the trust database so they can execute under fapolicyd enforcement. + +### firewalld + +The RPM ships a firewalld service definition. The setup command opens HTTPS (443) when using Caddy, or port 3000 when using `--skip-tls`. + +### systemd hardening + +The service runs with comprehensive systemd sandboxing: +- `ProtectSystem=strict` with explicit `ReadWritePaths` +- `NoNewPrivileges`, `PrivateTmp`, `PrivateDevices` +- `ProtectHome`, `ProtectKernelTunables`, `ProtectKernelModules` +- `ProtectClock`, `ProtectHostname`, `ProtectKernelLogs` +- `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` +- `SystemCallArchitectures=native` +- `CapabilityBoundingSet=` (empty — no capabilities) + +### Configuration file permissions + +- `/etc/heimdall-server/backend.env` — `root:heimdall 0640` (`%config(noreplace)`) +- `/etc/sysconfig/heimdall-server` — `root:root 0640` (`%config(noreplace)`) + +## PostgreSQL compatibility + +The setup scripts auto-detect PGDG installations of PostgreSQL 13 through 18, as well as system-packaged PostgreSQL. For remote databases, the local PostgreSQL package is optional (`Recommends:`, not `Requires:`). + +## Source files + +| File | Spec Source | Purpose | +|------|------------|---------| +| `heimdall-server.spec` | — | RPM spec file | +| `heimdall-server.service` | Source1 | systemd unit | +| `heimdall-backend.env` | Source2 | Environment template | +| `heimdall-server.sh` | Source3 | Service entrypoint | +| `heimdall-db-setup.sh` | Source4 | Database migration script | +| `heimdall-configure.sh` | Source5 | Config generator | +| `heimdall-postgres-setup.sh` | Source6 | PostgreSQL bootstrap | +| `heimdall-setup.sh` | Source7 | Legacy setup script (called by CLI) | +| `heimdall-server-tmpfiles.conf` | Source8 | tmpfiles.d for `/run` | +| `selinux/heimdall_server.te` | Source9 | SELinux type enforcement | +| `selinux/heimdall_server.fc` | Source10 | SELinux file contexts | +| `selinux/heimdall_server.if` | Source11 | SELinux interface | +| `fapolicyd/heimdall-fapolicyd-trust.sh` | Source12 | fapolicyd trust script | +| `firewalld/heimdall-server.xml` | Source13 | firewalld service | +| `heimdall-server.repo` | Source14 | COPR repo file | +| `heimdall-cli` (built) | Source15 | Go admin CLI binary | +| `heimdall-Caddyfile` | Source16 | Caddy reverse proxy template | +| `heimdall-sysconfig` | Source17 | Service path overrides | +| `heimdall-rsyslog.conf` | Source18 | rsyslog routing to log files | +| `heimdall-logrotate.conf` | Source19 | Log rotation (90-day FedRAMP) | +| `security/40-heimdall.rules` | Source20 | auditd rules (sample) | +| `security/SECURITY.md` | Source21 | Security documentation | +| `setup-rpm-build-env.sh` | — | Build environment setup | diff --git a/packaging/rpm/firewalld/heimdall-server.xml b/packaging/rpm/firewalld/heimdall-server.xml new file mode 100644 index 0000000000..3a2d58e38e --- /dev/null +++ b/packaging/rpm/firewalld/heimdall-server.xml @@ -0,0 +1,6 @@ + + + Heimdall Server + Heimdall security assessment and STIG compliance results viewer + + diff --git a/packaging/rpm/heimdall-Caddyfile b/packaging/rpm/heimdall-Caddyfile new file mode 100644 index 0000000000..183f0ebe0b --- /dev/null +++ b/packaging/rpm/heimdall-Caddyfile @@ -0,0 +1,41 @@ +# Heimdall Server reverse proxy — managed by heimdall-server-setup +# +# TLS is configured automatically by the setup script based on deployment: +# - Hostname (public DNS): Let's Encrypt via ACME (automatic) +# - Hostname (private/.internal): Caddy internal CA (setup adds "tls internal") +# - IP address: self-signed cert generated by setup script +# - BYO cert: --tls-cert and --tls-key flags +# +# Caddy internal CA root (import into browsers to avoid warnings): +# /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt + +# PLACEHOLDER — the setup script replaces :443 with your hostname +# and adds the appropriate TLS directive if needed. +:443 { + reverse_proxy 127.0.0.1:3000 + + # X-Forwarded-* headers — Caddy sets these by default: + # X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host + # To override, add a block to the reverse_proxy directive: + # reverse_proxy 127.0.0.1:3000 { + # header_up X-Forwarded-For {remote_host} + # header_up X-Forwarded-Proto {scheme} + # header_up X-Forwarded-Host {host} + # } + + # Access logging (AU-3 audit compliance) + # + # Enable this block to write structured access logs for AU-3 + # (Content of Audit Records) and AU-12 (Audit Generation). + # Logs include timestamp, client IP, method, URI, status, and + # response size — sufficient for audit trail requirements. + # + # log { + # output file /var/log/caddy/heimdall-access.log { + # roll_size 100mb + # roll_keep 90 + # roll_keep_for 2160h # 90 days (FedRAMP Moderate AU-11) + # } + # format json + # } +} diff --git a/packaging/rpm/heimdall-backend.env b/packaging/rpm/heimdall-backend.env new file mode 100644 index 0000000000..5a28a71df0 --- /dev/null +++ b/packaging/rpm/heimdall-backend.env @@ -0,0 +1,83 @@ +# Heimdall backend runtime settings +# Run `sudo heimdall-server-setup` to generate secrets and configure values. + +NODE_ENV=production +PORT=3000 + +# Logging — default: journald (via systemd). Set LOG_FILE to write to a file. +# LOG_FILE=/var/log/heimdall-server/server.log + +# Public URL — REQUIRED. Used for OAuth/OIDC callbacks and browser asset loading. +# Set this to the URL users access Heimdall at. The setup script auto-generates +# this when Caddy is configured. Must start with https:// for production use. +# EXTERNAL_URL=https://heimdall.example.com + +# PostgreSQL connection +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USERNAME=postgres +DATABASE_PASSWORD= +DATABASE_NAME=heimdall-server-production + +# Authentication — auto-generated by heimdall-server-setup if left blank. +JWT_SECRET= +JWT_EXPIRE_TIME=1d + +# API key secret — generate with: openssl rand -hex 33 +# Leave blank to disable API keys. +API_KEY_SECRET= + +# Hostname used by frontend defaults. +NGINX_HOST=localhost + +# Initial admin user (used during db:seed:all). +ADMIN_EMAIL=admin@heimdall.local +# ADMIN_PASSWORD= + +# --- Login Controls --- +# LOCAL_LOGIN_DISABLED=true # Disable local password login +# REGISTRATION_DISABLED=true # Disable public registration + +# --- SSO / OAuth / OIDC --- +# Providers are auto-enabled when their CLIENTID is set. +# See INSTALL.md for full configuration details. + +# GitHub OAuth +# GITHUB_CLIENTID= +# GITHUB_CLIENTSECRET= +# GITHUB_ENTERPRISE_INSTANCE_BASE_URL=https://github.example.com/ +# GITHUB_ENTERPRISE_INSTANCE_API_URL=https://github.example.com/api/v3/ + +# GitLab OAuth +# GITLAB_CLIENTID= +# GITLAB_SECRET= +# GITLAB_BASEURL=https://gitlab.com + +# Google OAuth +# GOOGLE_CLIENTID= +# GOOGLE_CLIENTSECRET= + +# Okta OIDC +# OKTA_DOMAIN=your-domain.okta.com +# OKTA_CLIENTID= +# OKTA_CLIENTSECRET= + +# Generic OIDC +# OIDC_NAME=My Identity Provider +# OIDC_ISSUER=https://auth.example.com +# OIDC_AUTHORIZATION_URL=https://auth.example.com/authorize +# OIDC_TOKEN_URL=https://auth.example.com/token +# OIDC_USER_INFO_URL=https://auth.example.com/userinfo +# OIDC_CLIENTID= +# OIDC_CLIENT_SECRET= + +# LDAP +# LDAP_ENABLED=true +# LDAP_HOST=ldap.example.com +# LDAP_PORT=389 +# LDAP_BINDDN=cn=admin,dc=example,dc=com +# LDAP_PASSWORD= +# LDAP_SEARCHBASE=OU=Users,DC=example,DC=com +# LDAP_SEARCHFILTER=(sAMAccountName={{username}}) +# LDAP_SSL=false +# LDAP_SSL_CA=/path/to/ca.pem diff --git a/packaging/rpm/heimdall-configure.sh b/packaging/rpm/heimdall-configure.sh new file mode 100644 index 0000000000..939de3fd69 --- /dev/null +++ b/packaging/rpm/heimdall-configure.sh @@ -0,0 +1,167 @@ +#!/bin/bash +set -euo pipefail + +ENV_FILE="/etc/heimdall-server/backend.env" +TMP_FILE="$(mktemp)" +trap 'rm -f "${TMP_FILE}"' EXIT + +usage() { + cat >&2 <<'EOF' +Usage: heimdall-configure [--interactive|--non-interactive] + +Configure /etc/heimdall-server/backend.env with connection settings and +secrets. Missing secrets are always auto-generated. + + --interactive Prompt for values (requires a TTY). + --non-interactive Accept all defaults / auto-generate everything. + --external-url URL Pre-set the EXTERNAL_URL value (skip prompt). + +Without flags the default is --non-interactive (safe for scripted use). +EOF +} + +MODE="non-interactive" +EXTERNAL_URL_OVERRIDE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --interactive) + MODE="interactive" + shift + ;; + --non-interactive) + shift + ;; + --external-url) + if [[ $# -lt 2 ]]; then echo "--external-url requires a URL" >&2; exit 64; fi + EXTERNAL_URL_OVERRIDE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 64 + ;; + esac +done + +# Load previously configured values if available. +if [[ -f "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + if ! source "${ENV_FILE}"; then + set +a + echo "Failed to parse ${ENV_FILE}" >&2 + exit 1 + fi + set +a +fi + +NODE_ENV="${NODE_ENV:-production}" +PORT="${PORT:-3000}" +DATABASE_HOST="${DATABASE_HOST:-localhost}" +DATABASE_PORT="${DATABASE_PORT:-5432}" +DATABASE_USERNAME="${DATABASE_USERNAME:-postgres}" +DATABASE_PASSWORD="${DATABASE_PASSWORD:-}" +DATABASE_NAME="${DATABASE_NAME:-heimdall-server-${NODE_ENV}}" +JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 64)}" +JWT_EXPIRE_TIME="${JWT_EXPIRE_TIME:-1d}" +API_KEY_SECRET="${API_KEY_SECRET:-$(openssl rand -hex 33)}" +NGINX_HOST="${NGINX_HOST:-localhost}" +EXTERNAL_URL="${EXTERNAL_URL_OVERRIDE:-${EXTERNAL_URL:-}}" +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@heimdall.local}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" + +prompt_with_default() { + local var_name="$1" + local prompt_text="$2" + local default_value="$3" + local user_input="" + + printf "%s" "${prompt_text}" >/dev/tty + read -r user_input >"${TMP_FILE}" + return 0 + fi + + local escaped + escaped="$(printf "%s" "${value}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/\\$/g' -e 's/`/\\`/g')" + printf '%s="%s"\n' "${key}" "${escaped}" >>"${TMP_FILE}" +} + +SHOULD_PROMPT=0 +if [[ "${MODE}" == "interactive" ]]; then + if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then + echo "Interactive mode requires a TTY." >&2 + exit 1 + fi + SHOULD_PROMPT=1 +fi + +if [[ "${SHOULD_PROMPT}" -eq 1 ]]; then + echo "Heimdall server configuration" >/dev/tty + echo "Press Enter to accept each default value." >/dev/tty + echo >/dev/tty + + prompt_with_default DATABASE_HOST "DATABASE_HOST [${DATABASE_HOST}]: " "${DATABASE_HOST}" + prompt_with_default DATABASE_PORT "DATABASE_PORT [${DATABASE_PORT}]: " "${DATABASE_PORT}" + prompt_with_default DATABASE_USERNAME "DATABASE_USERNAME [${DATABASE_USERNAME}]: " "${DATABASE_USERNAME}" + prompt_with_default DATABASE_PASSWORD "DATABASE_PASSWORD (blank = auto-generate): " "" + prompt_with_default DATABASE_NAME "DATABASE_NAME [${DATABASE_NAME}]: " "${DATABASE_NAME}" + prompt_with_default PORT "Application PORT [${PORT}]: " "${PORT}" + prompt_with_default JWT_EXPIRE_TIME "JWT_EXPIRE_TIME [${JWT_EXPIRE_TIME}]: " "${JWT_EXPIRE_TIME}" + prompt_with_default NGINX_HOST "FQDN / Hostname / IP [${NGINX_HOST}]: " "${NGINX_HOST}" + prompt_with_default EXTERNAL_URL "External URL (https://...) [${EXTERNAL_URL:-https://${NGINX_HOST}}]: " "${EXTERNAL_URL:-https://${NGINX_HOST}}" + prompt_with_default ADMIN_EMAIL "Initial admin email [${ADMIN_EMAIL}]: " "${ADMIN_EMAIL}" +fi + +PASSWORD_AUTO_GENERATED=0 +if [[ -z "${DATABASE_USERNAME//[[:space:]]/}" ]]; then + DATABASE_USERNAME="postgres" +fi + +if [[ -z "${DATABASE_PASSWORD//[[:space:]]/}" ]]; then + DATABASE_PASSWORD="$(openssl rand -hex 33)" + PASSWORD_AUTO_GENERATED=1 +fi + +printf "# Generated by heimdall-server-setup\n" >"${TMP_FILE}" +write_key NODE_ENV "${NODE_ENV}" +write_key PORT "${PORT}" +write_key DATABASE_HOST "${DATABASE_HOST}" +write_key DATABASE_PORT "${DATABASE_PORT}" +write_key DATABASE_USERNAME "${DATABASE_USERNAME}" +write_key DATABASE_PASSWORD "${DATABASE_PASSWORD}" +write_key DATABASE_NAME "${DATABASE_NAME}" +write_key JWT_SECRET "${JWT_SECRET}" +write_key JWT_EXPIRE_TIME "${JWT_EXPIRE_TIME}" +write_key API_KEY_SECRET "${API_KEY_SECRET}" +write_key NGINX_HOST "${NGINX_HOST}" +write_key EXTERNAL_URL "${EXTERNAL_URL:-https://${NGINX_HOST}}" +write_key ADMIN_EMAIL "${ADMIN_EMAIL}" +write_key ADMIN_PASSWORD "${ADMIN_PASSWORD}" + +mv "${TMP_FILE}" "${ENV_FILE}" +chown root:heimdall "${ENV_FILE}" +chmod 0640 "${ENV_FILE}" + +echo "Configuration saved to ${ENV_FILE}" +if [[ "${PASSWORD_AUTO_GENERATED}" -eq 1 ]]; then + echo "DATABASE_PASSWORD was auto-generated (a secure random value)." +fi diff --git a/packaging/rpm/heimdall-db-setup.sh b/packaging/rpm/heimdall-db-setup.sh new file mode 100644 index 0000000000..24f923d9f7 --- /dev/null +++ b/packaging/rpm/heimdall-db-setup.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +APP_ROOT="/usr/share/heimdall-server" +APP_DIR="${APP_ROOT}/apps/backend" +ENV_FILE="/etc/heimdall-server/backend.env" +TSX_BIN="${APP_DIR}/node_modules/.bin/tsx" +SEQUELIZE_BIN="${APP_DIR}/node_modules/.bin/sequelize" + +usage() { + echo "Usage: $0 [--skip-seed]" >&2 +} + +SKIP_SEED=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-seed) + SKIP_SEED=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 64 + ;; + esac +done + +if [[ -f "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1091 + if ! source "${ENV_FILE}"; then + set +a + echo "Failed to parse ${ENV_FILE}" >&2 + exit 1 + fi + set +a +fi + +export NODE_ENV="${NODE_ENV:-production}" + +if [[ ! -d "${APP_DIR}" ]]; then + echo "Application directory not found: ${APP_DIR}" >&2 + exit 1 +fi + +if [[ ! -x "${TSX_BIN}" || ! -x "${SEQUELIZE_BIN}" ]]; then + echo "Missing required executables in ${APP_DIR}/node_modules/.bin" >&2 + exit 1 +fi + +run_sequelize() { + "${TSX_BIN}" "${SEQUELIZE_BIN}" "$@" +} + +run_db_create() { + local output="" + + if output="$(run_sequelize db:create 2>&1)"; then + if [[ -n "${output}" ]]; then + echo "${output}" + fi + return 0 + fi + + if [[ "${output}" == *"already exists"* ]]; then + echo "${output}" >&2 + echo "Database already exists. Continuing with migrations." >&2 + return 0 + fi + + echo "${output}" >&2 + return 1 +} + +cd "${APP_DIR}" + +run_db_create +run_sequelize db:migrate + +if [[ "${SKIP_SEED}" -eq 0 ]]; then + run_sequelize db:seed:all +fi diff --git a/packaging/rpm/heimdall-logrotate.conf b/packaging/rpm/heimdall-logrotate.conf new file mode 100644 index 0000000000..6cde714d00 --- /dev/null +++ b/packaging/rpm/heimdall-logrotate.conf @@ -0,0 +1,36 @@ +# /etc/logrotate.d/heimdall-server +# Heimdall Server log rotation +# Installed by: heimdall-server RPM +# +# Retention: 90 days online (FedRAMP Moderate AU-11) +# Primary log path is journald. This handles rsyslog-forwarded file copies. + +/var/log/heimdall-server/heimdall-server.log { + daily + maxsize 100M + rotate 90 + compress + delaycompress + missingok + notifempty + create 0640 heimdall heimdall + sharedscripts + postrotate + /usr/bin/systemctl kill -s HUP rsyslog.service >/dev/null 2>&1 || true + endscript +} + +/var/log/heimdall-server/heimdall-cli.log { + daily + maxsize 100M + rotate 90 + compress + delaycompress + missingok + notifempty + create 0640 root heimdall + sharedscripts + postrotate + /usr/bin/systemctl kill -s HUP rsyslog.service >/dev/null 2>&1 || true + endscript +} diff --git a/packaging/rpm/heimdall-postgres-setup.sh b/packaging/rpm/heimdall-postgres-setup.sh new file mode 100644 index 0000000000..d93057bdc4 --- /dev/null +++ b/packaging/rpm/heimdall-postgres-setup.sh @@ -0,0 +1,177 @@ +#!/bin/bash +set -euo pipefail + +ENV_FILE="/etc/heimdall-server/backend.env" + +usage() { + echo "Usage: $0" >&2 +} + +if [[ $# -gt 0 ]]; then + if [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage + exit 0 + fi + echo "Unknown option: $1" >&2 + usage + exit 64 +fi + +if [[ -f "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + if ! source "${ENV_FILE}"; then + set +a + echo "Failed to parse ${ENV_FILE}" >&2 + exit 1 + fi + set +a +fi + +DATABASE_HOST="${DATABASE_HOST:-localhost}" +DATABASE_PORT="${DATABASE_PORT:-5432}" +DATABASE_USERNAME="${DATABASE_USERNAME:-postgres}" +DATABASE_PASSWORD="${DATABASE_PASSWORD:-}" + +if [[ -z "${DATABASE_USERNAME//[[:space:]]/}" ]]; then + echo "DATABASE_USERNAME is required." >&2 + exit 1 +fi + +if [[ -z "${DATABASE_PASSWORD//[[:space:]]/}" ]]; then + echo "DATABASE_PASSWORD is required." >&2 + echo "Run: sudo heimdall-server-setup" >&2 + exit 1 +fi + +if [[ "${DATABASE_HOST}" != "127.0.0.1" && "${DATABASE_HOST}" != "localhost" ]]; then + echo "DATABASE_HOST=${DATABASE_HOST}; skipping local PostgreSQL bootstrap." + exit 0 +fi + +# Detect PGDG installations (18 down to 13), then fall back to system psql. +PSQL_BIN="" +PG_SETUP_BIN="" +PG_SERVICE="" +PG_DATA_DIR="" + +for ver in 18 17 16 15 14 13; do + if [[ -x "/usr/pgsql-${ver}/bin/psql" ]]; then + PSQL_BIN="/usr/pgsql-${ver}/bin/psql" + PG_SETUP_BIN="/usr/pgsql-${ver}/bin/postgresql-${ver}-setup" + PG_SERVICE="postgresql-${ver}" + PG_DATA_DIR="/var/lib/pgsql/${ver}/data" + break + fi +done + +if [[ -z "${PSQL_BIN}" ]]; then + if command -v psql >/dev/null 2>&1; then + PSQL_BIN="$(command -v psql)" + PG_SETUP_BIN="$(command -v postgresql-setup || true)" + PG_SERVICE="postgresql" + PG_DATA_DIR="/var/lib/pgsql/data" + else + echo "psql not found. Install a PostgreSQL client/server (13+) before running setup." >&2 + exit 1 + fi +fi + +PG_MAJOR="$("${PSQL_BIN}" --version | awk '{print $3}' | cut -d. -f1)" +if [[ ! "${PG_MAJOR}" =~ ^[0-9]+$ ]]; then + echo "Unable to determine PostgreSQL major version from: $("${PSQL_BIN}" --version)" >&2 + exit 1 +fi + +if [[ "${PG_MAJOR}" -lt 13 ]]; then + echo "PostgreSQL >= 13 is required (detected: ${PG_MAJOR})." >&2 + exit 1 +fi + +echo "Detected PostgreSQL ${PG_MAJOR} (${PSQL_BIN})" + +if [[ ! -f "${PG_DATA_DIR}/PG_VERSION" ]]; then + echo "Initializing PostgreSQL data directory..." + if [[ -x "${PG_SETUP_BIN}" ]]; then + if [[ "${PG_SERVICE}" == postgresql-* ]]; then + "${PG_SETUP_BIN}" initdb + else + "${PG_SETUP_BIN}" --initdb || "${PG_SETUP_BIN}" --initdb --unit "${PG_SERVICE}" + fi + else + echo "Unable to initialize PostgreSQL cluster: setup utility not found." >&2 + exit 1 + fi +fi + +# Harden pg_hba.conf: use scram-sha-256 for TCP connections from localhost. +# Sequelize needs to connect to both the 'postgres' database (for db:create) +# and the application database. We allow the configured user to connect to +# any database via password auth over TCP. The default 'peer' rule for local +# Unix socket connections (used by runuser -u postgres) is preserved. +PG_HBA="${PG_DATA_DIR}/pg_hba.conf" +if [[ -f "${PG_HBA}" ]]; then + if ! grep -q "# Heimdall" "${PG_HBA}" 2>/dev/null; then + sed -i '/^# TYPE/a # Heimdall: password authentication for TCP connections from localhost\nhost all '"${DATABASE_USERNAME}"' 127.0.0.1/32 scram-sha-256\nhost all '"${DATABASE_USERNAME}"' ::1/128 scram-sha-256' "${PG_HBA}" + echo " pg_hba.conf: scram-sha-256 for ${DATABASE_USERNAME} TCP connections" + fi +fi + +# Ensure scram-sha-256 is the default password encryption +PG_CONF="${PG_DATA_DIR}/postgresql.conf" +if [[ -f "${PG_CONF}" ]]; then + if ! grep -q "^password_encryption = scram-sha-256" "${PG_CONF}" 2>/dev/null; then + echo "password_encryption = scram-sha-256" >> "${PG_CONF}" + echo " postgresql.conf: password_encryption = scram-sha-256" + fi +fi + +if command -v systemctl >/dev/null 2>&1; then + systemctl enable "${PG_SERVICE}" >/dev/null 2>&1 || true + systemctl start "${PG_SERVICE}" >/dev/null 2>&1 || true +fi + +run_psql_as_postgres() { + runuser -u postgres -- "${PSQL_BIN}" -v ON_ERROR_STOP=1 -d postgres "$@" +} + +echo "Configuring database role '${DATABASE_USERNAME}'..." + +run_psql_as_postgres \ + -v db_user="${DATABASE_USERNAME}" \ + -v db_pass="${DATABASE_PASSWORD}" <<'SQL' +ALTER SYSTEM SET password_encryption = 'scram-sha-256'; +SELECT pg_reload_conf(); + +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_pass') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'db_user') \gexec +SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_pass') \gexec +SELECT format('ALTER ROLE %I CREATEDB', :'db_user') \gexec +SQL + +PASSWORD_FORMAT="$( + run_psql_as_postgres -tA -v db_user="${DATABASE_USERNAME}" <<'SQL' | tr -d '[:space:]' +SELECT rolpassword FROM pg_authid WHERE rolname = :'db_user'; +SQL +)" + +if [[ "${PASSWORD_FORMAT}" != SCRAM-SHA-256* ]]; then + echo "Database role password is not stored as SCRAM-SHA-256." >&2 + echo "Current rolpassword prefix: ${PASSWORD_FORMAT:0:12}" >&2 + echo "Check PostgreSQL password_encryption and recreate/alter the role password." >&2 + exit 1 +fi + +# Verify password-based login works via the scram-sha-256 pg_hba rule. +# We connect to the heimdall database (which has the scram entry), creating +# it first if it doesn't exist. +run_psql_as_postgres -v db_name="${DATABASE_NAME:-heimdall-server-production}" <<'SQL' +SELECT format('CREATE DATABASE %I', :'db_name') +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db_name') \gexec +SQL + +PGPASSWORD="${DATABASE_PASSWORD}" "${PSQL_BIN}" \ + "postgresql://${DATABASE_USERNAME}@127.0.0.1:${DATABASE_PORT}/${DATABASE_NAME:-heimdall-server-production}" \ + -c "SELECT 1;" >/dev/null + +echo "PostgreSQL bootstrap complete." diff --git a/packaging/rpm/heimdall-rsyslog.conf b/packaging/rpm/heimdall-rsyslog.conf new file mode 100644 index 0000000000..251f17eee9 --- /dev/null +++ b/packaging/rpm/heimdall-rsyslog.conf @@ -0,0 +1,31 @@ +# /etc/rsyslog.d/30-heimdall-server.conf +# Heimdall Server rsyslog configuration +# Installed by: heimdall-server RPM +# +# Primary log path: NestJS -> stdout -> systemd -> journald +# This config routes journald messages to dedicated log files +# via the imjournal bridge. + +# Route Heimdall server messages to dedicated log file +if $programname == 'heimdall-server' then { + action(type="omfile" + file="/var/log/heimdall-server/heimdall-server.log" + fileCreateMode="0640" + fileOwner="heimdall" + fileGroup="heimdall" + dirCreateMode="0750" + dirOwner="heimdall" + dirGroup="heimdall") + stop +} + +# Route CLI admin actions to separate log +if $programname == 'heimdall-cli' then { + action(type="omfile" + file="/var/log/heimdall-server/heimdall-cli.log" + fileCreateMode="0640" + fileOwner="root" + fileGroup="heimdall" + dirCreateMode="0750") + stop +} diff --git a/packaging/rpm/heimdall-server-tmpfiles.conf b/packaging/rpm/heimdall-server-tmpfiles.conf new file mode 100644 index 0000000000..0c993bf04a --- /dev/null +++ b/packaging/rpm/heimdall-server-tmpfiles.conf @@ -0,0 +1 @@ +d /run/heimdall-server 0750 heimdall heimdall - diff --git a/packaging/rpm/heimdall-server.repo b/packaging/rpm/heimdall-server.repo new file mode 100644 index 0000000000..9d8f86547a --- /dev/null +++ b/packaging/rpm/heimdall-server.repo @@ -0,0 +1,10 @@ +[mitre-saf] +name=MITRE SAF - $releasever - $basearch +baseurl=https://download.copr.fedorainfracloud.org/results/@mitre/saf/epel-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/@mitre/saf/pubkey.gpg +repo_gpgcheck=0 +enabled=0 +enabled_metadata=1 diff --git a/packaging/rpm/heimdall-server.service b/packaging/rpm/heimdall-server.service new file mode 100644 index 0000000000..ac8e9a8adb --- /dev/null +++ b/packaging/rpm/heimdall-server.service @@ -0,0 +1,54 @@ +[Unit] +Description=Heimdall Server +Documentation=https://github.com/mitre/heimdall2 +Wants=network-online.target +After=network-online.target postgresql.service + +[Service] +Type=exec +User=heimdall +Group=heimdall +WorkingDirectory=/usr/share/heimdall-server/apps/backend +EnvironmentFile=-/etc/sysconfig/heimdall-server +EnvironmentFile=-/etc/heimdall-server/backend.env +Environment=NODE_ENV=production +ExecStartPre=/usr/bin/test -x /usr/bin/node +ExecStartPre=/usr/bin/heimdall-cli validate --skip-db +ExecStart=/usr/bin/heimdall-server +SyslogIdentifier=heimdall-server +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +UMask=0027 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ReadWritePaths=/var/lib/heimdall-server +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +ProtectClock=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectProc=invisible +RemoveIPC=true +RestrictRealtime=true +SystemCallArchitectures=native +# Do NOT enable SystemCallFilter or MemoryDenyWriteExecute — both break +# Node.js V8 JIT which requires syscalls outside @system-service and W+X pages. +CapabilityBoundingSet= +AmbientCapabilities= +LimitNOFILE=65536 +StateDirectory=heimdall-server +LogsDirectory=heimdall-server + +[Install] +WantedBy=multi-user.target diff --git a/packaging/rpm/heimdall-server.sh b/packaging/rpm/heimdall-server.sh new file mode 100644 index 0000000000..f397995943 --- /dev/null +++ b/packaging/rpm/heimdall-server.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +APP_ROOT="/usr/share/heimdall-server" +APP_DIR="${APP_ROOT}/apps/backend" +ENV_FILE="/etc/heimdall-server/backend.env" + +if [[ -f "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ENV_FILE}" + set +a +fi + +if [[ -z "${DATABASE_PASSWORD:-}" ]]; then + echo "DATABASE_PASSWORD is not set in ${ENV_FILE}." >&2 + echo "Run /usr/bin/heimdall-server-setup --non-interactive to generate and apply a secure password." >&2 + exit 1 +fi + +cd "${APP_DIR}" + +# If LOG_FILE is set, redirect stdout/stderr to the log file. +# Default (unset): logs go to journald via systemd. +# Example: LOG_FILE=/var/log/heimdall-server/server.log +if [[ -n "${LOG_FILE:-}" ]]; then + LOG_DIR="$(dirname "${LOG_FILE}")" + if [[ ! -d "${LOG_DIR}" ]]; then + mkdir -p "${LOG_DIR}" + chown heimdall:heimdall "${LOG_DIR}" 2>/dev/null || true + fi + exec /usr/bin/node dist/src/main.js >> "${LOG_FILE}" 2>&1 +else + exec /usr/bin/node dist/src/main.js +fi diff --git a/packaging/rpm/heimdall-server.spec b/packaging/rpm/heimdall-server.spec new file mode 100644 index 0000000000..368b10834b --- /dev/null +++ b/packaging/rpm/heimdall-server.spec @@ -0,0 +1,399 @@ +Name: heimdall-server +Version: 2.13.1 +Release: 1%{?dist} +Summary: Heimdall server for security result persistence and review + +License: Apache-2.0 +URL: https://github.com/mitre/heimdall2 +Source0: https://github.com/mitre/heimdall2/archive/refs/tags/v%{version}.tar.gz#/heimdall2-%{version}.tar.gz +Source1: heimdall-server.service +Source2: heimdall-backend.env +Source3: heimdall-server.sh +Source4: heimdall-db-setup.sh +Source5: heimdall-configure.sh +Source6: heimdall-postgres-setup.sh +Source7: heimdall-setup.sh +Source8: heimdall-server-tmpfiles.conf +Source9: heimdall_server.te +Source10: heimdall_server.fc +Source11: heimdall_server.if +Source13: heimdall-server.xml +Source14: heimdall-server.repo +Source15: heimdall-cli +Source16: heimdall-Caddyfile +Source17: heimdall-sysconfig +Source18: heimdall-rsyslog.conf +Source19: heimdall-logrotate.conf +Source20: 40-heimdall.rules +Source21: SECURITY.md + +# JS application with native addons: disable debug/debuginfo subpackages. +%global debug_package %{nil} +%global _debugsource_packages 0 + +ExclusiveArch: aarch64 x86_64 + +# Vendored node_modules are shipped with the application and must not drive +# automatic RPM dependency/provide generation. +%global __requires_exclude_from ^%{_datadir}/%{name}/(apps/backend/node_modules|libs)/.*$ +%global __provides_exclude_from ^%{_datadir}/%{name}/(apps/backend/node_modules|libs)/.*$ + +# Note: node_modules are vendored at build time via `yarn install --frozen-lockfile`. +# A full Provides: bundled(npm(...)) manifest is not generated; the lockfile in +# the source archive is the authoritative dependency record. + +BuildRequires: gcc-c++ +BuildRequires: make +BuildRequires: nodejs >= 22 +BuildRequires: python3 +BuildRequires: selinux-policy-devel +BuildRequires: systemd-rpm-macros +BuildRequires: /usr/bin/yarn + +%{?systemd_requires} + +Requires: nodejs >= 22 +Requires: openssl +Requires: policycoreutils-python-utils +Requires: selinux-policy-targeted +Requires: util-linux +Requires(pre): shadow-utils + +# PostgreSQL is needed for local deployments but users may provide a remote +# database. Recommends pulls it in by default while allowing opt-out. +Recommends: postgresql-server >= 13 +Recommends: postgresql >= 13 +Recommends: caddy +Recommends: firewalld-filesystem + +%description +Heimdall Server provides data persistence, authentication, RBAC, and API +access for Heimdall evaluations. + +After installation, run: + sudo heimdall-cli setup + +%prep +%autosetup -n heimdall2-%{version} + +%build +export NODE_ENV=production + +# YARN_CACHE_FOLDER: if caller exported one (e.g. `make rpm CACHE=1` +# for fast local rebuilds), honor it and leave it in place. Otherwise +# create a fresh mktemp and clean it up at the end — matches the +# stateless CI/mock/COPR build model. +if [ -n "${YARN_CACHE_FOLDER:-}" ]; then + mkdir -p "${YARN_CACHE_FOLDER}" + yarn_cache_owned=0 +else + export YARN_CACHE_FOLDER="$(mktemp -d)" + yarn_cache_owned=1 +fi + +# Use system CA bundle so yarn/node trust corporate TLS inspection proxies. +if [ -f /etc/pki/tls/certs/ca-bundle.crt ]; then + export NODE_EXTRA_CA_CERTS=/etc/pki/tls/certs/ca-bundle.crt +fi +yarn install --frozen-lockfile --production --network-timeout 600000 +yarn frontend build +yarn backend build + +if [ "$yarn_cache_owned" = "1" ]; then + rm -rf "${YARN_CACHE_FOLDER}" +fi + +# Build SELinux policy module +mkdir -p selinux +cp %{SOURCE9} %{SOURCE10} %{SOURCE11} selinux/ +make -f /usr/share/selinux/devel/Makefile -C selinux heimdall_server.pp + +%install +rm -rf %{buildroot} + +install -d %{buildroot}%{_datadir}/%{name} +install -d %{buildroot}%{_datadir}/%{name}/apps/backend +install -d %{buildroot}%{_datadir}/%{name}/libs +install -d %{buildroot}%{_sysconfdir}/%{name} +install -d %{buildroot}%{_unitdir} +install -d %{buildroot}%{_tmpfilesdir} +install -d %{buildroot}%{_bindir} +install -d %{buildroot}%{_libexecdir}/%{name} + +cp -a apps/backend/package.json %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/node_modules %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/.sequelizerc %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/db %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/config %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/migrations %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/seeders %{buildroot}%{_datadir}/%{name}/apps/backend/ +cp -a apps/backend/dist %{buildroot}%{_datadir}/%{name}/apps/backend/ + +# Strip executable bits from JS files that lack shebangs. +find %{buildroot}%{_datadir}/%{name}/apps/backend/node_modules \ + -type f \( -name '*.js' -o -name '*.cjs' -o -name '*.mjs' \) -perm /111 | \ +while IFS= read -r file; do + case "$(LC_ALL=C sed -n '1p' "${file}" 2>/dev/null || true)" in + '#!'*) ;; + *) chmod a-x "${file}" ;; + esac +done + +cp -a libs/common %{buildroot}%{_datadir}/%{name}/libs/ +cp -a libs/password-complexity %{buildroot}%{_datadir}/%{name}/libs/ +cp -a dist %{buildroot}%{_datadir}/%{name}/ + +install -m 0644 %{SOURCE1} %{buildroot}%{_unitdir}/%{name}.service +install -m 0640 %{SOURCE2} %{buildroot}%{_sysconfdir}/%{name}/backend.env +install -m 0644 %{SOURCE8} %{buildroot}%{_tmpfilesdir}/%{name}.conf +install -m 0755 %{SOURCE3} %{buildroot}%{_bindir}/%{name} +install -m 0755 %{SOURCE4} %{buildroot}%{_bindir}/%{name}-db-setup +install -m 0755 %{SOURCE7} %{buildroot}%{_bindir}/%{name}-setup +install -m 0755 %{SOURCE5} %{buildroot}%{_libexecdir}/%{name}/configure.sh +install -m 0755 %{SOURCE6} %{buildroot}%{_libexecdir}/%{name}/postgres-setup.sh + +# SELinux policy module +install -d %{buildroot}%{_datadir}/selinux/packages +install -m 0644 selinux/heimdall_server.pp %{buildroot}%{_datadir}/selinux/packages/%{name}.pp + +# fapolicyd trust entries are managed by `heimdall-cli fapolicyd add|remove`, +# invoked from the post and postun scriptlets (no shell helper to install). + +# firewalld service definition +install -d %{buildroot}%{_prefix}/lib/firewalld/services +install -m 0644 %{SOURCE13} %{buildroot}%{_prefix}/lib/firewalld/services/%{name}.xml + +# Yum/DNF repo file +install -d %{buildroot}%{_sysconfdir}/yum.repos.d +install -m 0644 %{SOURCE14} %{buildroot}%{_sysconfdir}/yum.repos.d/%{name}.repo + +# Caddy reverse proxy template +install -m 0644 %{SOURCE16} %{buildroot}%{_libexecdir}/%{name}/heimdall-Caddyfile + +# heimdall-cli admin tool (pre-built Go static binary) +install -m 0755 %{SOURCE15} %{buildroot}%{_bindir}/heimdall-cli + +# Sysconfig file for service-level path overrides +install -d %{buildroot}%{_sysconfdir}/sysconfig +install -m 0640 %{SOURCE17} %{buildroot}%{_sysconfdir}/sysconfig/%{name} + +# rsyslog config (routes journald messages to log files) +install -D -m 0644 %{SOURCE18} %{buildroot}%{_sysconfdir}/rsyslog.d/30-%{name}.conf + +# logrotate config (90-day retention for FedRAMP compliance) +install -D -m 0644 %{SOURCE19} %{buildroot}%{_sysconfdir}/logrotate.d/%{name} + +# Security samples (auditd rules, documentation — NOT activated by default) +install -d %{buildroot}%{_datadir}/%{name}/security +install -m 0644 %{SOURCE20} %{buildroot}%{_datadir}/%{name}/security/40-heimdall.rules +install -m 0644 %{SOURCE21} %{buildroot}%{_datadir}/%{name}/security/SECURITY.md + +# Man pages (auto-generated from CLI command tree, pre-staged by build system) +install -d %{buildroot}%{_mandir}/man1 +if ls %{_builddir}/man/man1/*.1 1>/dev/null 2>&1; then + install -p -m 0644 %{_builddir}/man/man1/*.1 %{buildroot}%{_mandir}/man1/ +fi + +# Runtime directories (owned by service user) +install -d -m 0750 %{buildroot}/var/lib/%{name} +install -d -m 0700 %{buildroot}/var/lib/%{name}/backups +install -d -m 0750 %{buildroot}/var/log/%{name} + +# Relative symlink: avoids rpmbuild's "absolute-symlink" warning AND +# lets rpmbuild's file-recognition step resolve the target inside +# BUILDROOT instead of looking on the real filesystem (which would +# fail at build time with "broken symbolic link"). Per Fedora +# packaging guidelines on symlinks. +ln -sr %{buildroot}%{_sysconfdir}/%{name}/backend.env \ + %{buildroot}%{_datadir}/%{name}/apps/backend/.env + +%pre +getent group heimdall >/dev/null || groupadd -r heimdall +getent passwd heimdall >/dev/null || \ + useradd -r -g heimdall -d %{_datadir}/%{name} -s /sbin/nologin \ + -c "Heimdall service user" heimdall + +# On upgrade ($1 -eq 2): attempt automatic backup before replacing files. +# Non-fatal — upgrade proceeds even if backup fails (e.g., DB unreachable). +if [ $1 -eq 2 ] && command -v heimdall-cli >/dev/null 2>&1; then + echo "Creating pre-upgrade backup..." + heimdall-cli backup \ + -o /var/lib/%{name}/backups \ + 2>/dev/null || echo " Pre-upgrade backup skipped (non-fatal)" +fi + +%post +%systemd_post %{name}.service + +# Load SELinux policy module +semodule -n -i %{_datadir}/selinux/packages/%{name}.pp 2>/dev/null || true +if /usr/sbin/selinuxenabled 2>/dev/null; then + /usr/sbin/load_policy 2>/dev/null || true + restorecon -R %{_datadir}/%{name}/ \ + %{_sysconfdir}/%{name}/ \ + %{_unitdir}/%{name}.service 2>/dev/null || true + # Register port 3000 (ignore if already registered) + semanage port -a -t heimdall_server_port_t -p tcp 3000 2>/dev/null || true +fi + +# Register bundled binaries with the fapolicyd trust database. +# heimdall-cli is a no-op when fapolicyd-cli is not installed, so the +# command is safe to call unconditionally. +heimdall-cli fapolicyd add 2>/dev/null || : + +if [ $1 -eq 1 ]; then + echo "" + echo "==========================================" + echo " Heimdall Server installed successfully." + echo "" + echo " Complete setup by running:" + echo " sudo heimdall-cli setup" + echo "==========================================" + echo "" +elif [ $1 -eq 2 ]; then + echo "" + echo "==========================================" + echo " Heimdall Server upgraded." + echo "" + echo " A backup was attempted before upgrade." + echo " Backups: /var/lib/%{name}/backups/" + echo "" + echo " Run database migrations:" + echo " sudo heimdall-cli setup --skip-tls" + echo "" + echo " The service will restart automatically." + echo "==========================================" + echo "" +fi + +%preun +%systemd_preun %{name}.service + +%postun +# On upgrade ($1 -ge 1): check RESTART_ON_UPGRADE in sysconfig before restarting. +# Follows the Grafana pattern — gives admins control over restart timing. +if [ $1 -ge 1 ]; then + RESTART_ON_UPGRADE=true + if [ -f %{_sysconfdir}/sysconfig/%{name} ]; then + . %{_sysconfdir}/sysconfig/%{name} + fi + if [ "${RESTART_ON_UPGRADE}" = "true" ]; then + systemctl try-restart %{name}.service >/dev/null 2>&1 || true + fi +else + systemctl daemon-reload >/dev/null 2>&1 || true +fi + +# Remove SELinux policy on full uninstall +if [ $1 -eq 0 ]; then + semodule -n -r heimdall_server 2>/dev/null || true + if /usr/sbin/selinuxenabled 2>/dev/null; then + /usr/sbin/load_policy 2>/dev/null || true + semanage port -d -t heimdall_server_port_t -p tcp 3000 2>/dev/null || true + fi + # Remove fapolicyd trust entries (no-op if fapolicyd-cli absent). + heimdall-cli fapolicyd remove 2>/dev/null || : +fi + +%files +%license LICENSE.md +%doc README.md CHANGELOG +%{_unitdir}/%{name}.service +%{_tmpfilesdir}/%{name}.conf +%{_bindir}/%{name} +%{_bindir}/%{name}-db-setup +%{_bindir}/%{name}-setup +%attr(0755,root,root) %dir %{_libexecdir}/%{name} +%attr(0755,root,root) %{_libexecdir}/%{name}/configure.sh +%attr(0755,root,root) %{_libexecdir}/%{name}/postgres-setup.sh +%attr(0750,root,heimdall) %dir %{_sysconfdir}/%{name} +%attr(0640,root,heimdall) %config(noreplace) %{_sysconfdir}/%{name}/backend.env +%attr(0755,root,root) %dir %{_datadir}/%{name} +%attr(0750,heimdall,heimdall) %dir /var/lib/%{name} +%attr(0700,heimdall,heimdall) %dir /var/lib/%{name}/backups +%attr(0750,heimdall,heimdall) %dir /var/log/%{name} +%{_datadir}/%{name}/apps +%{_datadir}/%{name}/dist +%{_datadir}/%{name}/libs +%{_datadir}/selinux/packages/%{name}.pp +%config(noreplace) %{_libexecdir}/%{name}/heimdall-Caddyfile +%config(noreplace) %{_prefix}/lib/firewalld/services/%{name}.xml +%config(noreplace) %{_sysconfdir}/yum.repos.d/%{name}.repo +%config(noreplace) %{_sysconfdir}/sysconfig/%{name} +%config(noreplace) %{_sysconfdir}/rsyslog.d/30-%{name}.conf +%config(noreplace) %{_sysconfdir}/logrotate.d/%{name} +%{_datadir}/%{name}/security +%{_bindir}/heimdall-cli +%{_mandir}/man1/heimdall-cli*.1* + +%changelog +* Thu Apr 16 2026 alippold - 2.13.1-1 +- Update to 2.13.1 + +* Fri Feb 27 2026 Heimdall Maintainers - 2.12.6-10 +- Replace Python CLI with Go static binary (heimdall-cli). + Single binary, no Python/vendor dependencies. + 14 commands: setup, status, config (list/get/set), backup, restore, + reset-password, start, stop, restart, logs, diag, set-port, add-cert, + validate. + +* Fri Feb 27 2026 Heimdall Maintainers - 2.12.6-9 +- Fix Caddy TLS for private hostnames: add "tls internal" explicitly. + Caddy does NOT auto-detect private hostnames (.internal, .local, .lan, + .corp, .localdomain, .home.arpa, .private, .test, single-label) — it + tries Let's Encrypt ACME and fails. Setup script now detects these + patterns and configures Caddy's internal CA automatically. +- Add --external-url, --tls-cert, --tls-key, --skip-tls flags to setup + script for enterprise deployments (load balancer, corporate PKI, existing + reverse proxy). +- Fix code review findings: remove local keyword outside functions, fix + duplicate EXTERNAL_URL appends, add semanage -m fallback for reruns, + skip --tls-cert validation when --skip-tls is set. + +* Fri Feb 27 2026 Heimdall Maintainers - 2.12.6-8 +- Add Caddy TLS reverse proxy support (Recommends: caddy, Caddyfile template). +- Setup script adds TLS/proxy step: Caddy config, EXTERNAL_URL, internal CA. +- Add cloud environment detection (EC2/Azure/GCP) with firewall hints. +- SELinux: enable httpd_can_network_connect for reverse proxy. +- Firewalld: open HTTPS (443) instead of app port (3000). +- Ship COPR repo file (enabled=0) instead of non-existent saf.mitre.org. + +* Thu Feb 26 2026 Heimdall Maintainers - 2.12.6-7 +- Fix heimdall-cli reset_password: hash via app bcryptjs, validate complexity, + update DB directly. Passwords verified via API login. +- Add configurable password rules via env vars (PASSWORD_MIN_LENGTH, + PASSWORD_REQUIRE_CLASSES, PASSWORD_MAX_CONSECUTIVE) with current defaults. +- Move packaging to saf-packaging mono-repo. +- Bundle heimdall-cli files into single Source15 tarball (replaces 6 flat Sources). +- Standalone build: fetch source from GitHub releases instead of requiring + local heimdall2 git checkout. + +* Thu Feb 26 2026 Heimdall Maintainers - 2.12.6-5 +- Add heimdall-cli admin tool (status, config, backup/restore, diagnostics). +- Add config schema data file for CLI validation and tab completion. +- Add yum repo file for saf.mitre.org/rpms/. +- Add upgrade message in %%post for $1 -eq 2. +- Add ENVIRONMENT_VARIABLES.md as authoritative config reference. +- Update .env-example with 14 previously undocumented variables. + +* Thu Feb 26 2026 Heimdall Maintainers - 2.12.6-4 +- Add SELinux policy module with custom heimdall_server_t domain. +- Add fapolicyd rules for bundled Node.js binary execution. +- Add firewalld service definition for port 3000. +- SELinux policy auto-loaded on install, removed on uninstall. + +* Thu Feb 26 2026 Heimdall Maintainers - 2.12.6-3 +- Remove all automation from %%post per Fedora packaging guidelines. +- Move PostgreSQL to Recommends for flexible local/remote deployments. +- Add systemd hardening (ProtectSystem=strict, RestrictAddressFamilies, etc). +- Add tmpfiles.d for /run/heimdall-server. +- Support PostgreSQL 13-18 auto-detection in setup scripts. + +* Thu Feb 26 2026 Heimdall Maintainers - 2.12.6-2 +- Run post-install setup in auto mode (interactive when TTY is available). +- Avoid RPM post scriptlet hard-fail for recoverable setup/startup issues. +- Treat existing database as idempotent during db:create. +- Validate DATABASE_PASSWORD at service startup with clear remediation guidance. + +* Wed Feb 25 2026 Heimdall Maintainers - 2.12.6-1 +- Initial Oracle/RHEL style RPM packaging scaffold with interactive install diff --git a/packaging/rpm/heimdall-setup.sh b/packaging/rpm/heimdall-setup.sh new file mode 100644 index 0000000000..6717c4513e --- /dev/null +++ b/packaging/rpm/heimdall-setup.sh @@ -0,0 +1,501 @@ +#!/bin/bash +set -euo pipefail + +CONFIGURE_BIN="/usr/libexec/heimdall-server/configure.sh" +POSTGRES_SETUP_BIN="/usr/libexec/heimdall-server/postgres-setup.sh" +DB_SETUP_BIN="/usr/bin/heimdall-server-db-setup" +SERVICE_NAME="heimdall-server.service" +ENV_FILE="/etc/heimdall-server/backend.env" +CERT_DIR="/etc/pki/heimdall-server" +CADDYFILE_SRC="/usr/libexec/heimdall-server/heimdall-Caddyfile" +CADDYFILE_DST="/etc/caddy/Caddyfile.d/heimdall-server.caddy" + +usage() { + cat >&2 <<'EOF' +Usage: heimdall-server-setup [OPTIONS] + +Complete Heimdall server post-install setup: generate configuration, +bootstrap PostgreSQL (if local), run database migrations, configure TLS +reverse proxy, apply security policies, and start the service. + +Options: + --interactive Prompt for configuration values (default when TTY). + --non-interactive Accept defaults and auto-generate all secrets. + --external-url URL Set the public URL (e.g., https://heimdall.example.com). + --tls-cert PATH Path to TLS certificate (PEM). Used with Caddy BYO cert. + --tls-key PATH Path to TLS private key (PEM). Used with Caddy BYO cert. + --reconfigure Re-run only the configuration step. + --skip-db Skip PostgreSQL bootstrap and database migrations. + --skip-tls Skip TLS reverse proxy setup (use when behind a load + balancer or existing reverse proxy). + -h, --help Show this help. + +Deployment patterns: + Direct install (Caddy handles TLS): + sudo heimdall-server-setup --external-url https://heimdall.example.com + + Behind a load balancer (LB terminates TLS): + sudo heimdall-server-setup --external-url https://heimdall.example.com --skip-tls + + Corporate PKI certificates: + sudo heimdall-server-setup --external-url https://heimdall.example.com \ + --tls-cert /etc/pki/tls/certs/heimdall.pem \ + --tls-key /etc/pki/tls/private/heimdall.key + + Air-gapped (IP only, self-signed): + sudo heimdall-server-setup --external-url https://10.0.1.50 +EOF +} + +SETUP_MODE="auto" +RECONFIGURE_ONLY=0 +SKIP_DB=0 +SKIP_TLS=0 +EXTERNAL_URL_FLAG="" +TLS_CERT="" +TLS_KEY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --interactive) + SETUP_MODE="interactive" + shift + ;; + --non-interactive) + SETUP_MODE="non-interactive" + shift + ;; + --reconfigure) + RECONFIGURE_ONLY=1 + shift + ;; + --skip-db) + SKIP_DB=1 + shift + ;; + --skip-tls) + SKIP_TLS=1 + shift + ;; + --external-url) + if [[ $# -lt 2 ]]; then echo "--external-url requires a URL" >&2; exit 64; fi + EXTERNAL_URL_FLAG="$2" + shift 2 + ;; + --tls-cert) + if [[ $# -lt 2 ]]; then echo "--tls-cert requires a path" >&2; exit 64; fi + TLS_CERT="$2" + shift 2 + ;; + --tls-key) + if [[ $# -lt 2 ]]; then echo "--tls-key requires a path" >&2; exit 64; fi + TLS_KEY="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 64 + ;; + esac +done + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root (use sudo)." >&2 + exit 1 +fi + +# Validate --tls-cert and --tls-key (must be provided together, ignored with --skip-tls) +if [[ "${SKIP_TLS}" -eq 0 && ( -n "${TLS_CERT}" || -n "${TLS_KEY}" ) ]]; then + if [[ -z "${TLS_CERT}" || -z "${TLS_KEY}" ]]; then + echo "--tls-cert and --tls-key must be provided together." >&2 + exit 64 + fi + if [[ ! -f "${TLS_CERT}" ]]; then + echo "TLS certificate not found: ${TLS_CERT}" >&2 + exit 1 + fi + if [[ ! -f "${TLS_KEY}" ]]; then + echo "TLS key not found: ${TLS_KEY}" >&2 + exit 1 + fi +fi + +# Determine configure mode. +CONFIGURE_FLAG="" +case "${SETUP_MODE}" in + interactive) + CONFIGURE_FLAG="--interactive" + ;; + non-interactive) + CONFIGURE_FLAG="--non-interactive" + ;; + auto) + # Default to interactive when a TTY is available. + if [[ -t 0 || -t 1 ]]; then + CONFIGURE_FLAG="--interactive" + else + CONFIGURE_FLAG="--non-interactive" + fi + ;; +esac + +echo "=== Step 1/6: Configuration ===" +CONFIGURE_ARGS=("${CONFIGURE_FLAG}") +if [[ -n "${EXTERNAL_URL_FLAG}" ]]; then + CONFIGURE_ARGS+=("--external-url" "${EXTERNAL_URL_FLAG}") +fi +"${CONFIGURE_BIN}" "${CONFIGURE_ARGS[@]}" + +if [[ "${RECONFIGURE_ONLY}" -eq 1 ]]; then + echo "" + echo "Reconfiguration complete. Restart the service to apply changes:" + echo " sudo systemctl restart ${SERVICE_NAME}" + exit 0 +fi + +if [[ "${SKIP_DB}" -eq 0 ]]; then + echo "" + echo "=== Step 2/6: PostgreSQL bootstrap ===" + "${POSTGRES_SETUP_BIN}" + + echo "" + echo "=== Step 3/6: Database migrations ===" + "${DB_SETUP_BIN}" +else + echo "" + echo "=== Steps 2-3 skipped (--skip-db) ===" +fi + +PORT="$(grep -oP '^PORT=\K[0-9]+' "${ENV_FILE}" 2>/dev/null || echo 3000)" + +# ----------------------------------------------------------------------- +# Cloud environment detection — advisory messages for external firewall +# ----------------------------------------------------------------------- +detect_cloud() { + # EC2 + if curl -sf -m 2 http://169.254.169.254/latest/meta-data/instance-id >/dev/null 2>&1; then + echo "ec2" + return + fi + # Azure + if curl -sf -m 2 -H "Metadata:true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01" >/dev/null 2>&1; then + echo "azure" + return + fi + # GCP + if curl -sf -m 2 -H "Metadata-Flavor: Google" http://metadata.google.internal/ >/dev/null 2>&1; then + echo "gcp" + return + fi + echo "bare-metal" +} + +print_cloud_hint() { + local cloud="$1" + case "${cloud}" in + ec2) + echo "" + echo " NOTE: Detected AWS EC2 instance." + echo " Ensure your Security Group allows inbound HTTPS (TCP 443)." + echo " Example: aws ec2 authorize-security-group-ingress --group-id --protocol tcp --port 443 --cidr 0.0.0.0/0" + ;; + azure) + echo "" + echo " NOTE: Detected Azure VM." + echo " Ensure your Network Security Group (NSG) allows inbound HTTPS (TCP 443)." + ;; + gcp) + echo "" + echo " NOTE: Detected GCP VM." + echo " Ensure your VPC firewall rule allows inbound HTTPS (TCP 443)." + echo " Example: gcloud compute firewall-rules create allow-heimdall-https --allow tcp:443" + ;; + esac +} + +# ----------------------------------------------------------------------- +# Step 4/6: TLS reverse proxy (Caddy) +# ----------------------------------------------------------------------- +if [[ "${SKIP_TLS}" -eq 0 ]]; then + echo "" + echo "=== Step 4/6: TLS reverse proxy ===" + + if command -v caddy >/dev/null 2>&1; then + # Determine hostname for EXTERNAL_URL + HOSTNAME_GUESS="$(hostname -f 2>/dev/null || hostname)" + EXTERNAL_URL="$(grep -oP '^EXTERNAL_URL=\K.*' "${ENV_FILE}" 2>/dev/null | tr -d '"' || true)" + + if [[ -z "${EXTERNAL_URL}" ]]; then + EXTERNAL_URL="https://${HOSTNAME_GUESS}" + # Update EXTERNAL_URL in env file (configure.sh already wrote the key) + sed -i "s|^EXTERNAL_URL=.*|EXTERNAL_URL=\"${EXTERNAL_URL}\"|" "${ENV_FILE}" + echo " Set EXTERNAL_URL=${EXTERNAL_URL}" + fi + + # Install Caddyfile + if [[ -f "${CADDYFILE_SRC}" ]]; then + mkdir -p "$(dirname "${CADDYFILE_DST}")" + cp -f "${CADDYFILE_SRC}" "${CADDYFILE_DST}" + + # Determine TLS strategy based on flags and hostname type + EXTERNAL_HOST="$(echo "${EXTERNAL_URL}" | sed 's|https\?://||; s|:.*||; s|/.*||')" + + # Validate hostname to prevent sed/openssl injection + if [[ ! "${EXTERNAL_HOST}" =~ ^[a-zA-Z0-9][a-zA-Z0-9.\-]*[a-zA-Z0-9]$ ]] && \ + [[ ! "${EXTERNAL_HOST}" =~ ^[a-zA-Z0-9]$ ]]; then + echo "Error: invalid hostname '${EXTERNAL_HOST}' — contains unsafe characters" >&2 + exit 1 + fi + + if [[ -n "${TLS_CERT}" && -n "${TLS_KEY}" ]]; then + # BYO certificate from corporate PKI + sed -i "s|^:443 {|${EXTERNAL_HOST} {|" "${CADDYFILE_DST}" + sed -i "/reverse_proxy/i\\\\ttls ${TLS_CERT} ${TLS_KEY}" "${CADDYFILE_DST}" + echo " Caddy: configured with provided certificate" + echo " Cert: ${TLS_CERT}" + echo " Key: ${TLS_KEY}" + elif [[ -n "${EXTERNAL_HOST}" && ! "${EXTERNAL_HOST}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Real hostname — determine if public or private + # + # Private hostnames need explicit "tls internal" — Caddy does NOT + # auto-detect these and will fail trying Let's Encrypt ACME instead. + # Patterns: .internal, .local, .lan, .localdomain, .home.arpa, + # .corp, .private, .test, single-label (no dots) + if [[ "${EXTERNAL_HOST}" =~ \.(internal|local|lan|localdomain|home\.arpa|corp|private|test)$ ]] || \ + [[ ! "${EXTERNAL_HOST}" =~ \. ]]; then + # Keep :443 (accepts any hostname/IP) — private hostnames aren't + # resolvable from outside, so Caddy must accept all connections. + sed -i "/reverse_proxy/i\\\\ttls internal" "${CADDYFILE_DST}" + echo " Caddy: private hostname detected — using internal CA" + echo " Import root CA into browsers to avoid warnings:" + echo " /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt" + echo "" + echo " For IP-based access, add to client /etc/hosts:" + echo " ${EXTERNAL_HOST}" + echo "" + echo " Or re-run with IP: sudo heimdall-server-setup --external-url https://" + else + # Public hostname — Caddy uses Let's Encrypt ACME automatically. + # Replace :443 with hostname so Caddy gets the right cert. + sed -i "s|^:443 {|${EXTERNAL_HOST} {|" "${CADDYFILE_DST}" + echo " Caddy: public hostname — automatic Let's Encrypt certificate" + fi + else + # IP-based — Caddy can't issue certs for bare IPs. + # Generate a self-signed cert with the IP as SAN. + mkdir -p "${CERT_DIR}" + if [[ ! -f "${CERT_DIR}/server.crt" ]]; then + openssl req -x509 -nodes -days 365 \ + -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -keyout "${CERT_DIR}/server.key" \ + -out "${CERT_DIR}/server.crt" \ + -subj "/CN=${EXTERNAL_HOST}" \ + -addext "subjectAltName=IP:${EXTERNAL_HOST},DNS:localhost" \ + 2>/dev/null + chmod 640 "${CERT_DIR}/server.key" + chown root:caddy "${CERT_DIR}/server.key" 2>/dev/null || true + echo " Generated self-signed cert for ${EXTERNAL_HOST}" + fi + sed -i "/reverse_proxy/i\\\\ttls ${CERT_DIR}/server.crt ${CERT_DIR}/server.key" "${CADDYFILE_DST}" + echo " Caddy: configured with self-signed cert (IP-based deployment)" + fi + + # Detect and warn about existing reverse proxies on 443 + if command -v ss >/dev/null 2>&1; then + existing_443="$(ss -tlnp 2>/dev/null | grep ':443 ' | grep -v caddy || true)" + if [[ -n "${existing_443}" ]]; then + echo "" + echo " WARNING: Another process is already listening on port 443:" + echo " ${existing_443}" + echo " Caddy may fail to start. Stop the other service first." + fi + fi + + # Ensure main Caddyfile imports our config + CADDY_MAIN="/etc/caddy/Caddyfile" + if [[ -f "${CADDY_MAIN}" ]] && ! grep -q "import /etc/caddy/Caddyfile.d/" "${CADDY_MAIN}" 2>/dev/null; then + echo "import /etc/caddy/Caddyfile.d/*.caddy" >> "${CADDY_MAIN}" + fi + + # Enable and start Caddy + systemctl enable --now caddy 2>/dev/null || true + systemctl reload caddy 2>/dev/null || true + echo " Caddy: enabled and running" + echo " Caddy internal CA root: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt" + fi + + # SELinux: allow Caddy (httpd_t) to proxy to Heimdall backend + if command -v setsebool >/dev/null 2>&1; then + setsebool -P httpd_can_network_connect on 2>/dev/null || true + echo " SELinux: httpd_can_network_connect enabled (Caddy → backend proxy)" + fi + else + echo " Caddy not installed — skipping TLS proxy setup." + if [[ -n "${TLS_CERT}" ]]; then + echo " WARNING: --tls-cert and --tls-key were provided but Caddy is not installed." + echo " Install Caddy first, then re-run setup." + fi + echo "" + # Offer to install EPEL + Caddy + if command -v dnf >/dev/null 2>&1; then + # Detect OS: Fedora doesn't need EPEL, RHEL/derivatives do + if grep -qi fedora /etc/os-release 2>/dev/null; then + echo " Install Caddy with: sudo dnf install -y caddy" + elif rpm -q epel-release >/dev/null 2>&1; then + echo " Install Caddy with: sudo dnf install -y caddy" + else + # Detect EL major version from os-release (more reliable than rpm -E) + el_ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID%%.*}")" + el_ver="${el_ver:-9}" + echo " EPEL repository not found. Install EPEL and Caddy with:" + echo " sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-${el_ver}.noarch.rpm" + echo " sudo dnf install -y caddy" + fi + fi + echo " Then re-run: sudo heimdall-server-setup --skip-db" + echo "" + echo " Alternatively, configure nginx or another reverse proxy manually." + echo " The app listens on http://127.0.0.1:${PORT}" + fi +else + echo "" + echo "=== Step 4/6 skipped (--skip-tls) ===" +fi + +echo "" +echo "=== Step 5/6: Security policies ===" + +# --- SELinux --- +# The policy module is loaded by RPM %post. Here we ensure the port is +# registered and file contexts are applied. Safe to run even when SELinux +# is disabled — semanage/restorecon just no-op. +if command -v semanage >/dev/null 2>&1; then + # Register the app port (3000 for direct, or custom) + semanage port -a -t heimdall_server_port_t -p tcp 3000 2>/dev/null || \ + semanage port -m -t heimdall_server_port_t -p tcp 3000 2>/dev/null || true + if [[ "${PORT}" != "3000" ]]; then + semanage port -a -t heimdall_server_port_t -p tcp "${PORT}" 2>/dev/null || \ + semanage port -m -t heimdall_server_port_t -p tcp "${PORT}" 2>/dev/null || true + echo " SELinux: registered port ${PORT}" + else + echo " SELinux: port 3000 registered" + fi + + # Allow reverse proxy (httpd_t) to connect to backend + if command -v setsebool >/dev/null 2>&1; then + setsebool -P httpd_can_network_connect on 2>/dev/null || true + echo " SELinux: httpd_can_network_connect enabled" + fi +fi +if command -v restorecon >/dev/null 2>&1; then + restorecon -R /usr/share/heimdall-server/ \ + /etc/heimdall-server/ \ + /var/lib/heimdall-server/ 2>/dev/null || true + echo " SELinux: file contexts applied" +fi + +# --- fapolicyd --- +FAPOLICYD_SCRIPT="/usr/libexec/heimdall-server/fapolicyd-trust.sh" +if command -v fapolicyd-cli >/dev/null 2>&1 && [[ -x "${FAPOLICYD_SCRIPT}" ]]; then + "${FAPOLICYD_SCRIPT}" add 2>/dev/null || true + echo " fapolicyd: bundled binaries trusted" +else + echo " fapolicyd: not installed (skipped)" +fi + +# --- firewalld --- +if command -v firewall-cmd >/dev/null 2>&1; then + if systemctl is-active --quiet firewalld 2>/dev/null; then + if [[ "${SKIP_TLS}" -eq 0 ]]; then + # Caddy handles TLS — open HTTPS (443), backend stays on localhost + firewall-cmd --permanent --add-service=https 2>/dev/null || true + echo " firewalld: HTTPS (443) enabled" + else + # No local TLS proxy — open the app port for LB health checks / direct access + firewall-cmd --permanent --add-port="${PORT}/tcp" 2>/dev/null || true + echo " firewalld: port ${PORT}/tcp enabled (no local TLS proxy)" + fi + firewall-cmd --reload 2>/dev/null || true + else + echo " firewalld: not running (skipped)" + fi +else + echo " firewalld: not installed (skipped)" +fi + +# --- File permission hardening (STIG/CIS) --- +echo " Applying STIG/CIS file permissions..." + +# Config files: root:heimdall, no world access +chmod 0640 "${ENV_FILE}" +chown root:heimdall "${ENV_FILE}" + +# Runtime directories +install -d -m 0750 -o heimdall -g heimdall /var/lib/heimdall-server +install -d -m 0700 -o heimdall -g heimdall /var/lib/heimdall-server/backups +install -d -m 0750 -o heimdall -g heimdall /var/log/heimdall-server + +# Cert directory (if exists): root:caddy, restricted +if [[ -d "${CERT_DIR}" ]]; then + chmod 0750 "${CERT_DIR}" + chmod 0640 "${CERT_DIR}"/*.key 2>/dev/null || true + chmod 0644 "${CERT_DIR}"/*.crt 2>/dev/null || true +fi + +# Helper scripts: root-only write +chmod 0755 "${CONFIGURE_BIN}" "${POSTGRES_SETUP_BIN}" "${DB_SETUP_BIN}" + +echo " File permissions hardened" + +echo "" +echo "=== Step 6/6: Starting service ===" +if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now "${SERVICE_NAME}" + echo "Service enabled and started." +else + echo "systemctl not found; start the service manually." +fi + +# Detect cloud environment and print helpful hints +CLOUD_ENV="$(detect_cloud)" + +EXTERNAL_URL="$(grep -oP '^EXTERNAL_URL=\K.*' "${ENV_FILE}" 2>/dev/null | tr -d '"' || echo "https://$(hostname)")" + +echo "" +echo "==========================================" +echo " Heimdall server is running." +echo "" +if [[ "${SKIP_TLS}" -eq 1 ]]; then + echo " Open: ${EXTERNAL_URL}" + echo "" + echo " TLS: handled externally (load balancer / reverse proxy)" + echo " App listening on: http://0.0.0.0:${PORT}" +elif command -v caddy >/dev/null 2>&1; then + echo " Open: ${EXTERNAL_URL}" + echo "" + echo " TLS: Caddy reverse proxy on port 443" + if [[ -n "${TLS_CERT}" ]]; then + echo " Cert: ${TLS_CERT}" + else + echo " Caddy CA root (for browser import):" + echo " /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt" + fi +else + echo " Open: http://localhost:${PORT}" + echo "" + echo " WARNING: No TLS reverse proxy configured." + echo " Install Caddy for automatic HTTPS:" + echo " sudo dnf install --enablerepo=epel caddy" + echo " sudo heimdall-server-setup --skip-db" +fi +echo "" +echo " Useful commands:" +echo " systemctl status ${SERVICE_NAME}" +echo " journalctl -u ${SERVICE_NAME}" +print_cloud_hint "${CLOUD_ENV}" +echo "==========================================" diff --git a/packaging/rpm/heimdall-sysconfig b/packaging/rpm/heimdall-sysconfig new file mode 100644 index 0000000000..869b905780 --- /dev/null +++ b/packaging/rpm/heimdall-sysconfig @@ -0,0 +1,44 @@ +# /etc/sysconfig/heimdall-server — service-level configuration +# +# NOTE: All paths below must be absolute (starting with /) and the +# directories must exist with correct ownership before starting the +# service. The RPM %post scriptlet creates default directories owned +# by the heimdall user/group. If you change a path here, ensure the +# new directory exists and is owned appropriately: +# install -d -o heimdall -g heimdall -m 0750 /new/path +# +# These settings control HOW the service runs (paths, user, restart behavior). +# For APPLICATION settings (database, ports, JWT), edit: +# /etc/heimdall-server/backend.env +# +# Changes here take effect after: systemctl restart heimdall-server +# +# All values can also be set via the heimdall-cli command: +# heimdall-cli setup --app-dir=/custom/path +# +# Or via environment variables with the HEIMDALL_ prefix: +# export HEIMDALL_APP_DIR=/custom/path + +# Application install directory (Node.js app files) +HEIMDALL_APP_DIR=/usr/share/heimdall-server + +# Variable data directory (backups, uploads) +HEIMDALL_DATA_DIR=/var/lib/heimdall-server + +# Configuration directory +HEIMDALL_CONFIG_DIR=/etc/heimdall-server + +# Helper scripts directory +HEIMDALL_LIBEXEC_DIR=/usr/libexec/heimdall-server + +# Log directory +HEIMDALL_LOG_DIR=/var/log/heimdall-server + +# TLS certificate directory +HEIMDALL_CERT_DIR=/etc/pki/heimdall-server + +# Environment file (application configuration) +HEIMDALL_ENV_FILE=/etc/heimdall-server/backend.env + +# Restart service automatically on package upgrade (true/false) +RESTART_ON_UPGRADE=true diff --git a/packaging/rpm/man/heimdall-server-backend.env.5.md b/packaging/rpm/man/heimdall-server-backend.env.5.md new file mode 100644 index 0000000000..d491a1d419 --- /dev/null +++ b/packaging/rpm/man/heimdall-server-backend.env.5.md @@ -0,0 +1,489 @@ +# heimdall-server-backend.env 5 "Heimdall Server" "Heimdall Server Manual" + +## NAME + +heimdall-server-backend.env - Heimdall Server application configuration + +## SYNOPSIS + +**/etc/heimdall-server/backend.env** + +## DESCRIPTION + +This file contains environment variables that control the runtime behavior +of the Heimdall Server application. It is loaded by the systemd service +unit as an EnvironmentFile and sourced by the entrypoint script before +starting the Node.js process. + +The file uses shell-compatible **KEY=value** syntax. Lines beginning with +**#** are comments. Blank lines are ignored. Values containing spaces or +special characters should be quoted. + +Most secrets (DATABASE_PASSWORD, JWT_SECRET, API_KEY_SECRET) are +auto-generated by **heimdall-cli setup** if left blank. Manual editing is +supported for all values. + +After changing any value, restart the service: + + sudo systemctl restart heimdall-server + +## GENERAL SETTINGS + +**NODE_ENV**=_production_ + +: Node.js environment. Must be **production** for deployed instances. + Controls logging verbosity, error detail, and security headers. + +**PORT**=_3000_ + +: TCP port the application listens on. If changed, update SELinux and + firewalld accordingly (or use **heimdall-cli set-port**). + +**EXTERNAL_URL**=_(unset)_ + +: The public URL at which users access Heimdall (e.g., + **https://heimdall.example.com**). Required for OAuth/OIDC callback + URLs, browser asset loading, and security headers. Must start with + **https://** for production use. Auto-generated by the setup script + when Caddy is configured. + +**NGINX_HOST**=_localhost_ + +: Hostname used by frontend defaults. Typically overridden by + EXTERNAL_URL in production deployments. + +**LOG_FILE**=_(unset)_ + +: When set, the application writes logs to this file path instead of + stdout (journald). The log directory must be writable by the heimdall + user. Example: **/var/log/heimdall-server/server.log**. Log rotation + is the administrator's responsibility when using file-based logging. + +## DATABASE SETTINGS + +**DATABASE_HOST**=_localhost_ + +: PostgreSQL server hostname or IP address. Set to a remote address for + external database deployments (RDS, Azure Database, etc.). + +**DATABASE_PORT**=_5432_ + +: PostgreSQL server port. + +**DATABASE_USERNAME**=_postgres_ + +: PostgreSQL role used for database connections. + +**DATABASE_PASSWORD**=_(auto-generated)_ + +: Password for the PostgreSQL role. Auto-generated by the setup script + using SCRAM-SHA-256 authentication. The service refuses to start if + this value is empty. + +**DATABASE_NAME**=_heimdall-server-production_ + +: Name of the PostgreSQL database. + +**DATABASE_URL**=_(unset)_ + +: Full PostgreSQL connection URL. Overrides individual DATABASE_HOST, + DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, and DATABASE_NAME + settings if set. Example: + **postgres://user:pass@host:5432/heimdall-server-production**. + +**DATABASE_SSL**=_false_ + +: Set to **true** to require TLS for PostgreSQL connections. Recommended + for external database deployments. + +**DATABASE_SSL_KEY**=_(unset)_ + +: Path to an SSL key file, or inline PEM content, for client certificate + authentication to PostgreSQL. + +**DATABASE_SSL_CERT**=_(unset)_ + +: Path to an SSL certificate file, or inline PEM content, for client + certificate authentication to PostgreSQL. + +**DATABASE_SSL_CA**=_(unset)_ + +: Path to a CA certificate file, or inline PEM content, used to verify + the PostgreSQL server's TLS certificate. + +**DATABASE_SSL_INSECURE**=_false_ + +: Set to **true** to skip SSL certificate verification for the database + connection. Not recommended for production. + +## AUTHENTICATION + +**JWT_SECRET**=_(auto-generated)_ + +: Secret key used to sign JSON Web Tokens for session authentication. + Auto-generated by the setup script if left blank. Must be a + cryptographically random string of sufficient length. + +**JWT_EXPIRE_TIME**=_1d_ + +: JWT token lifetime. Accepts values like **1d** (one day), **12h** + (twelve hours), or **7d** (one week). After expiration, users must + re-authenticate. + +**API_KEY_SECRET**=_(auto-generated)_ + +: Secret key used for API key authentication. Generated with + **openssl rand -hex 33**. Leave blank to disable API key support. + +**ADMIN_EMAIL**=_admin@heimdall.local_ + +: Email address for the initial administrator account created during + database seeding. + +**ADMIN_PASSWORD**=_(auto-generated)_ + +: Password for the initial admin account. If left blank, a random + password is generated during **db:seed:all** and printed to the + terminal. Change this password after first login. + +**ADMIN_USES_EXTERNAL_AUTH**=_false_ + +: Set to **true** when the initial admin account authenticates via an + external provider (LDAP, OIDC, etc.) instead of a local password. + +## LOGIN CONTROLS + +**LOCAL_LOGIN_DISABLED**=_false_ + +: Set to **true** to disable local username/password login. Use this + after configuring SSO to enforce external authentication. The initial + admin account remains accessible for emergency recovery. + +**REGISTRATION_DISABLED**=_false_ + +: Set to **true** to disable public user registration via the web + interface. New accounts can still be created by administrators through + the API. + +**ONE_SESSION_PER_USER**=_false_ + +: Set to **true** to allow only one active session per user. When a + user logs in from a new device or browser, any existing session is + invalidated. + +**MAX_FILE_UPLOAD_SIZE**=_50_ + +: Maximum evaluation file upload size in megabytes. Increase this if + users need to upload large HDF results files. + +## PASSWORD POLICY + +**PASSWORD_MIN_LENGTH**=_(application default)_ + +: Minimum password length for local accounts. + +**PASSWORD_REQUIRE_CLASSES**=_(application default)_ + +: Number of character classes required in passwords (lowercase, + uppercase, digits, special characters). For example, a value of **3** + requires characters from at least three of the four classes. + +**PASSWORD_MAX_CONSECUTIVE**=_(application default)_ + +: Maximum number of consecutive identical characters allowed in a + password. Prevents passwords like **aaaaaa** or **111111**. + +## UI / BRANDING + +**WARNING_BANNER**=_(unset)_ + +: Warning banner text displayed to all users at the top of the Heimdall + interface. Useful for environment notices (e.g., "This is a staging + instance"). + +**CLASSIFICATION_BANNER_TEXT**=_(unset)_ + +: Classification banner text (e.g., **CUI**, **FOUO**, **SECRET**). + When set, a banner is displayed at the top and bottom of every page. + +**CLASSIFICATION_BANNER_TEXT_COLOR**=_white_ + +: CSS color for the classification banner text (e.g., **white**, + **black**, **#000000**). + +**CLASSIFICATION_BANNER_COLOR**=_red_ + +: CSS background color for the classification banner (e.g., **red**, + **green**, **#007A33**). + +## GITHUB OAUTH + +Providers are auto-enabled when their CLIENTID variable is set. The +callback URL for GitHub is **{EXTERNAL_URL}/authn/github/callback**. + +**GITHUB_CLIENTID**=_(unset)_ + +: GitHub OAuth application client ID. + +**GITHUB_CLIENTSECRET**=_(unset)_ + +: GitHub OAuth application client secret. + +**GITHUB_ENTERPRISE_INSTANCE_BASE_URL**=_(unset)_ + +: Base URL for GitHub Enterprise (e.g., + **https://github.example.com/**). Leave unset for public GitHub. + +**GITHUB_ENTERPRISE_INSTANCE_API_URL**=_(unset)_ + +: API URL for GitHub Enterprise (e.g., + **https://github.example.com/api/v3/**). Leave unset for public + GitHub. + +## GITLAB OAUTH + +The callback URL for GitLab is **{EXTERNAL_URL}/authn/gitlab/callback**. + +**GITLAB_CLIENTID**=_(unset)_ + +: GitLab OAuth application client ID. + +**GITLAB_CLIENTSECRET**=_(unset)_ + +: GitLab OAuth application client secret. + +**GITLAB_BASEURL**=_https://gitlab.com_ + +: GitLab instance URL. Set to your self-hosted GitLab URL if not using + gitlab.com. + +## GOOGLE OAUTH + +The callback URL for Google is **{EXTERNAL_URL}/authn/google/callback**. + +**GOOGLE_CLIENTID**=_(unset)_ + +: Google OAuth client ID (from Google Cloud Console). + +**GOOGLE_CLIENTSECRET**=_(unset)_ + +: Google OAuth client secret. + +## OKTA OIDC + +The callback URL for Okta is **{EXTERNAL_URL}/authn/okta_callback**. + +**OKTA_DOMAIN**=_(unset)_ + +: Your Okta domain (e.g., **your-domain.okta.com**). Endpoints are + auto-discovered from this value. + +**OKTA_CLIENTID**=_(unset)_ + +: Okta OAuth/OIDC client ID. + +**OKTA_CLIENTSECRET**=_(unset)_ + +: Okta OAuth/OIDC client secret. + +**OKTA_ISSUER_URL**=_(auto-discovered from OKTA_DOMAIN)_ + +: Override the OIDC issuer URL. + +**OKTA_AUTHORIZATION_URL**=_(auto-discovered from OKTA_DOMAIN)_ + +: Override the authorization endpoint. + +**OKTA_TOKEN_URL**=_(auto-discovered from OKTA_DOMAIN)_ + +: Override the token endpoint. + +**OKTA_USER_INFO_URL**=_(auto-discovered from OKTA_DOMAIN)_ + +: Override the userinfo endpoint. + +**OKTA_USE_HTTPS_PROXY**=_false_ + +: Set to **true** to route Okta OIDC requests through the proxy + specified in **HTTPS_PROXY**. Required when the Heimdall server + reaches Okta through a corporate forward proxy. + +## GENERIC OIDC + +For any OpenID Connect provider (Keycloak, Azure AD, Auth0, etc.). The +callback URL is **{EXTERNAL_URL}/authn/oidc_callback**. + +**OIDC_NAME**=_(unset)_ + +: Display name shown on the login page button (e.g., **My Identity + Provider**). + +**OIDC_ISSUER**=_(unset)_ + +: OIDC issuer URL (e.g., **https://auth.example.com**). + +**OIDC_AUTHORIZATION_URL**=_(unset)_ + +: OIDC authorization endpoint. + +**OIDC_TOKEN_URL**=_(unset)_ + +: OIDC token endpoint. + +**OIDC_USER_INFO_URL**=_(unset)_ + +: OIDC userinfo endpoint. + +**OIDC_CLIENTID**=_(unset)_ + +: OIDC client ID. + +**OIDC_CLIENT_SECRET**=_(unset)_ + +: OIDC client secret. + +**OIDC_USE_HTTPS_PROXY**=_false_ + +: Set to **true** to route OIDC requests through the proxy specified in + **HTTPS_PROXY**. Required when the Heimdall server reaches the OIDC + provider through a corporate forward proxy. + +**OIDC_USES_PKCE_S256**=_false_ + +: Set to **true** to use Proof Key for Code Exchange (PKCE) with the + SHA-256 challenge method. Recommended by most OIDC providers. + +**OIDC_USES_PKCE_PLAIN**=_false_ + +: Set to **true** to use PKCE with the plain text challenge method. + Only use if the provider does not support SHA-256 PKCE. + +**OIDC_USES_VERIFIED_EMAIL**=_true_ + +: When **true**, only accepts email addresses marked as verified by the + OIDC provider. Set to **false** if the provider does not include an + **email_verified** claim. + +**OIDC_EXTERNAL_GROUPS**=_false_ + +: Set to **true** to synchronize user groups from OIDC provider claims. + Group membership in Heimdall is updated on each login based on the + **groups** claim. + +## LDAP + +**LDAP_ENABLED**=_(unset)_ + +: Set to **true** to enable LDAP authentication. + +**LDAP_HOST**=_(unset)_ + +: LDAP server hostname (e.g., **ldap.example.com**). + +**LDAP_PORT**=_389_ + +: LDAP server port. Use **636** for LDAPS. + +**LDAP_BINDDN**=_(unset)_ + +: Bind DN for LDAP searches (e.g., + **cn=admin,dc=example,dc=com**). + +**LDAP_PASSWORD**=_(unset)_ + +: Password for the bind DN. + +**LDAP_SEARCHBASE**=_(unset)_ + +: Base DN for user searches (e.g., + **OU=Users,DC=example,DC=com**). + +**LDAP_SEARCHFILTER**=_(sAMAccountName={{username}})_ + +: LDAP search filter. The **{{username}}** placeholder is replaced with + the user's login input. For OpenLDAP, use **(uid={{username}})**. + +**LDAP_NAMEFIELD**=_name_ + +: LDAP attribute containing the user's display name. Common values are + **name** (Active Directory), **cn**, or **displayName**. + +**LDAP_MAILFIELD**=_mail_ + +: LDAP attribute containing the user's email address. Almost always + **mail**, but some directories use **userPrincipalName** or a custom + attribute. + +**LDAP_SSL**=_false_ + +: Set to **true** to use TLS for LDAP connections (LDAPS). + +**LDAP_SSL_CA**=_(unset)_ + +: Path to a PEM-encoded CA certificate for verifying the LDAP server's + TLS certificate. + +**LDAP_SSL_INSECURE**=_false_ + +: Set to **true** to skip TLS certificate verification. Not recommended + for production. + +## PROXY / TLS + +**HTTPS_PROXY**=_(unset)_ + +: HTTPS proxy URL for outbound OAuth/OIDC requests. Used when the + Heimdall server must reach external identity providers through a + corporate forward proxy. Example: **http://proxy.example.com:3128**. + Individual providers opt in via **OKTA_USE_HTTPS_PROXY** and + **OIDC_USE_HTTPS_PROXY**. + +**NODE_EXTRA_CA_CERTS**=_(unset)_ + +: Path to a PEM-encoded CA bundle file. Node.js adds these certificates + to its default trust store. Required when a TLS-inspection proxy + re-signs traffic with an internal CA that is not in the system trust + store. Example: **/etc/pki/ca-trust/source/anchors/proxy-ca.pem**. + +## EXTERNAL SERVICES + +**TENABLE_HOST_URL**=_(unset)_ + +: Tenable.SC instance URL. When set, Heimdall enables the Tenable + integration for importing scan results. + +**SPLUNK_HOST_URL**=_(unset)_ + +: Splunk instance URL. When set, Heimdall enables the Splunk + integration for forwarding evaluation results. + +**FORCE_TENABLE_FRONTEND**=_false_ + +: Set to **true** to force the Tenable.SC UI integration on the + frontend, even when **TENABLE_HOST_URL** would not normally enable it. + +## SECURITY + +This file contains database passwords, JWT signing keys, API secrets, and +SSO client secrets. It is installed with ownership **root:heimdall** and +permissions **0640** so that only root and the heimdall service user can +read it. + +Do not change the ownership or permissions to be more permissive. The +service runs as the **heimdall** user and requires read access through +group membership. + +The file is marked **%config(noreplace)** in the RPM, so user edits +survive package upgrades. If the package ships a new default version, it +is saved alongside as **backend.env.rpmnew**. + +## SEE ALSO + +**heimdall-server**(8), **heimdall-server-sysconfig**(5), +**heimdall-cli**(1), **systemctl**(1) + +## AUTHORS + +MITRE SAF Team + +https://github.com/mitre/heimdall2 diff --git a/packaging/rpm/man/heimdall-server-sysconfig.5.md b/packaging/rpm/man/heimdall-server-sysconfig.5.md new file mode 100644 index 0000000000..d27a2f4118 --- /dev/null +++ b/packaging/rpm/man/heimdall-server-sysconfig.5.md @@ -0,0 +1,136 @@ +# heimdall-server-sysconfig 5 "Heimdall Server" "Heimdall Server Manual" + +## NAME + +heimdall-server-sysconfig - Heimdall Server service-level configuration + +## SYNOPSIS + +**/etc/sysconfig/heimdall-server** + +## DESCRIPTION + +This file contains service-level settings that control **how** the +Heimdall Server systemd service runs: filesystem paths, restart behavior, +and other operational parameters. It is loaded by the systemd unit as an +EnvironmentFile before the application starts. + +This file is **not** for application configuration. Database credentials, +authentication settings, and all other runtime parameters belong in +**/etc/heimdall-server/backend.env**. See +**heimdall-server-backend.env**(5). + +The file uses shell-compatible **KEY=value** syntax. Lines beginning with +**#** are comments. Changes take effect after restarting the service: + + sudo systemctl restart heimdall-server + +## CONFIGURATION PRIORITY + +Settings can be specified through multiple mechanisms. When the same +setting is defined in more than one place, the following priority order +applies (highest to lowest): + +1. CLI flag (e.g., **heimdall-cli setup --app-dir=/custom/path**) +2. Environment variable (e.g., **export HEIMDALL_APP_DIR=/custom/path**) +3. This configuration file (**/etc/sysconfig/heimdall-server**) +4. Compile-time default + +In practice, this means values set in this file are the baseline and can +be overridden by environment variables or CLI flags without editing the +file. + +## SETTINGS + +**HEIMDALL_APP_DIR**=_/usr/share/heimdall-server_ + +: Application install directory containing the Node.js application + files, compiled backend, frontend assets, vendored node_modules, + database migrations, and seeders. This directory is read-only at + runtime. + +**HEIMDALL_DATA_DIR**=_/var/lib/heimdall-server_ + +: Variable data directory owned by the heimdall user. Contains the + backups/ subdirectory for pre-upgrade and on-demand database backups. + This is the only directory the service writes to at runtime (via the + systemd ReadWritePaths directive). + +**HEIMDALL_CONFIG_DIR**=_/etc/heimdall-server_ + +: Configuration directory containing backend.env and any additional + configuration files. Owned by root:heimdall with mode 0750. + +**HEIMDALL_LIBEXEC_DIR**=_/usr/libexec/heimdall-server_ + +: Helper scripts directory containing configure.sh, postgres-setup.sh, + fapolicyd-trust.sh, and the Caddy reverse proxy template. + +**HEIMDALL_LOG_DIR**=_/var/log/heimdall-server_ + +: Log directory owned by the heimdall user. Used when LOG_FILE is set + in backend.env. When LOG_FILE is unset (the default), logs go to + journald and this directory is unused. + +**HEIMDALL_CERT_DIR**=_/etc/pki/heimdall-server_ + +: TLS certificate directory. Stores self-signed certificates generated + during setup for IP-based deployments and any manually installed + certificates. + +**HEIMDALL_ENV_FILE**=_/etc/heimdall-server/backend.env_ + +: Path to the application environment file. The entrypoint script + sources this file before starting the Node.js process. + +**RESTART_ON_UPGRADE**=_true_ + +: Controls whether the service is automatically restarted during RPM + upgrades. When set to **true** (the default), the RPM postun + scriptlet calls **systemctl try-restart heimdall-server** after + package upgrade. Set to **false** to manage restart timing manually, + which is useful in environments with maintenance windows or when + upgrades require manual database migration steps. + + This follows the same pattern used by Grafana and other enterprise + services that ship sysconfig files for restart control. + +## UPGRADE BEHAVIOR + +This file is marked **%config(noreplace)** in the RPM spec. During +package upgrades: + +- If you have **not** modified the file, the new version from the package + replaces it. +- If you **have** modified the file, your version is preserved and the + new package version is saved as + **/etc/sysconfig/heimdall-server.rpmnew** for reference. + +This ensures that custom path overrides and restart preferences survive +upgrades without manual intervention. + +## EXAMPLES + +Override the data directory to use a dedicated volume: + + HEIMDALL_DATA_DIR=/data/heimdall-server + +Disable automatic restart during upgrades: + + RESTART_ON_UPGRADE=false + +Point the configuration to a non-standard location: + + HEIMDALL_CONFIG_DIR=/opt/heimdall/etc + HEIMDALL_ENV_FILE=/opt/heimdall/etc/backend.env + +## SEE ALSO + +**heimdall-server**(8), **heimdall-server-backend.env**(5), +**heimdall-cli**(1), **systemd.exec**(5), **systemctl**(1) + +## AUTHORS + +MITRE SAF Team + +https://github.com/mitre/heimdall2 diff --git a/packaging/rpm/man/heimdall-server.8.md b/packaging/rpm/man/heimdall-server.8.md new file mode 100644 index 0000000000..5b61eb9bdb --- /dev/null +++ b/packaging/rpm/man/heimdall-server.8.md @@ -0,0 +1,225 @@ +# heimdall-server 8 "Heimdall Server" "Heimdall Server Manual" + +## NAME + +heimdall-server - security results persistence and review server + +## SYNOPSIS + +**systemctl** start|stop|restart|status **heimdall-server** + +## DESCRIPTION + +Heimdall Server is a Node.js application that provides data persistence, +authentication, role-based access control (RBAC), and a REST API for +managing InSpec and other security scan results. It stores evaluation data +in PostgreSQL and serves a web interface for reviewing, comparing, and +sharing compliance results. + +The server listens on a configurable TCP port (default 3000) and is +typically deployed behind a TLS reverse proxy such as Caddy, nginx, or a +load balancer. The RPM package ships a systemd service unit that runs the +application as the unprivileged **heimdall** user with extensive security +hardening. + +## POST-INSTALL SETUP + +After installing the RPM, complete the initial setup by running: + + sudo heimdall-cli setup + +This performs seven steps: generates secrets and configuration, bootstraps +a local PostgreSQL database (if applicable), tests the database connection, +runs schema migrations and seeds the initial admin user, configures TLS +via Caddy (if installed), applies SELinux and firewall policies, and starts +the service. + +See **heimdall-cli**(1) for the full list of setup options and deployment +patterns. + +## OPTIONS + +The **heimdall-server** binary itself takes no command-line options. All +runtime behavior is controlled through environment variables loaded from +the configuration files listed below. Service management is performed +exclusively through **systemctl**(1) or **heimdall-cli**(1). + +## SERVICE MANAGEMENT + +Start the service: + + sudo systemctl start heimdall-server + +Stop the service: + + sudo systemctl stop heimdall-server + +Restart after configuration changes: + + sudo systemctl restart heimdall-server + +Check service status: + + sudo systemctl status heimdall-server + +View logs: + + sudo journalctl -u heimdall-server -f + +Or use the admin CLI for service control: + + sudo heimdall-cli start + sudo heimdall-cli stop + sudo heimdall-cli restart + sudo heimdall-cli status + +## FILES + +**/etc/heimdall-server/backend.env** + +: Application configuration file containing database credentials, JWT + secrets, authentication provider settings, and all other runtime + parameters. Owned by root:heimdall with mode 0640. See + **heimdall-server-backend.env**(5). + +**/etc/sysconfig/heimdall-server** + +: Service-level configuration controlling filesystem paths and restart + behavior. Consumed by the systemd unit as an EnvironmentFile. See + **heimdall-server-sysconfig**(5). + +**/usr/lib/systemd/system/heimdall-server.service** + +: Systemd service unit. Runs the application as the **heimdall** user + with security hardening directives. Loads both the sysconfig and + backend.env files. + +**/usr/share/heimdall-server/** + +: Application files including the compiled Node.js backend, frontend + assets, database migrations, seeders, and vendored node_modules. + +**/usr/bin/heimdall-cli** + +: Administrative CLI tool for setup, status, configuration, backup, + restore, password reset, diagnostics, and service control. Static Go + binary with no external dependencies. + +**/usr/bin/heimdall-server** + +: Service entrypoint script. Sources configuration from backend.env, + validates that required secrets are present, and executes the Node.js + application. + +**/usr/libexec/heimdall-server/** + +: Helper scripts used during setup and maintenance: configure.sh + (generates configuration), postgres-setup.sh (bootstraps local + PostgreSQL), fapolicyd-trust.sh (registers binaries with fapolicyd), + and the Caddy reverse proxy template. + +**/var/lib/heimdall-server/** + +: Variable data directory owned by the heimdall user. Contains the + backups/ subdirectory for pre-upgrade and on-demand database backups. + +**/var/log/heimdall-server/** + +: Log directory. Used only when LOG_FILE is set in backend.env. + By default, logs go to journald via systemd. + +**/etc/pki/heimdall-server/** + +: TLS certificate directory. Stores self-signed certificates generated + during setup for IP-based deployments. Corporate PKI certificates may + also be placed here. + +**/usr/share/selinux/packages/heimdall-server.pp** + +: Compiled SELinux policy module defining the heimdall_server_t domain. + Automatically loaded on install and removed on uninstall. + +**/usr/lib/firewalld/services/heimdall-server.xml** + +: Firewalld service definition for the Heimdall Server listen port. + +## SECURITY + +### Systemd Hardening + +The service unit applies the following restrictions: + +- **NoNewPrivileges=true** -- prevents privilege escalation via setuid + binaries or filesystem capabilities. +- **PrivateTmp=true** -- isolates /tmp and /var/tmp from other services. +- **ProtectSystem=strict** -- mounts the entire filesystem read-only + except for explicitly listed ReadWritePaths. +- **ReadWritePaths=/var/lib/heimdall-server** -- the only writable path. +- **ProtectHome=true** -- hides /home, /root, and /run/user. +- **RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6** -- limits network + socket types to Unix domain, IPv4, and IPv6. +- **CapabilityBoundingSet=** (empty) -- drops all Linux capabilities. +- **RestrictNamespaces=true** -- prevents creation of new namespaces. +- **LockPersonality=true** -- locks the process execution domain. +- **PrivateDevices=true** -- restricts access to physical devices. +- **ProtectKernelTunables=true**, **ProtectKernelModules=true**, + **ProtectControlGroups=true** -- prevents kernel modification. +- **ProtectClock=true**, **ProtectHostname=true**, + **ProtectKernelLogs=true** -- additional kernel protection. +- **ProtectProc=invisible** -- hides other processes. +- **RemoveIPC=true** -- removes IPC resources on service stop. +- **RestrictRealtime=true** -- prevents realtime scheduling. +- **RestrictSUIDSGID=true** -- blocks setuid/setgid file creation. +- **SystemCallArchitectures=native** -- restricts to native arch only. + +SystemCallFilter and MemoryDenyWriteExecute are intentionally not enabled +because the Node.js V8 JIT engine requires syscalls outside the +@system-service set and writable-executable memory pages. + +### SELinux + +The RPM ships a custom policy module that confines the service to the +**heimdall_server_t** domain. The policy registers TCP port 3000 as +**heimdall_server_port_t**. If the listen port is changed, register the +new port: + + sudo semanage port -a -t heimdall_server_port_t -p tcp 8443 + +The policy includes a tunable for PostgreSQL connectivity: + + getsebool heimdall_server_connect_postgresql + +### fapolicyd + +The RPM registers all bundled native binaries with the fapolicyd trust +database at install time via **/usr/libexec/heimdall-server/fapolicyd-trust.sh**. +Entries are automatically removed on uninstall. + +### Firewalld + +A service definition is shipped at +**/usr/lib/firewalld/services/heimdall-server.xml**. When Caddy is +configured as a reverse proxy, the setup script opens HTTPS (port 443) +instead of the application port. + +## ENVIRONMENT + +All environment variables are documented in +**heimdall-server-backend.env**(5). + +## EXIT STATUS + +The entrypoint script exits with status 1 if DATABASE_PASSWORD is not set +in the configuration file. + +## SEE ALSO + +**heimdall-cli**(1), **heimdall-server-backend.env**(5), +**heimdall-server-sysconfig**(5), **systemctl**(1), **journalctl**(1), +**semanage**(8) + +## AUTHORS + +MITRE SAF Team + +https://github.com/mitre/heimdall2 diff --git a/packaging/rpm/security/40-heimdall.rules b/packaging/rpm/security/40-heimdall.rules new file mode 100644 index 0000000000..a0441cb281 --- /dev/null +++ b/packaging/rpm/security/40-heimdall.rules @@ -0,0 +1,33 @@ +## Heimdall Server audit rules (SAMPLE) +## +## To activate, copy to /etc/audit/rules.d/ and restart auditd: +## sudo cp /usr/share/heimdall-server/security/40-heimdall.rules /etc/audit/rules.d/ +## sudo systemctl restart auditd +## +## These rules supplement the STIG baseline (30-stig.rules). +## The 40- prefix loads after STIG rules but before 99-finalize.rules. +## +## Audit keys: search with ausearch -k heimdall-config, etc. + +## Configuration files containing credentials (DB passwords, JWT keys, OAuth secrets) +-w /etc/heimdall-server/ -p wa -k heimdall-config +-w /etc/sysconfig/heimdall-server -p wa -k heimdall-config + +## Application binaries (tampering detection) +-w /usr/bin/heimdall-cli -p wa -k heimdall-bin +-w /usr/bin/heimdall-server -p wa -k heimdall-bin + +## Data directory (backups contain database dump + credentials) +-w /var/lib/heimdall-server/ -p wa -k heimdall-data + +## SELinux policy module +-w /usr/share/selinux/packages/heimdall-server.pp -p wa -k heimdall-selinux + +## systemd unit file +-w /usr/lib/systemd/system/heimdall-server.service -p wa -k heimdall-service + +## Admin CLI execution (human-initiated only, per OSPP v4.2 pattern) +-a always,exit -F arch=b64 -F path=/usr/bin/heimdall-cli -F perm=x -F auid>=1000 -F auid!=unset -k heimdall-admin + +## Track admin CLI execution by root +-a always,exit -F arch=b64 -F path=/usr/bin/heimdall-cli -F perm=x -F auid=0 -k heimdall-admin diff --git a/packaging/rpm/security/SECURITY.md b/packaging/rpm/security/SECURITY.md new file mode 100644 index 0000000000..8d32c66f95 --- /dev/null +++ b/packaging/rpm/security/SECURITY.md @@ -0,0 +1,202 @@ +# Heimdall Server Security Configuration + +This directory contains sample security configurations for STIG and FedRAMP +environments. These files are NOT activated automatically — review and copy +them to the appropriate system directories. + +## Files + +| File | Destination | Purpose | +|------|------------|---------| +| `40-heimdall.rules` | `/etc/audit/rules.d/` | auditd filesystem watches | + +## SELinux Policy + +The RPM ships a custom SELinux policy module at +`/usr/share/selinux/packages/heimdall-server.pp`. The policy is automatically +loaded on install and removed on uninstall. + +### Domain and port types + +- **heimdall_server_t** — the confined domain under which the Heimdall Server + systemd service runs. The policy restricts file access, network sockets, and + inter-process communication to only what the application requires. +- **heimdall_server_port_t** — the TCP port type registered for the + application listen port (default TCP 3000). + +### Changing the listen port + +If you change the `PORT` variable in `/etc/heimdall-server/backend.env`, you +must update the SELinux port registration: + +```bash +sudo semanage port -m -t heimdall_server_port_t -p tcp +``` + +### PostgreSQL connectivity tunable + +The policy includes a boolean for PostgreSQL access: + +```bash +getsebool heimdall_server_connect_postgresql +``` + +### heimdall-cli (unconfined) + +The `heimdall-cli` binary runs as root in the system's unconfined domain. It +is an administrative tool intended for privileged operators and is not confined +by a custom SELinux policy. + +## Firewalld + +A service definition is shipped at +`/usr/lib/firewalld/services/heimdall-server.xml`. + +- **With Caddy (default):** The setup script configures Caddy as a TLS reverse + proxy and opens port **443** (HTTPS) via the `heimdall-server` firewalld + service. The application port (3000) is not exposed externally. +- **With `--skip-tls`:** The setup script opens port **3000** directly so the + application is reachable without a reverse proxy. + +The setup script handles firewalld configuration automatically. No manual +firewall changes are required for standard deployments. + +## Enabling Audit Rules + +The sample audit rules in `40-heimdall.rules` are **not activated by default**. +Follow these steps to enable them: + +1. **Review the sample rules** to ensure they are appropriate for your + environment: + + ```bash + cat /usr/share/heimdall-server/security/40-heimdall.rules + ``` + +2. **Copy the rules** to the active audit rules directory: + + ```bash + sudo cp /usr/share/heimdall-server/security/40-heimdall.rules /etc/audit/rules.d/ + ``` + +3. **Restart auditd** to load the new rules: + + ```bash + sudo systemctl restart auditd + ``` + +4. **Verify the rules loaded** successfully: + + ```bash + sudo auditctl -l | grep heimdall + ``` + + You should see entries for each `-w` watch and `-a` syscall rule defined in + the file. + +5. **Test with ausearch** to confirm events are being recorded: + + ```bash + sudo ausearch -k heimdall-config + sudo ausearch -k heimdall-admin + sudo ausearch -k heimdall-data + ``` + +## Security Considerations + +### Database password in process environment + +The CLI passes the PostgreSQL password via the `PGPASSWORD` environment variable +when calling `psql` and `pg_dump`. On shared systems, other users may be able to +see environment variables via `/proc//environ`. For maximum security: + +- Restrict access to the Heimdall server to authorized administrators only +- Consider using a `.pgpass` file (mode 0600) for unattended operations +- Run `heimdall-cli` commands only from secure terminals + +### Password reset output + +The `heimdall-cli reset-password` command prints auto-generated passwords to +stdout in plaintext. To prevent exposure: + +- Do not redirect output to log files +- Clear terminal history after running the command: `history -c` +- Consider piping output to a secure credential store + +## Active Configs (shipped in /etc/) + +These are installed by the RPM and active by default: + +| File | Purpose | +|------|---------| +| `/etc/rsyslog.d/30-heimdall-server.conf` | Routes journald messages to `/var/log/heimdall-server/` | +| `/etc/logrotate.d/heimdall-server` | 90-day log rotation (FedRAMP Moderate AU-11) | + +## Logging Architecture + +``` +NestJS app (stdout/stderr) + → systemd (captures) + → journald (primary storage, automatic) + → rsyslog (routes to file via /etc/rsyslog.d/30-heimdall-server.conf) + → /var/log/heimdall-server/heimdall-server.log + → /var/log/heimdall-server/heimdall-cli.log +``` + +### Viewing logs + +```bash +# Primary (journald) +journalctl -u heimdall-server +journalctl -u heimdall-server -f # follow +journalctl -u heimdall-server -p err # errors only +journalctl -u heimdall-server --since "1 hour ago" + +# File-based (via rsyslog) +tail -f /var/log/heimdall-server/heimdall-server.log + +# CLI admin actions +tail -f /var/log/heimdall-server/heimdall-cli.log + +# Audit events (if rules activated) +ausearch -k heimdall-config # config file changes +ausearch -k heimdall-admin # CLI tool usage +ausearch -k heimdall-data # data directory changes +``` + +## STIG Controls + +| Control | Implementation | +|---------|---------------| +| AU-2 (Audit Events) | auditd rules watch config, binaries, data | +| AU-3 (Audit Content) | journald captures timestamp, PID, unit, priority | +| AU-4 (Audit Storage) | 90-day logrotate retention | +| AU-9 (Audit Protection) | Log files 0640, audit rules in 40- slot (before -e 2) | +| AC-6 (Least Privilege) | systemd: CapabilityBoundingSet=, ProtectSystem=strict | +| CM-5 (Access Restrictions) | backend.env 0640 root:heimdall | +| SC-7 (Boundary Protection) | SELinux policy, firewalld, Caddy TLS | + +## systemd Hardening (active by default) + +The service unit includes comprehensive sandboxing: + +- NoNewPrivileges, PrivateTmp, PrivateDevices +- ProtectSystem=strict with explicit ReadWritePaths +- ProtectHome, ProtectClock, ProtectHostname, ProtectKernelLogs +- RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +- SystemCallArchitectures=native +- CapabilityBoundingSet= (empty — no capabilities) +- UMask=0027 + +## File Permissions + +| Path | Mode | Owner | Purpose | +|------|------|-------|---------| +| `/etc/heimdall-server/backend.env` | 0640 | root:heimdall | Credentials (DB, JWT, OAuth) | +| `/etc/sysconfig/heimdall-server` | 0640 | root:root | Service path overrides | +| `/etc/rsyslog.d/30-heimdall-server.conf` | 0644 | root:root | Rsyslog routing rules | +| `/etc/logrotate.d/heimdall-server` | 0644 | root:root | Log rotation policy | +| `/run/heimdall-server/` | 0750 | heimdall:heimdall | Runtime directory (via tmpfiles.d) | +| `/var/lib/heimdall-server/` | 0750 | heimdall:heimdall | Variable data | +| `/var/lib/heimdall-server/backups/` | 0700 | heimdall:heimdall | Backup archives (contain credentials) | +| `/var/log/heimdall-server/` | 0750 | heimdall:heimdall | Log files | diff --git a/packaging/rpm/selinux/heimdall_server.fc b/packaging/rpm/selinux/heimdall_server.fc new file mode 100644 index 0000000000..74beef7b5f --- /dev/null +++ b/packaging/rpm/selinux/heimdall_server.fc @@ -0,0 +1,20 @@ +# SELinux file contexts for heimdall-server + +# Systemd unit file +/usr/lib/systemd/system/heimdall-server\.service -- gen_context(system_u:object_r:heimdall_server_unit_file_t,s0) + +# Config directory +/etc/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_conf_t,s0) + +# Application + bundled Node.js binary +/usr/share/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_exec_t,s0) + +# Data directory +/var/lib/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_var_lib_t,s0) + +# Log directory +/var/log/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_log_t,s0) + +# Runtime directory +/run/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_var_run_t,s0) +/var/run/heimdall-server(/.*)? gen_context(system_u:object_r:heimdall_server_var_run_t,s0) diff --git a/packaging/rpm/selinux/heimdall_server.if b/packaging/rpm/selinux/heimdall_server.if new file mode 100644 index 0000000000..2b4d355537 --- /dev/null +++ b/packaging/rpm/selinux/heimdall_server.if @@ -0,0 +1,95 @@ +## policy for heimdall-server + +######################################## +## +## Execute heimdall-server in the heimdall_server domain. +## +## +## +## Domain allowed to transition. +## +## +# +interface(`heimdall_server_domtrans',` + gen_require(` + type heimdall_server_t, heimdall_server_exec_t; + ') + + corecmd_search_bin($1) + domtrans_pattern($1, heimdall_server_exec_t, heimdall_server_t) +') + +######################################## +## +## Allow a domain to connect to heimdall-server TCP port. +## +## +## +## Domain allowed access. +## +## +# +interface(`heimdall_server_connect_port',` + gen_require(` + type heimdall_server_port_t; + ') + + allow $1 heimdall_server_port_t:tcp_socket name_connect; +') + +######################################## +## +## Manage systemd unit for heimdall-server. +## +## +## +## Domain allowed access. +## +## +# +interface(`heimdall_server_systemctl',` + gen_require(` + type heimdall_server_t; + type heimdall_server_unit_file_t; + ') + + systemd_exec_systemctl($1) + allow $1 heimdall_server_unit_file_t:file read_file_perms; + allow $1 heimdall_server_unit_file_t:service manage_service_perms; + + ps_process_pattern($1, heimdall_server_t) +') + +######################################## +## +## Full administration of heimdall-server. +## +## +## +## Domain allowed access. +## +## +## +## +## Role allowed access. +## +## +## +# +interface(`heimdall_server_admin',` + gen_require(` + type heimdall_server_t; + type heimdall_server_unit_file_t; + ') + + allow $1 heimdall_server_t:process { signal_perms }; + ps_process_pattern($1, heimdall_server_t) + + tunable_policy(`deny_ptrace',`',` + allow $1 heimdall_server_t:process ptrace; + ') + + heimdall_server_systemctl($1) + admin_pattern($1, heimdall_server_unit_file_t) + allow $1 heimdall_server_unit_file_t:service all_service_perms; +') diff --git a/packaging/rpm/selinux/heimdall_server.te b/packaging/rpm/selinux/heimdall_server.te new file mode 100644 index 0000000000..b417fc6c13 --- /dev/null +++ b/packaging/rpm/selinux/heimdall_server.te @@ -0,0 +1,152 @@ +policy_module(heimdall_server, 1.0.0) + +######################################## +# +# Declarations +# + +## +##

+## Allow heimdall-server to connect to PostgreSQL (TCP port 5432). +## Enabled by default for localhost database deployments. +##

+##
+gen_tunable(heimdall_server_connect_postgresql, true) + +type heimdall_server_t; +type heimdall_server_exec_t; +init_daemon_domain(heimdall_server_t, heimdall_server_exec_t) +# Required when systemd unit has NoNewPrivileges=yes +init_nnp_daemon_domain(heimdall_server_t) + +# Systemd unit file label +type heimdall_server_unit_file_t; +systemd_unit_file(heimdall_server_unit_file_t) + +# /etc/heimdall-server/ +type heimdall_server_conf_t; +files_config_file(heimdall_server_conf_t) + +# /var/lib/heimdall-server/ +type heimdall_server_var_lib_t; +files_type(heimdall_server_var_lib_t) + +# /var/log/heimdall-server/ +type heimdall_server_log_t; +logging_log_file(heimdall_server_log_t) + +# /run/heimdall-server/ +type heimdall_server_var_run_t; +files_pid_file(heimdall_server_var_run_t) + +# Temporary files +type heimdall_server_tmp_t; +files_tmp_file(heimdall_server_tmp_t) + +type heimdall_server_tmpfs_t; +files_tmpfs_file(heimdall_server_tmpfs_t) + +# TCP port (3000 by default, registered via semanage) +type heimdall_server_port_t; +corenet_port(heimdall_server_port_t) + +######################################## +# +# heimdall_server local policy +# + +# Node.js V8 JIT requires execmem (writable+executable pages). +# Non-negotiable — V8 crashes without it. +allow heimdall_server_t self:process execmem; + +# Socket permissions +allow heimdall_server_t self:tcp_socket create_stream_socket_perms; +allow heimdall_server_t self:udp_socket create_stream_socket_perms; +allow heimdall_server_t self:unix_stream_socket { create_stream_socket_perms connectto }; +allow heimdall_server_t self:unix_dgram_socket create_socket_perms; +allow heimdall_server_t self:fifo_file rw_fifo_file_perms; + +# Netlink for DNS/network queries +allow heimdall_server_t self:netlink_route_socket { create bind getattr nlmsg_read }; + +# Bind to heimdall_server_port_t (labeled via semanage port) +allow heimdall_server_t heimdall_server_port_t:tcp_socket { name_bind name_connect }; +corenet_tcp_bind_generic_node(heimdall_server_t) + +# Config files: /etc/heimdall-server/ +manage_dirs_pattern(heimdall_server_t, heimdall_server_conf_t, heimdall_server_conf_t) +manage_files_pattern(heimdall_server_t, heimdall_server_conf_t, heimdall_server_conf_t) + +# Data: /var/lib/heimdall-server/ +manage_dirs_pattern(heimdall_server_t, heimdall_server_var_lib_t, heimdall_server_var_lib_t) +manage_files_pattern(heimdall_server_t, heimdall_server_var_lib_t, heimdall_server_var_lib_t) +manage_lnk_files_pattern(heimdall_server_t, heimdall_server_var_lib_t, heimdall_server_var_lib_t) +files_var_lib_filetrans(heimdall_server_t, heimdall_server_var_lib_t, { dir file }) + +# Logs: /var/log/heimdall-server/ +manage_dirs_pattern(heimdall_server_t, heimdall_server_log_t, heimdall_server_log_t) +manage_files_pattern(heimdall_server_t, heimdall_server_log_t, heimdall_server_log_t) +logging_log_filetrans(heimdall_server_t, heimdall_server_log_t, { dir file }) + +# Runtime: /run/heimdall-server/ +manage_dirs_pattern(heimdall_server_t, heimdall_server_var_run_t, heimdall_server_var_run_t) +manage_files_pattern(heimdall_server_t, heimdall_server_var_run_t, heimdall_server_var_run_t) +manage_sock_files_pattern(heimdall_server_t, heimdall_server_var_run_t, heimdall_server_var_run_t) +files_pid_filetrans(heimdall_server_t, heimdall_server_var_run_t, { dir file sock_file }) + +# Temp files +manage_dirs_pattern(heimdall_server_t, heimdall_server_tmp_t, heimdall_server_tmp_t) +manage_files_pattern(heimdall_server_t, heimdall_server_tmp_t, heimdall_server_tmp_t) +files_tmp_filetrans(heimdall_server_t, heimdall_server_tmp_t, { dir file }) + +manage_dirs_pattern(heimdall_server_t, heimdall_server_tmpfs_t, heimdall_server_tmpfs_t) +manage_files_pattern(heimdall_server_t, heimdall_server_tmpfs_t, heimdall_server_tmpfs_t) +fs_tmpfs_filetrans(heimdall_server_t, heimdall_server_tmpfs_t, { dir file }) + +# Execute bundled Node.js and native .node modules +allow heimdall_server_t heimdall_server_exec_t:file { execute execute_no_trans map }; + +# System access +sysnet_read_config(heimdall_server_t) +files_read_etc_files(heimdall_server_t) +auth_read_passwd(heimdall_server_t) +miscfiles_read_localization(heimdall_server_t) +miscfiles_read_generic_certs(heimdall_server_t) +kernel_read_net_sysctls(heimdall_server_t) +kernel_read_system_state(heimdall_server_t) +dev_read_sysfs(heimdall_server_t) +logging_send_syslog_msg(heimdall_server_t) +domain_use_interactive_fds(heimdall_server_t) + +# Outbound HTTP/HTTPS (OAuth callbacks, external APIs) +corenet_tcp_connect_http_port(heimdall_server_t) +corenet_tcp_connect_http_cache_port(heimdall_server_t) + +# PostgreSQL — enabled by tunable (default: true) +tunable_policy(`heimdall_server_connect_postgresql',` + corenet_tcp_connect_postgresql_port(heimdall_server_t) +') + +# PostgreSQL UNIX socket (same-host) +optional_policy(` + require { + type postgresql_t; + type postgresql_var_run_t; + } + allow heimdall_server_t postgresql_t:unix_stream_socket connectto; + allow heimdall_server_t postgresql_var_run_t:sock_file write; +') + +# cgroup access (Node.js diagnostics) +optional_policy(` + require { + type cgroup_t; + } + allow heimdall_server_t cgroup_t:dir search; + allow heimdall_server_t cgroup_t:file { open read }; +') + +# systemd private tmp +optional_policy(` + systemd_private_tmp(heimdall_server_tmp_t) +') diff --git a/packaging/rpm/setup-rpm-build-env.sh b/packaging/rpm/setup-rpm-build-env.sh new file mode 100755 index 0000000000..fb96fefb54 --- /dev/null +++ b/packaging/rpm/setup-rpm-build-env.sh @@ -0,0 +1,421 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +SPEC_FILE="${SCRIPT_DIR}/heimdall-server.spec" + +TOPDIR="${HOME}/rpmbuild" +RUN_BUILD=0 +INSTALL_DEPS=1 +RUN_DNF_UPDATE=1 +ENABLE_NODESOURCE=1 +ENABLE_PGDG=1 +ENABLE_YARN_REPO=1 +NO_GPG_CHECK=0 +SOURCE_DIR="" +VERSION_OVERRIDE="" + +usage() { + cat <<'EOF' +Usage: setup-rpm-build-env.sh [options] + +Sets up an OL/EL RPM build environment for heimdall-server, stages rpmbuild +inputs, and optionally runs rpmbuild. + +Source tarball options (pick one): + --source-dir Path to a local heimdall2 git checkout (creates tarball + via git archive or tar) + --version Download source tarball from GitHub release (e.g. 2.12.6) + +If neither is given, the version is read from the spec file and downloaded +from GitHub. + +Options: + --topdir RPM topdir (default: ~/rpmbuild) + --build Run rpmbuild -ba after setup + --skip-deps Skip dependency/repository installation + --skip-update Skip dnf update + --skip-nodesource Skip NodeSource setup_22.x repo bootstrap + --skip-pgdg Skip PGDG repository setup + --skip-yarn-repo Skip Yarn repository setup + --no-gpg-check Disable package and repo metadata GPG checks in dnf + -h, --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --topdir) + if [[ $# -lt 2 ]]; then + echo "--topdir requires a value" >&2 + exit 1 + fi + TOPDIR="$2" + shift 2 + ;; + --source-dir) + if [[ $# -lt 2 ]]; then + echo "--source-dir requires a value" >&2 + exit 1 + fi + SOURCE_DIR="$2" + shift 2 + ;; + --version) + if [[ $# -lt 2 ]]; then + echo "--version requires a value" >&2 + exit 1 + fi + VERSION_OVERRIDE="$2" + shift 2 + ;; + --build) + RUN_BUILD=1 + shift + ;; + --skip-deps) + INSTALL_DEPS=0 + shift + ;; + --skip-update) + RUN_DNF_UPDATE=0 + shift + ;; + --skip-nodesource) + ENABLE_NODESOURCE=0 + shift + ;; + --skip-pgdg) + ENABLE_PGDG=0 + shift + ;; + --skip-yarn-repo) + ENABLE_YARN_REPO=0 + shift + ;; + --no-gpg-check) + NO_GPG_CHECK=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +ensure_arch_specific_spec() { + local spec_path="$1" + if grep -Eiq '^[[:space:]]*BuildArch:[[:space:]]*noarch([[:space:]]|$)' "${spec_path}"; then + echo "Refusing to stage a noarch spec for heimdall-server." >&2 + echo "Remove 'BuildArch: noarch' from ${spec_path} and retry." >&2 + exit 1 + fi +} + +SUDO="" +if [[ "${EUID}" -ne 0 ]]; then + SUDO="sudo" +fi + +if [[ "${NO_GPG_CHECK}" -eq 1 ]]; then + DNF_INSTALL_ARGS=(-y --nogpgcheck --setopt=*.gpgcheck=0 --setopt=*.repo_gpgcheck=0) + DNF_UPDATE_ARGS=(-y --nogpgcheck --setopt=*.gpgcheck=0 --setopt=*.repo_gpgcheck=0) + DNF_MODULE_ARGS=(-qy --setopt=*.gpgcheck=0 --setopt=*.repo_gpgcheck=0) +else + DNF_INSTALL_ARGS=(-y) + DNF_UPDATE_ARGS=(-y) + DNF_MODULE_ARGS=(-qy) +fi + +install_build_deps() { + require_cmd dnf + require_cmd curl + + if [[ "${RUN_DNF_UPDATE}" -eq 1 ]]; then + ${SUDO} dnf "${DNF_UPDATE_ARGS[@]}" update + fi + + if [[ "${ENABLE_NODESOURCE}" -eq 1 ]]; then + curl -fsSL https://rpm.nodesource.com/setup_22.x | ${SUDO} bash - + fi + + if [[ "${ENABLE_YARN_REPO}" -eq 1 ]]; then + ${SUDO} curl -fsSL https://dl.yarnpkg.com/rpm/yarn.repo \ + -o /etc/yum.repos.d/yarn.repo + fi + + # Defensive: NodeSource's Node 22 ships corepack, which can install a + # yarn shim at /usr/bin/yarn that shadows the real yarn rpm. The shim + # intercepts `yarn install` with bogus packageManager checks (caught + # while building heimdall2 v2.13.1 — corepack invented a packageManager + # value the project does not declare). RPM builds need the deterministic + # /usr/bin/yarn from the yarn.repo, with no network calls at %build + # time, so disable corepack's shims before installing the real rpm. + if command -v corepack >/dev/null 2>&1; then + ${SUDO} corepack disable yarn 2>/dev/null || true + ${SUDO} corepack disable pnpm 2>/dev/null || true + fi + + if [[ "${ENABLE_PGDG}" -eq 1 ]]; then + local el_major="" + local rpm_arch="" + if command -v rpm >/dev/null 2>&1; then + el_major="$(rpm -E '%{?rhel}')" + rpm_arch="$(rpm -E '%{_arch}')" + fi + if [[ -z "${el_major}" ]]; then + el_major="$(. /etc/os-release && printf '%s' "${VERSION_ID%%.*}")" + fi + if [[ -z "${el_major}" ]]; then + echo "Unable to determine EL major version for PGDG repo URL." >&2 + exit 1 + fi + + if [[ -z "${rpm_arch}" || "${rpm_arch}" == "%{_arch}" ]]; then + rpm_arch="$(uname -m)" + fi + + local pgdg_arch="" + case "${rpm_arch}" in + x86_64|aarch64|ppc64le|s390x) + pgdg_arch="${rpm_arch}" + ;; + *) + echo "Unsupported architecture '${rpm_arch}' for PGDG repo bootstrap." >&2 + exit 1 + ;; + esac + + local pgdg_repo_url="https://download.postgresql.org/pub/repos/yum/reporpms/EL-${el_major}-${pgdg_arch}/pgdg-redhat-repo-latest.noarch.rpm" + ${SUDO} dnf install "${DNF_INSTALL_ARGS[@]}" "${pgdg_repo_url}" + ${SUDO} dnf "${DNF_MODULE_ARGS[@]}" module disable postgresql || true + fi + + ${SUDO} dnf install "${DNF_INSTALL_ARGS[@]}" \ + gcc-c++ \ + git \ + make \ + nodejs \ + openssl \ + python3 \ + redhat-rpm-config \ + selinux-policy-devel \ + rpm-build \ + rpmdevtools \ + systemd-rpm-macros \ + tar \ + util-linux \ + yarn + + if [[ "${ENABLE_PGDG}" -eq 1 ]]; then + ${SUDO} dnf install "${DNF_INSTALL_ARGS[@]}" postgresql18 postgresql18-server + fi +} + +# Build the Go heimdall-cli binary for the target platform. +# Produces a static binary (CGO_ENABLED=0) with no runtime dependencies. +build_cli_binary() { + local dest="$1" + local cli_src="${REPO_ROOT}/heimdall-cli" + require_cmd go + + if [[ ! -d "${cli_src}" ]]; then + echo "heimdall-cli/ directory not found at ${cli_src}" >&2 + exit 1 + fi + + # Default to host arch; override with GOARCH env var + local goarch="${GOARCH:-$(go env GOARCH)}" + + cd "${cli_src}" + GOOS=linux GOARCH="${goarch}" CGO_ENABLED=0 \ + go build -trimpath \ + -ldflags="-s -w" \ + -o "${dest}" \ + ./cmd/heimdall-cli + cd - >/dev/null + + echo " Built heimdall-cli (linux/${goarch})" +} + +# Fetch the upstream source tarball. +# Uses --source-dir (local git repo) or downloads from GitHub. +fetch_source_tarball() { + local version="$1" + local dest="$2" + + if [[ -n "${SOURCE_DIR}" ]]; then + # Local heimdall2 checkout — use git archive or tar + if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "Source directory not found: ${SOURCE_DIR}" >&2 + exit 1 + fi + echo " Creating source tarball from local checkout: ${SOURCE_DIR}" + if command -v git >/dev/null 2>&1 && [[ -d "${SOURCE_DIR}/.git" ]]; then + git -C "${SOURCE_DIR}" archive \ + --format=tar.gz \ + --prefix="heimdall2-${version}/" \ + HEAD \ + > "${dest}" + else + tar -C "${SOURCE_DIR}" \ + --exclude=".git" \ + --exclude="node_modules" \ + --exclude="apps/backend/node_modules" \ + --exclude="apps/frontend/node_modules" \ + --exclude="dist" \ + --exclude="apps/backend/dist" \ + --exclude="apps/frontend/dist" \ + --transform "s|^\.|heimdall2-${version}|" \ + -czf "${dest}" \ + . + fi + else + # Download from GitHub + local tag="v${version#v}" + local url="https://github.com/mitre/heimdall2/archive/refs/tags/${tag}.tar.gz" + echo " Downloading source from ${url}" + + # Try scripts/fetch-source.sh first (if available in the repo) + local fetch_script="${REPO_ROOT}/scripts/fetch-source.sh" + if [[ -x "${fetch_script}" ]]; then + "${fetch_script}" \ + --package heimdall-server \ + --version "${version}" \ + --output-dir "$(dirname "${dest}")" + # fetch-source.sh names it heimdall-server-VERSION.tar.gz, rename to match spec + local fetched="$(dirname "${dest}")/heimdall-server-${version}.tar.gz" + if [[ -f "${fetched}" && "${fetched}" != "${dest}" ]]; then + mv "${fetched}" "${dest}" + fi + elif command -v gh &>/dev/null; then + gh release download "${tag}" \ + --repo mitre/heimdall2 \ + --archive tar.gz \ + --output "${dest}" || \ + curl -fsSL -o "${dest}" "${url}" + else + curl -fsSL -o "${dest}" "${url}" + fi + fi + + if [[ ! -f "${dest}" ]]; then + echo "Failed to create/download source tarball" >&2 + exit 1 + fi + echo " Source tarball: ${dest}" +} + +stage_rpm_inputs() { + require_cmd awk + require_cmd cp + require_cmd tar + + if [[ ! -f "${SPEC_FILE}" ]]; then + echo "Spec file not found: ${SPEC_FILE}" >&2 + exit 1 + fi + ensure_arch_specific_spec "${SPEC_FILE}" + + local version + if [[ -n "${VERSION_OVERRIDE}" ]]; then + version="${VERSION_OVERRIDE}" + else + version="$(awk '/^Version:/ {print $2; exit}' "${SPEC_FILE}")" + fi + if [[ -z "${version}" ]]; then + echo "Unable to determine version. Use --version or set Version in spec." >&2 + exit 1 + fi + + echo "Staging rpmbuild inputs for heimdall-server ${version}..." + + mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + + # Spec file + cp -f "${SPEC_FILE}" "${TOPDIR}/SPECS/heimdall-server.spec" + + # Flat source files (Source1–Source14 in the spec) + echo " Copying packaging files..." + local source_files=( + heimdall-server.service + heimdall-backend.env + heimdall-server.sh + heimdall-db-setup.sh + heimdall-configure.sh + heimdall-postgres-setup.sh + heimdall-setup.sh + heimdall-server-tmpfiles.conf + heimdall-server.repo + heimdall-Caddyfile + heimdall-sysconfig + heimdall-rsyslog.conf + heimdall-logrotate.conf + ) + local source_file="" + for source_file in "${source_files[@]}"; do + cp -f "${SCRIPT_DIR}/${source_file}" "${TOPDIR}/SOURCES/" + done + + # SELinux policy sources (Source9–Source11) + for ext in te fc if; do + cp -f "${SCRIPT_DIR}/selinux/"*."${ext}" "${TOPDIR}/SOURCES/" 2>/dev/null || true + done + + # (fapolicyd helper script retired — handled by `heimdall-cli fapolicyd`) + + # firewalld service definition (Source13) + cp -f "${SCRIPT_DIR}/firewalld/"*.xml "${TOPDIR}/SOURCES/" 2>/dev/null || true + + # Security samples (Source20-21) + cp -f "${SCRIPT_DIR}/security/40-heimdall.rules" "${TOPDIR}/SOURCES/" 2>/dev/null || true + cp -f "${SCRIPT_DIR}/security/SECURITY.md" "${TOPDIR}/SOURCES/" 2>/dev/null || true + + # heimdall-cli Go binary (Source15) — static binary, no runtime deps + echo " Building heimdall-cli binary..." + build_cli_binary "${TOPDIR}/SOURCES/heimdall-cli" + + # Main source tarball (Source0) + echo " Fetching source tarball..." + fetch_source_tarball "${version}" "${TOPDIR}/SOURCES/heimdall2-${version}.tar.gz" + + echo "" + echo "Staged rpmbuild inputs in ${TOPDIR}" + echo " Spec: ${TOPDIR}/SPECS/heimdall-server.spec" + echo " Spec arch tags:" + grep -E '^(BuildArch|ExclusiveArch):' "${TOPDIR}/SPECS/heimdall-server.spec" \ + || echo " (BuildArch unset, package will be built for target CPU)" +} + +run_rpmbuild() { + require_cmd rpmbuild + rpmbuild --define "_topdir ${TOPDIR}" -ba "${TOPDIR}/SPECS/heimdall-server.spec" +} + +if [[ "${INSTALL_DEPS}" -eq 1 ]]; then + install_build_deps +fi + +stage_rpm_inputs + +if [[ "${RUN_BUILD}" -eq 1 ]]; then + run_rpmbuild +else + echo + echo "Next step:" + echo " rpmbuild --define \"_topdir ${TOPDIR}\" -ba ${TOPDIR}/SPECS/heimdall-server.spec" +fi From 381d00bb2acb06ebdce462851fab83993da56998 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:34:45 -0400 Subject: [PATCH 004/197] docs: restore NOTICE.md Removed in daac353ec (#7276, 'match what's on ironbank now'). Restored with the copyright year updated to 2026. Authored by: Aaron Lippold --- NOTICE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 NOTICE.md diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000000..bf908dad96 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,13 @@ +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE.md file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx From 9bfff1e93d127485b958a0ace607badcbcd4e2df Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:36:33 -0400 Subject: [PATCH 005/197] Revert "docs: restore NOTICE.md" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NOTICE was never lost. daac353ec (#7276) consolidated it into LICENSE.md, which is the standard MITRE SAF layout — LICENSE.md carries the Apache-2.0 terms followed by the NOTICE section. vulcan does the same, which is why its NOTICE.md is an empty placeholder. Restoring a separate NOTICE.md duplicated content already present in LICENSE.md. Authored by: Aaron Lippold --- NOTICE.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 NOTICE.md diff --git a/NOTICE.md b/NOTICE.md deleted file mode 100644 index bf908dad96..0000000000 --- a/NOTICE.md +++ /dev/null @@ -1,13 +0,0 @@ -© 2026 The MITRE Corporation. - -Approved for Public Release; Distribution Unlimited. Case Number 18-3678. - -NOTICE - -MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE.md file included with this project. - -This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. - -For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. - -DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx From ac593ebc1c31b85ce95b57b2a26c1cbb92e13f3e Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:37:57 -0400 Subject: [PATCH 006/197] docs: bump LICENSE.md copyright to 2026 Authored by: Aaron Lippold --- LICENSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index 61c479f00a..6a712fb952 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -© 2025 The MITRE Corporation. +© 2026 The MITRE Corporation. Approved for Public Release; Distribution Unlimited. Case Number 18-3678. From b6a1228723e8127ec93af405bfe7af5a455df164 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:39:16 -0400 Subject: [PATCH 007/197] docs: use the MITRE SAF license for hdf-converters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libs/hdf-converters/LICENSE.md was a fragment — no copyright line, no case number, no Apache-2.0 grant text, and it referenced 'the following Notice' when the file contained no Notice. Replaced with the repository's MITRE SAF LICENSE.md, which is a strict superset: it retains the non-endorsement clause and adds the copyright, Case Number 18-3678, the Apache-2.0 grant, and the NOTICE section. Authored by: Aaron Lippold --- libs/hdf-converters/LICENSE.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/libs/hdf-converters/LICENSE.md b/libs/hdf-converters/LICENSE.md index ff0d8689c9..6a712fb952 100644 --- a/libs/hdf-converters/LICENSE.md +++ b/libs/hdf-converters/LICENSE.md @@ -1,4 +1,18 @@ -Licensed under the Apache-2.0 license. +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -7,3 +21,13 @@ Redistribution and use in source and binary forms, with or without modification, - Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. - Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx From 7f1196166b9b26abbe94b329cf7d98cbdfb55c44 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:41:51 -0400 Subject: [PATCH 008/197] docs: normalize licensing across all monorepo workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every workspace package now declares its license, and every package that publishes to npm ships the license text. Added LICENSE.md (MITRE SAF, Apache-2.0, Case Number 18-3678) to the two published packages that shipped without one: - @mitre/heimdall-lite (apps/frontend) - inspecjs (libs/inspecjs) Both declared "license": "Apache-2.0" in package.json while carrying no license text at all — a metadata claim with nothing behind it, on packages users actually install. Added the missing "license" field to: - root (carried LICENSE.md but never declared it in package.json) - @heimdall/common - @heimdall/cypress-tests Private workspaces (heimdall-server, @heimdall/common, @heimdall/password-complexity, @heimdall/cypress-tests) intentionally do not carry their own LICENSE.md — the repository root covers them. Duplicating the file into every workspace creates copies that drift, which is exactly how libs/hdf-converters ended up with a fragment missing the copyright line, the case number, and the Apache-2.0 grant. Authored by: Aaron Lippold --- apps/frontend/LICENSE.md | 33 +++++++++++++++++++++++++++++++++ libs/common/package.json | 1 + libs/inspecjs/LICENSE.md | 33 +++++++++++++++++++++++++++++++++ package.json | 1 + test/package.json | 1 + 5 files changed, 69 insertions(+) create mode 100644 apps/frontend/LICENSE.md create mode 100644 libs/inspecjs/LICENSE.md diff --git a/apps/frontend/LICENSE.md b/apps/frontend/LICENSE.md new file mode 100644 index 0000000000..6a712fb952 --- /dev/null +++ b/apps/frontend/LICENSE.md @@ -0,0 +1,33 @@ +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright/ digital rights legend, this list of conditions and the following Notice. + +- Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. + +- Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx diff --git a/libs/common/package.json b/libs/common/package.json index aa3f98f73a..1b596e569c 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -1,6 +1,7 @@ { "name": "@heimdall/common", "version": "2.13.0", + "license": "Apache-2.0", "description": "Common utilities and interfaces between the front and backends of Heimdall", "private": true, "repository": { diff --git a/libs/inspecjs/LICENSE.md b/libs/inspecjs/LICENSE.md new file mode 100644 index 0000000000..6a712fb952 --- /dev/null +++ b/libs/inspecjs/LICENSE.md @@ -0,0 +1,33 @@ +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright/ digital rights legend, this list of conditions and the following Notice. + +- Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. + +- Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx diff --git a/package.json b/package.json index 6291423cd3..fe84449ffe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "root", "version": "0.0.0", + "license": "Apache-2.0", "workspaces": [ "apps/*", "libs/*", diff --git a/test/package.json b/test/package.json index c92b9ba07a..fc85e13537 100644 --- a/test/package.json +++ b/test/package.json @@ -1,6 +1,7 @@ { "name": "@heimdall/cypress-tests", "version": "2.13.0", + "license": "Apache-2.0", "private": true, "description": "", "scripts": { From de204f7ec891d53cd0a5cb16d4521b32f23e5670 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:46:51 -0400 Subject: [PATCH 009/197] chore: complete package.json metadata across workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published packages (@mitre/heimdall-lite, @mitre/hdf-converters, inspecjs) now carry the metadata npm surfaces and warns about: - keywords, homepage, bugs, author ("MITRE Corporation", matching the convention already used by hdf-libs, mcp-config-scaffold, and mitre-slidev) - repository.url corrected to the git+https://...git form npm normalizes to. Writing it explicitly avoids a mismatch against the value npm stores, which is also what provenance verification compares against (npm/cli#7978). - publishConfig.access "public" on the two scoped packages. This was passed as a CLI flag in push-to-npm.yml; declaring it in the manifest means a scoped package cannot accidentally publish restricted. Descriptions added to the two workspaces that had none (root and @heimdall/cypress-tests). Private workspaces deliberately do NOT get keywords/homepage/bugs/author. Babel, Jest, and Vue all keep private workspace manifests minimal — those fields exist for npm discovery and only drift on packages that never publish. Descriptions are kept everywhere since they help navigation. LICENSE.md files added earlier are not listed in "files": npm always includes LICENSE*, README, and package.json in the tarball regardless. Authored by: Aaron Lippold --- apps/frontend/package.json | 20 +++++++++++++++++++- libs/hdf-converters/package.json | 21 ++++++++++++++++++++- libs/inspecjs/package.json | 14 +++++++++++++- package.json | 1 + test/package.json | 2 +- 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1b8c41d4b0..2de8af619d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,11 +3,29 @@ "version": "2.13.1", "license": "Apache-2.0", "description": "Heimdall is a JavaScript based security results viewer and review tool supporting multiple security results formats, such as: InSpec, SonarQube, OWASP-Zap, and Fortify which you can load locally or from S3 and other data sources.", + "keywords": [ + "security", + "compliance", + "inspec", + "hdf", + "ohdf", + "stig", + "sonarqube", + "fortify", + "owasp-zap", + "mitre-saf" + ], + "homepage": "https://github.com/mitre/heimdall2#readme", + "bugs": "https://github.com/mitre/heimdall2/issues", + "author": "MITRE Corporation", "repository": { "type": "git", - "url": "https://github.com/mitre/heimdall2", + "url": "git+https://github.com/mitre/heimdall2.git", "directory": "apps/frontend" }, + "publishConfig": { + "access": "public" + }, "files": [ "dist", "src/server.js" diff --git a/libs/hdf-converters/package.json b/libs/hdf-converters/package.json index 9c21cfd4de..fbeb2c11f3 100644 --- a/libs/hdf-converters/package.json +++ b/libs/hdf-converters/package.json @@ -3,11 +3,30 @@ "version": "2.13.0", "license": "Apache-2.0", "description": "Converter util library used to transform various scan results into HDF format", + "keywords": [ + "hdf", + "ohdf", + "inspec", + "converter", + "sarif", + "asff", + "checklist", + "xccdf", + "security", + "compliance", + "mitre-saf" + ], + "homepage": "https://github.com/mitre/heimdall2#readme", + "bugs": "https://github.com/mitre/heimdall2/issues", + "author": "MITRE Corporation", "repository": { "type": "git", - "url": "https://github.com/mitre/heimdall2", + "url": "git+https://github.com/mitre/heimdall2.git", "directory": "libs/hdf-converters" }, + "publishConfig": { + "access": "public" + }, "files": [ "lib" ], diff --git a/libs/inspecjs/package.json b/libs/inspecjs/package.json index a5cea3c5ef..6953b7e47b 100644 --- a/libs/inspecjs/package.json +++ b/libs/inspecjs/package.json @@ -3,9 +3,21 @@ "version": "2.13.0", "license": "Apache-2.0", "description": "Schema definitions, classes on top, and utilities to deal with HDF files", + "keywords": [ + "inspec", + "hdf", + "ohdf", + "heimdall", + "security", + "compliance", + "mitre-saf" + ], + "homepage": "https://github.com/mitre/heimdall2#readme", + "bugs": "https://github.com/mitre/heimdall2/issues", + "author": "MITRE Corporation", "repository": { "type": "git", - "url": "https://github.com/mitre/heimdall2", + "url": "git+https://github.com/mitre/heimdall2.git", "directory": "libs/inspecjs" }, "files": [ diff --git a/package.json b/package.json index fe84449ffe..94de8620c6 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "root", "version": "0.0.0", "license": "Apache-2.0", + "description": "Heimdall monorepo — security results viewer (server and lite), OHDF converters, and InSpecJS", "workspaces": [ "apps/*", "libs/*", diff --git a/test/package.json b/test/package.json index fc85e13537..df0d2e5577 100644 --- a/test/package.json +++ b/test/package.json @@ -3,7 +3,7 @@ "version": "2.13.0", "license": "Apache-2.0", "private": true, - "description": "", + "description": "Cypress end-to-end UI tests for Heimdall Server", "scripts": { "lint": "eslint --fix", "lint:ci": "eslint --max-warnings 0", From ff16bc279bb519524c2c9a57d132449f81ce1401 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 21:58:39 -0400 Subject: [PATCH 010/197] fix: make the pack-time manifest rewrite crash-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three published packages rewrite "main" from src/index.ts to lib/index.js in prepack and restore it in postpack. This is not a stylistic choice — npm has never supported manifest field overrides in publishConfig (it accepts only config keys: registry, access, tag, provenance), npm 13 will hard-error on unknown keys, and Yarn 1 reads only access and registry. Publishing a pre-built tarball rules out any publish-time fix as well, since `npm publish .tgz` takes the tarball byte-for-byte and never runs prepack. The rewrite must happen at pack time. The defect is that Yarn 1's pack has no try/finally around the lifecycle scripts, so a failed pack skips postpack entirely and strands a mutated package.json alongside an untracked package.json.orig. pack:all runs --parallel across three packages, so one failure can corrupt the tree. - gitignore package.json.orig so a failure cannot leave an untracked artifact - add a CI guard after pack:all that fails the workflow if any package.json was left modified or a .orig file remains, rather than publishing a tarball built from a corrupted tree Authored by: Aaron Lippold --- .github/workflows/push-to-npm.yml | 17 +++++++++++++++++ .gitignore | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/push-to-npm.yml b/.github/workflows/push-to-npm.yml index bf2b924a0b..55f6569eb6 100644 --- a/.github/workflows/push-to-npm.yml +++ b/.github/workflows/push-to-npm.yml @@ -33,6 +33,23 @@ jobs: - name: Pack all items that are published as packages run: yarn pack:all + # The published packages rewrite "main" from src/index.ts to lib/index.js + # in prepack and restore it in postpack. Yarn 1 has no try/finally around + # those lifecycle scripts, so a failed pack skips the restore and leaves a + # mutated manifest behind. Running --parallel across three packages means + # one failure can strand another package's manifest. Fail loudly here + # rather than publish a tarball built from a corrupted tree. + - name: Verify manifests were restored after pack + run: | + if ! git diff --exit-code -- '*package.json'; then + echo "::error::package.json was left modified after pack:all — a prepack rewrite was not restored by postpack. Do not publish from this tree." + exit 1 + fi + if git ls-files --others --exclude-standard | grep -q 'package.json.orig'; then + echo "::error::A stray package.json.orig remains after pack:all — postpack did not run." + exit 1 + fi + - name: Publish Heimdall Lite to NPM if: always() run: npx -y npm@latest publish --access public apps/frontend/mitre-heimdall-lite*.tgz diff --git a/.gitignore b/.gitignore index 799fa4e2ba..c7630119da 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,11 @@ certs/*.pem # Database Content data/* + +# prepack/postpack manifest backup. +# The published packages rewrite "main" from src/index.ts to lib/index.js at +# pack time (npm has never supported publishConfig manifest overrides, and +# npm 13 will hard-error on them, so the rewrite is the only option). +# Yarn 1's pack has no try/finally around the lifecycle scripts, so a failed +# pack skips postpack and strands this file next to a mutated package.json. +package.json.orig From cb40f420412dae9f7033a367e5a107ff414819b7 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 22:10:06 -0400 Subject: [PATCH 011/197] docs: add SECURITY.md and CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit heimdall2 carried only CODE_OF_CONDUCT.md, LICENSE.md, README.md, and CHANGELOG. OpenSSF Scorecard's Security-Policy check looks for a SECURITY.md with reporting contacts and a disclosure timeline — for a security compliance product shipping into DoD and FedRAMP environments, publishing no vulnerability-reporting policy was a conspicuous gap. Adapted from the SAF templates and rewritten for this stack rather than find-replaced. Both documents describe the actual toolchain: Yarn workspaces with lerna, NestJS + Sequelize, Vue 2, vitest (with the note that the test runners use swc and do not typecheck, so `yarn backend build` is a separate gate), and the workspace-scoped lint scripts. SECURITY.md records deployment guidance drawn from how Heimdall actually behaves: TLS must terminate in front of it because Helmet emits headers but cannot enforce transport, API key support is disabled when API_KEY_SECRET is unset, a lost API key must be regenerated rather than recovered, and evaluation data may contain scanned-host detail. CONTRIBUTING.md documents the repository layout and the converter contribution path, which is the most common kind of outside contribution. Closes heimdall2-30c.3 Authored by: Aaron Lippold --- CONTRIBUTING.md | 181 ++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 119 +++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..b423c842b2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing to Heimdall + +Thank you for considering a contribution. Heimdall is used to review security +and compliance results across a lot of very different environments, so +correctness and clear reporting matter more here than speed. + +## Code of Conduct + +This project follows the [Code of Conduct](CODE_OF_CONDUCT.md). By +participating you agree to uphold it. + +## Reporting Bugs + +Open a [GitHub issue](https://github.com/mitre/heimdall2/issues) and include: + +- **What you expected** and **what happened instead** +- **Steps to reproduce** +- **Which component** — Heimdall Server, Heimdall Lite, `@mitre/hdf-converters`, + or `inspecjs` +- **Environment details** — OS, Node version, browser, deployment method + (Docker, RPM, or source) +- **A sample input file** where relevant, with anything sensitive removed + +For converter bugs, the input file is usually the single most useful thing you +can attach. Scan output frequently contains hostnames and configuration +detail — sanitize before sharing, or send it privately. + +## Reporting Security Issues + +**Do not open a public issue.** See [SECURITY.md](SECURITY.md) for the private +reporting process. + +## Development Process + +### Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork**: + ```bash + git clone git@github.com:your-username/heimdall2.git + cd heimdall2 + ``` +3. **Add the upstream remote**: + ```bash + git remote add upstream git@github.com:mitre/heimdall2.git + ``` +4. **Create a feature branch**: + ```bash + git switch -c feature/your-feature-name + ``` + +### Development Setup + +Heimdall is a Yarn workspaces monorepo managed with lerna. Node >= 22.18.0 is +required (see `engines`). + +1. **Install dependencies** from the repository root: + ```bash + yarn install + ``` + +2. **Set up the database** (Heimdall Server only — Heimdall Lite needs none): + ```bash + cp apps/backend/.env-example apps/backend/.env + yarn backend sequelize db:create + yarn backend sequelize db:migrate + ``` + +3. **Start in development mode**: + ```bash + yarn start:dev + ``` + +Workspace commands are proxied from the root — `yarn backend `, +`yarn frontend `, `yarn hdf-converters `, `yarn inspecjs `, +`yarn common `. + +### Repository Layout + +| Path | Contents | +|---|---| +| `apps/backend` | NestJS API server, Sequelize models, authentication | +| `apps/frontend` | Vue 2 application — also published as Heimdall Lite | +| `libs/hdf-converters` | Converters between scan formats and OHDF | +| `libs/inspecjs` | OHDF schema definitions and helpers | +| `libs/common` | Types shared between front and back end | +| `libs/password-complexity` | Password rule checks shared by both | +| `packaging/rpm` | RPM spec, systemd unit, SELinux policy | +| `test` | Cypress end-to-end UI tests | + +### Making Changes + +1. **Follow existing patterns.** Read the surrounding code before introducing a + new approach — matching what is already there is usually better than + importing a pattern from elsewhere. + +2. **Write tests first.** Every change to behaviour needs a test that fails + before the change and passes after. + ```bash + yarn backend test:ci + yarn frontend test:ci + yarn hdf-converters test:ci + yarn inspecjs test:ci + ``` + +3. **Typecheck separately.** The test runners use swc and **do not typecheck**: + ```bash + yarn backend build + ``` + +4. **Lint.** Use the workspace-scoped scripts: + ```bash + yarn backend lint:ci + yarn frontend lint:ci + ``` + Do not silence security-plugin findings with disable comments — fix the code. + +5. **Update documentation** — the README for user-facing changes, and + `CHANGELOG` for anything notable. + +### Adding a Converter + +New format support is the most common contribution. A converter needs: + +- The mapper in `libs/hdf-converters/src/` +- A sample input under `libs/hdf-converters/sample_jsons/_mapper/sample_input_report/` +- Expected OHDF output alongside it, committed as a fixture +- A test in `libs/hdf-converters/test/mappers/forward/` + +Use **real scan output** as the sample where you can, sanitized of hostnames and +customer detail. Synthetic input is acceptable when a real sample would be +excessive, but it must reflect the format accurately. + +### Commit Messages + +Use conventional prefixes — `feat:`, `fix:`, `test:`, `docs:`, `chore:`, +`build:`, `refactor:` — with a body explaining *why*, not just what. + +``` +feat: add support for Foo scanner output + +- Maps Foo severity levels onto OHDF impact +- Handles the multi-result form Foo emits for grouped checks +``` + +### Pull Requests + +1. Rebase on the latest `master` +2. Confirm the full suite passes and the build typechecks +3. Describe what changed and how you verified it +4. Link any related issue + +Draft PRs are welcome if you would like feedback before the work is finished. + +## Style Guidelines + +### TypeScript / JavaScript + +ESLint and Prettier are authoritative: + +```bash +yarn lint # fix +yarn lint:ci # check +``` + +- Prefer explicit types on exported functions +- Avoid `any` — if the type is genuinely unknown, use `unknown` and narrow +- Handle errors explicitly; do not swallow them in a bare `catch` + +### Vue + +- Follow the Vue 2 style guide and the conventions already in `apps/frontend` +- Keep components focused — extract shared logic rather than duplicating it + +## Getting Help + +- [GitHub Discussions](https://github.com/mitre/heimdall2/discussions) +- [Wiki](https://github.com/mitre/heimdall2/wiki) +- [GitHub Issues](https://github.com/mitre/heimdall2/issues) + +Thank you for contributing to Heimdall. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..fd3a84b85a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,119 @@ +# Security Policy + +## Reporting Security Issues + +The MITRE SAF team takes security seriously. If you discover a security +vulnerability in Heimdall, please report it responsibly. + +### Contact Information + +- **Email**: [saf-security@mitre.org](mailto:saf-security@mitre.org) +- **GitHub**: Use the [Security tab](https://github.com/mitre/heimdall2/security) + to report vulnerabilities privately + +Please do not open a public issue for a security vulnerability. + +### What to Include + +1. **Description** of the vulnerability +2. **Steps to reproduce** the issue +3. **Potential impact** assessment +4. **Affected component** — Heimdall Server, Heimdall Lite, or one of the + published libraries (`@mitre/hdf-converters`, `inspecjs`) +5. **Suggested fix** (if you have one) + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Initial Assessment**: Within 7 days +- **Fix Timeline**: Varies by severity + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| Latest release | ✅ Yes | +| Older releases | ❌ No — upgrade to the latest release | + +Heimdall ships as a Docker image, an RPM, and npm packages. Security fixes are +issued against the latest release of each. + +## Security Best Practices + +### For Deployers + +- **Terminate TLS in front of Heimdall.** The application sets HSTS and CSP + headers via Helmet, but headers cannot enforce transport. Run it behind a + TLS reverse proxy; serving it over plain HTTP will also break asset loading. +- **Use enterprise authentication.** Heimdall supports LDAP, OIDC, GitHub, + GitLab, Google, and Okta. Prefer these over local accounts in production. +- **Protect the environment file.** `DATABASE_PASSWORD`, `JWT_SECRET`, and + `API_KEY_SECRET` live there. Restrict it to the service account. +- **Set `API_KEY_SECRET` if API keys are enabled.** API key support is + disabled when it is unset — do not deploy with a placeholder value. +- **Use database TLS.** Configure `DATABASE_SSL` and the associated + certificate settings for connections that leave the host. +- **Scope evaluation visibility.** Evaluations can be public, group-scoped, or + private. Review group membership before importing sensitive scan results. + +### For Contributors + +- **Dependency scanning**: run `yarn audit` before submitting a PR +- **Credential handling**: never log or expose credentials, tokens, or + evaluation contents +- **Input validation**: validate at the trust boundary — DTO/pipe layer for + API input, and parameterize every database query +- **No linter suppressions for security rules**: the ESLint security plugin + findings must be fixed in code, not disabled +- **Test security behaviour**: authorization changes need tests covering the + denied path, not just the allowed one + +## Security Testing + +```bash +# Full test suites +yarn backend test:ci +yarn frontend test:ci + +# Type checking (the test runners do not typecheck) +yarn backend build + +# Lint, including the security ruleset +yarn backend lint:ci +yarn frontend lint:ci + +# Vulnerable dependency check +yarn audit +``` + +Container images are scanned with Syft/Grype in CI (see +`.github/workflows/anchore-syft.yml`). + +## Known Security Considerations + +### Authentication and Authorization + +- Local passwords are stored as salted, iterated hashes — never in plaintext +- Password complexity is enforced at 15 characters with all four character + classes and no run of four or more from a single class +- Authorization uses CASL ability rules; evaluation and group access is + checked per request rather than at the route level alone +- The login endpoint is rate limited per IP; there is no per-account lockout + +### API Keys + +- API keys are JWTs; only a hash of the signature is stored server-side +- **A lost API key cannot be recovered — it must be regenerated** +- API key support is disabled entirely when `API_KEY_SECRET` is unset + +### Data Protection + +- Evaluation data may contain hostnames, configuration detail, and finding + evidence from scanned systems — treat the database and its backups as + sensitive +- Use TLS for all external connections + +### Container Security + +- Images are based on Red Hat UBI and run as a non-root user +- Keep base images updated and rescan on rebuild From 3be08145f74a889b96d0abe90add6a531ce36871 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 22:22:21 -0400 Subject: [PATCH 012/197] =?UTF-8?q?docs:=20revise=20ADR-006=20=E2=80=94=20?= =?UTF-8?q?correct=20the=20FIPS-mode=20approach,=20add=20credential=20reco?= =?UTF-8?q?very?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second revision, folding in the second review round, the empirical UBI9 findings, and the now-real repository topology. Corrections to my own prior draft: - --force-fips REVERSED. RHEL's Node prints "Using options related to FIPS is not recommended, configure FIPS in openssl instead." The RHEL model is host FIPS -> OpenSSL -> Node inherits. This deletes the launcher preflight, the start:fips script, and the systemd crash-loop concern entirely, and makes the startup assertion the only real check. - The IG block quote is 2.4.C, not 2.4.A, and the ellipsis had dropped "and/or AES XTS". 2.4.C also turns out to SUPPORT the design: it says the module service "may still be considered approved" when a caller uses PBKDF outside storage applications, making this a documentation obligation rather than a design defect. - V-222571 is now quoted in full. The prior draft quoted 172 of 1,238 characters and called it verbatim, omitting the risk-acceptance path and the NSS clause -- which makes PASSWORD_HASH_ALGORITHM=sha256 itself a finding on NSS. - silent:true does not exist on sequelize.query(); it is a Model.update option. Corrected to the single Model.update form. - #4746 sunsets 2026-07-30; only #4857 is current. - The §6/§9 iteration-floor contradiction is resolved: floors and caps apply to hashing only, never verification. - The §7 regression AC was unsatisfiable — updateLoginMetadata changes lastLogin/loginCount/updatedAt on every login by design. Narrowed to passwordChangedAt and forcePasswordChange. New scope: - Credential recovery. Heimdall has no self-service reset and admins cannot edit their own account without their password, so enabling FIPS before cutover would lock out a single-admin deployment. heimdall-cli reset-password is the remedy — but it writes bcrypt, so adding PBKDF2 to it is a BLOCKING cross-repo dependency, not a follow-up. - Phase 2 now invalidates credentials rather than only setting forcePasswordChange, which converted nothing. - Configurable password complexity, using the env var names heimdall-cli already reads. The CLI and the app silently disagree today about what a valid password is. - Repository boundary documented now that it is real: packaging/rpm in-tree, heimdall-cli at github.com/mitre/heimdall-cli, bound by libs/password-hash-vectors with a formatVersion stamp. Findings folded in: the eighth call site (the admin seeder, which runs on every container start), the getFips seam, the 256-character external-auth placeholder that the length cap would have broken, compare-and-swap writes, the write gate, requiresReset as an enumeration oracle, and the app-side version check replacing an RPM %pre guard that cannot fire on downgrade. Unverified citations resolved: IA-7 dropped (it governs authenticating to a module, not password verification), V-16793 dropped (retired in 2016), zeroization dropped (IG 9.6.A exempts hashed passwords), SI-6 and IR 8547 qualified, CMVP MM §7.9 promoted and it supports us. Authored by: Aaron Lippold --- ...adr-006-fips-validated-password-hashing.md | 1122 ++++++++++++----- 1 file changed, 798 insertions(+), 324 deletions(-) diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index c4c4a1c10e..adc552b7ee 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -5,182 +5,313 @@ **Author:** Aaron Lippold **Branch:** `feature/fips-compliant-password-hashing` **Base:** `master` @ `2e1649c9e` +**Epic:** `heimdall2-e25` -> **On the title.** This document deliberately avoids the phrase "FIPS compliant." -> FedRAMP *Policy for Cryptographic Module Selection and Use* v1.1.0 (approved -> 2025-01-16), rule **FRR8**, states that representations "must use terminology -> approved by NIST" and that CSPs "must not use ambiguous or CSP-defined terms -> such as 'FIPS compliant.'" The accurate claim is that password hashing is -> performed by a FIPS 140-3 **validated module**. That phrasing is used throughout. +> **On the title.** This document avoids the phrase "FIPS compliant." FedRAMP +> *Policy for Cryptographic Module Selection and Use* v1.1 (approved +> 2025-01-16), rule **FRR8**: representations "must use terminology approved by +> NIST" and CSPs "must not use ambiguous or CSP-defined terms such as 'FIPS +> compliant.'" The accurate claim is that hashing is performed **by a FIPS +> 140-3 validated module**. ## Evidence standard -Every normative claim below is marked: +Every normative claim is marked **[V]** (verified against a primary source — +NIST/CMVP PDF, DISA STIG API, vendor source, or a direct read of this +repository) or **[U]** (unverified; not load-bearing; must not appear in an +SSP or POA&M without confirmation). -- **[V]** — verified this session against a primary source (NIST/CMVP PDF, DISA STIG API, vendor source or docs, or a direct read of this repository at `2e1649c9e`). -- **[U]** — plausible but **unverified**. Not load-bearing. Must be confirmed before it appears in any SSP, POA&M, or assessor-facing artifact. +This exists because an earlier review of this ADR produced confident, +well-formatted citations — STIG check text, CMVP guidance, FedRAMP rule IDs — +that had **never been read**. The reviewer retracted them, and on retraction +discovered its own central argument ran backwards. Independent verification +later confirmed most of the substance and **refuted one key citation**, which +is corrected in §3. -This standard exists because an earlier review pass of this ADR produced confident, well-formatted citations — STIG check text, CCI numbers, CMVP guidance sections, FedRAMP rule IDs — that had **never been read**. The reviewer subsequently retracted them. Independent verification against primary sources later confirmed most of the substance and **refuted one central argument** (see §3 on IG 2.4.A, which ran opposite to how it was first cited). +Two rules follow for anyone extending this: -The process failure is instructive and worth stating plainly: **an unsourced citation is worthless regardless of how correct it sounds, and a plausible-sounding one is worse than none — it survives review.** Nothing marked [V] below rests on recollection; each was read this session, and where two reviewers disagreed the primary source decided it. - -Two consequences for anyone extending this document: - -- **Do not promote a [U] to [V] without reading the source.** The [U] list is short and specific; it is not a formality. -- **Assessor-facing artifacts must cite only [V] items.** SP 800-53A Rev 5's SC-13 assessment objects explicitly include "cryptographic module validation certificates; list of FIPS-validated cryptographic modules," which is exactly the class of claim that was fabricated the first time. +- **Do not promote a [U] to [V] without reading the source.** +- **Assessor-facing artifacts cite only [V] items.** SP 800-53A Rev 5's SC-13 + assessment objects explicitly include "cryptographic module validation + certificates; list of FIPS-validated cryptographic modules" **[V]** — exactly + the class of claim that was fabricated the first time. ## Context -Heimdall2 hashes passwords with bcrypt via `bcryptjs` (pure JavaScript, cost factor 14) and stores API keys as bcrypt hashes of JWT signatures. - -**The core problem is boundary, not strength.** bcrypt at cost 14 is cryptographically strong. But `bcryptjs` is pure JavaScript — it never calls `node:crypto` or OpenSSL, so on a FIPS-enabled host it executes *undetected and unblocked*, entirely outside the validated module. **[V]** (verified by reading the package; Chainguard's `node-fips` image documentation flags it by name). +Heimdall hashes passwords with bcrypt via `bcryptjs` (pure JavaScript, cost 14) +and stores API keys as bcrypt hashes of JWT signatures. -**What this actually costs us, stated precisely.** An earlier draft of this ADR overstated the exposure. Corrected: +**The problem is boundary, not strength.** bcrypt at cost 14 is strong. +`bcryptjs` is pure JavaScript — it never calls `node:crypto` or OpenSSL, so on +a FIPS-enabled host it runs *undetected and unblocked*, entirely outside the +validated module **[V]**. -- **V-222542** ("must only store cryptographic representations of passwords", **CAT I**, CCI-004062 / CCI-000196) requires "strong cryptographic hash functions" with a random salt and prohibits MD5. **The phrase "FIPS-validated" does not appear in its title, description, check text, or fix text.** bcrypt already satisfies it today. **[V]** — DISA STIG API. -- **V-222571** ("must use FIPS-validated cryptographic modules when generating hashes", **CAT II**, CCI-002450) is the rule we actually fail — and its finding condition is **invocation-scoped**, not storage-scoped. **[V]** -- **V-222572** ("must utilize FIPS-validated cryptographic modules when protecting unclassified information that requires cryptographic protection", **CAT II**, CCI-002450) is the closest general FIPS-invocation rule. The prior draft omitted it entirely. **[V]** +**What this actually costs, stated precisely** — an earlier draft overstated it: -So the honest framing is: **two CAT II findings, not a CAT I failure.** That is still worth fixing — but the ADR must not overstate it to an assessor. +- **V-222542** ("must only store cryptographic representations of passwords", + **CAT I**, CCI-004062/CCI-000196) requires "strong cryptographic hash + functions" with a random salt and prohibits MD5. **The phrase "FIPS-validated" + appears nowhere in the rule.** bcrypt already satisfies it. **[V]** +- **V-222571** (**CAT II**, CCI-002450) is the rule we fail, and its finding + condition is **invocation-scoped**. **[V]** +- **V-222572** (**CAT II**, CCI-002450) — "must utilize FIPS-validated + cryptographic modules when protecting unclassified information." Omitted from + the prior draft. **[V]** -**Supporting control.** SP 800-53A Rev 5 **IA-5(1)(d)**: "for password-based authentication, passwords are stored using an **approved salted key derivation function**, preferably using a keyed hash." **[V]** PBKDF2-HMAC fits this text more directly than bcrypt does. This is the strongest affirmative control for the change and the prior draft never cited it. +So: **two CAT II findings, not a CAT I failure.** Worth fixing; not worth +overstating to an assessor. -### Prior art in this repository +**Supporting control.** SP 800-53A Rev 5 **IA-5(1)(d)**: "for password-based +authentication, passwords are stored using an **approved salted key derivation +function**, preferably using a keyed hash." **[V]** PBKDF2-HMAC fits this text +more directly than bcrypt. This is the strongest affirmative control and the +prior draft never cited it. -**`fips_compliance` branch (2023):** `--force-fips` startup, Postgres `scram-sha-256`, and `libs/common/crypto/crypto.ts` (**note: no `src/` segment** — the prior draft's path was wrong) implementing PBKDF2-SHA256 @ 600k with a `useBCrypt` flag. Defects: synchronous `pbkdf2Sync`, no self-describing format, `===` comparison rather than `timingSafeEqual`, iterations hardcoded twice. **[V]** +### Comparable projects, scoped to FIPS mode -**Heimdall v3 (`a52f6ceb`, `mitre/heimdall`):** PBKDF2-SHA512, format `pbkdf2-sha512$iterations$salt$key`, async, `timingSafeEqual`, env-configurable. Hard-rejects legacy hashes. Returns a bare boolean because better-auth's `verify` contract requires it. **[V]** +The prior draft surveyed seven projects, concluded "five of seven use lazy +rehash," and justified an unconditional bcrypt fallback with it. **That survey +measured non-FIPS behavior.** Corrected: -### What comparable projects do **in FIPS mode** - -The prior draft surveyed seven projects, concluded "five of seven use lazy rehash — it is industry standard," and used that to justify an unconditional bcrypt fallback. **That survey measured non-FIPS migration behavior and imported the conclusion into a FIPS document.** Corrected, scoped to FIPS mode specifically: - -| Project | FIPS-mode behavior | Verified | +| Project | In FIPS mode | Verified | |---|---|---| -| **Keycloak** | **Refuses.** `Argon2PasswordHashProviderFactory.isSupported()` returns false under FIPS; the provider never registers, so `verify()` is never reached. Docs: affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." | **[V]** source + docs | -| **GitLab** | **Gates on FIPS mode**, migrates lazily, with a forced-reset endgame. Docs: "Bcrypt: Used by default. **PBKDF2+SHA512: Used when FIPS mode is enabled.**" Concedes "these passwords cannot be re-encrypted without user help." Issue **#360659** — "Force password resets for users with bcrypt login passwords." Ships `gitlab:password:check_hashes`. | **[V]** docs + issues | -| **Mattermost** | Migrates lazily, **and its documentation is inaccurate.** `bcrypt.go` carries no `//go:build` FIPS exclusion, so pure-Go bcrypt compiles into and runs inside the FIPS build — while its FIPS/STIG doc claims "All application-level code uses only FIPS-approved algorithms." | **[V]** source + docs | +| **Keycloak** | **Refuses.** Provider never registers; affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." | **[V]** source + docs | +| **GitLab** | **Gates on FIPS mode.** "Bcrypt: Used by default. **PBKDF2+SHA512: Used when FIPS mode is enabled.**" Concedes bcrypt hashes "cannot be re-encrypted without user help." Issue **#360659** — "Force password resets for users with bcrypt login passwords" (closed 2022-07-27). | **[V]** | +| **Mattermost** | Lazy migration, **and its documentation is inaccurate** — `bcrypt.go` has no FIPS build-tag exclusion while its FIPS/STIG doc claims "All application-level code uses only FIPS-approved algorithms." | **[V]** source + docs | -**The prior draft's §3 was weaker than both Keycloak and GitLab** — ungated, unconditional, no terminal state — i.e. it reproduced Mattermost's posture in a document asserting the opposite. That is the single most important correction in this revision. +The prior draft's §3 was **weaker than both Keycloak and GitLab** — ungated, +unconditional, no terminal state — i.e. it reproduced Mattermost's posture in a +document asserting the opposite. That is the central correction here. -### The Grafana lesson (unchanged, and still the reason for the format choice) +### The Grafana lesson -Grafana has used PBKDF2 since inception — technically the right algorithm — and remains at **10,000 iterations** with no upgrade path, because the parameters were never encoded in the stored hash. **[V]** Encoding parameters is structural, not cosmetic. +Grafana has used PBKDF2 since inception and remains at **10,000 iterations** +with no upgrade path, because parameters were never encoded in the stored hash +**[V]**. Encoding parameters is structural, not cosmetic. ## Decision -### 1. PBKDF2 via `node:crypto`, with a correctly-scoped justification - -**Parameters:** PBKDF2-HMAC-SHA-512 (default; `sha256`/`sha384` selectable), 600,000 iterations, 32-byte salt from `crypto.randomBytes()`, derived key matching digest width. +### 1. PBKDF2 via `node:crypto` -**Why this is an approved operation — the argument the prior draft got wrong.** +**Parameters:** PBKDF2-HMAC-SHA-512 (default; `sha256`/`sha384` selectable), +600,000 iterations, 32-byte salt from `crypto.randomBytes()`, derived key +matching digest width. -The prior draft argued "PBKDF2 is the only NIST-approved password KDF, therefore compliant." That **overstates**, because SP 800-132's approval is scope-limited: +**Why this is an approved operation.** The prior draft argued "PBKDF2 is the +only NIST-approved password KDF, therefore compliant." That **overstates**: -- SP 800-132 §4: the derived Master Key "is used either 1) to generate one or more Data Protection Keys (DPKs) to protect data, or 2) to generate an intermediate key to protect one or more existing DPKs... **The MK shall not be used for other purposes.**" **[V]** -- FIPS 140-3 **IG §D.N**: "the vendor shall indicate in the module's Security Policy that keys derived from passwords, as shown in SP 800-132, **may only be used in storage applications.**" Every RHEL OpenSSL/libgcrypt/NSS security policy examined repeats this caveat verbatim. **[V]** +- SP 800-132 §4: the derived Master Key is for generating Data Protection Keys; + "**The MK shall not be used for other purposes.**" **[V]** +- IG **§D.N**: password-derived keys "**may only be used in storage + applications.**" Every RHEL security policy examined repeats this. **[V]** -Password *verification* by hash-and-compare is not "a storage application" in SP 800-132's sense. So the SP 800-132 lineage alone does not carry the claim. +Hash-and-compare verification is not "a storage application" in that sense. -**But the module remains approved, and CMVP says so explicitly.** IG 2.4.A anticipates precisely this situation **[V]**: +**But the module remains approved, and CMVP says so.** IG **2.4.C** anticipates +exactly this **[V]**: -> "If the module operator (e.g., calling application) can do things outside of the module's control/visibility that can take an otherwise approved algorithm and use it in a non-approved way (**e.g., use PBKDF ... outside of storage applications**), the corresponding module service **may still be considered approved** ... and the Security Policy shall clarify how to use the service in an approved manner." +> "If the module operator (e.g., calling application) can do things outside of +> the module's control/visibility that can take an otherwise approved algorithm +> and use it in a non-approved way (e.g., use PBKDF **and/or AES XTS** outside +> of storage applications), the corresponding module service **may still be +> considered approved** ... and the Security Policy shall clarify how to use the +> service in an approved manner." -So this is a **documentation obligation, not a design defect.** Two things follow: +So this is a **documentation obligation, not a design defect**. Two consequences: -1. **The argument that actually satisfies V-222571** is that the underlying **HMAC-SHA-512 primitive executes inside the validated module**, and secure hashing is an approved security function. PBKDF2's iteration structure is a *construction over* an approved primitive, not an appeal to SP 800-132's key-derivation scope. -2. **The SSP must state this reasoning rather than assume it**, and disclose the storage-application scope limit. Do not cite SP 800-132 as blanket authorization for password verification. +1. **What actually satisfies V-222571** is that the **HMAC-SHA-512 primitive + executes inside the validated module**; secure hashing is an approved + security function. PBKDF2's iteration structure is a construction *over* an + approved primitive, not an appeal to SP 800-132's key-derivation scope. +2. **The SSP must state this reasoning** and disclose the storage-application + scope limit. -**Our parameters clear every bound the module actually enforces [V]** — security policy `140sp4857.pdf`: salt ≥ 128 bits generated by the SP 800-90Ar1 DRBG, iterations ≥ 1000, derived key ≥ 112 bits. We use a 256-bit salt from `crypto.randomBytes` (which routes through that DRBG under FIPS), 600,000 iterations, and a 512-bit key. Note the same policy lists "PBKDF2 (short password; short salt; insufficient iterations; < 112-bit keys)" as a **non-approved service** — the failure mode is under-parameterisation, which we are well clear of. +**Our parameters clear every bound the module enforces [V]** — policy +`140sp4857.pdf`: salt ≥128 bits from the SP 800-90Ar1 DRBG, iterations ≥1000, +derived key ≥112 bits. The same policy lists "PBKDF2 (short password; short +salt; insufficient iterations; <112-bit keys)" as a **non-approved service** — +the failure mode is under-parameterisation, which we are well clear of. -**On password strength:** IG §D.N states that "**SP 800-132 does not impose any strictly defined requirements on the strength of a password**," only that passwords "should be strong enough so that it is infeasible for attackers to get access by guessing." **[V]** An earlier draft claimed a 112-bit floor implied a 14-character minimum. That was wrong — the "14" originates in a BouncyCastle-FIPS byte-length check, which Keycloak works around by *padding* short passwords, proving it mechanical rather than an entropy requirement. Heimdall's existing 15-character minimum is good practice; it is not a FIPS obligation and should not be presented as one. +**On password strength:** IG §D.N states "**SP 800-132 does not impose any +strictly defined requirements on the strength of a password**" **[V]**. An +earlier draft claimed a 112-bit floor implied a 14-character minimum. Wrong — +the "14" is a BouncyCastle byte-length check that Keycloak works around by +*padding*. Heimdall's 15-character minimum is good practice, not a FIPS +obligation, and must not be presented as one. -**On 600,000 iterations.** OWASP 2024 gives 210,000 for SHA-512. We use 600,000 — see the measured performance data in §11, which materially changes the trade-off the prior draft described. - -### 2. PHC string format (unchanged — this part was right) +### 2. PHC string format ``` $pbkdf2-sha512$i=600000$$ ``` -Per the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md), matching npm `phc-pbkdf2`. Standard base64 alphabet, padding stripped. PHC is a strict *subset* of Modular Crypt Format; bcrypt's `$2b$14$…` is valid MCF but invalid PHC — both coexist in one column and are distinguished on parse. - -The leading `$` is load-bearing: `$` appears in no base64 alphabet (`A-Za-z0-9+/=`) nor in bcrypt's radix-64 (`./A-Za-z0-9`), so dispatch is an unambiguous lookup on `parts[1]` — `'2b'` vs `'pbkdf2-sha512'`. **[V]** It also admits `$argon2id$v=19$m=…` unchanged if NIST approves Argon2. +Per the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md), +matching npm `phc-pbkdf2`. Standard base64, padding stripped. PHC is a strict +*subset* of Modular Crypt Format; bcrypt's `$2b$14$…` is valid MCF, invalid PHC. +Both coexist in one column. -**Storage width [V]:** the string is **154 characters** (24 + 43-char salt + 1 + 86-char key). Both `Users.encryptedPassword` (migration `20200417145649`) and `ApiKeys.apiKey` (migration `20210615141642`) are `Sequelize.STRING` = `VARCHAR(255)` in *both* model and migration — **101 characters of headroom**, no `VARCHAR(60)` anywhere. Postgres *errors* on overflow rather than truncating, so failure would be loud — but it would fire **inside the login path**. An AC asserts output ≤ 255 for all three digests. +The leading `$` is load-bearing: `$` appears in no base64 alphabet nor in +bcrypt's radix-64 **[V]**, so dispatch is an unambiguous lookup on `parts[1]`. +It also admits `$argon2id$v=19$m=…` unchanged if NIST approves Argon2. -### 3. Migration: FIPS-gated fallback with a terminal state +**Storage width [V]:** 154 characters. Both `Users.encryptedPassword` and +`ApiKeys.apiKey` are `VARCHAR(255)` in model *and* migration — 101 characters +of headroom, no `VARCHAR(60)` anywhere. Postgres *errors* on overflow rather +than truncating, but it would fire **inside the login path**, so an AC asserts +output ≤255 for all three digests. -**This section replaces the prior draft's unconditional fallback, which was its central defect.** +### 3. Migration: FIPS-gated fallback with a real terminal state -`verifyPassword` dispatches on stored format **and on FIPS state**: +`verifyPassword` dispatches on stored format **and FIPS state**: | Stored prefix | FIPS off | FIPS on (`getFips() === 1`) | |---|---|---| | `$pbkdf2-sha{256,384,512}$` | verify, `needsRehash: false` | verify, `needsRehash: false` | -| `$2a$` / `$2b$` / `$2y$` | `bcryptjs.compare()`; `needsRehash: valid` | **refuse — do not invoke bcryptjs**; return `{valid: false, requiresReset: true}` | +| `$2a$`/`$2b$`/`$2y$` | `bcryptjs.compare()`; `needsRehash: valid` | **refuse — do not invoke bcryptjs**; `{valid: false, needsRehash: false, requiresReset: true}` | | anything else | reject without throwing | reject without throwing | -**Why the gate is required — the ASD STIG, and only the ASD STIG.** - -An earlier version of this section rested on CMVP **IG 2.4.A** ("non-approved security functions shall not be used in the approved mode of operation"). **That argument is withdrawn — it was backwards.** IG 2.4.A scopes to functions "**within the scope of the module**," i.e. it governs what the validated module itself offers in approved mode. `bcryptjs` is not within OpenSSL's boundary at all, so 2.4.A never reaches it. IG 2.4.A *example 1* in fact lists "store authentication data using MD5 or using HMAC-SHA-1 with a weak HMAC key" among non-approved algorithms **permitted** in approved mode where no security is claimed of the module. **[V]** — direct PDF read. - -**The FIPS 140-3 standard does not, by itself, prohibit calling bcryptjs.** CMVP validates modules, not applications (FIPS 140-3 §9), and nothing the calling application does voids OpenSSL's certificate. - -**The prohibition is application-scoped and comes from the STIG**, which is sufficient on its own. V-222571's check text, verbatim **[V]**: - -> "If FIPS-validated cryptographic modules are **not used when generating hashes** or if the application is configured to use the MD5 or SHA1 hashing algorithm, this is a finding." - -`bcryptjs.compare()` *generates* a bcrypt hash of the candidate password, in pure JavaScript, inside no validated module. That meets the finding condition literally. V-222572 (CCI-002450) applies on the same basis. - -So the accurate claim is narrow and defensible: **an ungated bcrypt call in a deployment asserting FIPS is a CAT II STIG finding, not a FIPS 140-3 violation.** That is still worth designing around — and it is exactly why Keycloak refuses and GitLab gates on FIPS mode. - -**SP 800-131A does not apply either way.** Its "legacy use" doctrine covers verify-only continuation of algorithms that were *once* NIST-approved. Bcrypt never was. **[V]** — searched, zero hits for "bcrypt" or "password." - -**The migration therefore has three phases, and a defined end:** - -1. **Non-FIPS operation** — lazy rehash on login. Users migrate transparently, no disruption. -2. **Cutover** — a migration script sets `forcePasswordChange = true` on every remaining `$2%` row. This is GitLab's #360659 and it is what the prior draft's vague "eventual forced reset required" must become. -3. **FIPS enablement** — by this point no bcrypt hashes remain, so the gate never fires in normal operation. If one is encountered anyway, the user is told to reset. +#### Why the gate is required — the STIG, and only the STIG + +An earlier version rested on CMVP **IG 2.4.A**. **That argument is withdrawn — +it ran backwards.** IG 2.4.A scopes to functions "**within the scope of the +module**"; `bcryptjs` is not within OpenSSL's boundary, so 2.4.A never reaches +it. IG 2.4.A *example 1* in fact lists "store authentication data using MD5" +among non-approved algorithms **permitted** in approved mode where no security +is claimed of the module — subject to that example's own conditions (no +security claimed; the result is "considered unprotected plaintext"). **[V]** + +**FIPS 140-3 does not itself prohibit calling bcryptjs.** CMVP validates +modules, not applications; nothing the caller does voids OpenSSL's certificate. + +**The prohibition is application-scoped and comes from the STIG.** V-222571's +check text, in full **[V]**: + +> "If FIPS-validated cryptographic modules are **not used when generating +> hashes** or if the application is configured to use the MD5 or SHA1 hashing +> algorithm, this is a finding. +> +> **If hashing of application components has been identified in the application +> security plan as not being required and if a documented acceptance of risk is +> provided, this is not a finding.** +> +> **If the application resides on a National Security System (NSS) and uses an +> algorithm weaker than SHA-384, this is a finding.**" + +The prior draft quoted only the first sentence and labelled it "verbatim." Two +consequences of the full text: + +- A documented **risk acceptance** is an available path. We are not taking it, + but an assessor knows it exists. +- **On NSS, `PASSWORD_HASH_ALGORITHM=sha256` is itself a finding.** §9 must + carry that warning. + +`bcryptjs.compare()` *generates* a bcrypt hash in pure JS inside no validated +module — the finding condition, literally. So the accurate claim is narrow: +**an ungated bcrypt call in a deployment asserting FIPS is a CAT II STIG +finding, not a FIPS 140-3 violation.** That is why Keycloak refuses and GitLab +gates. + +**SP 800-131A does not apply** — its legacy-use doctrine covers algorithms that +were *once* approved. Bcrypt never was. **[V]** (verified for Rev 2; Rev 3 ipd +adds PBKDF language, so pin the revision when citing). + +#### Three phases, with an end + +1. **Non-FIPS operation** — lazy rehash on login. Transparent, no disruption. +2. **Cutover** — a migration **invalidates** every remaining `$2%` credential: + overwrite `encryptedPassword` with an unusable sentinel *and* set + `forcePasswordChange`. Setting the flag alone does **not** convert a hash — + the user would still have to log in (refused under FIPS) and submit their + old password (verified via bcrypt). The prior draft's phase 2 did not produce + the terminal state it claimed. +3. **FIPS enablement** — no bcrypt hashes remain, so the gate never fires in + normal operation. + +The sentinel is any value `hashPassword` cannot produce; `verifyPassword` +rejects it on the unknown-format path with no new branch. + +**Fresh installs have no transition** — provided the seeder is fixed (§4 site 8). + +#### Recovery is a prerequisite, and it already exists + +Heimdall has **no self-service password reset** — no forgot-password flow, no +reset token, anywhere in backend or frontend **[V]**. And admins **cannot edit +their own account without supplying their password** — `casl-ability.factory.ts:65`: +```ts +// Force admins to supply their password when editing their own user. +cannot(Action.Manage, User, {id: user.id}); +``` +**[V]** So a single-admin deployment that enables FIPS before cutover would be +locked out with no in-application remedy. -This gives the terminal state the prior draft lacked and makes the `bcryptjs` removal criterion satisfiable. +**The remedy is `heimdall-cli reset-password`**, which exists today at +[github.com/mitre/heimdall-cli](https://github.com/mitre/heimdall-cli). It +writes directly to the database via `psql` with parameterized binding, and sets +`passwordChangedAt`/`forcePasswordChange` — correct for a genuine reset. **[V]** -**Fresh FIPS installs have no transition at all** — no bcrypt hash is ever written (see §4, the seeder). +**But it hashes with bcrypt (cost 14)**, so after this change it would write a +credential the FIPS-gated server refuses — turning the break-glass tool into a +break-glass *trap*. Fixing it is a **blocking cross-repo dependency**, not a +follow-up. See §14. -### 4. Eight call sites, not seven +### 4. Eight call sites -The prior draft enumerated seven and **omitted the admin bootstrap seeder** — the one that runs on every deployment. **[V]** All line numbers verified at `2e1649c9e`. +The prior draft enumerated seven and omitted the seeder — the one that runs on +every deployment. Line numbers verified at `2e1649c9e` **[V]**. | # | File | Line | Function | Change | |---|---|---|---|---| -| 1 | `users.service.ts` | 66 | `create()` | `hash(pw,14)` → service hash | -| 2 | `users.service.ts` | 89 | `update()` | `hash(pw,14)` → service hash | -| 3 | `users.service.ts` | 126 | `remove()` | `compare()` → **pure** `verifyPassword`, `.valid` only | -| 4 | `authn.service.ts` | 53 | `validateUser()` | `compare()` → verify **+ CAS rehash** (primary migration path) | -| 5 | `authn.service.ts` | 75 | `validateApiKey()` | `compare()` → verify + CAS rehash | -| 6 | `authn.service.ts` | 208 | `testPassword()` | `compare()` → **pure** `verifyPassword` — see constraint below | -| 7 | `apikey.service.ts` | 43 | `create()` | `hash(sig,14)` → service hash | -| **8** | **`seeders/20200514154327-create-administrator.js`** | **56** | admin bootstrap | **`bcrypt.hashSync(pw,14)` → compiled pure function, awaited** | - -**Site 8 is the most consequential omission.** `cmd.sh:4` runs `db:seed:all` on **every container start**, and the RPM path runs the same seeder via `heimdall-db-setup.sh`. Left unchanged, **every fresh install provisions its administrator — the highest-privilege account — with a bcrypt hash on day one**, in a change whose purpose is to eliminate them. With `ADMIN_USES_EXTERNAL_AUTH=true` the local credential may never be used for a local login, so lazy rehash never fires and the hash persists indefinitely. - -It is also structurally awkward: CommonJS `.js`, run by `sequelize-cli` outside both Nest DI and the TypeScript build, and **synchronous**. It must `require()` the compiled pure function from `dist/` and `await` it (its `up` is already `async`). - -**AC:** a fresh install, zero logins, must yield `bcrypt_remaining = 0`. - -#### Two structural constraints the prior draft missed - -**`testPassword` is called unbound. [V]** `users.service.ts:79` does `await AuthnService.prototype.testPassword(updateUserDto, userToUpdate)`. This works *only because* `testPassword` uses the module-scope `compare` import and never touches `this`. If it becomes `this.passwordService.verify(...)` it throws `TypeError` — and `UsersService` cannot inject `AuthnService` (circular; `AuthnService` injects `UsersService` at line 42). **Site 6 must use the pure function.** This is why §5's pure-function/injectable split is a requirement, not a style preference. - -**No persistence method can honor the lifecycle constraint. [V]** `usersService.update()` unconditionally sets `passwordChangedAt` and `forcePasswordChange`; `apiKeyService.update()` writes only `name`. Neither can perform a narrow rehash. Two new methods are required — `UsersService.updateEncryptedPassword()` and an `ApiKeyService` equivalent — following the existing narrow-writer pattern (`updateLoginMetadata`, `updateUserSecret`). +| 1 | `users.service.ts` | 66 | `create()` | → service hash | +| 2 | `users.service.ts` | 89 | `update()` | → service hash | +| 3 | `users.service.ts` | 126 | `remove()` | → **pure** `verifyPassword`, `.valid` only | +| 4 | `authn.service.ts` | 53 | `validateUser()` | verify **+ CAS rehash** — primary migration path | +| 5 | `authn.service.ts` | 75 | `validateApiKey()` | verify + CAS rehash | +| 6 | `authn.service.ts` | 208 | `testPassword()` | → **pure** `verifyPassword` (see below) | +| 7 | `apikey.service.ts` | 43 | `create()` | → service hash | +| **8** | **`seeders/20200514154327-create-administrator.js`** | **56** | admin bootstrap | **`bcrypt.hashSync` → compiled pure function, awaited** | + +**Site 8 matters most.** `cmd.sh:4` runs `db:seed:all` on **every container +start**, and the RPM path runs the same seeder. Unchanged, **every fresh install +provisions its administrator — the highest-privilege account — with a bcrypt +hash on day one**, in a change whose purpose is to eliminate them. With +`ADMIN_USES_EXTERNAL_AUTH=true` it may never migrate. It is also CommonJS, +synchronous, and runs outside both Nest DI and the TypeScript build. + +**Exact require path [V]:** rootDir is inferred across `src/`, `db/`, `config/`, +so `src/crypto/password.ts` compiles to **`dist/src/crypto/password.js`** — note +the `src/` segment. From `seeders/*.js` that is `require('../dist/src/crypto/password')`. +`.sequelizerc` already depends on build output, and `cmd.sh` runs under `set -e`, +so a bad require is a **boot crash loop, not a degraded seed**. An AC must +assert the seeder resolves in the built image, and `password.ts` must stay +dependency-free so the inferred layout cannot shift. + +**AC:** a fresh install, zero logins → `bcrypt_remaining = 0`. + +#### Two structural constraints + +**`testPassword` is called unbound [V].** `users.service.ts:79` does +`await AuthnService.prototype.testPassword(...)`. This works *only because* +`testPassword` uses the module-scope `compare` and never touches `this`. Making +it `this.passwordService.verify(...)` throws `TypeError`, and `UsersService` +cannot inject `AuthnService` (circular). **Site 6 must use the pure function.** + +**No persistence method can honor the lifecycle constraint [V].** +`usersService.update()` unconditionally sets `passwordChangedAt` and +`forcePasswordChange`. Two narrow writers are required — +`UsersService.updateEncryptedPassword()` and an `ApiKeyService` equivalent — +following the existing `updateLoginMetadata`/`updateUserSecret` pattern. ### 5. Module structure -Only `hashPassword` needs configuration; `verifyPassword` reads its parameters from the self-describing hash. Hence: +Only `hashPassword` needs configuration; `verifyPassword` reads parameters from +the hash. Hence: -- `apps/backend/src/crypto/password.ts` — **pure functions**, options as parameters. Usable from the seeder and scripts with no DI container (§4 site 6 and site 8 both require this). -- `apps/backend/src/crypto/password.service.ts` — Nest injectable reading `ConfigService`. -- `apps/backend/src/crypto/crypto.module.ts` — **required**: `ConfigModule` is *not* `@Global()`, so `UsersModule`, `AuthnModule`, and `ApiKeyModule` each need an explicit import. **[V]** +- `apps/backend/src/crypto/password.ts` — **pure functions**. Usable from the + seeder and scripts with no DI container (§4 sites 6 and 8 both require this). +- `apps/backend/src/crypto/password.service.ts` — Nest injectable reading + `ConfigService`. +- `apps/backend/src/crypto/crypto.module.ts` — **required**: `ConfigModule` is + *not* `@Global()` **[V]**. ```ts export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; @@ -192,184 +323,431 @@ export interface PasswordHashOptions { export interface PasswordVerifyResult { valid: boolean; - needsRehash: boolean; + needsRehash: boolean; // required, always present requiresReset?: boolean; // bcrypt encountered while FIPS on } -export function hashPassword(password: string, options?: PasswordHashOptions): Promise; -export function verifyPassword(args: {hash: string; password: string}): Promise; -``` +export function hashPassword( + password: string, + options?: PasswordHashOptions +): Promise; -### 6. Input validation — the exact sequence +export function verifyPassword(args: { + hash: string; + password: string; + getFips?: () => number; // default crypto.getFips — INJECTABLE +}): Promise; +``` -Each item below is a verified trap, not a precaution. **[V]** — all confirmed by execution on Node 24. +**`getFips` must be injectable.** §10's `assertFipsMode` already is; without the +same seam here the document's central new behavior is untestable in non-FIPS CI. +`vi.mock('crypto')` is unusable (the module also needs real `pbkdf2`, +`randomBytes`, `timingSafeEqual`), and `vi.spyOn` works only under a namespace +import — a destructured `import {getFips}` compiles to a non-writable binding +under swc. **AC: namespace import, never destructured.** -1. Reject non-string or empty. **`''.split('$')` is `['']`**, so a `parts[0] === ''` check *passes* for the empty string; the field-count check is what catches it. These are `AND`, not alternatives. -2. `split('$')` must yield **exactly 5** parts, and `parts[0] === ''`. -3. **Algorithm from a strict allowlist** — `Set(['sha256','sha384','sha512'])`. **Never prefix-match `sha*`.** `crypto.pbkdf2` accepts `md5` and `sha1`, so a stored `$pbkdf2-md5$…` would verify happily. The prior draft's dispatch table literally specified `$pbkdf2-sha*$` — an algorithm-confusion downgrade in a document banning MD5. -4. **Iterations by regex only** — `/^i=([1-9][0-9]{0,8})$/`. Never `parseInt`/`Number`: **`parseInt('6e5')` is `6`** (a forged hash verifies at six iterations — a 100,000× work-factor downgrade that looks well-formed), `parseInt('600000abc')` is `600000`, `Number('0x10000')` is `65536`, `Number('')` is `0`. -5. Iterations within `[100_000, 10_000_000]`. **An upper bound is mandatory**: Node permits up to 2³¹−1, which is roughly 8.6 minutes of one libuv thread per verification. Four such rows would exhaust the default 4-thread pool and take authentication down. -6. Decode salt and key, **re-encode and compare** (padding stripped both sides). `Buffer.from(str,'base64')` is lenient — `'AA@@AA'` and `'A A A A'` both decode identically to `'AAAA'`. -7. **Assert key length equals the digest's expected width, and salt ≥ 16 bytes, *before* calling `pbkdf2`.** `crypto.pbkdf2` with `keylen=0` throws an **untyped** error (`e.code` undefined) *before* any downstream guard runs — so the ADR's own required test, "malformed hash rejected without throwing," cannot pass without this check. A hash claiming `sha512` but carrying a 32-byte key would otherwise verify happily: silent acceptance of a downgraded artifact. -8. Guard length before `timingSafeEqual`, which **throws** `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH` on mismatch. Return `false`; never let it throw. Length is not secret — it is fixed by the stored parameters. +**The FIPS-refusal test must assert non-invocation**, not just the return value. +V-222571 fires on *generating* a hash, so an implementation that calls +`bcryptjs.compare()` and discards the result would pass a return-value +assertion while committing the exact finding. -**Maximum password length: 128 characters, enforced on every path** (`validateUser`, `create`, `update`, `remove`, `testPassword`, and the seeder). +### 6. Input validation — exact sequence -Two independent reasons. **(a) DoS** — this is Django **CVE-2013-1443** exactly: "A password one megabyte in size... will require roughly one minute of computation to check when using the PBKDF2 hasher." **[V]** Django capped at 4096 bytes. Heimdall currently sets `json({limit: '50mb'})` (`main.ts:67`), rate-limits only `/authn/login` at 20/min/IP (`main.ts:101-112`), and `libs/password-complexity` enforces a *minimum* of 15 with **no maximum**. **[V]** **(b) Approved range** — RHEL 9 OpenSSL security policy `140sp4857.pdf` states PBKDF2 "8-128 characters with password strength between 10⁸ and 10¹²⁸." **[V]** Over 128 is outside the module's documented approved range. +Each item is a verified trap **[V]** (confirmed by execution on Node 24): -Note that removing bcrypt also removes its implicit 72-byte truncation, so a >72-character password will be validated in full after rehash where previously only its first 72 bytes mattered. Behaviorally correct; worth a test. +1. Reject non-string/empty. **`''.split('$')` is `['']`**, so a `parts[0] === ''` + check *passes* for the empty string — the field-count check catches it. These + are `AND`, not alternatives. +2. `split('$')` must yield **exactly 5** parts, and `parts[0] === ''`. +3. **Strict allowlist over the full identifier** — + `Set(['pbkdf2-sha256','pbkdf2-sha384','pbkdf2-sha512'])`. Never prefix-match: + `crypto.pbkdf2` accepts `md5` and `sha1`, so `$pbkdf2-md5$…` would verify. + Allowlisting only the digest still admits `$pbkdf2-sha512-md5$` via naive + splitting. The prior draft specified `$pbkdf2-sha*$` — an algorithm-confusion + downgrade in a document banning MD5. +4. **Iterations by regex only** — `/^i=([1-9][0-9]{0,8})$/`. **`parseInt('6e5')` + is `6`** (a forged hash verifies at six iterations — a 100,000× downgrade + that looks well-formed); `parseInt('600000abc')` is `600000`; + `Number('0x10000')` is `65536`. +5. **Upper bound mandatory** — reject above 10,000,000. Node permits 2³¹−1, + roughly 8.6 minutes of one libuv thread per verification; four such rows + exhaust the default 4-thread pool and take authentication down. + **No lower bound on the verify path** — see §9. +6. Decode salt and key, **re-encode and compare** (padding stripped both sides). + `Buffer.from(str,'base64')` is lenient: `'AA@@AA'` and `'A A A A'` decode to + the same three bytes as `'AAAA'`. +7. **Assert key length equals the digest's width, and salt ≥16 bytes, *before* + calling `pbkdf2`.** `keylen=0` throws an **untyped** error (`e.code` is + `undefined`) *before* any downstream guard — so the ADR's own required test + ("malformed hash rejected without throwing") cannot pass without this. A hash + claiming `sha512` with a 32-byte key would otherwise verify: silent + acceptance of a downgraded artifact. +8. Guard length before `timingSafeEqual`, which **throws** + `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH` on mismatch. Return `false`. + +**Maximum password length: 128 characters**, enforced **on hashing only** (see +§9). Two reasons: **(a) DoS** — this is Django **CVE-2013-1443** exactly ("A +password one megabyte in size... roughly one minute of computation") **[V]**; +Heimdall sets `json({limit:'50mb'})` and rate-limits only `/authn/login` at +20/min/IP **[V]**. **(b) Approved range** — policy `140sp4857.pdf` states +PBKDF2 "8-128 characters" **[V]**. + +**Critical: `authn.service.ts:109` generates a 256-character password [V].** +```ts +const randomPass = crypto.randomBytes(128).toString('hex'); +``` +It is the placeholder for **every** externally-authenticated user (OIDC, LDAP, +GitHub, GitLab, Google, Okta) and goes straight to `usersService.create()`. +A cap enforced inside the hash path would make **every external-auth user +creation throw**. **Shorten it to `randomBytes(32)`** (64 hex characters, 256 +bits — ample for a credential never used to log in) so the cap stays uniform on +every path. An exemption would be a bypass waiting to be misused. -### 7. Concurrency: compare-and-swap, not `save()` +Removing bcrypt also removes its 72-byte truncation, so a >72-character password +is validated in full after rehash. Behaviorally correct; worth a test. -**[V]** `authn.service.ts:54` already calls `this.usersService.updateLoginMetadata(user)` **without `await`** — a floating promise ending in `user.save()`. Adding a rehash `save()` on the same Sequelize instance creates two concurrent unawaited writes to one row. +### 7. Concurrency: compare-and-swap -**The damaging interleaving:** a user changes their password (writes H2) while an in-flight login rehashes the **old** password and writes H1′. Last-write-wins silently reverts the password change. **If that change was a response to compromise, the compliance fix reinstates the compromise.** +**`authn.service.ts:54` already calls `updateLoginMetadata(user)` without +`await` [V]** — a floating promise ending in `user.save()`. Adding a rehash +`save()` on the same instance gives two concurrent unawaited writes to one row. -A bare `.save()` also cannot honor the lifecycle constraint — Sequelize flushes *all* dirty attributes, which at that moment include `lastLogin` and `loginCount`, and `@UpdatedAt` always bumps `updatedAt`. +**The damaging interleaving:** a user changes their password (writes H2) while +an in-flight login rehashes the **old** password and writes H1′. Last-write-wins +silently reverts the change. **If that change was a response to compromise, the +compliance fix reinstates the compromise.** -**Required:** +**Use `Model.update`, not raw SQL. [V]** The prior draft prescribed raw SQL plus +`silent: true` — but `sequelize.query()` **has no `silent` option** (zero hits in +Sequelize v6 source); it exists on `Model.update`. One call gives CAS, field +restriction, suppressed `updatedAt`, and the affected count: -```sql -UPDATE "Users" SET "encryptedPassword" = :new -WHERE id = :id AND "encryptedPassword" = :originalHash +```ts +const [affected] = await this.userModel.update( + {encryptedPassword: newHash}, + {where: {id: user.id, encryptedPassword: originalHash}, + fields: ['encryptedPassword'], silent: true} +); ``` -Zero rows affected means another writer won — do nothing. This makes concurrent logins idempotent and enforces the "write `encryptedPassword` only" constraint **in SQL rather than by convention**. Use `silent: true` to suppress the `updatedAt` bump, so a mass migration does not make every account look recently modified. Wrap in try/catch: **a failed rehash must never fail an otherwise-successful login** — it retries next time. A crash between verify and save is safe; the bcrypt hash survives. +Zero rows means another writer won — do nothing. `silent: true` suppresses the +`updatedAt` bump so a mass migration does not make every account look recently +modified. Wrap in try/catch: **a failed rehash must never fail an otherwise +successful login.** -The same un-awaited pattern exists at `apikey.service.ts:44`. **[V]** +**The rehash must not mutate the Sequelize instance.** Assigning +`user.encryptedPassword = newHash` to keep the object coherent puts that field +into the in-flight `updateLoginMetadata` `save()` — **outside** the CAS +predicate, recreating the exact revert this prevents. -#### Password lifecycle fields must not change +The same un-awaited pattern exists at `apikey.service.ts:44` **[V]**. -`user.model.ts` declares `forcePasswordChange` (line 55) and `passwordChangedAt` (line 68). **[V]** A rehash changes only the stored representation — the password itself did not change. Writing `passwordChangedAt` would silently reset the password-expiry clock for every migrating user: a security regression introduced by a compliance fix. +#### Lifecycle fields -**Known wrinkle [V]:** migration `20200417145649` creates `passwordChangedAt` as `Sequelize.STRING`, while `user.model.ts` declares `DataType.DATE`. No migration reconciles them. Since `database.module.ts` returns `synchronize: true` outside test, a synchronize-built database gets `DATE` and a migration-built one gets `VARCHAR(255)`. The regression test must account for both. Pre-existing defect; noted, not fixed here. +`user.model.ts` declares `forcePasswordChange` (55) and `passwordChangedAt` (68) +**[V]**. A rehash changes only the stored representation. Writing +`passwordChangedAt` would silently reset the password-expiry clock for every +migrating user — a security regression introduced by a compliance fix. -**Test must assert both directions** — `encryptedPassword` *changed* and now starts with `$pbkdf2-`, while `passwordChangedAt`, `forcePasswordChange`, `lastLogin`, `loginCount`, and `updatedAt` are unchanged — verified after `await user.reload()`, not against the in-memory instance. A no-op rehash otherwise passes. +**Test scope, corrected.** The prior draft required asserting `lastLogin`, +`loginCount`, and `updatedAt` unchanged. **That AC is unsatisfiable on the path +it protects** — `updateLoginMetadata` changes all three on every successful +login by design. Assert **`passwordChangedAt` and `forcePasswordChange` +unchanged**, `encryptedPassword` changed and now `$pbkdf2-`-prefixed, verified +after `await user.reload()` — not against the in-memory instance. -### 8. Known limitation: iteration upgrades do not propagate +**Known wrinkle [V]:** migration `20200417145649` creates `passwordChangedAt` as +`Sequelize.STRING` while the model declares `DataType.DATE`, and +`synchronize: true` outside test means a synchronize-built DB gets `DATE` and a +migration-built one `VARCHAR(255)`. The test must compare type-agnostically. +Pre-existing; documented, not fixed here. -Per §3, a PBKDF2 hash always returns `needsRehash: false`, even when its stored `i=` is below current policy. **This is the Grafana failure mode this ADR criticizes**, and it is a deliberate trade — reading parameters from the hash is what makes iteration changes non-breaking. +### 8. Known limitation: iteration upgrades do not propagate -Recorded explicitly so a future maintainer does not "fix" it accidentally. If iteration upgrades become desirable, add a params-below-policy check to the dispatch, gated behind its own decision. Not in scope here. +A PBKDF2 hash always returns `needsRehash: false`, even when its stored `i=` is +below policy. **This is the Grafana failure mode this ADR criticizes**, and it +is a deliberate trade — reading parameters from the hash is what makes iteration +changes non-breaking. Recorded so a maintainer does not "fix" it accidentally. -### 9. Environment variables +### 9. Configuration -| Variable | Type | Default | Purpose | +| Variable | Type | Default | Notes | |---|---|---|---| -| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | PBKDF2 digest | -| `PASSWORD_HASH_ITERATIONS` | int ≥ 100000 | `600000` | Iterations | -| `PASSWORD_MAX_LENGTH` | int ≤ 128 | `128` | Input cap (§6) | -| `FIPS_MODE` | boolean | unset | Assertion + fallback gate (§3, §10) | -| `PASSWORD_HASH_WRITE_ENABLED` | boolean | `false` in release N, `true` in N+1 | Rollout gate (§12) | -| `UV_THREADPOOL_SIZE` | int | platform default 4 | Auth throughput ceiling (§11) | - -Floor/ceiling semantics must be explicit: out-of-range values **throw at startup**, they do not clamp silently. **The floor applies to hashing only, never to verification** — a stored hash with `i=50000` must remain verifiable or users are locked out. - -`libs/password-complexity` remains hardcoded (15-char minimum, four classes, no 4+ consecutive same-class) with **no** environment variables. **[V]** Complexity is orthogonal to hashing; making it configurable is out of scope. - -### 10. FIPS assertion and startup - -**The in-process assertion cannot be the only gate. [V]** Verified by execution: +| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | **NSS deployments must not use `sha256`** — V-222571 makes anything weaker than SHA-384 a finding | +| `PASSWORD_HASH_ITERATIONS` | int ≥100000 | `600000` | | +| `PASSWORD_MIN_LENGTH` | int | `15` | already read by heimdall-cli | +| `PASSWORD_MAX_LENGTH` | int ≤128 | `128` | §6 cap | +| `PASSWORD_REQUIRE_CLASSES` | int | `4` | already read by heimdall-cli | +| `PASSWORD_MAX_CONSECUTIVE` | int | `3` | already read by heimdall-cli | +| `FIPS_MODE` | boolean | unset | assertion + fallback gate | +| `PASSWORD_HASH_WRITE_ENABLED` | boolean | see §12 | rollout gate | + +Out-of-range values **throw at startup**; they do not clamp silently. + +**Floors and caps apply to hashing only, never verification.** The prior draft +contradicted itself — §6 required stored iterations within `[100_000, +10_000_000]` during the *verify* parse while §9 stated the floor "applies to +hashing only... or users are locked out." A user hashed under an earlier +`PASSWORD_HASH_ITERATIONS=50000` was simultaneously rejected and required to +succeed. **Verification enforces only the upper bound** (the DoS guard) plus a +sanity floor of 1000, the module's own documented minimum **[V]**. The same +applies to `PASSWORD_MAX_LENGTH`: capping on verify would lock out any user +whose password exceeds it. If an oversized password reaches the rehash path, +**skip the rehash and log it** — never fail the login (§7). + +**Configurable complexity is now in scope.** `libs/password-complexity` is +hardcoded today, but **heimdall-cli already reads `PASSWORD_MIN_LENGTH`, +`PASSWORD_REQUIRE_CLASSES`, and `PASSWORD_MAX_CONSECUTIVE`** from `backend.env` +**[V]** — env vars the app does not support. The CLI and the app therefore +disagree today about what a valid password is, which is the same class of bug as +the hash format. The variable names above match the CLI's exactly; that is the +contract. + +### 10. FIPS mode on RHEL — the prior draft had this backwards + +**Red Hat's Node actively discourages `--force-fips`. [V]** Verified by running +the UBI9 image: ``` -$ NODE_OPTIONS="--force-fips" node -e "console.log('ok')" -node: OpenSSL error when trying to enable FIPS: -EXIT=1 +$ node --force-fips -p 'require("crypto").getFips()' +ERROR: Using options related to FIPS is not recommended, + configure FIPS in openssl instead. ``` -Node aborts **in bootstrap**, before `main.ts`, before Nest, before any logger — with an **empty error body**. The §10 assertion never runs on that path. Combined with the RPM unit's `Restart=on-failure` / `RestartSec=5` and no `StartLimitBurst` override, this produces a **permanent crash loop at 12 restarts/minute** with `systemctl status` showing `activating (auto-restart)` rather than `failed`. +The RHEL model is **host FIPS mode → OpenSSL enables → Node inherits**, not an +application flag. The prior draft built a three-layer design around +`--force-fips` — a `start:fips` script, a launcher preflight, and systemd +`StartLimitBurst` hardening to survive the resulting crash loop. **On RHEL none +of that is correct**, and the 2023 `fips_compliance` branch's `start:fips` was +wrong for the same reason. -**Therefore, three layers:** +**This simplifies the epic.** No preflight, no `start:fips`, and the crash-loop +blocker disappears because the flag is never passed. `--force-fips` remains +documented **only** for non-RHEL deployments running stock Node with a manually +configured provider. -1. **Launcher preflight** — never put `--force-fips` directly in the systemd unit or `NODE_OPTIONS`. Probe first, and on failure emit a real diagnostic (`/proc/sys/crypto/fips_enabled`, `update-crypto-policies --show`, `openssl list -providers`, `node -p process.versions.openssl`) and `exit 78` (`EX_CONFIG`). -2. **Unit hardening** — `StartLimitIntervalSec=60`, `StartLimitBurst=3`, so a misconfiguration reaches `failed` within a minute. -3. **In-process assertion** — still required, because it catches the *dangerous* case: `FIPS_MODE=true` set for compliance reporting **without** `--force-fips`, i.e. the system claims FIPS and is not. This is GitLab's Workhorse failure — it shipped without the `fips` build tag and `fips.Enabled()` returned false with no error. Must be an **exported, injectable** function (`assertFipsMode({fipsMode, getFips})`) so it is testable without booting the app; `bootstrap()` in `main.ts` is not exported. **[V]** +**The startup assertion becomes more important, not less** — with no flag +forcing the issue, it is the only thing between us and silent non-FIPS +operation. This is GitLab's Workhorse failure: it shipped without the `fips` +build tag and `fips.Enabled()` returned **false with no error**. -**When `FIPS_MODE` is unset, log loudly at boot that no assertion was performed** — silence is how the Workhorse class of failure survives. +`assertFipsMode({fipsMode, getFips})` must be **exported and injectable** — +`bootstrap()` in `main.ts` is not exported and cannot be unit-tested **[V]**. +When `FIPS_MODE` is unset, **log loudly at boot that no assertion was +performed**; silence is how the Workhorse class of failure survives. -**`crypto.getFips()` is necessary but not sufficient as evidence.** It proves the flag is set, not which provider loaded at what version, nor that the operational environment matches the certificate. Log module identity at startup as a durable artifact. Never silently degrade to a non-approved path. +**Never call `crypto.setFips()`** — under `--force-fips` it triggers a native +`CHECK()` that **aborts the process**; it does not throw. **[V]** -**Never call `crypto.setFips()`** — under `--force-fips` it triggers a native `CHECK()` that **aborts the process**; it does not throw. **[V]** +Two error families must not be conflated: `ERR_OSSL_EVP_UNSUPPORTED` is an +OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for +FIPS` is a real denial. -### 11. Performance — measured, and it inverts the prior draft's risk rating +### 11. Performance — measured, and it inverts the prior rating -The prior draft asserted "PBKDF2-SHA512 @ 600k ≈ bcrypt cost 14 (~200-400 ms)" and rated the change a Low/Low performance *regression*. **Both are wrong.** Measured on Node 24 (Apple Silicon; server vCPUs will be 2-4× slower): +The prior draft asserted "≈ bcrypt cost 14 (~200-400 ms)" and rated the change a +Low/Low performance *regression*. **Both wrong.** Measured on Node 24: -| Operation | Latency | Concurrent throughput | Event-loop lag | +| Operation | Latency | Throughput | Event-loop lag | |---|---|---|---| | `bcryptjs` compare cost 14 (**current production**) | **1120 ms** | 0.9/sec | 788 ms | -| PBKDF2-SHA512 @ 600k (**this ADR**) | **145 ms** | 20/sec | 1.4 ms | -| PBKDF2-SHA512 @ 210k (OWASP) | 52 ms | ~55/sec | — | - -This is a **7.7× latency improvement and a 22× throughput improvement.** The prior draft buried its own strongest justification. - -**The real cost it failed to document:** `crypto.pbkdf2` dispatches to the **libuv threadpool (default 4 threads)**. Throughput pins at ~20 auth-ops/sec *regardless of concurrency*, and the pool is shared with `fs`, `dns.lookup`, and `zlib` — measured, `fs.readFile` went **1.16 ms → 337 ms** with 8 PBKDF2 operations queued. "Async, does not block the event loop" is true but materially misleading: the event loop stays responsive while all file I/O stalls. bcrypt today is far worse (0.9 ops/sec, 3232 ms `fs` stall), so this remains a large net win — but `UV_THREADPOOL_SIZE` must be set explicitly and the resulting ceiling documented. - -**On keeping 600,000.** SHA-512 is the right digest — fast per byte on 64-bit CPUs while GPUs are comparatively weaker at 64-bit operations, so the defender/attacker ratio favors it. The *count* is defensible at 210k (OWASP) or at 600k **only if** `UV_THREADPOOL_SIZE` is raised and a global KDF concurrency limit lands. Keeping 600k while addressing neither is the one indefensible combination. **Benchmark on the target RHEL container before finalizing.** - -**Login is a DoS amplification vector. [V]** The only protection is 20 req/min/IP on `/authn/login`; there is no global cap, no `@nestjs/throttler`, and **no account lockout** (`loginCount` increments only on success). A ~200-byte request buys 145 ms of CPU. Add a global KDF concurrency limiter and a per-account failed-attempt counter. - -**API keys: 600,000 iterations is cryptographically pointless there.** The hashed value is a JWT HS256 signature — 43 base64url characters, **256 bits of machine-generated entropy**. Iterated KDFs raise per-guess cost against *low-entropy human* inputs; against a 256-bit token, brute force from a stolen hash is infeasible at any iteration count. GitHub and Stripe store API tokens as a single SHA-256. Note this path is **not** reachable by unauthenticated attackers — `jwt.verify` gates it and is cheap **[V]** — and per-request cost still *drops* 1120 → 145 ms. Recorded as a known inefficiency; changing it is out of scope (§14). - -### 12. Rollout, rollback, and the mixed-version window - -**The prior draft named rollback asymmetry as a High risk and specified no mechanism.** Two distinct hazards: - -**(a) Rolling deploys — within a single release.** Old and new pods serve one database concurrently. A user rehashed by a new pod then hits an old pod: `bcryptjs.compare()` returns `false` on a PBKDF2 hash (it does not throw), so they get "Incorrect Username or Password" — *intermittent* auth failure that appears to self-resolve as the deploy completes, the hardest class to triage, amplified by the 20/min rate limit turning retries into 429s. - -**API keys make this materially worse.** `validateApiKey` serves CI pipelines and `saf` CLI uploads — no human to retry, silent pipeline failure. And an API key **cannot be recovered**: the server stores only a hash of a signature it never retains in plaintext. A bad rollback means regenerating every key by hand. - -**(b) Version skips — air-gapped RPM.** Forward skips are safe (pre-N → N+1 gets read+write together, having never run read-incapable code against PBKDF2 hashes). **Reverse is catastrophic** and `dnf downgrade` is one command. - -**Mechanism — all four parts required:** - -1. **`PASSWORD_HASH_WRITE_ENABLED`**, default `false` in release N, `true` in N+1. When false, `verifyPassword` still reports `needsRehash` but call sites skip the write. Two releases alone don't cover the intra-release rolling window; the flag alone doesn't cover operators who skip the read release. -2. **A durable format marker planted in release N** — the one thing that must not be deferred, because it is what makes both guards possible later. A DB row (not a file — container filesystems are ephemeral and the database is the only shared durable state) recording that PBKDF2 writes have begun. -3. **RPM `%pre` downgrade guard** — refuse installation below the recorded floor, with an explicit message naming the consequence and pointing at the recovery procedure. -4. **Graceful-degradation AC** — an integration test running the *old* verify path against a PBKDF2 hash, asserting a clean `false` rather than a throw or a 500. - -**Sequence (SaaS):** N read-only + marker → soak → canary the flag on one replica → fleet-wide as a *separate* rollout → N+1 default true → N+2 flag removed → N+3 `bcryptjs` removed, gated on telemetry not a date. - -**Sequence (air-gapped):** same artifacts, operator-timed; warning in the **upgrade** section of release notes, not the changelog; `%post` prints it to console; `%pre` guard enforces it. - -**Also unguarded:** a `pg_dump` taken post-migration and restored onto pre-N code locks out every migrated user. Same hazard, different door, and `%pre` does not catch it. Document the forced-reset recovery — including that **API keys must be regenerated**. - -**Read replicas.** The lazy rehash is a write on the login path. If reads were ever routed to a replica, a lagging read would return the stale bcrypt hash and rehash again — an unbounded loop burning a full KDF per login. Heimdall does not use read replicas today; recorded as an assumption to revisit. - -### 13. Dependency and platform audit - -**Our own code is clean. [V]** No `md5`/`sha1`/`createHash` in `apps/backend/src`, `apps/backend/config`, `libs/common`, or `libs/password-complexity`. Only `crypto.randomBytes` is used. `uuid` v4 only (v3/v5 would use MD5/SHA-1). No `@aws-sdk/*` or `hdf-converters` in the backend — AWS SDK is browser-side in `apps/frontend`. - -**Express ETag — the prior draft's diagnosis was wrong. [V]** `etag/index.js:47` uses **`createHash('sha1')`**, not MD5. Confirmed two ways: a direct read of the installed package source, and the empty-body fast-path constant `2jmj7l5rSw0yVb/vlWAYkK/YBwk`, which is exactly `sha1('')` in base64 (`md5('')` is `1B2M2Y8AsgTpgAmY7PhCfg==`). SHA-1 **is** an approved hash in the OpenSSL 3 FIPS provider, so this likely does **not** break under plain `--force-fips`. - -*Two separate reviewers asserted MD5 here.* Both were wrong; the source read is definitive. Recorded so this is not re-litigated. - -**But it may still break under `FIPS:STIG`**, whose permitted hash list is SHA-2/SHA-3 only. **Verify empirically under both `FIPS` and `FIPS:STIG` policies before spending any work here.** If it does break, choose the **SHA-256 custom generator**, never `app.set('etag', false)` — Heimdall serves large HDF JSON payloads (`json({limit:'50mb'})`), so losing 304 revalidation costs far more than rehashing. Note `app.set('etag', false)` would not disable `serve-static`'s ETag anyway, and `send` passes an `fs.Stats` object to `etag`'s `stattag()`, which uses no hash at all. - -**`pg` MD5 auth breaks under FIPS** ([node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706)) — see §15. - -**Runtime audit required.** Static analysis cannot see transitive dependencies. Boot and exercise auth under `--force-fips` on a real RHEL FIPS host. Do not conflate the two error families: `ERR_OSSL_EVP_UNSUPPORTED` is an OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for FIPS` is a real denial. - -### 14. Platform: what the base image does and does not give us - -`Dockerfile:1` sets `ARG BASE_CONTAINER=registry.access.redhat.com/ubi9/nodejs-22-minimal:1`, used by both stages. **[V]** RHEL's Node is a `--shared-openssl` build, so it uses system OpenSSL rather than a statically-bundled copy. - -**Four corrections to the prior draft's over-claim:** - -1. **It is an overridable `ARG`, not a fixed `FROM`.** `--build-arg BASE_CONTAINER=node:22-alpine` silently produces exactly the compliance theater this ADR warns against. State it as a constraint; consider failing the build if the base is not UBI. -2. **A UBI image carries no validation of its own.** Red Hat's position: products are not FIPS validated, cryptographic components are — and if the host OS is not in FIPS mode, containers are not either. **The FIPS-mode RHEL host is a hard requirement**, not an implementation detail. -3. **The prior draft contradicted itself.** It claimed both "UBI9 inherits FIPS from system OpenSSL automatically" and "Node never reads `/proc/sys/crypto/fips_enabled`." Both are true and compatible: Node does not read it, but RHEL's *OpenSSL* does — that runtime check **is** the inheritance mechanism. Stated correctly here. -4. **Since RHEL 9.2 the FIPS provider ships as a separate RPM.** The prior draft's "our base image needs none of that" was asserted for a `-minimal` image with no evidence the package is present. **AC:** run `openssl list -providers` and `node --force-fips -e "console.log(require('crypto').getFips())"` *inside* the actual image and record the output. - -Stock nodejs.org binaries **do** support FIPS — `BUILDING.md`: "It is not necessary to rebuild Node.js to enable support for FIPS" — but require `openssl fipsinstall`, `OPENSSL_CONF`, and `OPENSSL_MODULES`. **[V]** - -**Operational-environment binding.** CMVP **IG 2.3.A**: the tested operational environment "must consist of the Operating System, the platform, and the processor," and "a claim cannot be made that the implementation also runs on another operating system." **[V]** Customers running outside the certificate's tested OE set need an explicit conformance statement. (Deploy-time porting to an untested OE maps to Management Manual §7.9 — **[U]**, not independently verified.) - -### 15. PostgreSQL - -**Scope is narrower than the prior draft implied. [V]** `docker-compose.yml:3` pins **`postgres:17`**, and Postgres 14+ defaults to `scram-sha-256`. The default stack needs no change. Exposure is limited to RHEL 8 AppStream (Postgres 13) and pre-existing customer databases. - -**And the prior draft's remediation did not address the case it identified.** `POSTGRES_HOST_AUTH_METHOD` / `POSTGRES_INITDB_ARGS` are Docker-image variables that take effect **only during `initdb` on an empty data directory** — they do nothing for a pre-existing database. Even `password_encryption = 'scram-sha-256'` affects only passwords set *after* the change; existing roles keep their `md5…` verifier in `pg_authid` indefinitely. The role must be re-set: +| PBKDF2-SHA512 @600k (**this ADR**) | **145 ms** | 20/sec | 1.4 ms | +| PBKDF2-SHA512 @210k (OWASP) | 52 ms | ~55/sec | — | + +**A 7.7× latency and 22× throughput improvement.** The prior draft buried its +own strongest justification. + +**The cost it failed to document:** `crypto.pbkdf2` dispatches to the **libuv +threadpool (default 4)**. Throughput pins at ~20 auth/sec *regardless of +concurrency*, and the pool is shared with `fs`, `dns.lookup`, and `zlib` — +`fs.readFile` went **1.16 ms → 337 ms** with 8 PBKDF2 operations queued. +"Async, does not block the event loop" is true but misleading. `UV_THREADPOOL_SIZE` +must be set — and it is **not an application env var**; libuv reads it at first +threadpool use, so it belongs in the Dockerfile, `cmd.sh`, or the systemd unit. + +**600k is defensible at 210k (OWASP) or at 600k only if `UV_THREADPOOL_SIZE` is +raised and a global KDF concurrency limit lands.** Keeping 600k while addressing +neither is the one indefensible combination. Benchmark on the target RHEL +container before finalizing. + +**Login is a DoS amplification vector [V]** — 20 req/min/IP on `/authn/login` is +the only protection; no global cap, no account lockout (`loginCount` increments +only on success). + +**API keys: 600k iterations is pointless there.** The hashed value is a JWT +HS256 signature — 43 base64url characters, **256 bits of machine entropy**. +Iterated KDFs raise per-guess cost against *low-entropy human* input; brute +force from a stolen hash is infeasible at any iteration count. GitHub and Stripe +store API tokens as a single SHA-256. The path is **not** reachable +unauthenticated (`jwt.verify` gates it, and is cheap) **[V]**, and per-request +cost still *drops* 1120 → 145 ms. Recorded as a known inefficiency; changing it +is out of scope (§15). + +### 12. Rollout and rollback + +**(a) Rolling deploys.** Old and new pods share one database. A user rehashed by +a new pod then hits an old pod: `bcryptjs.compare()` returns `false` on a PBKDF2 +hash (it does not throw — verified: `compare()` short-circuits on +`hashValue.length !== 60`) **[V]**, so they get "Incorrect Username or +Password" — *intermittent* auth failure that appears to self-resolve as the +deploy completes, amplified by the rate limit turning retries into 429s. + +**API keys are worse.** `validateApiKey` serves CI and `saf` CLI — no human to +retry, silent pipeline failure. And an API key **cannot be recovered**; the +server stores only a hash of a signature it never retains. + +**(b) Version skips (air-gapped RPM).** Forward skips are safe. **Reverse is +catastrophic**, and `dnf downgrade` is one command. + +**Mechanism:** + +1. **`PASSWORD_HASH_WRITE_ENABLED`**, default `false` in release N, `true` in + N+1. When false, `verifyPassword` still reports `needsRehash` but call sites + skip the write. **Scope must be stated explicitly**: the gate covers *all* + writes (sites 1, 2, 7, 8) during the rolling window, not just rehash — + otherwise a password change or a new SSO user on an N pod is unreadable by a + pre-N pod. Consequently §4's "fresh install → `bcrypt_remaining = 0`" AC + applies from **N+1**, or the gate must be derived: enabled unconditionally on + a fresh install (no pre-N peer can exist), defaulted off only on upgrade. +2. **A durable DB marker planted in release N**, recording that PBKDF2 writes + have begun. **Planting trigger must be defined** — at install it records + something untrue; on first write it flips during the canary while most rows + are still bcrypt. Define which, and who reads it. +3. **Enforcement is in the application, not RPM `%pre`.** The prior draft + specified a `%pre` guard; **it cannot fire on the downgrades it targets** — + on downgrade the `%pre` that runs belongs to the **older** package, built + before the guard existed. It also cannot abort the transaction, and would + need Postgres access mid-transaction on exactly the air-gapped hosts it + serves. Instead: **the application refuses to start when it reads a marker + newer than its own code version.** That works on RPM and container paths + alike, needs no scriptlet DB access, and is the only mechanism that also + catches the `pg_dump`-restore hazard. +4. **Graceful-degradation AC** — an integration test running the old verify path + against a PBKDF2 hash, asserting a clean `false`, no throw, no 500. + +**Read replicas.** The lazy rehash is a write on the login path; a lagging +replica read would return the stale bcrypt hash and rehash again — an unbounded +loop. Heimdall does not use read replicas today; recorded as an assumption. + +### 13. Dependency audit + +**Own code is clean [V]** — no `md5`/`sha1`/`createHash` in +`apps/backend/src`, `apps/backend/config`, `libs/common`, or +`libs/password-complexity`. Only `crypto.randomBytes`. `uuid` v4 only. No +`@aws-sdk/*` or `hdf-converters` in the backend. + +**Express ETag — the prior draft's diagnosis was wrong. [V]** `etag/index.js:47` +uses **`createHash('sha1')`**, not MD5; the empty-body constant +`2jmj7l5rSw0yVb/vlWAYkK/YBwk` is exactly `sha1('')`. SHA-1 **is** approved in the +OpenSSL 3 FIPS provider, so this likely does **not** break under plain FIPS. +*Two reviewers asserted MD5; both were wrong — the source read is definitive.* + +**But verify under `FIPS:STIG`**, whose permitted hash list is SHA-2/SHA-3 only. +If it does break, use a **SHA-256 custom generator**, never `app.set('etag', +false)` — Heimdall serves large HDF JSON payloads, so losing 304 revalidation +costs more than rehashing. `serve-static` uses stat-based tags and no hash. + +**`pg` MD5 auth breaks under FIPS** — see §16. + +**Runtime audit required.** Static analysis cannot see transitive dependencies. +Unaudited: `passport-google-oauth` (bundles an OAuth 1.0a HMAC-SHA1 path), +`passport-ldapauth` (SASL DIGEST-MD5 if configured), `express-session`. + +### 14. Repository boundary and the cross-repo dependency + +This is no longer hypothetical. Current topology: + +| Repo | Owns | +|---|---| +| **mitre/heimdall2** | application, `packaging/rpm/` (imported `35d47dee3`), `libs/password-hash-vectors/` (the contract) | +| **mitre/heimdall-cli** | Go admin binary, own release pipeline, consumes the vectors | +| **mitre/saf-packaging** | cross-SAF policy, airgap/repo infrastructure, other tools | + +**Why the CLI stays separate.** Of its fifteen commands, fourteen are +deployment-domain (start/stop/status/logs/backup/restore/certs/fapolicyd/…). +Exactly one, `reset-password`, touches an app contract. Its value is being a +**static binary that works when the app is broken** — no Node, no `dist/`, no +working install. Note the history: an earlier Python CLI hashed by shelling out +to the app's `bcryptjs` — true single-implementation — and was **deliberately +replaced** one day later by the Go binary for "single binary, no Python/vendor +dependencies" **[V]**. That trade was made on purpose; the contract restores +safety without giving it back. + +**The contract: `libs/password-hash-vectors/`.** heimdall2 owns the format and +publishes versioned vectors — known password→hash pairs plus the malformed-hash +corpus (which §6's tests need anyway) and a `formatVersion` stamp. Both +implementations test against it; a mismatch is a **build failure**. + +**Blocking cross-repo work in this epic:** + +1. Add `Pbkdf2Hasher` implementing the CLI's existing `PasswordHasher` + interface — the seam is already there +2. Write PHC format, not bcrypt; **remove the bcrypt write path entirely** +3. Consume the published vectors, asserting `formatVersion` +4. Update `heimdall-cli-reset-password.1`, which documents bcrypt cost 14 + +Until (1)–(3) land, enabling the FIPS gate turns break-glass into a trap. + +**RPM packaging is now in-tree**, so §10's deployment changes and §16's Postgres +detection are ordinary cards in this repo rather than cross-repo coordination. + +### 15. Platform + +`Dockerfile:1` sets `ARG BASE_CONTAINER=registry.access.redhat.com/ubi9/nodejs-22-minimal:1` +**[V]**. Verified by running it: + +- **`shared_openssl: true`** — Node 22.23.1 against OpenSSL 3.5.5, shared build, + so it uses system OpenSSL rather than a bundled copy **[V]** +- **`fips.so` is present** at `/usr/lib64/ossl-modules/` (1.3 MB), and the + provider identifies as **"Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", + version 3.0.7-cda111b5812c30d4** **[V]** — that is the module name and version + an SSP must cite +- **`fipsmodule.cnf` is absent.** On a stock OpenSSL flow that file (from + `openssl fipsinstall`) activates the provider; RHEL's patched OpenSSL instead + keys off `/proc/sys/crypto/fips_enabled`. **Whether a container on a FIPS host + activates the provider without its own `fipsmodule.cnf` is unresolved** and + requires a FIPS host to settle. **[U]** + +**Four constraints:** + +1. **It is an overridable `ARG`.** `--build-arg BASE_CONTAINER=node:22-alpine` + silently produces exactly the compliance theater this ADR warns against. + Consider failing the build if the base is not UBI. +2. **A UBI image carries no validation of its own.** Red Hat's position: + products are not FIPS validated, cryptographic components are. **A FIPS-mode + RHEL host is a hard requirement.** +3. Node **never** reads `/proc/sys/crypto/fips_enabled`; RHEL's *OpenSSL* does. + That runtime check **is** the inheritance mechanism. +4. **Since RHEL 9.2 the FIPS provider ships as a separate RPM** **[U]** — the + package exists on Red Hat's UBI CDN, but the "since 9.2" claim could not be + retrieved. + +Stock nodejs.org binaries **do** support FIPS — `BUILDING.md`: "It is not +necessary to rebuild Node.js to enable support for FIPS" **[V]** — but require +`openssl fipsinstall`, `OPENSSL_CONF`, and `OPENSSL_MODULES` (documented in +`doc/api/crypto.md`, **not** BUILDING.md). + +**Operational environment.** CMVP **Management Manual §7.9**: a user "may +perform post-validation porting of a module and affirm the module's continued +validation compliance," and a Level 1 software module "will remain compliant +with the FIPS 140-3 validation on any general-purpose platform/processor that +supports the specified operating system... or another compatible operating +system." CMVP "makes no statement as to the correct operation of the module... +when ported and executed in an OE not listed on the validation certificate." +**[V]** So an untested OE **does not void validation** at Level 1 — the customer +self-affirms. + +### 16. PostgreSQL + +**Narrower than the prior draft implied [V].** `docker-compose.yml:3` pins +**`postgres:17`**; Postgres 14+ defaults to `scram-sha-256`. Exposure is RHEL 8 +AppStream (Postgres 13) and pre-existing customer databases. + +**And the prior remediation did not address the case it identified.** +`POSTGRES_HOST_AUTH_METHOD`/`POSTGRES_INITDB_ARGS` are Docker-image variables +that take effect **only during `initdb` on an empty data directory**. Even +`password_encryption = 'scram-sha-256'` affects only passwords set *after* the +change; existing roles keep their `md5…` verifier in `pg_authid`: ```sql ALTER SYSTEM SET password_encryption = 'scram-sha-256'; @@ -379,17 +757,29 @@ ALTER ROLE heimdall WITH PASSWORD ''; -- rewrites the verifier SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = 'heimdall'; ``` -Add the `pg_authid` check to the RPM setup script's FIPS detection so an operator is warned *before* the app fails to connect. - -### 16. Observability - -**The prior draft's `bcryptjs` removal criterion — "zero rows across all deployments" — is unsatisfiable as written.** MITRE ships to air-gapped customers; the vendor never sees their `Users` table. - -Required: - -- **Log every rehash** at `info` via the existing Winston logger (`authn.service.ts:26`): user id, `from: bcrypt`, `to: pbkdf2-sha512`, iterations. A rehash is a security-relevant state change. Without it a stalled migration is invisible and a *spurious* rehash loop is undetectable. Also the audit trail for the migration — since §7 forbids touching `passwordChangedAt`, nothing else records that a credential was converted or when. -- **A `/health` endpoint.** None exists today **[V]** — no `/health`, `/ready`, or `/livez`. Return `{status, version, fips: crypto.getFips() === 1, passwordHashWriteEnabled, bcryptRemaining}`. This is load-bearing four ways: rolling-deploy readiness gating (§12), continuous FIPS evidence rather than a one-time boot log (§10), migration progress, and a machine-readable artifact an assessor can collect without shell access. It is also what makes the removal criterion achievable — a customer sends one JSON blob instead of a DB dump. -- **Progress query**, shipped as an installed script (`/usr/bin/heimdall-server-hash-report`), not a wiki snippet air-gapped operators cannot reach: +Add the `pg_authid` check to `packaging/rpm`'s setup detection so an operator is +warned *before* the app fails to connect. + +### 17. Observability + +**The prior removal criterion — "zero rows across all deployments" — is +unsatisfiable.** MITRE ships to air-gapped customers; the vendor never sees +their tables. + +- **Log every rehash** at `info` via the existing Winston logger: user id, + `from: bcrypt`, `to: pbkdf2-sha512`, iterations. Since §7 forbids touching + `passwordChangedAt`, nothing else records that a credential converted. +- **A `/health` endpoint.** None exists **[V]**. Split it: unauthenticated + liveness returning `{status, version}` only; **authenticated** admin detail + for `fips`, `passwordHashWriteEnabled`, and `bcryptRemaining`. The counts are + a full scan of `Users` — a readiness probe that scans a user table every few + seconds is a self-inflicted outage, and publishing migration state + unauthenticated is a disclosure decision. Note `app.controller.ts:11` already + exposes an unauthenticated `/server` endpoint — the right thing to compare + against. +- **Progress query covering both tables** — the prior draft's covered `Users` + only, so `bcrypt_remaining = 0` could be true while every `ApiKeys.apiKey` + row was still `$2b$`: ```sql SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_remaining, @@ -397,100 +787,184 @@ SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_re max(age(now(), "lastLogin")) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS oldest_unmigrated FROM "Users"; ``` +Plus the `ApiKeys` equivalent. Ship as `heimdall-cli report`, not a wiki snippet +air-gapped operators cannot reach. +- **Admin UI** — per-user legacy-hash badge, bulk force-password-change, and + **bulk API-key invalidation** (keys are *regenerated*, not reset). -- **Admin UI affordance** — a per-user legacy-hash badge and a bulk "force password change for all users on legacy hashes" action. This is the operational close-out for the dormant-account tail and the mechanism behind §3's cutover. - -**Restated removal criterion:** no earlier than N+3, and only after `bcrypt_remaining = 0` is confirmed via the health endpoint across supported deployments **or** a forced-reset release has shipped. - -## STIG and control mapping — corrected +**Restated removal criterion:** no earlier than N+3, and only after +`bcrypt_remaining = 0` across **both tables** is confirmed via the health +endpoint, or a forced-reset release has shipped. -The prior draft's table had four defects. All rule metadata below is **[V]** against the DISA STIG API. +## STIG and control mapping -| Rule | Severity | What it actually requires | Status after this ADR | +| Rule | Severity | Requirement | Status | |---|---|---|---| -| **V-222542** | CAT I | Store only cryptographic representations — "strong cryptographic hash functions" + random salt, MD5 prohibited. **Does not mention FIPS validation.** CCI-004062/000196 | **Already satisfied today** by bcrypt; remains satisfied | -| **V-222571** | **CAT II** | FIPS-validated modules **when generating hashes**. CCI-002450. Invocation-scoped | **Satisfied** once §3's gate lands and legacy hashes are retired | -| **V-222572** | **CAT II** | FIPS-validated modules when protecting unclassified information. CCI-002450. *Omitted from the prior draft* | **Satisfied** on the same condition | -| **V-222543** | CAT I | Transmit only cryptographically-protected passwords. CCI-000197 | **NOT satisfied — and the prior draft claimed it was.** `main.ts:39-45` *explicitly removes* `upgrade-insecure-requests` ("causes issues for users trying to run over http"), and the session cookie is `secure` only in production. Helmet emits headers; it cannot enforce transport. **[V]** Requires a TLS reverse proxy — a deployment requirement, not an application control | -| **V-222570** | CAT II | FIPS-validated modules when **signing application components** — i.e. *code signing*, not JWT signing. CCI-002450 | **Mapping itself is questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, the "already compliant" claim was false: `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** rather than an approved KDF. **[V]** Both are real defects, carded separately. Note the rule's own escape hatch: "If signing has been identified in the application security plan as not being required and if a documented acceptance of risk is provided, this is not a finding" — which requires an AoR artifact we do not have | -| **V-230223** (RHEL 8) | CAT I | System-wide FIPS crypto policy, verified with `update-crypto-policies --show`. CCI-000068 | **Customer host responsibility.** No application change can satisfy an OS crypto-policy rule. Note this is a **RHEL 8** rule while our base image is UBI **9** | -| **V-258241** (RHEL 9) | CAT I | RHEL 9 analog; required hash list adds SHAKE-256 | **Customer host responsibility** | - -**Supporting controls:** IA-5(1)(d) — "passwords are stored using an approved salted key derivation function, preferably using a keyed hash" **[V]** — is the affirmative control this change satisfies. SC-13 assessment objects explicitly include "cryptographic module validation certificates; list of FIPS-validated cryptographic modules" **[V]**, which is why §Certificates below must be exact. - -**[U] — asserted in review but not verified; do not cite without confirmation:** IA-7 applicability to authenticator verification; SI-6 as the control for startup self-verification; V-16793 memory-zeroization applicability (note `password: string` is an immutable GC-managed V8 string that cannot be zeroized — a `Buffer`-based API with explicit zero-fill is the available mitigation, documented as compensating); NIST IR 8547 deprecation dates. - -**SP 800-63B peppering:** the secret-salt step is **SHOULD, not SHALL**, in both Rev 3 §5.1.1.2 and Rev 4 §3.1.1.2. **[V]** We do not pepper. Recorded as a decision rather than an omission. Our 32-byte salt far exceeds 800-63B's 32-*bit* minimum. +| **V-222542** | CAT I | Salted iterated hash; MD5 prohibited. **No FIPS mention.** | Already satisfied; remains so | +| **V-222571** | CAT II | FIPS-validated modules **when generating hashes** | Satisfied once §3's gate lands and legacy hashes retire | +| **V-222572** | CAT II | FIPS-validated modules for unclassified data | Same condition | +| **V-222543** | CAT I | Passwords transmitted cryptographically protected | **NOT satisfied — prior draft claimed it was.** `main.ts:39-45` *explicitly removes* `upgrade-insecure-requests`; cookie `secure` only in production **[V]**. Requires a TLS reverse proxy — deployment requirement, not an application control | +| **V-222570** | CAT II | FIPS-validated modules when **signing application components** (code signing) | **Mapping questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** **[V]**. Carded separately. The rule offers an AoR path we do not have | +| **V-230223** (RHEL 8) / **V-258241** (RHEL 9) | CAT I | System-wide FIPS crypto policy via `update-crypto-policies` | **Customer host responsibility.** No application change satisfies an OS rule. V-230223 is a RHEL **8** rule; our base is UBI **9** | + +**Supporting:** IA-5(1)(d) is the affirmative control **[V]**. SC-13 assessment +objects name validation certificates explicitly **[V]**. + +**Corrections to the prior draft's `[U]` list:** + +- **IA-7 — dropped. It is the wrong control.** IA-7 governs authenticating an + operator **to a cryptographic module**, not an application verifying an end + user's password. DISA's own implementation (APSC-DV-001860): "If the + application does not provide authenticated access to a cryptographic module, + the requirement is not applicable." **[V]** The correct citation is SC-13 via + CCI-002450 — implemented as **APSC-DV-002030**, which *is* V-222571. +- **V-16793 — dropped. Retired.** Zero occurrences in the current ASD STIG + (V6R4, benchmark 2025-10-01); superseded by the 2016 move to `APSC-DV-*` + rules. Nearest current coverage is APSC-DV-002380 (SC-4) and APSC-DV-002330 + (SC-28), both CAT II. **[V]** +- **Memory zeroization — not applicable.** FIPS 140-3 AS09.28 requires zeroising + SSPs "**within the module**"; the application is outside it. IG 9.6.A + explicitly exempts our case: "An approved hash algorithm for a CSP such as a + password that does not need to be recovered but is used to check if it matches + any other values." **[V]** No Node practice exists because the requirement was + never scoped there — the HTTP body parser produces a string before our code + sees it. Mention only as defense-in-depth, if at all. +- **SI-6** — Rev 5 title is "Security **and Privacy** Function Verification", + and it is **HIGH baseline only** (absent from LOW and MODERATE) **[V]**. + Defensible for a startup FIPS check; not mandatory at MODERATE. +- **SP 800-63B peppering** — **SHOULD, not SHALL**, in Rev 3 §5.1.1.2 and Rev 4 + §3.1.1.2 **[V]**. Cite **Rev 4**; Rev 3 was withdrawn 2025-08-01. We do not + pepper; recorded as a decision. Our 32-byte salt far exceeds the 32-*bit* + minimum. +- **NIST IR 8547** — still an **Initial Public Draft**, and "Deprecated after + 2030 / Disallowed after 2035" applies **only to the 112-bit row**; at ≥128-bit + strength there is no 2030 deprecation **[V]**. Cite with both qualifiers or + omit. ## Certificates -The prior draft cited **two wrong certificates**, and this is the first thing an assessor checks. **[V]** — CMVP registry: +The prior draft cited **two wrong certificates** — the first thing an assessor +checks. **[V]**: | Cited | Actual | Verdict | |---|---|---| -| #4985 "RHEL OpenSSL" | **OpenSSL FIPS Provider**, vendor *The OpenSSL Project* | Wrong vendor — upstream generic module, not Red Hat | -| #4754 "Red Hat FIPS 140-3 policy" | **RHEL 9 libgcrypt** v1.10.0 | Wrong library (Node does not use libgcrypt) and **Historical**, superseded by #5366 | +| #4985 "RHEL OpenSSL" | **OpenSSL FIPS Provider**, vendor *The OpenSSL Project* | Wrong vendor | +| #4754 "Red Hat FIPS 140-3 policy" | **RHEL 9 libgcrypt** | Wrong library, and **Historical**, superseded by #5366 | -**Correct:** RHEL 9 OpenSSL FIPS Provider — **#4746** (RHEL 9.0) and **#4857** (RHEL 9.2/9.4/9.5/9.6, **Active**, validated 2024-10-29, sunset 2029-10-28). Policy `140sp4857.pdf` lists PBKDF2 [SP 800-132] Option 1a with ACVP certs A4813/A4823-A4826/A5578/A5585 (SHA-1/2) and A4814/A5587 (SHA-3), password range 8-128 characters. +**Correct: #4857** — "Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", +**Active**, validated 2024-10-29, sunset 2029-10-28. **#4746** covers RHEL 9.0 +but **sunsets 2026-07-30** — do not cite it as current. -The SSP must name the module, version, certificate, **and** the certificate's tested operational environments for what the deployment actually links. +The running module self-identifies as **3.0.7-cda111b5812c30d4** (§15). The SSP +must name module, version, certificate, **and** the certificate's tested +operational environments. ## Scope -**In scope:** pure module + service + Nest module; migration at all **eight** sites; FIPS-gated fallback; the §6 validation sequence; CAS rehash writes; narrow persistence methods; new env vars; launcher preflight + unit hardening + injectable assertion; `/health`; rehash logging; progress script; Postgres documentation and RPM detection; forced-reset cutover script; tests. +**In scope:** pure module + service + Nest module; migration at all **eight** +sites; FIPS-gated fallback; §6 validation; CAS writes; narrow persistence +methods; env vars including configurable complexity; startup assertion; +`/health`; rehash logging; cutover invalidation; progress reporting; +`libs/password-hash-vectors/`; `packaging/rpm` FIPS + Postgres detection; +**heimdall-cli PBKDF2 support (cross-repo, blocking)**. **NOT in scope:** -1. **Changing what API keys hash.** `apikey.service.ts:41` notes bcrypt's 72-byte limit as the reason only the signature is hashed. PBKDF2 removes that limit, but changing it invalidates every existing key. Needs its own ADR and a rotation plan. §11's inefficiency finding is recorded, not acted on. -2. **Migrating to better-auth.** Stays on Passport + Sequelize (`izw` epic). Forward note: v3 returns a bare boolean because better-auth's `verify` contract requires it; our richer return is possible *because* heimdall2 has no such constraint, and a future adapter will discard `needsRehash` on better-auth's path while an outer hook performs the rehash. -3. **Configurable password complexity.** -4. **Removing `bcryptjs`** — required for legacy verification until §16's criterion is met. -5. **Fixing V-222570** (empty-string JWT key, concatenated secret) — real, verified, and separately carded. -6. **Fixing the `passwordChangedAt` column-type mismatch** — pre-existing; documented in §7. -7. **Elastic-style `pbkdf2_stretch`** — the 15-character minimum makes the SHA-512 pre-hash unnecessary. -8. **RPM packaging changes** — `packaging/` does not exist on this branch **[V]**; it lives on `feat/rpm-build` / `saf-packaging`. Cross-repo coordination required, and the §12 downgrade guard is packaging-side, making it a **prerequisite** for enabling writes. - -## Documentation target - -**`ENVIRONMENT_VARIABLES.md` does not exist in this repository. [V]** The prior draft imported that filename from Vulcan. Heimdall2 documents environment variables in the **GitHub wiki** (`README.md:178`). - -That is unusable for this audience: **air-gapped customers cannot read a wiki.** Create **`docs/fips-deployment.md`** in-repo, shipped inside the RPM at `/usr/share/doc/heimdall-server/`. It covers env vars, the FIPS host requirement, Postgres remediation, the migration query, rollout/rollback sequence, and recovery procedure. +1. **Changing what API keys hash** — bcrypt's 72-byte limit is why only the + signature is hashed; changing it invalidates every existing key. Needs its + own ADR and a rotation plan. §11's inefficiency is recorded, not acted on. +2. **Migrating to better-auth** (`izw`). Forward note: v3 returns a bare boolean + because better-auth's `verify` contract requires it; our richer return is + possible *because* heimdall2 has no such constraint. A future adapter + discards `needsRehash` on better-auth's path while an outer hook rehashes. +3. **Self-service password reset** — Heimdall has **no email infrastructure** + (zero `nodemailer`/SMTP anywhere) **[V]**, and outbound mail is often + unavailable in the target deployments. `heimdall-cli` plus admin UI covers + recovery. A forgot-password flow is a separate epic gated on SMTP. +4. **Removing `bcryptjs`** — required for legacy verification until §17's + criterion is met. +5. **Fixing V-222570** (empty-string JWT key, concatenated secret) — real, + verified, carded separately. +6. **The `passwordChangedAt` column-type mismatch** — pre-existing (§7). +7. **Elastic-style `pbkdf2_stretch`** — exists to defeat *bcrypt's 72-byte + truncation*, which PBKDF2 does not have. (The prior draft justified excluding + it by the 15-character minimum, which is a non-sequitur.) ## Risks | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| -| Rollback / mixed-version lockout | Medium | **High** | §12 — write gate, durable marker, `%pre` guard, graceful-degradation test. API keys unrecoverable — regeneration is the only recovery | -| Fresh install ships a bcrypt admin | **High if unfixed** | **High** | §4 site 8; AC asserts `bcrypt_remaining = 0` on a fresh install | +| Rollback / mixed-version lockout | Medium | **High** | §12 — write gate, durable marker, app-side version check. API keys unrecoverable; regeneration is the only path | +| Break-glass tool writes an unusable credential | **High if unfixed** | **High** | §14 — heimdall-cli PBKDF2 support is blocking, not follow-up | +| Fresh install ships a bcrypt admin | **High if unfixed** | **High** | §4 site 8 | | Rehash reverts a password change | Medium | **High** | §7 compare-and-swap | -| Silent FIPS bypass | Medium | **High** | §10 three layers; loud log when `FIPS_MODE` unset | +| Silent FIPS bypass | Medium | **High** | §10 assertion; loud log when `FIPS_MODE` unset | | Auth throughput ceiling / threadpool starvation | Medium | Medium | §11 — `UV_THREADPOOL_SIZE`, global KDF limiter, benchmark on target hardware | -| DoS via long password or forged iterations | Medium | Medium | §6 — 128-char cap, iteration bounds | -| Transitive dependency uses a non-approved digest | Medium | Medium | §13 runtime audit under `--force-fips` | -| Dormant accounts never migrate | **High** | Low | §3 cutover + §16 admin bulk action. bcrypt remains strong meanwhile — the gap is compliance, not security | -| Timing side-channel | Low | Low | Measured ratio is 7.7× (1120 vs 145 ms), trivially separable; identifies dormant never-migrated accounts. Compounded by a pre-existing ~1000× enumeration oracle — `validateUser` performs **no hashing** for a nonexistent user **[V]**. Mitigation is a dummy-hash on the absent/unknown-format paths. **Note:** the prior draft cited Django's `harden_runtime()` for this — incorrectly. That method equalizes *intra-PBKDF2 iteration* differences and cannot bridge a bcrypt-vs-PBKDF2 gap | +| DoS via long password or forged iterations | Medium | Medium | §6 — 128-char cap on hashing, iteration bounds | +| Operator enables FIPS before cutover | Medium | **High** | §3 phased order; heimdall-cli break-glass; document the ordering as a hard rule | +| Transitive dependency uses a non-approved digest | Medium | Medium | §13 runtime audit | +| Dormant accounts never migrate | **High** | Low | §3 cutover + §17 bulk action. bcrypt remains strong — the gap is compliance, not security | +| `requiresReset` becomes an enumeration oracle | Medium | Medium | **Return the generic 401 to unauthenticated callers.** `local.strategy.ts` collapses every failure into one message today; distinguishing "needs reset" would tell an attacker which accounts exist *and* are dormant. Surface migration state only through the authenticated admin surface and logs | +| Timing side-channel | Low | Low | Under FIPS the refuse path does no KDF work at all, so separation is effectively infinite rather than the 7.7× in §11. Mitigation is a **dummy hash on the absent, unknown-format, *and refuse* paths**. Note: the prior draft cited Django's `harden_runtime()` — **incorrectly**; that equalizes *intra-PBKDF2 iteration* differences and cannot bridge a bcrypt-vs-PBKDF2 gap | ## Alternatives considered -1. **Argon2id** — OWASP's first recommendation, **not FIPS-approved**; no revised SP 800-132 draft exists. Keycloak defaults to it and must override in FIPS mode. The PHC format admits it later with no parser change. -2. **Hard cutover** (v3's approach) — clean, but forces a reset for every user. §3's phased design achieves the same terminal state without the disruption. -3. **Keep bcrypt, add `--force-fips`** — compliance theater. The process reports FIPS while a non-approved algorithm runs in pure JS where neither OpenSSL nor the OS can observe it. -4. **Unconditional bcrypt fallback** (the prior draft) — rejected against IG 2.4.A, and weaker than both Keycloak and GitLab. -5. **Adopt an npm package** — no viable candidate. `@phc/pbkdf2` last published **2018**, repo dead since 2021, no types, 13 stars. `pbkdf2-password` defaults to **SHA-1**. Everything maintained uses a non-approved KDF or native/WASM bindings that bypass OpenSSL. We borrow the format spec and implement ~80 lines. -6. **Spring-style opt-in rehash service** — `UserDetailsPasswordService` silently no-ops when unwired. Django's inline setter fails loudly. We follow Django. +1. **Argon2id** — OWASP's first recommendation, **not FIPS-approved**; no + revised SP 800-132 draft exists. Keycloak defaults to it and must override in + FIPS mode. The PHC format admits it later with no parser change. +2. **Hard cutover** (v3's approach) — forces a reset for every user. §3's phased + design reaches the same terminal state without it. +3. **Keep bcrypt, add `--force-fips`** — compliance theater, and on RHEL the + flag is discouraged outright (§10). +4. **Unconditional bcrypt fallback** (prior draft) — weaker than both Keycloak + and GitLab. +5. **Adopt an npm package** — no viable candidate. `@phc/pbkdf2` last published + **2018**, repo dead since 2021, no types, 13 stars. `pbkdf2-password` defaults + to **SHA-1**. Everything maintained uses a non-approved KDF or native/WASM + bindings that bypass OpenSSL. **[V]** +6. **Spring-style opt-in rehash service** — `UserDetailsPasswordService` + silently no-ops when unwired. Django's inline setter fails loudly. We follow + Django. +7. **Move heimdall-cli into this monorepo** — rejected (§14). Fourteen of its + fifteen commands are deployment-domain, its `go.mod` already declares a + top-level module path, and its value is being a zero-dependency static binary. ## Guiding principle -GitLab's stated tiebreaker, adopted: **when security and compliance cannot both be satisfied, favor security.** Nothing here requires that trade — PBKDF2 at 600k is both — but it governs any future conflict. +GitLab's stated tiebreaker, adopted: **when security and compliance cannot both +be satisfied, favor security.** Nothing here requires that trade — PBKDF2 at +600k is both — but it governs any future conflict. ## References -**Standards (verified)** — [SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) · [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) · [FIPS 140-3 Implementation Guidance](https://csrc.nist.gov/CSRC/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS%20140-3%20IG.pdf) (IG 2.3.A, 2.4.A, D.N) · [SP 800-53A Rev 5](https://csrc.nist.gov/pubs/sp/800/53/a/r5/final) (SC-13, IA-5(1)(d)) · [SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html) · [FedRAMP Cryptographic Module Policy v1.1.0](https://www.fedramp.gov/resources/documents/FedRAMP_Policy_for_Cryptographic_Module_Selection_v1.1.0.pdf) (FRR8) · [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) · [OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - -**Certificates** — [CMVP #4857](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4857) (RHEL 9 OpenSSL, Active) · [#4746](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4746) (RHEL 9.0) · [#4985](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4985) (OpenSSL Project — *not* Red Hat) · [#4754](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4754) (libgcrypt, Historical) - -**Implementations** — [GitLab FIPS](https://docs.gitlab.com/development/fips_gitlab/) · [GitLab password storage](https://docs.gitlab.com/security/password_storage/) · [GitLab #360659](https://gitlab.com/gitlab-org/gitlab/-/issues/360659) · [Keycloak FIPS](https://www.keycloak.org/server/fips) · [Django CVE-2013-1443](https://www.djangoproject.com/weblog/2013/sep/15/security/) · [Django hashers.py](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) · [phc-pbkdf2](https://github.com/simonepri/phc-pbkdf2) - -**Known breakage** — [node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706) - -**In-repo prior art** — Heimdall v3 `a52f6ceb` (`mitre/heimdall`) · `fips_compliance` `cbfa40946`, `b384fd335`, `310c24a3c` +**Standards** — [SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) · +[FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) · +[FIPS 140-3 IG](https://csrc.nist.gov/CSRC/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS%20140-3%20IG.pdf) +(2.4.A, 2.4.C, 9.6.A, D.N) · +[CMVP Management Manual](https://csrc.nist.gov/csrc/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS-140-3-CMVP%20Management%20Manual.pdf) +(§7.9) · [SP 800-53A Rev 5](https://csrc.nist.gov/pubs/sp/800/53/a/r5/final) +(SC-13, IA-5(1)(d), SI-6) · [SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html) · +[FedRAMP Cryptographic Module Policy v1.1](https://www.fedramp.gov/resources/documents/FedRAMP_Policy_for_Cryptographic_Module_Selection_v1.1.0.pdf) +(FRR8) · [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) · +[OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) + +**Certificates** — [#4857](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4857) (Active) · +[#4746](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4746) (sunsets 2026-07-30) · +[#4985](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4985) (OpenSSL Project, *not* Red Hat) · +[#4754](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4754) (libgcrypt, Historical) + +**Implementations** — [GitLab FIPS](https://docs.gitlab.com/development/fips_gitlab/) · +[GitLab password storage](https://docs.gitlab.com/security/password_storage/) · +[GitLab #360659](https://gitlab.com/gitlab-org/gitlab/-/issues/360659) · +[Keycloak FIPS](https://www.keycloak.org/server/fips) · +[Django CVE-2013-1443](https://www.djangoproject.com/weblog/2013/sep/15/security/) · +[Django hashers.py](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) · +[phc-pbkdf2](https://github.com/simonepri/phc-pbkdf2) + +**Known breakage** — [jshttp/etag#17](https://github.com/jshttp/etag/issues/17) · +[node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706) (a PR, not an issue) + +**In-repo / cross-repo** — Heimdall v3 `a52f6ceb` (`mitre/heimdall`) · +`fips_compliance` branch `cbfa40946`, `b384fd335`, `310c24a3c` · +[mitre/heimdall-cli](https://github.com/mitre/heimdall-cli) · +`packaging/rpm/` (imported `35d47dee3`) From d2fca993ea5774aa0147de0f232afd297f3d856a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 22:37:29 -0400 Subject: [PATCH 013/197] build: repair RPM build inputs after the move into this repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import in 35d47dee3 left four references pointing at saf-packaging's directory layout, where heimdall-server/, heimdall-cli/, and scripts/ were siblings. None of them resolve from packaging/rpm/ in this repo, so the RPM could not be built from its new home. sources: no longer downloads a tarball from GitHub. The packaging now lives inside heimdall2, so the source is two directories up — scripts/fetch-source.sh is not ported. Two thirds of that script was a dispatcher for vulcan and saf-cli, which is dead weight here, and dropping the download keeps airgapped builds working without a mirror. RPMs are built from tagged points in time, so `sources` archives the tag matching the spec Version (v$(VERSION)) and fails with an actionable message when that tag is not present locally. DEV=1 archives HEAD instead, clearly marked as not a release build. heimdall-cli is a separate repository now, not a sibling directory, so it is cloned at a pinned ref rather than referenced by path. HEIMDALL_CLI_REF is the contract carrier — it records exactly which CLI a given RPM shipped, and release builds must override it with a tag. Man pages are generated from the same pinned checkout as the binary, so they cannot drift from the commands they document. Fixes a real bug while here: CLI_COMMIT was `git rev-parse HEAD` evaluated in the packaging repo, so the binary's --version output reported a heimdall2 commit as the heimdall-cli commit. It now resolves from the CLI checkout. scripts/setup-build-deps.sh is ported (7 heimdall references, 0 references to any other package — it was mis-filed in saf-packaging's shared scripts/). copr-submit.sh and sign-rpms.sh stay there; both are genuinely cross-SAF distribution tooling. Adds a guard so a developer on macOS gets "rpmspec is required" rather than "tag v not found". NOT yet verified by an actual build — that requires a RHEL-family host and is the gate before saf-packaging's copies are removed (heimdall2-30c.5). Authored by: Aaron Lippold --- packaging/rpm/Makefile | 90 ++++++-- packaging/rpm/scripts/setup-build-deps.sh | 248 ++++++++++++++++++++++ 2 files changed, 318 insertions(+), 20 deletions(-) create mode 100755 packaging/rpm/scripts/setup-build-deps.sh diff --git a/packaging/rpm/Makefile b/packaging/rpm/Makefile index 0f463ec544..fc169eb9e0 100644 --- a/packaging/rpm/Makefile +++ b/packaging/rpm/Makefile @@ -5,9 +5,8 @@ TOPDIR := $(shell pwd)/rpmbuild # Target architecture for cross-compilation (default: host arch) GOARCH ?= $(shell go env GOARCH) -CLI_DIR := ../heimdall-cli -.PHONY: srpm rpm rpm-install rpm-package clean lint lint-rpm sources heimdall-cli man stage deps check-upstream bump-version +.PHONY: srpm rpm rpm-install rpm-package clean lint lint-rpm sources cli-src heimdall-cli man stage deps check-upstream bump-version # Upstream repo for `make check-upstream` (override for forks/mirrors). UPSTREAM_REPO ?= mitre/heimdall2 @@ -24,43 +23,94 @@ else RPMBUILD_PREFIX := endif +# Source tarball, produced from the local repository at the TAG matching the +# spec Version. RPMs are built from tagged points in time, never from mainline. +# +# This replaces the previous scripts/fetch-source.sh download: the packaging now +# lives inside heimdall2, so the source is two directories up and there is no +# reason to reach across the network for it. That also keeps airgapped builds +# working without a mirror. +# +# Set DEV=1 to archive the working tree's HEAD instead. Development only — +# the resulting RPM does not correspond to any released version. +SRC_REF := v$(VERSION) sources: - @echo "Downloading source tarball for $(NAME)-$(VERSION)..." + @test -n "$(VERSION)" || { \ + echo "ERROR: could not read Version from $(SPEC)."; \ + echo " 'rpmspec' is required (rpm-build package) and is not available on macOS."; \ + echo " Build RPMs on a RHEL-family host, or in a container."; \ + exit 1; } @mkdir -p $(TOPDIR)/SOURCES - ../scripts/fetch-source.sh \ - --package heimdall-server \ - --version $(VERSION) \ - --output-dir $(TOPDIR)/SOURCES - @# fetch-source.sh produces heimdall-server-VERSION.tar.gz; spec expects heimdall2-VERSION.tar.gz - mv $(TOPDIR)/SOURCES/heimdall-server-$(VERSION).tar.gz \ - $(TOPDIR)/SOURCES/heimdall2-$(VERSION).tar.gz +ifeq ($(DEV),1) + @echo "WARNING: DEV=1 — archiving HEAD, not $(SRC_REF). Not a release build." + @git -C ../.. archive --format=tar.gz \ + --prefix=heimdall2-$(VERSION)/ HEAD \ + -o $(abspath $(TOPDIR)/SOURCES/heimdall2-$(VERSION).tar.gz) +else + @git -C ../.. rev-parse --verify --quiet $(SRC_REF) >/dev/null || { \ + echo "ERROR: tag $(SRC_REF) not found in this repository."; \ + echo " RPMs are built from tagged releases. Fetch tags with 'git fetch --tags',"; \ + echo " or build a development RPM with 'make rpm DEV=1'."; \ + exit 1; } + @echo "Archiving $(NAME)-$(VERSION) from tag $(SRC_REF)..." + @git -C ../.. archive --format=tar.gz \ + --prefix=heimdall2-$(VERSION)/ $(SRC_REF) \ + -o $(abspath $(TOPDIR)/SOURCES/heimdall2-$(VERSION).tar.gz) +endif + +# heimdall-cli source, cloned at a pinned ref. +# +# The CLI lives at github.com/mitre/heimdall-cli — it is a separate repository, +# not a sibling directory, so it is fetched rather than referenced by path. +# +# HEIMDALL_CLI_REF is the contract carrier: it records exactly which CLI a given +# RPM shipped. Release builds MUST override it with a tag. `main` is a +# development default only. +HEIMDALL_CLI_REPO ?= https://github.com/mitre/heimdall-cli.git +HEIMDALL_CLI_REF ?= main +CLI_DIR := $(TOPDIR)/heimdall-cli-src + +cli-src: + @mkdir -p $(TOPDIR) + @if [ -d "$(CLI_DIR)/.git" ]; then \ + git -C $(CLI_DIR) fetch --quiet --tags origin; \ + else \ + git clone --quiet $(HEIMDALL_CLI_REPO) $(CLI_DIR); \ + fi + @git -C $(CLI_DIR) checkout --quiet --detach $(HEIMDALL_CLI_REF) || { \ + echo "ERROR: ref '$(HEIMDALL_CLI_REF)' not found in $(HEIMDALL_CLI_REPO)"; exit 1; } + @echo "heimdall-cli source at $(HEIMDALL_CLI_REF) ($$(git -C $(CLI_DIR) rev-parse --short HEAD))" # Build Go CLI binary for the target platform (static, no CGO). # Produces a flat binary at rpmbuild/SOURCES/heimdall-cli (spec Source15). # All three version fields (Version/Commit/Date) are injected so -# `heimdall-cli --version` shows real provenance — matches what the -# heimdall-cli/Makefile injects for its own build target. -CLI_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) -CLI_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -heimdall-cli: +# `heimdall-cli --version` shows real provenance. +# +# CLI_COMMIT is resolved from the CLI checkout, not from heimdall2. The previous +# `git rev-parse HEAD` ran in the packaging repo and stamped the wrong commit +# into the binary's version output. +CLI_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +heimdall-cli: cli-src @mkdir -p $(TOPDIR)/SOURCES cd $(CLI_DIR) && GOOS=linux GOARCH=$(GOARCH) CGO_ENABLED=0 \ go build -trimpath \ -ldflags="-s -w \ -X 'github.com/mitre/heimdall-cli/internal/version.Version=$(VERSION)' \ - -X 'github.com/mitre/heimdall-cli/internal/version.Commit=$(CLI_COMMIT)' \ + -X 'github.com/mitre/heimdall-cli/internal/version.Commit=$$(git rev-parse --short HEAD)' \ -X 'github.com/mitre/heimdall-cli/internal/version.Date=$(CLI_DATE)'" \ -o $(abspath $(TOPDIR)/SOURCES/heimdall-cli) \ ./cmd/heimdall-cli - @echo "Built: $(TOPDIR)/SOURCES/heimdall-cli (linux/$(GOARCH), commit $(CLI_COMMIT))" + @echo "Built: $(TOPDIR)/SOURCES/heimdall-cli (linux/$(GOARCH), ref $(HEIMDALL_CLI_REF))" -# Generate man pages from CLI command tree. -man: +# Generate man pages from the CLI command tree. +# Generated from the same pinned checkout as the binary, so the pages can never +# drift from the commands they document. +man: cli-src cd $(CLI_DIR) && go run ./cmd/gen-manpages $(abspath man/man1) # Install build dependencies (delegates to setup script). deps: - ../scripts/setup-build-deps.sh + scripts/setup-build-deps.sh # Stage all source files into rpmbuild tree (Sources 0-21). # Single source of truth for which files go into the RPM. diff --git a/packaging/rpm/scripts/setup-build-deps.sh b/packaging/rpm/scripts/setup-build-deps.sh new file mode 100755 index 0000000000..4e307197b6 --- /dev/null +++ b/packaging/rpm/scripts/setup-build-deps.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# setup-build-deps.sh — Install repositories and packages needed to build +# heimdall-server RPMs on RHEL-family systems. +# +# Supported: RHEL, Oracle Linux, CentOS Stream, Rocky Linux, AlmaLinux (EL8, EL9) +# +# Does NOT build anything, fetch source, or run rpmbuild. +# After running this script, build with: cd heimdall-server && make rpm +# +# Usage: +# sudo ./scripts/setup-build-deps.sh [options] +# +# Options: +# --skip-update Skip dnf update +# --with-pgdg Also install PGDG PostgreSQL repo +# --no-gpg-check Disable GPG checks (air-gapped environments) +# -h, --help Show this help + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +RUN_DNF_UPDATE=1 +ENABLE_PGDG=0 +NO_GPG_CHECK=0 + +usage() { + cat <&2; usage 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Detect platform +# --------------------------------------------------------------------------- +el_major="" +if command -v rpm >/dev/null 2>&1; then + el_major="$(rpm -E '%{?rhel}')" +fi +if [[ -z "${el_major}" || "${el_major}" == "%{?rhel}" ]]; then + el_major="$(. /etc/os-release 2>/dev/null && printf '%s' "${VERSION_ID%%.*}")" +fi +if [[ -z "${el_major}" ]]; then + echo "Error: cannot determine EL major version." >&2 + echo "This script supports RHEL, Oracle Linux, CentOS Stream, Rocky, and Alma (EL8/EL9)." >&2 + exit 1 +fi + +distro_name="EL${el_major}" +if [[ -f /etc/os-release ]]; then + distro_name="$(. /etc/os-release && echo "${NAME} ${VERSION_ID}")" +fi +echo "Platform: ${distro_name} ($(uname -m))" + +# Sudo detection (skip if already root, e.g. inside a container) +SUDO="" +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + SUDO="sudo" +fi + +# DNF args +DNF_ARGS=(-y) +if [[ "${NO_GPG_CHECK}" -eq 1 ]]; then + DNF_ARGS+=("--nogpgcheck" "--setopt=*.gpgcheck=0" "--setopt=*.repo_gpgcheck=0") +fi + +# --------------------------------------------------------------------------- +# Step 1: Enable required repositories +# --------------------------------------------------------------------------- +echo "" +echo "=== Step 1/5: Repositories ===" + +# --- EPEL --- +# EPEL is needed for yarnpkg and other build deps. +# Package name varies: epel-release (CentOS/Rocky/Alma), oracle-epel-release-el* (OL) +if ! rpm -q epel-release >/dev/null 2>&1 && \ + ! rpm -q oracle-epel-release-el${el_major} >/dev/null 2>&1; then + echo " Installing EPEL..." + ${SUDO} dnf install "${DNF_ARGS[@]}" epel-release 2>/dev/null \ + || ${SUDO} dnf install "${DNF_ARGS[@]}" \ + "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${el_major}.noarch.rpm" \ + || echo " Warning: EPEL install failed (may need manual setup on RHEL with subscription-manager)" +else + echo " EPEL: already installed" +fi + +# --- CRB / PowerTools / CodeReady Builder --- +# Name varies across distros. Try all known names; at least one should work. +echo " Enabling CRB/PowerTools..." +enabled_crb=0 +for repo_name in crb powertools PowerTools \ + "ol${el_major}_codeready_builder" \ + "codeready-builder-for-rhel-${el_major}-$(uname -m)-rpms"; do + if ${SUDO} dnf config-manager --set-enabled "${repo_name}" 2>/dev/null; then + echo " Enabled: ${repo_name}" + enabled_crb=1 + break + fi +done +if [[ "${enabled_crb}" -eq 0 ]]; then + echo " Warning: could not enable CRB/PowerTools (may already be enabled or not available)" +fi + +# --- NodeSource (Node.js 22) --- +# We use NodeSource on ALL platforms for consistency. AppStream modules +# may not have Node.js 22 on all EL8/EL9 minor versions and distro variants. +if ! rpm -q nodesource-release >/dev/null 2>&1; then + echo " Installing NodeSource repo for Node.js 22..." + curl -fsSL https://rpm.nodesource.com/setup_22.x | ${SUDO} bash - +else + echo " NodeSource: already installed" +fi + +# --- PGDG (optional) --- +if [[ "${ENABLE_PGDG}" -eq 1 ]]; then + echo " Setting up PGDG PostgreSQL repo..." + local_arch="$(uname -m)" + pgdg_url="https://download.postgresql.org/pub/repos/yum/reporpms/EL-${el_major}-${local_arch}/pgdg-redhat-repo-latest.noarch.rpm" + ${SUDO} dnf install "${DNF_ARGS[@]}" "${pgdg_url}" 2>/dev/null || true + ${SUDO} dnf module disable postgresql "${DNF_ARGS[@]}" 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# Step 2: System update (optional) +# --------------------------------------------------------------------------- +if [[ "${RUN_DNF_UPDATE}" -eq 1 ]]; then + echo "" + echo "=== Step 2/5: System update ===" + ${SUDO} dnf update "${DNF_ARGS[@]}" +else + echo "" + echo "=== Step 2/5: System update (skipped -- use --skip-update to suppress) ===" +fi + +# --------------------------------------------------------------------------- +# Step 3: Install build packages +# --------------------------------------------------------------------------- +echo "" +echo "=== Step 3/5: Build packages ===" +${SUDO} dnf install "${DNF_ARGS[@]}" \ + gcc-c++ \ + make \ + git \ + nodejs \ + python3 \ + openssl \ + rpm-build \ + rpmdevtools \ + rpmlint \ + redhat-rpm-config \ + selinux-policy-devel \ + systemd-rpm-macros \ + tar \ + curl \ + util-linux + +# --------------------------------------------------------------------------- +# Step 4: Yarn +# --------------------------------------------------------------------------- +# The spec uses BuildRequires: /usr/bin/yarn. This must be satisfied by an RPM +# package (not corepack), because rpmbuild checks the RPM database, not $PATH. +# +# Priority: yarnpkg from EPEL > yarn from Yarn's own repo +echo "" +echo "=== Step 4/5: Yarn ===" +if command -v yarn >/dev/null 2>&1 && rpm -qf "$(command -v yarn)" >/dev/null 2>&1; then + echo " Yarn: $(yarn --version) ($(rpm -qf "$(command -v yarn)"))" +else + # Try yarnpkg from EPEL first + if ${SUDO} dnf install "${DNF_ARGS[@]}" yarnpkg 2>/dev/null; then + echo " Yarn: $(yarn --version) (yarnpkg from EPEL)" + else + # Fall back to Yarn's own RPM repo + echo " yarnpkg not in EPEL; adding Yarn repo..." + ${SUDO} curl -fsSL https://dl.yarnpkg.com/rpm/yarn.repo \ + -o /etc/yum.repos.d/yarn.repo + ${SUDO} dnf install "${DNF_ARGS[@]}" yarn + echo " Yarn: $(yarn --version) (yarn from dl.yarnpkg.com)" + fi +fi + +# --------------------------------------------------------------------------- +# Step 5/5: Go (required for building heimdall-cli) +# --------------------------------------------------------------------------- +# Distro Go packages are typically too old (1.20-1.21). We install the +# official Go tarball from go.dev which works on all EL variants. +GO_VERSION="${GO_VERSION:-1.24.4}" +echo "" +echo "=== Step 5/5: Go ===" +if command -v go >/dev/null 2>&1; then + installed_go="$(go version | grep -oP 'go\K[0-9]+\.[0-9]+')" + echo " Go: $(go version) (already installed)" +else + arch_suffix="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" + go_tarball="go${GO_VERSION}.linux-${arch_suffix}.tar.gz" + echo " Installing Go ${GO_VERSION}..." + curl -fsSL "https://go.dev/dl/${go_tarball}" | ${SUDO} tar -C /usr/local -xzf - + if [[ ! -f /etc/profile.d/golang.sh ]]; then + echo 'export PATH=$PATH:/usr/local/go/bin' | ${SUDO} tee /etc/profile.d/golang.sh >/dev/null + fi + export PATH="$PATH:/usr/local/go/bin" + echo " Go: $(go version)" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "==========================================" +echo " Build dependencies installed." +echo "" +echo " Build the RPM:" +echo " cd heimdall-server" +echo " make rpm GOARCH=amd64" +echo "" +echo " Or step by step:" +echo " make sources # Download upstream source" +echo " make heimdall-cli # Build Go CLI binary" +echo " make man # Generate man pages" +echo " make stage # Stage all files for rpmbuild" +echo " make rpm # Run rpmbuild" +echo "" +echo " For build options: make -n rpm" +echo "==========================================" From 7fb61561c3403fa6b22df37fbc5386645f0a4294 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 22:39:57 -0400 Subject: [PATCH 014/197] build: resolve RPM version from the repository, not rpmspec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAME and VERSION came from `rpmspec`, which only exists on RHEL-family hosts. Every version-dependent target was therefore unusable on a developer laptop, and failed with a misleading message ("tag v not found") rather than an honest one, because VERSION silently resolved to an empty string. Version now resolves with POSIX sed, which works everywhere: - The repository VERSION file is canonical. It is what the release process and release-drafter already work from. - The spec's own Version: field is read separately and compared against it by a new check-version target that every version-dependent target depends on. The comparison is the point. A spec that drifts from the repository version is not hypothetical — the copy on feat/rpm-build sat at 2.12.6 while the shipped one tracked to 2.13.1, and nothing caught it. Now a mismatch fails the build with both values printed. check-upstream no longer shells out to rpmspec either; it reuses the resolved VERSION. rpmspec is still required to actually build an RPM, which is correct — it is just no longer required to read a version number. Authored by: Aaron Lippold --- packaging/rpm/Makefile | 45 +++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/packaging/rpm/Makefile b/packaging/rpm/Makefile index fc169eb9e0..5ba1926250 100644 --- a/packaging/rpm/Makefile +++ b/packaging/rpm/Makefile @@ -1,12 +1,39 @@ SPEC := heimdall-server.spec -NAME := $(shell rpmspec -q --qf '%{name}' $(SPEC) 2>/dev/null | head -1) -VERSION := $(shell rpmspec -q --qf '%{version}' $(SPEC) 2>/dev/null | head -1) TOPDIR := $(shell pwd)/rpmbuild +# Version resolution is deliberately POSIX sed, not `rpmspec`. +# +# rpmspec only exists on RHEL-family hosts, which made every version-dependent +# target unusable on a developer laptop and produced misleading errors ("tag v +# not found") rather than an honest one. +# +# The repository VERSION file is canonical — it is what the release process and +# release-drafter work from. The spec's own Version: field is read separately +# and checked against it, so the two cannot silently drift. That drift is not +# hypothetical: the copy of this spec on feat/rpm-build sat at 2.12.6 while the +# shipped one tracked to 2.13.1. +NAME := $(shell sed -n 's/^Name:[[:space:]]*//p' $(SPEC) | head -1) +SPEC_VERSION := $(shell sed -n 's/^Version:[[:space:]]*//p' $(SPEC) | head -1) +VERSION := $(shell sed -e 's/^v//' -e 's/[[:space:]]//g' ../../VERSION 2>/dev/null) + +# Fail fast when the spec and the repository disagree about the version. +# Every version-dependent target depends on this. +check-version: + @test -n "$(VERSION)" || { \ + echo "ERROR: could not read ../../VERSION"; exit 1; } + @test -n "$(SPEC_VERSION)" || { \ + echo "ERROR: could not read Version: from $(SPEC)"; exit 1; } + @test "$(VERSION)" = "$(SPEC_VERSION)" || { \ + echo "ERROR: version mismatch."; \ + echo " VERSION file: $(VERSION)"; \ + echo " $(SPEC): $(SPEC_VERSION)"; \ + echo " Update the spec's Version: field to match the repository version."; \ + exit 1; } + # Target architecture for cross-compilation (default: host arch) GOARCH ?= $(shell go env GOARCH) -.PHONY: srpm rpm rpm-install rpm-package clean lint lint-rpm sources cli-src heimdall-cli man stage deps check-upstream bump-version +.PHONY: srpm rpm rpm-install rpm-package clean lint lint-rpm check-version sources cli-src heimdall-cli man stage deps check-upstream bump-version # Upstream repo for `make check-upstream` (override for forks/mirrors). UPSTREAM_REPO ?= mitre/heimdall2 @@ -34,12 +61,7 @@ endif # Set DEV=1 to archive the working tree's HEAD instead. Development only — # the resulting RPM does not correspond to any released version. SRC_REF := v$(VERSION) -sources: - @test -n "$(VERSION)" || { \ - echo "ERROR: could not read Version from $(SPEC)."; \ - echo " 'rpmspec' is required (rpm-build package) and is not available on macOS."; \ - echo " Build RPMs on a RHEL-family host, or in a container."; \ - exit 1; } +sources: check-version @mkdir -p $(TOPDIR)/SOURCES ifeq ($(DEV),1) @echo "WARNING: DEV=1 — archiving HEAD, not $(SRC_REF). Not a release build." @@ -191,9 +213,8 @@ clean: # Compare the spec's Version against the latest GitHub release on UPSTREAM_REPO. # Read-only: never touches the spec. Useful before `make bump-version`. # Uses gh CLI if available, otherwise falls back to curl (always present on RHEL). -check-upstream: - @command -v rpmspec >/dev/null 2>&1 || { echo "rpmspec required (rpm-build package)"; exit 1; } - @spec_ver=$$(rpmspec -q --qf '%{version}\n' $(SPEC) 2>/dev/null | head -1); \ +check-upstream: check-version + @spec_ver="$(VERSION)"; \ if command -v gh >/dev/null 2>&1; then \ gh_tag=$$(gh release view --repo $(UPSTREAM_REPO) --json tagName -q '.tagName' 2>/dev/null); \ else \ From 987bfffc9cc82fc1a3166d464ea438ef7100df5d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 22:47:52 -0400 Subject: [PATCH 015/197] ci: build RPMs for el8/el9 on x86_64 and aarch64, attach to releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit heimdall2 has never built an RPM in CI, and release v2.13.1 ships zero downloadable assets. This adds both. Structure adapted from redis/memtier_benchmark's release-rpm.yml, which is the closest production analogue: distro containers matrixed over EL version and architecture, an install smoke test, and release upload gated on the event. One deliberate divergence. memtier builds an SRPM once and installs it into each arch job so both come from identical sources. That does not work here: Source15 is a pre-built architecture-specific heimdall-cli binary, so an x86_64 SRPM cannot build an aarch64 RPM. Each arch does a full native build instead. Sources stay identical by construction — every leg archives the same tag and clones heimdall-cli at the same HEIMDALL_CLI_REF. Choices worth recording: - Native ubuntu-24.04-arm runners rather than QEMU. Free for public repos, and roughly 5-10x faster for a Node + Go build; QEMU routinely times out on compiles this size. The Go CLI is built natively, not cross-compiled. - One workflow, not two. PRs touching packaging/ prove the RPM still builds and installs; a published release additionally uploads. The thing that ships is the thing CI exercised. - CI builds with DEV=1 (archives HEAD) because on a feature branch the tag for an in-progress version does not exist yet. Releases build from the tag. The flag makes that explicit rather than silently producing an RPM that claims to be a release. - The smoke test installs into a clean container with no build dependencies present, so a missing Requires: fails in CI rather than on a customer host. It also asserts heimdall-cli is installed and reports its provenance, which is what proves HEIMDALL_CLI_REF threaded through correctly. - actions/attest-build-provenance rather than GPG signing. smallstep/cli ships .sigstore.json alongside each RPM; this is the same idea and keeps a release key out of Actions secrets. Whether MITRE also wants detached GPG signatures is a decision for the security team, not a default. - make deps is the same script a human runs, so CI and local builds cannot drift. Not yet exercised — the workflow has never run. First PR touching packaging/ will be the real test. Authored by: Aaron Lippold --- .github/workflows/build-rpm.yml | 204 ++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 .github/workflows/build-rpm.yml diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml new file mode 100644 index 0000000000..f503f13696 --- /dev/null +++ b/.github/workflows/build-rpm.yml @@ -0,0 +1,204 @@ +name: Build RPM + +# One workflow for both purposes, following redis/memtier_benchmark: +# every PR and master push proves the RPM still builds and installs, and a +# published release additionally uploads the artifacts. Publishing steps are +# gated on the event rather than split into a second workflow, so the thing +# that ships is the thing CI exercised. + +on: + pull_request: + paths: + - 'packaging/**' + - 'VERSION' + - '.github/workflows/build-rpm.yml' + push: + branches: [master] + paths: + - 'packaging/**' + - 'VERSION' + - '.github/workflows/build-rpm.yml' + release: + types: [published] + workflow_dispatch: + inputs: + heimdall_cli_ref: + description: 'heimdall-cli ref to build against (tag, branch, or SHA)' + required: false + default: 'main' + +permissions: + contents: read + +env: + # Release builds must pin a tag. Everything else tracks main. + # This value is what the RPM records as the CLI it shipped. + HEIMDALL_CLI_REF: ${{ inputs.heimdall_cli_ref || 'main' }} + +jobs: + build: + name: ${{ matrix.distro }} / ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + include: + - distro: el8 + image: rockylinux:8 + arch: x86_64 + runner: ubuntu-latest + - distro: el9 + image: rockylinux:9 + arch: x86_64 + runner: ubuntu-latest + # Native ARM runners — free for public repositories, and roughly + # 5-10x faster than QEMU emulation for a Node + Go build. The Go CLI + # is compiled natively here rather than cross-compiled. + - distro: el8 + image: rockylinux:8 + arch: aarch64 + runner: ubuntu-24.04-arm + - distro: el9 + image: rockylinux:9 + arch: aarch64 + runner: ubuntu-24.04-arm + + steps: + - name: Install git (required before checkout in a bare container) + run: | + dnf install -y git + git --version + + - uses: actions/checkout@v6 + with: + # Full history and tags: `make sources` archives the tag matching the + # VERSION file. A shallow clone has no tags and the build would fail. + fetch-depth: 0 + fetch-tags: true + + - name: Mark workspace safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install build dependencies + working-directory: packaging/rpm + run: make deps + + - name: Install Go (for the heimdall-cli build) + run: dnf install -y golang + + - name: Verify spec and repository versions agree + working-directory: packaging/rpm + run: make check-version + + # Releases build from the tag matching VERSION. CI builds from HEAD, + # because on a feature branch the tag for an in-progress version does not + # exist yet. DEV=1 makes that explicit rather than silently producing an + # RPM that claims to be a release. + - name: Build RPM + working-directory: packaging/rpm + env: + HEIMDALL_CLI_REF: ${{ env.HEIMDALL_CLI_REF }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "release" ]; then + make rpm + else + make rpm DEV=1 + fi + + - name: Lint the built packages (advisory) + working-directory: packaging/rpm + continue-on-error: true + run: make lint-rpm + + - name: Record what was built + working-directory: packaging/rpm + run: | + find rpmbuild/RPMS rpmbuild/SRPMS -name '*.rpm' -printf '%f\n' | sort + echo "heimdall-cli ref: ${HEIMDALL_CLI_REF}" + + - uses: actions/upload-artifact@v7 + with: + name: rpm-${{ matrix.distro }}-${{ matrix.arch }} + path: | + packaging/rpm/rpmbuild/RPMS/**/*.rpm + packaging/rpm/rpmbuild/SRPMS/*.rpm + retention-days: 7 + if-no-files-found: error + + # The highest-value check: install into a clean container with NO build + # dependencies pre-installed, so a missing Requires: fails here rather than on + # a customer's host. Verifies %files claims via rpm -ql. + smoke-test: + name: install ${{ matrix.distro }} / ${{ matrix.arch }} + needs: build + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + include: + - {distro: el8, image: rockylinux:8, arch: x86_64, runner: ubuntu-latest} + - {distro: el9, image: rockylinux:9, arch: x86_64, runner: ubuntu-latest} + - {distro: el8, image: rockylinux:8, arch: aarch64, runner: ubuntu-24.04-arm} + - {distro: el9, image: rockylinux:9, arch: aarch64, runner: ubuntu-24.04-arm} + + steps: + - uses: actions/download-artifact@v8 + with: + name: rpm-${{ matrix.distro }}-${{ matrix.arch }} + path: rpms + + - name: Install the package and its dependencies + run: | + dnf install -y epel-release || true + rpm_file=$(find rpms -name "heimdall-server-*.${{ matrix.arch }}.rpm" | head -1) + test -n "$rpm_file" || { echo "::error::no binary RPM found"; exit 1; } + echo "Installing $rpm_file" + dnf install -y "$rpm_file" + + - name: Verify the package contents match its manifest + run: | + rpm -q heimdall-server + rpm -V heimdall-server || true # config file changes are expected + echo "--- files ---" + rpm -ql heimdall-server | head -40 + echo "--- heimdall-cli is present and reports provenance ---" + test -x /usr/bin/heimdall-cli + /usr/bin/heimdall-cli --version + + - name: Verify the unit file is valid + run: | + dnf install -y systemd + systemd-analyze verify /usr/lib/systemd/system/heimdall-server.service || true + + publish: + name: Attach RPMs to the release + needs: [build, smoke-test] + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: write # upload release assets + id-token: write # build provenance attestation + attestations: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: rpm-* + path: rpms + merge-multiple: true + + - name: List artifacts + run: find rpms -name '*.rpm' -printf '%f\n' | sort + + # The modern equivalent of GPG-signing in CI: a signed, verifiable + # statement of what built these artifacts and from where. Same + # supply-chain story as npm provenance, without a release key in secrets. + - uses: actions/attest-build-provenance@v4 + with: + subject-path: 'rpms/**/*.rpm' + + - uses: softprops/action-gh-release@v3 + with: + files: rpms/**/*.rpm + fail_on_unmatched_files: true From a8b1f1c0e54f9caa90aa8269a8a637f71983ef33 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 30 Jul 2026 12:57:10 -0400 Subject: [PATCH 016/197] docs: add the distribution model to ADR-006 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build pipeline is only half the story — where RPMs are built, signed, and hosted is the other half, and it was undocumented. COPR project mitresaf/saf (ID 249476) created with epel-8/epel-9 chroots on x86_64 and aarch64. As an open-source project this gives real mock chroots on native multi-architecture builders at no cost, which is an authentic EL build environment rather than the approximation a container-on-Ubuntu CI job provides. Caddy — which this RPM already Recommends — ships the same way. Three verified facts shape the design: - enable_net has defaulted to false since June 2022 and %build runs yarn install, so every COPR build fails without it. Confirmed set. - COPR keeps one build per package indefinitely and deletes the rest after 14 days, with all content removed 180 days after a chroot goes EOL. It is a build farm, not archival storage — GitHub Releases is the durable home. - COPR signs with its own per-project key, generated on first successful build. That last point resolves an inconsistency worth recording: saf.repo and heimdall-server.repo disagree about which GPG key users verify against, and both reference a project namespace (@mitre/saf) that never existed. Correcting them is blocked until the first build publishes a key. Also documented: EPEL proper is not viable — not because of bundled node_modules, which Fedora made the default in F34, but because Koji builds are network-isolated and %build runs yarn install. And the air-gapped bundle deserves more investment than the online repo, since DoD sites mirror internally regardless. RKE2 is the closest analogue and worth following. Authored by: Aaron Lippold --- ...adr-006-fips-validated-password-hashing.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index adc552b7ee..0e120b890b 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -691,6 +691,118 @@ Until (1)–(3) land, enabling the FIPS gate turns break-glass into a trap. **RPM packaging is now in-tree**, so §10's deployment changes and §16's Postgres detection are ordinary cards in this repo rather than cross-repo coordination. +#### The build pipeline carries the contract + +The import (`35d47dee3`) left four references pointing at saf-packaging's layout, +where `heimdall-server/`, `heimdall-cli/`, and `scripts/` were siblings. Repaired +in `d2fca993e` and `7fb61561c`: + +| Was | Now | +|---|---| +| `scripts/fetch-source.sh` downloaded a release tarball | `git archive v$(VERSION)` from the local repository — no network, so airgapped builds work without a mirror | +| `CLI_DIR := ../heimdall-cli` (sibling path) | clone at **`HEIMDALL_CLI_REF`** | +| `man:` used the same broken sibling path | generated from the *same* pinned checkout as the binary, so pages cannot drift from the commands they document | +| `NAME`/`VERSION` from `rpmspec` (RHEL-only) | POSIX `sed`; the repository `VERSION` file is canonical and `check-version` fails the build if the spec disagrees | + +**`HEIMDALL_CLI_REF` is where the §14 contract actually lives.** It records +exactly which CLI a given RPM shipped. Release builds must pin a tag. Combined +with `libs/password-hash-vectors/`'s `formatVersion`, a CLI that cannot produce +the current hash format fails the build rather than shipping a break-glass tool +that writes credentials the server refuses. + +Two bugs were fixed in passing: `CLI_COMMIT` was `git rev-parse HEAD` evaluated +in the *packaging* repo, so `heimdall-cli --version` reported a heimdall2 commit +as the CLI commit; and a spec/repository version mismatch had no detection at +all — which is how the `feat/rpm-build` copy sat at 2.12.6 while the shipped one +tracked to 2.13.1. + +**CI builds the RPM** (`987bfffc9`, `.github/workflows/build-rpm.yml`) for +el8/el9 × x86_64/aarch64 on native runners, smoke-tests installation in a clean +container with no build dependencies present, and attaches the artifacts to +GitHub Releases with `actions/attest-build-provenance`. heimdall2 published no +downloadable release assets before this. + +Note the RPM cannot use the usual SRPM-as-handoff idiom: `Source15` is a +pre-built architecture-specific CLI binary, so an x86_64 SRPM cannot build an +aarch64 RPM. Each architecture does a full native build, with sources identical +by construction — same tag, same `HEIMDALL_CLI_REF`. + +**Not yet build-verified.** `rpmspec` does not exist on macOS and hosted runners +are not FIPS-enabled, so the RPM has never actually been built from its new home. +That verification gates removing saf-packaging's copies (`heimdall2-30c.5`), and +belongs on the same FIPS-host trip as the §15 `[U]` questions. + +#### Distribution + +RPMs are built in two places, for two different reasons. + +**Fedora COPR is the build farm.** Project `mitresaf/saf` +([copr.fedorainfracloud.org/coprs/mitresaf/saf](https://copr.fedorainfracloud.org/coprs/mitresaf/saf/), +ID 249476) created 2026-07-30 with `epel-8` and `epel-9` chroots on x86_64 and +aarch64. **[V]** As an open-source project we get real `mock` chroots on native +multi-architecture builders at no cost — an authentic EL build environment +rather than the approximation a container-on-Ubuntu CI job provides. The +precedent is directly relevant: Caddy, which this RPM already `Recommends:`, +ships via `dnf copr enable @caddy/caddy` as its official RHEL channel. + +Three operational facts, verified: + +- **`enable_net` must be on.** It has defaulted to *false* since June 2022, and + `%build` runs `yarn install`. Every build fails without it. Confirmed set on + the project (`enable_net: True`). **[V]** +- **COPR is not durable storage.** One build per package is kept indefinitely; + everything older is deleted after 14 days, and all content is removed 180 days + after a chroot reaches EOL. **GitHub Releases is therefore the archival home**, + not an alternative to it. **[V]** +- **COPR signs with its own per-project key**, published at + `results/mitresaf/saf/pubkey.gpg` — which does not exist until the first + successful build. **[V]** + +That last point resolves a real inconsistency: `saf.repo` currently sets +`gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-SAF-MITRE` while +`heimdall-server.repo` points at COPR's `pubkey.gpg`. **These disagree, and both +reference a project namespace (`@mitre/saf`) that never existed.** Either users +verify COPR's key, or artifacts are re-signed with the MITRE key on the way to +GitHub Releases. Both files must be corrected once the first build publishes a +key. + +**Signing, when it happens, uses RSA — not ed25519.** RHEL 9 ships rpm 4.16; +EdDSA-signed RPMs sign successfully but will not install (rpm#1877). The key +should be published over HTTPS at a MITRE URL *and* shipped inside a +`heimdall-server-release` RPM to `/etc/pki/rpm-gpg/`, which is the pattern that +survives air-gap. **[U]** — the RSA/EdDSA constraint is verified; whether MITRE +wants detached GPG signatures in addition to COPR's is a security-team decision, +not an engineering default. + +**EPEL proper is not a viable target.** Not because of bundled `node_modules` — +Fedora made npm bundling the default in F34 — but mechanically: Koji builds are +network-isolated and `%build` runs `yarn install --frozen-lockfile`. Submitting +would require vendoring `node_modules` into the source tarball and generating the +`Provides: bundled(npm(...))` manifest the spec explicitly declines to produce, +then clearing first-package sponsorship. Quarters, not weeks. **[V]** + +**The air-gapped bundle matters more than the online repo.** DoD sites mirror +internally regardless, so `airgap/build-bundle.sh` (createrepo_c output plus the +key and a `file:///` `.repo`) is the artifact most deployments actually consume. +RKE2 is the closest analogue — FIPS/government focus, an online repo plus air-gap +tarballs in GitHub Releases — and it is worth following. + +**Resulting layers:** + +| Layer | Mechanism | Why | +|---|---|---| +| Build farm | COPR `mitresaf/saf` | free native multi-arch, real mock chroots | +| Durable artifacts | GitHub Releases | COPR retention makes this mandatory | +| Online convenience | COPR repo | `dnf copr enable mitresaf/saf` for current-version users | +| Air-gapped | `airgap/build-bundle.sh` | the path the target deployments use | +| CI | `.github/workflows/build-rpm.yml` | PR-time proof the spec builds and installs | + +**Sequencing note.** COPR generates the signing key and repository tree on first +successful build, so the `.repo` corrections above are blocked until one lands. +A first manual build (`copr-cli build mitresaf/saf `) doubles as the +verification that the packaging move works — in a real mock chroot on both +architectures, which is stronger evidence than the OL9 VM would provide. + ### 15. Platform `Dockerfile:1` sets `ARG BASE_CONTAINER=registry.access.redhat.com/ubi9/nodejs-22-minimal:1` From 647536b0c928bceef6638dac104a44460eb7ea94 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 30 Jul 2026 18:46:32 -0400 Subject: [PATCH 017/197] ci: bootstrap make alongside git in the RPM build container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare rockylinux container has neither git nor make. actions/checkout needs git, and every subsequent step goes through the Makefile — including `make deps`, which is what installs the real build dependencies. That was circular: make deps installs make, but make deps needs make to run. All four matrix legs failed with 'make: command not found' (exit 127) about a minute in. Authored by: Aaron Lippold --- .github/workflows/build-rpm.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index f503f13696..096e5df505 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -65,10 +65,15 @@ jobs: runner: ubuntu-24.04-arm steps: - - name: Install git (required before checkout in a bare container) + # A bare Rocky container has neither git nor make. actions/checkout needs + # git, and every subsequent step goes through the Makefile — including + # `make deps`, which is what installs the real build dependencies. Both + # have to be bootstrapped here or `make deps` cannot run itself. + - name: Bootstrap git and make run: | - dnf install -y git + dnf install -y git make git --version + make --version | head -1 - uses: actions/checkout@v6 with: From ed3ff63834a745bd258b91feab24e8a83c76a592 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 30 Jul 2026 19:45:12 -0400 Subject: [PATCH 018/197] =?UTF-8?q?docs:=20fold=20in=20Will's=20review=20?= =?UTF-8?q?=E2=80=94=20correct=20module=20version,=20CLI=20claims,=20prece?= =?UTF-8?q?dents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent verification pass by @wdower on PR #8469. The core argument chain held; these are the corrections. The one that matters — the module version was wrong, and it is not fixable by pinning. The ADR cited 3.0.7-cda111b5812c30d4 from inspecting the running UBI9 container. Certificate #4857 validates 3.0.7-395c1a240fbfffd8; the observed string appears in no CMVP record. This is structural, not an inspection error: Red Hat validated one openssl build and has shipped security errata since, so any current UBI image carries a newer, non-validated maintenance build of the same module, essentially always. Pinning the literally-validated build would forgo every CVE fix since validation — which contradicts this ADR's own security-over-compliance tiebreaker. §15 now prescribes documentation instead: cite the certificate and its validated version, disclose the deployed build as a maintenance build, self-affirm the operational environment under CMVP MM §7.9, and cite the FedRAMP crypto policy already referenced for FRR8, which directs prioritizing patching over a frozen binary. Other corrections: - bcryptjs DOES call node:crypto (randomBytes for salt). Narrowed to "its hash computation is pure JavaScript" — the conclusion is unchanged, but the earlier phrasing failed this document's own precision standard. - heimdall-cli does NOT read the PASSWORD_* variables. Only the retired Python CLI did; the Go rewrite hardcodes its rules, as does libs/password-complexity. The §9 "contract" was aspirational and is now stated as such, with teaching the CLI those vars moved to §14's blocking cross-repo list. - Keycloak's refuse-and-reset quotes describe argon2, not bcrypt. Pattern genuine, algorithm corrected. - Mattermost is no longer a cautionary example. As of v11 it defaults to PBKDF2-HMAC-SHA256 @600k behind a requirefips build tag, making it a supporting precedent. - OWASP's PBKDF2-SHA512 floor is 220,000, and its guidance now says "600,000 or more" for FIPS-140 contexts — which supports the chosen parameter rather than merely permitting it. - COPR mitresaf/saf uses the Pulp backend: retention is the 5 most recent successful builds per package, not the 14-day rule. - ASD V6R4 revision date, V-222542's CCI list, cert #4746 now Historical, rpm#1877 attribution, and the "portion of the salt" phrasing in 140sp4857. Two blocking cross-repo items found while verifying: - cmd/gen-manpages did not survive heimdall-cli's extraction, while the spec's %files claims %{_mandir}/man1/heimdall-cli*.1* and the CLI gitignores man/man1/ as generated. The pages are neither committed nor generatable, so the RPM cannot build. The man: target now fails with an explicit message. - Teaching the Go CLI the PASSWORD_* variables. Also: the ADR twice said the JWT findings were "carded separately" when no card existed. Review caught it; now tracked as heimdall2-0bi. SonarCloud findings on the new workflow: third-party action pinned to a commit SHA (it holds contents: write, and a tag can be repointed), and read permissions moved from workflow level to job level. Authored by: Aaron Lippold --- .github/workflows/build-rpm.yml | 11 +- ...adr-006-fips-validated-password-hashing.md | 133 +++++++++++++----- packaging/rpm/Makefile | 7 + 3 files changed, 108 insertions(+), 43 deletions(-) diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index 096e5df505..27a3088dbe 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -27,9 +27,6 @@ on: required: false default: 'main' -permissions: - contents: read - env: # Release builds must pin a tag. Everything else tracks main. # This value is what the RPM records as the CLI it shipped. @@ -40,6 +37,8 @@ jobs: name: ${{ matrix.distro }} / ${{ matrix.arch }} runs-on: ${{ matrix.runner }} container: ${{ matrix.image }} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -139,6 +138,8 @@ jobs: needs: build runs-on: ${{ matrix.runner }} container: ${{ matrix.image }} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -203,7 +204,9 @@ jobs: with: subject-path: 'rpms/**/*.rpm' - - uses: softprops/action-gh-release@v3 + # Pinned to a commit SHA: this is a third-party action holding + # contents: write, and a mutable tag can be repointed at any time. + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: rpms/**/*.rpm fail_on_unmatched_files: true diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index 0e120b890b..524d056b43 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -41,15 +41,22 @@ Two rules follow for anyone extending this: Heimdall hashes passwords with bcrypt via `bcryptjs` (pure JavaScript, cost 14) and stores API keys as bcrypt hashes of JWT signatures. -**The problem is boundary, not strength.** bcrypt at cost 14 is strong. -`bcryptjs` is pure JavaScript — it never calls `node:crypto` or OpenSSL, so on -a FIPS-enabled host it runs *undetected and unblocked*, entirely outside the -validated module **[V]**. +**The problem is boundary, not strength.** bcrypt at cost 14 is strong. But +`bcryptjs` computes its **hash in pure JavaScript** — the Blowfish key schedule +and the digest never enter `node:crypto` or OpenSSL, so on a FIPS-enabled host +hash generation runs *undetected and unblocked*, entirely outside the validated +module **[V]**. + +Be precise about this: bcryptjs v3 *does* import `node:crypto`, using +`crypto.randomBytes()` for salt generation. The claim is narrower than "it never +touches crypto" — it is that the **hashing** is not performed by a validated +module, which is exactly what V-222571's check text turns on. The conclusion is +unchanged; the earlier phrasing overstated it. **[V]** **What this actually costs, stated precisely** — an earlier draft overstated it: - **V-222542** ("must only store cryptographic representations of passwords", - **CAT I**, CCI-004062/CCI-000196) requires "strong cryptographic hash + **CAT I**, CCI-004062) requires "strong cryptographic hash functions" with a random salt and prohibits MD5. **The phrase "FIPS-validated" appears nowhere in the rule.** bcrypt already satisfies it. **[V]** - **V-222571** (**CAT II**, CCI-002450) is the rule we fail, and its finding @@ -75,13 +82,15 @@ measured non-FIPS behavior.** Corrected: | Project | In FIPS mode | Verified | |---|---|---| -| **Keycloak** | **Refuses.** Provider never registers; affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." | **[V]** source + docs | +| **Keycloak** | **Refuses.** The provider never registers, so verification is never reached; affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." Note the quotes describe **argon2** (Keycloak 25+ default), not bcrypt. The refuse-and-reset *pattern* is what transfers. | **[V]** source + docs | | **GitLab** | **Gates on FIPS mode.** "Bcrypt: Used by default. **PBKDF2+SHA512: Used when FIPS mode is enabled.**" Concedes bcrypt hashes "cannot be re-encrypted without user help." Issue **#360659** — "Force password resets for users with bcrypt login passwords" (closed 2022-07-27). | **[V]** | -| **Mattermost** | Lazy migration, **and its documentation is inaccurate** — `bcrypt.go` has no FIPS build-tag exclusion while its FIPS/STIG doc claims "All application-level code uses only FIPS-approved algorithms." | **[V]** source + docs | +| **Mattermost** | **Supporting precedent.** As of v11 it defaults to PBKDF2-HMAC-SHA256 @ 600,000 behind a `requirefips` build tag — the same shape as this design. | **[V]** | -The prior draft's §3 was **weaker than both Keycloak and GitLab** — ungated, -unconditional, no terminal state — i.e. it reproduced Mattermost's posture in a -document asserting the opposite. That is the central correction here. +All three now point the same way: **gate on FIPS mode, and define a terminal +state.** Keycloak refuses outright, GitLab gates and drives toward forced resets, +Mattermost ships a FIPS build that uses PBKDF2 by default. An ungated, +unterminated bcrypt fallback — which is what an earlier draft of §3 specified — +matches none of them. ### The Grafana lesson @@ -127,7 +136,7 @@ So this is a **documentation obligation, not a design defect**. Two consequences scope limit. **Our parameters clear every bound the module enforces [V]** — policy -`140sp4857.pdf`: salt ≥128 bits from the SP 800-90Ar1 DRBG, iterations ≥1000, +`140sp4857.pdf`: **a portion of** the salt ≥128 bits from the SP 800-90Ar1 DRBG, iterations ≥1000, derived key ≥112 bits. The same policy lists "PBKDF2 (short password; short salt; insufficient iterations; <112-bit keys)" as a **non-approved service** — the failure mode is under-parameterisation, which we are well clear of. @@ -495,13 +504,19 @@ applies to `PASSWORD_MAX_LENGTH`: capping on verify would lock out any user whose password exceeds it. If an oversized password reaches the rehash path, **skip the rehash and log it** — never fail the login (§7). -**Configurable complexity is now in scope.** `libs/password-complexity` is -hardcoded today, but **heimdall-cli already reads `PASSWORD_MIN_LENGTH`, -`PASSWORD_REQUIRE_CLASSES`, and `PASSWORD_MAX_CONSECUTIVE`** from `backend.env` -**[V]** — env vars the app does not support. The CLI and the app therefore -disagree today about what a valid password is, which is the same class of bug as -the hash format. The variable names above match the CLI's exactly; that is the -contract. +**Configurable complexity is now in scope, and neither side supports it yet.** +`libs/password-complexity` hardcodes its rules, and so does the Go +`heimdall-cli` — **[V]**. Only the *retired Python* CLI read +`PASSWORD_MIN_LENGTH` / `PASSWORD_REQUIRE_CLASSES` / `PASSWORD_MAX_CONSECUTIVE` +from `backend.env`; that capability was lost when the CLI was rewritten in Go, +and an earlier draft of this ADR wrongly attributed it to the current binary. + +So the two implementations disagree about what a valid password is **by +duplication**, not by configuration drift: each hardcodes its own copy of the +rules, with nothing keeping them aligned. That is the same class of bug as the +hash format, and it needs fixing on both sides — the variable names above are the +contract, and **teaching the Go CLI to read them belongs on §14's blocking +cross-repo list**, not to a later phase. ### 10. FIPS mode on RHEL — the prior draft had this backwards @@ -552,7 +567,7 @@ Low/Low performance *regression*. **Both wrong.** Measured on Node 24: |---|---|---|---| | `bcryptjs` compare cost 14 (**current production**) | **1120 ms** | 0.9/sec | 788 ms | | PBKDF2-SHA512 @600k (**this ADR**) | **145 ms** | 20/sec | 1.4 ms | -| PBKDF2-SHA512 @210k (OWASP) | 52 ms | ~55/sec | — | +| PBKDF2-SHA512 @220k (OWASP floor) | ~55 ms | ~50/sec | — | **A 7.7× latency and 22× throughput improvement.** The prior draft buried its own strongest justification. @@ -565,7 +580,10 @@ concurrency*, and the pool is shared with `fs`, `dns.lookup`, and `zlib` — must be set — and it is **not an application env var**; libuv reads it at first threadpool use, so it belongs in the Dockerfile, `cmd.sh`, or the systemd unit. -**600k is defensible at 210k (OWASP) or at 600k only if `UV_THREADPOOL_SIZE` is +**600k is well supported.** OWASP's floor for PBKDF2-SHA512 is 220,000, and its +guidance now explicitly recommends "600,000 or more" in FIPS-140 contexts — so +the chosen value sits on the recommendation, not above it. It remains defensible +at 220k if latency matters more, but 600k is only safe if `UV_THREADPOOL_SIZE` is raised and a global KDF concurrency limit lands.** Keeping 600k while addressing neither is the one indefensible combination. Benchmark on the target RHEL container before finalizing. @@ -685,8 +703,20 @@ implementations test against it; a mismatch is a **build failure**. 2. Write PHC format, not bcrypt; **remove the bcrypt write path entirely** 3. Consume the published vectors, asserting `formatVersion` 4. Update `heimdall-cli-reset-password.1`, which documents bcrypt cost 14 - -Until (1)–(3) land, enabling the FIPS gate turns break-glass into a trap. +5. **Read the `PASSWORD_*` environment variables** (§9). The Go CLI hardcodes + its complexity rules, as does `libs/password-complexity` — each carries its + own copy with nothing keeping them aligned. Only the retired Python CLI read + them. **[V]** +6. **Restore `cmd/gen-manpages`.** It did not survive the extraction to a + standalone repository, and the spec's `%files` claims + `%{_mandir}/man1/heimdall-cli*.1*` while the CLI's `.gitignore` excludes + `man/man1/` as generated output. So the pages are neither committed nor + generatable — the RPM cannot currently build. `packaging/rpm/Makefile`'s + `man:` target fails with an explicit message rather than a confusing + `go: cannot find main module`. **[V]** + +Until (1)–(3) land, enabling the FIPS gate turns break-glass into a trap. (6) +blocks the RPM build outright. **RPM packaging is now in-tree**, so §10's deployment changes and §16's Postgres detection are ordinary cards in this repo rather than cross-repo coordination. @@ -750,10 +780,10 @@ Three operational facts, verified: - **`enable_net` must be on.** It has defaulted to *false* since June 2022, and `%build` runs `yarn install`. Every build fails without it. Confirmed set on the project (`enable_net: True`). **[V]** -- **COPR is not durable storage.** One build per package is kept indefinitely; - everything older is deleted after 14 days, and all content is removed 180 days - after a chroot reaches EOL. **GitHub Releases is therefore the archival home**, - not an alternative to it. **[V]** +- **COPR is not durable storage.** `mitresaf/saf` uses the Pulp backend, which + retains only the **5 most recent successful builds per package**; content is + also removed 180 days after a chroot reaches EOL. **GitHub Releases is + therefore the archival home**, not an alternative to it. **[V]** - **COPR signs with its own per-project key**, published at `results/mitresaf/saf/pubkey.gpg` — which does not exist until the first successful build. **[V]** @@ -766,8 +796,9 @@ verify COPR's key, or artifacts are re-signed with the MITRE key on the way to GitHub Releases. Both files must be corrected once the first build publishes a key. -**Signing, when it happens, uses RSA — not ed25519.** RHEL 9 ships rpm 4.16; -EdDSA-signed RPMs sign successfully but will not install (rpm#1877). The key +**Signing, when it happens, uses RSA — not ed25519.** RHEL 9 ships rpm 4.16, which predates EdDSA +verification support; EdDSA-signed RPMs sign but will not install (rpm#1877 +documents the behaviour on rpm 4.17/openSUSE). The key should be published over HTTPS at a MITRE URL *and* shipped inside a `heimdall-server-release` RPM to `/etc/pki/rpm-gpg/`, which is the pattern that survives air-gap. **[U]** — the RSA/EdDSA constraint is verified; whether MITRE @@ -812,8 +843,31 @@ architectures, which is stronger evidence than the OL9 VM would provide. so it uses system OpenSSL rather than a bundled copy **[V]** - **`fips.so` is present** at `/usr/lib64/ossl-modules/` (1.3 MB), and the provider identifies as **"Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", - version 3.0.7-cda111b5812c30d4** **[V]** — that is the module name and version - an SSP must cite + version `3.0.7-cda111b5812c30d4`** **[V]** + +**That version is NOT the validated one, and it never will be.** Certificate +#4857 validates version **`3.0.7-395c1a240fbfffd8`**; the string the running +container reports appears in no CMVP record. This is not a mistake to fix by +pinning — it is structural. Red Hat validated one specific openssl build and has +shipped security errata since, so **any current UBI image carries a newer, +non-validated maintenance build of the same module**, essentially always. + +Pinning the literally-validated build is the strictly worse option: it forgoes +every CVE fix issued since validation, which contradicts this ADR's own +security-over-compliance tiebreaker. The remedy is **documentation, not pinning**: + +1. Cite certificate **#4857** and its validated version `3.0.7-395c1a240fbfffd8` +2. **Disclose the deployed build** as a Red Hat maintenance build of that module +3. Self-affirm the operational environment under CMVP Management Manual **§7.9** + (Level 1 porting — see §14) +4. Cite the FedRAMP *Policy for Cryptographic Module Selection and Use* — the + same document this ADR already cites for FRR8 — which directs CSPs to + prioritize security patching over remaining on a frozen validated binary + +This posture must be verified on the FIPS-host trip alongside the `[U]` items +below. In particular, **whether the containerized provider activates at all +without its own `fipsmodule.cnf` is upstream of any version-citation question** — +if it does not activate, the version discussion is moot. - **`fipsmodule.cnf` is absent.** On a stock OpenSSL flow that file (from `openssl fipsinstall`) activates the provider; RHEL's patched OpenSSL instead keys off `/proc/sys/crypto/fips_enabled`. **Whether a container on a FIPS host @@ -912,11 +966,11 @@ endpoint, or a forced-reset release has shipped. | Rule | Severity | Requirement | Status | |---|---|---|---| -| **V-222542** | CAT I | Salted iterated hash; MD5 prohibited. **No FIPS mention.** | Already satisfied; remains so | +| **V-222542** | CAT I | Salted iterated hash; MD5 prohibited. **No FIPS mention.** CCI-004062 | Already satisfied; remains so | | **V-222571** | CAT II | FIPS-validated modules **when generating hashes** | Satisfied once §3's gate lands and legacy hashes retire | | **V-222572** | CAT II | FIPS-validated modules for unclassified data | Same condition | | **V-222543** | CAT I | Passwords transmitted cryptographically protected | **NOT satisfied — prior draft claimed it was.** `main.ts:39-45` *explicitly removes* `upgrade-insecure-requests`; cookie `secure` only in production **[V]**. Requires a TLS reverse proxy — deployment requirement, not an application control | -| **V-222570** | CAT II | FIPS-validated modules when **signing application components** (code signing) | **Mapping questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** **[V]**. Carded separately. The rule offers an AoR path we do not have | +| **V-222570** | CAT II | FIPS-validated modules when **signing application components** (code signing) | **Mapping questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** **[V]**. Tracked as `heimdall2-0bi`. The rule offers an AoR path we do not have | | **V-230223** (RHEL 8) / **V-258241** (RHEL 9) | CAT I | System-wide FIPS crypto policy via `update-crypto-policies` | **Customer host responsibility.** No application change satisfies an OS rule. V-230223 is a RHEL **8** rule; our base is UBI **9** | **Supporting:** IA-5(1)(d) is the affirmative control **[V]**. SC-13 assessment @@ -931,7 +985,7 @@ objects name validation certificates explicitly **[V]**. the requirement is not applicable." **[V]** The correct citation is SC-13 via CCI-002450 — implemented as **APSC-DV-002030**, which *is* V-222571. - **V-16793 — dropped. Retired.** Zero occurrences in the current ASD STIG - (V6R4, benchmark 2025-10-01); superseded by the 2016 move to `APSC-DV-*` + (V6R4, revised 2025-09-09); superseded by the 2016 move to `APSC-DV-*` rules. Nearest current coverage is APSC-DV-002380 (SC-4) and APSC-DV-002330 (SC-28), both CAT II. **[V]** - **Memory zeroization — not applicable.** FIPS 140-3 AS09.28 requires zeroising @@ -964,12 +1018,13 @@ checks. **[V]**: | #4754 "Red Hat FIPS 140-3 policy" | **RHEL 9 libgcrypt** | Wrong library, and **Historical**, superseded by #5366 | **Correct: #4857** — "Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", -**Active**, validated 2024-10-29, sunset 2029-10-28. **#4746** covers RHEL 9.0 -but **sunsets 2026-07-30** — do not cite it as current. +**Active**, validated 2024-10-29, sunset 2029-10-28. **#4746** covered RHEL 9.0 and went +**Historical on 2026-07-30** — do not cite it. -The running module self-identifies as **3.0.7-cda111b5812c30d4** (§15). The SSP -must name module, version, certificate, **and** the certificate's tested -operational environments. +The running module self-identifies as `3.0.7-cda111b5812c30d4`, which is a +Red Hat **maintenance build**, not the validated `3.0.7-395c1a240fbfffd8` (§15). +The SSP must name the certificate and its validated version, disclose the +deployed build, and self-affirm the operational environment. ## Scope @@ -996,7 +1051,7 @@ methods; env vars including configurable complexity; startup assertion; 4. **Removing `bcryptjs`** — required for legacy verification until §17's criterion is met. 5. **Fixing V-222570** (empty-string JWT key, concatenated secret) — real, - verified, carded separately. + verified, tracked as `heimdall2-0bi`. 6. **The `passwordChangedAt` column-type mismatch** — pre-existing (§7). 7. **Elastic-style `pbkdf2_stretch`** — exists to defeat *bcrypt's 72-byte truncation*, which PBKDF2 does not have. (The prior draft justified excluding diff --git a/packaging/rpm/Makefile b/packaging/rpm/Makefile index 5ba1926250..37325e5ecf 100644 --- a/packaging/rpm/Makefile +++ b/packaging/rpm/Makefile @@ -128,6 +128,13 @@ heimdall-cli: cli-src # Generated from the same pinned checkout as the binary, so the pages can never # drift from the commands they document. man: cli-src + @test -d $(CLI_DIR)/cmd/gen-manpages || { \ + echo "ERROR: $(HEIMDALL_CLI_REF) of heimdall-cli has no cmd/gen-manpages."; \ + echo " The spec's %files claims %{_mandir}/man1/heimdall-cli*.1*, so the"; \ + echo " build will fail without generated pages. The generator was not"; \ + echo " carried over when heimdall-cli was extracted to its own repository."; \ + echo " Tracked as a blocking cross-repo item in ADR-006 §14."; \ + exit 1; } cd $(CLI_DIR) && go run ./cmd/gen-manpages $(abspath man/man1) # Install build dependencies (delegates to setup script). From c1140e131052d673c38ee279027c29158c75b6f8 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 5 Aug 2026 18:50:23 -0400 Subject: [PATCH 019/197] =?UTF-8?q?docs:=20settle=20ADR-006=20open=20desig?= =?UTF-8?q?ns=20=E2=80=94=20KDF=20limiter,=20write-gate=20triggers,=20admi?= =?UTF-8?q?n=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amendments matching the carded epic (heimdall2-e25, 28 children): - §9: PASSWORD_KDF_CONCURRENCY env var (default 2) - §11: limiter design settled — zero-dep semaphore in password.ts, bounded queue, UV_THREADPOOL_SIZE=8 in Dockerfile/cmd.sh/systemd - §12: marker plants on first PBKDF2 write; write gate derived — forced on for fresh installs, default off on upgrade - §13: runtime dependency audit carded (e25.3), FIPS-host trip shared with the §15 spike - §17: admin surface concretized against existing code — derived passwordHashScheme (admin list only), bulk force-change endpoint, bulk legacy-key deletion, Migration tab, /health + /health/details shapes Authored by: Aaron Lippold --- ...adr-006-fips-validated-password-hashing.md | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index 524d056b43..4085619340 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -488,6 +488,7 @@ changes non-breaking. Recorded so a maintainer does not "fix" it accidentally. | `PASSWORD_MAX_LENGTH` | int ≤128 | `128` | §6 cap | | `PASSWORD_REQUIRE_CLASSES` | int | `4` | already read by heimdall-cli | | `PASSWORD_MAX_CONSECUTIVE` | int | `3` | already read by heimdall-cli | +| `PASSWORD_KDF_CONCURRENCY` | int ≥1 | `2` | §11 limiter — max concurrent KDF ops; bounded queue rejects overflow to the generic auth failure | | `FIPS_MODE` | boolean | unset | assertion + fallback gate | | `PASSWORD_HASH_WRITE_ENABLED` | boolean | see §12 | rollout gate | @@ -588,6 +589,20 @@ raised and a global KDF concurrency limit lands.** Keeping 600k while addressing neither is the one indefensible combination. Benchmark on the target RHEL container before finalizing. +**Settled design (2026-08-05).** The limiter is a hand-rolled counting semaphore +(~15 lines, zero dependencies) **inside `password.ts`**, wrapping every pbkdf2 +dispatch. It cannot live in the service layer: §5's pure-path callers (sites 3 +and 6) run in the server process too, and the seeder's bare-require constraint +forbids dependencies — so a service-layer limiter would leak, and p-limit is not +an option. Default concurrency 2 (leaves ≥2 of libuv's default 4 threads for +`fs`/`dns`), overridable via `PASSWORD_KDF_CONCURRENCY` (§9). The pending queue +is bounded (default 100); overflow rejects with a typed error the auth layer +maps to the generic failure — unbounded queueing would convert the thread- +starvation DoS into memory exhaustion, and a distinct error would leak state. +`UV_THREADPOOL_SIZE=8` is set in the Dockerfile, `cmd.sh`, and the systemd unit +— libuv reads it at first threadpool use, so it is not an application env var +and cannot go through ConfigService. + **Login is a DoS amplification vector [V]** — 20 req/min/IP on `/authn/login` is the only protection; no global cap, no account lockout (`loginCount` increments only on success). @@ -627,10 +642,15 @@ catastrophic**, and `dnf downgrade` is one command. pre-N pod. Consequently §4's "fresh install → `bcrypt_remaining = 0`" AC applies from **N+1**, or the gate must be derived: enabled unconditionally on a fresh install (no pre-N peer can exist), defaulted off only on upgrade. + Settled (2026-08-05): the **derived form** — forced on for fresh installs, + default off on upgrade — so the fresh-install AC holds from release N. 2. **A durable DB marker planted in release N**, recording that PBKDF2 writes have begun. **Planting trigger must be defined** — at install it records something untrue; on first write it flips during the canary while most rows - are still bcrypt. Define which, and who reads it. + are still bcrypt. Define which, and who reads it. Settled (2026-08-05): + planted on the **first PBKDF2 write** — the marker never records something + untrue — and its readers are mechanism 3's startup version check and §17's + authenticated health detail. 3. **Enforcement is in the application, not RPM `%pre`.** The prior draft specified a `%pre` guard; **it cannot fire on the downgrades it targets** — on downgrade the `%pre` that runs belongs to the **older** package, built @@ -670,6 +690,9 @@ costs more than rehashing. `serve-static` uses stat-based tags and no hash. **Runtime audit required.** Static analysis cannot see transitive dependencies. Unaudited: `passport-google-oauth` (bundles an OAuth 1.0a HMAC-SHA1 path), `passport-ldapauth` (SASL DIGEST-MD5 if configured), `express-session`. +Carded (2026-08-05) as `heimdall2-e25.3`, executed on the FIPS host alongside +the §15 spike — same trip, separate deliverable — and it exercises §16's +pg-against-md5-auth failure case live. ### 14. Repository boundary and the cross-repo dependency @@ -958,6 +981,33 @@ air-gapped operators cannot reach. - **Admin UI** — per-user legacy-hash badge, bulk force-password-change, and **bulk API-key invalidation** (keys are *regenerated*, not reset). +**Admin surface, settled design (2026-08-05)** — built on plumbing that already +exists, verified by code read: + +- **`passwordHashScheme`** (`bcrypt` | `pbkdf2` | `invalidated`) — derived from + the stored prefix via the crypto module's shared constant, never persisted, + and exposed **only on the admin list response**. The self-view and every + unauthenticated surface omit it — the Risks table's enumeration rule. Rendered + as a badge column in `UserManagement.vue`'s existing v-data-table. +- **`POST /users/force-password-change`** — admin-only; `{userIds}` or + `{scheme: 'bcrypt'}`; one SQL UPDATE. `forcePasswordChange` is already plumbed + end-to-end (`update-user.dto.ts:40` accepts it, `users.service.ts:103` applies + it) **[V]** — the endpoint adds bulk, not new semantics. +- **Bulk legacy API-key invalidation** — admin-only **deletion** of + `$2%`-prefixed `ApiKeys` rows; regeneration is the only recovery, per this + section's own rule. +- **A fourth "Migration" admin tab** (`Admin.vue` already hosts Users / Groups / + Statistics tabs **[V]**) showing FIPS state, write-gate state, both tables' + counts, and the two bulk actions — fed by the authenticated health detail + endpoint, so the tab and the operator query share one source. +- The `/health` split concretely: **`GET /health`** (unauthenticated liveness, + `{status, version}` only) and **`GET /health/details`** (JwtAuthGuard + CASL + admin, following `StatisticsController`'s existing pattern **[V]**). +- Post-cutover recovery needs no new code: an admin sets a temporary password + through the existing `UserModal` admin path (which skips currentPassword) and + `forcePasswordChange` compels rotation at next login — documented in the + deployment runbook. + **Restated removal criterion:** no earlier than N+3, and only after `bcrypt_remaining = 0` across **both tables** is confirmed via the health endpoint, or a forced-reset release has shipped. From 66a921d2028266f89d3530d3d85f01a22df6e062 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 10:13:43 -0400 Subject: [PATCH 020/197] feat: add OpenTofu module for FIPS-enabled RHEL 9 EC2 test host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test tier containers cannot provide: kernel FIPS mode, SELinux enforcing, fapolicyd, real systemd. Serves ADR-006's empirical cards (provider activation spike, runtime dependency audit) and iterative RPM build/install testing now that local VMs are unavailable. - FIPS-on before first reachable session: cloud-init runs fips-mode-setup --enable + reboot (supported method on the pinned RHEL 9.4 AMI; 9.5+ deprecates it for switching, noted inline) - Access via SSM Session Manager over 443 — the MITRE network filters the SSH protocol at the edge (TCP connects, banner exchange killed; verified against EC2 and github.com). Minimal IAM role, SSM core policy only; SSM agent installed pre-reboot via user-data - Two lifecycle profiles: ephemeral (apply/destroy) and persistent dev box (stop between sessions) - t3.medium default; resize on evidence Verified live: instance reaches fips_enabled=1 + provision marker via SSM polling through install -> FIPS enable -> reboot -> re-register. Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/.gitignore | 6 ++ packaging/test-infra/fips-ec2/README.md | 56 ++++++++++++ packaging/test-infra/fips-ec2/main.tf | 85 +++++++++++++++++++ packaging/test-infra/fips-ec2/outputs.tf | 17 ++++ .../test-infra/fips-ec2/user-data.yaml.tftpl | 32 +++++++ packaging/test-infra/fips-ec2/variables.tf | 53 ++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 packaging/test-infra/fips-ec2/.gitignore create mode 100644 packaging/test-infra/fips-ec2/README.md create mode 100644 packaging/test-infra/fips-ec2/main.tf create mode 100644 packaging/test-infra/fips-ec2/outputs.tf create mode 100644 packaging/test-infra/fips-ec2/user-data.yaml.tftpl create mode 100644 packaging/test-infra/fips-ec2/variables.tf diff --git a/packaging/test-infra/fips-ec2/.gitignore b/packaging/test-infra/fips-ec2/.gitignore new file mode 100644 index 0000000000..adc00fa355 --- /dev/null +++ b/packaging/test-infra/fips-ec2/.gitignore @@ -0,0 +1,6 @@ +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +*.tfvars +tfplan diff --git a/packaging/test-infra/fips-ec2/README.md b/packaging/test-infra/fips-ec2/README.md new file mode 100644 index 0000000000..5cce7c4803 --- /dev/null +++ b/packaging/test-infra/fips-ec2/README.md @@ -0,0 +1,56 @@ +# FIPS-enabled RHEL 9 test host (EC2, OpenTofu) + +Provisions a RHEL 9 EC2 instance that is **FIPS-enabled at the kernel before it +is ever reachable**: cloud-init runs `fips-mode-setup --enable` and reboots, so +`/proc/sys/crypto/fips_enabled` reads `1` from the first usable session. This is +the host tier for ADR-006's empirical work (provider activation, benchmarks, +runtime dependency audit) and for RPM install/runtime testing that containers +cannot do (FIPS, SELinux enforcing, fapolicyd, real systemd). + +## Access is SSM, not SSH + +The MITRE network filters the SSH **protocol** at the edge — TCP to port 22 +connects, then the banner exchange is killed (verified 2026-08-06 against both +an EC2 host and github.com). Session Manager tunnels over HTTPS/443 via the +agent's outbound connection, so it works where SSH cannot. + +```bash +# interactive shell (needs: brew install --cask session-manager-plugin) +aws ssm start-session --target + +# one-shot command +aws ssm send-command --instance-ids --document-name AWS-RunShellScript \ + --parameters 'commands=["cat /proc/sys/crypto/fips_enabled"]' +``` + +The key pair is still attached — plain SSH works from networks that permit it. + +## Lifecycle + +```bash +tofu init && tofu apply # create (~5 min to FIPS-on: update, agent, enable, reboot) +aws ec2 stop-instances --instance-ids # dev box idle — costs only EBS (~$4/mo @ 50GB) +aws ec2 start-instances --instance-ids # public IP CHANGES on start — re-read it +tofu destroy # ephemeral use — gone entirely +``` + +Two profiles, one module: ephemeral test host (apply → test → destroy) and +persistent dev/test box (apply once, stop between sessions). + +## Sizing + +Default `t3.medium` — sufficient for the FIPS spike and CLI testing. If an RPM +build proves it needs more: stop → modify instance type → start (same disk, +two minutes). Resize on evidence, not speculation. `t4g.*` + an aarch64 AMI +for ARM checks. + +## Notes + +- `fips-mode-setup` is deprecated for *switching* on RHEL 9.5+ (replacements + are install-time: kickstart `fips=1`, image builder, bootc). The pinned AMI + is RHEL 9.4, where post-install switching is the supported, documented + method. Long term, move to a FIPS-enabled image. +- Never `--force-fips` on Node here — the RHEL model is host FIPS → OpenSSL → + Node inherits (ADR-006 §10). +- State is local and gitignored; this module manages a throwaway test tier, + not production infrastructure. diff --git a/packaging/test-infra/fips-ec2/main.tf b/packaging/test-infra/fips-ec2/main.tf new file mode 100644 index 0000000000..2ef7971797 --- /dev/null +++ b/packaging/test-infra/fips-ec2/main.tf @@ -0,0 +1,85 @@ +# FIPS-enabled RHEL 9 test host for Heimdall. +# +# Two lifecycle profiles from this one module: +# - Ephemeral test host: tofu apply -> run tests -> tofu destroy +# - Dev/test box: tofu apply once, then STOP the instance between sessions +# (aws ec2 stop-instances). A stopped instance costs only its EBS volume. +# The public IP changes on every stop/start cycle — re-read the output or +# `aws ec2 describe-instances` after starting. +# +# Resize on evidence, not speculation: stop -> modify instance type -> start. + +terraform { + required_version = ">= 1.6" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.region +} + +# SSM access — the MITRE network blocks the SSH protocol at the edge (TCP to +# port 22 connects, then the banner exchange is killed; verified 2026-08-06 +# against both this host and github.com). Session Manager tunnels over +# HTTPS/443 via the agent's outbound connection, so it works where SSH cannot. +# Minimal role: AmazonSSMManagedInstanceCore and nothing else. +resource "aws_iam_role" "ssm" { + name = "${var.name}-ssm" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + tags = { Project = "heimdall2-fips" } +} + +resource "aws_iam_role_policy_attachment" "ssm_core" { + role = aws_iam_role.ssm.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "ssm" { + name = "${var.name}-ssm" + role = aws_iam_role.ssm.name +} + +resource "aws_instance" "fips_host" { + ami = var.ami_id + instance_type = var.instance_type + key_name = var.key_name + subnet_id = var.subnet_id + vpc_security_group_ids = [var.security_group_id] + associate_public_ip_address = true + iam_instance_profile = aws_iam_instance_profile.ssm.name + + user_data = templatefile("${path.module}/user-data.yaml.tftpl", { + fips = var.fips + }) + + # RHEL AMI default root volume is 10 GB — too small for iterative RPM + # builds (node_modules + rpmbuild trees). gp3 baseline is fine. + root_block_device { + volume_size = var.volume_gb + volume_type = "gp3" + } + + # IMDSv2 only. + metadata_options { + http_tokens = "required" + http_endpoint = "enabled" + } + + tags = { + Name = var.name + Project = "heimdall2-fips" + Purpose = var.fips ? "fips-test-host" : "test-host" + } +} diff --git a/packaging/test-infra/fips-ec2/outputs.tf b/packaging/test-infra/fips-ec2/outputs.tf new file mode 100644 index 0000000000..444b441c57 --- /dev/null +++ b/packaging/test-infra/fips-ec2/outputs.tf @@ -0,0 +1,17 @@ +output "instance_id" { + value = aws_instance.fips_host.id +} + +output "public_ip" { + description = "Changes on every stop/start — re-read after starting." + value = aws_instance.fips_host.public_ip +} + +output "ssh" { + value = "ssh -i ~/.ssh/${var.key_name}.pem ec2-user@${aws_instance.fips_host.public_ip}" +} + +output "verify_fips" { + description = "Must print 1 once cloud-init's post-enable reboot completes." + value = "ssh -i ~/.ssh/${var.key_name}.pem ec2-user@${aws_instance.fips_host.public_ip} 'cat /proc/sys/crypto/fips_enabled'" +} diff --git a/packaging/test-infra/fips-ec2/user-data.yaml.tftpl b/packaging/test-infra/fips-ec2/user-data.yaml.tftpl new file mode 100644 index 0000000000..5a5a0ce8ed --- /dev/null +++ b/packaging/test-infra/fips-ec2/user-data.yaml.tftpl @@ -0,0 +1,32 @@ +#cloud-config +# Provisioning for the Heimdall FIPS test host. +# When fips=true: enable FIPS mode, then reboot — the RHEL model is host FIPS +# mode -> OpenSSL enables -> everything inherits (never --force-fips; see +# ADR-006 §10). The kernel fips=1 boot arg only takes effect on the reboot, +# so the box is FIPS-on before anyone can ssh in. + +package_update: true +packages: + - podman + - git + +runcmd: + # SSM agent — not bundled in RHEL AMIs (it is in Amazon Linux). Installed + # and enabled BEFORE the FIPS reboot; systemd brings it back after. This is + # the only management path from the MITRE network (SSH protocol is filtered). + - dnf install -y https://s3.us-east-1.amazonaws.com/amazon-ssm-us-east-1/latest/linux_amd64/amazon-ssm-agent.rpm + - systemctl enable --now amazon-ssm-agent +%{ if fips ~} + # fips-mode-setup is deprecated for SWITCHING on RHEL 9.5+ (the documented + # replacements are all install-time: kickstart fips=1, image builder, bootc). + # This AMI is RHEL 9.4, where post-install switching via this tool is the + # supported, documented method. Long term: move to a FIPS-enabled image. + - fips-mode-setup --enable +%{ endif ~} + - touch /var/tmp/provision-complete + +%{ if fips ~} +power_state: + mode: reboot + message: "Rebooting to enable FIPS mode (fips=1 kernel arg)" +%{ endif ~} diff --git a/packaging/test-infra/fips-ec2/variables.tf b/packaging/test-infra/fips-ec2/variables.tf new file mode 100644 index 0000000000..783ce15743 --- /dev/null +++ b/packaging/test-infra/fips-ec2/variables.tf @@ -0,0 +1,53 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "ami_id" { + description = "RHEL 9 AMI (default: RHEL-9.4.0_HVM x86_64, us-east-1)" + type = string + default = "ami-03137f1c4d12e4ac5" +} + +variable "instance_type" { + description = "Instance type. t3.medium suffices for the FIPS spike; resize (stop -> modify -> start) only if an RPM build proves it needs more." + type = string + default = "t3.medium" +} + +variable "key_name" { + description = "EC2 key pair name" + type = string + default = "aaronl-aws" +} + +variable "subnet_id" { + description = "Subnet (us-east-1a; t3 not offered in us-east-1e)" + type = string + default = "subnet-16bbda4b" +} + +variable "security_group_id" { + description = "Security group with SSH ingress" + type = string + default = "sg-75232501" +} + +variable "fips" { + description = "Enable FIPS mode at first boot (fips-mode-setup --enable + reboot). The host is FIPS-on before it is ever reachable." + type = bool + default = true +} + +variable "volume_gb" { + description = "Root volume size in GB" + type = number + default = 50 +} + +variable "name" { + description = "Name tag" + type = string + default = "heimdall-fips-dev" +} From 404a550c5953e5a0ffd7134b636dbfac8f67189d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 10:41:01 -0400 Subject: [PATCH 021/197] =?UTF-8?q?docs:=20user-space=20session-manager-pl?= =?UTF-8?q?ugin=20install=20=E2=80=94=20brew=20cask=20needs=20blocked=20el?= =?UTF-8?q?evation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/test-infra/fips-ec2/README.md b/packaging/test-infra/fips-ec2/README.md index 5cce7c4803..9507f7c590 100644 --- a/packaging/test-infra/fips-ec2/README.md +++ b/packaging/test-infra/fips-ec2/README.md @@ -15,7 +15,11 @@ an EC2 host and github.com). Session Manager tunnels over HTTPS/443 via the agent's outbound connection, so it works where SSH cannot. ```bash -# interactive shell (needs: brew install --cask session-manager-plugin) +# interactive shell — needs session-manager-plugin. The brew cask requires +# admin elevation (blocked by corporate privilege management); the user-space +# install needs none: +# curl -o /tmp/smp.zip https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac_arm64/sessionmanager-bundle.zip +# unzip /tmp/smp.zip -d /tmp && cp /tmp/sessionmanager-bundle/bin/session-manager-plugin ~/.local/bin/ aws ssm start-session --target # one-shot command From 5b38daadd5fe51cd997a07aa4349222b288210b5 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 10:44:00 -0400 Subject: [PATCH 022/197] =?UTF-8?q?docs:=20rebuild-proof=20SSH-over-SSM=20?= =?UTF-8?q?config=20=E2=80=94=20wildcard=20i-*=20+=20Name-tag=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packaging/test-infra/fips-ec2/README.md b/packaging/test-infra/fips-ec2/README.md index 9507f7c590..05d1b257a0 100644 --- a/packaging/test-infra/fips-ec2/README.md +++ b/packaging/test-infra/fips-ec2/README.md @@ -29,6 +29,27 @@ aws ssm send-command --instance-ids --document-name AWS-RunShellScript \ The key pair is still attached — plain SSH works from networks that permit it. +**Real `ssh`/`scp` through the tunnel** — add to `~/.ssh/config` (before any +`Host *` block). The wildcard entry serves any instance ID ever; the alias +resolves the instance by Name tag at connect time, so it survives rebuilds, +IP changes, and VPC changes without edits: + +``` +Host i-* mi-* + ProxyCommand aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' + User ec2-user + IdentityFile ~/.ssh/aaronl-aws.pem + UserKnownHostsFile /dev/null # host keys churn on rebuild; SSM transport is already SigV4-authed + TLS + LogLevel ERROR + +Host heimdall-fips + ProxyCommand aws ssm start-session --target $(aws ec2 describe-instances --filters Name=tag:Name,Values=heimdall-fips-dev Name=instance-state-name,Values=running --query 'Reservations[0].Instances[0].InstanceId' --output text) --document-name AWS-StartSSHSession --parameters 'portNumber=%p' + User ec2-user + IdentityFile ~/.ssh/aaronl-aws.pem + UserKnownHostsFile /dev/null + LogLevel ERROR +``` + ## Lifecycle ```bash From f7173af25279a0e08c2a664f015511434145bbc6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 11:51:17 -0400 Subject: [PATCH 023/197] =?UTF-8?q?feat:=20bake=20tmux=20into=20the=20test?= =?UTF-8?q?=20host=20=E2=80=94=20long=20jobs=20must=20survive=20SSM=20idle?= =?UTF-8?q?=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/user-data.yaml.tftpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packaging/test-infra/fips-ec2/user-data.yaml.tftpl b/packaging/test-infra/fips-ec2/user-data.yaml.tftpl index 5a5a0ce8ed..60a4e76153 100644 --- a/packaging/test-infra/fips-ec2/user-data.yaml.tftpl +++ b/packaging/test-infra/fips-ec2/user-data.yaml.tftpl @@ -9,6 +9,9 @@ package_update: true packages: - podman - git + # Long-running work (RPM builds) must survive SSM session idle timeouts — + # run it in tmux and re-attach after reconnecting. + - tmux runcmd: # SSM agent — not bundled in RHEL AMIs (it is in Amazon Linux). Installed From 0e0675b72267b817913bcdb7bf63978a5313ba8c Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 11:56:11 -0400 Subject: [PATCH 024/197] =?UTF-8?q?feat:=20idle=20auto-stop=20=E2=80=94=20?= =?UTF-8?q?CloudWatch=20alarm=20stops=20the=20box=20after=2045=20min=20bel?= =?UTF-8?q?ow=203%=20CPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine 5-minute periods of basic (free) monitoring; builds and test runs burn CPU so a job can never be stopped mid-run. treat_missing_data notBreaching so a stopped instance does not flap the alarm. 0 disables. Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/main.tf | 22 ++++++++++++++++++++++ packaging/test-infra/fips-ec2/variables.tf | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packaging/test-infra/fips-ec2/main.tf b/packaging/test-infra/fips-ec2/main.tf index 2ef7971797..dc1d567741 100644 --- a/packaging/test-infra/fips-ec2/main.tf +++ b/packaging/test-infra/fips-ec2/main.tf @@ -83,3 +83,25 @@ resource "aws_instance" "fips_host" { Purpose = var.fips ? "fips-test-host" : "test-host" } } + +# Idle auto-stop — the box must never run up a bill because someone walked +# away. CPU < threshold for the full window -> EC2 stop action. Basic +# monitoring reports in 5-minute periods, so idle_minutes should be a +# multiple of 5. A build or test run burns CPU and can never be stopped +# mid-job; a stop IS a power-off, so detached tmux sessions die with it. +resource "aws_cloudwatch_metric_alarm" "idle_stop" { + count = var.idle_stop_minutes > 0 ? 1 : 0 + alarm_name = "${var.name}-idle-stop" + alarm_description = "Stop ${var.name} after ${var.idle_stop_minutes} min below ${var.idle_cpu_threshold}% CPU" + namespace = "AWS/EC2" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = var.idle_stop_minutes / 5 + threshold = var.idle_cpu_threshold + comparison_operator = "LessThanThreshold" + treat_missing_data = "notBreaching" # already stopped -> no data -> no flapping + dimensions = { InstanceId = aws_instance.fips_host.id } + alarm_actions = ["arn:aws:automate:${var.region}:ec2:stop"] + tags = { Project = "heimdall2-fips" } +} diff --git a/packaging/test-infra/fips-ec2/variables.tf b/packaging/test-infra/fips-ec2/variables.tf index 783ce15743..00bc1c4cd0 100644 --- a/packaging/test-infra/fips-ec2/variables.tf +++ b/packaging/test-infra/fips-ec2/variables.tf @@ -51,3 +51,15 @@ variable "name" { type = string default = "heimdall-fips-dev" } + +variable "idle_stop_minutes" { + description = "Auto-stop after this many minutes below the CPU threshold (multiple of 5; 0 disables). Restart with: aws ec2 start-instances" + type = number + default = 45 +} + +variable "idle_cpu_threshold" { + description = "CPU % considered idle" + type = number + default = 3 +} From 1691d36b969750694c2dc176b00d1e5d6f4116b6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 12:07:45 -0400 Subject: [PATCH 025/197] =?UTF-8?q?feat:=20fips-box=20pause/wake/status=20?= =?UTF-8?q?=E2=80=94=20deliberate=20stop=20and=20ssh-ready=20wake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the instance by Name tag (rebuild-proof, same pattern as the ssh alias). wake waits for SSM registration — the actual ssh-ready signal — not just instance-running. Full cycle verified live: the idle alarm auto-stopped the box on its own at the 45-min mark, wake brought it back FIPS-on with a new IP the alias resolved untouched, pause stopped it again. Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/README.md | 16 +++-- packaging/test-infra/fips-ec2/bin/fips-box | 79 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100755 packaging/test-infra/fips-ec2/bin/fips-box diff --git a/packaging/test-infra/fips-ec2/README.md b/packaging/test-infra/fips-ec2/README.md index 05d1b257a0..4785f585e8 100644 --- a/packaging/test-infra/fips-ec2/README.md +++ b/packaging/test-infra/fips-ec2/README.md @@ -53,12 +53,20 @@ Host heimdall-fips ## Lifecycle ```bash -tofu init && tofu apply # create (~5 min to FIPS-on: update, agent, enable, reboot) -aws ec2 stop-instances --instance-ids # dev box idle — costs only EBS (~$4/mo @ 50GB) -aws ec2 start-instances --instance-ids # public IP CHANGES on start — re-read it -tofu destroy # ephemeral use — gone entirely +tofu init && tofu apply # create (~5 min to FIPS-on: update, agent, enable, reboot) +bin/fips-box pause # taking a break — stop now, don't wait for the idle alarm +bin/fips-box wake # start + wait until SSM is reachable (prints when ssh-ready) +bin/fips-box status # state, IP, idle-alarm state +tofu destroy # ephemeral use — gone entirely ``` +`fips-box` resolves the instance by Name tag, so it needs no edits across +rebuilds. Stopped costs only EBS (~$4/mo @ 50GB). The **idle alarm** stops the +box automatically after 45 min under 3% CPU (`idle_stop_minutes`, 0 disables) — +`pause` is for when you know you're leaving and don't want to wait. Either way, +`wake` brings it back; FIPS mode persists across stop/start (kernel args); +running processes and tmux sessions do not. + Two profiles, one module: ephemeral test host (apply → test → destroy) and persistent dev/test box (apply once, stop between sessions). diff --git a/packaging/test-infra/fips-ec2/bin/fips-box b/packaging/test-infra/fips-ec2/bin/fips-box new file mode 100755 index 0000000000..eed51777c3 --- /dev/null +++ b/packaging/test-infra/fips-ec2/bin/fips-box @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# fips-box — pause/wake/status for the Heimdall FIPS test host. +# +# fips-box pause stop now (don't wait for the 45-min idle alarm) +# fips-box wake start and wait until SSM is reachable (ssh-ready) +# fips-box status instance state, IP, idle-alarm state +# +# The instance is resolved by Name tag at run time — rebuilds, IP changes, +# and VPC changes never require edits. Note: pause is a power-off; disk and +# FIPS mode persist, running processes and tmux sessions do not. +set -euo pipefail + +NAME="${FIPS_BOX_NAME:-heimdall-fips-dev}" + +find_id() { + aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=${NAME}" \ + "Name=instance-state-name,Values=pending,running,stopping,stopped" \ + --query 'Reservations[].Instances[] | [0].InstanceId' --output text +} + +id="$(find_id)" +if [ -z "$id" ] || [ "$id" = "None" ]; then + echo "No instance tagged Name=${NAME} found (is it built? tofu apply)" >&2 + exit 1 +fi + +state() { + aws ec2 describe-instances --instance-ids "$id" \ + --query 'Reservations[0].Instances[0].State.Name' --output text +} + +case "${1:-status}" in + pause) + echo "Pausing ${NAME} (${id})..." + aws ec2 stop-instances --instance-ids "$id" --output text --query 'StoppingInstances[0].CurrentState.Name' + aws ec2 wait instance-stopped --instance-ids "$id" + echo "Paused. Costs only EBS while stopped. Wake with: fips-box wake" + ;; + wake) + s="$(state)" + if [ "$s" = "stopping" ]; then + echo "Instance is still stopping — waiting for it to settle..." + aws ec2 wait instance-stopped --instance-ids "$id" + fi + echo "Waking ${NAME} (${id})..." + aws ec2 start-instances --instance-ids "$id" --output text --query 'StartingInstances[0].CurrentState.Name' + aws ec2 wait instance-running --instance-ids "$id" + echo "Running — waiting for SSM agent (this is what makes ssh work)..." + for _ in $(seq 1 30); do + ping="$(aws ssm describe-instance-information \ + --filters "Key=InstanceIds,Values=${id}" \ + --query 'InstanceInformationList[0].PingStatus' --output text 2>/dev/null || true)" + if [ "$ping" = "Online" ]; then + ip="$(aws ec2 describe-instances --instance-ids "$id" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)" + echo "Awake and reachable: ssh heimdall-fips (public IP now ${ip})" + exit 0 + fi + sleep 10 + done + echo "Instance is running but SSM has not registered after 5 min — check: aws ssm describe-instance-information" >&2 + exit 1 + ;; + status) + ip="$(aws ec2 describe-instances --instance-ids "$id" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)" + alarm="$(aws cloudwatch describe-alarms --alarm-names "${NAME}-idle-stop" \ + --query 'MetricAlarms[0].StateValue' --output text 2>/dev/null || echo none)" + echo "instance: ${id}" + echo "state: $(state)" + echo "ip: ${ip}" + echo "idle-stop alarm: ${alarm}" + ;; + *) + echo "usage: fips-box [pause|wake|status]" >&2 + exit 2 + ;; +esac From 0c1f180ba3cbe14ddc3a1ce2d4d860e8b1d3c9c4 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 6 Aug 2026 12:18:11 -0400 Subject: [PATCH 026/197] =?UTF-8?q?feat:=20single=20identity=20knob=20?= =?UTF-8?q?=E2=80=94=20TF=5FVAR=5Fname=20drives=20tofu,=20fips-box,=20and?= =?UTF-8?q?=20the=20ssh=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default renamed heimdall-fips-dev -> heimdall-fips (tag now matches the alias). Per-user boxes: export TF_VAR_name + TF_VAR_key_name, separate state dir. README warns that renaming an existing box REPLACES it (IAM profile name derives from name; instance-profile change forces replacement) — plan before approving against a box carrying work. Verified on the replacement box: fresh build FIPS-on, alias resolves the new tag, tmux present from user-data, alarm re-bound under the new name. Authored by: Aaron Lippold --- packaging/test-infra/fips-ec2/README.md | 19 ++++++++++++++++++- packaging/test-infra/fips-ec2/bin/fips-box | 9 ++++++--- packaging/test-infra/fips-ec2/variables.tf | 4 ++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packaging/test-infra/fips-ec2/README.md b/packaging/test-infra/fips-ec2/README.md index 4785f585e8..63d1ad5451 100644 --- a/packaging/test-infra/fips-ec2/README.md +++ b/packaging/test-infra/fips-ec2/README.md @@ -43,7 +43,7 @@ Host i-* mi-* LogLevel ERROR Host heimdall-fips - ProxyCommand aws ssm start-session --target $(aws ec2 describe-instances --filters Name=tag:Name,Values=heimdall-fips-dev Name=instance-state-name,Values=running --query 'Reservations[0].Instances[0].InstanceId' --output text) --document-name AWS-StartSSHSession --parameters 'portNumber=%p' + ProxyCommand aws ssm start-session --target $(aws ec2 describe-instances --filters Name=tag:Name,Values=${TF_VAR_name:-heimdall-fips} Name=instance-state-name,Values=running --query 'Reservations[0].Instances[0].InstanceId' --output text) --document-name AWS-StartSSHSession --parameters 'portNumber=%p' User ec2-user IdentityFile ~/.ssh/aaronl-aws.pem UserKnownHostsFile /dev/null @@ -70,6 +70,23 @@ running processes and tmux sessions do not. Two profiles, one module: ephemeral test host (apply → test → destroy) and persistent dev/test box (apply once, stop between sessions). +## Per-user boxes — one env var + +The Name tag is the single identity knob, driven by `TF_VAR_name` everywhere +(OpenTofu reads it natively; `bin/fips-box` and the ssh alias follow the same +variable). Default: `heimdall-fips`. + +```bash +export TF_VAR_name=will-fips # your box, your tools, no file edits +export TF_VAR_key_name=will-aws # REQUIRED per user — default is Aaron's key pair +tofu apply # separate state dir = separate box +``` + +⚠️ **Renaming an EXISTING box replaces it** — the IAM profile name derives from +`name`, and an instance-profile change forces instance replacement. Run +`tofu plan` and read it before approving any apply against a box that carries +work; the disk does not survive replacement. + ## Sizing Default `t3.medium` — sufficient for the FIPS spike and CLI testing. If an RPM diff --git a/packaging/test-infra/fips-ec2/bin/fips-box b/packaging/test-infra/fips-ec2/bin/fips-box index eed51777c3..db33cdbfc7 100755 --- a/packaging/test-infra/fips-ec2/bin/fips-box +++ b/packaging/test-infra/fips-ec2/bin/fips-box @@ -6,11 +6,14 @@ # fips-box status instance state, IP, idle-alarm state # # The instance is resolved by Name tag at run time — rebuilds, IP changes, -# and VPC changes never require edits. Note: pause is a power-off; disk and -# FIPS mode persist, running processes and tmux sessions do not. +# and VPC changes never require edits. The tag follows TF_VAR_name (the same +# variable OpenTofu reads), so one export drives tofu, this script, and the +# ssh alias. FIPS_BOX_NAME remains as a script-only override. +# Note: pause is a power-off; disk and FIPS mode persist, running processes +# and tmux sessions do not. set -euo pipefail -NAME="${FIPS_BOX_NAME:-heimdall-fips-dev}" +NAME="${FIPS_BOX_NAME:-${TF_VAR_name:-heimdall-fips}}" find_id() { aws ec2 describe-instances \ diff --git a/packaging/test-infra/fips-ec2/variables.tf b/packaging/test-infra/fips-ec2/variables.tf index 00bc1c4cd0..9838cd5bc0 100644 --- a/packaging/test-infra/fips-ec2/variables.tf +++ b/packaging/test-infra/fips-ec2/variables.tf @@ -47,9 +47,9 @@ variable "volume_gb" { } variable "name" { - description = "Name tag" + description = "Name tag — the single identity knob. Set via TF_VAR_name; fips-box and the ssh alias follow it through the same variable." type = string - default = "heimdall-fips-dev" + default = "heimdall-fips" } variable "idle_stop_minutes" { From 20674fc7d939f569240e883a6b7f6acb94f81009 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 11:07:48 -0400 Subject: [PATCH 027/197] =?UTF-8?q?research:=20FIPS-host=20spike=20finding?= =?UTF-8?q?s=20=E2=80=94=20provider=20activates=20in-container,=20epic=20S?= =?UTF-8?q?TOP-gate=20cleared=20(e25.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical answers to ADR-006 §15's [U] items, measured on a FIPS-mode RHEL 9.4 EC2 host (packaging/test-infra/fips-ec2), 5 rounds of independent AC review: - Provider activates in ubi9/nodejs-22-minimal with ZERO container config — pure kernel-flag inheritance; deployed build 3.0.7-cda111b5812c30d4 observed live (SSP posture string) - 'Separate RPM since 9.2' refuted — el9 stream first packaged openssl-fips-provider 2024-01-24; ADR §15 corrected - PBKDF2@600k on target hardware: 594.3ms p50 / 600.7ms p95; throughput ~1.7 ops/sec per PHYSICAL core (t3 vCPUs are HT siblings — size by cores, never vCPUs); fs.readFile starves 12.1s under sustained KDF load, 4.1s at UV_THREADPOOL_SIZE=8 — limiter + pool sizing now measured-justified; io_uring hypothesis refuted - bcryptjs executes freely under FIPS — the application gate is the only enforcement point - Node 24 image: activation identical - ADR §11 synced: on-target block supersedes laptop numbers; 600k default decision (Aaron, 2026-08-08) recorded - Instrumented harnesses committed (bench.js concurrency ladder + two-container discriminator) so every published number re-derives Authored by: Aaron Lippold --- ...adr-006-fips-validated-password-hashing.md | 52 +++- docs/research/fips-host-spike.md | 263 ++++++++++++++++++ packaging/test-infra/fips-ec2/spike/bench.js | 97 +++++++ .../fips-ec2/spike/two-container.sh | 23 ++ 4 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 docs/research/fips-host-spike.md create mode 100644 packaging/test-infra/fips-ec2/spike/bench.js create mode 100755 packaging/test-infra/fips-ec2/spike/two-container.sh diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index 4085619340..6b9a0714a1 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -562,7 +562,9 @@ FIPS` is a real denial. ### 11. Performance — measured, and it inverts the prior rating The prior draft asserted "≈ bcrypt cost 14 (~200-400 ms)" and rated the change a -Low/Low performance *regression*. **Both wrong.** Measured on Node 24: +Low/Low performance *regression*. **Both wrong.** Measured on Node 24 — **on a +dev laptop; superseded for capacity planning by the on-target block below +(2026-08-08), kept for the bcrypt-vs-PBKDF2 ratio it demonstrates:** | Operation | Latency | Throughput | Event-loop lag | |---|---|---|---| @@ -587,7 +589,32 @@ the chosen value sits on the recommendation, not above it. It remains defensible at 220k if latency matters more, but 600k is only safe if `UV_THREADPOOL_SIZE` is raised and a global KDF concurrency limit lands.** Keeping 600k while addressing neither is the one indefensible combination. Benchmark on the target RHEL -container before finalizing. +container before finalizing. *(Done — the block below is that benchmark.)* + +**On-target measurement (2026-08-08, spike `docs/research/fips-host-spike.md` +F4) — supersedes the laptop numbers above for capacity planning. [V]** +On a FIPS-mode t3.medium (1 physical core / 2 HT): + +- Single op: **594.3 ms p50 / 600.7 ms p95** (40-sample sequential) at 600k (the + laptop's 145 ms was real but ~4× optimistic for commodity cloud hardware). +- **Throughput ≈ 1.7 ops/sec × physical core** — flat across concurrency 1→32 + and threadpool 4 vs 8; two isolated containers split the same aggregate. + The bound is silicon. **Size by physical cores, never vCPUs** (burstable + instances' "2 vCPU" = 2 HT siblings of one core). +- **`fs.readFile` under SUSTAINED 8-deep KDF load: p50 12.1 s (threadpool 4) + → 4.1 s (threadpool 8)** — the starvation warning above holds on target + hardware far beyond the laptop's 337 ms, and both required mitigations now + carry measured justification (the limiter bounds how many pool slots KDFs + occupy; the larger pool cuts read-wait 3×). + +**Iteration default decided (Aaron, 2026-08-08): 600,000 stays.** The measured +p95 exceeded the spike card's 500 ms review threshold and the decision was +taken deliberately: the production deployment is SSO-dominant (Okta/Keycloak — +external-auth users never invoke PBKDF2), leaving few, privileged local +accounts as the only payers; deployments tune via `PASSWORD_HASH_ITERATIONS`; +and parameters ride in each PHC hash, so a future change needs no migration. +The API-key path — the one KDF consumer with real request volume — is being +removed from iterated hashing entirely under its own ADR (ADR-007, carded). **Settled design (2026-08-05).** The limiter is a hand-rolled counting semaphore (~15 lines, zero dependencies) **inside `password.ts`**, wrapping every pbkdf2 @@ -891,11 +918,13 @@ This posture must be verified on the FIPS-host trip alongside the `[U]` items below. In particular, **whether the containerized provider activates at all without its own `fipsmodule.cnf` is upstream of any version-citation question** — if it does not activate, the version discussion is moot. -- **`fipsmodule.cnf` is absent.** On a stock OpenSSL flow that file (from - `openssl fipsinstall`) activates the provider; RHEL's patched OpenSSL instead - keys off `/proc/sys/crypto/fips_enabled`. **Whether a container on a FIPS host - activates the provider without its own `fipsmodule.cnf` is unresolved** and - requires a FIPS host to settle. **[U]** +- **`fipsmodule.cnf` is absent — and unnecessary. RESOLVED [V]** (spike, + 2026-08-08, `docs/research/fips-host-spike.md` F1): on a FIPS-enabled RHEL 9.4 + host, the container reports `getFips() === 1` with no `fipsmodule.cnf` + anywhere in the image — activation is pure host inheritance via + `/proc/sys/crypto/fips_enabled`, exactly the §10 model. The provider is + active and self-identifies as `3.0.7-cda111b5812c30d4` (F2), confirming the + maintenance-build posture above. **Four constraints:** @@ -907,9 +936,12 @@ if it does not activate, the version discussion is moot. RHEL host is a hard requirement.** 3. Node **never** reads `/proc/sys/crypto/fips_enabled`; RHEL's *OpenSSL* does. That runtime check **is** the inheritance mechanism. -4. **Since RHEL 9.2 the FIPS provider ships as a separate RPM** **[U]** — the - package exists on Red Hat's UBI CDN, but the "since 9.2" claim could not be - retrieved. +4. **The FIPS provider ships as a separate RPM [V]; "since 9.2" is refuted + [V]** (spike F3): `fips.so` is owned by + `openssl-fips-provider-3.0.7-2.el9.x86_64`, whose changelog shows initial + packaging 2024-01-24 — impossible for 9.2 (GA May 2023). The positive + placement ("9.4") is an inference from the date **[U]**, per the spike's + own grading; the load-bearing facts are the separate RPM and the refutation. Stock nodejs.org binaries **do** support FIPS — `BUILDING.md`: "It is not necessary to rebuild Node.js to enable support for FIPS" **[V]** — but require diff --git a/docs/research/fips-host-spike.md b/docs/research/fips-host-spike.md new file mode 100644 index 0000000000..b81189b500 --- /dev/null +++ b/docs/research/fips-host-spike.md @@ -0,0 +1,263 @@ +# FIPS-Host Spike — ADR-006 §15/§10/§11 Empirical Findings + +**Card:** `heimdall2-e25.1` · **Date:** 2026-08-08 +**Host:** EC2 `i-00b942baf369dc6be`, RHEL 9.4 (`RHEL-9.4.0_HVM-20260217`), t3.medium +(2 vCPU, burstable), kernel FIPS mode enabled. Provisioned by +`packaging/test-infra/fips-ec2/` (cloud-init `fips-mode-setup --enable` + reboot). +**Container:** `registry.access.redhat.com/ubi9/nodejs-22-minimal:1` +(Node v22.23.1) under rootless podman — the exact image `Dockerfile:1` pins. +**Method:** every claim below is a live observation on this host; raw outputs +inline. Evidence markers follow ADR-006's standard: **[V]** verified by +execution here; **[U]** plausible mechanism, not load-bearing. + +Host state, verified before any container work: + +``` +$ cat /proc/sys/crypto/fips_enabled +1 +$ fips-mode-setup --check +FIPS mode is enabled. +$ openssl version +OpenSSL 3.0.7 1 Nov 2022 (Library: OpenSSL 3.0.7 1 Nov 2022) +``` + +--- + +## F1 — The provider ACTIVATES in the container with no container-local config. **[V]** + +**The epic's STOP-gate question (ADR §15 [U] item 1) resolves YES.** + +``` +$ podman run --rm ubi9/nodejs-22-minimal:1 node -p 'require("crypto").getFips()' +1 +$ podman run --rm ... sh -c 'find / -name fipsmodule.cnf 2>/dev/null' +(nothing) +$ podman run --rm ... sh -c 'ls -la /usr/lib64/ossl-modules/' +-rwxr-xr-x. 1 root root 1338392 Jun 3 15:40 fips.so +-rwxr-xr-x. 1 root root 140352 Jul 15 09:44 legacy.so +(abridged — total/./.. lines elided) +``` + +`crypto.getFips()` returns `1` inside the container, and **no `fipsmodule.cnf` +exists anywhere in the image**. Activation is pure host inheritance: kernel +`fips_enabled=1` → RHEL's patched OpenSSL reads it at runtime → Node inherits — +across the container boundary, with zero container-side configuration. This is +the §10 model working exactly as documented. The epic's deployment design +stands; no redesign needed. + +## F2 — Provider identity and version, as deployed. **[V]** + +``` +$ podman run --rm ... sh -c 'openssl list -providers' +Providers: + base name: OpenSSL Base Provider version: 3.5.5 status: active + default name: OpenSSL Default Provider version: 3.5.5 status: active + fips name: Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider + version: 3.0.7-cda111b5812c30d4 status: active +``` + +The running module self-identifies as **`3.0.7-cda111b5812c30d4`** — a Red Hat +maintenance build, exactly as ADR §15 predicted, ≠ certificate #4857's validated +`3.0.7-395c1a240fbfffd8`. This is the observed string for the SSP posture +(cite cert #4857, disclose this build, self-affirm the OE per CMVP MM §7.9). + +Note: the **default provider is active alongside fips** — the container's +OpenSSL config does not restrict to fips-only. Approved-algorithm enforcement +on RHEL comes via crypto-policies + the patched OpenSSL, not provider +exclusivity. Consistent with F5 (nothing blocks pure-JS code either). + +## F3 — "Separate RPM since 9.2" is REFUTED. **[V]** (positive dating: inference) + +Run **on the HOST** (RHEL 9.4). Scope note, reconciling with F1/F2: the +provider actually *loaded* in a container is the **image's own** `fips.so` +(F1's `ls`; F2's version string) — activation comes from the kernel flag, not +from host files crossing the boundary. This query dates when the **el9 package +stream** began shipping `fips.so` as its own RPM — which is precisely what §15 +constraint 4 claims; host and UBI image draw from the same el9 stream: + +``` +$ rpm -qf /usr/lib64/ossl-modules/fips.so +openssl-fips-provider-3.0.7-2.el9.x86_64 +$ rpm -q --changelog openssl-fips-provider | tail -2 +* Wed Jan 24 2024 Simo Sorce - 3.0.7-1 +Initial packaging +``` + +The FIPS provider **is** a separate RPM (`openssl-fips-provider`) — that half +of ADR §15 [U] item 4 is confirmed **[V]**. The "since 9.2" dating is +**refuted [V]**: a package first packaged 2024-01-24 cannot have shipped in +9.2 (GA May 2023). The positive placement — "9.4" — is an **inference [U]** +from the date falling between 9.3 GA (Nov 2023) and 9.4 GA (Apr 2024); the +load-bearing fact is the refutation plus the separate-RPM confirmation. The +pinning insight is unchanged: the provider version is decoupled from +`openssl-libs`, deliberately frozen at the validated module's base version +while the linking OpenSSL moves (3.5.5 in the container, per F2; 3.0.7 on +this 9.4 host, per the pasted `openssl version`). + +## F4 — Performance on target hardware: latency 4× the laptop, throughput = physical cores. **[V]** ⚠️ + +Measured with the concurrency-ladder harness at +`packaging/test-infra/fips-ec2/spike/bench.js` (v2, part of this card's change +set — every published number re-derivable from it; the first harness ran a +single unlabelled concurrency and its **throughput and fs numbers are +retracted as methodology flaws** — its 40-sample sequential-latency phase was +sound and is retained below). Host topology **[V]**: + +``` +$ lscpu | grep -E '^CPU\(s\)|Thread|Core|Model name' +CPU(s): 2 +Model name: Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +Thread(s) per core: 2 +Core(s) per socket: 1 +``` + +A t3.medium's "2 vCPU" is **two HT siblings of ONE physical core**. + +Sequential latency, 40 samples (produced by the v1 harness's sequential phase +— methodologically sound and retained; the identical procedure is now +bench.js v2's sequential phase, `BENCH_SEQ_N`, same output format): + +``` +latency ms — p50: 594.3, p95: 600.7, min: 592.5, max: 600.7 +``` + +Concurrency ladder (v2 harness; per-level sample count is 2×C, so ladder +latency columns characterize queueing shape — the 40-sample run above is the +latency source of record. These ladder runs predate the harness's added +sequential phase: reproduce byte-identical output with `BENCH_SEQ_N=0`): + +``` +fips=1 node=v22.23.1 iter=600000 nproc=2 threadpool=4 (default) +C ops wall_s ops/sec op_p50_ms op_p95_ms steal% +1 2 1.2 1.68 598 598 0.0 +2 4 2.4 1.65 1214 1215 0.0 +4 8 4.9 1.64 2425 2440 0.0 +8 16 9.7 1.64 4754 4873 0.0 +16 32 18.8 1.70 6941 9416 0.0 +32 64 38.0 1.68 11743 19013 0.0 +fs.readFile ms — baseline p50=0.15 p95=0.29 | under sustained 8-KDF load p50=12145.73 p95=12245.05 + +``` + +Second ladder, `UV_THREADPOOL_SIZE=8` (pasted in full): + +``` +fips=1 node=v22.23.1 iter=600000 nproc=2 threadpool=8 +C ops wall_s ops/sec op_p50_ms op_p95_ms steal% +1 2 1.2 1.67 600 600 0.0 +2 4 2.4 1.65 1214 1215 0.0 +4 8 4.9 1.64 2427 2440 0.0 +8 16 10.0 1.60 4948 5066 0.0 +16 32 19.4 1.65 9658 9698 0.0 +32 64 37.9 1.69 13840 19020 0.0 +fs.readFile ms — baseline p50=0.15 p95=0.29 | under sustained 8-KDF load p50=4143.75 p95=4693.13 +``` + +Two-container discriminator **[V]** — two isolated podman containers each +running continuous C=1 for 15 s, simultaneously +(script: `packaging/test-infra/fips-ec2/spike/two-container.sh`, this change set): + +``` +A ops: 13 in 15.784 s = 0.82 ops/sec +B ops: 13 in 15.746 s = 0.83 ops/sec (aggregate 1.65 = the same ceiling) +``` + +- **Single-op cost: 594.3 ms p50 / 600.7 ms p95** (40-sample sequential run, + pasted above) at 600k — vs 145 ms on the dev laptop (§11's number was real + but not representative). **p95 exceeded the card's 500 ms STOP threshold** — + surfaced; decision recorded below. +- **Throughput is pinned at 1.60–1.70 ops/sec at EVERY concurrency (1→32) + and BOTH threadpool sizes** (the low point, 1.60, is the UV=8 ladder's C=8 + row — pasted above) **while wall time and p95 latency scale ~linearly with + C** (p95: 598 → 1215 → 2440 → 4873 → 9416 → 19013; p50 tracks more loosely + at mid-ladder due to completion-order spread) — + real concurrency, hard ceiling. The two-container test splits the same + 1.65/sec between isolated processes, proving the bound is the **physical + core**, not a threadpool artifact or a FIPS-provider lock. SMT contributes + ~nothing to this ALU-bound SHA-512 loop. **Sizing law: KDF throughput ≈ + 1.7 ops/sec × physical cores (this CPU generation, 600k iterations) — + count cores, never vCPUs.** +- **`fs.readFile` starves catastrophically under SUSTAINED KDF load [V]:** + p50 **12.1 s** at default threadpool, **4.1 s** at UV_THREADPOOL_SIZE=8. + §11's starvation warning is reinstated *stronger* than its laptop numbers, + and both mitigations now carry measured justification: the KDF concurrency + limiter (bounding how many pool slots KDFs may hold) and UV_THREADPOOL_SIZE=8 + (3× less read-wait under saturation). +- **History of this finding:** harness v1 launched 8 one-shot KDFs (a + transient, not sustained, load) and observed no starvation, which this doc + briefly attributed to io_uring **[U]**. v2's continuous-refill load shows + the truth; the io_uring hypothesis is **refuted** — reads demonstrably share + the threadpool. v1's "throughput" run never recorded its concurrency and is + superseded by the ladder. +- **Caveats [U — predictions, not measured]:** t3-class burstable credits + could make sustained production load worse than these short runs; per-op + latency on other CPU generations will differ. The measured sizing law above + is the [V] part. + +## F5 — bcryptjs executes freely under FIPS: the gate must be OURS. **[V]** + +``` +fips: 1 +bcrypt.hashSync under FIPS: EXECUTED, prefix $2b$12$ +bcrypt.compareSync correct pw: true +bcrypt.compareSync wrong pw: false +``` + +With the host in FIPS mode and the provider active, pure-JS bcrypt hashing and +comparison run to completion, unblocked and undetected. The platform will never +enforce ADR §3's prohibition — the application-level FIPS gate in +`verifyPassword` is the only enforcement point. Confirmed by execution. + +## F6 — Node 24 behaves identically. **[V]** + +`ubi9/nodejs-24-minimal:1` exists (Node v24.18.0, current LTS; the Dockerfile +pins 22). Activation checks, same host, outputs pasted: + +``` +$ podman run --rm ubi9/nodejs-24-minimal:1 sh -c 'node -v; node -p "require(\"crypto\").getFips()"' +v24.18.0 +1 +``` + +FIPS activation is identical to Node 22 **[V]**. A v1-methodology benchmark +run showed sequential latency consistent with Node 22 — expected, since pbkdf2 +executes in OpenSSL's C code — but its output is not published here and **no +performance figure is claimed for Node 24 [U]**; re-derive with the v2 harness +against the nodejs-24 image if it ever becomes load-bearing. A future Node +bump requires no FIPS *activation* rework **[V — scoped to the tested +nodejs-24 image]**. + +--- + +## Decision — iteration count: 600,000 stays (Aaron, 2026-08-08) + +Rationale: best-practice default (OWASP's FIPS-context recommendation), and the +target deployment (140 AF PMOs) is Okta/Keycloak-dominant — external-auth users +never touch PBKDF2, so the payers are few and privileged (local break-glass +admins), exactly the accounts to harden hardest. Deployments tune via +`PASSWORD_HASH_ITERATIONS`; parameters live in each hash, so any later change +needs no migration. The real scale bottleneck is the API-key path, addressed +separately (ADR-007 card). Original decision table preserved below for the record. + +### The options as presented + +The card's STOP threshold (p95 > 500 ms) is exceeded at 600k on 2-vCPU cloud +hardware. Scaling from measured numbers: + +| Iterations | p50 est. | Throughput est. (per physical core) | Standing | +|---|---|---|---| +| **600,000** (ADR default) | ~594 ms | ~1.6/sec | OWASP's "600k or more" for FIPS-140 contexts | +| **310,000** | ~307 ms | ~3.1/sec | above OWASP floor, middle path | +| **220,000** (OWASP floor) | ~218 ms | ~4.4/sec | §11's documented fallback | + +All three clear the module's own minimum (1,000) by orders of magnitude, and +parameters live in the PHC string, so the choice is **tunable later without +migration** — hashes verify at their recorded iterations regardless (ADR §8's +no-propagation caveat noted). `PASSWORD_HASH_ITERATIONS` also lets individual +deployments tune per hardware. The decision sets the *default* in `e25.6`/§9. + +## Verdict for the epic + +**Foundation confirmed — proceed.** F1 clears the STOP-gate; F2 gives the SSP +posture string; F5 proves the gate design is necessary. The iteration default +is decided (600k — see Decision above); implementation lands in `e25.6`. diff --git a/packaging/test-infra/fips-ec2/spike/bench.js b/packaging/test-infra/fips-ec2/spike/bench.js new file mode 100644 index 0000000000..8efe598d66 --- /dev/null +++ b/packaging/test-infra/fips-ec2/spike/bench.js @@ -0,0 +1,97 @@ +// PBKDF2 benchmark harness — heimdall2-e25.1 (ADR-006 §11 on target hardware). +// Lives in-repo so every published number is re-derivable. Reports a +// concurrency LADDER with per-op latency at each level, so serial timings can +// never be mistaken for a saturation ceiling (the defect in this harness's v1). +// Note: ladder sample count per level is 2×C (small at C=1) — the ladder +// characterizes queueing shape and throughput; latency-of-record claims should +// come from a dedicated high-N sequential run (spike doc pastes a 40-sample one). +'use strict'; +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); + +const ITER = Number(process.env.BENCH_ITER || 600_000); +const pct = (a, p) => { + const s = [...a].sort((x, y) => x - y); + return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; +}; +const pbkdf2Timed = (pw) => + new Promise((res, rej) => { + const t = process.hrtime.bigint(); + crypto.pbkdf2(pw, crypto.randomBytes(32), ITER, 64, 'sha512', (e) => + e ? rej(e) : res(Number(process.hrtime.bigint() - t) / 1e6) + ); + }); + +const steal = () => { + // /proc/stat cpu line: user nice system idle iowait irq softirq steal + const f = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0].trim().split(/\s+/).slice(1).map(Number); + return {steal: f[7], total: f.reduce((a, b) => a + b, 0)}; +}; + +(async () => { + console.log(`fips=${crypto.getFips()} node=${process.version} iter=${ITER} nproc=${os.cpus().length} threadpool=${process.env.UV_THREADPOOL_SIZE || '4 (default)'}`); + + // Warmup + await Promise.all([pbkdf2Timed('warm'), pbkdf2Timed('warm')]); + + // Sequential latency phase — the latency source of record (high-N, one op + // in flight). SEQ_N=0 to skip. + const SEQ_N = Number(process.env.BENCH_SEQ_N ?? 40); + if (SEQ_N > 0) { + const seq = []; + for (let i = 0; i < SEQ_N; i++) seq.push(await pbkdf2Timed('CorrectHorseBatteryStaple15!')); + console.log(`latency ms — p50: ${pct(seq, 50).toFixed(1)}, p95: ${pct(seq, 95).toFixed(1)}, min: ${Math.min(...seq).toFixed(1)}, max: ${Math.max(...seq).toFixed(1)}`); + } + + // Concurrency ladder: at each level C, run 2 batches of C concurrent ops. + // Per-op latency + wall time together distinguish serial from parallel: + // parallel => per-op latency grows while ops/sec grows; serial => flat + // latency, flat ops/sec. + console.log('C\tops\twall_s\tops/sec\top_p50_ms\top_p95_ms\tsteal%'); + for (const C of [1, 2, 4, 8, 16, 32]) { + const ops = C * 2; + const lats = []; + const s0 = steal(); + const t0 = process.hrtime.bigint(); + for (let batch = 0; batch < 2; batch++) { + const r = await Promise.all(Array.from({length: C}, () => pbkdf2Timed('CorrectHorse15!'))); + lats.push(...r); + } + const wall = Number(process.hrtime.bigint() - t0) / 1e9; + const s1 = steal(); + const stealPct = (100 * (s1.steal - s0.steal)) / Math.max(1, s1.total - s0.total); + console.log(`${C}\t${ops}\t${wall.toFixed(1)}\t${(ops / wall).toFixed(2)}\t${pct(lats, 50).toFixed(0)}\t${pct(lats, 95).toFixed(0)}\t${stealPct.toFixed(1)}`); + } + + // fs.readFile under sustained KDF load (8 concurrent, refilled) vs baseline + fs.writeFileSync('/tmp/bench-probe', 'x'.repeat(4096)); + const readOnce = () => + new Promise((res) => { + const t = process.hrtime.bigint(); + fs.readFile('/tmp/bench-probe', () => res(Number(process.hrtime.bigint() - t) / 1e6)); + }); + const base = []; + for (let i = 0; i < 50; i++) base.push(await readOnce()); + + let active = 0; + let stop = false; + const refill = () => { + while (!stop && active < 8) { + active++; + pbkdf2Timed('load').then(() => { + active--; + refill(); + }); + } + }; + refill(); + const loaded = []; + for (let i = 0; i < 50; i++) loaded.push(await readOnce()); + stop = true; + + console.log(`fs.readFile ms — baseline p50=${pct(base, 50).toFixed(2)} p95=${pct(base, 95).toFixed(2)} | under sustained 8-KDF load p50=${pct(loaded, 50).toFixed(2)} p95=${pct(loaded, 95).toFixed(2)}`); +})().catch((e) => { + console.error('BENCH FAILED:', e.message); + process.exit(1); +}); diff --git a/packaging/test-infra/fips-ec2/spike/two-container.sh b/packaging/test-infra/fips-ec2/spike/two-container.sh new file mode 100755 index 0000000000..0cf4d4173b --- /dev/null +++ b/packaging/test-infra/fips-ec2/spike/two-container.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Two-container throughput discriminator — heimdall2-e25.1 spike F4. +# Runs continuous C=1 PBKDF2@600k in two ISOLATED podman containers +# simultaneously for 15s each. If aggregate throughput matches the in-process +# ceiling (~1.7 ops/sec on 1 physical core), the bound is silicon; if it +# doubles, the bound was in-process (threadpool or provider lock). +# Observed 2026-08-08 on FIPS t3.medium: A 0.82 + B 0.83 = 1.65 ops/sec +# aggregate — the bound is the physical core. +set -euo pipefail +IMG="${1:-registry.access.redhat.com/ubi9/nodejs-22-minimal:1}" + +run_one() { + local label="$1" + podman run --rm "$IMG" node -e " + const c=require('crypto');const t=Date.now();let n=0; + const go=()=>c.pbkdf2('$label',c.randomBytes(32),600000,64,'sha512',()=>{ + n++; + if(Date.now()-t<15000)go(); + else console.log('$label ops:',n,'in',(Date.now()-t)/1000,'s =',(n/((Date.now()-t)/1000)).toFixed(2),'ops/sec') + });go()" +} + +run_one A & run_one B & wait From 4de0e261076b53ff246f48b2e7288f99a2274930 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 12:35:55 -0400 Subject: [PATCH 028/197] feat: add @heimdall/password-hash-vectors workspace with malformed-hash corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The password-hash format contract shared between heimdall2 and heimdall-cli (ADR-006 §14). This card (e25.2) ships the malformed-hash corpus; known-good vectors + formatVersion follow in e25.4. - 21 corpus entries, one per ADR-006 §6 input-validation trap and §3 dispatch case, each {hash, expected, trap, note} with the note citing its §6 rule: parseInt('6e5')===6, $pbkdf2-sha* algorithm confusion, lenient base64, key/salt length, the bcrypt dispatch prefixes, and the cutover sentinel - byte-level spec assertions (arithmetic isCanonicalB64 + b64ByteWidth) pin that the §6 item-7 fixtures use canonical base64 so item 6 cannot mask them, and that iter-over-max reaches the item-5 bound — both defects an independent review caught and that would otherwise let a broken verifyPassword pass - pure data + types, no crypto; consumed in-repo and by heimdall-cli via pinned git ref, not published to npm - 8 tests pass, tsc clean, zero eslint-disable Authored by: Aaron Lippold --- libs/password-hash-vectors/.gitignore | 1 + libs/password-hash-vectors/README.md | 41 ++++ libs/password-hash-vectors/package.json | 25 +++ libs/password-hash-vectors/src/index.ts | 15 ++ .../src/malformed-corpus.spec.ts | 190 ++++++++++++++++ .../src/malformed-corpus.ts | 205 ++++++++++++++++++ libs/password-hash-vectors/tsconfig.json | 9 + libs/password-hash-vectors/vitest.config.ts | 3 + 8 files changed, 489 insertions(+) create mode 100644 libs/password-hash-vectors/.gitignore create mode 100644 libs/password-hash-vectors/README.md create mode 100644 libs/password-hash-vectors/package.json create mode 100644 libs/password-hash-vectors/src/index.ts create mode 100644 libs/password-hash-vectors/src/malformed-corpus.spec.ts create mode 100644 libs/password-hash-vectors/src/malformed-corpus.ts create mode 100644 libs/password-hash-vectors/tsconfig.json create mode 100644 libs/password-hash-vectors/vitest.config.ts diff --git a/libs/password-hash-vectors/.gitignore b/libs/password-hash-vectors/.gitignore new file mode 100644 index 0000000000..c3af857904 --- /dev/null +++ b/libs/password-hash-vectors/.gitignore @@ -0,0 +1 @@ +lib/ diff --git a/libs/password-hash-vectors/README.md b/libs/password-hash-vectors/README.md new file mode 100644 index 0000000000..bf2964d3da --- /dev/null +++ b/libs/password-hash-vectors/README.md @@ -0,0 +1,41 @@ +# @heimdall/password-hash-vectors + +The password-hash **format contract** for Heimdall, shared between the +application (`mitre/heimdall2`) and the admin CLI (`mitre/heimdall-cli`). + +heimdall2 **owns** the format. Both implementations test against these vectors, +so a divergence surfaces as a build failure rather than as a break-glass tool +that writes credentials the FIPS-gated server refuses (ADR-006 §14). + +## Contents + +| Export | Status | Purpose | +| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MALFORMED_CORPUS` | **this card (e25.2)** | Every ADR-006 §6 input-validation trap and §3 dispatch case, as `{hash, expected, trap, note}` entries. Drives `verifyPassword`'s rejection tests (e25.7) and heimdall-cli's parity suite. | +| known-good vectors | follow-on (e25.4) | Reproducible password→hash pairs for all three digests. | +| `formatVersion` | follow-on (e25.4) | Integer stamp heimdall-cli asserts against at build time; bumped on any change to the PHC grammar, allowlist, or parameter bounds. | + +## The corpus is executable documentation + +Each entry's `note` cites the specific §6 rule it exercises — the parser traps +that are invisible until you hit them: + +- `parseInt('6e5') === 6` — a forged hash would verify at 6 iterations +- `$pbkdf2-sha*` prefix-matching accepts `md5`/`sha1` — algorithm-confusion downgrade +- `Buffer.from('AA@@AA','base64')` silently drops `@@` — lenient base64 +- a `sha512` hash carrying a 32-byte key would verify a downgraded artifact + +`expected` is what a correct `verifyPassword` must produce: `reject` (return +`{valid:false}` without throwing), `bcrypt`/`pbkdf2` (well-formed — dispatch, +don't reject), or `sentinel` (the §3 cutover-invalidation marker). + +## Scope + +Data and types only — no crypto, no parsing logic. Consumed in-repo and by +heimdall-cli via a pinned git ref; not published to npm. + +## Test + +```sh +yarn workspace @heimdall/password-hash-vectors test +``` diff --git a/libs/password-hash-vectors/package.json b/libs/password-hash-vectors/package.json new file mode 100644 index 0000000000..427613d28e --- /dev/null +++ b/libs/password-hash-vectors/package.json @@ -0,0 +1,25 @@ +{ + "name": "@heimdall/password-hash-vectors", + "version": "2.13.0", + "license": "Apache-2.0", + "description": "Password-hash format contract for Heimdall: malformed-hash corpus and (follow-on) known-good vectors shared between heimdall2 and heimdall-cli", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/mitre/heimdall2", + "directory": "libs/password-hash-vectors" + }, + "main": "src/index.ts", + "engines": { + "node": ">=22.18.0" + }, + "scripts": { + "lint": "eslint --fix", + "lint:ci": "eslint --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "vitest": "^4.0.18" + } +} diff --git a/libs/password-hash-vectors/src/index.ts b/libs/password-hash-vectors/src/index.ts new file mode 100644 index 0000000000..c0e1171423 --- /dev/null +++ b/libs/password-hash-vectors/src/index.ts @@ -0,0 +1,15 @@ +/** + * @heimdall/password-hash-vectors + * + * The password-hash format contract shared between heimdall2 and heimdall-cli + * (ADR-006 §14). heimdall2 owns the format; heimdall-cli consumes these + * vectors and asserts a formatVersion at build time. + * + * This card (e25.2) ships the malformed-hash corpus only. The known-good + * password→hash vectors and the formatVersion stamp arrive in e25.4. + */ +export { + MALFORMED_CORPUS, + type MalformedExpected, + type MalformedVector, +} from './malformed-corpus'; diff --git a/libs/password-hash-vectors/src/malformed-corpus.spec.ts b/libs/password-hash-vectors/src/malformed-corpus.spec.ts new file mode 100644 index 0000000000..6e8f470ffe --- /dev/null +++ b/libs/password-hash-vectors/src/malformed-corpus.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { MALFORMED_CORPUS, type MalformedVector } from './malformed-corpus'; + +// Every §6 trap class (ADR-006 §6 "Input validation — exact sequence", plus +// the §3 dispatch prefixes and sentinel). The corpus is the shared ammunition +// for e25.7's verifyPassword tests and for heimdall-cli's parity suite, so +// coverage of each class is the contract, asserted here. +const REQUIRED_TRAPS = [ + 'empty-string', + 'non-string', + 'four-field', + 'six-field', + 'parts0-nonempty', + 'algo-md5', + 'algo-sha1', + 'algo-sha512-md5-naive-split', + 'iter-6e5', + 'iter-600000abc', + 'iter-0x10000', + 'iter-zero', + 'iter-over-max', + 'lenient-base64-at', + 'lenient-base64-space', + 'key-length-mismatch', + 'salt-too-short', + 'bcrypt-2a', + 'bcrypt-2b', + 'bcrypt-2y', + 'sentinel', +] as const; + +const B64_ALPHABET + = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +// Decoded byte width of a no-padding base64 string: floor(len * 6 / 8). +function b64ByteWidth(field: string): number { + return Math.floor((field.length * 3) / 4); +} + +function fieldsOf(hash: string): string[] { + return hash.split('$'); +} + +// A no-padding base64 string is canonical iff its trailing bits are zero: +// length%4===2 → the last char's low 4 bits must be 0; length%4===3 → low 2 +// bits must be 0; length%4===0 → always canonical. 'A' (value 0) always is; +// 'B' (value 1) is NOT at those remainders — exactly the masking bug this +// pins against (§6 item 6 rejects a non-canonical field before item 7 runs). +// Computed arithmetically so the check needs neither Buffer nor a regex. +function isCanonicalB64(field: string): boolean { + const rem = field.length % 4; + if (rem === 0) { + return true; + } + if (rem === 1) { + return false; // not a valid base64 length + } + const lastValue = B64_ALPHABET.indexOf(field.at(-1) ?? ''); + if (lastValue === -1) { + return false; + } + const modulus = rem === 2 ? 16 : 4; + return lastValue % modulus === 0; +} + +describe('MALFORMED_CORPUS', () => { + it('contains an entry for every §6 trap class', () => { + const traps = new Set(MALFORMED_CORPUS.map(v => v.trap)); + for (const required of REQUIRED_TRAPS) { + expect(traps, `missing corpus entry for trap "${required}"`).toContain( + required, + ); + } + }); + + it('types every entry with an expected outcome and a trap label', () => { + const outcomes = new Set([ + 'bcrypt', + 'pbkdf2', + 'reject', + 'sentinel', + ]); + for (const v of MALFORMED_CORPUS) { + expect(typeof v.trap, `trap not a string: ${JSON.stringify(v)}`).toBe( + 'string', + ); + expect(outcomes, `bad expected for ${v.trap}`).toContain(v.expected); + } + }); + + it('gives every entry a string hash except the deliberate non-string probe', () => { + const stringHashes = MALFORMED_CORPUS.filter( + v => v.trap !== 'non-string', + ); + const nonStringHashes = MALFORMED_CORPUS.filter( + v => v.trap === 'non-string', + ); + expect(stringHashes.every(v => typeof v.hash === 'string')).toBe(true); + // The 'non-string' entry exists precisely to exercise §6 step 1's + // reject-non-string branch, so its hash must NOT be a string. + expect(nonStringHashes).toHaveLength(1); + expect(typeof nonStringHashes[0].hash).not.toBe('string'); + }); + + it('marks the three bcrypt prefixes as dispatch fixtures, not rejects', () => { + for (const trap of ['bcrypt-2a', 'bcrypt-2b', 'bcrypt-2y'] as const) { + const entry = MALFORMED_CORPUS.find(v => v.trap === trap); + expect(entry, `no entry for ${trap}`).toBeDefined(); + // These are well-formed bcrypt hashes — verifyPassword dispatches on + // them (§3), it does not reject them as malformed. + expect(entry?.expected).toBe('bcrypt'); + } + }); + + // The §6 item-7 entries (key/salt LENGTH) only exercise item 7 if they + // survive item 6 (decode → re-encode → compare). A non-canonical base64 + // field would be rejected at item 6 first, silently masking item 7 — so + // pin canonicality + intended byte width here, at the corpus's own layer. + it('gives the item-7 entries canonical base64 fields so item 6 does not mask them', () => { + // key-length-mismatch: $pbkdf2-sha512$i=..$salt$key → salt=[3], key=[4] + const keyLength = MALFORMED_CORPUS.find( + v => v.trap === 'key-length-mismatch', + ); + const klFields = fieldsOf(keyLength?.hash as string); + // Pin the DECLARED digest too: without this, changing the header to + // pbkdf2-sha256 would make the 32-byte key a valid width and turn this + // into a well-formed hash still labelled 'reject' (the D1/D2 class). + expect(klFields[1]).toBe('pbkdf2-sha512'); + expect(isCanonicalB64(klFields[3]), 'key-length salt not canonical').toBe( + true, + ); + expect(isCanonicalB64(klFields[4]), 'key-length key not canonical').toBe( + true, + ); + // sha512 declared (64-byte width), but the key is 32 bytes — the mismatch. + expect(b64ByteWidth(klFields[4])).toBe(32); + + const saltShort = MALFORMED_CORPUS.find(v => v.trap === 'salt-too-short'); + const ssFields = fieldsOf(saltShort?.hash as string); + expect(isCanonicalB64(ssFields[3]), 'salt-too-short salt not canonical').toBe( + true, + ); + expect(isCanonicalB64(ssFields[4]), 'salt-too-short key not canonical').toBe( + true, + ); + // salt is 15 bytes — below the 16-byte minimum (§6 item 7). + expect(b64ByteWidth(ssFields[3])).toBeLessThan(16); + }); + + it('gives iter-over-max a value that passes the item-4 pattern but exceeds the item-5 bound', () => { + const entry = MALFORMED_CORPUS.find(v => v.trap === 'iter-over-max'); + const iterField = fieldsOf(entry?.hash as string)[2]; // 'i=NNNN' + expect(iterField.startsWith('i=')).toBe(true); + const digits = iterField.slice(2); + // Characterizes item-4's /^i=[1-9][0-9]{0,8}$/ without a regex literal: + // 1–9 digits, no leading zero, all decimal. + expect(digits.length).toBeGreaterThanOrEqual(1); + expect(digits.length).toBeLessThanOrEqual(9); + expect(digits.startsWith('0')).toBe(false); + expect([...digits].every(c => c >= '0' && c <= '9')).toBe(true); + // …yet the value is above the item-5 DoS bound. + expect(Number(digits)).toBeGreaterThan(10_000_000); + }); + + it('gives parts0-nonempty a 5-field hash with a non-empty first field', () => { + const entry = MALFORMED_CORPUS.find(v => v.trap === 'parts0-nonempty'); + const fields = fieldsOf(entry?.hash as string); + expect(fields).toHaveLength(5); // NOT caught by the field-count arm + expect(fields[0]).not.toBe(''); // caught only by the parts[0] arm + }); + + it('classifies every algorithm-confusion and iteration trap as reject', () => { + const rejectTraps = [ + 'algo-md5', + 'algo-sha1', + 'algo-sha512-md5-naive-split', + 'iter-6e5', + 'iter-600000abc', + 'iter-0x10000', + 'iter-zero', + 'iter-over-max', + 'key-length-mismatch', + 'salt-too-short', + ]; + for (const trap of rejectTraps) { + const entry = MALFORMED_CORPUS.find(v => v.trap === trap); + expect(entry?.expected, `${trap} should be reject`).toBe('reject'); + } + }); +}); diff --git a/libs/password-hash-vectors/src/malformed-corpus.ts b/libs/password-hash-vectors/src/malformed-corpus.ts new file mode 100644 index 0000000000..bed7f20571 --- /dev/null +++ b/libs/password-hash-vectors/src/malformed-corpus.ts @@ -0,0 +1,205 @@ +/** + * Malformed-hash corpus — the shared ammunition for verifyPassword's input + * validation (ADR-006 §6) and the §3 dispatch table. + * + * Each entry is a hash string (or, in one deliberate case, a non-string) paired + * with the outcome a correct verifyPassword MUST produce, and a `trap` label + * naming the §6 rule it exercises. heimdall2's verifyPassword (e25.7) and + * heimdall-cli's parity suite both run against this corpus; a divergence is a + * contract violation. + * + * This module is DATA ONLY — no crypto, no parsing logic. The known-good + * password→hash vectors and the formatVersion stamp land in the follow-on + * card (e25.4). + * + * Expected outcomes: + * 'reject' — verifyPassword returns {valid:false} WITHOUT throwing (§6) + * 'bcrypt' — a well-formed bcrypt hash; §3 dispatches to the bcrypt path + * (FIPS-off) or refuses (FIPS-on). NOT a malformed reject. + * 'pbkdf2' — a well-formed PBKDF2/PHC hash; §3 dispatches to the pbkdf2 path + * 'sentinel' — the cutover-invalidation sentinel (§3): unproducible by + * hashPassword, rejected on the unknown-format path + */ +export type MalformedExpected = 'bcrypt' | 'pbkdf2' | 'reject' | 'sentinel'; + +export type MalformedVector = { + readonly expected: MalformedExpected; + /** The stored value fed to verifyPassword. Non-string only for 'non-string'. */ + readonly hash: unknown; + readonly note: string; + /** Names the §6 rule (or §3 dispatch case) this entry exercises. */ + readonly trap: string; +}; + +// A valid-looking 32-byte base64 salt and a 64-byte (sha512-width) base64 key, +// padding stripped per PHC — reused to build hashes that are well-formed +// EXCEPT for the one field each entry is probing. +// +// Both MUST be CANONICAL base64: 'A' is byte value 0, so any run of 'A's +// re-encodes to itself. 'B' (value 1) does NOT — 'B'.repeat(86) round-trips to +// '...BA', which fails §6 item 6 (decode/re-encode/compare) and would mask the +// item-7 (key/salt length) entries by rejecting them a step early. Verified. +const B64_SALT_32 = 'A'.repeat(43); // 32 bytes → 43 b64 chars, canonical +const B64_KEY_64 = 'A'.repeat(86); // 64 bytes → 86 b64 chars, canonical + +export const MALFORMED_CORPUS: readonly MalformedVector[] = [ + // §6 step 1 — reject non-string / empty. ''.split('$') is [''] so the + // parts[0]==='' check alone passes for the empty string; the field-count + // check (step 2) is what catches it. These are AND, not alternatives. + { + expected: 'reject', + hash: '', + note: "''.split('$') === [''] — parts[0]==='' passes; field count catches it", + trap: 'empty-string', + }, + { + expected: 'reject', + hash: 12_345, + note: 'non-string input rejected before any parsing', + trap: 'non-string', + }, + + // §6 step 2 — split('$') must yield EXACTLY 5 parts. + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000$${B64_SALT_32}`, + note: '4 fields (missing key) — not exactly 5', + trap: 'four-field', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000$${B64_SALT_32}$${B64_KEY_64}$extra`, + note: '6 fields (trailing segment) — not exactly 5', + trap: 'six-field', + }, + { + expected: 'reject', + // 5 fields (correct count) but parts[0] is 'x' not '' — isolates the + // parts[0]==='' arm of §6 item 2, which a field-count-only check misses. + hash: `x$pbkdf2-sha512$i=600000$${B64_SALT_32}$${B64_KEY_64}`, + note: "leading char before $ — parts[0] !== '' though field count is 5 (§2: leading $ is load-bearing)", + trap: 'parts0-nonempty', + }, + + // §6 step 3 — STRICT allowlist over the FULL identifier. crypto.pbkdf2 + // accepts 'md5' and 'sha1', so prefix-matching $pbkdf2-sha* would verify a + // downgraded digest. Allowlisting only the digest still admits the + // naive-split confusion below. + { + expected: 'reject', + hash: `$pbkdf2-md5$i=600000$${B64_SALT_32}$${'B'.repeat(22)}`, + note: 'algorithm confusion: $pbkdf2-md5$ — MD5 banned; strict allowlist rejects', + trap: 'algo-md5', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha1$i=600000$${B64_SALT_32}$${'B'.repeat(27)}`, + note: 'algorithm confusion: $pbkdf2-sha1$ — SHA-1 not in the allowlist', + trap: 'algo-sha1', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512-md5$i=600000$${B64_SALT_32}$${B64_KEY_64}`, + note: 'naive split on last dash would read digest as md5; full-identifier allowlist rejects', + trap: 'algo-sha512-md5-naive-split', + }, + + // §6 step 4 — iterations by regex /^i=([1-9][0-9]{0,8})$/ ONLY. + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=6e5$${B64_SALT_32}$${B64_KEY_64}`, + note: "parseInt('6e5') === 6 — a 100,000x downgrade that looks well-formed; regex rejects", + trap: 'iter-6e5', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000abc$${B64_SALT_32}$${B64_KEY_64}`, + note: "parseInt('600000abc') === 600000 — trailing garbage; regex rejects", + trap: 'iter-600000abc', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=0x10000$${B64_SALT_32}$${B64_KEY_64}`, + note: "Number('0x10000') === 65536 — hex literal; regex (decimal only) rejects", + trap: 'iter-0x10000', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=0$${B64_SALT_32}$${B64_KEY_64}`, + note: 'i=0 — leading-zero / below the module minimum (1000); regex requires [1-9] start', + trap: 'iter-zero', + }, + { + expected: 'reject', + // 8 digits — PASSES the item-4 regex, so it actually reaches item 5's + // upper bound (unlike a 10-digit value, which item 4's 9-digit cap + // rejects first and would never test the DoS guard). + hash: `$pbkdf2-sha512$i=20000000$${B64_SALT_32}$${B64_KEY_64}`, + note: 'i=20,000,000 passes the item-4 regex but exceeds the 10,000,000 upper bound — DoS guard (§6 step 5)', + trap: 'iter-over-max', + }, + + // §6 step 6 — decode, re-encode, compare. Buffer.from is lenient: + // 'AA@@AA' and 'A A A A' decode to the same bytes as 'AAAA'. A hash whose + // salt/key round-trips to a different string is malformed. + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000$AA@@${'A'.repeat(39)}$${B64_KEY_64}`, + note: "Buffer.from('AA@@AA','base64') drops '@@' — salt does not re-encode to itself", + trap: 'lenient-base64-at', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000$A A ${'A'.repeat(39)}$${B64_KEY_64}`, + note: 'Buffer.from ignores spaces — salt does not re-encode to itself', + trap: 'lenient-base64-space', + }, + + // §6 step 7 — key length must equal digest width; salt ≥16 bytes, BOTH + // checked BEFORE pbkdf2 is called (keylen=0 throws an untyped error). + { + expected: 'reject', + // Canonical 32-byte key ('A'.repeat(43)) under a sha512 (64-byte) header: + // passes item 6's re-encode check, so item 7's length assert is what must + // catch it. A non-canonical key here would be masked by item 6. + hash: `$pbkdf2-sha512$i=600000$${B64_SALT_32}$${'A'.repeat(43)}`, + note: 'sha512 hash carrying a canonical 32-byte key — item 7 must reject; would silently verify a downgraded artifact otherwise', + trap: 'key-length-mismatch', + }, + { + expected: 'reject', + hash: `$pbkdf2-sha512$i=600000$${'A'.repeat(20)}$${B64_KEY_64}`, + note: '15-byte salt (< 16 minimum) — reject before pbkdf2', + trap: 'salt-too-short', + }, + + // §3 dispatch fixtures — well-formed bcrypt hashes. NOT malformed rejects: + // verifyPassword dispatches (bcrypt path when FIPS off, refuse when FIPS on). + { + expected: 'bcrypt', + hash: '$2a$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + note: '$2a$ prefix — legacy bcrypt; §3 dispatches, does not reject', + trap: 'bcrypt-2a', + }, + { + expected: 'bcrypt', + hash: '$2b$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + note: '$2b$ prefix — current bcryptjs output; §3 dispatches', + trap: 'bcrypt-2b', + }, + { + expected: 'bcrypt', + hash: '$2y$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + note: '$2y$ prefix — crypt(3) variant; §3 dispatches', + trap: 'bcrypt-2y', + }, + + // §3 cutover sentinel — an unusable value hashPassword cannot produce. + // verifyPassword rejects it on the unknown-format path with no new branch. + { + expected: 'sentinel', + hash: 'INVALIDATED-BY-FIPS-CUTOVER-DO-NOT-USE', + note: 'cutover-invalidation sentinel (§3) — unknown format, rejected without a dedicated branch', + trap: 'sentinel', + }, +] as const; diff --git a/libs/password-hash-vectors/tsconfig.json b/libs/password-hash-vectors/tsconfig.json new file mode 100644 index 0000000000..18d5a2f6fb --- /dev/null +++ b/libs/password-hash-vectors/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + + "compilerOptions": { + "outDir": "lib" + }, + + "include": ["**/*.ts"] +} diff --git a/libs/password-hash-vectors/vitest.config.ts b/libs/password-hash-vectors/vitest.config.ts new file mode 100644 index 0000000000..50eb6b6ba6 --- /dev/null +++ b/libs/password-hash-vectors/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ test: { include: ['src/**/*.spec.ts'] } }); From 3dd3db92cedd2088ba90a8dadd851d115c928db5 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 13:12:00 -0400 Subject: [PATCH 029/197] feat: add known-good vectors + FORMAT_VERSION to @heimdall/password-hash-vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the cross-repo hash contract (ADR-006 §14, §2). heimdall2 owns the format; heimdall-cli consumes these vectors and asserts FORMAT_VERSION equality at build time. - 6 known-good vectors covering sha256/sha384/sha512 each at 600k plus a non-default iteration count; passwords spanning ASCII, the 15-char minimum, the 128-char maximum, multi-byte UTF-8, and >72 chars. Every hash re-derives from its password via raw node:crypto pbkdf2Sync — the implementation-independent contract test, verified by independent review. - PHC form $pbkdf2-$i=$$, unpadded standard base64, matching the reference @phc/pbkdf2 / @phc/format. - FORMAT_VERSION integer stamp; README documents the bump rule. - BCRYPT_DEGRADATION_VECTOR: a PBKDF2 hash bcryptjs.compare rejects cleanly (false, no throw) — the §12(4) rolling-deploy assertion. - Deterministic generation: pure generate-vectors.ts module + write-vectors.ts writer (fixed salts, never crypto.randomBytes); the spec asserts buildVectors() reproduces the committed data byte-for-byte. - 16 tests, tsc clean, zero eslint-disable. bcryptjs + tsx added as devDeps. Base64 uses Buffer, not Uint8Array.fromBase64: that TC39 Stage-4 API is undefined at the Node runtime (verified) — the same reference libraries use Buffers. Authored by: Aaron Lippold --- libs/password-hash-vectors/README.md | 59 ++++- libs/password-hash-vectors/package.json | 3 + .../scripts/generate-vectors.ts | 205 ++++++++++++++++++ .../scripts/write-vectors.ts | 15 ++ .../src/format-version.ts | 16 ++ libs/password-hash-vectors/src/index.ts | 10 + .../password-hash-vectors/src/vectors.spec.ts | 198 +++++++++++++++++ libs/password-hash-vectors/src/vectors.ts | 78 +++++++ yarn.lock | 11 +- 9 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 libs/password-hash-vectors/scripts/generate-vectors.ts create mode 100644 libs/password-hash-vectors/scripts/write-vectors.ts create mode 100644 libs/password-hash-vectors/src/format-version.ts create mode 100644 libs/password-hash-vectors/src/vectors.spec.ts create mode 100644 libs/password-hash-vectors/src/vectors.ts diff --git a/libs/password-hash-vectors/README.md b/libs/password-hash-vectors/README.md index bf2964d3da..2b45fbd811 100644 --- a/libs/password-hash-vectors/README.md +++ b/libs/password-hash-vectors/README.md @@ -9,11 +9,55 @@ that writes credentials the FIPS-gated server refuses (ADR-006 §14). ## Contents -| Export | Status | Purpose | -| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `MALFORMED_CORPUS` | **this card (e25.2)** | Every ADR-006 §6 input-validation trap and §3 dispatch case, as `{hash, expected, trap, note}` entries. Drives `verifyPassword`'s rejection tests (e25.7) and heimdall-cli's parity suite. | -| known-good vectors | follow-on (e25.4) | Reproducible password→hash pairs for all three digests. | -| `formatVersion` | follow-on (e25.4) | Integer stamp heimdall-cli asserts against at build time; bumped on any change to the PHC grammar, allowlist, or parameter bounds. | +| Export | Purpose | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MALFORMED_CORPUS` | Every ADR-006 §6 input-validation trap and §3 dispatch case, as `{hash, expected, trap, note}` entries. Drives `verifyPassword`'s rejection tests (e25.7) and heimdall-cli's parity suite. | +| `KNOWN_GOOD_VECTORS` | Reproducible `{password, hash, algorithm, iterations, label}` vectors covering all three digests. Each hash re-derives from its password via raw `node:crypto` `pbkdf2Sync` — implementation-independent ground truth both `hashPassword` and heimdall-cli test against. | +| `BCRYPT_DEGRADATION_VECTOR` | A PBKDF2 hash paired with `expectedBcryptCompare: false` — the §12(4) rolling-deploy assertion that a pre-upgrade pod's `bcryptjs.compare` rejects a PBKDF2 hash cleanly. Consumed by the write-gate card (e25.21). | +| `FORMAT_VERSION` | Integer stamp both implementations assert **equality** against at build time. | + +## FORMAT_VERSION — the build-time contract + +`FORMAT_VERSION` is a single integer (starts at `1`). heimdall2 and heimdall-cli +each assert equality against it at build time; a mismatch is a **build failure** +(ADR-006 §14) — what stops heimdall-cli from shipping a break-glass tool that +writes a hash format the FIPS-gated server refuses. + +**Bump it whenever any of these changes:** the PHC grammar +(`$pbkdf2-$i=$$`), the algorithm allowlist +(sha256 | sha384 | sha512), or the parameter bounds (iteration floor/ceiling, +salt/key widths). It is a plain stamp, not a semver — §14 specifies equality. + +## Regenerating the known-good vectors + +`KNOWN_GOOD_VECTORS` is generated, never hand-edited: + +```sh +yarn workspace @heimdall/password-hash-vectors gen:vectors +``` + +`scripts/generate-vectors.ts` is a pure module (safe to import); the script +above renders it to `src/vectors.ts`. + +The generator uses raw `node:crypto` (never the app's `hashPassword` — circular) +and **fixed salts** (never `crypto.randomBytes`), so regeneration is byte-for-byte +identical. The spec asserts `buildVectors()` equals the committed vectors, so an +un-regenerated seed change fails the build. + +### PHC encoding, per the [C2SP phc-strings spec](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) + +- **Standard base64**, not base64url (alphabet `+/`, not `-_`). +- **Padding stripped** from both salt and key (`=` removed). +- Salt is 32 bytes; key width equals the digest width (32 / 48 / 64 bytes for + sha256 / sha384 / sha512). + +This matches the reference [`@phc/pbkdf2`](https://github.com/simonepri/phc-pbkdf2) +/ [`@phc/format`](https://www.npmjs.com/package/@phc/format), which represent +salt and hash as Node `Buffer`s. Base64 uses `Buffer` (not the newer +`Uint8Array.fromBase64`/`toBase64`): that TC39 API is Stage 4 but **undefined at +our Node runtime** (verified), so `Buffer` is the only working encoder. ESLint's +`unicorn/prefer-uint8array-base64` flags this — a config-ahead-of-runtime false +preference; migrate when Node ships the API. ## The corpus is executable documentation @@ -31,8 +75,9 @@ don't reject), or `sentinel` (the §3 cutover-invalidation marker). ## Scope -Data and types only — no crypto, no parsing logic. Consumed in-repo and by -heimdall-cli via a pinned git ref; not published to npm. +Runtime exports are data and types. The vector generator uses raw `node:crypto`, +but that is a build-time script, not a runtime dependency. Consumed in-repo and +by heimdall-cli via a pinned git ref; not published to npm. ## Test diff --git a/libs/password-hash-vectors/package.json b/libs/password-hash-vectors/package.json index 427613d28e..fb56fe38dd 100644 --- a/libs/password-hash-vectors/package.json +++ b/libs/password-hash-vectors/package.json @@ -14,12 +14,15 @@ "node": ">=22.18.0" }, "scripts": { + "gen:vectors": "tsx scripts/write-vectors.ts", "lint": "eslint --fix", "lint:ci": "eslint --max-warnings 0", "test": "vitest run", "test:watch": "vitest" }, "devDependencies": { + "bcryptjs": "^3.0.0", + "tsx": "^4.20.0", "vitest": "^4.0.18" } } diff --git a/libs/password-hash-vectors/scripts/generate-vectors.ts b/libs/password-hash-vectors/scripts/generate-vectors.ts new file mode 100644 index 0000000000..74b31bf720 --- /dev/null +++ b/libs/password-hash-vectors/scripts/generate-vectors.ts @@ -0,0 +1,205 @@ +/** + * Deterministic generator for KNOWN_GOOD_VECTORS (ADR-006 §14, §2). + * + * A PURE module — no side effects, safe to import. It builds vectors from a + * fixed seed list using raw node:crypto (never the app's hashPassword, which + * must be tested AGAINST these vectors; never crypto.randomBytes, so salts are + * fixed and regeneration is byte-for-byte identical). Regenerate src/vectors.ts: + * + * yarn workspace @heimdall/password-hash-vectors gen:vectors + * + * The spec imports buildVectors() and asserts it equals the committed + * KNOWN_GOOD_VECTORS, so an edit to the seeds or the encoder that is not + * regenerated fails the build. renderVectorsModule() emits lint-clean source + * so regeneration is idempotent. + */ +import { pbkdf2Sync } from 'node:crypto'; + +export type Algorithm = 'sha256' | 'sha384' | 'sha512'; + +export type RenderedVector = { + readonly algorithm: Algorithm; + readonly hash: string; + readonly iterations: number; + readonly label: string; + readonly password: string; +}; + +type Seed = { + readonly algorithm: Algorithm; + readonly iterations: number; + readonly label: string; + readonly password: string; +}; + +// PBKDF2 derived-key width equals the digest width (§2). A switch (not a +// Record lookup) keeps it exhaustive over the union and free of dynamic +// object indexing. +export function digestWidth(algorithm: Algorithm): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new Error('unhandled algorithm'); + } + } +} + +// Coverage (asserted by vectors.spec.ts): every digest at 600,000 plus one +// non-default count each; passwords spanning ASCII, the 15-char minimum, the +// 128-char maximum, multi-byte UTF-8, and >72 chars (bcrypt-truncation-gone). +const SEEDS: readonly Seed[] = [ + { + algorithm: 'sha512', + iterations: 600_000, + label: 'sha512/600k/ascii-15char-min', + password: 'Aa1!Bb2@Cc3#Dd4', // exactly 15 chars + }, + { + algorithm: 'sha512', + iterations: 310_000, + label: 'sha512/310k/128char-max', + password: 'Aa1!'.repeat(32), // exactly 128 chars + }, + { + algorithm: 'sha256', + iterations: 600_000, + label: 'sha256/600k/multibyte-utf8', + password: 'Pä1!sswörd-日本語-🔒-Secure9#', + }, + { + algorithm: 'sha256', + iterations: 220_000, + label: 'sha256/220k/over-72-chars', + password: 'Zz9@Xx8#'.repeat(10), // 80 chars, > bcrypt's 72-byte cap + }, + { + algorithm: 'sha384', + iterations: 600_000, + label: 'sha384/600k/ascii', + password: 'StrongPass1!Word', + }, + { + algorithm: 'sha384', + iterations: 310_000, + label: 'sha384/310k/ascii', + password: 'AnotherPass2@Word', + }, +]; + +/** The vectors as data — same seeds + fixed salts → same output, always. */ +export function buildVectors(): RenderedVector[] { + return SEEDS.map((seed, index) => ({ + algorithm: seed.algorithm, + hash: phcHash(seed, fixedSalt(index)), + iterations: seed.iterations, + label: seed.label, + password: seed.password, + })); +} + +// Deterministic 32-byte salt for seed index i: byte j = (i * 31 + j) mod 256. +// Fixed and reproducible — NOT crypto.randomBytes (§14 determinism rule). +export function fixedSalt(index: number): Buffer { + const salt = Buffer.alloc(32); + for (let index_ = 0; index_ < salt.length; index_++) { + salt.writeUInt8((index * 31 + index_) % 256, index_); + } + return salt; +} + +/** The full text of src/vectors.ts. Pure and lint-clean (idempotent). */ +export function renderVectorsModule(): string { + const vectors = buildVectors(); + const entries = vectors + .map( + v => + ' {\n' + + ` algorithm: ${sq(v.algorithm)},\n` + + ` hash: ${sq(v.hash)},\n` + + ` iterations: ${v.iterations},\n` + + ` label: ${sq(v.label)},\n` + + ` password: ${sq(v.password)},\n` + + ' },', + ) + .join('\n'); + + // The bcrypt graceful-degradation vector (§12(4)): a PBKDF2 hash that the + // OLD verify path (bcryptjs.compare) must reject cleanly. Reuse the first + // known-good hash so it is a real, re-derivable PBKDF2 string. + const degradationHash = vectors[0].hash; + + return `/** + * GENERATED by scripts/generate-vectors.ts — DO NOT EDIT BY HAND. + * Regenerate: npx tsx libs/password-hash-vectors/scripts/generate-vectors.ts + * + * Known-good password→hash vectors (ADR-006 §14, §2). Every entry's hash + * re-derives from its password via raw node:crypto pbkdf2Sync with the + * parameters encoded in the hash — the implementation-independent contract + * that heimdall2's hashPassword and heimdall-cli both test against. + */ +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export type KnownGoodVector = { + readonly algorithm: PasswordHashAlgorithm; + readonly hash: string; + readonly iterations: number; + readonly label: string; + readonly password: string; +}; + +export const KNOWN_GOOD_VECTORS: readonly KnownGoodVector[] = [ +${entries} +]; + +export type BcryptDegradationVector = { + readonly expectedBcryptCompare: false; + readonly hash: string; +}; + +/** + * §12(4) rolling-deploy assertion: a pre-upgrade pod running bcryptjs.compare + * against this PBKDF2 hash must return false without throwing. Consumed by the + * write-gate card (e25.21). + */ +export const BCRYPT_DEGRADATION_VECTOR: BcryptDegradationVector = { + expectedBcryptCompare: false, + hash: ${sq(degradationHash)}, +}; +`; +} + +// Standard base64 (not base64url), padding stripped — §2. The stored Buffer +// method is the base64 encoder available at the Node runtime (the linter's +// preferred Uint8Array#toBase64 is undefined here — see README). +function b64(buffer: Buffer): string { + let out = buffer.toString('base64'); + while (out.endsWith('=')) { + out = out.slice(0, -1); + } + return out; +} + +function phcHash(seed: Seed, salt: Buffer): string { + const key = pbkdf2Sync( + seed.password, + salt, + seed.iterations, + digestWidth(seed.algorithm), + seed.algorithm, + ); + return `$pbkdf2-${seed.algorithm}$i=${seed.iterations}$${b64(salt)}$${b64(key)}`; +} + +// A single-quoted TS string literal, escaping backslash and single quote so +// any future seed string stays valid. Multi-byte UTF-8 passes through as-is. +function sq(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", String.raw`\'`)}'`; +} diff --git a/libs/password-hash-vectors/scripts/write-vectors.ts b/libs/password-hash-vectors/scripts/write-vectors.ts new file mode 100644 index 0000000000..00c3b837f4 --- /dev/null +++ b/libs/password-hash-vectors/scripts/write-vectors.ts @@ -0,0 +1,15 @@ +/** + * Writes src/vectors.ts from the pure generator. Run: + * + * yarn workspace @heimdall/password-hash-vectors gen:vectors + * + * This file is only ever executed, never imported, so it needs no main-guard. + * The output path is a literal relative to the package root (yarn runs the + * script with cwd = the package dir), which keeps it lint-clean and avoids + * __dirname (the package is CJS but the lint config assumes ESM). + */ +import { writeFileSync } from 'node:fs'; +import { renderVectorsModule } from './generate-vectors'; + +writeFileSync('src/vectors.ts', renderVectorsModule(), 'utf8'); +process.stdout.write('wrote src/vectors.ts\n'); diff --git a/libs/password-hash-vectors/src/format-version.ts b/libs/password-hash-vectors/src/format-version.ts new file mode 100644 index 0000000000..217510e092 --- /dev/null +++ b/libs/password-hash-vectors/src/format-version.ts @@ -0,0 +1,16 @@ +/** + * The password-hash format version — a single integer stamp that both + * heimdall2 and heimdall-cli assert equality against at build time (ADR-006 + * §14). A mismatch is a build failure, which is what prevents heimdall-cli from + * shipping a break-glass tool that writes a hash format the FIPS-gated server + * refuses. + * + * BUMP THIS whenever ANY of the following changes: + * - the PHC grammar (§2: `$pbkdf2-$i=$$`) + * - the algorithm allowlist (§6: sha256 | sha384 | sha512) + * - the parameter bounds (iteration floor/ceiling, salt/key widths) + * + * It is a plain stamp, not a semantic version — §14 specifies equality, not a + * negotiation protocol. Starts at 1. + */ +export const FORMAT_VERSION = 1; diff --git a/libs/password-hash-vectors/src/index.ts b/libs/password-hash-vectors/src/index.ts index c0e1171423..a74647c860 100644 --- a/libs/password-hash-vectors/src/index.ts +++ b/libs/password-hash-vectors/src/index.ts @@ -1,3 +1,5 @@ +export { FORMAT_VERSION } from './format-version'; + /** * @heimdall/password-hash-vectors * @@ -13,3 +15,11 @@ export { type MalformedExpected, type MalformedVector, } from './malformed-corpus'; + +export { + BCRYPT_DEGRADATION_VECTOR, + type BcryptDegradationVector, + KNOWN_GOOD_VECTORS, + type KnownGoodVector, + type PasswordHashAlgorithm, +} from './vectors'; diff --git a/libs/password-hash-vectors/src/vectors.spec.ts b/libs/password-hash-vectors/src/vectors.spec.ts new file mode 100644 index 0000000000..cba4b94441 --- /dev/null +++ b/libs/password-hash-vectors/src/vectors.spec.ts @@ -0,0 +1,198 @@ +import { pbkdf2Sync } from 'node:crypto'; +import * as bcrypt from 'bcryptjs'; +import { describe, expect, it } from 'vitest'; +import { buildVectors } from '../scripts/generate-vectors'; +import { FORMAT_VERSION } from './format-version'; +import { + BCRYPT_DEGRADATION_VECTOR, + KNOWN_GOOD_VECTORS, + type KnownGoodVector, +} from './vectors'; + +// Standard base64 alphabet (RFC 4648 §4, i.e. +/, not base64url's -_). +const STANDARD_B64_ALPHABET + = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +// Decoded byte width of a no-padding base64 string: floor(len * 6 / 8). +// Arithmetic so the field-width checks need no Buffer decode. +function b64ByteWidth(field: string): number { + return Math.floor((field.length * 3) / 4); +} + +// PBKDF2 derived-key width equals the digest width (§2), exhaustive over the +// union — no dynamic object indexing. +function digestWidth(algorithm: KnownGoodVector['algorithm']): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new Error('unhandled algorithm'); + } + } +} + +// Every code point in the printable ASCII range space..'~'. +function isPrintableAscii(s: string): boolean { + return [...s].every((c) => { + const code = c.codePointAt(0) ?? 0; + return code >= 0x20 && code <= 0x7E; + }); +} + +// True iff s is non-empty and every char is in the standard base64 alphabet. +// A char-set check rather than a regex, sidestepping the /u-vs-/v flag churn. +function isStandardB64(s: string): boolean { + return s.length > 0 && [...s].every(c => STANDARD_B64_ALPHABET.includes(c)); +} + +// Parse a PHC pbkdf2 hash WITHOUT the app's parser — raw string ops, so the +// re-derivation below is implementation-independent ground truth (§14). +function parsePhc(hash: string): { + algorithm: KnownGoodVector['algorithm']; + iterations: number; + key: Buffer; + salt: Buffer; +} { + const parts = hash.split('$'); + // ['', 'pbkdf2-sha512', 'i=600000', '', ''] + const algorithm = parts[1].replace('pbkdf2-', '') as KnownGoodVector['algorithm']; + const iterations = Number(parts[2].slice(2)); + return { + algorithm, + iterations, + key: Buffer.from(parts[4], 'base64'), + salt: Buffer.from(parts[3], 'base64'), + }; +} + +describe('KNOWN_GOOD_VECTORS', () => { + it('re-derives every vector from its password via raw node:crypto pbkdf2Sync', () => { + // THE CONTRACT: each vector's stored key must equal what pbkdf2Sync + // produces from the vector's password and the parameters encoded in the + // hash. Any implementation (app or CLI) that produces a different key for + // the same inputs is non-conformant. + for (const v of KNOWN_GOOD_VECTORS) { + const { algorithm, iterations, key, salt } = parsePhc(v.hash); + // The hash's encoded params must match the vector's declared params. + expect(algorithm, `${v.label}: algorithm mismatch`).toBe(v.algorithm); + expect(iterations, `${v.label}: iterations mismatch`).toBe(v.iterations); + const rederived = pbkdf2Sync( + v.password, + salt, + iterations, + digestWidth(algorithm), + algorithm, + ); + expect( + rederived.equals(key), + `${v.label}: pbkdf2Sync output != stored key`, + ).toBe(true); + } + }); + + it('encodes every hash in exact §2 PHC form with padding-stripped standard base64', () => { + for (const v of KNOWN_GOOD_VECTORS) { + // $pbkdf2-$i=$$ — 5 fields, empty first. + const parts = v.hash.split('$'); + expect(parts, `${v.label}: not 5 fields`).toHaveLength(5); + expect(parts[0]).toBe(''); + expect(parts[1]).toBe(`pbkdf2-${v.algorithm}`); + expect(parts[2]).toBe(`i=${v.iterations}`); + // Padding stripped: no '=' in salt or key. + expect(parts[3]).not.toContain('='); + expect(parts[4]).not.toContain('='); + // Standard base64 alphabet (not base64url): allow +/ , reject -_ . + expect(isStandardB64(parts[3]), `${v.label}: salt not standard b64`).toBe( + true, + ); + expect(isStandardB64(parts[4]), `${v.label}: key not standard b64`).toBe( + true, + ); + // Salt is 32 bytes; key is the digest width (arithmetic, no decode). + expect(b64ByteWidth(parts[3])).toBe(32); + expect(b64ByteWidth(parts[4])).toBe(digestWidth(v.algorithm)); + } + }); + + it('covers all three digests at 600,000 plus a non-default iteration count each', () => { + for (const algorithm of ['sha256', 'sha384', 'sha512'] as const) { + const forAlg = KNOWN_GOOD_VECTORS.filter(v => v.algorithm === algorithm); + const iterCounts = new Set(forAlg.map(v => v.iterations)); + expect(iterCounts, `${algorithm}: missing default 600000`).toContain( + 600_000, + ); + // At least one non-default iteration count for this digest. + expect( + [...iterCounts].some(n => n !== 600_000), + `${algorithm}: no non-default iteration vector`, + ).toBe(true); + } + }); + + it('covers every required password class', () => { + const passwords = KNOWN_GOOD_VECTORS.map(v => v.password); + // 15-char minimum boundary (STIG default min length). + expect(passwords.some(p => p.length === 15)).toBe(true); + // 128-char maximum boundary (§6 cap). + expect(passwords.some(p => p.length === 128)).toBe(true); + // Multi-byte UTF-8 (a char whose byte length exceeds its code-unit length). + expect( + passwords.some(p => Buffer.byteLength(p, 'utf8') > p.length), + ).toBe(true); + // >72 characters — documents that bcrypt's 72-byte truncation is gone (§6). + expect(passwords.some(p => p.length > 72)).toBe(true); + // Plain ASCII — uses the module-scoped isPrintableAscii helper. + expect(passwords.some(p => isPrintableAscii(p))).toBe(true); + }); + + it('keeps every hash within the VARCHAR(255) storage limit', () => { + for (const v of KNOWN_GOOD_VECTORS) { + expect(v.hash.length, `${v.label}: hash exceeds 255 chars`).toBeLessThanOrEqual( + 255, + ); + } + }); + + it('exports a positive integer formatVersion', () => { + expect(Number.isSafeInteger(FORMAT_VERSION)).toBe(true); + expect(FORMAT_VERSION).toBeGreaterThanOrEqual(1); + }); + + it('is byte-for-byte reproducible from the deterministic generator', () => { + // The committed vectors MUST equal what buildVectors() recomputes from the + // fixed seeds + fixed salts. If a seed or the encoder changed without + // regenerating (or a random salt slipped in), this fails. + const regenerated = buildVectors(); + expect(regenerated).toHaveLength(KNOWN_GOOD_VECTORS.length); + expect(regenerated).toStrictEqual( + KNOWN_GOOD_VECTORS.map(v => ({ + algorithm: v.algorithm, + hash: v.hash, + iterations: v.iterations, + label: v.label, + password: v.password, + })), + ); + }); +}); + +describe('BCRYPT_DEGRADATION_VECTOR', () => { + it('is a PBKDF2 hash that bcryptjs.compare rejects cleanly (false, no throw)', () => { + // §12(4): a pre-upgrade pod running the OLD verify path (bcryptjs.compare) + // against a NEW PBKDF2 hash must get a clean false — never a throw or 500. + expect(BCRYPT_DEGRADATION_VECTOR.hash.startsWith('$pbkdf2-')).toBe(true); + let result: boolean | undefined; + expect(() => { + result = bcrypt.compareSync('any-password', BCRYPT_DEGRADATION_VECTOR.hash); + }).not.toThrow(); + expect(result).toBe(BCRYPT_DEGRADATION_VECTOR.expectedBcryptCompare); + expect(BCRYPT_DEGRADATION_VECTOR.expectedBcryptCompare).toBe(false); + }); +}); diff --git a/libs/password-hash-vectors/src/vectors.ts b/libs/password-hash-vectors/src/vectors.ts new file mode 100644 index 0000000000..2b945c9c81 --- /dev/null +++ b/libs/password-hash-vectors/src/vectors.ts @@ -0,0 +1,78 @@ +export type KnownGoodVector = { + readonly algorithm: PasswordHashAlgorithm; + readonly hash: string; + readonly iterations: number; + readonly label: string; + readonly password: string; +}; + +/** + * GENERATED by scripts/generate-vectors.ts — DO NOT EDIT BY HAND. + * Regenerate: npx tsx libs/password-hash-vectors/scripts/generate-vectors.ts + * + * Known-good password→hash vectors (ADR-006 §14, §2). Every entry's hash + * re-derives from its password via raw node:crypto pbkdf2Sync with the + * parameters encoded in the hash — the implementation-independent contract + * that heimdall2's hashPassword and heimdall-cli both test against. + */ +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export const KNOWN_GOOD_VECTORS: readonly KnownGoodVector[] = [ + { + algorithm: 'sha512', + hash: '$pbkdf2-sha512$i=600000$AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8$Jak+mPMZR0uTl9aahxqJj7bizgRxx+uPvohGrHh9NiKgRhyFGoQHvdz4l62bwdOIkDzG7wmKqe2iY8oPvyd0MQ', + iterations: 600_000, + label: 'sha512/600k/ascii-15char-min', + password: 'Aa1!Bb2@Cc3#Dd4', + }, + { + algorithm: 'sha512', + hash: '$pbkdf2-sha512$i=310000$HyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4$9rcsQqmO94zDag3Yp2n011B8MfYzOgN6vngfy/asRDhbXD54vLXjxytomSOd3L8JClKHHlpGGpOWHUJ2s0EEwg', + iterations: 310_000, + label: 'sha512/310k/128char-max', + password: 'Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!', + }, + { + algorithm: 'sha256', + hash: '$pbkdf2-sha256$i=600000$Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF0$t6eIoLnlVZnKgtZ0Mhszm2vT9EvQ9bG1TVwqA0Nlfo4', + iterations: 600_000, + label: 'sha256/600k/multibyte-utf8', + password: 'Pä1!sswörd-日本語-🔒-Secure9#', + }, + { + algorithm: 'sha256', + hash: '$pbkdf2-sha256$i=220000$XV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3w$GIX4ljrgztG9iRBf7Y6HFOCIT5LfW2DsLmL+cMCGLNc', + iterations: 220_000, + label: 'sha256/220k/over-72-chars', + password: 'Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#Zz9@Xx8#', + }, + { + algorithm: 'sha384', + hash: '$pbkdf2-sha384$i=600000$fH1+f4CBgoOEhYaHiImKi4yNjo+QkZKTlJWWl5iZmps$qIeGzbMys6rj5rbSWOu9yYNVj7T6BGy3g0IemZ20y77EJj0M14GEzgMBrz8WxBK1', + iterations: 600_000, + label: 'sha384/600k/ascii', + password: 'StrongPass1!Word', + }, + { + algorithm: 'sha384', + hash: '$pbkdf2-sha384$i=310000$m5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubo$Im7eDvMpXAo+IxxHn3eHmMCtUO+FRLUGtLRToMVvxB0gR0snAY0QuSXd8rxqsh31', + iterations: 310_000, + label: 'sha384/310k/ascii', + password: 'AnotherPass2@Word', + }, +]; + +export type BcryptDegradationVector = { + readonly expectedBcryptCompare: false; + readonly hash: string; +}; + +/** + * §12(4) rolling-deploy assertion: a pre-upgrade pod running bcryptjs.compare + * against this PBKDF2 hash must return false without throwing. Consumed by the + * write-gate card (e25.21). + */ +export const BCRYPT_DEGRADATION_VECTOR: BcryptDegradationVector = { + expectedBcryptCompare: false, + hash: '$pbkdf2-sha512$i=600000$AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8$Jak+mPMZR0uTl9aahxqJj7bizgRxx+uPvohGrHh9NiKgRhyFGoQHvdz4l62bwdOIkDzG7wmKqe2iY8oPvyd0MQ', +}; diff --git a/yarn.lock b/yarn.lock index a53b9ac5ab..ea5f4537f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8642,7 +8642,7 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild@^0.28.0: +esbuild@^0.28.0, esbuild@~0.28.0: version "0.28.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== @@ -18324,6 +18324,15 @@ tslib@2.8.1, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4. resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsx@^4.20.0: + version "4.23.11" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.23.11.tgz#82a7cbcce167b575060db03ef789a84c687c7f97" + integrity sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw== + dependencies: + esbuild "~0.28.0" + optionalDependencies: + fsevents "~2.3.3" + tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" From df311255c2dfc4ac9a398fd1afc39386313648b6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 13:48:57 -0400 Subject: [PATCH 030/197] feat: add pure hashPassword with PHC encoding (apps/backend/src/crypto/password.ts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency-free core primitive for FIPS-validated password hashing (ADR-006 §1, §2, §5, §6). Only import is node:crypto. - hashPassword(password, options?) → PHC $pbkdf2-$i=$$, standard base64 padding-stripped, defaults sha512 / 600000 / 32-byte crypto.randomBytes salt, derived key = digest width. - hashPasswordWithSalt(password, salt, options?): the deterministic variant for tests and vector tooling; hashPassword delegates with a random salt so its signature stays §5-exact and production never controls the salt. - Typed PasswordHashError, never a silent clamp: rejects non-string, empty, >128 chars, algorithm outside sha256|sha384|sha512, and iterations outside the inclusive [100000, 10000000] range (§6, §9). - node:crypto is a NAMESPACE import, never destructured — swc compiles a destructured binding non-writable, which would block the getFips seam verifyPassword (e25.7) needs. - Verified against the @heimdall/password-hash-vectors known-good vectors (test-only dep): all 6 reproduced byte-for-byte via salt injection. §4's exact-require-path holds — nest build emits dist/src/crypto/password.js and a bare require() of the compiled output works (the seeder's path). - 12 tests, nest build clean, zero eslint-disable. Authored by: Aaron Lippold --- apps/backend/package.json | 1 + apps/backend/src/crypto/password.spec.ts | 149 +++++++++++++++++++++ apps/backend/src/crypto/password.ts | 163 +++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 apps/backend/src/crypto/password.spec.ts create mode 100644 apps/backend/src/crypto/password.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 54af24de0a..cb4779f38f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -84,6 +84,7 @@ "winston": "^3.3.3" }, "devDependencies": { + "@heimdall/password-hash-vectors": "^2.13.0", "@nestjs/testing": "^11.0.1", "@swc/core": "^1.13.0", "@types/mock-fs": "^4.10.0", diff --git a/apps/backend/src/crypto/password.spec.ts b/apps/backend/src/crypto/password.spec.ts new file mode 100644 index 0000000000..3e5a1314c8 --- /dev/null +++ b/apps/backend/src/crypto/password.spec.ts @@ -0,0 +1,149 @@ +import { KNOWN_GOOD_VECTORS } from '@heimdall/password-hash-vectors'; +import { describe, expect, it } from 'vitest'; +import { + hashPassword, + hashPasswordWithSalt, + PasswordHashError, +} from './password'; + +type Algorithm = 'sha256' | 'sha384' | 'sha512'; + +// Decoded byte width of a no-padding base64 string: floor(len * 6 / 8). +// Arithmetic so the width checks need no Buffer decode. +function b64ByteWidth(field: string): number { + return Math.floor((field.length * 3) / 4); +} + +// PBKDF2 derived-key width = digest width (§2), exhaustive, no dynamic index. +function digestWidth(algorithm: Algorithm): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new Error('unhandled algorithm'); + } + } +} + +// Decode a vector's salt straight out of its PHC hash so we can inject it — +// the vectors are the implementation-independent ground truth (ADR §14). +function saltOf(hash: string): Buffer { + return Buffer.from(hash.split('$', 4)[3], 'base64'); +} + +describe('hashPasswordWithSalt', () => { + it('reproduces every known-good vector when given the vector salt', async () => { + // THE CONTRACT: hashing a vector's password with its salt/algorithm/ + // iterations must reproduce the vector's hash byte-for-byte. This proves + // the encoder matches the ground truth, not itself. + for (const v of KNOWN_GOOD_VECTORS) { + const produced = await hashPasswordWithSalt(v.password, saltOf(v.hash), { + algorithm: v.algorithm, + iterations: v.iterations, + }); + expect(produced).toBe(v.hash); + } + }); + + it('emits standard base64 with padding stripped in both salt and key', async () => { + const hash = await hashPasswordWithSalt('CorrectHorse15!x', Buffer.alloc(32, 7)); + const parts = hash.split('$'); + expect(parts).toHaveLength(5); + expect(parts[0]).toBe(''); + expect(parts[3]).not.toContain('='); + expect(parts[4]).not.toContain('='); + // Standard alphabet (+/), never base64url (-_). + expect(parts[3].includes('-') || parts[3].includes('_')).toBe(false); + expect(parts[4].includes('-') || parts[4].includes('_')).toBe(false); + }); + + it('keeps every digest output within VARCHAR(255)', async () => { + for (const algorithm of ['sha256', 'sha384', 'sha512'] as const) { + const hash = await hashPasswordWithSalt('CorrectHorse15!x', Buffer.alloc(32, 1), { algorithm }); + expect(hash.length).toBeLessThanOrEqual(255); + // Key width (arithmetic, no decode) must equal the digest width. + expect(b64ByteWidth(hash.split('$', 5)[4])).toBe(digestWidth(algorithm)); + } + }); +}); + +describe('hashPassword (production path)', () => { + it('defaults to sha512 / 600000 / a 32-byte random salt', async () => { + const hash = await hashPassword('CorrectHorse15!x'); + const parts = hash.split('$'); + expect(parts[1]).toBe('pbkdf2-sha512'); + expect(parts[2]).toBe('i=600000'); + expect(b64ByteWidth(parts[3])).toBe(32); + expect(b64ByteWidth(parts[4])).toBe(64); + }); + + it('produces a different salt (and hash) on each call', async () => { + const a = await hashPassword('CorrectHorse15!x'); + const b = await hashPassword('CorrectHorse15!x'); + expect(a).not.toBe(b); + }); + + it('honours algorithm and iteration options', async () => { + const hash = await hashPassword('CorrectHorse15!x', { + algorithm: 'sha256', + iterations: 200_000, + }); + expect(hash.startsWith('$pbkdf2-sha256$i=200000$')).toBe(true); + }); +}); + +describe('validation (§6/§9) — throws typed errors, never clamps', () => { + it('rejects a non-string password', async () => { + // Runtime guard: the seeder is CommonJS JS, so a non-string can arrive. + await expect( + hashPassword(42 as unknown as string), + ).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects an empty password', async () => { + await expect(hashPassword('')).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects a password longer than 128 characters', async () => { + await expect( + hashPassword('a'.repeat(129)), + ).rejects.toBeInstanceOf(PasswordHashError); + // 128 exactly is allowed. + await expect(hashPassword('a'.repeat(128))).resolves.toContain('$pbkdf2-'); + }); + + it('rejects an algorithm outside the strict allowlist', async () => { + await expect( + hashPassword('CorrectHorse15!x', { algorithm: 'md5' as unknown as 'sha512' }), + ).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects iterations below 100000 and above 10000000', async () => { + await expect( + hashPassword('CorrectHorse15!x', { iterations: 99_999 }), + ).rejects.toBeInstanceOf(PasswordHashError); + await expect( + hashPassword('CorrectHorse15!x', { iterations: 10_000_001 }), + ).rejects.toBeInstanceOf(PasswordHashError); + // The boundaries themselves are allowed. + await expect( + hashPassword('CorrectHorse15!x', { iterations: 100_000 }), + ).resolves.toContain('$pbkdf2-'); + await expect( + hashPassword('CorrectHorse15!x', { iterations: 10_000_000 }), + ).resolves.toContain('$pbkdf2-'); + }, 60_000); + + it('rejects a non-integer iteration count', async () => { + await expect( + hashPassword('CorrectHorse15!x', { iterations: 600_000.5 }), + ).rejects.toBeInstanceOf(PasswordHashError); + }); +}); diff --git a/apps/backend/src/crypto/password.ts b/apps/backend/src/crypto/password.ts new file mode 100644 index 0000000000..bb1607e1bf --- /dev/null +++ b/apps/backend/src/crypto/password.ts @@ -0,0 +1,163 @@ +/** + * FIPS-validated password hashing — the pure primitive (ADR-006 §1, §2, §5, §6). + * + * DEPENDENCY-FREE by hard constraint: the only import is `node:crypto`. The + * admin bootstrap seeder (ADR §4 site 8) requires this module via a bare + * `require()` of the COMPILED output, outside Nest DI and the ConfigService — + * so it must pull in nothing else, and the inferred build layout must not + * shift. Do NOT add imports of Nest, config, a logger, or the vectors package + * (the vectors are a TEST-only dependency, imported by the spec, never here). + * + * `node:crypto` is a NAMESPACE import, never destructured: swc compiles a + * destructured `import {getFips}` to a non-writable binding, which blocks the + * injectable `getFips` seam that verifyPassword (e25.7) needs. + * + * Public API is `hashPassword(password, options?)` — it always generates a + * fresh 32-byte random salt. `hashPasswordWithSalt` is the deterministic + * variant used by tests and vector tooling to reproduce known-good vectors; + * production code must never pass a caller-controlled salt. + */ +import * as crypto from 'node:crypto'; + +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export type PasswordHashOptions = { + /** HMAC digest. Default 'sha512'. */ + algorithm?: PasswordHashAlgorithm; + /** PBKDF2 iterations. Default 600000. Must be within [100000, 10000000]. */ + iterations?: number; +}; + +/** Thrown for any invalid input — never a silent clamp (§6, §9). */ +export class PasswordHashError extends Error { + constructor(message: string) { + super(message); + this.name = 'PasswordHashError'; + } +} + +const DEFAULT_ALGORITHM: PasswordHashAlgorithm = 'sha512'; +const DEFAULT_ITERATIONS = 600_000; +const SALT_BYTES = 32; +const MAX_PASSWORD_LENGTH = 128; // §6 DoS cap + approved 8–128 range +const MIN_ITERATIONS = 100_000; // §9 hash-path floor (NOT enforced on verify) +const MAX_ITERATIONS = 10_000_000; // §6 DoS ceiling +const ALLOWED_ALGORITHMS = new Set([ + 'sha256', + 'sha384', + 'sha512', +]); + +/** + * Hash a password with a fresh 32-byte random salt, producing a PHC string + * `$pbkdf2-$i=$$` (§1, §2). Defaults: sha512, + * 600000 iterations. Throws {@link PasswordHashError} on invalid input. + */ +export function hashPassword( + password: string, + options?: PasswordHashOptions, +): Promise { + return hashPasswordWithSalt(password, crypto.randomBytes(SALT_BYTES), options); +} + +/** + * Deterministic hash — the caller supplies the salt. Tests and vector tooling + * only; production code uses {@link hashPassword}. Reusing a salt across + * passwords in production would be a critical weakness. + */ +export async function hashPasswordWithSalt( + password: string, + salt: Buffer, + options?: PasswordHashOptions, +): Promise { + const algorithm = options?.algorithm ?? DEFAULT_ALGORITHM; + const iterations = options?.iterations ?? DEFAULT_ITERATIONS; + validate(password, algorithm, iterations); + const key = await pbkdf2( + password, + salt, + iterations, + digestWidth(algorithm), + algorithm, + ); + return `$pbkdf2-${algorithm}$i=${iterations}$${toB64(salt)}$${toB64(key)}`; +} + +/** PBKDF2 derived-key width = digest width (§2). Exhaustive over the union. */ +function digestWidth(algorithm: PasswordHashAlgorithm): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new PasswordHashError('unhandled algorithm'); + } + } +} + +function pbkdf2( + password: string, + salt: Buffer, + iterations: number, + keylen: number, + digest: PasswordHashAlgorithm, +): Promise { + return new Promise((resolve, reject) => { + crypto.pbkdf2(password, salt, iterations, keylen, digest, (error, key) => { + if (error) { + reject(error); + return; + } + resolve(key); + }); + }); +} + +/** + * Standard base64 (not base64url), padding stripped — §2. Uses Buffer, not + * Uint8Array#toBase64: that TC39 API is undefined at the Node runtime + * (verified), so eslint's prefer-uint8array-base64 is a config-ahead-of-runtime + * false preference here — migrate when Node ships the API. + */ +function toB64(buffer: Buffer): string { + let out = buffer.toString('base64'); + while (out.endsWith('=')) { + out = out.slice(0, -1); + } + return out; +} + +function validate( + password: unknown, + algorithm: PasswordHashAlgorithm, + iterations: number, +): asserts password is string { + if (typeof password !== 'string') { + throw new PasswordHashError('password must be a string'); + } + if (password.length === 0) { + throw new PasswordHashError('password must not be empty'); + } + if (password.length > MAX_PASSWORD_LENGTH) { + throw new PasswordHashError( + `password must be at most ${MAX_PASSWORD_LENGTH} characters`, + ); + } + if (!ALLOWED_ALGORITHMS.has(algorithm)) { + throw new PasswordHashError(`unsupported algorithm: ${algorithm}`); + } + if (!Number.isSafeInteger(iterations)) { + throw new PasswordHashError('iterations must be an integer'); + } + if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS) { + throw new PasswordHashError( + `iterations must be within [${MIN_ITERATIONS}, ${MAX_ITERATIONS}]`, + ); + } +} From 8e41ad2e2ecc1e9d6135e6ca97ad3a18c425ea62 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:44:07 -0400 Subject: [PATCH 031/197] fix: complete the Multer mock file in the evaluations controller spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The partial `Express.Multer.File` literals were a TS2740 under `tsc --noEmit`. The transpile-only test runner never type checks, so the gap stayed invisible until the compiler gate was run — the specs passed the whole time. Replaces both literals with a `buildMockFile()` helper returning a complete file object. The controller still only reads `originalname` and `buffer`. Authored by: Aaron Lippold --- .../evaluations.controller.spec.ts | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/evaluations/evaluations.controller.spec.ts b/apps/backend/src/evaluations/evaluations.controller.spec.ts index 839ef9e29d..3245351b64 100644 --- a/apps/backend/src/evaluations/evaluations.controller.spec.ts +++ b/apps/backend/src/evaluations/evaluations.controller.spec.ts @@ -1,3 +1,4 @@ +import {Readable} from 'node:stream'; import {ForbiddenError} from '@casl/ability'; import {NotFoundException} from '@nestjs/common'; import {SequelizeModule} from '@nestjs/sequelize'; @@ -34,16 +35,26 @@ import {Evaluation} from './evaluation.model'; import {EvaluationsController} from './evaluations.controller'; import {EvaluationsService} from './evaluations.service'; -// This allows basic testing of the evaluations controller -// interface without having to construct a full File object -const mockFile: Express.Multer.File = { - originalname: 'abc.json', - buffer: Buffer.from('{}') -}; -const secondMockFile: Express.Multer.File = { - originalname: 'cda.json', - buffer: Buffer.from('{}') -}; +// A complete Multer File — the controller only reads originalname/buffer, +// but a partial literal is a TS2740 under tsc --noEmit (the transpile-only +// test runner never type checks, so the gap was invisible until then). +function buildMockFile(originalname: string): Express.Multer.File { + const buffer = Buffer.from('{}'); + return { + buffer, + destination: '', + encoding: '7bit', + fieldname: 'data', + filename: originalname, + mimetype: 'application/json', + originalname, + path: '', + size: buffer.length, + stream: Readable.from(buffer) + }; +} +const mockFile = buildMockFile('abc.json'); +const secondMockFile = buildMockFile('cda.json'); describe('EvaluationsController', () => { let evaluationsController: EvaluationsController; From b6d646594737860cb8f41554b8c0bb1d82eb6062 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:44:23 -0400 Subject: [PATCH 032/197] feat: add verifyPassword with the FIPS gate and a KDF concurrency limiter ADR-006 sections 3, 6 and 11 (heimdall2-e25.7, heimdall2-e25.8). verifyPassword dispatches on the stored format AND the FIPS state: $pbkdf2-sha{256,384,512}$ verify, needsRehash false $2a$ / $2b$ / $2y$ FIPS off: bcryptjs.compare, needsRehash = valid FIPS on: refuse without invoking bcryptjs, requiresReset true anything else reject without throwing The FIPS refusal is STIG-scoped, not a FIPS 140-3 prohibition: V-222571 fires on GENERATING a hash, and bcryptjs.compare() generates one in pure JS outside the validated module. bcryptjs is therefore lazy-imported so a FIPS deployment never loads unapproved-crypto code, and the seeder's bare require() of the compiled module stays free of it. Implements the section 6 validation sequence in order (five-part split, an identifier allowlist, bounded iteration parsing that rejects parseInt-style coercion, a canonical re-encode check, and a key-width guard before timingSafeEqual, which throws on a length mismatch). Every rejection path pays constant work through a dummy KDF so an absent, malformed or refused credential is not distinguishable by timing from a wrong password. The limiter is a dependency-free counting semaphore inside password.ts, wrapping the single pbkdf2 dispatcher so hashing, verification and the timing dummy are all globally capped. It cannot live in the service layer: the pure callers run in the same process and the seeder forbids dependencies. Overflow rejects with a typed KdfOverloadedError that the auth layer maps to its generic failure. UV_THREADPOOL_SIZE=8 is set in the Dockerfile and cmd.sh because libuv reads it at first threadpool use, so it cannot go through ConfigService. Measured on the target host: PBKDF2-SHA512 at 600k iterations is 594ms p50, and fs.readFile starvation under sustained KDF load improves from 12.1s to 4.1s with the larger threadpool. Authored by: Aaron Lippold --- Dockerfile | 5 + apps/backend/package.json | 3 + apps/backend/src/crypto/password.spec.ts | 366 ++++++++++++++++++++++- apps/backend/src/crypto/password.ts | 315 ++++++++++++++++++- cmd.sh | 4 + 5 files changed, 676 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2dbeba3d79..b06672b4e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,11 @@ COPY --from=builder --chown=1001 /src/dist/ dist/ COPY --chmod=755 cmd.sh /usr/local/bin/ +# ADR-006 §11: libuv reads UV_THREADPOOL_SIZE at first threadpool use, before +# app config loads — it must be process environment, never ConfigService. 8 +# threads + the in-app KDF limiter (concurrency 2) keep fs/dns from starving. +ENV UV_THREADPOOL_SIZE=8 + USER 1001 CMD ["/usr/local/bin/cmd.sh"] diff --git a/apps/backend/package.json b/apps/backend/package.json index cb4779f38f..606717d327 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -91,5 +91,8 @@ "mock-fs": "^5.0.0", "unplugin-swc": "^1.5.5", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.18.0" } } diff --git a/apps/backend/src/crypto/password.spec.ts b/apps/backend/src/crypto/password.spec.ts index 3e5a1314c8..c4b6ca56b4 100644 --- a/apps/backend/src/crypto/password.spec.ts +++ b/apps/backend/src/crypto/password.spec.ts @@ -1,11 +1,31 @@ -import { KNOWN_GOOD_VECTORS } from '@heimdall/password-hash-vectors'; -import { describe, expect, it } from 'vitest'; +import * as nodeCrypto from 'node:crypto'; import { + KNOWN_GOOD_VECTORS, + MALFORMED_CORPUS, +} from '@heimdall/password-hash-vectors'; +import * as bcryptjs from 'bcryptjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + configureKdfLimiter, hashPassword, hashPasswordWithSalt, + kdfLimiterState, + KdfOverloadedError, PasswordHashError, + verifyPassword, } from './password'; +// Wrap compare in a pass-through spy so tests can assert INVOCATION and +// NON-invocation of the real module (§5: a return-value assertion alone would +// pass an implementation that calls compare() and discards the result — the +// exact V-222571 finding). vi.mock intercepts the lazy dynamic import in +// password.ts too; the FIPS-off positive control proves the interception is +// live, so the non-invocation assertion cannot pass vacuously. +vi.mock('bcryptjs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, compare: vi.fn(actual.compare) }; +}); + type Algorithm = 'sha256' | 'sha384' | 'sha512'; // Decoded byte width of a no-padding base64 string: floor(len * 6 / 8). @@ -32,6 +52,28 @@ function digestWidth(algorithm: Algorithm): number { } } +// Build a PHC hash with RAW node:crypto, bypassing hashPassword's §9 hashing +// floors — the only way to produce the legacy-parameter hashes (50k, 1000 +// iterations; >128-char passwords) that verifyPassword must still accept. +// Buffer base64, not Uint8Array#toBase64 — that TC39 API is undefined at our +// Node runtime (see toB64 in password.ts). +function rawPhc( + password: string, + salt: Buffer, + iterations: number, + algorithm: Algorithm, +): string { + const key = nodeCrypto.pbkdf2Sync( + password, + salt, + iterations, + digestWidth(algorithm), + algorithm, + ); + const b64 = (buffer: Buffer) => buffer.toString('base64').replaceAll('=', ''); + return `$pbkdf2-${algorithm}$i=${iterations}$${b64(salt)}$${b64(key)}`; +} + // Decode a vector's salt straight out of its PHC hash so we can inject it — // the vectors are the implementation-independent ground truth (ADR §14). function saltOf(hash: string): Buffer { @@ -147,3 +189,323 @@ describe('validation (§6/§9) — throws typed errors, never clamps', () => { ).rejects.toBeInstanceOf(PasswordHashError); }); }); + +describe('verifyPassword — §3 dispatch × FIPS gate', () => { + it('refuses a bcrypt hash under FIPS without ever invoking bcryptjs.compare', async () => { + // §5: the spy IS the point. An implementation that calls compare() and + // discards the result generates a bcrypt hash outside the validated + // module — the V-222571 finding — while passing a return-value check. + vi.mocked(bcryptjs.compare).mockClear(); + const result = await verifyPassword({ + getFips: () => 1, + hash: '$2b$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + password: 'CorrectHorse15!x', + }); + expect(result).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(bcryptjs.compare).not.toHaveBeenCalled(); + }); + + it('verifies every known-good vector in BOTH FIPS states with needsRehash:false', async () => { + // §3 row 1: the pbkdf2 path is identical whether FIPS is on or off. + for (const v of KNOWN_GOOD_VECTORS) { + for (const fips of [0, 1]) { + const result = await verifyPassword({ + getFips: () => fips, + hash: v.hash, + password: v.password, + }); + expect(result, `${v.label} fips=${fips}`).toEqual({ + needsRehash: false, + valid: true, + }); + } + } + }); + + it('rejects every vector when the last key character is perturbed', async () => { + // 'A' (0) and 'Q' (16) both have zero trailing bits at every digest + // width, so the perturbed hash stays CANONICAL base64 — it passes the + // §6 step-6 re-encode check and step-7 lengths, reaching the real KDF + // comparison. This pins the timingSafeEqual mismatch path, not an + // earlier malformed-input rejection. + for (const v of KNOWN_GOOD_VECTORS) { + const perturbed + = v.hash.slice(0, -1) + (v.hash.endsWith('A') ? 'Q' : 'A'); + const result = await verifyPassword({ + getFips: () => 0, + hash: perturbed, + password: v.password, + }); + expect(result, `perturbed ${v.label}`).toEqual({ + needsRehash: false, + valid: false, + }); + } + }); +}); + +describe('verifyPassword — §6 validation against the malformed corpus', () => { + it('rejects the ENTIRE corpus of reject/sentinel entries without throwing', async () => { + const rejects = MALFORMED_CORPUS.filter( + entry => entry.expected === 'reject' || entry.expected === 'sentinel', + ); + // Pin the count so a silently shrunk corpus (or a broken filter) fails + // loudly instead of vacuously passing on an empty table. + expect(rejects).toHaveLength(18); + // One FIPS state suffices: getFips is unreachable on every reject path + // (only the bcrypt branch consults it — covered both-states in the + // dispatch tests), and each rejection burns a full-cost constant-work + // dummy, so doubling the loop doubles ~5s of KDF time for zero coverage. + // fips=1 is the deployment-relevant state; the timing and non-string + // tests exercise reject paths at fips=0. + for (const entry of rejects) { + const result = await verifyPassword({ + getFips: () => 1, + // The corpus's one non-string entry deliberately violates the + // signature — §6 step 1 is a runtime guard for the CommonJS seeder. + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(result, `corpus trap: ${entry.trap}`).toEqual({ + needsRehash: false, + valid: false, + }); + } + }, 15_000); + + it('rejects a non-string password without throwing', async () => { + const result = await verifyPassword({ + getFips: () => 0, + hash: KNOWN_GOOD_VECTORS[0].hash, + password: 42 as unknown as string, + }); + expect(result).toEqual({ needsRehash: false, valid: false }); + }); +}); + +describe('verifyPassword — constant-work rejection (Risks: timing side-channel)', () => { + it('burns KDF-equivalent work on the unknown-format and FIPS-refuse paths', async () => { + // Timing IS the requirement, so the assertion is temporal: a reject must + // cost at least a quarter of a real default-parameter verification. + // Correct code runs the SAME 600k-iteration KDF on both sides (~1x), so + // the 4x margin cannot flake; pre-fix rejects are ~1000x faster and fail. + const vector = KNOWN_GOOD_VECTORS[0]; // sha512 @ 600k — default params + const realStart = performance.now(); + await verifyPassword({ + getFips: () => 0, + hash: vector.hash, + password: vector.password, + }); + const realDuration = performance.now() - realStart; + + const sentinelStart = performance.now(); + const sentinel = await verifyPassword({ + getFips: () => 0, + hash: 'INVALIDATED-BY-FIPS-CUTOVER-DO-NOT-USE', + password: vector.password, + }); + const sentinelDuration = performance.now() - sentinelStart; + + const refuseStart = performance.now(); + const refused = await verifyPassword({ + getFips: () => 1, + hash: '$2b$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + password: vector.password, + }); + const refuseDuration = performance.now() - refuseStart; + + expect(sentinel).toEqual({ needsRehash: false, valid: false }); + expect(refused).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(sentinelDuration).toBeGreaterThan(realDuration / 4); + expect(refuseDuration).toBeGreaterThan(realDuration / 4); + }); +}); + +describe('verifyPassword — §9 verify has no policy floor and no length cap', () => { + it('verifies a 50k-iteration hash of a 200-char password — legacy config params must not lock users out', async () => { + // §9: a user hashed under an earlier PASSWORD_HASH_ITERATIONS=50000 (below + // the 100k HASHING floor) must still verify, and PASSWORD_MAX_LENGTH + // applies to hashing only — capping on verify would lock out any user + // whose existing password exceeds it. + const password = 'Aa1!'.repeat(50); // 200 chars — over the 128 hashing cap + const hash = rawPhc(password, Buffer.alloc(24, 9), 50_000, 'sha512'); + const result = await verifyPassword({ getFips: () => 1, hash, password }); + expect(result).toEqual({ needsRehash: false, valid: true }); + }); + + it('accepts the 1000-iteration sanity floor and rejects 999', async () => { + const atFloor = rawPhc('CorrectHorse15!x', Buffer.alloc(16, 3), 1000, 'sha256'); + await expect( + verifyPassword({ getFips: () => 0, hash: atFloor, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + + const belowFloor = rawPhc('CorrectHorse15!x', Buffer.alloc(16, 3), 999, 'sha256'); + await expect( + verifyPassword({ getFips: () => 0, hash: belowFloor, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: false }); + }); +}); + +describe('verifyPassword — §3 bcrypt path (FIPS off)', () => { + it('verifies a real bcrypt hash via bcryptjs.compare with needsRehash:valid — the positive control for the spy', async () => { + // This test proves the vi.mock interception is LIVE: the same spy the + // FIPS-refusal test asserts was NOT called must observe the call here. + // Without this control, a spy that failed to intercept would make the + // non-invocation assertion pass vacuously. + const hash = await bcryptjs.hash('CorrectHorse15!x', 4); + vi.mocked(bcryptjs.compare).mockClear(); + const match = await verifyPassword({ + getFips: () => 0, + hash, + password: 'CorrectHorse15!x', + }); + expect(match).toEqual({ needsRehash: true, valid: true }); + expect(bcryptjs.compare).toHaveBeenCalledTimes(1); + expect(bcryptjs.compare).toHaveBeenCalledWith('CorrectHorse15!x', hash); + + const mismatch = await verifyPassword({ + getFips: () => 0, + hash, + password: 'WrongHorse15!x', + }); + expect(mismatch).toEqual({ needsRehash: false, valid: false }); + }); + + it('rejects a bcrypt-prefixed but malformed hash without throwing — a corrupted row must fail auth, not 500', async () => { + // bcryptjs v3 compare, probed live: length !== 60 resolves false, but a + // 60-char hash whose salt section uses a non-bcrypt alphabet REJECTS the + // promise ("Illegal salt length: 0 != 16") — verifyPassword must convert + // that into the reject result (the "never throws on malformed input" + // contract covers the §3 bcrypt row too). + const shortMalformed = await verifyPassword({ + getFips: () => 0, + hash: '$2b$zz$not-a-valid-bcrypt-hash', + password: 'CorrectHorse15!x', + }); + expect(shortMalformed).toEqual({ needsRehash: false, valid: false }); + + const rejectingMalformed = await verifyPassword({ + getFips: () => 0, + hash: `$2b$10$${'!'.repeat(53)}`, // 60 chars — compare() rejects on this + password: 'CorrectHorse15!x', + }); + expect(rejectingMalformed).toEqual({ needsRehash: false, valid: false }); + }); + + it('dispatches all three corpus bcrypt prefixes: compare invoked when FIPS off, refused un-invoked when FIPS on', async () => { + const bcryptEntries = MALFORMED_CORPUS.filter( + entry => entry.expected === 'bcrypt', + ); + expect(bcryptEntries).toHaveLength(3); // $2a$ / $2b$ / $2y$ + for (const entry of bcryptEntries) { + // FIPS off — §3 dispatches to bcryptjs; garbage checksum → false, no throw. + vi.mocked(bcryptjs.compare).mockClear(); + const offResult = await verifyPassword({ + getFips: () => 0, + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(offResult, `${entry.trap} fips=0`).toEqual({ + needsRehash: false, + valid: false, + }); + expect(bcryptjs.compare, `${entry.trap} fips=0`).toHaveBeenCalledTimes(1); + + // FIPS on — §3 refuses without ever invoking bcryptjs. + vi.mocked(bcryptjs.compare).mockClear(); + const onResult = await verifyPassword({ + getFips: () => 1, + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(onResult, `${entry.trap} fips=1`).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(bcryptjs.compare, `${entry.trap} fips=1`).not.toHaveBeenCalled(); + } + }); +}); + +describe('KDF concurrency limiter (§11) — global semaphore inside password.ts', () => { + afterEach(() => { + // Restore defaults so limiter config never leaks across tests. + configureKdfLimiter(); + }); + + it('with concurrency 2, a third concurrent hashPassword queues and does not dispatch until a slot frees', async () => { + configureKdfLimiter({ concurrency: 2 }); + // 200k iterations ≈ tens of ms — long enough that all three overlap and + // the state probe below runs before ANY of them completes. + const inFlight = [ + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + ]; + // One microtask flush: the first two acquired slots synchronously, the + // third must be QUEUED — not dispatched to pbkdf2. + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 2, queued: 1 }); + + const hashes = await Promise.all(inFlight); + for (const hash of hashes) { + expect(hash.startsWith('$pbkdf2-sha512$i=200000$')).toBe(true); + } + // All slots released after settlement — no leaked accounting. + expect(kdfLimiterState()).toEqual({ active: 0, queued: 0 }); + }); + + it('rejects with the typed KdfOverloadedError when the bounded queue is full', async () => { + configureKdfLimiter({ concurrency: 1, maxQueue: 1 }); + const first = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + const second = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + // 1 active + 1 queued — the third must reject, typed, without ever + // dispatching KDF work. + await expect( + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + ).rejects.toBeInstanceOf(KdfOverloadedError); + await expect(first).resolves.toContain('$pbkdf2-'); + await expect(second).resolves.toContain('$pbkdf2-'); + expect(kdfLimiterState()).toEqual({ active: 0, queued: 0 }); + }); + + it('covers the verify path too — a queued verifyPassword completes correctly', async () => { + configureKdfLimiter({ concurrency: 1 }); + const vector = KNOWN_GOOD_VECTORS[0]; + // Occupy the single slot, then verify — the verify must queue, then run. + const occupant = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + const verified = verifyPassword({ + getFips: () => 0, + hash: vector.hash, + password: vector.password, + }); + await Promise.resolve(); + expect(kdfLimiterState().queued).toBe(1); + await expect(verified).resolves.toEqual({ + needsRehash: false, + valid: true, + }); + await occupant; + }); + + it('rejects invalid limiter configuration with a typed error, never clamps', () => { + expect(() => configureKdfLimiter({ concurrency: 0 })).toThrow( + PasswordHashError, + ); + expect(() => configureKdfLimiter({ maxQueue: 0 })).toThrow( + PasswordHashError, + ); + expect(() => + configureKdfLimiter({ concurrency: 1.5 }), + ).toThrow(PasswordHashError); + }); +}); diff --git a/apps/backend/src/crypto/password.ts b/apps/backend/src/crypto/password.ts index bb1607e1bf..31dc6f3aad 100644 --- a/apps/backend/src/crypto/password.ts +++ b/apps/backend/src/crypto/password.ts @@ -1,12 +1,15 @@ /** * FIPS-validated password hashing — the pure primitive (ADR-006 §1, §2, §5, §6). * - * DEPENDENCY-FREE by hard constraint: the only import is `node:crypto`. The - * admin bootstrap seeder (ADR §4 site 8) requires this module via a bare - * `require()` of the COMPILED output, outside Nest DI and the ConfigService — - * so it must pull in nothing else, and the inferred build layout must not - * shift. Do NOT add imports of Nest, config, a logger, or the vectors package - * (the vectors are a TEST-only dependency, imported by the spec, never here). + * DEPENDENCY-FREE by hard constraint: the only TOP-LEVEL import is + * `node:crypto`. The admin bootstrap seeder (ADR §4 site 8) requires this + * module via a bare `require()` of the COMPILED output, outside Nest DI and + * the ConfigService — so it must pull in nothing else, and the inferred build + * layout must not shift. Do NOT add imports of Nest, config, a logger, or the + * vectors package (the vectors are a TEST-only dependency, imported by the + * spec, never here). ONE disclosed exception: verifyPassword's FIPS-off + * bcrypt fallback lazily `await import()`s bcryptjs at call time — never + * loaded under FIPS, never loaded on the hash path the seeder uses. * * `node:crypto` is a NAMESPACE import, never destructured: swc compiles a * destructured `import {getFips}` to a non-writable binding, which blocks the @@ -28,6 +31,26 @@ export type PasswordHashOptions = { iterations?: number; }; +/** Result of {@link verifyPassword} (§5). `requiresReset` appears ONLY on the + * FIPS-refuse path: a bcrypt credential encountered while FIPS is on. */ +export type PasswordVerifyResult = { + needsRehash: boolean; + requiresReset?: boolean; + valid: boolean; +}; + +/** + * Thrown when the bounded KDF queue is full (§11). Server-side signal only: + * the auth layer maps this to its generic failure — never a distinct + * client-visible error (Risks — enumeration oracle). + */ +export class KdfOverloadedError extends Error { + constructor(message: string) { + super(message); + this.name = 'KdfOverloadedError'; + } +} + /** Thrown for any invalid input — never a silent clamp (§6, §9). */ export class PasswordHashError extends Error { constructor(message: string) { @@ -83,6 +106,170 @@ export async function hashPasswordWithSalt( return `$pbkdf2-${algorithm}$i=${iterations}$${toB64(salt)}$${toB64(key)}`; } +const BCRYPT_PREFIX = /^\$2[aby]\$/v; // §3: exactly $2a$/$2b$/$2y$ + +/** + * §6 step 3: STRICT allowlist over the FULL identifier. Never prefix-match — + * crypto.pbkdf2 accepts 'md5' and 'sha1', so `$pbkdf2-sha*$` would verify a + * downgraded digest, and allowlisting only the digest admits + * `$pbkdf2-sha512-md5$` via naive splitting. + */ +const PBKDF2_IDENTIFIERS = new Map([ + ['pbkdf2-sha256', 'sha256'], + ['pbkdf2-sha384', 'sha384'], + ['pbkdf2-sha512', 'sha512'], +]); + +/** + * §6 step 4: iterations by regex ONLY — parseInt('6e5') is 6 (a 100,000× + * downgrade that looks well-formed), parseInt('600000abc') is 600000, + * Number('0x10000') is 65536. Nine digits max, so Number() on the capture + * is always a safe integer. + */ +const ITERATIONS_FIELD = /^i=(?[1-9]\d{0,8})$/v; + +/** + * §9: floors and caps apply to HASHING only, never verification — a user + * hashed under an earlier PASSWORD_HASH_ITERATIONS=50000 must still verify. + * Verify enforces only the DoS upper bound (MAX_ITERATIONS) plus this sanity + * floor of 1000, the module's own documented minimum. + */ +const VERIFY_MIN_ITERATIONS = 1000; +const MIN_SALT_BYTES = 16; // §6 step 7 + +/** Constant input for the timing-mitigation dummy — its output is discarded. */ +const DUMMY_SALT = Buffer.alloc(SALT_BYTES); + +/** + * §11 KDF concurrency limiter — a hand-rolled counting semaphore (zero deps; + * the module must stay dependency-free for the seeder's bare require). It + * wraps the module's single internal pbkdf2 dispatcher, so hashPassword, + * verifyPassword, AND the constant-work dummy are all globally capped — + * "600k is only safe if UV_THREADPOOL_SIZE is raised AND a global KDF + * concurrency limit lands." + * + * Defaults per §11's starvation measurements: concurrency 2 leaves ≥2 of + * libuv's default 4 threads for fs/dns; queue 100 bounds the flood so thread + * starvation cannot become memory exhaustion. This is DELIBERATE module-scope + * shared state (the limit is global by design); configureKdfLimiter is the + * init/test seam. + */ +const DEFAULT_KDF_CONCURRENCY = 2; +const DEFAULT_KDF_MAX_QUEUE = 100; + +type KdfLimiter = { + active: number; + concurrency: number; + maxQueue: number; + queue: (() => void)[]; +}; + +const kdfLimiter: KdfLimiter = { + active: 0, + concurrency: DEFAULT_KDF_CONCURRENCY, + maxQueue: DEFAULT_KDF_MAX_QUEUE, + queue: [], +}; + +/** + * Init/test seam: the service card binds PASSWORD_KDF_CONCURRENCY here at + * construction; tests use it to configure and reset. Call ONLY at boot or + * between settled operations — it zeroes the accounting, so reconfiguring + * with KDFs in flight would corrupt the slot count. Out-of-range values + * THROW (§9) — never a silent clamp. + */ +export function configureKdfLimiter(options?: { + concurrency?: number; + maxQueue?: number; +}): void { + const concurrency = options?.concurrency ?? DEFAULT_KDF_CONCURRENCY; + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new PasswordHashError( + 'PASSWORD_KDF_CONCURRENCY must be an integer >= 1', + ); + } + const maxQueue = options?.maxQueue ?? DEFAULT_KDF_MAX_QUEUE; + if (!Number.isSafeInteger(maxQueue) || maxQueue < 1) { + throw new PasswordHashError('KDF maxQueue must be an integer >= 1'); + } + kdfLimiter.concurrency = concurrency; + kdfLimiter.maxQueue = maxQueue; + kdfLimiter.active = 0; + kdfLimiter.queue = []; +} + +/** Observable semaphore state — the test seam the limiter ACs require. */ +export function kdfLimiterState(): { active: number; queued: number } { + return { active: kdfLimiter.active, queued: kdfLimiter.queue.length }; +} + +/** + * Verify a submitted password against a stored hash, dispatching on stored + * format AND FIPS state (§3). Never throws on malformed input — rejects via + * the result object. `getFips` is injectable (§5) so both FIPS states are + * testable in non-FIPS CI; the default is the real `crypto.getFips`. + */ +export async function verifyPassword(arguments_: { + getFips?: () => number; + hash: string; + password: string; +}): Promise { + const { hash, password } = arguments_; + if (typeof hash !== 'string' || typeof password !== 'string') { + // §6 step 1 — the seeder path is CommonJS JS, so a non-string can arrive. + return rejectWithConstantWork(password); + } + const getFips = arguments_.getFips ?? crypto.getFips; + if (BCRYPT_PREFIX.test(hash)) { + if (getFips() === 1) { + // §3: refuse — do NOT invoke bcryptjs. bcryptjs.compare() generates a + // bcrypt hash in pure JS outside the validated module; under a FIPS + // deployment that is the V-222571 finding itself. + return rejectWithConstantWork(password, true); + } + // Lazy import so FIPS deployments never load unapproved-crypto code and + // the seeder's bare require() of the compiled module stays free of it — + // this branch is the ONLY exception to the header's node:crypto-only + // rule, and it never executes under FIPS or on the hash path. + const { compare } = await import('bcryptjs'); + try { + const isValid = await compare(password, hash); + // needsRehash: valid — a rehash requires the correct plaintext (§3). + return { needsRehash: isValid, valid: isValid }; + } catch { + // compare() rejects on a bcrypt-prefixed hash with an unparseable + // rounds/salt section (verified against bcryptjs 3.0.3) — a corrupted + // stored credential. Classification: unverifiable-hash → auth failure, + // never a thrown 500. This module is dependency-free by the header's + // constraint, so logging the corruption belongs to the service layer + // (rehash audit card). Constant work keeps the fast parse failure from + // advertising that the stored hash is corrupt rather than mismatched. + return rejectWithConstantWork(password); + } + } + return verifyPbkdf2(hash, password); +} + +function acquireKdfSlot(): Promise { + if (kdfLimiter.active < kdfLimiter.concurrency) { + kdfLimiter.active += 1; + return Promise.resolve(); + } + if (kdfLimiter.queue.length >= kdfLimiter.maxQueue) { + return Promise.reject( + new KdfOverloadedError( + `KDF queue full (${kdfLimiter.maxQueue} pending) — server overloaded`, + ), + ); + } + return new Promise((resolve) => { + kdfLimiter.queue.push(() => { + kdfLimiter.active += 1; + resolve(); + }); + }); +} + /** PBKDF2 derived-key width = digest width (§2). Exhaustive over the union. */ function digestWidth(algorithm: PasswordHashAlgorithm): number { switch (algorithm) { @@ -101,22 +288,66 @@ function digestWidth(algorithm: PasswordHashAlgorithm): number { } } -function pbkdf2( +/** The module's ONLY pbkdf2 dispatcher — every KDF passes the §11 limiter. */ +async function pbkdf2( password: string, salt: Buffer, iterations: number, keylen: number, digest: PasswordHashAlgorithm, ): Promise { - return new Promise((resolve, reject) => { - crypto.pbkdf2(password, salt, iterations, keylen, digest, (error, key) => { - if (error) { - reject(error); - return; - } - resolve(key); + await acquireKdfSlot(); + try { + return await new Promise((resolve, reject) => { + crypto.pbkdf2( + password, + salt, + iterations, + keylen, + digest, + (error, key) => { + if (error) { + reject(error); + return; + } + resolve(key); + }, + ); }); - }); + } finally { + releaseKdfSlot(); + } +} + +/** + * Risks (timing side-channel): burn one KDF-equivalent of work at DEFAULT + * parameters on every reject that did not run the real KDF, so no rejection — + * unknown format, the cutover sentinel, any §6 step failure, or the FIPS + * refuse path — is distinguishable by timing from a failed verification. + * Rejections after the real KDF (§6 step 8) already paid full cost. + */ +async function rejectWithConstantWork( + password: unknown, + shouldRequireReset?: boolean, +): Promise { + await pbkdf2( + typeof password === 'string' ? password : '', + DUMMY_SALT, + DEFAULT_ITERATIONS, + digestWidth(DEFAULT_ALGORITHM), + DEFAULT_ALGORITHM, + ); + return shouldRequireReset === true + ? { needsRehash: false, requiresReset: true, valid: false } + : { needsRehash: false, valid: false }; +} + +function releaseKdfSlot(): void { + kdfLimiter.active -= 1; + const next = kdfLimiter.queue.shift(); + if (next !== undefined) { + next(); + } } /** @@ -161,3 +392,57 @@ function validate( ); } } + +/** The §6 validation sequence, in order, against a candidate PHC string. */ +async function verifyPbkdf2( + hash: string, + password: string, +): Promise { + // §6 steps 1–2: ''.split('$') is [''], so the parts[0] === '' check alone + // passes for the empty string — the exact-field-count check catches it. + // These are AND, not alternatives. + const parts = hash.split('$'); + if (parts.length !== 5 || parts[0] !== '') { + return rejectWithConstantWork(password); + } + const algorithm = PBKDF2_IDENTIFIERS.get(parts[1]); // step 3 + if (algorithm === undefined) { + return rejectWithConstantWork(password); + } + const iterationsMatch = ITERATIONS_FIELD.exec(parts[2]); // step 4 + if (iterationsMatch === null) { + return rejectWithConstantWork(password); + } + const iterations = Number(iterationsMatch.groups?.iterations); + // Step 5: the upper bound is the DoS guard — Node permits 2³¹−1, roughly + // 8.6 minutes of one libuv thread per verification. (isSafeInteger also + // rejects the cannot-happen NaN if the named group were ever absent.) + if ( + !Number.isSafeInteger(iterations) + || iterations < VERIFY_MIN_ITERATIONS + || iterations > MAX_ITERATIONS + ) { + return rejectWithConstantWork(password); + } + // Step 6: Buffer.from(str, 'base64') is lenient — 'AA@@AA' and 'A A A A' + // decode to the same bytes as 'AAAA'. Re-encode and compare (padding + // stripped both sides) to reject non-canonical fields. (Buffer, not + // Uint8Array.fromBase64 — undefined at our Node runtime; see toB64.) + const salt = Buffer.from(parts[3], 'base64'); + const key = Buffer.from(parts[4], 'base64'); + if (toB64(salt) !== parts[3] || toB64(key) !== parts[4]) { + return rejectWithConstantWork(password); + } + // Step 7: BEFORE pbkdf2 — keylen=0 throws an untyped error, and a sha512 + // hash carrying a 32-byte key would otherwise silently verify a + // downgraded artifact. + if (key.length !== digestWidth(algorithm) || salt.length < MIN_SALT_BYTES) { + return rejectWithConstantWork(password); + } + const derived = await pbkdf2(password, salt, iterations, key.length, algorithm); + // Step 8: timingSafeEqual THROWS on length mismatch — guard first. + if (derived.length !== key.length) { + return { needsRehash: false, valid: false }; + } + return { needsRehash: false, valid: crypto.timingSafeEqual(derived, key) }; +} diff --git a/cmd.sh b/cmd.sh index 8622ac2d0d..c5ab37b94f 100644 --- a/cmd.sh +++ b/cmd.sh @@ -1,5 +1,9 @@ #!/bin/sh set -e +# ADR-006 §11: libuv reads UV_THREADPOOL_SIZE at first threadpool use, before +# app config loads — set it in the process environment (defense in depth with +# the Dockerfile ENV; also covers non-container invocations of this script). +export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-8}" yarn backend sequelize db:migrate yarn backend sequelize db:seed:all yarn backend start From f97b6256172031a6e05cd7fc9c1153832be0d319 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:44:36 -0400 Subject: [PATCH 033/197] feat: add PasswordService and CryptoModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-006 sections 5 and 9 (heimdall2-e25.9). A thin Nest layer over the pure primitives. It reads the section 9 configuration through the existing ConfigService — not the env.ts of the modernization branch — and validates every value at construction, throwing rather than silently clamping: PASSWORD_HASH_ALGORITHM sha512 (NSS deployments must not use sha256; V-222571 makes anything below SHA-384 a finding) PASSWORD_HASH_ITERATIONS 600000 PASSWORD_MAX_LENGTH 128 PASSWORD_KDF_CONCURRENCY 2 Only hashing takes configuration. verify() passes no policy bounds, because the stored hash carries its own parameters — a credential written under an earlier, lower iteration count must still verify. Integer parsing accepts decimal digits only, so '6e5' and '0x10' are rejected instead of coerced. The constructor binds PASSWORD_KDF_CONCURRENCY to the limiter's init seam once, before any hashing runs. CryptoModule imports ConfigModule explicitly (ConfigModule is not global in this app) and exports PasswordService for the call-site migrations. Authored by: Aaron Lippold --- apps/backend/src/app.module.ts | 2 + apps/backend/src/crypto/crypto.module.ts | 15 ++ .../src/crypto/password.service.spec.ts | 169 ++++++++++++++++++ apps/backend/src/crypto/password.service.ts | 149 +++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 apps/backend/src/crypto/crypto.module.ts create mode 100644 apps/backend/src/crypto/password.service.spec.ts create mode 100644 apps/backend/src/crypto/password.service.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 1bfd6ffbb5..8f93893ccc 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -9,6 +9,7 @@ import {AuthnModule} from './authn/authn.module'; import {AuthzModule} from './authz/authz.module'; import {CaslExceptionFilter} from './casl/casl-exception.filter'; import {ConfigModule} from './config/config.module'; +import {CryptoModule} from './crypto/crypto.module'; import {DatabaseModule} from './database/database.module'; import {EvaluationTagsModule} from './evaluation-tags/evaluation-tags.module'; import {EvaluationsModule} from './evaluations/evaluations.module'; @@ -27,6 +28,7 @@ import {TenableModule} from './tenable/tenable.module'; renderPath: '*splat' }), ConfigModule, + CryptoModule, ApiKeyModule, UsersModule, DatabaseModule, diff --git a/apps/backend/src/crypto/crypto.module.ts b/apps/backend/src/crypto/crypto.module.ts new file mode 100644 index 0000000000..bf02dc8704 --- /dev/null +++ b/apps/backend/src/crypto/crypto.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '../config/config.module'; +import { PasswordService } from './password.service'; + +/** + * ADR-006 §5. PasswordService needs ConfigService, and ConfigModule is NOT + * @Global() in this app, so the import is required — not optional. Exported so + * the call-site cards can inject PasswordService. + */ +@Module({ + exports: [PasswordService], + imports: [ConfigModule], + providers: [PasswordService], +}) +export class CryptoModule {} diff --git a/apps/backend/src/crypto/password.service.spec.ts b/apps/backend/src/crypto/password.service.spec.ts new file mode 100644 index 0000000000..6e13919e9b --- /dev/null +++ b/apps/backend/src/crypto/password.service.spec.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { + configureKdfLimiter, + hashPassword, + kdfLimiterState, + PasswordHashError, +} from './password'; +import { PasswordService } from './password.service'; + +// Hoisted to module scope (prefer-static-regex): assertion patterns for the +// construction-time validation messages. +const ITERATIONS_RANGE = /PASSWORD_HASH_ITERATIONS.*100000.*10000000/v; +const ITERATIONS_NAMED = /PASSWORD_HASH_ITERATIONS/v; +const ALGORITHM_ALLOWLIST = /PASSWORD_HASH_ALGORITHM.*sha256.*sha384.*sha512/v; +const MAX_LENGTH_NAMED = /PASSWORD_MAX_LENGTH.*128/v; +const KDF_CONCURRENCY_NAMED = /PASSWORD_KDF_CONCURRENCY/v; + +// Build a PasswordService whose ConfigService returns the given values. A Map +// (not a Record index) avoids the object-injection sink; spying `get` (not +// casting) keeps the real type — no Gate 3 bypass. +function serviceWith( + values: Record, +): PasswordService { + const lookup = new Map(Object.entries(values)); + const config = new ConfigService(); + vi.spyOn(config, 'get').mockImplementation((key: string) => lookup.get(key)); + return new PasswordService(config); +} + +describe('PasswordService — §9 config binding + delegation', () => { + beforeEach(() => { + // Reset the module-scope KDF limiter so concurrency-binding assertions + // start from a known state (e25.8's seam). + configureKdfLimiter(); + }); + + describe('construction-time §9 validation — throws, never clamps', () => { + it('throws when PASSWORD_HASH_ITERATIONS is below the floor, naming the variable and range', () => { + expect(() => serviceWith({ PASSWORD_HASH_ITERATIONS: '50000' })).toThrow( + ITERATIONS_RANGE, + ); + }); + + it('throws when PASSWORD_HASH_ITERATIONS exceeds the ceiling', () => { + expect(() => + serviceWith({ PASSWORD_HASH_ITERATIONS: '10000001' }), + ).toThrow(ITERATIONS_NAMED); + }); + + it('throws when PASSWORD_HASH_ALGORITHM is not in the allowlist', () => { + expect(() => serviceWith({ PASSWORD_HASH_ALGORITHM: 'md5' })).toThrow( + ALGORITHM_ALLOWLIST, + ); + }); + + it('throws when PASSWORD_MAX_LENGTH exceeds 128', () => { + expect(() => serviceWith({ PASSWORD_MAX_LENGTH: '256' })).toThrow( + MAX_LENGTH_NAMED, + ); + }); + + it('throws when PASSWORD_KDF_CONCURRENCY is below 1', () => { + expect(() => serviceWith({ PASSWORD_KDF_CONCURRENCY: '0' })).toThrow( + KDF_CONCURRENCY_NAMED, + ); + }); + + it('throws on a non-integer iteration value (never silently coerces)', () => { + expect(() => + serviceWith({ PASSWORD_HASH_ITERATIONS: '6e5' }), + ).toThrow(ITERATIONS_NAMED); + }); + }); + + describe('defaults (§9 table) when nothing is configured', () => { + it('hashes with sha512 / 600000 by default', async () => { + const service = serviceWith({}); + const hash = await service.hash('CorrectHorse15!x'); + expect(hash.startsWith('$pbkdf2-sha512$i=600000$')).toBe(true); + }); + + it('binds PASSWORD_KDF_CONCURRENCY default of 2 to the limiter', async () => { + // Pre-set a DIFFERENT concurrency so this test proves the constructor + // actively rebinds to 2 — not merely that beforeEach left it at 2. If + // the bind were removed, the limiter would stay at 5 and the third probe + // would run ({active:3,queued:0}), failing the assertion. + configureKdfLimiter({ concurrency: 5 }); + serviceWith({}); + const runs = Promise.all([ + hashViaLimiterProbe(), + hashViaLimiterProbe(), + hashViaLimiterProbe(), + ]); + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 2, queued: 1 }); + await runs; + }); + }); + + describe('configured values are honoured on the hash path', () => { + it('uses PASSWORD_HASH_ALGORITHM and PASSWORD_HASH_ITERATIONS', async () => { + const service = serviceWith({ + PASSWORD_HASH_ALGORITHM: 'sha256', + PASSWORD_HASH_ITERATIONS: '200000', + }); + const hash = await service.hash('CorrectHorse15!x'); + expect(hash.startsWith('$pbkdf2-sha256$i=200000$')).toBe(true); + }); + + it('binds a custom PASSWORD_KDF_CONCURRENCY to the limiter', async () => { + serviceWith({ PASSWORD_KDF_CONCURRENCY: '1' }); + const runs = Promise.all([hashViaLimiterProbe(), hashViaLimiterProbe()]); + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 1, queued: 1 }); + await runs; + }); + + it('rejects a password longer than the configured PASSWORD_MAX_LENGTH (hash path cap)', async () => { + const service = serviceWith({ PASSWORD_MAX_LENGTH: '64' }); + await expect(service.hash('a'.repeat(65))).rejects.toBeInstanceOf( + PasswordHashError, + ); + await expect(service.hash('Aa1!'.repeat(15))).resolves.toContain( + '$pbkdf2-', + ); // 60 chars, under the cap + }); + }); + + describe('verify path applies NO policy bounds (§9)', () => { + it('verifies a hash made under a DIFFERENT (lower) iteration config', async () => { + // Hash at 200k, then verify through a service configured at 600k — the + // stored parameters govern verification, not the service policy. + const lo = serviceWith({ PASSWORD_HASH_ITERATIONS: '200000' }); + const stored = await lo.hash('CorrectHorse15!x'); + const hi = serviceWith({}); // default 600k + await expect( + hi.verify({ hash: stored, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + await expect( + hi.verify({ hash: stored, password: 'WrongHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: false }); + }); + + it('verify does not apply PASSWORD_MAX_LENGTH — a password over the configured cap still verifies', async () => { + // A 100-char password: over the configured cap (64) but under the pure + // module's absolute 128 DoS cap, so it was hashable when the cap was + // higher. After the cap drops to 64 the user must STILL verify (§9: + // caps never apply on verify). + const capped = serviceWith({ PASSWORD_MAX_LENGTH: '64' }); + const longPass = 'Aa1!'.repeat(25); // 100 chars + const stored = await hashUncapped(longPass); + await expect( + capped.verify({ hash: stored, password: longPass }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + }); + }); +}); + +function hashUncapped(password: string): Promise { + return hashPassword(password); +} + +// Local helpers kept out of the describe bodies for scoping cleanliness. +// Direct (not dynamic-import) so the acquire runs synchronously and the +// limiter-state probe after one microtask flush is deterministic. +function hashViaLimiterProbe(): Promise { + return hashPassword('CorrectHorse15!x', { iterations: 200_000 }); +} diff --git a/apps/backend/src/crypto/password.service.ts b/apps/backend/src/crypto/password.service.ts new file mode 100644 index 0000000000..2653b74c9f --- /dev/null +++ b/apps/backend/src/crypto/password.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '../config/config.service'; +import { + configureKdfLimiter, + hashPassword, + PasswordHashAlgorithm, + PasswordHashError, + PasswordVerifyResult, + verifyPassword, +} from './password'; + +const DECIMAL_INTEGER = /^\d+$/v; +const ALGORITHMS: readonly PasswordHashAlgorithm[] = [ + 'sha256', + 'sha384', + 'sha512', +]; + +function isAlgorithm(value: string): value is PasswordHashAlgorithm { + return (ALGORITHMS as readonly string[]).includes(value); +} + +/** + * Nest layer over the pure password primitives (ADR-006 §5, §9). Reads the + * §9 configuration through ConfigService and delegates to password.ts. + * + * Only hashing needs configuration — verifyPassword reads its parameters from + * the stored hash, so the verify path passes NO policy bounds (§9's + * contradiction fix: a user hashed under an earlier, lower iteration/length + * config must still verify). All §9 values are validated at construction and + * throw — never a silent clamp. + * + * §9 defaults (must match the ADR table): + * PASSWORD_HASH_ALGORITHM = sha512 (NSS deployments must NOT use sha256 — + * V-222571 makes anything weaker than + * SHA-384 a finding) + * PASSWORD_HASH_ITERATIONS = 600000 + * PASSWORD_MAX_LENGTH = 128 + * PASSWORD_KDF_CONCURRENCY = 2 + */ +@Injectable() +export class PasswordService { + private static readonly ABSOLUTE_MAX_LENGTH = 128; // §6 approved 8–128 range + private static readonly DEFAULT_ALGORITHM: PasswordHashAlgorithm = 'sha512'; + private static readonly DEFAULT_ITERATIONS = 600_000; + private static readonly DEFAULT_KDF_CONCURRENCY = 2; + private static readonly DEFAULT_MAX_LENGTH = 128; + private static readonly MAX_ITERATIONS = 10_000_000; // §6 DoS ceiling + private static readonly MIN_ITERATIONS = 100_000; // §9 hash-path floor + + private readonly algorithm: PasswordHashAlgorithm; + private readonly iterations: number; + private readonly maxLength: number; + + constructor(private readonly configService: ConfigService) { + this.algorithm = this.readAlgorithm(); + this.iterations = this.readIntInRange( + 'PASSWORD_HASH_ITERATIONS', + PasswordService.DEFAULT_ITERATIONS, + PasswordService.MIN_ITERATIONS, + PasswordService.MAX_ITERATIONS, + ); + this.maxLength = this.readIntInRange( + 'PASSWORD_MAX_LENGTH', + PasswordService.DEFAULT_MAX_LENGTH, + 1, + PasswordService.ABSOLUTE_MAX_LENGTH, + ); + const concurrency = this.readIntInRange( + 'PASSWORD_KDF_CONCURRENCY', + PasswordService.DEFAULT_KDF_CONCURRENCY, + 1, + Number.MAX_SAFE_INTEGER, + ); + // §11: bind the global KDF limiter's init seam (e25.8). Done once at + // construction, before any hashing runs. + configureKdfLimiter({ concurrency }); + } + + /** + * Hash a password using the configured algorithm and iterations. Enforces + * the configured PASSWORD_MAX_LENGTH on this (hash) path only; the pure + * function keeps its own absolute 128 cap as defense in depth. + */ + hash(password: string): Promise { + if (typeof password === 'string' && password.length > this.maxLength) { + return Promise.reject( + new PasswordHashError( + `password must be at most ${this.maxLength} characters`, + ), + ); + } + return hashPassword(password, { + algorithm: this.algorithm, + iterations: this.iterations, + }); + } + + /** + * Verify a password against a stored hash. NO policy bounds are applied — + * the stored hash's own parameters govern (§9). getFips defaults to the real + * crypto.getFips inside verifyPassword. + */ + verify(arguments_: { + hash: string; + password: string; + }): Promise { + return verifyPassword(arguments_); + } + + private readAlgorithm(): PasswordHashAlgorithm { + const raw = this.configService.get('PASSWORD_HASH_ALGORITHM'); + if (raw === undefined || raw === '') { + return PasswordService.DEFAULT_ALGORITHM; + } + if (isAlgorithm(raw)) { + return raw; + } + throw new PasswordHashError( + `PASSWORD_HASH_ALGORITHM must be one of sha256, sha384, sha512 (got '${raw}')`, + ); + } + + private readIntInRange( + key: string, + fallback: number, + min: number, + max: number, + ): number { + const raw = this.configService.get(key); + if (raw === undefined || raw === '') { + return fallback; + } + // Decimal integers only — never parseInt/Number coercion (§6 step 4: + // parseInt('6e5') === 6, Number('0x10') === 16). + if (!DECIMAL_INTEGER.test(raw)) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + return value; + } +} From 7223795da35323670dba0535c776808701ac9d42 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:44:51 -0400 Subject: [PATCH 034/197] feat: add compare-and-swap rehash writers and migrate the verify-only site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-006 sections 4 and 7 (heimdall2-e25.10, heimdall2-e25.13 site 3). UsersService.updateEncryptedPassword and ApiKeyService.updateApiKeyHash are narrow writers for the lazy rehash. Neither existing persistence method can be used: update() unconditionally stamps passwordChangedAt and forcePasswordChange, which would reset every migrating user's password-expiry clock — a security regression introduced by a compliance fix. Each writer is a single Model.update carrying the whole contract: where: {id, : originalHash} compare-and-swap predicate fields: [''] nothing else can be written silent: true no updatedAt bump, so a mass migration does not make every account look recently modified and returns the affected row count. Zero means a concurrent writer won, and the caller does nothing. This is what stops the damaging interleaving: a user changes their password while an in-flight login rehashes the old one, and last-write-wins silently reverts the change — if that change was a response to a compromise, the compliance fix would reinstate the compromise. Both take a user/key id rather than a model instance, so the new hash cannot leak into the un-awaited updateLoginMetadata save that races them. That makes the no-instance-mutation requirement structural instead of a review rule. The ApiKeys writer is named updateApiKeyHash because that table has no encryptedPassword column; the hashed value lives in apiKey. UsersService.remove() (site 3) now consumes the pure verifyPassword and reads .valid only, so a FIPS-refused credential blocks deletion exactly like a wrong password. Authored by: Aaron Lippold --- .../src/apikeys/apikey.service.spec.ts | 103 +++++++++++++++ apps/backend/src/apikeys/apikey.service.ts | 21 +++ apps/backend/src/users/users.service.spec.ts | 125 +++++++++++++++++- apps/backend/src/users/users.service.ts | 53 ++++++-- 4 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/apikeys/apikey.service.spec.ts diff --git a/apps/backend/src/apikeys/apikey.service.spec.ts b/apps/backend/src/apikeys/apikey.service.spec.ts new file mode 100644 index 0000000000..e53b9264a4 --- /dev/null +++ b/apps/backend/src/apikeys/apikey.service.spec.ts @@ -0,0 +1,103 @@ +import {SequelizeModule} from '@nestjs/sequelize'; +import {Test} from '@nestjs/testing'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {ConfigService} from '../config/config.service'; +import {DatabaseModule} from '../database/database.module'; +import {DatabaseService} from '../database/database.service'; +import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; +import {Evaluation} from '../evaluations/evaluation.model'; +import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; +import {GroupUser} from '../group-users/group-user.model'; +import {Group} from '../groups/group.model'; +import {User} from '../users/user.model'; +import {ApiKey} from './apikey.model'; +import {ApiKeyService} from './apikey.service'; + +// ADR-006 §7: narrow compare-and-swap writer for lazy API-key rehash. Same +// shape as UsersService.updateEncryptedPassword, against the ApiKeys.apiKey +// hash column. This spec did not exist before this card. +describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + const ORIGINAL = '$pbkdf2-sha512$i=600000$origOrigOrigOrigOrig$origKeyOrig'; + const NEW = '$pbkdf2-sha512$i=600000$newnewnewnewnewnew$newKeyNewKey'; + let apiKeyId: string; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + // The full model set must be registered so ApiKey's @BelongsTo(User, + // Group) and their transitive associations (Group↔User through + // GroupUser, etc.) resolve — constraints:false still needs the models + // defined. Mirrors users.service.spec's registration set. + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag + ]) + ], + providers: [ApiKeyService, ConfigService, DatabaseService] + }).compile(); + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + // Insert a key row with a known stored hash (associations are + // constraints:false and userId is nullable, so no owner is required). + const created = await ApiKey.create({ + apiKey: ORIGINAL, + name: 'cas-test', + type: 'user' + }); + apiKeyId = created.id; + }); + + it('returns 0 and writes nothing when the stored hash no longer matches originalHash', async () => { + const affected = await apiKeyService.updateApiKeyHash( + apiKeyId, + 'a-stale-hash-that-does-not-match', + NEW + ); + expect(affected).toBe(0); + const reloaded = await ApiKey.findByPk(apiKeyId); + expect(reloaded?.apiKey).toBe(ORIGINAL); + }); + + it('returns 1 and swaps apiKey when originalHash matches', async () => { + const affected = await apiKeyService.updateApiKeyHash( + apiKeyId, + ORIGINAL, + NEW + ); + expect(affected).toBe(1); + const reloaded = await ApiKey.findByPk(apiKeyId); + expect(reloaded?.apiKey).toBe(NEW); + }); + + it('does NOT bump updatedAt on a winning write (silent: true)', async () => { + const before = await ApiKey.findByPk(apiKeyId); + const beforeUpdatedAt = before?.updatedAt?.getTime(); + await apiKeyService.updateApiKeyHash(apiKeyId, ORIGINAL, NEW); + const after = await ApiKey.findByPk(apiKeyId); + expect(after?.updatedAt?.getTime()).toBe(beforeUpdatedAt); + }); + + it('does NOT touch name or type', async () => { + await apiKeyService.updateApiKeyHash(apiKeyId, ORIGINAL, NEW); + const after = await ApiKey.findByPk(apiKeyId); + expect(after?.name).toBe('cas-test'); + expect(after?.type).toBe('user'); + }); +}); diff --git a/apps/backend/src/apikeys/apikey.service.ts b/apps/backend/src/apikeys/apikey.service.ts index f08bdfcd6a..d1374df743 100644 --- a/apps/backend/src/apikeys/apikey.service.ts +++ b/apps/backend/src/apikeys/apikey.service.ts @@ -45,6 +45,27 @@ export class ApiKeyService { return {id: newApiKey.id, name: newApiKey.name, apiKey: newJWT}; } + /** + * ADR-006 §7: narrow compare-and-swap writer for lazy API-key rehash — the + * ApiKeys equivalent of UsersService.updateEncryptedPassword. Rewrites the + * `apiKey` hash column ONLY, gated on the stored value still matching + * `originalHash`, silent so updatedAt is not bumped. Same shape as the Users + * writer (§4 names the updateLoginMetadata/updateUserSecret precedent; the + * ApiKey field is `apiKey`, so the method is named for it). Returns affected + * count — 0 means another writer won; the caller does nothing. + */ + async updateApiKeyHash( + id: string, + originalHash: string, + newHash: string + ): Promise { + const [affected] = await this.apiKeyModel.update( + {apiKey: newHash}, + {where: {id, apiKey: originalHash}, fields: ['apiKey'], silent: true} + ); + return affected; + } + async update( id: string, updateAPIKeyDto: UpdateAPIKeyDto diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index bd182bedae..58555e4811 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -6,7 +6,15 @@ import { } from '@nestjs/common'; import {SequelizeModule} from '@nestjs/sequelize'; import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; import { CREATE_ADMIN_DTO, @@ -37,6 +45,8 @@ import { import {AuthzModule} from '../authz/authz.module'; import {AuthzService} from '../authz/authz.service'; import {ConfigService} from '../config/config.service'; +import type * as PasswordCrypto from '../crypto/password'; +import {hashPassword, verifyPassword} from '../crypto/password'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -50,6 +60,15 @@ import {UserDto} from './dto/user.dto'; import {User} from './user.model'; import {UsersService} from './users.service'; +// Pass-through wrap so the FIPS-refuse test can steer ONE verifyPassword +// result (real host FIPS state cannot be entered in CI — §10 it is host-level; +// verifyPassword's own FIPS behavior is proven in password.spec.ts with an +// injected getFips). Every other call goes to the real implementation. +vi.mock('../crypto/password', async (importOriginal) => { + const actual = await importOriginal(); + return {...actual, verifyPassword: vi.fn(actual.verifyPassword)}; +}); + describe('UsersService', () => { let authzService: AuthzService; let usersService: UsersService; @@ -518,6 +537,45 @@ describe('UsersService', () => { ).rejects.toThrow(ForbiddenException); }); + it('succeeds when the supplied password matches a PBKDF2-stored hash (site 3)', async () => { + // Overwrite the bcrypt hash create() wrote (site 1 — e25.12's card) with + // a PBKDF2 hash of the same password. remove() must verify it via the + // pure verifyPassword; bcryptjs.compare returns false on a PHC string. + // DeleteUserDto.password is optional; '' makes hashPassword throw, so a + // fixture that ever loses its password fails this test loudly. + await user.update({ + encryptedPassword: await hashPassword( + DELETE_USER_DTO_TEST_OBJ.password ?? '' + ) + }); + const removedUser = await usersService.remove( + user, + DELETE_USER_DTO_TEST_OBJ, + abacPolicy + ); + expect(removedUser.email).toEqual(user.email); + await expect(usersService.findByEmail(user.email)).rejects.toThrow( + NotFoundException + ); + }); + + it('refuses deletion when verifyPassword returns the FIPS-refuse result (site 3 consumes .valid only)', async () => { + // Steer one result to the §3 refuse shape. remove() must read .valid + // alone — a refused bcrypt credential blocks deletion exactly like a + // wrong password. Clear first so the invocation assertion below cannot + // be satisfied by a prior test's call on the shared module mock. + vi.mocked(verifyPassword).mockClear(); + vi.mocked(verifyPassword).mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false + }); + await expect( + usersService.remove(user, DELETE_USER_DTO_TEST_OBJ, abacPolicy) + ).rejects.toThrow(ForbiddenException); + expect(verifyPassword).toHaveBeenCalled(); + }); + it('should remove created user', async () => { const removedUser = await usersService.remove( user, @@ -591,4 +649,69 @@ describe('UsersService', () => { ).toEqual(new UserDto(user)); }); }); + + // ADR-006 §7: narrow compare-and-swap writer for lazy rehash. Touches + // encryptedPassword ONLY, gated on the stored hash still matching, silent so + // updatedAt is not bumped. Takes a userId (not a User instance) so it cannot + // leak the new hash into the un-awaited updateLoginMetadata save (AC6 by + // construction). + describe('updateEncryptedPassword (§7 compare-and-swap)', () => { + let user: User; + const ORIGINAL = '$pbkdf2-sha512$i=600000$origOrigOrigOrigOrig$origKeyOrig'; + const NEW = '$pbkdf2-sha512$i=600000$newnewnewnewnewnew$newKeyNewKey'; + + beforeEach(async () => { + const dto = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const created = await User.findByPk(dto.id); + if (created === null) { + throw new TypeError(errorString); + } + user = created; + // Seed a known stored hash directly (bypassing hashing — this card is + // persistence only). silent so the baseline updatedAt is stable. + await user.update({encryptedPassword: ORIGINAL}, {silent: true}); + }); + + it('returns 0 and writes nothing when the stored hash no longer matches originalHash', async () => { + // The CAS-loses-the-race case (§7's damage scenario) — comes first. + const affected = await usersService.updateEncryptedPassword( + user.id, + 'a-stale-hash-that-does-not-match', + NEW + ); + expect(affected).toBe(0); + const reloaded = await User.findByPk(user.id); + expect(reloaded?.encryptedPassword).toBe(ORIGINAL); + }); + + it('returns 1 and swaps encryptedPassword when originalHash matches', async () => { + const affected = await usersService.updateEncryptedPassword( + user.id, + ORIGINAL, + NEW + ); + expect(affected).toBe(1); + const reloaded = await User.findByPk(user.id); + expect(reloaded?.encryptedPassword).toBe(NEW); + }); + + it('does NOT bump updatedAt on a winning write (silent: true)', async () => { + const before = await User.findByPk(user.id); + const beforeUpdatedAt = before?.updatedAt?.getTime(); + await usersService.updateEncryptedPassword(user.id, ORIGINAL, NEW); + const after = await User.findByPk(user.id); + expect(after?.updatedAt?.getTime()).toBe(beforeUpdatedAt); + }); + + it('does NOT touch passwordChangedAt or forcePasswordChange', async () => { + const before = await User.findByPk(user.id); + // Type-agnostic capture (§7 wrinkle: column may be STRING or DATE). + const beforePwChanged = String(before?.passwordChangedAt); + const beforeForce = before?.forcePasswordChange; + await usersService.updateEncryptedPassword(user.id, ORIGINAL, NEW); + const after = await User.findByPk(user.id); + expect(String(after?.passwordChangedAt)).toBe(beforePwChanged); + expect(after?.forcePasswordChange).toBe(beforeForce); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 9a87304103..c4610a1e33 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -6,12 +6,13 @@ import { NotFoundException } from '@nestjs/common'; import {InjectModel} from '@nestjs/sequelize'; -import {compare, hash} from 'bcryptjs'; +import {hash} from 'bcryptjs'; import {FindOptions} from 'sequelize'; import {v4} from 'uuid'; import {AuthnService} from '../authn/authn.service'; import {Action} from '../casl/casl-ability.factory'; import {ConfigService} from '../config/config.service'; +import {verifyPassword} from '../crypto/password'; import {GroupsService} from '../groups/groups.service'; import {CreateUserDto} from './dto/create-user.dto'; import {DeleteUserDto} from './dto/delete-user.dto'; @@ -116,21 +117,51 @@ export class UsersService { await user.save(); } + /** + * ADR-006 §7: narrow compare-and-swap writer for lazy password rehash. + * Rewrites encryptedPassword ONLY, and only while the stored value still + * equals `originalHash` — so an in-flight password change (which the + * un-awaited updateLoginMetadata save at authn.service.ts races) is never + * silently reverted. `fields` restricts the write to the one column; + * `silent` suppresses the updatedAt bump so a mass rehash does not make + * every account look recently modified. Takes a userId (not a User + * instance) so the new hash cannot leak into that racing save. Returns the + * affected row count — 0 means another writer won; the caller does nothing. + */ + async updateEncryptedPassword( + userId: string, + originalHash: string, + newHash: string + ): Promise { + const [affected] = await this.userModel.update( + {encryptedPassword: newHash}, + { + where: {id: userId, encryptedPassword: originalHash}, + fields: ['encryptedPassword'], + silent: true + } + ); + return affected; + } + async remove( userToDelete: User, deleteUserDto: DeleteUserDto, abac: Ability ): Promise { - if ( - abac.cannot(Action.DeleteNoPassword, userToDelete) && - !(await compare( - deleteUserDto.password || '', - userToDelete.encryptedPassword - )) - ) { - throw new ForbiddenException( - 'Password was incorrect, could not delete account' - ); + if (abac.cannot(Action.DeleteNoPassword, userToDelete)) { + // Site 3 (ADR-006 §4): verify-only — consumes .valid alone, never + // rehashes. Handles PBKDF2 and legacy bcrypt; refuses bcrypt under + // FIPS like any failed verification. + const {valid} = await verifyPassword({ + hash: userToDelete.encryptedPassword, + password: deleteUserDto.password || '' + }); + if (!valid) { + throw new ForbiddenException( + 'Password was incorrect, could not delete account' + ); + } } const adminCount = await this.userModel.count({where: {role: 'admin'}}); From ac36c60edf68741e89234a2bc201577148535fdf Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:45:10 -0400 Subject: [PATCH 035/197] feat: migrate the login path to PBKDF2 verification with lazy rehash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-006 sections 3, 4, 7, 11 and the Risks table (heimdall2-e25.14, heimdall2-e25.13 site 6, heimdall2-e25.11). validateUser is the primary migration path: this is where every active user's bcrypt hash converts to PBKDF2, one login at a time, racing the un-awaited updateLoginMetadata save the whole time. - verifies through PasswordService - on needsRehash, hashes and writes through the compare-and-swap writer with the ORIGINAL hash as the predicate, and never assigns the new hash to the Sequelize instance, so the racing save cannot carry it outside the predicate and revert a concurrent password change - wraps the rehash in try/catch: a failed or lost rehash logs and the login still succeeds - returns null on requiresReset, which LocalStrategy maps to the same generic 401 as every other failure, and logs the condition server-side with the user id. A distinct response would tell an attacker which accounts exist and are dormant - runs the constant-work dummy on the absent-user path, so user-exists and user-absent are not separable by timing - maps KdfOverloadedError to that same generic failure. Left unmapped it escaped as a 500 beside everyone else's 401, and under a saturated queue the absent-user dummy consumes a KDF slot while a legacy bcrypt compare consumes none — separating "no such account" from "account still on bcrypt" by status code alone. Any other error still propagates, so a real bug is never swallowed testPassword (site 6) becomes a pure verifyPassword call and is now declared `this: void`. users.service.ts invokes it unbound through the prototype and UsersService cannot inject AuthnService (circular), so the constraint is now compiler-enforced rather than a comment. The external-auth placeholder drops from randomBytes(128) to randomBytes(32) — 64 hex characters, still 256 bits for a credential never used to log in — so it fits under the 128-character cap the hash path enforces. Exempting these users from the cap instead would be a bypass waiting to be misused. AuthnService is provided by both AuthnModule and ApiKeyModule, so both import CryptoModule; without either the application does not boot. Verified against a running server and a real database: a login converts $2b$14$ (60 chars) to $pbkdf2-sha512$i=600000 (154 chars), passwordChangedAt and forcePasswordChange stay untouched, loginCount still increments, the migrated credential re-authenticates, and repeat logins leave the hash byte identical. Authored by: Aaron Lippold --- apps/backend/src/apikeys/apikeys.module.ts | 2 + apps/backend/src/authn/authn.module.ts | 2 + apps/backend/src/authn/authn.service.spec.ts | 421 +++++++++++++++++++ apps/backend/src/authn/authn.service.ts | 133 +++++- 4 files changed, 539 insertions(+), 19 deletions(-) create mode 100644 apps/backend/src/authn/authn.service.spec.ts diff --git a/apps/backend/src/apikeys/apikeys.module.ts b/apps/backend/src/apikeys/apikeys.module.ts index 7d564d324b..0fb45a9d8b 100644 --- a/apps/backend/src/apikeys/apikeys.module.ts +++ b/apps/backend/src/apikeys/apikeys.module.ts @@ -4,6 +4,7 @@ import {AuthnService} from '../authn/authn.service'; import {AuthzModule} from '../authz/authz.module'; import {ConfigModule} from '../config/config.module'; import {ConfigService} from '../config/config.service'; +import {CryptoModule} from '../crypto/crypto.module'; import {Group} from '../groups/group.model'; import {GroupsService} from '../groups/groups.service'; import {TokenModule} from '../token/token.module'; @@ -18,6 +19,7 @@ import {ApiKeyService} from './apikey.service'; SequelizeModule.forFeature([ApiKey, User, Group]), AuthzModule, ConfigModule, + CryptoModule, ApiKeyModule, TokenModule ], diff --git a/apps/backend/src/authn/authn.module.ts b/apps/backend/src/authn/authn.module.ts index a1a1280e2a..ec18cbc8a7 100644 --- a/apps/backend/src/authn/authn.module.ts +++ b/apps/backend/src/authn/authn.module.ts @@ -4,6 +4,7 @@ import {PassportModule} from '@nestjs/passport'; import {AuthnController} from './authn.controller'; import {ApiKeyModule} from '../apikeys/apikeys.module'; import {ConfigModule} from '../config/config.module'; +import {CryptoModule} from '../crypto/crypto.module'; import {GroupsModule} from '../groups/groups.module'; import {TokenModule} from '../token/token.module'; import {UsersModule} from '../users/users.module'; @@ -33,6 +34,7 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { PassportModule, TokenModule, ConfigModule, + CryptoModule, GroupsModule ], providers: [ diff --git a/apps/backend/src/authn/authn.service.spec.ts b/apps/backend/src/authn/authn.service.spec.ts new file mode 100644 index 0000000000..34ed5a83e4 --- /dev/null +++ b/apps/backend/src/authn/authn.service.spec.ts @@ -0,0 +1,421 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { JwtService } from '@nestjs/jwt'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { hash } from 'bcryptjs'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { CREATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import type { ApiKeyService } from '../apikeys/apikey.service'; +import { AuthzModule } from '../authz/authz.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigService } from '../config/config.service'; +import { hashPassword, KdfOverloadedError } from '../crypto/password'; +import { PasswordService } from '../crypto/password.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AuthnService } from './authn.service'; + +// Site 6 ONLY (ADR-006 §4). users.service.ts:79 invokes testPassword UNBOUND +// via AuthnService.prototype.testPassword(...) — it works only because the +// method never touches `this`. These tests call it with `this === undefined` +// (stricter than the production prototype-receiver call): ANY `this` access +// throws immediately. The full authn spec build-out (validateUser, +// validateApiKey) belongs to the sites-4/5 cards. +function userWith(encryptedPassword: string): User { + // testPassword reads exactly one field; a model instance needs the DB. + return { encryptedPassword } as User; +} + +describe('AuthnService.testPassword — site 6, pure and this-free', () => { + const PASSWORD = 'CorrectHorse15!x'; + const unboundTestPassword = AuthnService.prototype.testPassword; + + it('invoked unbound (no this) verifies a PBKDF2 hash without throwing (§4 structural constraint)', async () => { + const user = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call(undefined, { currentPassword: PASSWORD }, user), + ).resolves.toBeUndefined(); + }); + + it('invoked unbound verifies a legacy bcrypt hash (FIPS off)', async () => { + const user = userWith(await hash(PASSWORD, 4)); + await expect( + unboundTestPassword.call(undefined, { currentPassword: PASSWORD }, user), + ).resolves.toBeUndefined(); + }); + + it('rejects a wrong password with ForbiddenException for BOTH hash formats', async () => { + const pbkdf2User = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call( + undefined, + { currentPassword: 'WrongHorse15!x' }, + pbkdf2User, + ), + ).rejects.toThrow(ForbiddenException); + + const bcryptUser = userWith(await hash(PASSWORD, 4)); + await expect( + unboundTestPassword.call( + undefined, + { currentPassword: 'WrongHorse15!x' }, + bcryptUser, + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('rejects a missing currentPassword with ForbiddenException', async () => { + const user = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call(undefined, {}, user), + ).rejects.toThrow(ForbiddenException); + }); +}); + +// 32 random bytes → 64 lowercase-hex chars (ADR-006 §6). Module scope so it is +// compiled once, not recompiled on every assertion. +const PLACEHOLDER_HEX_64 = /^[0-9a-f]{64}$/v; + +describe('AuthnService.validateOrCreateUser — external-auth placeholder (ADR-006 §6)', () => { + // Real-DB harness (127.0.0.1:5433). Every external-auth provider (github, + // gitlab, google, ldap, oidc, okta) provisions new users through this single + // method, which generates ONE placeholder password (authn.service.ts) fed to + // usersService.create(). validateApiKey + login are unused here, so the + // ApiKeyService and JwtService collaborators are inert stubs. + let authnService: AuthnService; + let usersService: UsersService; + let databaseService: DatabaseService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + AuthzModule, + ], + providers: [ + AuthzService, + ConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + usersService = module.get(UsersService); + databaseService = module.get(DatabaseService); + + // AuthnService ⇄ UsersService is a circular import (users.service.ts calls + // AuthnService.prototype.testPassword unbound), so Nest cannot DI-resolve + // AuthnService here. validateOrCreateUser only uses this.usersService, so + // build it directly with the real UsersService and inert collaborators — + // apiKeyService/configService/jwtService are never touched on this path. + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + {} as PasswordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + }); + + it('provisions an external-auth user with a 64-char (256-bit) placeholder and persists the record', async () => { + expect.assertions(7); + const email = 'ext-oauth-user@example.com'; + // Spy CALLS THROUGH — the real create() hashes + writes to the DB, so this + // is a genuine end-to-end provisioning. The plaintext placeholder is + // unrecoverable from the stored hash by design, so its length is asserted + // at the generation boundary: the DTO create() actually received. + const createSpy = vi.spyOn(usersService, 'create'); + + const user = await authnService.validateOrCreateUser( + email, + 'Ext', + 'User', + 'github', + ); + + expect(createSpy).toHaveBeenCalledTimes(1); + const dto = createSpy.mock.calls[0][0]; + // Exact 64 — NOT `< 128`, which would silently pass a future weakening. + expect(dto.password).toHaveLength(64); + expect(dto.passwordConfirmation).toHaveLength(64); + expect(dto.password).toBe(dto.passwordConfirmation); + expect(dto.password).toMatch(PLACEHOLDER_HEX_64); + + // Persisted in the real DB via the external-auth provisioning path. + expect(user.email).toBe(email); + const stored = await usersService.findByEmail(email); + expect(stored.creationMethod).toBe('github'); + }); +}); + +describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 site 4, §7)', () => { + // Real-DB harness. validateUser is the primary migration path: a local login + // verifies through PasswordService and, on a still-bcrypt credential (FIPS + // off), lazily rehashes to PBKDF2 through the §7 compare-and-swap writer — + // never mutating the instance, never failing the login on a rehash error. + const { email, password } = CREATE_USER_DTO_TEST_OBJ; + let authnService: AuthnService; + let usersService: UsersService; + let databaseService: DatabaseService; + let passwordService: PasswordService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + AuthzModule, + ], + providers: [ + AuthzService, + ConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + usersService = module.get(UsersService); + databaseService = module.get(DatabaseService); + passwordService = new PasswordService( + module.get(ConfigService), + ); + + // Same circular-import reason as the validateOrCreateUser block: construct + // AuthnService directly. Only usersService, passwordService and the logger + // are exercised on this path. + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + passwordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + vi.restoreAllMocks(); + }); + + // Seed a user, then overwrite the stored hash with a controlled value. create() + // still bcrypts (site 1 is a different card), so we set the exact hash we want + // to test the dispatch against. + async function seedUserWithStoredHash(storedHash: string): Promise { + const dto = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const created = await User.findByPk(dto.id); + if (created === null) { + throw new TypeError('seed failed: user not found after create'); + } + await created.update({ encryptedPassword: storedHash }, { silent: true }); + return created; + } + + it('rehashes a valid bcrypt login (FIPS off) to $pbkdf2- via the CAS writer', async () => { + expect.assertions(3); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(seeded.id); + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + }); + + it('does not revert a concurrent password change — the in-flight rehash CAS loses (0 affected)', async () => { + expect.assertions(2); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + // The concurrent password change (H2) lands in the DB after this login's + // findByEmail read but before its CAS write. Move the DB to a real PBKDF2 + // H2 on a separate instance; hand validateUser the stale (bcrypt) view. + const concurrentHash = await hashPassword('Rotated#Pass88x'); + const databaseRow = await User.findByPk(seeded.id); + await databaseRow?.update( + { encryptedPassword: concurrentHash }, + { silent: true }, + ); + vi.spyOn(usersService, 'findByEmail').mockResolvedValueOnce(seeded); + + const result = await authnService.validateUser(email, password); + + expect(result?.id).toBe(seeded.id); // stale-but-valid login still succeeds + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword).toBe(concurrentHash); // H2 survived + }); + + it('logs and still succeeds the login when the rehash write fails (§7)', async () => { + expect.assertions(3); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(usersService, 'updateEncryptedPassword').mockRejectedValueOnce( + new Error('database unavailable'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(seeded.id); + expect(logSpy).toHaveBeenCalled(); + }); + + it('never mutates the Sequelize instance with the new hash (the racing login save cannot carry it)', async () => { + expect.assertions(2); + const bcryptHash = await hash(password, 4); + const seeded = await seedUserWithStoredHash(bcryptHash); + + const result = await authnService.validateUser(email, password); + + // Returned instance still holds the ORIGINAL hash — we never assigned the + // new one, so updateLoginMetadata's un-awaited save cannot persist it. + expect(result?.encryptedPassword).toBe(bcryptHash); + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + }); + + it('returns null (the generic 401) and logs the user id when the result is requiresReset (FIPS-refused bcrypt)', async () => { + expect.assertions(3); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(seeded.id); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('runs the constant-work dummy and returns null for an absent user (timing mitigation)', async () => { + expect.assertions(2); + const verifySpy = vi.spyOn(passwordService, 'verify'); + + const result = await authnService.validateUser('ghost@nowhere.test', password); + + expect(result).toBeNull(); + // Same KDF cost a present user pays — an empty hash routes verifyPassword to + // its reject-with-constant-work path — so user-absent is timing-invisible. + expect(verifySpy).toHaveBeenCalledWith({ hash: '', password }); + }); + + // §11: the bounded KDF queue rejects with KdfOverloadedError when saturated. + // The ADR assigns the mapping to "the auth layer" — validateUser IS that layer + // for site 4. Unmapped, the error escapes as a 500 next to everyone else's 401, + // which is itself an enumeration oracle: under saturation the absent-user dummy + // consumes a KDF slot while a legacy bcrypt compare consumes none, separating + // "no such account" from "account still on bcrypt". + it('maps a saturated KDF queue to the generic failure, not a 500 (§11)', async () => { + expect.assertions(3); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new KdfOverloadedError('KDF queue is full'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + // Resolves null (-> LocalStrategy's generic 401). Must NOT reject. + await expect(authnService.validateUser(email, password)).resolves.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(seeded.id); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('maps a saturated KDF queue on the ABSENT-user path to the generic failure too (§11)', async () => { + expect.assertions(1); + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new KdfOverloadedError('KDF queue is full'), + ); + + await expect( + authnService.validateUser('ghost@nowhere.test', password), + ).resolves.toBeNull(); + }); + + it('rethrows a non-overload error — a real bug must not be silently swallowed', async () => { + expect.assertions(1); + await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new Error('unexpected failure'), + ); + + await expect(authnService.validateUser(email, password)).rejects.toThrow( + 'unexpected failure', + ); + }); + + it('leaves passwordChangedAt and forcePasswordChange unchanged after a rehash (§7 lifecycle)', async () => { + expect.assertions(3); + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const before = await User.findByPk(seeded.id); + const beforePwChanged = String(before?.passwordChangedAt); + const beforeForce = before?.forcePasswordChange; + + await authnService.validateUser(email, password); + + const after = await User.findByPk(seeded.id); + expect(after?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + expect(String(after?.passwordChangedAt)).toBe(beforePwChanged); + expect(after?.forcePasswordChange).toBe(beforeForce); + }); +}); diff --git a/apps/backend/src/authn/authn.service.ts b/apps/backend/src/authn/authn.service.ts index db2e9f3117..2b4ddc4d3c 100644 --- a/apps/backend/src/authn/authn.service.ts +++ b/apps/backend/src/authn/authn.service.ts @@ -1,7 +1,6 @@ import { ForbiddenException, - Injectable, - UnauthorizedException + Injectable } from '@nestjs/common'; import {JwtService} from '@nestjs/jwt'; import {compare} from 'bcryptjs'; @@ -13,6 +12,12 @@ import ms from 'ms'; import winston from 'winston'; import {ApiKeyService} from '../apikeys/apikey.service'; import {ConfigService} from '../config/config.service'; +import { + KdfOverloadedError, + PasswordVerifyResult, + verifyPassword +} from '../crypto/password'; +import {PasswordService} from '../crypto/password.service'; import {Group} from '../groups/group.model'; import {limitJWTTime} from '../token/token.providers'; import {CreateUserDto} from '../users/dto/create-user.dto'; @@ -40,22 +45,105 @@ export class AuthnService { private readonly apiKeyService: ApiKeyService, private readonly configService: ConfigService, private readonly usersService: UsersService, - private readonly jwtService: JwtService + private readonly jwtService: JwtService, + private readonly passwordService: PasswordService ) {} + /** + * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is + * saturated, and the ADR assigns the mapping to "the auth layer" — this is + * that layer for site 4. Left unmapped the error escapes as a 500 alongside + * everyone else's 401, which is itself an enumeration oracle: under + * saturation the absent-user dummy consumes a KDF slot while a legacy + * `bcryptjs.compare` consumes none, separating "no such account" from + * "account still on bcrypt". Returns null on overload (caller fails + * generically); anything else is a real bug and propagates. + */ + private async verifyOrGenericFailure( + arguments_: {hash: string; password: string}, + userId?: string + ): Promise { + try { + return await this.passwordService.verify(arguments_); + } catch (error) { + if (error instanceof KdfOverloadedError) { + this.logger.info({ + message: `Password verification rejected — KDF queue saturated${ + userId === undefined ? '' : ` for User` + }; returning the generic authentication failure.` + }); + return null; + } + throw error; + } + } + async validateUser(email: string, password: string): Promise { let user: User; try { user = await this.usersService.findByEmail(email); } catch { - throw new UnauthorizedException('Incorrect Username or Password'); + // Absent-user timing mitigation (ADR-006 Risks). Pay the same constant- + // work KDF cost a present user's verify would — an empty hash routes + // verifyPassword to its reject-with-constant-work path — so user-exists + // and user-absent are indistinguishable by timing. Then fail generically + // (LocalStrategy maps a null return to the same 401 as any failure). + await this.verifyOrGenericFailure({hash: '', password}); + return null; } - if (user && (await compare(password, user.encryptedPassword))) { - this.usersService.updateLoginMetadata(user); - return user; - } else { + + const result = await this.verifyOrGenericFailure( + {hash: user.encryptedPassword, password}, + user.id + ); + if (result === null) { + // KDF queue saturated — already logged; fail generically (§11). + return null; + } + const {valid, needsRehash, requiresReset} = result; + + if (requiresReset === true) { + // §3 refuse path: a bcrypt credential encountered under FIPS mode. + // verifyPassword already paid the constant-work cost. Surface NOTHING + // distinct to the caller (Risks: enumeration oracle) — LocalStrategy maps + // this null to the same generic 401 as any other failure — but record it + // server-side so an operator can see who still needs to migrate. + this.logger.info({ + message: `User presented a non-FIPS (bcrypt) credential; login refused under FIPS mode. A password reset is required.` + }); return null; } + + if (!valid) { + return null; + } + + if (needsRehash) { + // §7 lazy rehash via compare-and-swap. The narrow writer takes the user + // id and the ORIGINAL stored hash as the CAS predicate and NEVER mutates + // this instance — so the un-awaited updateLoginMetadata save below cannot + // carry the new hash outside the predicate and silently revert a + // concurrent password change. A failed or lost (0-row) rehash must never + // fail an otherwise successful login. + const originalHash = user.encryptedPassword; + try { + const newHash = await this.passwordService.hash(password); + await this.usersService.updateEncryptedPassword( + user.id, + originalHash, + newHash + ); + } catch (error) { + this.logger.info({ + message: `Lazy password rehash failed for User; login still succeeded: ${ + error instanceof Error ? error.message : String(error) + }` + }); + } + } + + this.usersService.updateLoginMetadata(user); + return user; } async validateApiKey(apikey: string): Promise { @@ -106,7 +194,12 @@ export class AuthnService { try { user = await this.usersService.findByEmail(email); } catch { - const randomPass = crypto.randomBytes(128).toString('hex'); + // ADR-006 §6: 32 bytes → 64 hex chars = 256 bits of entropy for a + // credential that is never used to log in. Kept well under the 128-char + // PASSWORD_MAX_LENGTH so external-auth provisioning obeys the SAME hash- + // path length cap as every other create() — an exemption for these users + // would be a bypass waiting to be misused. + const randomPass = crypto.randomBytes(32).toString('hex'); const createUser: CreateUserDto = { email: email, password: randomPass, @@ -200,19 +293,21 @@ export class AuthnService { } async testPassword( + this: void, updateUserDto: {currentPassword?: string}, user: User ): Promise { - try { - if ( - !(await compare( - updateUserDto.currentPassword || '', - user.encryptedPassword - )) - ) { - throw new ForbiddenException('Current password is incorrect'); - } - } catch { + // Site 6 (ADR-006 §4): MUST stay `this`-free — users.service.ts calls + // this method UNBOUND via AuthnService.prototype.testPassword(...), and + // UsersService cannot inject AuthnService (circular). `this: void` makes + // that constraint COMPILER-enforced: any future `this.` access in this + // body is a type error. The pure verifyPassword handles PBKDF2 + legacy + // bcrypt and never throws on malformed input, so no try/catch is needed. + const {valid} = await verifyPassword({ + hash: user.encryptedPassword, + password: updateUserDto.currentPassword || '' + }); + if (!valid) { throw new ForbiddenException('Current password is incorrect'); } } From ea229561235fdb091b4e9e40fff0254ffc759a25 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 8 Aug 2026 23:45:21 -0400 Subject: [PATCH 036/197] feat: hash the bootstrap administrator with PBKDF2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-006 section 4, site 8 (heimdall2-e25.16). This is the site that matters most for a fresh install. cmd.sh runs db:seed:all on every container start and the RPM path runs the same seeder, so while it called bcrypt.hashSync every new deployment provisioned its administrator — the highest-privilege account — with a bcrypt hash on day one, in a change whose entire purpose is to eliminate them. With ADMIN_USES_EXTERNAL_AUTH set it might never migrate. The seeder is CommonJS, runs outside Nest DI and outside the TypeScript build, so it requires the compiled pure function and awaits it. The require path carries the src segment — rootDir is inferred across src/, db/ and config/, so password.ts compiles to dist/src/crypto/password.js. cmd.sh runs under set -e, which makes a bad require a boot crash loop rather than a degraded seed; that is also why password.ts must stay dependency-free, so the inferred layout cannot shift. Verified live through sequelize-cli: a fresh seed writes $pbkdf2-sha512$ and a fresh install now reaches zero remaining bcrypt credentials. Authored by: Aaron Lippold --- .../20200514154327-create-administrator.js | 13 +- apps/backend/test/seeders.spec.ts | 114 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/backend/test/seeders.spec.ts diff --git a/apps/backend/seeders/20200514154327-create-administrator.js b/apps/backend/seeders/20200514154327-create-administrator.js index 9854982b27..d53d07e08c 100644 --- a/apps/backend/seeders/20200514154327-create-administrator.js +++ b/apps/backend/seeders/20200514154327-create-administrator.js @@ -1,5 +1,13 @@ 'use strict'; -const bcrypt = require('bcryptjs'); +// ADR-006 §4 site 8: the admin bootstrap must hash through the SINGLE +// FIPS-validated implementation, not bcrypt. This seeder is CommonJS, runs +// outside Nest DI and the TS build, and executes on every container start +// (cmd.sh runs db:seed:all), so it requires the COMPILED pure function. The +// `dist/src/` segment is load-bearing — nest build infers rootDir across +// src/db/config, emitting dist/src/crypto/password.js. `.sequelizerc` already +// depends on build output; a bad path is a boot crash loop under cmd.sh's +// `set -e`, not a degraded seed. +const {hashPassword} = require('../dist/src/crypto/password'); const crypto = require('crypto'); const dotenv = require('dotenv'); const fs = require('fs'); @@ -46,6 +54,7 @@ module.exports = { console.log('You should change this password on first login.'); } + const encryptedPassword = await hashPassword(password); return queryInterface.bulkInsert( 'Users', [ @@ -53,7 +62,7 @@ module.exports = { firstName: 'Admin', email: email, role: 'admin', - encryptedPassword: bcrypt.hashSync(password, 14), + encryptedPassword: encryptedPassword, creationMethod: adminUsesExternalAuth ? 'ldap' : 'local', passwordChangedAt: new Date(), forcePasswordChange: true, diff --git a/apps/backend/test/seeders.spec.ts b/apps/backend/test/seeders.spec.ts new file mode 100644 index 0000000000..a28bde99df --- /dev/null +++ b/apps/backend/test/seeders.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +type Seeder = { up: (queryInterface: FakeQueryInterface) => Promise }; + +// The seeder is CommonJS (sequelize-cli owns it), lives OUTSIDE the TS project, +// and requires '../dist/src/crypto/password' — so `yarn backend build` must +// have run (the Verification command does exactly that before test:ci). It is +// loaded via a runtime dynamic import (not a static ESM import of an +// out-of-project .js) and held on an object so the assignment is a property +// write, not a top-level rebind. `seeder()` reads it back. +const SEEDER_PATH = '../seeders/20200514154327-create-administrator.js'; +const loaded: { module?: Seeder } = {}; + +beforeAll(async () => { + loaded.module = (await import(SEEDER_PATH)) as Seeder; +}); + +type FakeQueryInterface = { + bulkInsert: ReturnType; + sequelize: { + query: ReturnType; + QueryTypes: { SELECT: string }; + }; +}; + +type InsertedAdmin = { + creationMethod: string; + email: string; + encryptedPassword: string; + forcePasswordChange: boolean; + role: string; +}; + +// A queryInterface whose admin-count query returns `adminCount`, capturing any +// bulkInsert so the seeded row can be inspected. +function fakeQueryInterface(adminCount: string): FakeQueryInterface { + const bulkInsert = vi.fn().mockResolvedValue(undefined); + const query = vi.fn().mockImplementation((sql: string) => { + if (sql.includes('COUNT')) { + return Promise.resolve([{ count: adminCount }]); + } + return Promise.resolve([{ result: 2 }]); + }); + return { bulkInsert, sequelize: { query, QueryTypes: { SELECT: 'SELECT' } } }; +} + +function insertedAdmin(qi: FakeQueryInterface): InsertedAdmin { + return qi.bulkInsert.mock.calls[0][1][0] as InsertedAdmin; +} + +function seeder(): Seeder { + if (loaded.module === undefined) { + throw new Error('seeder module not loaded'); + } + return loaded.module; +} + +describe('administrator bootstrap seeder (site 8)', () => { + // vi.stubEnv layers over process.env without manual bracket mutation; each + // test that needs a value stubs it, and unstub restores everything. The + // seeder merges process.env last, so a stub takes effect. (.env-ci sets no + // ADMIN_* keys, so the unset-default tests are clean without pre-clearing.) + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('stores an encryptedPassword with the $pbkdf2-sha512$ prefix on a clean DB', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$')).toBe( + true, + ); + }); + + it('awaits the hash — the stored value is a resolved string, not a Promise', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const stored = insertedAdmin(qi).encryptedPassword; + expect(typeof stored).toBe('string'); + expect(stored).not.toContain('[object Promise]'); + }); + + it('is idempotent — inserts nothing when an administrator already exists', async () => { + const qi = fakeQueryInterface('1'); + await seeder().up(qi); + expect(qi.bulkInsert).not.toHaveBeenCalled(); + }); + + it('defaults to local creationMethod when ADMIN_USES_EXTERNAL_AUTH is unset', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).creationMethod).toBe('local'); + }); + + it('honors ADMIN_USES_EXTERNAL_AUTH=true — creationMethod ldap, still a real hash', async () => { + vi.stubEnv('ADMIN_USES_EXTERNAL_AUTH', 'true'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const admin = insertedAdmin(qi); + expect(admin.creationMethod).toBe('ldap'); + // The placeholder password is still PBKDF2-hashed (never bcrypt). + expect(admin.encryptedPassword.startsWith('$pbkdf2-sha512$')).toBe(true); + }); + + it('uses ADMIN_EMAIL when provided, and forces a password change', async () => { + vi.stubEnv('ADMIN_EMAIL', 'boss@example.mil'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const admin = insertedAdmin(qi); + expect(admin.email).toBe('boss@example.mil'); + expect(admin.role).toBe('admin'); + expect(admin.forcePasswordChange).toBe(true); + }); +}); From 043a94405aae0d43f01ca70ba32943088d872ff2 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:20:37 -0400 Subject: [PATCH 037/197] =?UTF-8?q?feat:=20add=20the=20=C2=A712=20write=20?= =?UTF-8?q?gate,=20FIPS=20boot=20assertions,=20and=20marker=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure shared derivation (env explicit → marker-sticky → fresh-install-on), HashWriteGateService + seeder consume it; seeder plants the marker and hashes the bootstrap admin with PBKDF2 (bcrypt fallback refused under FIPS, V-222571). assertFipsMode + assertMarkerCompatible enforce coherence at boot. vitest pins PASSWORD_HASH_WRITE_ENABLED and test-DB isolation. Authored by: Aaron Lippold --- ...0810133411-create-hash-migration-marker.js | 57 ++++ .../20200514154327-create-administrator.js | 86 +++++- apps/backend/src/crypto/crypto.module.ts | 18 +- apps/backend/src/crypto/fips.spec.ts | 107 +++++++ apps/backend/src/crypto/fips.ts | 73 +++++ .../src/crypto/hash-migration-marker.model.ts | 58 ++++ .../backend/src/crypto/hash-write-decision.ts | 82 ++++++ .../crypto/hash-write-gate.service.spec.ts | 266 ++++++++++++++++++ .../src/crypto/hash-write-gate.service.ts | 132 +++++++++ .../src/crypto/password.service.spec.ts | 16 +- apps/backend/src/crypto/password.service.ts | 49 +++- apps/backend/src/main.ts | 12 + apps/backend/test/seeders.spec.ts | 84 +++++- apps/backend/vitest.config.ts | 6 + 14 files changed, 1029 insertions(+), 17 deletions(-) create mode 100644 apps/backend/migrations/20260810133411-create-hash-migration-marker.js create mode 100644 apps/backend/src/crypto/fips.spec.ts create mode 100644 apps/backend/src/crypto/fips.ts create mode 100644 apps/backend/src/crypto/hash-migration-marker.model.ts create mode 100644 apps/backend/src/crypto/hash-write-decision.ts create mode 100644 apps/backend/src/crypto/hash-write-gate.service.spec.ts create mode 100644 apps/backend/src/crypto/hash-write-gate.service.ts diff --git a/apps/backend/migrations/20260810133411-create-hash-migration-marker.js b/apps/backend/migrations/20260810133411-create-hash-migration-marker.js new file mode 100644 index 0000000000..1252e68f78 --- /dev/null +++ b/apps/backend/migrations/20260810133411-create-hash-migration-marker.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * ADR-006 §12 mechanism 2 — the durable hash-migration marker table. + * + * This migration creates the TABLE ONLY. The marker ROW is planted on the + * FIRST PBKDF2 write (§12's settled planting trigger: first-write, not + * install — a row planted here would record something untrue): on a fresh + * install that first write is the admin bootstrap seeder's (cmd.sh runs + * db:seed:all before the app boots), otherwise PasswordService.hash plants + * it. Readers of the marker: the write-gate derivation itself (sticky), the + * §12 mechanism-3 startup refusal (the application refuses to start when + * markerVersion exceeds the write epoch its code understands), and §17's + * authenticated /health detail. + * + * DECISION RECORD (card heimdall2-e25.21 decision point): markerVersion is a + * DEDICATED WRITE-EPOCH INTEGER owned by the crypto module + * (SUPPORTED_HASH_MARKER_VERSION, currently 1 = PBKDF2-PHC writes), NOT the + * package.json semver. Reasons: (1) the comparison's subject is + * write-semantics capability, not package identity — an RPM Release-only + * bump (2.13.0-1 -> 2.13.0-2) changes neither, and a same-code repackage + * must not trip the refusal; (2) the repo's package versions are unreliable + * for comparison (root package.json is 0.0.0, backend 2.13.0 vs frontend + * 2.13.1 skew); (3) semver strings compare wrong lexicographically + * ('2.13.0' < '2.9.9') and would need parsing that an integer does not. + */ +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.createTable('HashMigrationMarkers', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.BIGINT + }, + markerVersion: { + allowNull: false, + type: Sequelize.INTEGER + }, + pbkdf2WritesBeganAt: { + allowNull: false, + type: Sequelize.DATE + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: async (queryInterface, _Sequelize) => { + return queryInterface.dropTable('HashMigrationMarkers'); + } +}; diff --git a/apps/backend/seeders/20200514154327-create-administrator.js b/apps/backend/seeders/20200514154327-create-administrator.js index d53d07e08c..f56b233525 100644 --- a/apps/backend/seeders/20200514154327-create-administrator.js +++ b/apps/backend/seeders/20200514154327-create-administrator.js @@ -8,10 +8,48 @@ // depends on build output; a bad path is a boot crash loop under cmd.sh's // `set -e`, not a degraded seed. const {hashPassword} = require('../dist/src/crypto/password'); +const { + deriveHashWriteState, + SUPPORTED_HASH_MARKER_VERSION +} = require('../dist/src/crypto/hash-write-decision'); +const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const dotenv = require('dotenv'); const fs = require('fs'); +// ADR-006 §12: site 8 sits inside the rollout write gate's scope. The +// DECISION is the same compiled pure function the Nest gate service uses +// (hash-write-decision.js) — this file only supplies the DB probes it cannot +// inject. cmd.sh runs this seeder BEFORE the app's first boot, so on a fresh +// install THIS file performs the first PBKDF2 write — and therefore plants +// the §12 durable marker (first-write trigger, not install-time: no write, +// no marker). Without that planting, the app's first derivation would see +// the seeded admin as "existing users, no marker" and default the gate OFF +// forever (AC-review round-1 finding). +async function hashWriteDecision(queryInterface, envConfig) { + const explicitSetting = envConfig.PASSWORD_HASH_WRITE_ENABLED; + if (explicitSetting === 'true' || explicitSetting === 'false') { + return deriveHashWriteState({ + explicitSetting, + markerPresent: false, + usersPresent: false + }); + } + const markers = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "HashMigrationMarkers"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + const users = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "Users"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + return deriveHashWriteState({ + explicitSetting, + markerPresent: markers[0].count !== '0', + usersPresent: users[0].count !== '0' + }); +} + module.exports = { up: async (queryInterface, _Sequelize) => { const result = await queryInterface.sequelize.query( @@ -54,8 +92,28 @@ module.exports = { console.log('You should change this password on first login.'); } - const encryptedPassword = await hashPassword(password); - return queryInterface.bulkInsert( + let encryptedPassword; + let plantMarker = false; + if ((await hashWriteDecision(queryInterface, envConfig)).enabled) { + encryptedPassword = await hashPassword(password); + plantMarker = true; + } else { + // §12 rolling window: a pre-N pod must be able to read this admin + // credential, so fall back to bcrypt (cost 14, the historical + // parameter) — except under FIPS mode, where generating a bcrypt + // hash is itself a finding (V-222571): refuse loudly instead, the + // same coherence rule as PasswordService.hash. + if (crypto.getFips() === 1) { + throw new Error( + 'PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS ' + + 'mode: the admin bootstrap cannot generate a bcrypt fallback ' + + 'hash inside the validated boundary (V-222571). Enable PBKDF2 ' + + 'writes or disable FIPS mode.' + ); + } + encryptedPassword = await bcrypt.hash(password, 14); + } + await queryInterface.bulkInsert( 'Users', [ { @@ -72,6 +130,30 @@ module.exports = { ], {} ); + if (plantMarker) { + // §12 first-write planting: on a fresh install THIS was the first + // PBKDF2 write, and the app's later derivation reads the marker back + // (sticky). Idempotent — skip when a row for this epoch exists. + const planted = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "HashMigrationMarkers"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + if (planted[0].count === '0') { + await queryInterface.bulkInsert( + 'HashMigrationMarkers', + [ + { + markerVersion: SUPPORTED_HASH_MARKER_VERSION, + pbkdf2WritesBeganAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + } + } + return; } else { console.log('Administrator exists. Skipping creation.'); return queryInterface.sequelize.query('SELECT 1+1 AS result'); diff --git a/apps/backend/src/crypto/crypto.module.ts b/apps/backend/src/crypto/crypto.module.ts index bf02dc8704..9da602cc94 100644 --- a/apps/backend/src/crypto/crypto.module.ts +++ b/apps/backend/src/crypto/crypto.module.ts @@ -1,15 +1,27 @@ import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; import { ConfigModule } from '../config/config.module'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; import { PasswordService } from './password.service'; /** * ADR-006 §5. PasswordService needs ConfigService, and ConfigModule is NOT * @Global() in this app, so the import is required — not optional. Exported so * the call-site cards can inject PasswordService. + * + * §12: HashWriteGateService needs the durable-marker table and the Users + * table (the fresh-install probe), so this module registers both models — + * making CryptoModule self-contained: importing it is all a consumer (or a + * test module) needs. */ @Module({ - exports: [PasswordService], - imports: [ConfigModule], - providers: [PasswordService], + exports: [HashWriteGateService, PasswordService], + imports: [ + ConfigModule, + SequelizeModule.forFeature([HashMigrationMarker, User]), + ], + providers: [HashWriteGateService, PasswordService], }) export class CryptoModule {} diff --git a/apps/backend/src/crypto/fips.spec.ts b/apps/backend/src/crypto/fips.spec.ts new file mode 100644 index 0000000000..9b68e8b490 --- /dev/null +++ b/apps/backend/src/crypto/fips.spec.ts @@ -0,0 +1,107 @@ +import * as nodeCrypto from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { assertFipsMode } from './fips'; +import { PasswordHashError } from './password'; + +// Module-scope assertion patterns (prefer-static-regex). +const REFUSAL_MESSAGE = /FIPS_MODE=true.*getFips\(\).*fips-mode-setup --enable/sv; +const NO_FORCE_FIPS = /Do NOT use.*--force-fips/sv; +const UNSET_WARNING = /FIPS_MODE is not set.*NO FIPS assertion was performed/sv; +const INVALID_VALUE = /FIPS_MODE must be 'true' or 'false'/v; + +// Pass-through wrap so the default-seam tests can steer getFips and so the +// never-calls-setFips AC is mechanically observable. Every other node:crypto +// function goes to the real implementation via the spread. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getFips: vi.fn(actual.getFips), + setFips: vi.fn(actual.setFips), + }; +}); + +// ADR-006 §10: with --force-fips gone (wrong on RHEL), this assertion is the +// ONLY thing between us and silent non-FIPS operation — the GitLab Workhorse +// failure shape. Injectable getFips/logWarning so both FIPS states are +// testable in non-FIPS CI. +describe('assertFipsMode — §10 startup assertion', () => { + it('FIPS_MODE=true + getFips()===0 throws, naming FIPS_MODE and the RHEL host-FIPS remedy (never --force-fips)', () => { + expect.assertions(2); + expect(() => + assertFipsMode({ fipsMode: 'true', getFips: () => 0 }), + ).toThrow(REFUSAL_MESSAGE); + expect(() => + assertFipsMode({ fipsMode: 'true', getFips: () => 0 }), + ).toThrow(NO_FORCE_FIPS); + }); + + it('FIPS_MODE=true + getFips()===1 passes silently — no throw, no warning', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: 'true', getFips: () => 1, logWarning }); + expect(logWarning).not.toHaveBeenCalled(); + }); + + it('FIPS_MODE=false is a deliberate operator statement — no throw, no warning, and getFips is never consulted', () => { + expect.assertions(2); + const logWarning = vi.fn(); + const getFips = vi.fn(() => 0); + assertFipsMode({ fipsMode: 'false', getFips, logWarning }); + expect(logWarning).not.toHaveBeenCalled(); + expect(getFips).not.toHaveBeenCalled(); + }); + + it('FIPS_MODE unset logs the prominent no-assertion boot warning and does not throw (§10: silence is how Workhorse-class failures survive)', () => { + expect.assertions(2); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: undefined, getFips: () => 0, logWarning }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toMatch(UNSET_WARNING); + }); + + it('FIPS_MODE empty string behaves as unset — warning, no throw', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: '', getFips: () => 0, logWarning }); + expect(logWarning).toHaveBeenCalledTimes(1); + }); + + it('an invalid FIPS_MODE value throws at startup (§9: out-of-range config never clamps silently)', () => { + expect.assertions(1); + expect(() => assertFipsMode({ fipsMode: 'yes' })).toThrow( + PasswordHashError, + ); + }); + + it('the invalid-value message names the accepted values', () => { + expect.assertions(1); + expect(() => assertFipsMode({ fipsMode: 'enabled' })).toThrow( + INVALID_VALUE, + ); + }); + + it('the default getFips seam reads crypto.getFips (namespace import) — both outcomes exercised', () => { + expect.assertions(2); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(0); + expect(() => assertFipsMode({ fipsMode: 'true' })).toThrow( + REFUSAL_MESSAGE, + ); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + expect(() => assertFipsMode({ fipsMode: 'true' })).not.toThrow(); + }); + + it('NEVER calls crypto.setFips — under --force-fips it is a native CHECK() abort, not a throw (§10)', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: 'false', logWarning }); + assertFipsMode({ fipsMode: undefined, logWarning }); + assertFipsMode({ fipsMode: 'true', getFips: () => 1, logWarning }); + try { + assertFipsMode({ fipsMode: 'true', getFips: () => 0, logWarning }); + } catch { + // the refusal throw is the expected behavior under test elsewhere + } + expect(vi.mocked(nodeCrypto.setFips)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/crypto/fips.ts b/apps/backend/src/crypto/fips.ts new file mode 100644 index 0000000000..050789cf6f --- /dev/null +++ b/apps/backend/src/crypto/fips.ts @@ -0,0 +1,73 @@ +import * as nodeCrypto from 'node:crypto'; +import { createLogger, format, transports } from 'winston'; +import { PasswordHashError } from './password'; + +/** + * ADR-006 §10: the FIPS startup assertion. With --force-fips gone (Red Hat's + * Node rejects it — the RHEL model is host FIPS mode -> OpenSSL -> Node), + * this assertion is the ONLY thing between us and silent non-FIPS operation: + * GitLab's Workhorse shipped exactly that failure, fips.Enabled() returning + * false with no error. Exported and injectable because bootstrap() in main.ts + * is not exported and cannot be unit-tested. + * + * NEVER call crypto.setFips() here or anywhere: under --force-fips it + * triggers a native CHECK() that ABORTS the process — it does not throw. + * + * Operator error-family note (§10 — you will hit both): an + * ERR_OSSL_EVP_UNSUPPORTED error is an OpenSSL 3 LEGACY-PROVIDER problem, NOT + * a FIPS denial; "...disabled for FIPS" in an OpenSSL error IS a real FIPS + * denial. Do not conflate them when diagnosing a refused boot. + * + * `node:crypto` is a NAMESPACE import (§5): a destructured getFips compiles + * to a non-writable binding under swc, which would break the injectable seam. + */ + +const fipsLogger = createLogger({ + format: format.printf(info => `[FIPS]: ${String(info.message)}`), + transports: [new transports.Console()], +}); + +export type AssertFipsModeArguments = { + /** Raw FIPS_MODE value; undefined or '' means unset. */ + readonly fipsMode: string | undefined; + /** Injectable FIPS probe; defaults to the real crypto.getFips. */ + readonly getFips?: () => number; + /** Injectable warning sink; defaults to the module's winston logger. */ + readonly logWarning?: (message: string) => void; +}; + +/** + * Throws when FIPS_MODE=true but the OpenSSL provider reports FIPS inactive; + * warns LOUDLY when FIPS_MODE is unset (no assertion performed — §10's + * anti-Workhorse rule); silent for an explicit 'false' and for a satisfied + * 'true'. Invalid values throw per §9 (never clamp silently). + */ +export function assertFipsMode(arguments_: AssertFipsModeArguments): void { + const { + fipsMode, + getFips = nodeCrypto.getFips, + logWarning = (message: string): void => { + fipsLogger.warn({ message }); + }, + } = arguments_; + + if (fipsMode === undefined || fipsMode === '') { + logWarning( + 'FIPS_MODE is not set — NO FIPS assertion was performed at boot. If this host is supposed to run in FIPS mode, set FIPS_MODE=true so a silently non-FIPS OpenSSL provider refuses startup instead of running non-validated crypto (ADR-006 §10).', + ); + return; + } + if (fipsMode !== 'true' && fipsMode !== 'false') { + throw new PasswordHashError( + `FIPS_MODE must be 'true' or 'false' (got '${fipsMode}')`, + ); + } + if (fipsMode === 'false') { + return; + } + if (getFips() !== 1) { + throw new Error( + 'REFUSING TO START: FIPS_MODE=true but the OpenSSL provider reports FIPS is NOT active (getFips() returned 0). Running would silently use non-validated crypto. Remedy on RHEL: enable HOST FIPS mode — fips-mode-setup --enable and reboot — so OpenSSL and Node inherit it (ADR-006 §10). Do NOT use node --force-fips on RHEL: the platform Node rejects it (configure FIPS in OpenSSL instead).', + ); + } +} diff --git a/apps/backend/src/crypto/hash-migration-marker.model.ts b/apps/backend/src/crypto/hash-migration-marker.model.ts new file mode 100644 index 0000000000..74f76f1495 --- /dev/null +++ b/apps/backend/src/crypto/hash-migration-marker.model.ts @@ -0,0 +1,58 @@ +import { + AllowNull, + AutoIncrement, + Column, + CreatedAt, + DataType, + Model, + PrimaryKey, + Table, + UpdatedAt, +} from 'sequelize-typescript'; + +/** + * ADR-006 §12 mechanism 2: the durable marker recording that PBKDF2 writes + * have BEGUN on this database. Planted on the FIRST PBKDF2 write (§12's + * settled planting trigger — never at install or migration time, which would + * record something untrue): the admin bootstrap seeder's write on a fresh + * install, or PasswordService.hash otherwise. Readers: the write-gate + * derivation itself (sticky), the §12 mechanism-3 startup refusal + * (HashWriteGateService.assertMarkerCompatible), and §17's authenticated + * /health detail (e25.20). + * + * markerVersion is a dedicated write-epoch integer (see + * SUPPORTED_HASH_MARKER_VERSION in hash-write-decision.ts for the + * rationale over package.json's semver). + */ +// Decorator stacks below keep sequelize-typescript's REQUIRED order — the +// attribute modifiers first and @Column last (decorators apply bottom-up, so +// @Column must execute before @PrimaryKey/@AllowNull annotate the attribute; +// the library throws "@Column annotation is missing or annotation order is +// wrong" otherwise). perfectionist/sort-decorators wants them alphabetical, +// which the library rejects at runtime — correctness wins. +@Table +export class HashMigrationMarker extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @PrimaryKey + @AutoIncrement + @AllowNull(false) + @Column(DataType.BIGINT) + declare id: string; + + @AllowNull(false) + @Column(DataType.INTEGER) + declare markerVersion: number; + + @AllowNull(false) + @Column(DataType.DATE) + declare pbkdf2WritesBeganAt: Date; + + @UpdatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare updatedAt: Date; +} diff --git a/apps/backend/src/crypto/hash-write-decision.ts b/apps/backend/src/crypto/hash-write-decision.ts new file mode 100644 index 0000000000..ea0bd14722 --- /dev/null +++ b/apps/backend/src/crypto/hash-write-decision.ts @@ -0,0 +1,82 @@ +import { PasswordHashError } from './password'; + +/** + * ADR-006 §12 — the write-gate DECISION, extracted pure so the Nest service + * (hash-write-gate.service.ts) and the CommonJS admin seeder (site 8, which + * runs outside DI and requires the COMPILED dist/src/crypto output) share ONE + * implementation instead of a keep-in-sync copy. Dependency-free by the same + * §5 rule as password.ts. + * + * The write epoch this build understands. Epoch 1 = PBKDF2-PHC credential + * writes (§2). Bump ONLY when stored-credential write semantics change + * incompatibly; the §12 mechanism-3 startup refusal fires when a database's + * marker records a NEWER epoch than this constant. DECISION (card e25.21): a + * dedicated integer, NOT package.json's semver — an RPM Release-only bump + * changes neither write semantics nor this constant; the repo's package + * versions are unreliable comparison subjects (root 0.0.0, backend/frontend + * skew); integers compare without the lexicographic trap semver strings carry + * ('2.13.0' < '2.9.9'). Recorded in the marker table's migration. + */ +export const SUPPORTED_HASH_MARKER_VERSION = 1; + +export type HashWriteDecision = { + readonly enabled: boolean; + readonly reason: string; +}; + +export type HashWriteDecisionInput = { + /** Raw PASSWORD_HASH_WRITE_ENABLED value; undefined or '' means unset. */ + readonly explicitSetting: string | undefined; + /** A durable marker row exists — PBKDF2 writes already began. */ + readonly markerPresent: boolean; + /** The Users table has at least one row. */ + readonly usersPresent: boolean; +}; + +/** + * §9: out-of-range configuration throws at startup, never clamps silently. + */ +export function assertValidHashWriteSetting(raw: string | undefined): void { + if (raw !== undefined && raw !== '' && raw !== 'true' && raw !== 'false') { + throw new PasswordHashError( + `PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false' (got '${raw}')`, + ); + } +} + +/** + * §12 derivation (settled 2026-08-05): an explicit env value wins; otherwise + * the gate is ON when PBKDF2 writes have already begun on this database + * (marker present — sticky across restarts) or on a fresh install (empty + * Users table — no pre-N peer can exist), and OFF only on an upgrade, where + * a rolling window with pre-N pods is possible. + */ +export function deriveHashWriteState( + input: HashWriteDecisionInput, +): HashWriteDecision { + assertValidHashWriteSetting(input.explicitSetting); + if (input.explicitSetting === 'true') { + return { enabled: true, reason: 'PASSWORD_HASH_WRITE_ENABLED=true' }; + } + if (input.explicitSetting === 'false') { + return { enabled: false, reason: 'PASSWORD_HASH_WRITE_ENABLED=false' }; + } + if (input.markerPresent) { + return { + enabled: true, + reason: + 'durable marker present — PBKDF2 writes already began on this database', + }; + } + if (!input.usersPresent) { + return { + enabled: true, + reason: 'fresh install (empty Users table) — no pre-N peer can exist', + }; + } + return { + enabled: false, + reason: + 'upgrade default — existing users and no marker, so a rolling window with pre-N peers is possible', + }; +} diff --git a/apps/backend/src/crypto/hash-write-gate.service.spec.ts b/apps/backend/src/crypto/hash-write-gate.service.spec.ts new file mode 100644 index 0000000000..9ea26ee49d --- /dev/null +++ b/apps/backend/src/crypto/hash-write-gate.service.spec.ts @@ -0,0 +1,266 @@ +import * as nodeCrypto from 'node:crypto'; +import { KNOWN_GOOD_VECTORS } from '@heimdall/password-hash-vectors'; +import { getModelToken, SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { compare as bcryptCompare } from 'bcryptjs'; +import { Sequelize } from 'sequelize-typescript'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; +import { verifyPassword } from './password'; +import { PasswordService } from './password.service'; + +const PASSWORD = 'CorrectHorse15!x'; + +// Module-scope assertion patterns (prefer-static-regex). +const BCRYPT_COST_14_PREFIX = /^\$2b\$14\$/v; +const ENV_VALIDATION_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false'/v; +const FIPS_COHERENCE_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode/v; +const REFUSAL_MESSAGE = /REFUSING TO START.*epoch 2.*epoch 1.*[Rr]emedy/sv; + +// Pass-through wrap so the §3 coherence test can steer ONE getFips result — +// real host FIPS state cannot be entered in CI (§10: it is host-level). +// Every other node:crypto function (pbkdf2, randomBytes, timingSafeEqual) +// goes to the real implementation via the spread. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getFips: vi.fn(actual.getFips) }; +}); + +// ADR-006 §12: the rollout write gate. Real-DB harness (the derivation probes +// the Users table and the durable marker). Services are constructed MANUALLY +// per test — the derivation is boot-scoped and cached per instance, so a fresh +// instance per case is the only way to exercise both derivation outcomes. +describe('HashWriteGateService — §12 rollout write gate', () => { + let databaseService: DatabaseService; + let configService: ConfigService; + let markerModel: typeof HashMigrationMarker; + let userModel: typeof User; + let sequelize: Sequelize; + const priorEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + + function freshGate(): HashWriteGateService { + return new HashWriteGateService(markerModel, userModel, configService); + } + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + HashMigrationMarker, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + ], + providers: [ConfigService, DatabaseService], + }).compile(); + databaseService = module.get(DatabaseService); + configService = module.get(ConfigService); + markerModel = module.get( + getModelToken(HashMigrationMarker), + ); + userModel = module.get(getModelToken(User)); + sequelize = module.get(Sequelize); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + }); + + afterEach(() => { + if (priorEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorEnvironment; + } + }); + + it('when PASSWORD_HASH_WRITE_ENABLED=false: the gate reports writesEnabled=false and PasswordService.hash still produces a legacy-readable bcrypt credential', async () => { + expect.assertions(4); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const gate = freshGate(); + expect(await gate.writesEnabled()).toBe(false); + // §12 scope: a NEW credential written while the gate is off must stay + // readable by a pre-N pod — so hash() falls back to bcrypt, and the + // credential carries rehash debt for after the gate opens. + const passwordService = new PasswordService(configService, gate); + const hash = await passwordService.hash(PASSWORD); + expect(hash).toMatch(BCRYPT_COST_14_PREFIX); + const result = await verifyPassword({ hash, password: PASSWORD }); + expect(result.valid).toBe(true); + expect(result.needsRehash).toBe(true); + }); + + describe('§12 derivation — both ways, per the settled 2026-08-05 decision', () => { + it('env unset + empty Users table → ENABLED (fresh install, no pre-N peer can exist)', async () => { + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('env unset + existing users + no marker → DISABLED (upgrade default, rolling window possible)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-upgrade@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + expect(await freshGate().writesEnabled()).toBe(false); + }); + + it('env unset + existing users + marker present → ENABLED (PBKDF2 writes already began; sticky)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-sticky@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + await HashMigrationMarker.create({ + markerVersion: 1, + pbkdf2WritesBeganAt: new Date(), + }); + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('env true + existing users → ENABLED (explicit env wins over the upgrade default)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-env-wins@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'true'; + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('an invalid value throws at construction (§9: out-of-range config never clamps silently)', () => { + process.env.PASSWORD_HASH_WRITE_ENABLED = 'yes'; + expect(() => freshGate()).toThrow(ENV_VALIDATION_MESSAGE); + }); + }); + + describe('§12 the REAL fresh-install sequence — migrate, seed, then boot (AC-review round-1 finding)', () => { + it('after the admin seeder runs (its PBKDF2 write plants the marker), the first app boot derives ENABLED', async () => { + expect.assertions(3); + // cmd.sh runs db:migrate -> db:seed:all -> start, so the app's FIRST + // derivation happens with the seeded admin already in Users. Without + // the seeder planting the marker on its own (first) PBKDF2 write, the + // derivation would see users=1/markers=0 and return the upgrade + // default — leaving every fresh containerized install writing bcrypt + // forever. This drives the seeder through a REAL queryInterface. The + // path lives in a const so tsc does not demand declarations for the + // out-of-project CJS file (test/seeders.spec.ts's loading pattern). + const seederPath = '../../seeders/20200514154327-create-administrator.js'; + const seederModule = (await import( + seederPath, + )) as { up: (queryInterface: unknown) => Promise }; + await seederModule.up(sequelize.getQueryInterface()); + expect(await userModel.count()).toBe(1); + expect(await HashMigrationMarker.count()).toBe(1); + expect(await freshGate().writesEnabled()).toBe(true); + }); + }); + + describe('§12 durable marker — planted on the first PBKDF2 write only', () => { + it('the first PBKDF2 hash plants exactly one row {markerVersion: 1, pbkdf2WritesBeganAt}; a second hash does not duplicate it', async () => { + expect.assertions(4); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'true'; + const passwordService = new PasswordService(configService, freshGate()); + expect(await HashMigrationMarker.count()).toBe(0); + await passwordService.hash(PASSWORD); + const rows = await HashMigrationMarker.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0].markerVersion).toBe(1); + await passwordService.hash(PASSWORD); + expect(await HashMigrationMarker.count()).toBe(1); + }); + + it('a bcrypt fallback write (gate off) plants NOTHING — the marker must never record something untrue', async () => { + expect.assertions(2); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const passwordService = new PasswordService(configService, freshGate()); + const hash = await passwordService.hash(PASSWORD); + expect(hash).toMatch(BCRYPT_COST_14_PREFIX); + expect(await HashMigrationMarker.count()).toBe(0); + }); + }); + + describe('§12 mechanism 3 — startup refusal on a newer marker', () => { + it('refuses to start when the marker records a newer write epoch, naming the remedy', async () => { + expect.assertions(1); + await HashMigrationMarker.create({ + markerVersion: 2, + pbkdf2WritesBeganAt: new Date(), + }); + await expect(freshGate().assertMarkerCompatible()).rejects.toThrow( + REFUSAL_MESSAGE, + ); + }); + + it('starts normally when the marker matches the supported epoch', async () => { + await HashMigrationMarker.create({ + markerVersion: 1, + pbkdf2WritesBeganAt: new Date(), + }); + await expect( + freshGate().assertMarkerCompatible(), + ).resolves.toBeUndefined(); + }); + + it('starts normally when no marker exists (PBKDF2 writes never began)', async () => { + await expect( + freshGate().assertMarkerCompatible(), + ).resolves.toBeUndefined(); + }); + }); + + describe('§3 coherence — the bcrypt fallback is FIPS-gated', () => { + it('gate off + FIPS mode on → hash() refuses rather than generate bcrypt inside the validated boundary (V-222571)', async () => { + expect.assertions(1); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const passwordService = new PasswordService(configService, freshGate()); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + await expect(passwordService.hash(PASSWORD)).rejects.toThrow( + FIPS_COHERENCE_MESSAGE, + ); + }); + }); + + describe('§12(4) graceful degradation — the old verify path against a PBKDF2 hash', () => { + it('bcryptjs.compare returns a clean false (no throw) for every known-good PBKDF2 vector', async () => { + // The rolling-deploy hazard §12(a) describes: a pre-N pod running + // bcryptjs.compare against a row a new pod rehashed. The contract lib's + // vectors stand in for those rows. + expect(KNOWN_GOOD_VECTORS.length).toBeGreaterThan(0); + for (const vector of KNOWN_GOOD_VECTORS) { + await expect( + bcryptCompare(vector.password, vector.hash), + ).resolves.toBe(false); + } + }); + }); +}); diff --git a/apps/backend/src/crypto/hash-write-gate.service.ts b/apps/backend/src/crypto/hash-write-gate.service.ts new file mode 100644 index 0000000000..95de98bd71 --- /dev/null +++ b/apps/backend/src/crypto/hash-write-gate.service.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { + assertValidHashWriteSetting, + deriveHashWriteState, + HashWriteDecision, + SUPPORTED_HASH_MARKER_VERSION, +} from './hash-write-decision'; + +export { SUPPORTED_HASH_MARKER_VERSION } from './hash-write-decision'; + +/** + * ADR-006 §12: the rollout write gate. One question — may this process write + * PBKDF2 credentials? — answered once per boot, plus the durable marker's + * planting and the mechanism-3 startup refusal. + * + * The gate covers ALL PBKDF2 writes (sites 1, 2, 7, 8 and both rehash + * paths): PasswordService.hash consults it for new-credential writes + * (falling back to bcrypt when off, so a pre-N pod can still read the row), + * and the rehash call sites consult writesEnabled() to skip persistence. + */ +@Injectable() +export class HashWriteGateService { + public logger = createLogger({ + format: format.printf(info => `[Hash Write Gate]: ${String(info.message)}`), + transports: [new transports.Console()], + }); + + private derivation?: HashWriteDecision; + private markerPlanted = false; + + constructor( + @InjectModel(HashMigrationMarker) + private readonly markerModel: typeof HashMigrationMarker, + @InjectModel(User) + private readonly userModel: typeof User, + private readonly configService: ConfigService, + ) { + // §9: out-of-range configuration throws at startup, never clamps. + assertValidHashWriteSetting( + this.configService.get('PASSWORD_HASH_WRITE_ENABLED'), + ); + } + + /** + * §12 mechanism 3 — the downgrade refusal, in the application because RPM + * %pre cannot fire on the downgrades it targets (on downgrade the OLDER + * package's %pre runs, built before the guard existed) and because this + * path also catches the pg_dump-restore hazard. Called from bootstrap() + * before the app starts listening. + */ + async assertMarkerCompatible(): Promise { + const newest = await this.markerModel.max('markerVersion'); + if (typeof newest === 'number' && newest > SUPPORTED_HASH_MARKER_VERSION) { + throw new Error( + `REFUSING TO START: this database records credential write epoch ${newest}, but this build understands only epoch ${SUPPORTED_HASH_MARKER_VERSION} — it was written to by a NEWER Heimdall release, and credentials written under epoch ${newest} would silently fail to verify here. Remedy: reinstall the newer release (or, after an accidental restore, restore a database backup taken under this release). Do not delete the HashMigrationMarkers row to force startup — that trades this loud refusal for silent authentication failures.`, + ); + } + } + + /** + * §12 planting trigger (settled 2026-08-05): the marker is planted on the + * FIRST PBKDF2 write — never at install or migration time, which would + * record something untrue. findOrCreate keyed on the epoch makes planting + * idempotent across pods. A planting failure is loud but must never fail + * the credential write it accompanies: the write itself goes to the same + * database, so a real outage surfaces there with its own error, while the + * marker's protection is only needed on a LATER downgrade. + */ + async plantMarker(): Promise { + if (this.markerPlanted) { + return; + } + try { + await this.markerModel.findOrCreate({ defaults: { pbkdf2WritesBeganAt: new Date() }, where: { markerVersion: SUPPORTED_HASH_MARKER_VERSION } }); + this.markerPlanted = true; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const message = `failed to plant the §12 hash-migration marker (epoch ${SUPPORTED_HASH_MARKER_VERSION}); downgrade protection is NOT recorded for this write: ${reason}`; + this.logger.error({ message }); + } + } + + /** + * §12 derivation (settled 2026-08-05): an explicit env value wins; + * otherwise the gate is ON when PBKDF2 writes have already begun on this + * database (marker present — sticky across restarts) or on a fresh install + * (empty Users table at first boot — no pre-N peer can exist), and OFF + * only on an upgrade, where a rolling window with pre-N pods is possible. + * + * Boot-scoped: derived once per service instance and cached — the §12 + * rolling-window question is about this process's release, which does not + * change while it runs. + */ + async writesEnabled(): Promise { + if (this.derivation === undefined) { + this.derivation = await this.derive(); + this.logger.info({ + message: `PBKDF2 writes ${ + this.derivation.enabled ? 'ENABLED' : 'DISABLED' + } — ${this.derivation.reason}`, + }); + } + return this.derivation.enabled; + } + + private async derive(): Promise { + const explicitSetting = this.configService.get( + 'PASSWORD_HASH_WRITE_ENABLED', + ); + if (explicitSetting === 'true' || explicitSetting === 'false') { + // An explicit setting decides alone — no DB probes (the manual test + // constructions with unregistered model classes rely on this). + return deriveHashWriteState({ + explicitSetting, + markerPresent: false, + usersPresent: false, + }); + } + const hasMarker = (await this.markerModel.count()) > 0; + const hasUsers = (await this.userModel.count()) > 0; + return deriveHashWriteState({ + explicitSetting, + markerPresent: hasMarker, + usersPresent: hasUsers, + }); + } +} diff --git a/apps/backend/src/crypto/password.service.spec.ts b/apps/backend/src/crypto/password.service.spec.ts index 6e13919e9b..042ce3c885 100644 --- a/apps/backend/src/crypto/password.service.spec.ts +++ b/apps/backend/src/crypto/password.service.spec.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; import { configureKdfLimiter, hashPassword, @@ -22,10 +25,19 @@ const KDF_CONCURRENCY_NAMED = /PASSWORD_KDF_CONCURRENCY/v; function serviceWith( values: Record, ): PasswordService { - const lookup = new Map(Object.entries(values)); + const lookup = new Map( + Object.entries({ PASSWORD_HASH_WRITE_ENABLED: 'true', ...values }), + ); const config = new ConfigService(); vi.spyOn(config, 'get').mockImplementation((key: string) => lookup.get(key)); - return new PasswordService(config); + // §12: writes explicitly enabled and marker planting stubbed — the gate's + // own behavior is hash-write-gate.service.spec.ts's subject, and this spec + // must never reach for a database. The model classes are passed + // unregistered; with an explicit env value the derivation never queries + // them, and the plantMarker spy (not a cast) keeps the real types. + const gate = new HashWriteGateService(HashMigrationMarker, User, config); + vi.spyOn(gate, 'plantMarker').mockResolvedValue(undefined); + return new PasswordService(config, gate); } describe('PasswordService — §9 config binding + delegation', () => { diff --git a/apps/backend/src/crypto/password.service.ts b/apps/backend/src/crypto/password.service.ts index 2653b74c9f..a7a1753cee 100644 --- a/apps/backend/src/crypto/password.service.ts +++ b/apps/backend/src/crypto/password.service.ts @@ -1,5 +1,8 @@ +import * as nodeCrypto from 'node:crypto'; import { Injectable } from '@nestjs/common'; +import { hash as bcryptHashLegacy } from 'bcryptjs'; import { ConfigService } from '../config/config.service'; +import { HashWriteGateService } from './hash-write-gate.service'; import { configureKdfLimiter, hashPassword, @@ -52,7 +55,10 @@ export class PasswordService { private readonly iterations: number; private readonly maxLength: number; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly hashWriteGate: HashWriteGateService, + ) { this.algorithm = this.readAlgorithm(); this.iterations = this.readIntInRange( 'PASSWORD_HASH_ITERATIONS', @@ -81,19 +87,37 @@ export class PasswordService { * Hash a password using the configured algorithm and iterations. Enforces * the configured PASSWORD_MAX_LENGTH on this (hash) path only; the pure * function keeps its own absolute 128 cap as defense in depth. + * + * §12 rollout gate: while PBKDF2 writes are DISABLED (rolling-deploy + * window), a NEW credential must still be readable by a pre-N pod, so this + * falls back to bcrypt (cost 14, the historical parameter) and leaves + * rehash debt for after the gate opens. When writes are enabled, the first + * PBKDF2 hash plants the §12 durable marker. */ - hash(password: string): Promise { + async hash(password: string): Promise { if (typeof password === 'string' && password.length > this.maxLength) { - return Promise.reject( - new PasswordHashError( - `password must be at most ${this.maxLength} characters`, - ), + throw new PasswordHashError( + `password must be at most ${this.maxLength} characters`, ); } - return hashPassword(password, { + if (!(await this.hashWriteGate.writesEnabled())) { + // §3 / V-222571: bcrypt (pure JS, outside the validated module) must + // never GENERATE a hash while FIPS mode is active. Gate-off + FIPS-on + // is a self-contradictory deployment — §12's phase ordering enables + // FIPS only after cutover, when the gate is necessarily on. + if (nodeCrypto.getFips() === 1) { + throw new PasswordHashError( + 'PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode: a bcrypt fallback hash cannot be generated inside the validated boundary (V-222571). Enable PBKDF2 writes or disable FIPS mode.', + ); + } + return bcryptHashLegacy(password, 14); + } + const hashed = await hashPassword(password, { algorithm: this.algorithm, iterations: this.iterations, }); + await this.hashWriteGate.plantMarker(); + return hashed; } /** @@ -108,6 +132,17 @@ export class PasswordService { return verifyPassword(arguments_); } + /** + * §12: whether PBKDF2 credential writes are enabled for this process. + * The rehash call sites (sites 4 and 5) consult this to SKIP persistence + * while the gate is off — verifyPassword still reports needsRehash, but a + * rehash written during the rolling window would be unreadable by pre-N + * pods. Exposed here so callers need no direct gate dependency. + */ + writesEnabled(): Promise { + return this.hashWriteGate.writesEnabled(); + } + private readAlgorithm(): PasswordHashAlgorithm { const raw = this.configService.get('PASSWORD_HASH_ALGORITHM'); if (raw === undefined || raw === '') { diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 54768d316b..31f79d032a 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -11,6 +11,8 @@ import postgresSessionStore = require('connect-pg-simple'); import session = require('express-session'); import {AppModule} from './app.module'; import {ConfigService} from './config/config.service'; +import { assertFipsMode } from './crypto/fips'; +import { HashWriteGateService } from './crypto/hash-write-gate.service'; import {generateDefault} from './token/token.providers'; const line = '_______________________________________________\n'; @@ -30,6 +32,16 @@ const logger = winston.createLogger({ async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + // ADR-006 §10: assert host FIPS state before anything else — logic lives in + // the testable module; this call site stays one line. + assertFipsMode({ fipsMode: configService.get('FIPS_MODE') }); + // ADR-006 §12 mechanism 3: refuse to start against a database whose + // credential write epoch is newer than this build understands (a downgrade + // or a pg_dump restore from a newer system) — enforced here in the + // application because RPM %pre cannot fire on the downgrades it targets, + // and a container path has no scriptlet at all. The thrown error crashes + // bootstrap loudly with the operator remedy in the message. + await app.get(HashWriteGateService).assertMarkerCompatible(); app.set('query parser', 'extended'); app.enableShutdownHooks(); app.use(helmet()); diff --git a/apps/backend/test/seeders.spec.ts b/apps/backend/test/seeders.spec.ts index a28bde99df..590a46d83b 100644 --- a/apps/backend/test/seeders.spec.ts +++ b/apps/backend/test/seeders.spec.ts @@ -32,13 +32,24 @@ type InsertedAdmin = { }; // A queryInterface whose admin-count query returns `adminCount`, capturing any -// bulkInsert so the seeded row can be inspected. -function fakeQueryInterface(adminCount: string): FakeQueryInterface { +// bulkInsert so the seeded row can be inspected. `counts` feeds the §12 +// write-gate derivation queries the seeder runs (site 8): total Users and +// HashMigrationMarkers rows — both default to '0', the fresh-install shape. +function fakeQueryInterface( + adminCount: string, + counts: { markers?: string; users?: string } = {}, +): FakeQueryInterface { const bulkInsert = vi.fn().mockResolvedValue(undefined); const query = vi.fn().mockImplementation((sql: string) => { - if (sql.includes('COUNT')) { + if (sql.includes('HashMigrationMarkers')) { + return Promise.resolve([{ count: counts.markers ?? '0' }]); + } + if (sql.includes('COUNT') && sql.includes("role = 'admin'")) { return Promise.resolve([{ count: adminCount }]); } + if (sql.includes('COUNT')) { + return Promise.resolve([{ count: counts.users ?? '0' }]); + } return Promise.resolve([{ result: 2 }]); }); return { bulkInsert, sequelize: { query, QueryTypes: { SELECT: 'SELECT' } } }; @@ -55,6 +66,16 @@ function seeder(): Seeder { return loaded.module; } +// §9 validation message, shared by the gate service and the seeder's +// compiled decision function. +const ENV_VALIDATION_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false'/v; + +function markerInserts(qi: FakeQueryInterface): number { + return qi.bulkInsert.mock.calls.filter( + (call: unknown[]) => call[0] === 'HashMigrationMarkers', + ).length; +} + describe('administrator bootstrap seeder (site 8)', () => { // vi.stubEnv layers over process.env without manual bracket mutation; each // test that needs a value stubs it, and unstub restores everything. The @@ -111,4 +132,61 @@ describe('administrator bootstrap seeder (site 8)', () => { expect(admin.role).toBe('admin'); expect(admin.forcePasswordChange).toBe(true); }); + + // ADR-006 §12: site 8 is in the write gate's scope. The DECISION is the + // same compiled pure function the Nest gate uses (hash-write-decision.js); + // the seeder supplies the DB probes and — because cmd.sh runs it BEFORE the + // app's first boot — plants the durable marker when its own write is the + // first PBKDF2 write, so the app's later derivation stays enabled (sticky). + describe('§12 write gate (site 8)', () => { + it('upgrade shape — existing users, no marker, env unset → bcrypt fallback readable by pre-N pods, and NO marker planted', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '0', users: '5' }); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$2b$14$')).toBe( + true, + ); + expect(markerInserts(qi)).toBe(0); + }); + + it('marker present — PBKDF2 writes already began, so the admin hashes PBKDF2 even with existing users (no duplicate marker)', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '1', users: '5' }); + await seeder().up(qi); + expect( + insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$'), + ).toBe(true); + expect(markerInserts(qi)).toBe(0); + }); + + it('fresh install — the seeder performs the first PBKDF2 write and PLANTS the §12 marker', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '0', users: '0' }); + await seeder().up(qi); + expect( + insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$'), + ).toBe(true); + expect(markerInserts(qi)).toBe(1); + const markerCall = qi.bulkInsert.mock.calls.find( + (call: unknown[]) => call[0] === 'HashMigrationMarkers', + ) as [string, { markerVersion: number }[]]; + expect(markerCall[1][0].markerVersion).toBe(1); + }); + + it('explicit PASSWORD_HASH_WRITE_ENABLED=false wins — bcrypt even on an otherwise fresh DB', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', 'false'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$2b$14$')).toBe( + true, + ); + expect(markerInserts(qi)).toBe(0); + }); + + it('an invalid PASSWORD_HASH_WRITE_ENABLED throws (§9: never clamp silently) — same rule as the gate service', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', 'yes'); + const qi = fakeQueryInterface('0'); + await expect(seeder().up(qi)).rejects.toThrow(ENV_VALIDATION_MESSAGE); + }); + }); }); diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 250a0a9fd1..2106d99e46 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -3,6 +3,12 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { + // ADR-006 §12: the write gate's no-env derivation probes live DB state + // (marker row, Users count), which would make every suite's hashing + // behavior depend on truncation order. Tests therefore run with writes + // explicitly enabled; hash-write-gate.service.spec.ts manipulates + // process.env per case to exercise the derivation itself. + env: { PASSWORD_HASH_WRITE_ENABLED: 'true' }, hookTimeout: 20000, testTimeout: 20000, fileParallelism: false From b13c4a79e1bdebb4d372a0d4ebd94e52e69709c9 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:20:37 -0400 Subject: [PATCH 038/197] feat: migrate remaining bcrypt sites to PasswordService API-key hashing and user password writes go through the validated-module service; spec harnesses adopt the self-contained CryptoModule so crypto DI changes no longer ripple through test modules. Authored by: Aaron Lippold --- .../src/apikeys/apikey.service.spec.ts | 116 +++++++++++++++++- apps/backend/src/apikeys/apikey.service.ts | 15 ++- .../evaluation-tags.controller.spec.ts | 2 + .../src/evaluations/evaluations.module.ts | 2 + .../evaluations/evaluations.service.spec.ts | 2 + .../src/groups/groups.controller.spec.ts | 2 + .../backend/src/groups/groups.service.spec.ts | 2 + .../src/statistics/statistics.module.ts | 4 +- .../src/users/users.controller.spec.ts | 2 + apps/backend/src/users/users.module.ts | 2 + apps/backend/src/users/users.service.spec.ts | 94 +++++++++++++- apps/backend/src/users/users.service.ts | 22 +++- 12 files changed, 252 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/apikeys/apikey.service.spec.ts b/apps/backend/src/apikeys/apikey.service.spec.ts index e53b9264a4..33d5bd54f4 100644 --- a/apps/backend/src/apikeys/apikey.service.spec.ts +++ b/apps/backend/src/apikeys/apikey.service.spec.ts @@ -1,7 +1,18 @@ import {SequelizeModule} from '@nestjs/sequelize'; import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { verifyPassword } from '../crypto/password'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -39,7 +50,8 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { GroupEvaluation, Evaluation, EvaluationTag - ]) + ]), + CryptoModule ], providers: [ApiKeyService, ConfigService, DatabaseService] }).compile(); @@ -101,3 +113,103 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { expect(after?.type).toBe('user'); }); }); + +// ADR-006 §2: exact prefix — algorithm AND iteration count pinned. Module +// scope so the regex is compiled once. +const PHC_SHA512_600K_PREFIX = /^\$pbkdf2-sha512\$i=600000\$/v; + +// ADR-006 §4 site 7: create() hashes the JWT signature (and ONLY the +// signature — §11/Scope: changing what is hashed invalidates every existing +// key) through PasswordService into the ApiKeys.apiKey column. Own harness so +// this suite controls API_KEY_SECRET (jwt.sign throws on an empty secret). +describe('ApiKeyService.create (§4 site 7 — PBKDF2 hash of the JWT signature)', () => { + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + let owner: User; + const priorApiKeySecret = process.env.API_KEY_SECRET; + + beforeAll(async () => { + // AppConfig.get reads process.env first, live at each call. + process.env.API_KEY_SECRET = 'apikey-spec-secret'; + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ApiKeyService, ConfigService, DatabaseService], + }).compile(); + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + }); + + afterAll(async () => { + if (priorApiKeySecret === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = priorApiKeySecret; + } + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + owner = await User.create({ + creationMethod: 'local', + email: 'apikey-owner@example.com', + encryptedPassword: 'placeholder-never-verified-in-this-suite', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('stores the JWT signature as a PBKDF2 PHC hash that round-trips through verifyPassword', async () => { + expect.assertions(4); + const result = await apiKeyService.create(owner, { + currentPassword: 'unused-by-service-layer', + name: 'site-7-key', + }); + // The caller receives the full JWT; the DB holds only a signature hash. + expect(result.apiKey.split('.', 3)).toHaveLength(3); + const stored = await ApiKey.findByPk(result.id); + expect(stored?.apiKey).toMatch(PHC_SHA512_600K_PREFIX); + const verification = await verifyPassword({ + hash: stored?.apiKey ?? '', + password: result.apiKey.split('.', 3)[2], + }); + expect(verification.valid).toBe(true); + expect(verification.needsRehash).toBe(false); + }); + + it('persists the signature hash BEFORE create() resolves (the hash write is awaited)', async () => { + expect.assertions(2); + // Found defect fixed in this card: the second save() was un-awaited, so + // create() could resolve before the hash hit the DB — a client using the + // key immediately could 403, and a failed save became an unhandled + // rejection. The spy calls through and records settlement: a save still + // in its Postgres round trip is 'incomplete' when create() resolves, so + // an un-awaited write can never report 'fulfilled' here. + const saveSpy = vi.spyOn(ApiKey.prototype, 'save'); + const result = await apiKeyService.create(owner, { + currentPassword: 'unused-by-service-layer', + name: 'awaited-key', + }); + expect(saveSpy.mock.settledResults.map((entry) => entry.type)).toEqual([ + 'fulfilled', + 'fulfilled', + ]); + const stored = await ApiKey.findByPk(result.id); + expect(stored?.apiKey).toMatch(PHC_SHA512_600K_PREFIX); + }); +}); diff --git a/apps/backend/src/apikeys/apikey.service.ts b/apps/backend/src/apikeys/apikey.service.ts index d1374df743..690d0b2516 100644 --- a/apps/backend/src/apikeys/apikey.service.ts +++ b/apps/backend/src/apikeys/apikey.service.ts @@ -1,9 +1,9 @@ import {Injectable, NotFoundException} from '@nestjs/common'; import {InjectModel} from '@nestjs/sequelize'; -import {hash} from 'bcryptjs'; import jwt from 'jsonwebtoken'; import {CreateApiKeyDto} from '../apikeys/dto/create-apikey.dto'; import {ConfigService} from '../config/config.service'; +import { PasswordService } from '../crypto/password.service'; import {Group} from '../groups/group.model'; import {User} from '../users/user.model'; import {ApiKey} from './apikey.model'; @@ -15,7 +15,8 @@ export class ApiKeyService { constructor( @InjectModel(ApiKey) private readonly apiKeyModel: typeof ApiKey, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly passwordService: PasswordService ) {} async count(): Promise { @@ -38,10 +39,14 @@ export class ApiKeyService { {keyId: newApiKey.id, createdAt: new Date()}, APIKeySecret ); - // Since BCrypt has a 72 byte limit only hash the JWT signature + // ADR-006 §4 site 7: PBKDF2 via the validated module, PHC output (§2). + // Only the JWT signature is hashed — originally because of bcrypt's + // 72-byte limit, kept because changing what is hashed invalidates every + // existing key (§11/Scope). The save is awaited: create() must not + // resolve before the hash is persisted (found defect fixed in e25.12). const JWTSignature = newJWT.split('.')[2]; - newApiKey.apiKey = await hash(JWTSignature, 14); - newApiKey.save(); + newApiKey.apiKey = await this.passwordService.hash(JWTSignature); + await newApiKey.save(); return {id: newApiKey.id, name: newApiKey.name, apiKey: newJWT}; } diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts index 07d944c015..b190b7611b 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts @@ -11,6 +11,7 @@ import { } from '../../test/constants/users-test.constant'; import {AuthzService} from '../authz/authz.service'; import {ConfigModule} from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {Evaluation} from '../evaluations/evaluation.model'; @@ -40,6 +41,7 @@ describe('EvaluationTagsController', () => { controllers: [EvaluationTagsController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Evaluation, diff --git a/apps/backend/src/evaluations/evaluations.module.ts b/apps/backend/src/evaluations/evaluations.module.ts index 5a59332dd2..8f0e2c3d29 100644 --- a/apps/backend/src/evaluations/evaluations.module.ts +++ b/apps/backend/src/evaluations/evaluations.module.ts @@ -1,6 +1,7 @@ import {Module} from '@nestjs/common'; import {SequelizeModule} from '@nestjs/sequelize'; import {ConfigModule} from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; @@ -23,6 +24,7 @@ import {EvaluationsService} from './evaluations.service'; GroupEvaluation ]), ConfigModule, + CryptoModule, DatabaseModule ], providers: [EvaluationsService, UsersService, GroupsService], diff --git a/apps/backend/src/evaluations/evaluations.service.spec.ts b/apps/backend/src/evaluations/evaluations.service.spec.ts index c69b3f0710..1b2bb6f423 100644 --- a/apps/backend/src/evaluations/evaluations.service.spec.ts +++ b/apps/backend/src/evaluations/evaluations.service.spec.ts @@ -13,6 +13,7 @@ import { import {GROUP_1} from '../../test/constants/groups-test.constant'; import {CREATE_USER_DTO_TEST_OBJ} from '../../test/constants/users-test.constant'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTagsModule} from '../evaluation-tags/evaluation-tags.module'; @@ -39,6 +40,7 @@ describe('EvaluationsService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Evaluation, diff --git a/apps/backend/src/groups/groups.controller.spec.ts b/apps/backend/src/groups/groups.controller.spec.ts index c208f9ce04..e681745f4b 100644 --- a/apps/backend/src/groups/groups.controller.spec.ts +++ b/apps/backend/src/groups/groups.controller.spec.ts @@ -14,6 +14,7 @@ import { } from '../../test/constants/users-test.constant'; import {AuthzService} from '../authz/authz.service'; import {ConfigModule} from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -43,6 +44,7 @@ describe('GroupsController', () => { controllers: [GroupsController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Group, diff --git a/apps/backend/src/groups/groups.service.spec.ts b/apps/backend/src/groups/groups.service.spec.ts index 74f4f47bf1..ed6661d896 100644 --- a/apps/backend/src/groups/groups.service.spec.ts +++ b/apps/backend/src/groups/groups.service.spec.ts @@ -12,6 +12,7 @@ import { CREATE_USER_DTO_TEST_OBJ_2 } from '../../test/constants/users-test.constant'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTagDto} from '../evaluation-tags/dto/evaluation-tag.dto'; @@ -36,6 +37,7 @@ describe('GroupsService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Group, diff --git a/apps/backend/src/statistics/statistics.module.ts b/apps/backend/src/statistics/statistics.module.ts index 6e92e8fca9..29c5a440fc 100644 --- a/apps/backend/src/statistics/statistics.module.ts +++ b/apps/backend/src/statistics/statistics.module.ts @@ -4,6 +4,7 @@ import {ApiKey} from '../apikeys/apikey.model'; import {ApiKeyService} from '../apikeys/apikey.service'; import {ConfigModule} from '../config/config.module'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; @@ -25,7 +26,8 @@ import {StatisticsService} from './statistics.service'; User, Group ]), - ConfigModule + ConfigModule, + CryptoModule ], providers: [ StatisticsService, diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 3a2b730d62..02aaebb2d8 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -25,6 +25,7 @@ import { import {AuthzService} from '../authz/authz.service'; import {ConfigModule} from '../config/config.module'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -54,6 +55,7 @@ describe('UsersController Unit Tests', () => { controllers: [UsersController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ User, diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index fbc3fa3fdc..687fee5cf3 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -2,6 +2,7 @@ import {forwardRef, Module} from '@nestjs/common'; import {SequelizeModule} from '@nestjs/sequelize'; import {AuthzModule} from '../authz/authz.module'; import {ConfigModule} from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; import {GroupsModule} from '../groups/groups.module'; import {User} from './user.model'; import {UsersController} from './users.controller'; @@ -12,6 +13,7 @@ import {UsersService} from './users.service'; SequelizeModule.forFeature([User]), AuthzModule, ConfigModule, + CryptoModule, forwardRef(() => GroupsModule) ], providers: [UsersService], diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 58555e4811..f0c1c20275 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -30,6 +30,7 @@ import { DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE, UPDATE_USER_DTO_TEST_OBJ, + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, UPDATE_USER_DTO_TEST_WITHOUT_EMAIL, UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, @@ -45,6 +46,7 @@ import { import {AuthzModule} from '../authz/authz.module'; import {AuthzService} from '../authz/authz.service'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import type * as PasswordCrypto from '../crypto/password'; import {hashPassword, verifyPassword} from '../crypto/password'; import {DatabaseModule} from '../database/database.module'; @@ -69,6 +71,11 @@ vi.mock('../crypto/password', async (importOriginal) => { return {...actual, verifyPassword: vi.fn(actual.verifyPassword)}; }); +// ADR-006 §2: exact prefix — algorithm AND iteration count pinned, never a +// loose $pbkdf2-sha* match (the ADR anti-pattern). Module scope so the regex +// is compiled once. +const PHC_SHA512_600K_PREFIX = /^\$pbkdf2-sha512\$i=600000\$/v; + describe('UsersService', () => { let authzService: AuthzService; let usersService: UsersService; @@ -88,7 +95,8 @@ describe('UsersService', () => { Evaluation, EvaluationTag ]), - AuthzModule + AuthzModule, + CryptoModule ], providers: [ AuthzService, @@ -129,6 +137,46 @@ describe('UsersService', () => { expect(user.role).toEqual(USER_ONE_DTO.role); }); + it('stores encryptedPassword as a PBKDF2 PHC string that round-trips through verifyPassword (ADR-006 §4 site 1)', async () => { + expect.assertions(3); + const created = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const stored = await User.findByPk(created.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + const result = await verifyPassword({ + hash: stored?.encryptedPassword ?? '', + password: CREATE_USER_DTO_TEST_OBJ.password, + }); + expect(result.valid).toBe(true); + // A freshly written hash must already be at policy — no rehash debt. + expect(result.needsRehash).toBe(false); + }); + + it('accepts the 64-char external-auth placeholder password (ADR-006 §6 — regression pairing with e25.11)', async () => { + expect.assertions(1); + // validateOrCreateUser feeds randomBytes(32).toString('hex') — exactly + // 64 chars — through this path; it must clear the 128 cap. + const placeholder = 'ab'.repeat(32); + const created = await usersService.create({ + ...CREATE_USER_DTO_TEST_OBJ, + password: placeholder, + passwordConfirmation: placeholder, + }); + const stored = await User.findByPk(created.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + }); + + it('rejects a password over the 128-char cap with BadRequestException (§6 approved range)', async () => { + expect.assertions(1); + const overCap = 'a'.repeat(129); + await expect( + usersService.create({ + ...CREATE_USER_DTO_TEST_OBJ, + password: overCap, + passwordConfirmation: overCap, + }), + ).rejects.toThrow(BadRequestException); + }); + it('should throw an error when missing the email field', async () => { expect.assertions(1); await expect( @@ -295,6 +343,50 @@ describe('UsersService', () => { ); }); + it('stores a changed password as PBKDF2 PHC and preserves the lifecycle semantics (ADR-006 §4 site 2)', async () => { + expect.assertions(5); + // Seed force-change ON so the clear inside update()'s password branch + // is observable — with the fixture's false baseline the assertion below + // would pass even if the clear were deleted (AC-review round-1 finding). + await user.update({ forcePasswordChange: true }, { silent: true }); + const preUpdate = await User.findByPk(user.id); + const pre = preUpdate?.passwordChangedAt; + await usersService.update( + user, + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + abacPolicy, + ); + const stored = await User.findByPk(user.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + const result = await verifyPassword({ + hash: stored?.encryptedPassword ?? '', + password: UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD.password ?? '', + }); + expect(result.valid).toBe(true); + expect(result.needsRehash).toBe(false); + // Genuine password change (users.service.ts:84-104 unchanged): the + // lifecycle fields still move — passwordChangedAt is stamped, and + // forcePasswordChange clears when the DTO does not re-raise it. + expect(String(stored?.passwordChangedAt)).not.toBe(String(pre)); + expect(stored?.forcePasswordChange).toBe(false); + }); + + it('rejects a changed password over the 128-char cap with BadRequestException (§6 approved range)', async () => { + expect.assertions(1); + const overCap = 'a'.repeat(129); + await expect( + usersService.update( + user, + { + ...UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + password: overCap, + passwordConfirmation: overCap, + }, + abacPolicy, + ), + ).rejects.toThrow(BadRequestException); + }); + // Users should be able to update their account without updating their email it('should update a user without updating email', async () => { expect.assertions(2); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index c4610a1e33..16d6b65fdd 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -6,13 +6,13 @@ import { NotFoundException } from '@nestjs/common'; import {InjectModel} from '@nestjs/sequelize'; -import {hash} from 'bcryptjs'; import {FindOptions} from 'sequelize'; import {v4} from 'uuid'; import {AuthnService} from '../authn/authn.service'; import {Action} from '../casl/casl-ability.factory'; import {ConfigService} from '../config/config.service'; import {verifyPassword} from '../crypto/password'; +import { PasswordService } from '../crypto/password.service'; import {GroupsService} from '../groups/groups.service'; import {CreateUserDto} from './dto/create-user.dto'; import {DeleteUserDto} from './dto/delete-user.dto'; @@ -25,7 +25,8 @@ export class UsersService { @InjectModel(User) private readonly userModel: typeof User, private readonly configService: ConfigService, - private readonly groupsService: GroupsService + private readonly groupsService: GroupsService, + private readonly passwordService: PasswordService ) {} async adminFindAllUsers(): Promise { @@ -64,7 +65,11 @@ export class UsersService { user.role = createUserDto.role; user.creationMethod = createUserDto.creationMethod; try { - user.encryptedPassword = await hash(createUserDto.password, 14); + // ADR-006 §4 site 1: PBKDF2 via the validated module, PHC output (§2). + // PasswordHashError (missing password, over-cap length) maps to 400. + user.encryptedPassword = await this.passwordService.hash( + createUserDto.password + ); } catch { throw new BadRequestException(); } @@ -87,7 +92,16 @@ export class UsersService { ) { throw new BadRequestException('You must change your password'); } else if (updateUserDto.password) { - userToUpdate.encryptedPassword = await hash(updateUserDto.password, 14); + try { + // ADR-006 §4 site 2: PBKDF2 via the validated module, PHC output + // (§2). Over-cap length (§6 approved range) maps to 400, matching + // create(); bcryptjs silently truncated at 72 bytes instead. + userToUpdate.encryptedPassword = await this.passwordService.hash( + updateUserDto.password + ); + } catch { + throw new BadRequestException(); + } userToUpdate.passwordChangedAt = new Date(); userToUpdate.forcePasswordChange = false; } From ebc5a785c633869f12a8513ea54b2b4af94afb00 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:20:52 -0400 Subject: [PATCH 039/197] =?UTF-8?q?feat:=20add=20=C2=A717=20rehash=20audit?= =?UTF-8?q?=20logging=20and=20the=20lifecycle=20regression=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five §17 events on both credential paths (user + API key): conversion, CAS-lost, oversized-skip, gate-skip, refusal. Lifecycle suite proves rehash touches only encryptedPassword and the §6 truncation is gone. Authored by: Aaron Lippold --- apps/backend/src/authn/authn.service.spec.ts | 462 ++++++++++++++- apps/backend/src/authn/authn.service.ts | 534 ++++++++++-------- .../src/authn/rehash-lifecycle.spec.ts | 293 ++++++++++ 3 files changed, 1047 insertions(+), 242 deletions(-) create mode 100644 apps/backend/src/authn/rehash-lifecycle.spec.ts diff --git a/apps/backend/src/authn/authn.service.spec.ts b/apps/backend/src/authn/authn.service.spec.ts index 34ed5a83e4..4ef45f351e 100644 --- a/apps/backend/src/authn/authn.service.spec.ts +++ b/apps/backend/src/authn/authn.service.spec.ts @@ -3,6 +3,7 @@ import type { JwtService } from '@nestjs/jwt'; import { SequelizeModule } from '@nestjs/sequelize'; import { Test } from '@nestjs/testing'; import { hash } from 'bcryptjs'; +import { sign } from 'jsonwebtoken'; import { afterAll, beforeAll, @@ -14,10 +15,14 @@ import { } from 'vitest'; import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; -import type { ApiKeyService } from '../apikeys/apikey.service'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ApiKeyService } from '../apikeys/apikey.service'; import { AuthzModule } from '../authz/authz.module'; import { AuthzService } from '../authz/authz.service'; import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HashMigrationMarker } from '../crypto/hash-migration-marker.model'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; import { hashPassword, KdfOverloadedError } from '../crypto/password'; import { PasswordService } from '../crypto/password.service'; import { DatabaseModule } from '../database/database.module'; @@ -116,6 +121,7 @@ describe('AuthnService.validateOrCreateUser — external-auth placeholder (ADR-0 EvaluationTag, ]), AuthzModule, + CryptoModule, ], providers: [ AuthzService, @@ -153,7 +159,6 @@ describe('AuthnService.validateOrCreateUser — external-auth placeholder (ADR-0 }); it('provisions an external-auth user with a 64-char (256-bit) placeholder and persists the record', async () => { - expect.assertions(7); const email = 'ext-oauth-user@example.com'; // Spy CALLS THROUGH — the real create() hashes + writes to the DB, so this // is a genuine end-to-end provisioning. The plaintext placeholder is @@ -207,6 +212,7 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si EvaluationTag, ]), AuthzModule, + CryptoModule, ], providers: [ AuthzService, @@ -219,9 +225,7 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si usersService = module.get(UsersService); databaseService = module.get(DatabaseService); - passwordService = new PasswordService( - module.get(ConfigService), - ); + passwordService = module.get(PasswordService); // Same circular-import reason as the validateOrCreateUser block: construct // AuthnService directly. Only usersService, passwordService and the logger @@ -259,7 +263,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si } it('rehashes a valid bcrypt login (FIPS off) to $pbkdf2- via the CAS writer', async () => { - expect.assertions(3); const seeded = await seedUserWithStoredHash(await hash(password, 4)); const result = await authnService.validateUser(email, password); @@ -270,8 +273,93 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); }); + it('a successful login rehash emits one info log with userId, from bcrypt, to pbkdf2-sha512, and iterations (§17)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`User`); + expect(logged.message).toContain('bcrypt'); + expect(logged.message).toContain('pbkdf2-sha512'); + // §17's field list ends at the iteration COUNT — never the password, + // hash, or salt. 600000 is the suite's configured iteration default. + expect(logged.message).toContain('600000'); + }); + + it('the rehash log NEVER carries the password or any hash/salt material (§17 anti-pattern)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + await authnService.validateUser(email, password); + + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).not.toContain(password); + const reloaded = await User.findByPk(seeded.id); + // The stored PHC string (salt + key material) must be absent; its salt + // segment alone is enough to prove leakage, so check the whole string. + expect(logged.message).not.toContain(reloaded?.encryptedPassword ?? ''); + }); + + it('skips the §7 rehash while the §12 write gate is off — login succeeds, the stored bcrypt hash is untouched, and the skip is logged', async () => { + const bcryptHash = await hash(password, 4); + const seeded = await seedUserWithStoredHash(bcryptHash); + const priorGateEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Fresh gate + service chain: the §12 derivation is boot-scoped and the + // suite's DI singletons already cached writes-enabled. With an explicit + // env value the gate never queries its models, so the classes are + // passed unregistered (password.service.spec's pattern). + const gateConfig = new ConfigService(); + const gateOff = new HashWriteGateService( + HashMigrationMarker, + User, + gateConfig, + ); + const authnOff = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + new PasswordService(gateConfig, gateOff), + ); + const logSpy = vi + .spyOn(authnOff.logger, 'info') + .mockReturnValue(authnOff.logger); + + const result = await authnOff.validateUser(email, password); + + expect(result?.id).toBe(seeded.id); + const reloaded = await User.findByPk(seeded.id); + // needsRehash was true, but persistence must wait for the gate: the + // stored credential stays byte-identical bcrypt, readable by a pre-N + // pod during the §12 rolling window. + expect(reloaded?.encryptedPassword).toBe(bcryptHash); + expect(reloaded?.encryptedPassword.startsWith('$2b$')).toBe(true); + // §17: the gate-skipped rehash is still an event — the operator's only + // sign that migration debt is accruing behind a closed gate. + const gateSkipMessage = expect.stringContaining('writes are disabled'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: gateSkipMessage }), + ); + } finally { + if (priorGateEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorGateEnvironment; + } + } + }); + it('does not revert a concurrent password change — the in-flight rehash CAS loses (0 affected)', async () => { - expect.assertions(2); const seeded = await seedUserWithStoredHash(await hash(password, 4)); // The concurrent password change (H2) lands in the DB after this login's // findByEmail read but before its CAS write. Move the DB to a real PBKDF2 @@ -292,7 +380,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si }); it('logs and still succeeds the login when the rehash write fails (§7)', async () => { - expect.assertions(3); const seeded = await seedUserWithStoredHash(await hash(password, 4)); vi.spyOn(usersService, 'updateEncryptedPassword').mockRejectedValueOnce( new Error('database unavailable'), @@ -305,11 +392,14 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si expect(result).not.toBeNull(); expect(result?.id).toBe(seeded.id); - expect(logSpy).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + const failureMessage = expect.stringContaining('rehash failed'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: failureMessage }), + ); }); it('never mutates the Sequelize instance with the new hash (the racing login save cannot carry it)', async () => { - expect.assertions(2); const bcryptHash = await hash(password, 4); const seeded = await seedUserWithStoredHash(bcryptHash); @@ -322,30 +412,63 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); }); - it('returns null (the generic 401) and logs the user id when the result is requiresReset (FIPS-refused bcrypt)', async () => { - expect.assertions(3); + it('returns null (the generic 401) and WARNS with the user id when the result is requiresReset (FIPS-refused bcrypt, §17 operator-actionable)', async () => { const seeded = await seedUserWithStoredHash(await hash(password, 4)); vi.spyOn(passwordService, 'verify').mockResolvedValueOnce({ needsRehash: false, requiresReset: true, valid: false, }); - const logSpy = vi - .spyOn(authnService.logger, 'info') + const warnSpy = vi + .spyOn(authnService.logger, 'warn') .mockReturnValue(authnService.logger); const result = await authnService.validateUser(email, password); expect(result).toBeNull(); - expect(logSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); const idInMessage = expect.stringContaining(seeded.id); - expect(logSpy).toHaveBeenCalledWith( + expect(warnSpy).toHaveBeenCalledWith( expect.objectContaining({ message: idInMessage }), ); }); + it('logs the CAS-lost rehash at info as a skip — §7 benign case is still an event', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(usersService, 'updateEncryptedPassword').mockResolvedValueOnce(0); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`User`); + expect(logged.message).toContain('compare-and-swap lost'); + }); + + it('WARNS and leaves the login unaffected when an oversized password reaches the rehash path (§9 skip)', async () => { + // Legacy bcrypt predates the §6 cap, so a stored credential for a + // 150-char password is realistic; bcrypt verifies it (truncating at + // byte 72) but the PBKDF2 hash path rejects it — §9: skip and log. + const oversized = 'Ov3r!'.repeat(30); + await seedUserWithStoredHash(await hash(oversized, 4)); + const warnSpy = vi + .spyOn(authnService.logger, 'warn') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, oversized); + + expect(result).not.toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const [logged] = warnSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain('cannot be hashed under current policy'); + expect(logged.message).not.toContain(oversized); + }); + it('runs the constant-work dummy and returns null for an absent user (timing mitigation)', async () => { - expect.assertions(2); const verifySpy = vi.spyOn(passwordService, 'verify'); const result = await authnService.validateUser('ghost@nowhere.test', password); @@ -363,7 +486,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si // consumes a KDF slot while a legacy bcrypt compare consumes none, separating // "no such account" from "account still on bcrypt". it('maps a saturated KDF queue to the generic failure, not a 500 (§11)', async () => { - expect.assertions(3); const seeded = await seedUserWithStoredHash(await hash(password, 4)); vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( new KdfOverloadedError('KDF queue is full'), @@ -382,7 +504,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si }); it('maps a saturated KDF queue on the ABSENT-user path to the generic failure too (§11)', async () => { - expect.assertions(1); vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( new KdfOverloadedError('KDF queue is full'), ); @@ -393,7 +514,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si }); it('rethrows a non-overload error — a real bug must not be silently swallowed', async () => { - expect.assertions(1); await seedUserWithStoredHash(await hash(password, 4)); vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( new Error('unexpected failure'), @@ -405,7 +525,6 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si }); it('leaves passwordChangedAt and forcePasswordChange unchanged after a rehash (§7 lifecycle)', async () => { - expect.assertions(3); const seeded = await seedUserWithStoredHash(await hash(password, 4)); const before = await User.findByPk(seeded.id); const beforePwChanged = String(before?.passwordChangedAt); @@ -419,3 +538,304 @@ describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 si expect(after?.forcePasswordChange).toBe(beforeForce); }); }); + +const API_KEY_SECRET = 'e2515-test-api-key-secret'; + +// Mirrors apikey.service.create(): sign {keyId, createdAt} with the secret, +// then store a hash of the JWT's signature segment (bcrypt has a 72-byte +// limit, which is why only the signature is hashed). +async function seedApiKey( + storedHashOfSignature: (signature: string) => Promise, +): Promise<{ apiKeyRow: ApiKey; ownerId: string; token: string }> { + const owner = await User.create({ + creationMethod: 'local', + email: `apikey-owner-${String(Date.now())}@example.com`, + encryptedPassword: await hash('irrelevant-for-this-path', 4), + role: 'user', + }); + const apiKeyRow = await ApiKey.create({ + name: 'e2515-test-key', + type: 'user', + userId: owner.id, + }); + const token = sign( + { createdAt: new Date(), keyId: apiKeyRow.id }, + API_KEY_SECRET, + ); + const signature = token.split('.', 3)[2]; + await apiKeyRow.update( + { apiKey: await storedHashOfSignature(signature) }, + { silent: true }, + ); + return { apiKeyRow, ownerId: owner.id, token }; +} + +describe('AuthnService.validateApiKey — verify + CAS rehash (ADR-006 §4 site 5, §7)', () => { + // Real-DB harness. API keys store a bcrypt hash of the JWT's SIGNATURE + // segment (apikey.service.create), so this path migrates exactly like + // validateUser but against ApiKeys.apiKey. §12: this path serves CI and the + // saf CLI — no human retries a 401 and a key cannot be recovered, only + // regenerated — so a failed rehash must never fail a valid key. + let authnService: AuthnService; + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + let passwordService: PasswordService; + + beforeAll(async () => { + vi.stubEnv('API_KEY_SECRET', API_KEY_SECRET); + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ApiKeyService, ConfigService, DatabaseService], + }).compile(); + + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + const configService = module.get(ConfigService); + passwordService = module.get(PasswordService); + + // validateApiKey uses apiKeyService, configService and passwordService + // only — usersService and jwtService are never touched on this path. + authnService = new AuthnService( + apiKeyService, + configService, + {} as UsersService, + {} as JwtService, + passwordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + vi.unstubAllEnvs(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + vi.restoreAllMocks(); + }); + + it('rehashes a valid bcrypt-stored key to $pbkdf2- via the CAS writer, using the same default parameters as passwords', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + // Same defaults as the password path (§11 records the iteration + // inefficiency for API keys but this card must NOT diverge from it). + expect(reloaded?.apiKey.startsWith('$pbkdf2-sha512$i=600000$')).toBe(true); + expect(reloaded?.apiKey).not.toBe(apiKeyRow.apiKey); + }); + + it('skips the §7 rehash while the §12 write gate is off — the key validates, the stored bcrypt hash is untouched, and the skip is logged', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const storedRow = await ApiKey.findByPk(apiKeyRow.id); + const storedBefore = storedRow?.apiKey; + const priorGateEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Same fresh-chain reasoning as the validateUser suite's gate-off test. + // The real ConfigService reads the suite's stubbed API_KEY_SECRET. + const gateConfig = new ConfigService(); + const gateOff = new HashWriteGateService( + HashMigrationMarker, + User, + gateConfig, + ); + const authnOff = new AuthnService( + apiKeyService, + gateConfig, + {} as UsersService, + {} as JwtService, + new PasswordService(gateConfig, gateOff), + ); + const logSpy = vi + .spyOn(authnOff.logger, 'info') + .mockReturnValue(authnOff.logger); + + const result = await authnOff.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + // §12: this path serves CI and the saf CLI on pre-N pods too — the + // stored hash must stay byte-identical bcrypt while the gate is off. + expect(reloaded?.apiKey).toBe(storedBefore); + expect(reloaded?.apiKey.startsWith('$2b$')).toBe(true); + // §17: gate-skipped rehashes log too (same event as the login path). + const gateSkipMessage = expect.stringContaining('writes are disabled'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: gateSkipMessage }), + ); + } finally { + if (priorGateEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorGateEnvironment; + } + } + }); + + it('gates on jwt.verify BEFORE any KDF work — a forged token never reaches the KDF (§11 unauthenticated-reachability guard)', async () => { + await seedApiKey(signature => hash(signature, 4)); + const forged = sign( + { createdAt: new Date(), keyId: '1' }, + 'not-the-real-secret', + ); + const verifySpy = vi.spyOn(passwordService, 'verify'); + + const result = await authnService.validateApiKey(forged); + + expect(result).toBeNull(); + // The expensive path must be unreachable without a valid signature. + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it('still validates the key when the rehash write fails (§12 — no human retries a CI 401)', async () => { + const { ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(apiKeyService, 'updateApiKeyHash').mockRejectedValueOnce( + new Error('database unavailable'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + expect(logSpy).toHaveBeenCalledTimes(1); + const failureMessage = expect.stringContaining('rehash failed'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: failureMessage }), + ); + }); + + it('never mutates the ApiKey instance with the new hash (apikey.service.ts:44 has the same un-awaited save trap)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const storedBefore = apiKeyRow.apiKey; + // Assert on the instance validateApiKey ACTUALLY works with — it fetches + // its own via findById, so asserting on the test's copy would prove + // nothing (it cannot change no matter what the service does). The spy + // calls through; mock.results holds the served instance. + const findByIdSpy = vi.spyOn(apiKeyService, 'findById'); + + await authnService.validateApiKey(token); + + const served = (await findByIdSpy.mock.results[0].value) as ApiKey; + // If the service assigned the new hash to this instance, the racing + // un-awaited save at apikey.service.ts:44 could persist it OUTSIDE the CAS + // predicate — the §7 revert. Both assertions fail if that ever happens. + expect(served.apiKey).toBe(storedBefore); + expect(served.changed()).toBe(false); + // ...while the DB itself did move to PBKDF2 through the CAS writer. + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + expect(reloaded?.apiKey.startsWith('$pbkdf2-')).toBe(true); + expect(storedBefore.startsWith('$2')).toBe(true); + }); + + it('refuses a FIPS-refused (requiresReset) key with the path generic failure and WARNS with the key id (§17 operator-actionable)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(passwordService, 'verify').mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + const warnSpy = vi + .spyOn(authnService.logger, 'warn') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(apiKeyRow.id); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('a successful key rehash emits one info log with apiKeyId, from bcrypt, to pbkdf2-sha512, and iterations (§17)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`ApiKey`); + expect(logged.message).toContain('bcrypt'); + expect(logged.message).toContain('pbkdf2-sha512'); + expect(logged.message).toContain('600000'); + }); + + it('logs the CAS-lost key rehash at info as a skip (§7 benign case is still an event)', async () => { + const { ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(apiKeyService, 'updateApiKeyHash').mockResolvedValueOnce(0); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain('compare-and-swap lost'); + }); + + it('returns null for a valid token whose stored hash does not match the signature', async () => { + const { apiKeyRow, token } = await seedApiKey(() => + hash('a-different-signature-entirely', 4), + ); + + const result = await authnService.validateApiKey(token); + + expect(result).toBeNull(); + // A failed verification must NOT rehash anything. + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + expect(reloaded?.apiKey.startsWith('$2')).toBe(true); + }); + + it('does not rehash a key already stored as PBKDF2 (no churn)', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hashPassword(signature), + ); + const rowBefore = await ApiKey.findByPk(apiKeyRow.id); + const storedBefore = rowBefore?.apiKey; + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const rowAfter = await ApiKey.findByPk(apiKeyRow.id); + expect(rowAfter?.apiKey).toBe(storedBefore); + }); +}); diff --git a/apps/backend/src/authn/authn.service.ts b/apps/backend/src/authn/authn.service.ts index 2b4ddc4d3c..f012137772 100644 --- a/apps/backend/src/authn/authn.service.ts +++ b/apps/backend/src/authn/authn.service.ts @@ -1,44 +1,46 @@ +import * as crypto from 'crypto'; import { ForbiddenException, - Injectable + Injectable, } from '@nestjs/common'; -import {JwtService} from '@nestjs/jwt'; -import {compare} from 'bcryptjs'; -import * as crypto from 'crypto'; -import jwt from 'jsonwebtoken'; +import { JwtService } from '@nestjs/jwt'; +import { verify } from 'jsonwebtoken'; import _ from 'lodash'; import moment from 'moment'; import ms from 'ms'; -import winston from 'winston'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {ConfigService} from '../config/config.service'; +import { createLogger, format, transports } from 'winston'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ConfigService } from '../config/config.service'; import { KdfOverloadedError, + PasswordHashError, PasswordVerifyResult, - verifyPassword + verifyPassword, } from '../crypto/password'; -import {PasswordService} from '../crypto/password.service'; -import {Group} from '../groups/group.model'; -import {limitJWTTime} from '../token/token.providers'; -import {CreateUserDto} from '../users/dto/create-user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; +import { PasswordService } from '../crypto/password.service'; +import { Group } from '../groups/group.model'; +import { limitJWTTime } from '../token/token.providers'; +import { CreateUserDto } from '../users/dto/create-user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; @Injectable() export class AuthnService { - private readonly line = '_______________________________________________\n'; + // unicorn/consistent-class-member-order (privates-first) and + // perfectionist/sort-classes (publics-first) are mutually exclusive on any + // mixed class — documented floor class (password.service.ts carries the + // same finding); perfectionist's order is kept. public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + private readonly line = '_______________________________________________\n'; + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String(info.timestamp)}] (Authn Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); constructor( @@ -46,149 +48,189 @@ export class AuthnService { private readonly configService: ConfigService, private readonly usersService: UsersService, private readonly jwtService: JwtService, - private readonly passwordService: PasswordService + private readonly passwordService: PasswordService, ) {} - /** - * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is - * saturated, and the ADR assigns the mapping to "the auth layer" — this is - * that layer for site 4. Left unmapped the error escapes as a 500 alongside - * everyone else's 401, which is itself an enumeration oracle: under - * saturation the absent-user dummy consumes a KDF slot while a legacy - * `bcryptjs.compare` consumes none, separating "no such account" from - * "account still on bcrypt". Returns null on overload (caller fails - * generically); anything else is a real bug and propagates. - */ - private async verifyOrGenericFailure( - arguments_: {hash: string; password: string}, - userId?: string - ): Promise { - try { - return await this.passwordService.verify(arguments_); - } catch (error) { - if (error instanceof KdfOverloadedError) { - this.logger.info({ - message: `Password verification rejected — KDF queue saturated${ - userId === undefined ? '' : ` for User` - }; returning the generic authentication failure.` - }); - return null; - } - throw error; + async login(user: { + email: string; + forcePasswordChange: boolean | undefined; + id: string; + role: string; + }): Promise<{ accessToken: string; userID: string }> { + const payload = { + email: user.email, + forcePasswordChange: user.forcePasswordChange, + role: user.role, + sub: user.id, + }; + // Users have their own JWT Secret to allow for session invalidation on sign out + const loginUser = await this.usersService.findById(user.id); + if ( + !loginUser.jwtSecret + || this.configService.get('ONE_SESSION_PER_USER')?.toLowerCase() === 'true' + ) { + // The new jwtSecret is assigned synchronously; only its persistence + // floats (legacy pattern — the token below signs with the new value). + void this.usersService.updateUserSecret(loginUser); } - } - - async validateUser(email: string, password: string): Promise { - let user: User; - try { - user = await this.usersService.findByEmail(email); - } catch { - // Absent-user timing mitigation (ADR-006 Risks). Pay the same constant- - // work KDF cost a present user's verify would — an empty hash routes - // verifyPassword to its reject-with-constant-work path — so user-exists - // and user-absent are indistinguishable by timing. Then fail generically - // (LocalStrategy maps a null return to the same 401 as any failure). - await this.verifyOrGenericFailure({hash: '', password}); - return null; + if (payload.forcePasswordChange || user.role === 'admin') { + // Admin sessions are only valid for 10 minutes, for regular users give them 10 minutes to (hopefully) change their password. + const expireTime = moment(new Date(Date.now() + ms('600s'))).format( + this.loggingTimeFormat, + ); + this.logger.info({ message: `New session for User expires at ${expireTime}` }); + return { + accessToken: this.jwtService.sign(payload, { + expiresIn: '600s', + secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret, + }), + userID: user.id, + }; } - - const result = await this.verifyOrGenericFailure( - {hash: user.encryptedPassword, password}, - user.id + const expiresIn = limitJWTTime( + this.configService.get('JWT_EXPIRE_TIME') || '60s', + false, ); - if (result === null) { - // KDF queue saturated — already logged; fail generically (§11). - return null; - } - const {valid, needsRehash, requiresReset} = result; + const expireTime = moment(new Date(Date.now() + expiresIn)).format( + this.loggingTimeFormat, + ); + this.logger.info({ message: `New session for User expires at ${expireTime}` }); + return { + accessToken: this.jwtService.sign(payload, { secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret }), + userID: user.id, + }; + } - if (requiresReset === true) { - // §3 refuse path: a bcrypt credential encountered under FIPS mode. - // verifyPassword already paid the constant-work cost. Surface NOTHING - // distinct to the caller (Risks: enumeration oracle) — LocalStrategy maps - // this null to the same generic 401 as any other failure — but record it - // server-side so an operator can see who still needs to migrate. - this.logger.info({ - message: `User presented a non-FIPS (bcrypt) credential; login refused under FIPS mode. A password reset is required.` - }); - return null; - } + splitName(fullName: string): { firstName: string; lastName: string } { + const nameArray = fullName.split(' '); + return { + firstName: nameArray[0], + lastName: nameArray.slice(1).join(' '), + }; + } + async testPassword( + this: void, + updateUserDto: { currentPassword?: string }, + user: User, + ): Promise { + // Site 6 (ADR-006 §4): MUST stay `this`-free — users.service.ts calls + // this method UNBOUND via AuthnService.prototype.testPassword(...), and + // UsersService cannot inject AuthnService (circular). `this: void` makes + // that constraint COMPILER-enforced: any future `this.` access in this + // body is a type error. The pure verifyPassword handles PBKDF2 + legacy + // bcrypt and never throws on malformed input, so no try/catch is needed. + const { valid } = await verifyPassword({ + hash: user.encryptedPassword, + password: updateUserDto.currentPassword || '', + }); if (!valid) { - return null; - } - - if (needsRehash) { - // §7 lazy rehash via compare-and-swap. The narrow writer takes the user - // id and the ORIGINAL stored hash as the CAS predicate and NEVER mutates - // this instance — so the un-awaited updateLoginMetadata save below cannot - // carry the new hash outside the predicate and silently revert a - // concurrent password change. A failed or lost (0-row) rehash must never - // fail an otherwise successful login. - const originalHash = user.encryptedPassword; - try { - const newHash = await this.passwordService.hash(password); - await this.usersService.updateEncryptedPassword( - user.id, - originalHash, - newHash - ); - } catch (error) { - this.logger.info({ - message: `Lazy password rehash failed for User; login still succeeded: ${ - error instanceof Error ? error.message : String(error) - }` - }); - } + throw new ForbiddenException('Current password is incorrect'); } - - this.usersService.updateLoginMetadata(user); - return user; } - async validateApiKey(apikey: string): Promise { + async validateApiKey(apikey: string): Promise { const APIKeySecret = this.configService.get('API_KEY_SECRET'); if (APIKeySecret) { try { - const jwtPayload = jwt.verify(apikey, APIKeySecret) as { - token: string; - keyId: string; + const jwtPayload = verify(apikey, APIKeySecret) as { createdAt: Date; + keyId: string; + token: string; }; - const JWTSignature = apikey.split('.')[2]; + const JWTSignature = apikey.split('.', 3)[2]; if (_.has(jwtPayload, 'keyId')) { const matchingKey = await this.apiKeyService.findById( - jwtPayload.keyId + jwtPayload.keyId, ); - if (await compare(JWTSignature, matchingKey.apiKey)) { - if (matchingKey.type === 'user') { - return matchingKey.user; - } else if (matchingKey.type === 'group') { - return matchingKey.group; - } else { - return null; - } - } else { + // Site 5 (§4). jwt.verify above has already gated this path, so the + // KDF is unreachable without a valid signature (§11) — that ordering + // is load-bearing and must not be relaxed. + const result = await this.verifyOrGenericFailure( + { hash: matchingKey.apiKey, password: JWTSignature }, + `ApiKey`, + ); + if (result === null) { return null; } - } else { - return null; + const { needsRehash, requiresReset, valid } = result; + + if (requiresReset === true) { + // §3 refuse path under FIPS. Same generic failure this path + // already returns for every other error (Risks: no distinct + // response), recorded server-side so an operator can find the + // keys that must be regenerated — a key cannot be recovered. + // Warn, not info: §17 treats operator-actionable states as + // warnings (a key can only be regenerated, never recovered). + const message = `ApiKey is stored as a non-FIPS (bcrypt) hash; validation refused under FIPS mode. The key must be regenerated.`; + this.logger.warn({ message }); + return null; + } + + if (!valid) { + return null; + } + + if (needsRehash && !(await this.passwordService.writesEnabled())) { + // §17: gate-skipped rehashes log too (same event as the login + // path — migration debt accruing behind the closed §12 gate). + this.logger.info({ message: `Lazy API-key rehash for ApiKey skipped: PBKDF2 writes are disabled (§12 gate); the debt is re-reported on the next validation after the gate opens` }); + } else if (needsRehash) { + // §7 CAS rehash, same shape as validateUser but against the + // ApiKeys.apiKey column, behind the same §12 write gate (skip + // while PBKDF2 writes are disabled — pre-N pods must keep + // reading this row). Never mutates the instance — the formerly + // un-awaited save at apikey.service.ts create() was the same + // trap as the login path's. §12: this path serves CI and the + // saf CLI, where no human retries a 401, so a failed rehash + // must never fail an otherwise valid key. + const originalHash = matchingKey.apiKey; + try { + const newHash = await this.passwordService.hash(JWTSignature); + const affected = await this.apiKeyService.updateApiKeyHash( + matchingKey.id, + originalHash, + newHash, + ); + // §17: the ONLY record that this key's credential converted — + // field list ends at the iteration count, never the key/hash/ + // salt. Read from the PHC string actually written. + const phcParts = newHash.split('$'); + const toFormat = phcParts[1] ?? 'unknown'; + const iterationCount = (phcParts[2] ?? '').replace('i=', ''); + if (affected === 0) { + this.logger.info({ message: `Lazy API-key rehash for ApiKey skipped: another writer updated the credential first (compare-and-swap lost — benign, §7)` }); + } else { + this.logger.info({ message: `ApiKey credential converted: from bcrypt to ${toFormat} at ${iterationCount} iterations` }); + } + } catch (error) { + const reason + = error instanceof Error ? error.message : String(error); + const message = `Lazy API-key rehash failed for ApiKey; validation still succeeded: ${reason}`; + this.logger.info({ message }); + } + } + + if (matchingKey.type === 'user') { + return matchingKey.user; + } + return matchingKey.type === 'group' ? matchingKey.group : null; } + return null; } catch { return null; } - } else { - throw new ForbiddenException( - 'API Keys have been disabled as the API-Key secret is not set' - ); } + throw new ForbiddenException( + 'API Keys have been disabled as the API-Key secret is not set', + ); } async validateOrCreateUser( email: string, firstName: string, lastName: string, - creationMethod: string + creationMethod: string, ): Promise { let user: User; try { @@ -200,18 +242,18 @@ export class AuthnService { // path length cap as every other create() — an exemption for these users // would be a bypass waiting to be misused. const randomPass = crypto.randomBytes(32).toString('hex'); - const createUser: CreateUserDto = { + const newUserDto: CreateUserDto = { + creationMethod: creationMethod, email: email, - password: randomPass, - passwordConfirmation: randomPass, firstName: firstName, lastName: lastName, organization: '', - title: '', + password: randomPass, + passwordConfirmation: randomPass, role: 'user', - creationMethod: creationMethod + title: '', }; - await this.usersService.create(createUser); + await this.usersService.create(newUserDto); user = await this.usersService.findByEmail(email); } @@ -221,94 +263,144 @@ export class AuthnService { if (user.firstName !== firstName || user.lastName !== lastName) { user.firstName = firstName; user.lastName = lastName; - user.save(); + void user.save(); } - this.usersService.updateLoginMetadata(user); + // §7-documented deliberate float: awaiting would serialize every + // login behind this write. + void this.usersService.updateLoginMetadata(user); } return user; } - async login(user: { - id: string; - email: string; - role: string; - forcePasswordChange: boolean | undefined; - }): Promise<{userID: string; accessToken: string}> { - const payload = { - email: user.email, - sub: user.id, - role: user.role, - forcePasswordChange: user.forcePasswordChange - }; - // Users have their own JWT Secret to allow for session invalidation on sign out - const loginUser = await this.usersService.findById(user.id); - if ( - !loginUser.jwtSecret || - this.configService.get('ONE_SESSION_PER_USER')?.toLowerCase() === 'true' - ) { - this.usersService.updateUserSecret(loginUser); + async validateUser(email: string, password: string): Promise { + let user: User; + try { + user = await this.usersService.findByEmail(email); + } catch { + // Absent-user timing mitigation (ADR-006 Risks). Pay the same constant- + // work KDF cost a present user's verify would — an empty hash routes + // verifyPassword to its reject-with-constant-work path — so user-exists + // and user-absent are indistinguishable by timing. Then fail generically + // (LocalStrategy maps a null return to the same 401 as any failure). + await this.verifyOrGenericFailure({ hash: '', password }); + return null; } - if (payload.forcePasswordChange || user.role === 'admin') { - // Admin sessions are only valid for 10 minutes, for regular users give them 10 minutes to (hopefully) change their password. - const expireTime = moment(new Date(Date.now() + ms('600s'))).format( - this.loggingTimeFormat - ); - this.logger.info({ - message: `New session for User expires at ${expireTime}` - }); - return { - userID: user.id, - accessToken: this.jwtService.sign(payload, { - expiresIn: '600s', - secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret - }) - }; - } else { - const expiresIn = limitJWTTime( - this.configService.get('JWT_EXPIRE_TIME') || '60s', - false - ); - const expireTime = moment(new Date(Date.now() + expiresIn)).format( - this.loggingTimeFormat - ); - this.logger.info({ - message: `New session for User expires at ${expireTime}` - }); - return { - userID: user.id, - accessToken: this.jwtService.sign(payload, { - secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret - }) - }; + + const result = await this.verifyOrGenericFailure( + { hash: user.encryptedPassword, password }, + `User`, + ); + if (result === null) { + // KDF queue saturated — already logged; fail generically (§11). + return null; } - } + const { needsRehash, requiresReset, valid } = result; - splitName(fullName: string): {firstName: string; lastName: string} { - const nameArray = fullName.split(' '); - return { - firstName: nameArray[0], - lastName: nameArray.slice(1).join(' ') - }; - } + if (requiresReset === true) { + // §3 refuse path: a bcrypt credential encountered under FIPS mode. + // verifyPassword already paid the constant-work cost. Surface NOTHING + // distinct to the caller (Risks: enumeration oracle) — LocalStrategy maps + // this null to the same generic 401 as any other failure — but record it + // server-side so an operator can see who still needs to migrate. Warn, + // not info: §17 treats operator-actionable states as warnings. + this.logger.warn({ message: `User presented a non-FIPS (bcrypt) credential; login refused under FIPS mode. A password reset is required.` }); + return null; + } - async testPassword( - this: void, - updateUserDto: {currentPassword?: string}, - user: User - ): Promise { - // Site 6 (ADR-006 §4): MUST stay `this`-free — users.service.ts calls - // this method UNBOUND via AuthnService.prototype.testPassword(...), and - // UsersService cannot inject AuthnService (circular). `this: void` makes - // that constraint COMPILER-enforced: any future `this.` access in this - // body is a type error. The pure verifyPassword handles PBKDF2 + legacy - // bcrypt and never throws on malformed input, so no try/catch is needed. - const {valid} = await verifyPassword({ - hash: user.encryptedPassword, - password: updateUserDto.currentPassword || '' - }); if (!valid) { - throw new ForbiddenException('Current password is incorrect'); + return null; + } + + if (needsRehash && !(await this.passwordService.writesEnabled())) { + // §17: the gate-skipped rehash is still an event — the operator's only + // sign that migration debt is accruing behind the closed §12 gate. + this.logger.info({ message: `Lazy password rehash for User skipped: PBKDF2 writes are disabled (§12 gate); the debt is re-reported on the next login after the gate opens` }); + } else if (needsRehash) { + // §7 lazy rehash via compare-and-swap, behind the §12 write gate: while + // PBKDF2 writes are disabled (rolling-deploy window), the rehash is + // SKIPPED — a converted row would be unreadable by a pre-N pod, and the + // debt is re-reported on the next login after the gate opens. The + // narrow writer takes the user id and the ORIGINAL stored hash as the + // CAS predicate and NEVER mutates this instance — so the un-awaited + // updateLoginMetadata save below cannot carry the new hash outside the + // predicate and silently revert a concurrent password change. A failed + // or lost (0-row) rehash must never fail an otherwise successful login. + const originalHash = user.encryptedPassword; + try { + const newHash = await this.passwordService.hash(password); + const affected = await this.usersService.updateEncryptedPassword( + user.id, + originalHash, + newHash, + ); + // §17: since §7 forbids touching passwordChangedAt, these lines are + // the ONLY record that a credential converted. The field list ends at + // the iteration COUNT — never the password, hash, or salt. Format and + // iterations are read from the PHC string actually written, not from + // config, so the log states what is stored. + const phcParts = newHash.split('$'); + const toFormat = phcParts[1] ?? 'unknown'; + const iterationCount = (phcParts[2] ?? '').replace('i=', ''); + if (affected === 0) { + this.logger.info({ message: `Lazy password rehash for User skipped: another writer updated the credential first (compare-and-swap lost — benign, §7)` }); + } else { + this.logger.info({ message: `User credential converted: from bcrypt to ${toFormat} at ${iterationCount} iterations` }); + } + } catch (error) { + if (error instanceof PasswordHashError) { + // §9: an input the hash policy rejects — post-login the only + // reachable case is the length cap — can NEVER convert lazily, so + // warn is the operator-actionable severity (§17); transient + // failures below stay info because the next login retries them. + this.logger.warn({ message: `Lazy password rehash for User skipped (§9): the password cannot be hashed under current policy (${error.message}); login still succeeded but this credential cannot migrate lazily` }); + } else { + this.logger.info({ + message: `Lazy password rehash failed for User; login still succeeded: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + } + + // §7-documented deliberate float ("authn.service.ts already calls + // updateLoginMetadata without await [V]") — the CAS rehash design above + // exists BECAUSE this save races; void marks the intent. + void this.usersService.updateLoginMetadata(user); + return user; + } + + /** + * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is + * saturated, and the ADR assigns the mapping to "the auth layer" — this is + * that layer for site 4. Left unmapped the error escapes as a 500 alongside + * everyone else's 401, which is itself an enumeration oracle: under + * saturation the absent-user dummy consumes a KDF slot while a legacy + * `bcryptjs.compare` consumes none, separating "no such account" from + * "account still on bcrypt". Returns null on overload (caller fails + * generically); anything else is a real bug and propagates. + * + * `subject` is a pre-formatted label for the server-side log only — e.g. + * `User` or `ApiKey` — so both credential paths share this + * mapping without the helper knowing which one called it. + */ + private async verifyOrGenericFailure( + arguments_: { hash: string; password: string }, + subject?: string, + ): Promise { + try { + return await this.passwordService.verify(arguments_); + } catch (error) { + if (error instanceof KdfOverloadedError) { + this.logger.info({ + message: `Credential verification rejected — KDF queue saturated${ + subject === undefined ? '' : ` for ${subject}` + }; returning the generic authentication failure.`, + }); + return null; + } + throw error; } } } diff --git a/apps/backend/src/authn/rehash-lifecycle.spec.ts b/apps/backend/src/authn/rehash-lifecycle.spec.ts new file mode 100644 index 0000000000..7fce00624b --- /dev/null +++ b/apps/backend/src/authn/rehash-lifecycle.spec.ts @@ -0,0 +1,293 @@ +import type { JwtService } from '@nestjs/jwt'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { hashSync } from 'bcryptjs'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { UPDATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import type { ApiKeyService } from '../apikeys/apikey.service'; +import { CaslAbilityFactory } from '../casl/casl-ability.factory'; +import type { ConfigService } from '../config/config.service'; +import { ConfigService as RealConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { PasswordService } from '../crypto/password.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AuthnService } from './authn.service'; + +/** + * ADR-006 §7 lifecycle regression suite (heimdall2-e25.17): a lazy rehash is + * INVISIBLE to every lifecycle mechanism — it changes only the stored + * representation of the credential. §7's corrected test scope governs: + * lastLogin/loginCount/updatedAt change on login BY DESIGN + * (updateLoginMetadata), so this suite asserts exactly what must NOT change: + * passwordChangedAt (the password-expiry clock) and forcePasswordChange. + * §6: bcrypt's silent 72-byte truncation must be GONE after conversion. + */ + +// Legacy fixture cost: verification accepts any cost factor, and cost 10 +// keeps fixture minting fast — production cost (14) would add ~1s per hash +// for no additional coverage. +const LEGACY_BCRYPT_COST = 10; +const KNOWN_PASSWORD = 'correct horse battery staple 42!'; +const NEW_PASSWORD = 'aB1!cD2@eF3#gH4$x9'; +// 100 ASCII chars (1 byte each): bcrypt silently truncates at byte 72, so +// the tail beyond it is invisible to legacy verification (§6). +const LONG_PASSWORD = 'L0ng!'.repeat(20); +const LONG_PASSWORD_WRONG_TAIL + = LONG_PASSWORD.slice(0, 72) + 'X'.repeat(28); +// Seeded well in the past so an accidental rewrite is unambiguous. +const SEEDED_CHANGED_AT = new Date('2026-01-15T12:00:00.000Z'); +const PBKDF2_PREFIX = /^\$pbkdf2-sha512\$/v; + +function createBcryptUser( + email: string, + password: string, + hasPendingForcedChange = false, +): Promise { + return User.create({ + creationMethod: 'local', + email, + encryptedPassword: hashSync(password, LEGACY_BCRYPT_COST), + forcePasswordChange: hasPendingForcedChange, + passwordChangedAt: SEEDED_CHANGED_AT, + }); +} + +/** + * §7 known wrinkle: migration-built DBs store passwordChangedAt as + * VARCHAR(255) while synchronize-built DBs use DATE — so the column round- + * trips as string OR Date depending on how the test DB was built. Compare + * type-agnostically: Dates by ISO value, everything else byte-identical. + */ +function normalizeTimestamp(value: unknown): null | string { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === 'string') { + return value; + } + // §7 names exactly two storage types; anything else is a new defect and + // must fail loudly, never stringify into a comparison. + throw new TypeError( + `passwordChangedAt round-tripped as unexpected type: ${typeof value}`, + ); +} + +describe('Rehash lifecycle regression suite (ADR-006 §7 corrected scope)', () => { + let authnService: AuthnService; + let databaseService: DatabaseService; + let usersService: UsersService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ + RealConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + databaseService = module.get(DatabaseService); + usersService = module.get(UsersService); + + // AuthnService ⇄ UsersService is a circular import (users.service.ts + // calls AuthnService.prototype.testPassword unbound), so Nest cannot + // DI-resolve AuthnService here — the authn.service.spec.ts pattern: + // construct it directly with the REAL collaborators this suite exercises + // (usersService, passwordService) and inert stand-ins for the ones the + // validateUser path never touches (apiKeyService, configService, + // jwtService). + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + module.get(PasswordService), + ); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + it('a login-triggered rehash leaves passwordChangedAt byte-identical and forcePasswordChange false, asserted after user.reload()', async () => { + const user = await createBcryptUser( + 'rehash-lifecycle@example.com', + KNOWN_PASSWORD, + ); + await user.reload(); + const changedAtBefore = normalizeTimestamp( + user.getDataValue('passwordChangedAt'), + ); + expect(changedAtBefore).not.toBeNull(); + + const validated = await authnService.validateUser( + user.email, + KNOWN_PASSWORD, + ); + expect(validated).not.toBeNull(); + + await user.reload(); + // The conversion must have HAPPENED for the unchanged-assertions to mean + // anything (reviewer round 1: vacuous in isolation without this). + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + expect( + normalizeTimestamp(user.getDataValue('passwordChangedAt')), + ).toBe(changedAtBefore); + expect(user.forcePasswordChange).toBe(false); + }); + + it('rehash preserves a PENDING mandated change: forcePasswordChange true stays true across conversion', async () => { + const user = await createBcryptUser( + 'pending-forced-change@example.com', + KNOWN_PASSWORD, + true, + ); + + expect( + await authnService.validateUser(user.email, KNOWN_PASSWORD), + ).not.toBeNull(); + + await user.reload(); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + // A rehash that cleared this flag would silently skip a mandated + // password change — the compliance fix cancelling a security response. + expect(user.forcePasswordChange).toBe(true); + }); + + it('rehash converts the stored credential: encryptedPassword changed and $pbkdf2-prefixed, read from reload not memory', async () => { + const user = await createBcryptUser( + 'rehash-converts@example.com', + KNOWN_PASSWORD, + ); + const bcryptHashBefore = user.encryptedPassword; + + expect( + await authnService.validateUser(user.email, KNOWN_PASSWORD), + ).not.toBeNull(); + + await user.reload(); + expect(user.encryptedPassword).not.toBe(bcryptHashBefore); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + }); + + it('BEFORE rehash a >72-char password verifies with a wrong tail — documents legacy bcrypt truncation (§6)', async () => { + const user = await createBcryptUser( + 'legacy-truncation@example.com', + LONG_PASSWORD, + ); + + // Characters 73-100 differ; bcrypt never sees them. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD_WRONG_TAIL), + ).not.toBeNull(); + }); + + it('AFTER rehash character 73+ is validated: the wrong tail fails and the full password succeeds (§6 truncation gone)', async () => { + const user = await createBcryptUser( + 'truncation-gone@example.com', + LONG_PASSWORD, + ); + + // Convert with the CORRECT full-length password. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD), + ).not.toBeNull(); + await user.reload(); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + + // The same wrong tail that verified under bcrypt now fails... + expect( + await authnService.validateUser(user.email, LONG_PASSWORD_WRONG_TAIL), + ).toBeNull(); + // ...and the genuine full-length password still succeeds. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD), + ).not.toBeNull(); + }); + + it('a GENUINE password change via usersService.update still sets the lifecycle fields (the narrow writer did not leak into the real path)', async () => { + // Seeded TRUE so the expected clear is a real transition, not the + // default value (reviewer round 1: false -> false was vacuous). + const user = await createBcryptUser( + 'genuine-change@example.com', + KNOWN_PASSWORD, + true, + ); + const admin = await User.create({ + creationMethod: 'local', + email: 'genuine-change-admin@example.com', + encryptedPassword: hashSync(KNOWN_PASSWORD, LEGACY_BCRYPT_COST), + role: 'admin', + }); + const abac = new CaslAbilityFactory().createForUser(admin); + await user.reload(); + const changedAtBefore = normalizeTimestamp( + user.getDataValue('passwordChangedAt'), + ); + + await usersService.update( + user, + { + ...UPDATE_USER_DTO_TEST_OBJ, + currentPassword: KNOWN_PASSWORD, + forcePasswordChange: false, + password: NEW_PASSWORD, + passwordConfirmation: NEW_PASSWORD, + }, + abac, + ); + + await user.reload(); + // The expiry clock DOES move on a genuine change — the exact opposite of + // the rehash contract above. Compared against the reloaded stored value, + // not the seed constant, so a format difference can never mask a no-op. + expect( + normalizeTimestamp(user.getDataValue('passwordChangedAt')), + ).not.toBe(changedAtBefore); + expect(user.forcePasswordChange).toBe(false); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + expect( + await authnService.validateUser(user.email, NEW_PASSWORD), + ).not.toBeNull(); + }); +}); From caed8412d7974a96568357243d842edd30b1100d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:20:52 -0400 Subject: [PATCH 040/197] feat: add the health/readiness surface and admin migration report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /health unauthenticated liveness (status+version, no dependency checks), GET /health/ready Terminus DB ping, GET /admin/migration-status behind JwtAuthGuard+CASL — ratified §17 endpoint policy recorded in ADR-006. Authored by: Aaron Lippold --- apps/backend/package.json | 1 + .../src/admin/admin.controller.spec.ts | 188 +++++++++++++++++ apps/backend/src/admin/admin.controller.ts | 45 ++++ apps/backend/src/admin/admin.module.ts | 20 ++ apps/backend/src/app.module.ts | 62 +++--- apps/backend/src/health/dto/health.dto.ts | 33 +++ .../src/health/health.controller.spec.ts | 180 ++++++++++++++++ apps/backend/src/health/health.controller.ts | 45 ++++ apps/backend/src/health/health.module.ts | 21 ++ .../backend/src/health/health.service.spec.ts | 193 ++++++++++++++++++ apps/backend/src/health/health.service.ts | 95 +++++++++ ...adr-006-fips-validated-password-hashing.md | 32 ++- .../health/health-details.interface.ts | 13 ++ .../interfaces/health/health.interface.ts | 4 + libs/common/interfaces/index.ts | 50 ++--- yarn.lock | 17 +- 16 files changed, 945 insertions(+), 54 deletions(-) create mode 100644 apps/backend/src/admin/admin.controller.spec.ts create mode 100644 apps/backend/src/admin/admin.controller.ts create mode 100644 apps/backend/src/admin/admin.module.ts create mode 100644 apps/backend/src/health/dto/health.dto.ts create mode 100644 apps/backend/src/health/health.controller.spec.ts create mode 100644 apps/backend/src/health/health.controller.ts create mode 100644 apps/backend/src/health/health.module.ts create mode 100644 apps/backend/src/health/health.service.spec.ts create mode 100644 apps/backend/src/health/health.service.ts create mode 100644 libs/common/interfaces/health/health-details.interface.ts create mode 100644 libs/common/interfaces/health/health.interface.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 606717d327..1d158aab14 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,6 +39,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/sequelize": "^11.0.0", "@nestjs/serve-static": "^5.0.3", + "@nestjs/terminus": "^11", "@types/connect-pg-simple": "^7.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.17.3", diff --git a/apps/backend/src/admin/admin.controller.spec.ts b/apps/backend/src/admin/admin.controller.spec.ts new file mode 100644 index 0000000000..98f0571830 --- /dev/null +++ b/apps/backend/src/admin/admin.controller.spec.ts @@ -0,0 +1,188 @@ +import { ForbiddenError } from '@casl/ability'; +import type { INestApplication } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { sign } from 'jsonwebtoken'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { ApiKey } from '../apikeys/apikey.model'; +import { JwtStrategy } from '../authn/jwt.strategy'; +import { AuthzModule } from '../authz/authz.module'; +import { CaslExceptionFilter } from '../casl/casl-exception.filter'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { HealthModule } from '../health/health.module'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AdminController } from './admin.controller'; +import { AdminModule } from './admin.module'; + +// Prefix-shaped literal for the §17 count queries — never verified as a +// credential, only matched against LIKE '$pbkdf2-%'. +const PBKDF2_SHAPED_HASH = '$pbkdf2-sha512$i=600000$c2FsdHNhbHQ$aGFzaGhhc2g'; +const USER_JWT_SECRET = 'admin-spec-session-secret'; + +function createRoleUser(role: string): Promise { + return User.create({ + creationMethod: 'local', + email: `admin-spec-${role}@example.com`, + encryptedPassword: PBKDF2_SHAPED_HASH, + jwtSecret: USER_JWT_SECRET, + role, + }); +} + +describe('AdminController Unit Tests', () => { + let app: INestApplication; + let baseUrl: string; + let adminController: AdminController; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + // Mirrors AuthnService.login: same payload shape, same JWT_SECRET + + // per-user jwtSecret concatenation the JwtStrategy re-derives per request. + function signSessionToken(user: User): string { + return sign( + { + email: user.email, + forcePasswordChange: false, + role: user.role, + sub: user.id, + }, + String(configService.get('JWT_SECRET')) + user.jwtSecret, + { expiresIn: '600s' }, + ); + } + + beforeAll(async () => { + // The REAL AdminModule and HealthModule, not root-mounted controllers: + // each controller resolves its dependencies inside its own module, + // exactly as in app.module — a missing module import fails HERE, not + // only at live boot (found live: the original root-mounted harness + // masked AdminModule's missing ConfigModule import). Both modules + // mounted keeps the old-path-404 assertion honest. + module = await Test.createTestingModule({ + imports: [ + AdminModule, + AuthzModule, + ConfigModule, + CryptoModule, + DatabaseModule, + HealthModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + ], + providers: [ + DatabaseService, + JwtStrategy, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + // The real app maps CASL ForbiddenError -> 403 through this filter. + { provide: APP_FILTER, useClass: CaslExceptionFilter }, + ], + }).compile(); + + adminController = module.get(AdminController, { strict: false }); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address(); + if (address === null || typeof address !== 'object') { + throw new TypeError('expected the test server to bind a TCP port'); + } + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first. + await databaseService.cleanAll(); + await app.close(); + }); + + describe('GET /admin/migration-status (authenticated migration report)', () => { + it('serves the migration detail to an admin JWT over HTTP', async () => { + const admin = await createRoleUser('admin'); + + const response = await fetch(`${baseUrl}/admin/migration-status`, { headers: { Authorization: `Bearer ${signSessionToken(admin)}` } }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + bcryptRemaining: { apiKeys: 0, users: 0 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: null, + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 0, users: 1 }, + }); + }); + + it('refuses HTTP requests without a JWT — 401 from JwtAuthGuard', async () => { + const response = await fetch(`${baseUrl}/admin/migration-status`); + expect(response.status).toBe(401); + }); + + it('refuses a non-admin JWT over HTTP with 403 (CASL -> CaslExceptionFilter)', async () => { + const basicUser = await createRoleUser('user'); + + const response = await fetch(`${baseUrl}/admin/migration-status`, { headers: { Authorization: `Bearer ${signSessionToken(basicUser)}` } }); + expect(response.status).toBe(403); + }); + + it('rejects a non-admin authenticated user with ForbiddenError (CASL admin check, direct call)', async () => { + const basicUser = await createRoleUser('user'); + + await expect( + adminController.getMigrationStatus({ user: basicUser }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + describe('the old path is gone (ratified rename, 2026-08-10)', () => { + it('GET /health/details returns 404 with the health routes mounted', async () => { + const admin = await createRoleUser('admin'); + + const response = await fetch(`${baseUrl}/health/details`, { headers: { Authorization: `Bearer ${signSessionToken(admin)}` } }); + expect(response.status).toBe(404); + }); + + it('the probe-safe health surface still serves — /health 200 unauthenticated', async () => { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/apps/backend/src/admin/admin.controller.ts b/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000000..b7f5c575a3 --- /dev/null +++ b/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,45 @@ +import { ForbiddenError } from '@casl/ability'; +import { + Controller, + Get, + Request, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { HealthDetailsDto } from '../health/dto/health.dto'; +import { HealthService } from '../health/health.service'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; + +/** + * ADR-006 §17 (renamed 2026-08-10, ratified policy): the migration report is + * an ADMIN endpoint, not a health check — it moved out of the probe + * namespace so /health carries only the probe-safe surface. Same guard chain + * as StatisticsController: JwtAuthGuard + the CASL admin-only ViewStatistics + * action. + * + * NEVER wire this route into a container healthcheck or readiness probe: + * its counts are full Users/ApiKeys scans, uncached by design (§17 — + * self-inflicted outage). + */ +@Controller('admin') +@UseInterceptors(LoggingInterceptor) +export class AdminController { + constructor( + private readonly authz: AuthzService, + private readonly healthService: HealthService, + ) {} + + @Get('migration-status') + @UseGuards(JwtAuthGuard) + async getMigrationStatus( + @Request() request: { user: User }, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.ViewStatistics, User); + return this.healthService.getDetails(); + } +} diff --git a/apps/backend/src/admin/admin.module.ts b/apps/backend/src/admin/admin.module.ts new file mode 100644 index 0000000000..a3c41a4cbe --- /dev/null +++ b/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '../config/config.module'; +import { HealthModule } from '../health/health.module'; +import { AdminController } from './admin.controller'; + +/** + * ADR-006 §17: the admin surface. Holds the migration report (moved out of + * the probe namespace, ratified 2026-08-10); e25.24's bulk migration + * endpoints may extend it. HealthModule exports HealthService — the report's + * data source is unchanged by the rename. ConfigModule feeds the + * LoggingInterceptor (ConfigModule is NOT @Global in this app — found live: + * the app context failed to boot without it while the spec was green, + * because the original harness mounted the controller at root with a + * root-level ConfigModule; the spec now consumes this real module instead). + */ +@Module({ + controllers: [AdminController], + imports: [ConfigModule, HealthModule], +}) +export class AdminModule {} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 8f93893ccc..6a03fcb471 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,34 +1,41 @@ -import {Module} from '@nestjs/common'; -import {APP_FILTER} from '@nestjs/core'; -import {ServeStaticModule} from '@nestjs/serve-static'; -import {join} from 'path'; -import {ApiKeyModule} from './apikeys/apikeys.module'; -import {AppController} from './app.controller'; -import {AppService} from './app.service'; -import {AuthnModule} from './authn/authn.module'; -import {AuthzModule} from './authz/authz.module'; -import {CaslExceptionFilter} from './casl/casl-exception.filter'; -import {ConfigModule} from './config/config.module'; -import {CryptoModule} from './crypto/crypto.module'; -import {DatabaseModule} from './database/database.module'; -import {EvaluationTagsModule} from './evaluation-tags/evaluation-tags.module'; -import {EvaluationsModule} from './evaluations/evaluations.module'; -import {GroupEvaluationsModule} from './group-evaluations/group-evaluations.module'; -import {GroupUsersModule} from './group-users/group-users.module'; -import {GroupsModule} from './groups/groups.module'; -import {StatisticsModule} from './statistics/statistics.module'; -import {UsersModule} from './users/users.module'; -import {TenableModule} from './tenable/tenable.module'; +import path from 'node:path'; +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { AdminModule } from './admin/admin.module'; +import { ApiKeyModule } from './apikeys/apikeys.module'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthnModule } from './authn/authn.module'; +import { AuthzModule } from './authz/authz.module'; +import { CaslExceptionFilter } from './casl/casl-exception.filter'; +import { ConfigModule } from './config/config.module'; +import { CryptoModule } from './crypto/crypto.module'; +import { DatabaseModule } from './database/database.module'; +import { EvaluationTagsModule } from './evaluation-tags/evaluation-tags.module'; +import { EvaluationsModule } from './evaluations/evaluations.module'; +import { GroupEvaluationsModule } from './group-evaluations/group-evaluations.module'; +import { GroupUsersModule } from './group-users/group-users.module'; +import { GroupsModule } from './groups/groups.module'; +import { HealthModule } from './health/health.module'; +import { StatisticsModule } from './statistics/statistics.module'; +import { TenableModule } from './tenable/tenable.module'; +import { UsersModule } from './users/users.module'; @Module({ controllers: [AppController], imports: [ ServeStaticModule.forRoot({ - rootPath: join(__dirname, '..', '..', '..', '..', 'dist', 'frontend'), - renderPath: '*splat' + renderPath: '*splat', + // eslint's prefer-module wants import.meta here, but this package + // compiles to CommonJS (nodenext, no "type": "module") where + // import.meta is a syntax error — resolving the frontend bundle stays + // on __dirname until an ESM migration. + rootPath: path.join(__dirname, '..', '..', '..', '..', 'dist', 'frontend'), }), ConfigModule, CryptoModule, + AdminModule, ApiKeyModule, UsersModule, DatabaseModule, @@ -39,15 +46,16 @@ import {TenableModule} from './tenable/tenable.module'; GroupEvaluationsModule, GroupsModule, GroupUsersModule, + HealthModule, StatisticsModule, - TenableModule + TenableModule, ], providers: [ AppService, { provide: APP_FILTER, - useClass: CaslExceptionFilter - } - ] + useClass: CaslExceptionFilter, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/health/dto/health.dto.ts b/apps/backend/src/health/dto/health.dto.ts new file mode 100644 index 0000000000..60ac53d3f3 --- /dev/null +++ b/apps/backend/src/health/dto/health.dto.ts @@ -0,0 +1,33 @@ +import type { + IHealth, + IHealthDetails, + IHealthTableCounts, +} from '@heimdall/common/interfaces'; + +export class HealthDetailsDto implements IHealthDetails { + readonly bcryptRemaining: IHealthTableCounts; + readonly fips: boolean; + readonly fipsModeAsserted: boolean; + readonly oldestUnmigratedLogin: null | string; + readonly passwordHashWriteEnabled: boolean; + readonly pbkdf2Migrated: IHealthTableCounts; + + constructor(details: IHealthDetails) { + this.bcryptRemaining = details.bcryptRemaining; + this.fips = details.fips; + this.fipsModeAsserted = details.fipsModeAsserted; + this.oldestUnmigratedLogin = details.oldestUnmigratedLogin; + this.passwordHashWriteEnabled = details.passwordHashWriteEnabled; + this.pbkdf2Migrated = details.pbkdf2Migrated; + } +} + +export class HealthDto implements IHealth { + readonly status: string; + readonly version: string; + + constructor(health: IHealth) { + this.status = health.status; + this.version = health.version; + } +} diff --git a/apps/backend/src/health/health.controller.spec.ts b/apps/backend/src/health/health.controller.spec.ts new file mode 100644 index 0000000000..84bb456634 --- /dev/null +++ b/apps/backend/src/health/health.controller.spec.ts @@ -0,0 +1,180 @@ +import type { INestApplication } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { + HealthCheckError, + SequelizeHealthIndicator, + TerminusModule, +} from '@nestjs/terminus'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { version as backendVersion } from '../../package.json'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { HealthService } from '../health/health.service'; +import { User } from '../users/user.model'; +import { HealthController } from './health.controller'; + +// §17 disclosure boundary: none of these may ever appear on a probe surface. +const MIGRATION_STATE_PATTERN = /bcrypt|fips|passwordHashWriteEnabled|pbkdf2/v; + +describe('HealthController Unit Tests', () => { + let app: INestApplication; + let baseUrl: string; + let healthController: HealthController; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [HealthController], + imports: [ + ConfigModule, + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + TerminusModule, + ], + providers: [DatabaseService, HealthService], + }).compile(); + + healthController = module.get(HealthController); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address(); + if (address === null || typeof address !== 'object') { + throw new TypeError('expected the test server to bind a TCP port'); + } + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first. + await databaseService.cleanAll(); + await app.close(); + }); + + describe('GET /health (unauthenticated liveness)', () => { + it('returns {status, version} ONLY — no fips, write-gate, or count fields (ADR-006 §17 disclosure boundary)', () => { + expect(healthController.getHealth()).toEqual({ + status: 'ok', + version: backendVersion, + }); + }); + + it('serves the liveness shape over HTTP with NO Authorization header (unauthenticated surface)', async () => { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: 'ok', + version: backendVersion, + }); + }); + }); + + describe('GET /health/ready (unauthenticated Terminus readiness probe)', () => { + it('returns the standard Terminus envelope with the database up, with NO Authorization header', async () => { + const response = await fetch(`${baseUrl}/health/ready`); + expect(response.status).toBe(200); + const body: unknown = await response.json(); + expect(body).toEqual({ + details: { database: { status: 'up' } }, + error: {}, + info: { database: { status: 'up' } }, + status: 'ok', + }); + // §17 disclosure boundary, asserted explicitly per the AC: no + // migration state anywhere in the probe response (the exact toEqual + // above already pins the key set; this sweeps nested values too). + expect(JSON.stringify(body)).not.toMatch(MIGRATION_STATE_PATTERN); + }); + + it('sends Cache-Control: no-cache, no-store, must-revalidate — probe responses must never be cached (@HealthCheck)', async () => { + const response = await fetch(`${baseUrl}/health/ready`); + expect(response.headers.get('cache-control')).toBe( + 'no-cache, no-store, must-revalidate', + ); + }); + + it('returns 503 with the Terminus error envelope when the DB check fails (same handler path, failing indicator)', async () => { + // The real controller, HealthCheckService, and 503 mapping — only the + // indicator (the piece that talks to the DB) is substituted with one + // that fails the way a dead connection does. Deliberately NO + // DatabaseModule here: a second Sequelize registration rebinds the + // shared model classes and poisons the main module's cleanup, and the + // ready route never touches HealthService. + const failingModule = await Test.createTestingModule({ + controllers: [HealthController], + imports: [ConfigModule, TerminusModule], + providers: [ + { provide: HealthService, useValue: {} }, + ], + }) + .overrideProvider(SequelizeHealthIndicator) + .useValue({ + pingCheck: () => { + throw new HealthCheckError('sequelize ping failed', { database: { status: 'down' } }); + }, + }) + .compile(); + const failingApp = failingModule.createNestApplication(); + await failingApp.init(); + await failingApp.listen(0); + const failingAddress = failingApp.getHttpServer().address(); + if (failingAddress === null || typeof failingAddress !== 'object') { + throw new TypeError('expected the failing test server to bind a port'); + } + + try { + const response = await fetch( + `http://127.0.0.1:${String(failingAddress.port)}/health/ready`, + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + details: { database: { status: 'down' } }, + error: { database: { status: 'down' } }, + info: {}, + status: 'error', + }); + } finally { + await failingApp.close(); + } + }); + }); +}); diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts new file mode 100644 index 0000000000..7b868c38c9 --- /dev/null +++ b/apps/backend/src/health/health.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, UseInterceptors } from '@nestjs/common'; +import type { HealthCheckResult } from '@nestjs/terminus'; +import { + HealthCheck, + HealthCheckService, + SequelizeHealthIndicator, +} from '@nestjs/terminus'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { HealthDto } from './dto/health.dto'; +import { HealthService } from './health.service'; + +/** + * ADR-006 §17 (ratified policy, 2026-08-10): this controller carries ONLY + * the probe-safe surface. GET /health is the UNAUTHENTICATED liveness check + * ({status, version}, no dependency checks — a DB outage must never restart + * app pods). GET /health/ready is the UNAUTHENTICATED Terminus readiness + * probe — a constant-cost DB ping for container/k8s/systemd probe use. + * + * The admin migration report lives at /admin/migration-status + * (AdminController) — it is NOT a health check, must never be probed (its + * counts are full table scans), and never returns on this surface (Risks — + * disclosure). + */ +@Controller('health') +@UseInterceptors(LoggingInterceptor) +export class HealthController { + constructor( + private readonly healthCheckService: HealthCheckService, + private readonly healthService: HealthService, + private readonly sequelizeIndicator: SequelizeHealthIndicator, + ) {} + + @Get('ready') + @HealthCheck() + checkReadiness(): Promise { + return this.healthCheckService.check([ + () => this.sequelizeIndicator.pingCheck('database'), + ]); + } + + @Get() + getHealth(): HealthDto { + return this.healthService.getHealth(); + } +} diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts new file mode 100644 index 0000000000..7bf46f2fc9 --- /dev/null +++ b/apps/backend/src/health/health.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +/** + * ADR-006 §17. ConfigModule feeds FIPS_MODE; CryptoModule feeds the write + * gate. The Sequelize connection (raw §17 count queries and the Terminus + * ping) is provided by the root DatabaseModule registration. TerminusModule + * supplies the /health/ready probe machinery — probes stay constant-cost and + * never touch the §17 scans. + */ +@Module({ + controllers: [HealthController], + exports: [HealthService], + imports: [ConfigModule, CryptoModule, TerminusModule], + providers: [HealthService], +}) +export class HealthModule {} diff --git a/apps/backend/src/health/health.service.spec.ts b/apps/backend/src/health/health.service.spec.ts new file mode 100644 index 0000000000..f2657f079a --- /dev/null +++ b/apps/backend/src/health/health.service.spec.ts @@ -0,0 +1,193 @@ +import * as nodeCrypto from 'node:crypto'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HashMigrationMarker } from '../crypto/hash-migration-marker.model'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { HealthService } from './health.service'; + +// Pass-through mock: every crypto member stays real; getFips gains a +// mockable seam (vi.spyOn on ESM builtin namespaces is not configurable — +// the fips.spec.ts pattern). +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getFips: vi.fn(actual.getFips) }; +}); + +// Prefix-shaped literals for the §17 count queries — never verified as +// credentials, only matched against LIKE '$2%' / '$pbkdf2-%'. +const BCRYPT_SHAPED_HASH + = '$2b$14$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const PBKDF2_SHAPED_HASH = '$pbkdf2-sha512$i=600000$c2FsdHNhbHQ$aGFzaGhhc2g'; +const DAY_MS = 24 * 60 * 60 * 1000; +const THREE_DAYS_INTERVAL_PREFIX = /^3 days/v; + +describe('HealthService Unit Tests', () => { + let healthService: HealthService; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule, + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + ], + providers: [DatabaseService, HealthService], + }).compile(); + + healthService = module.get(HealthService); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + describe('getDetails', () => { + it('returns zero counts, null oldestUnmigratedLogin, and the exact §17 shape on empty tables', async () => { + expect(await healthService.getDetails()).toEqual({ + bcryptRemaining: { apiKeys: 0, users: 0 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: null, + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 0, users: 0 }, + }); + }); + + it('splits counts by hash prefix over BOTH tables and reports the OLDEST unmigrated login (§17 FILTER shape)', async () => { + const bcryptUserOld = await User.create({ + creationMethod: 'local', + email: 'bcrypt-old@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + lastLogin: new Date(Date.now() - 3 * DAY_MS), + }); + await User.create({ + creationMethod: 'local', + email: 'bcrypt-recent@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + lastLogin: new Date(Date.now() - 1 * DAY_MS), + }); + await User.create({ + creationMethod: 'local', + email: 'pbkdf2-older-login@example.com', + encryptedPassword: PBKDF2_SHAPED_HASH, + // Older than every bcrypt login — must NOT win: the age() aggregate + // is FILTERed to unmigrated ('$2%') rows only. + lastLogin: new Date(Date.now() - 5 * DAY_MS), + }); + await ApiKey.create({ + apiKey: BCRYPT_SHAPED_HASH, + name: 'legacy key', + userId: bcryptUserOld.id, + }); + await ApiKey.create({ + apiKey: PBKDF2_SHAPED_HASH, + name: 'migrated key', + userId: bcryptUserOld.id, + }); + + expect(await healthService.getDetails()).toEqual({ + bcryptRemaining: { apiKeys: 1, users: 2 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: expect.stringMatching( + THREE_DAYS_INTERVAL_PREFIX, + ), + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 1, users: 1 }, + }); + }); + + it('reports null oldestUnmigratedLogin when no unmigrated user has ever logged in', async () => { + await User.create({ + creationMethod: 'local', + email: 'bcrypt-never-logged-in@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + }); + await User.create({ + creationMethod: 'local', + email: 'pbkdf2-logged-in@example.com', + encryptedPassword: PBKDF2_SHAPED_HASH, + lastLogin: new Date(Date.now() - 2 * DAY_MS), + }); + + const details = await healthService.getDetails(); + expect(details.oldestUnmigratedLogin).toBeNull(); + expect(details.bcryptRemaining).toEqual({ apiKeys: 0, users: 1 }); + }); + + it('reports fipsModeAsserted=true for FIPS_MODE=true while fips still reflects the real provider probe', async () => { + configService.set('FIPS_MODE', 'true'); + const details = await healthService.getDetails(); + expect(details.fipsModeAsserted).toBe(true); + // Non-FIPS test host: the OpenSSL probe is independent of the setting. + expect(details.fips).toBe(false); + }); + + it('reports fips=true when the OpenSSL provider probe is active (getFips()===1)', async () => { + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + const details = await healthService.getDetails(); + expect(details.fips).toBe(true); + }); + + it('reports passwordHashWriteEnabled=false when the write gate derives OFF (explicit env)', async () => { + const priorSetting = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Fresh instances: the module singletons cached the suite-wide + // gate-ON derivation. The explicit-env path never touches the + // injected models (hash-write-gate contract). + const gatedOffService = new HealthService( + configService, + new HashWriteGateService(HashMigrationMarker, User, configService), + databaseService.sequelize, + ); + const details = await gatedOffService.getDetails(); + expect(details.passwordHashWriteEnabled).toBe(false); + } finally { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorSetting; + } + }); + }); +}); diff --git a/apps/backend/src/health/health.service.ts b/apps/backend/src/health/health.service.ts new file mode 100644 index 0000000000..cd26392b9e --- /dev/null +++ b/apps/backend/src/health/health.service.ts @@ -0,0 +1,95 @@ +import * as nodeCrypto from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { QueryTypes } from 'sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { version as backendVersion } from '../../package.json'; +import { ConfigService } from '../config/config.service'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; +import { HealthDetailsDto, HealthDto } from './dto/health.dto'; + +/** + * ADR-006 §17: the split health surface. The liveness half returns + * {status, version} ONLY — migration state (fips, write gate, hash counts) is + * a disclosure decision the Risks table forbids on any unauthenticated + * surface, and it lives behind auth on /health/details instead. + * + * The version is the backend's own package.json version — the backend analog + * of the frontend's build-time PACKAGE_VERSION (vue.config.js reads the same + * field from its package.json). resolveJsonModule emits the file into dist/, + * so the compiled require resolves at runtime. + * + * The count queries are §17's single-scan FILTER shape over BOTH credential + * tables — the prior draft's Users-only query is the documented mistake + * (bcrypt_remaining could read 0 while every ApiKeys row was still $2b$). + * Each call runs the full scans: no caching, and never wire these into a + * readiness probe (§17 — self-inflicted outage). + */ + +const USERS_HASH_COUNTS_SQL = ` +SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%')::int AS "bcryptRemaining", + count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%')::int AS "pbkdf2Migrated", + (max(age(now(), "lastLogin")) + FILTER (WHERE "encryptedPassword" LIKE '$2%'))::text AS "oldestUnmigratedLogin" +FROM "Users"`; + +const API_KEYS_HASH_COUNTS_SQL = ` +SELECT count(*) FILTER (WHERE "apiKey" LIKE '$2%')::int AS "bcryptRemaining", + count(*) FILTER (WHERE "apiKey" LIKE '$pbkdf2-%')::int AS "pbkdf2Migrated" +FROM "ApiKeys"`; + +type ApiKeysHashCountsRow = { + readonly bcryptRemaining: number; + readonly pbkdf2Migrated: number; +}; + +type UsersHashCountsRow = { + readonly bcryptRemaining: number; + readonly oldestUnmigratedLogin: null | string; + readonly pbkdf2Migrated: number; +}; + +@Injectable() +export class HealthService { + constructor( + private readonly configService: ConfigService, + private readonly hashWriteGate: HashWriteGateService, + private readonly sequelize: Sequelize, + ) {} + + async getDetails(): Promise { + const userCounts = await this.sequelize.query( + USERS_HASH_COUNTS_SQL, + { plain: true, type: QueryTypes.SELECT }, + ); + const apiKeyCounts = await this.sequelize.query( + API_KEYS_HASH_COUNTS_SQL, + { plain: true, type: QueryTypes.SELECT }, + ); + if (userCounts === null || apiKeyCounts === null) { + // A single-row aggregate cannot return an empty set; a null here means + // the query itself broke and must never read as "zero remaining". + throw new Error('hash-count aggregate returned no row'); + } + return new HealthDetailsDto({ + bcryptRemaining: { + apiKeys: apiKeyCounts.bcryptRemaining, + users: userCounts.bcryptRemaining, + }, + fips: nodeCrypto.getFips() === 1, + // Exact-match semantics shared with assertFipsMode (§10): only the + // literal 'true' asserts, anything else reports unasserted here and is + // warned about or refused at boot. + fipsModeAsserted: this.configService.get('FIPS_MODE') === 'true', + oldestUnmigratedLogin: userCounts.oldestUnmigratedLogin, + passwordHashWriteEnabled: await this.hashWriteGate.writesEnabled(), + pbkdf2Migrated: { + apiKeys: apiKeyCounts.pbkdf2Migrated, + users: userCounts.pbkdf2Migrated, + }, + }); + } + + getHealth(): HealthDto { + return new HealthDto({ status: 'ok', version: backendVersion }); + } +} diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adr-006-fips-validated-password-hashing.md index 6b9a0714a1..9e11711059 100644 --- a/docs/adr-006-fips-validated-password-hashing.md +++ b/docs/adr-006-fips-validated-password-hashing.md @@ -1030,11 +1030,41 @@ exists, verified by code read: section's own rule. - **A fourth "Migration" admin tab** (`Admin.vue` already hosts Users / Groups / Statistics tabs **[V]**) showing FIPS state, write-gate state, both tables' - counts, and the two bulk actions — fed by the authenticated health detail + counts, and the two bulk actions — fed by the authenticated migration-status endpoint, so the tab and the operator query share one source. - The `/health` split concretely: **`GET /health`** (unauthenticated liveness, `{status, version}` only) and **`GET /health/details`** (JwtAuthGuard + CASL admin, following `StatisticsController`'s existing pattern **[V]**). + +**Endpoint policy, ratified 2026-08-10 (Aaron, health-endpoint review against +Kubernetes probe guidance, Spring Boot Actuator's `show-details: +when-authorized` pattern, and the Azure Health Endpoint Monitoring pattern) — +this supersedes the prior bullet's naming:** + +| Purpose | Endpoint | Auth | Consumer | +|---|---|---|---| +| startupProbe / livenessProbe / LB target check / systemd smoke | `GET /health` | none | kubelet, load balancers, uptime monitors | +| readinessProbe / compose healthcheck | `GET /health/ready` | none | kubelet, docker-compose (Terminus DB ping — the one hard dependency, nothing else) | +| migration/ops report | `GET /admin/migration-status` | admin (JwtAuthGuard + CASL ViewStatistics) | Migration tab, operators | +| login-page bootstrap | `GET /server` | none | frontend (pre-existing contract) | + +- **Renamed:** the migration report moved from `/health/details` to + `/admin/migration-status` — it is an admin report, not a health check, and + the old name invited probing it. The old path returns 404; nothing consumed + it before the rename. +- **Liveness carries no dependency checks** (a DB outage must never restart + app pods) and **readiness checks only the hard dependency** (Postgres); + optional integrations (Splunk, Tenable) never gate either. +- **`version` stays on unauthenticated `/health`**: the frontend bundle + already ships the exact version publicly (About modal), so removing it here + alone changes nothing real; revisit only as a two-surface change if the + accreditation posture demands it. +- **The migration report is NEVER probed** — its counts are full table scans, + uncached by design. +- **Deployment rule:** `/health/*` is probed from inside the boundary and not + routed through the public ingress/load-balancer path (documented in the + deployment runbook); a separate management port is deferred until a + `/metrics` endpoint exists. - Post-cutover recovery needs no new code: an admin sets a temporary password through the existing `UserModal` admin path (which skips currentPassword) and `forcePasswordChange` compels rotation at next login — documented in the diff --git a/libs/common/interfaces/health/health-details.interface.ts b/libs/common/interfaces/health/health-details.interface.ts new file mode 100644 index 0000000000..58a780caa3 --- /dev/null +++ b/libs/common/interfaces/health/health-details.interface.ts @@ -0,0 +1,13 @@ +export type IHealthDetails = { + readonly bcryptRemaining: IHealthTableCounts; + readonly fips: boolean; + readonly fipsModeAsserted: boolean; + readonly oldestUnmigratedLogin: null | string; + readonly passwordHashWriteEnabled: boolean; + readonly pbkdf2Migrated: IHealthTableCounts; +}; + +export type IHealthTableCounts = { + readonly apiKeys: number; + readonly users: number; +}; diff --git a/libs/common/interfaces/health/health.interface.ts b/libs/common/interfaces/health/health.interface.ts new file mode 100644 index 0000000000..bb7d782220 --- /dev/null +++ b/libs/common/interfaces/health/health.interface.ts @@ -0,0 +1,4 @@ +export type IHealth = { + readonly status: string; + readonly version: string; +}; diff --git a/libs/common/interfaces/index.ts b/libs/common/interfaces/index.ts index b7fb118700..59d56e0a2e 100644 --- a/libs/common/interfaces/index.ts +++ b/libs/common/interfaces/index.ts @@ -1,24 +1,26 @@ -export * from './apikey/apikey.interface'; -export * from './apikey/create-apikey.interface'; -export * from './apikey/delete-apikey.interface'; -export * from './apikey/regenerate-apikey.interface'; -export * from './apikey/update-apikey.interface'; -export * from './config/startup-settings.interface'; -export * from './evaluation-tag/create-evaluation-tag.interface'; -export * from './evaluation-tag/delete-evaluation-tag.interface'; -export * from './evaluation-tag/evaluation-tag.interface'; -export * from './evaluation/create-evaluation.interface'; -export * from './evaluation/evaluation.interface'; -export * from './evaluation/update-evaluation.interface'; -export * from './group/add-user-to-group.interface'; -export * from './group/create-group.interface'; -export * from './group/evaluation-group.interface'; -export * from './group/group.interface'; -export * from './group/remove-user-from-group.interface'; -export * from './group/update-group-user.interface'; -export * from './statistics/statistics.interface'; -export * from './user/create-user.interface'; -export * from './user/delete-user.interface'; -export * from './user/slim-user.interface'; -export * from './user/update-user.interface'; -export * from './user/user.interface'; +export type * from './apikey/apikey.interface'; +export type * from './apikey/create-apikey.interface'; +export type * from './apikey/delete-apikey.interface'; +export type * from './apikey/regenerate-apikey.interface'; +export type * from './apikey/update-apikey.interface'; +export type * from './config/startup-settings.interface'; +export type * from './evaluation-tag/create-evaluation-tag.interface'; +export type * from './evaluation-tag/delete-evaluation-tag.interface'; +export type * from './evaluation-tag/evaluation-tag.interface'; +export type * from './evaluation/create-evaluation.interface'; +export type * from './evaluation/evaluation.interface'; +export type * from './evaluation/update-evaluation.interface'; +export type * from './group/add-user-to-group.interface'; +export type * from './group/create-group.interface'; +export type * from './group/evaluation-group.interface'; +export type * from './group/group.interface'; +export type * from './group/remove-user-from-group.interface'; +export type * from './group/update-group-user.interface'; +export type * from './health/health-details.interface'; +export type * from './health/health.interface'; +export type * from './statistics/statistics.interface'; +export type * from './user/create-user.interface'; +export type * from './user/delete-user.interface'; +export type * from './user/slim-user.interface'; +export type * from './user/update-user.interface'; +export type * from './user/user.interface'; diff --git a/yarn.lock b/yarn.lock index ea5f4537f8..1969dcd1a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,6 +2519,14 @@ dependencies: path-to-regexp "8.4.2" +"@nestjs/terminus@^11": + version "11.1.1" + resolved "https://registry.yarnpkg.com/@nestjs/terminus/-/terminus-11.1.1.tgz#22da9e59597917630419331360e8cc662b071e35" + integrity sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw== + dependencies: + boxen "5.1.2" + check-disk-space "3.4.0" + "@nestjs/testing@^11.0.1": version "11.1.27" resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-11.1.27.tgz#1aeb3535d8965753b42e9d931848456f70f51e5d" @@ -6053,7 +6061,7 @@ bowser@^2.11.0: resolved "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== -boxen@^5.0.0: +boxen@5.1.2, boxen@^5.0.0: version "5.1.2" resolved "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== @@ -6523,6 +6531,11 @@ chardet@^2.1.1: resolved "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz#5c75593704a642f71ee53717df234031e65373c8" integrity sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ== +check-disk-space@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/check-disk-space/-/check-disk-space-3.4.0.tgz#eb8e69eee7a378fd12e35281b8123a8b4c4a8ff7" + integrity sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw== + chokidar@4.0.3, chokidar@^4.0.0, chokidar@^4.0.1: version "4.0.3" resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" @@ -9434,7 +9447,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@3.3.3, fast-glob@^3.2.7, fast-glob@^3.2.9: +fast-glob@^3.2.7, fast-glob@^3.2.9: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== From fb853b52ac781bb5abc9df542c9eb0f7fa0b1c79 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:21:09 -0400 Subject: [PATCH 041/197] feat: harden RPM packaging for FIPS hosts md5-verifier detection with --check-auth mode, UV_THREADPOOL_SIZE=8 in the systemd unit, PBKDF2-aware configure/db-setup scripts, operator runbook. Authored by: Aaron Lippold --- packaging/rpm/INSTALL.md | 39 +++++- packaging/rpm/heimdall-configure.sh | 1 + packaging/rpm/heimdall-db-setup.sh | 2 +- packaging/rpm/heimdall-postgres-setup.sh | 169 ++++++++++++++++++----- packaging/rpm/heimdall-server.service | 5 + packaging/rpm/heimdall-server.sh | 2 +- packaging/rpm/heimdall-setup.sh | 3 +- packaging/rpm/setup-rpm-build-env.sh | 4 +- 8 files changed, 189 insertions(+), 36 deletions(-) diff --git a/packaging/rpm/INSTALL.md b/packaging/rpm/INSTALL.md index 53963b8be3..e82125abcf 100644 --- a/packaging/rpm/INSTALL.md +++ b/packaging/rpm/INSTALL.md @@ -124,7 +124,9 @@ To use an existing PostgreSQL server instead of a local one: 1. Run `sudo heimdall-server-setup --interactive` 2. Set `DATABASE_HOST` to your server's hostname or IP 3. Set `DATABASE_PORT`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` as needed -4. The PostgreSQL bootstrap step is automatically skipped +4. The PostgreSQL bootstrap step is automatically skipped — but the setup + still checks the role's password verifier for FIPS compatibility (see + "FIPS Hosts and md5 Password Verifiers" below) Or edit `/etc/heimdall-server/backend.env` directly and run: ```bash @@ -146,6 +148,41 @@ The setup script automatically configures PostgreSQL with: - Verification that the database role password is stored as SCRAM-SHA-256 (setup exits with an error if not) +### FIPS Hosts and md5 Password Verifiers + +A FIPS-mode host cannot complete md5 password authentication, so a database +role whose stored verifier is still md5 makes the Heimdall server **fail to +connect** the moment FIPS mode is enabled. This bites pre-existing databases: +`password_encryption = scram-sha-256` only affects passwords set *after* the +change — existing roles keep their old `md5...` verifier in `pg_authid`. + +Setup checks this automatically (for remote databases too) and prints a +warning with the remediation. To run the check on its own: + +```bash +sudo /usr/libexec/heimdall-server/postgres-setup.sh --check-auth +``` + +Reading `pg_authid` requires superuser; when the configured role cannot read +it, the check prints the manual query to run as a superuser instead: + +```sql +SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = 'heimdall'; +``` + +If the result starts with `md5`, remediate as a PostgreSQL superuser (this +rewrites the role's stored credential — schedule accordingly): + +```sql +ALTER SYSTEM SET password_encryption = 'scram-sha-256'; +SELECT pg_reload_conf(); +ALTER ROLE heimdall WITH PASSWORD ''; -- rewrites the verifier +-- then change any md5 rules in pg_hba.conf to scram-sha-256 and reload +``` + +Setup never modifies your database's authentication configuration itself — +the warning and this runbook are the intended remediation path. + ### External Database (RDS, Azure DB, etc.) When using an external database, ensure: diff --git a/packaging/rpm/heimdall-configure.sh b/packaging/rpm/heimdall-configure.sh index 939de3fd69..585fbf7fed 100644 --- a/packaging/rpm/heimdall-configure.sh +++ b/packaging/rpm/heimdall-configure.sh @@ -101,6 +101,7 @@ write_key() { fi local escaped + # shellcheck disable=SC2016 # the single quotes are deliberate: sed must receive literal \$ patterns, not shell expansions escaped="$(printf "%s" "${value}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/\\$/g' -e 's/`/\\`/g')" printf '%s="%s"\n' "${key}" "${escaped}" >>"${TMP_FILE}" } diff --git a/packaging/rpm/heimdall-db-setup.sh b/packaging/rpm/heimdall-db-setup.sh index 24f923d9f7..ffb4c3c639 100644 --- a/packaging/rpm/heimdall-db-setup.sh +++ b/packaging/rpm/heimdall-db-setup.sh @@ -32,7 +32,7 @@ done if [[ -f "${ENV_FILE}" ]]; then set -a - # shellcheck disable=SC1091 + # shellcheck disable=SC1090 if ! source "${ENV_FILE}"; then set +a echo "Failed to parse ${ENV_FILE}" >&2 diff --git a/packaging/rpm/heimdall-postgres-setup.sh b/packaging/rpm/heimdall-postgres-setup.sh index d93057bdc4..152496cc3d 100644 --- a/packaging/rpm/heimdall-postgres-setup.sh +++ b/packaging/rpm/heimdall-postgres-setup.sh @@ -4,18 +4,29 @@ set -euo pipefail ENV_FILE="/etc/heimdall-server/backend.env" usage() { - echo "Usage: $0" >&2 + echo "Usage: $0 [--check-auth]" >&2 + echo " --check-auth Only check the configured role's password verifier" >&2 + echo " (ADR-006 §16 FIPS compatibility) and exit." >&2 } -if [[ $# -gt 0 ]]; then - if [[ "$1" == "-h" || "$1" == "--help" ]]; then - usage - exit 0 - fi - echo "Unknown option: $1" >&2 - usage - exit 64 -fi +CHECK_AUTH_ONLY=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --check-auth) + CHECK_AUTH_ONLY=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 64 + ;; + esac +done if [[ -f "${ENV_FILE}" ]]; then set -a @@ -44,37 +55,133 @@ if [[ -z "${DATABASE_PASSWORD//[[:space:]]/}" ]]; then exit 1 fi +# Locate a psql client: PGDG installations (18 down to 13) first, then the +# system psql. Prints the path, or nothing when no client exists. +find_psql_binary() { + local ver + for ver in 18 17 16 15 14 13; do + if [[ -x "/usr/pgsql-${ver}/bin/psql" ]]; then + echo "/usr/pgsql-${ver}/bin/psql" + return 0 + fi + done + command -v psql || true +} + +# ADR-006 §16: a FIPS-mode host cannot complete md5 password authentication, +# so a role whose pg_authid verifier is still md5 makes the Heimdall server +# fail to connect the moment FIPS mode is enabled. This check WARNS and prints +# the remediation — it never modifies the database (the remediation rewrites +# credentials; the operator decides). +# +# Three outcomes, all return 0 — detection must never crash the setup: +# SCRAM verifier -> silent pass +# md5 verifier -> loud warning + the exact §16 remediation sequence +# no pg_authid access / role absent -> prints the manual superuser check +check_password_verifier() { + local psql_bin="$1" + local conninfo="$2" + local role="$3" + local row="" + local verifier="" + if ! row="$(PGPASSWORD="${DATABASE_PASSWORD}" "${psql_bin}" "${conninfo}" \ + -v ON_ERROR_STOP=1 -tA -v db_user="${role}" 2>&1 <<'SQL' +SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = :'db_user'; +SQL + )"; then + echo "NOTE: could not read pg_authid as role '${role}' (superuser required)." + echo " The FIPS password-verifier check was skipped. To check manually," + echo " run as a PostgreSQL superuser:" + echo " SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = '${role}';" + echo " A result starting 'md5' will fail on FIPS-mode hosts; see the" + echo " remediation in INSTALL.md (ADR-006 §16)." + return 0 + fi + verifier="${row##*|}" + verifier="${verifier//[[:space:]]/}" + case "${verifier}" in + SCRAM-SHA-256*) + return 0 + ;; + md5*) + echo "==============================================================================" + echo "WARNING: the PostgreSQL password verifier for role '${role}' is MD5." + echo "A FIPS-mode host cannot complete md5 password authentication, so the" + echo "Heimdall server will FAIL TO CONNECT to this database once FIPS mode is" + echo "enabled. Remediation (run as a PostgreSQL superuser; ADR-006 §16):" + echo "" + echo " ALTER SYSTEM SET password_encryption = 'scram-sha-256';" + echo " SELECT pg_reload_conf();" + echo " ALTER ROLE ${role} WITH PASSWORD ''; -- rewrites the verifier" + echo " -- then change any md5 rules in pg_hba.conf to scram-sha-256 and reload" + echo "" + echo "This setup does NOT modify the database authentication configuration —" + echo "the ALTER ROLE rewrites stored credentials, so the operator decides." + echo "==============================================================================" + return 0 + ;; + '') + echo "NOTE: role '${role}' was not found in pg_authid; the FIPS password-" + echo " verifier check was skipped (the role may not exist yet)." + return 0 + ;; + *) + echo "NOTE: unrecognized password verifier prefix for role '${role}': ${verifier}" + echo " Verify it is SCRAM-SHA-256 before enabling FIPS mode (ADR-006 §16)." + return 0 + ;; + esac +} + +run_verifier_check_via_tcp() { + local psql_bin="" + psql_bin="$(find_psql_binary)" + if [[ -z "${psql_bin}" ]]; then + echo "NOTE: psql not found; the FIPS password-verifier check was skipped." + echo " Install a PostgreSQL client, or run the manual superuser check" + echo " from INSTALL.md (ADR-006 §16)." + return 0 + fi + check_password_verifier "${psql_bin}" \ + "postgresql://${DATABASE_USERNAME}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME:-postgres}" \ + "${DATABASE_USERNAME}" +} + +if [[ "${CHECK_AUTH_ONLY}" -eq 1 ]]; then + run_verifier_check_via_tcp + echo "Password-verifier check complete for role '${DATABASE_USERNAME}' on ${DATABASE_HOST}:${DATABASE_PORT}." + exit 0 +fi + if [[ "${DATABASE_HOST}" != "127.0.0.1" && "${DATABASE_HOST}" != "localhost" ]]; then echo "DATABASE_HOST=${DATABASE_HOST}; skipping local PostgreSQL bootstrap." + # ADR-006 §16: pre-existing remote/customer databases are exactly where an + # md5 verifier survives unnoticed — check before the app fails to connect. + run_verifier_check_via_tcp exit 0 fi -# Detect PGDG installations (18 down to 13), then fall back to system psql. -PSQL_BIN="" +# Local bootstrap: locate the client plus the version-specific setup paths. +PSQL_BIN="$(find_psql_binary)" PG_SETUP_BIN="" PG_SERVICE="" PG_DATA_DIR="" -for ver in 18 17 16 15 14 13; do - if [[ -x "/usr/pgsql-${ver}/bin/psql" ]]; then - PSQL_BIN="/usr/pgsql-${ver}/bin/psql" - PG_SETUP_BIN="/usr/pgsql-${ver}/bin/postgresql-${ver}-setup" - PG_SERVICE="postgresql-${ver}" - PG_DATA_DIR="/var/lib/pgsql/${ver}/data" - break - fi -done - if [[ -z "${PSQL_BIN}" ]]; then - if command -v psql >/dev/null 2>&1; then - PSQL_BIN="$(command -v psql)" - PG_SETUP_BIN="$(command -v postgresql-setup || true)" - PG_SERVICE="postgresql" - PG_DATA_DIR="/var/lib/pgsql/data" - else - echo "psql not found. Install a PostgreSQL client/server (13+) before running setup." >&2 - exit 1 - fi + echo "psql not found. Install a PostgreSQL client/server (13+) before running setup." >&2 + exit 1 +fi + +if [[ "${PSQL_BIN}" == /usr/pgsql-*/bin/psql ]]; then + PG_VER="${PSQL_BIN#/usr/pgsql-}" + PG_VER="${PG_VER%%/*}" + PG_SETUP_BIN="/usr/pgsql-${PG_VER}/bin/postgresql-${PG_VER}-setup" + PG_SERVICE="postgresql-${PG_VER}" + PG_DATA_DIR="/var/lib/pgsql/${PG_VER}/data" +else + PG_SETUP_BIN="$(command -v postgresql-setup || true)" + PG_SERVICE="postgresql" + PG_DATA_DIR="/var/lib/pgsql/data" fi PG_MAJOR="$("${PSQL_BIN}" --version | awk '{print $3}' | cut -d. -f1)" diff --git a/packaging/rpm/heimdall-server.service b/packaging/rpm/heimdall-server.service index ac8e9a8adb..d71b5223c9 100644 --- a/packaging/rpm/heimdall-server.service +++ b/packaging/rpm/heimdall-server.service @@ -12,6 +12,11 @@ WorkingDirectory=/usr/share/heimdall-server/apps/backend EnvironmentFile=-/etc/sysconfig/heimdall-server EnvironmentFile=-/etc/heimdall-server/backend.env Environment=NODE_ENV=production +# ADR-006 §11: libuv reads UV_THREADPOOL_SIZE at first threadpool use. The +# PBKDF2 KDF concurrency limiter assumes 8 threadpool slots — the same value +# the container path sets (Dockerfile ENV / cmd.sh). EnvironmentFile= values +# override Environment=, so sysconfig or backend.env can still override this. +Environment=UV_THREADPOOL_SIZE=8 ExecStartPre=/usr/bin/test -x /usr/bin/node ExecStartPre=/usr/bin/heimdall-cli validate --skip-db ExecStart=/usr/bin/heimdall-server diff --git a/packaging/rpm/heimdall-server.sh b/packaging/rpm/heimdall-server.sh index f397995943..e3df908f82 100644 --- a/packaging/rpm/heimdall-server.sh +++ b/packaging/rpm/heimdall-server.sh @@ -7,7 +7,7 @@ ENV_FILE="/etc/heimdall-server/backend.env" if [[ -f "${ENV_FILE}" ]]; then set -a - # shellcheck disable=SC1091 + # shellcheck disable=SC1090 source "${ENV_FILE}" set +a fi diff --git a/packaging/rpm/heimdall-setup.sh b/packaging/rpm/heimdall-setup.sh index 6717c4513e..a9fa1a0319 100644 --- a/packaging/rpm/heimdall-setup.sh +++ b/packaging/rpm/heimdall-setup.sh @@ -349,7 +349,8 @@ if [[ "${SKIP_TLS}" -eq 0 ]]; then elif rpm -q epel-release >/dev/null 2>&1; then echo " Install Caddy with: sudo dnf install -y caddy" else - # Detect EL major version from os-release (more reliable than rpm -E) + # Detect EL major version from os-release (more reliable than rpm -E). + # shellcheck disable=SC1091 # runtime source of a system file — shellcheck cannot follow it (wiki-prescribed directive) el_ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID%%.*}")" el_ver="${el_ver:-9}" echo " EPEL repository not found. Install EPEL and Caddy with:" diff --git a/packaging/rpm/setup-rpm-build-env.sh b/packaging/rpm/setup-rpm-build-env.sh index fb96fefb54..b140ccbff9 100755 --- a/packaging/rpm/setup-rpm-build-env.sh +++ b/packaging/rpm/setup-rpm-build-env.sh @@ -178,6 +178,7 @@ install_build_deps() { rpm_arch="$(rpm -E '%{_arch}')" fi if [[ -z "${el_major}" ]]; then + # shellcheck disable=SC1091 # runtime source of a system file — shellcheck cannot follow it el_major="$(. /etc/os-release && printf '%s' "${VERSION_ID%%.*}")" fi if [[ -z "${el_major}" ]]; then @@ -298,7 +299,8 @@ fetch_source_tarball() { --version "${version}" \ --output-dir "$(dirname "${dest}")" # fetch-source.sh names it heimdall-server-VERSION.tar.gz, rename to match spec - local fetched="$(dirname "${dest}")/heimdall-server-${version}.tar.gz" + local fetched + fetched="$(dirname "${dest}")/heimdall-server-${version}.tar.gz" if [[ -f "${fetched}" && "${fetched}" != "${dest}" ]]; then mv "${fetched}" "${dest}" fi From a669c0011202f642c740f508a0204f3f51d8dd0d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:21:09 -0400 Subject: [PATCH 042/197] =?UTF-8?q?chore:=20modernize=20the=20dev=20workfl?= =?UTF-8?q?ow=20=E2=80=94=20self-selecting=20start:dev,=20per-app=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root start:dev becomes 'lerna run start:dev --parallel --stream' (runs only in packages defining the script — no ignore list to maintain); the frontend owns its proxy via API_PROXY_TARGET in apps/frontend/.env.development and inherits nothing from the backend .env; start:built serves the built app on one URL (:3000); README documents both run modes, the ports, and the PORT trap. Authored by: Aaron Lippold --- README.md | 19 +++++++++++++++++++ apps/backend/.env-example | 4 ++-- apps/frontend/.env.development | 14 ++++++++++++++ apps/frontend/vue.config.js | 16 ++++++++-------- package.json | 3 ++- 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 apps/frontend/.env.development diff --git a/README.md b/README.md index 7d89875aee..7d52488cc7 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,25 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve This will start both the frontend and backend in development mode, meaning any changes you make to the source code will take effect immediately. Please note we already have a Visual Studio Code workspace file you can use to organize your workspace. +### Run modes and ports + +Development mode (`yarn start:dev`) runs **two servers**: + +- **Backend (NestJS) — port `3000`:** API only — `/health`, `/server`, `/authn`, etc. It does **not** serve the UI in dev mode, so browsing `localhost:3000` returns a JSON 404 for `dist/frontend/index.html`. This is expected. +- **Frontend (webpack dev server) — port `8080` by default:** **The app you browse.** Hot-reloads on code changes and proxies API calls to the backend (`API_PROXY_TARGET` in `apps/frontend/.env.development`). If 8080 is busy the dev server picks the next free port — **read the `Local: http://localhost:` line it prints.** + +To run the whole app on **one URL** (`localhost:3000`, production-style, no hot-reload): + +```bash +yarn start:built +``` + +This builds the frontend to `dist/frontend/` and the backend to `dist/`, then serves both from the backend on port 3000. + +> **Warning:** Do not set `PORT` in `apps/backend/.env` for local development. The backend already defaults to 3000. The frontend's port and proxy are configured independently in `apps/frontend/.env.development` (personal overrides go in the gitignored `apps/frontend/.env.development.local`). +> +> The backend's `.env` is for **development**. The backend test suite pins its own environment (`NODE_ENV=test`) and derives its own database (`heimdall-server-test` — create it once with `NODE_ENV=test yarn backend sequelize db:create db:migrate`), so running tests never requires editing `.env`. + ### Debugging Heimdall Server If you are using Visual Studio Code, it is very simple to debug this application locally. First open up the Visual Studio Code workspace and ensure the [Node debugger Auto Attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) feature in Visual Studio Code is enabled. Next, open the integrated Visual Studio Code terminal and run: diff --git a/apps/backend/.env-example b/apps/backend/.env-example index 1a3d724fa2..c5ca9fb1d8 100644 --- a/apps/backend/.env-example +++ b/apps/backend/.env-example @@ -10,8 +10,8 @@ CLASSIFICATION_BANNER_TEXT_COLOR= ## Backend -NODE_ENV= -PORT= +NODE_ENV= +PORT= ADMIN_EMAIL= ADMIN_USES_EXTERNAL_AUTH= diff --git a/apps/frontend/.env.development b/apps/frontend/.env.development new file mode 100644 index 0000000000..81681b57a2 --- /dev/null +++ b/apps/frontend/.env.development @@ -0,0 +1,14 @@ +# Frontend dev-server configuration (vue-cli env file, loaded for `start:dev`). +# This file is CHECKED IN — put personal overrides in .env.development.local +# (gitignored) instead of editing this file. +# +# API_PROXY_TARGET points the webpack dev server's proxy at the backend so the +# browser talks to ONE origin (this dev server) and API calls are forwarded. +# Leave it empty in .env.development.local to develop heimdall-lite standalone +# with no backend (GET /server then fails and the app runs in lite mode — +# src/store/server.ts handles that path). +# +# The frontend deliberately reads NOTHING from apps/backend/.env: sharing the +# backend's PORT here (as both this server's bind port and the proxy target) +# is what broke dev on 2026-08-10. +API_PROXY_TARGET=http://127.0.0.1:3000 diff --git a/apps/frontend/vue.config.js b/apps/frontend/vue.config.js index 5e19f47225..adadea8675 100644 --- a/apps/frontend/vue.config.js +++ b/apps/frontend/vue.config.js @@ -26,14 +26,14 @@ module.exports = { lintOnSave: 'warning', publicPath: '/', devServer: { - // JWT_SECRET is a required secret for the backend. If it is sourced - // then it is safe to assume the app is in server mode in development. - // - // PORT is not required so use the default backend port value - // is used here if JWT_SECRET is applied but PORT is undefined - proxy: process.env.JWT_SECRET - ? `http://127.0.0.1:${process.env.PORT || 3000}` - : '' + // API_PROXY_TARGET (apps/frontend/.env.development) points this dev + // server's proxy at the backend — server-mode development. Unset/empty + // (e.g. via .env.development.local) means no proxy: GET /server fails and + // the app runs as heimdall-lite standalone (src/store/server.ts catches + // that path). The frontend owns this setting; it deliberately reads + // NOTHING from apps/backend/.env — reusing the backend's PORT here (as + // both the bind port and the proxy target) broke dev on 2026-08-10. + proxy: process.env.API_PROXY_TARGET || '' }, outputDir: '../../dist/frontend', configureWebpack: { diff --git a/package.json b/package.json index 94de8620c6..1c67da4ccd 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "lint:ci": "eslint --max-warnings 0 || true", "pack:all": "lerna exec yarn pack --scope inspecjs --scope @mitre/heimdall-lite --scope @mitre/hdf-converters --parallel", "start": "yarn backend start", - "start:dev": "./node_modules/.bin/dotenv -e ./apps/backend/.env -- lerna exec yarn run start:dev --ignore @heimdall/common --ignore @mitre/hdf-converters --ignore @heimdall/password-complexity --ignore @heimdall/cypress-tests --ignore inspecjs", + "start:built": "yarn build && yarn backend start", + "start:dev": "lerna run start:dev --parallel --stream", "test:ui": "cypress run", "test:ui:open": "cypress open" }, From 84fcda6a2118b9f3ecc579100cd8ad012c062a57 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:22:50 -0400 Subject: [PATCH 043/197] fix: import CryptoModule in the evaluations spec harness The committed users.service now injects PasswordService; without this slice a fresh checkout fails DI resolution in this spec. Partial staging of a file whose remaining changes are parked (separate routing-bug workstream). Authored by: Aaron Lippold --- apps/backend/src/evaluations/evaluations.controller.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/evaluations/evaluations.controller.spec.ts b/apps/backend/src/evaluations/evaluations.controller.spec.ts index 3245351b64..a5fbb4d8a2 100644 --- a/apps/backend/src/evaluations/evaluations.controller.spec.ts +++ b/apps/backend/src/evaluations/evaluations.controller.spec.ts @@ -21,6 +21,7 @@ import { } from '../../test/constants/users-test.constant'; import {AuthzService} from '../authz/authz.service'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -70,6 +71,7 @@ describe('EvaluationsController', () => { module = await Test.createTestingModule({ controllers: [EvaluationsController], imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ EvaluationTag, From a790b7cdb09280b9d34386eb0156c88011fd40b0 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 09:44:24 -0400 Subject: [PATCH 044/197] feat: wire health probes into compose and the RPM runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose server service gains a readiness healthcheck that asserts the response body, not just the HTTP status: the app serves the SPA as a catch-all for unmatched routes, so an image without /health/ready answers 200 with index.html and a bare 'curl -f' would report false-healthy. nginx depends_on deliberately stays service_started (release coupling — a stale cached image must not deadlock startup). INSTALL.md gains a post-install smoke section for both probe endpoints with expected output. Authored by: Aaron Lippold --- docker-compose.yml | 32 ++++++++++++++++++++++++++++++++ packaging/rpm/INSTALL.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index c1accd727b..42a124cb15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,34 @@ services: depends_on: database: condition: service_healthy + # ADR-006 §17: probe the readiness endpoint only. /admin/migration-status + # is NEVER probed — its counts are full table scans. + # + # RELEASE COUPLING (deliberate, not hidden): this file pulls + # `release-latest`, and /health/ready ships with the next release. Both + # land in the same repo state, so release-latest consumers get them + # together; a git-pull user running a stale cached image will see + # "unhealthy" until `docker compose pull`. That status stays purely + # informational — see the nginx depends_on note below. + # + # The response body is matched, not just the HTTP status, because the app + # serves the SPA as a catch-all for unmatched routes: an image WITHOUT + # /health/ready answers 200 with index.html, which `curl -f` alone would + # accept as healthy. Matching the readiness envelope is what makes a stale + # image report unhealthy instead of silently lying. + # + # start_period exceeds the database's 80s because the server's first boot + # additionally runs migrations and seeding before it can serve traffic. + healthcheck: + test: + [ + "CMD-SHELL", + "curl -fsS http://localhost:3000/health/ready | grep -q '\"status\":\"ok\"'" + ] + interval: 30s + timeout: 60s + retries: 5 + start_period: 120s nginx: image: nginx:alpine @@ -60,6 +88,10 @@ services: ports: - "80:80" - "443:443" + # Deliberately NOT upgraded to `condition: service_healthy` (blast-radius + # control): until a release ships with /health/ready, a stale cached image + # would never report healthy and nginx would wait forever. Revisit only + # after the endpoints are in a published release. depends_on: - "server" diff --git a/packaging/rpm/INSTALL.md b/packaging/rpm/INSTALL.md index e82125abcf..3d7c6ea874 100644 --- a/packaging/rpm/INSTALL.md +++ b/packaging/rpm/INSTALL.md @@ -203,6 +203,45 @@ DATABASE_NAME=heimdall-server-production DATABASE_SSL=true ``` +## Post-Install Smoke Check + +Verify the service is actually serving before logging in. Both endpoints are +unauthenticated by design so probes and load balancers can reach them. + +```bash +# 1. Liveness — is the process up and serving? (no dependency checks) +curl -fsS http://localhost:3000/health +``` + +```json +{"status":"ok","version":"2.13.0"} +``` + +```bash +# 2. Readiness — is the database reachable? (the one hard dependency) +curl -fsS http://localhost:3000/health/ready +``` + +```json +{"status":"ok","info":{"database":{"status":"up"}},"error":{},"details":{"database":{"status":"up"}}} +``` + +Both commands exit `0` on success. Interpreting failures: + +- **`curl: (7) Failed to connect`** — the service is not listening; check + `systemctl status heimdall-server`. +- **`/health` succeeds but `/health/ready` returns 503** — the app is up but + the database is unreachable; check the `DATABASE_*` settings in + `/etc/heimdall-server/backend.env`. +- **Both succeed** — the install is serving; proceed to Initial Login. + +If you changed the listen port (see _Changing the Listen Port_), substitute it +for `3000` above. + +> **Note:** probe only these two endpoints. The migration report at +> `/admin/migration-status` is authenticated and runs full table scans — it is +> an operator report, never a health probe (ADR-006 §17). + ## Initial Login After setup completes, the admin credentials are printed to the terminal: From d29c2c5966ec2cebe5d1f3cf612298fc1d19a05b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 14:24:05 -0400 Subject: [PATCH 045/197] fix: pin the Tailwind generator and guard its committed output The committed reverse-html-mapper assets were generated by Tailwind 4.2.4 while the declared range ^4.0.6 resolved to 4.3.1, so every build rewrote style.css and the embedded-assets.ts derived from it and left the tree dirty. Pin both Tailwind packages to an exact 4.3.3 and regenerate once. The only semantic change in the regenerated CSS is the removal of two utilities, .start and .end, which appear in neither template.html (the sole @source) nor any code path in the HTML converter; the remaining selector set is identical. Guard it the way inspecjs already guards its generated parsers: a validate-generated script that diffs the artifacts against HEAD, run in CI after a regeneration, so a Tailwind bump without regenerated output fails the build instead of silently dirtying every checkout. Authored by: Aaron Lippold --- .github/workflows/hdfconverter-tests.yml | 5 + .../data/reverse-html-mapper/style.css | 4 +- libs/hdf-converters/package.json | 5 +- .../html/embedded-assets.ts | 2 +- yarn.lock | 210 ++++++++++-------- 5 files changed, 131 insertions(+), 95 deletions(-) diff --git a/.github/workflows/hdfconverter-tests.yml b/.github/workflows/hdfconverter-tests.yml index 1c65b4ab39..94109d2860 100644 --- a/.github/workflows/hdfconverter-tests.yml +++ b/.github/workflows/hdfconverter-tests.yml @@ -52,6 +52,11 @@ jobs: - name: Start Mock Sonarqube Server run: yarn run cypress-test mock-json & + - name: Validate there are no changes between the Tailwind source and the generated assets + run: | + yarn hdf-converters prebuild + yarn hdf-converters validate-generated + - name: Run unit tests run: yarn hdf-converters test:ci env: diff --git a/libs/hdf-converters/data/reverse-html-mapper/style.css b/libs/hdf-converters/data/reverse-html-mapper/style.css index a755de9b9a..3f4671177f 100644 --- a/libs/hdf-converters/data/reverse-html-mapper/style.css +++ b/libs/hdf-converters/data/reverse-html-mapper/style.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 0)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 0)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 0)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 0)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 0)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 0)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 0)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 0)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\!absolute{position:absolute!important}.\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.-top-\[18px\]{top:-18px}.-top-\[21px\]{top:-21px}.-top-\[35px\]{top:-35px}.top-0{top:calc(var(--spacing) * 0)}.top-1{top:calc(var(--spacing) * 1)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\[11px\]{top:11px}.top-\[13px\]{top:13px}.top-\[50\%\]{top:50%}.top-\[50px\]{top:50px}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:calc(var(--spacing) * 1)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\[47px\]{bottom:-47px}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:calc(var(--spacing) * 1)}.bottom-1\/2{bottom:50%}.-left-\[15px\]{left:-15px}.-left-\[9999px\]{left:-9999px}.left-0{left:calc(var(--spacing) * 0)}.left-1{left:calc(var(--spacing) * 1)}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.left-\[50px\]{left:50px}.left-\[calc\(50\%-1px\)\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[2\]{z-index:2}.z-\[999\]{z-index:999}.z-\[1035\]{z-index:1035}.z-\[1040\]{z-index:1040}.z-\[1065\]{z-index:1065}.z-\[1066\]{z-index:1066}.z-\[1070\]{z-index:1070}.z-\[1080\]{z-index:1080}.z-\[1100\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:320px){.container\!{max-width:320px!important}}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:calc(var(--spacing) * 0)}.m-1{margin:calc(var(--spacing) * 1)}.m-auto{margin:auto}.mx-0{margin-inline:calc(var(--spacing) * 0)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\[10px\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\!my-0{margin-block:calc(var(--spacing) * 0)!important}.my-0{margin-block:calc(var(--spacing) * 0)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\[5px\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\[0\.15rem\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\[6px\]{margin-right:6px}.mr-\[8px\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\[0\.125rem\]{margin-bottom:.125rem}.mb-\[10px\]{margin-bottom:10px}.-ml-\[1\.5rem\]{margin-left:-1.5rem}.ml-0{margin-left:calc(var(--spacing) * 0)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\[3px\]{margin-left:3px}.ml-\[30px\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\!h-0{height:calc(var(--spacing) * 0)!important}.\!h-px{height:1px!important}.h-0{height:calc(var(--spacing) * 0)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\[0\.9375rem\]{height:.9375rem}.h-\[1\.4rem\]{height:1.4rem}.h-\[1\.125rem\]{height:1.125rem}.h-\[2px\]{height:2px}.h-\[4px\]{height:4px}.h-\[6px\]{height:6px}.h-\[10px\]{height:10px}.h-\[30px\]{height:30px}.h-\[32px\]{height:32px}.h-\[40px\]{height:40px}.h-\[42px\]{height:42px}.h-\[48px\]{height:48px}.h-\[50px\]{height:50px}.h-\[56px\]{height:56px}.h-\[72px\]{height:72px}.h-\[100px\]{height:100px}.h-\[120px\]{height:120px}.h-\[160px\]{height:160px}.h-\[260px\]{height:260px}.h-\[380px\]{height:380px}.h-\[512px\]{height:512px}.h-\[calc\(100\%-100px\)\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[calc\(100\%-64px\)\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\[1\.5rem\]{min-height:1.5rem}.min-h-\[40px\]{min-height:40px}.min-h-\[305px\]{min-height:305px}.min-h-\[325px\]{min-height:325px}.min-h-\[auto\]{min-height:auto}.\!w-px{width:1px!important}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[0\.9375rem\]{width:.9375rem}.w-\[1\.4rem\]{width:1.4rem}.w-\[1\.125rem\]{width:1.125rem}.w-\[2px\]{width:2px}.w-\[4px\]{width:4px}.w-\[6px\]{width:6px}.w-\[15px\]{width:15px}.w-\[30px\]{width:30px}.w-\[32px\]{width:32px}.w-\[45\%\]{width:45%}.w-\[50px\]{width:50px}.w-\[70px\]{width:70px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[304px\]{width:304px}.w-\[328px\]{width:328px}.w-\[calc\(100\%-100px\)\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\[90\%\]{max-width:90%}.max-w-\[200px\]{max-width:200px}.max-w-\[267px\]{max-width:267px}.max-w-\[325px\]{max-width:325px}.max-w-\[calc\(100\%-1rem\)\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[48px\]{min-width:48px}.min-w-\[64px\]{min-width:64px}.min-w-\[100px\]{min-width:100px}.min-w-\[310px\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\[0_0\]{transform-origin:0 0}.origin-\[50\%_50\%\]{transform-origin:50%}.origin-\[center_bottom_0\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[6px\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[50\%\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[150\%\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\[50\%\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[6px\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\[0\.8\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[0\.25\]{scale:.25}.scale-\[1\.02\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\[-180deg\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\[fade-in_0\.3s_both\]{animation:.3s both fade-in}.animate-\[fade-in_0\.15s_both\]{animation:.15s both fade-in}.animate-\[fade-in_350ms_ease-in-out\]{animation:.35s ease-in-out fade-in}.animate-\[fade-out_0\.3s_both\]{animation:.3s both fade-out}.animate-\[fade-out_0\.15s_both\]{animation:.15s both fade-out}.animate-\[fade-out_350ms_ease-in-out\]{animation:.35s ease-in-out fade-out}.animate-\[progress_3s_ease-in-out_infinite\]{animation:3s ease-in-out infinite progress}.animate-\[show-up-clock_350ms_linear\]{animation:.35s linear show-up-clock}.animate-\[slide-in-left_0\.8s_both\]{animation:.8s both slide-in-left}.animate-\[slide-in-right_0\.8s_both\]{animation:.8s both slide-in-right}.animate-\[slide-out-left_0\.8s_both\]{animation:.8s both slide-out-left}.animate-\[slide-out-right_0\.8s_both\]{animation:.8s both slide-out-right}.animate-\[spinner-grow_0\.75s_linear_infinite\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\[0\.5rem\]{border-radius:.5rem}.rounded-\[0\.6rem\]{border-radius:.6rem}.rounded-\[0\.25rem\]{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[16px\]{border-radius:16px}.rounded-\[50\%\]{border-radius:50%}.rounded-\[100\%\]{border-radius:100%}.rounded-\[999px\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\[0\.6rem\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\[0\.25rem\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\[0\.25rem\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\!border-\[3px\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\[\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[0\.15em\]{border-style:var(--tw-border-style);border-width:.15em}.border-\[0\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[1px\]{border-style:var(--tw-border-style);border-width:1px}.border-\[14px\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\[0\.125rem\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\!border-\[\#14a44d\]{border-color:#14a44d!important}.\!border-\[\#b2b3b4\]{border-color:#b2b3b4!important}.\!border-\[\#dc4c64\]{border-color:#dc4c64!important}.border-\[\#3b71ca\]{border-color:#3b71ca}.border-\[\#14a44d\]{border-color:#14a44d}.border-\[\#dc4c64\]{border-color:#dc4c64}.border-\[\#eee\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\!bg-\[\#858585\]{background-color:#858585!important}.\!bg-danger-100{background-color:#fae5e9!important}.\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\!bg-primary-100{background-color:#e3ebf7!important}.\!bg-success-100{background-color:#d6fae4!important}.bg-\[\#000000e6\]{background-color:#000000e6}.bg-\[\#3b71ca\]{background-color:#3b71ca}.bg-\[\#6d6d6d\]{background-color:#6d6d6d}.bg-\[\#00000012\]{background-color:#00000012}.bg-\[\#00000066\]{background-color:#0006}.bg-\[\#aaa\]{background-color:#aaa}.bg-\[\#eceff1\]{background-color:#eceff1}.bg-\[\#eee\]{background-color:#eee}.bg-\[rgba\(0\,0\,0\,0\.4\)\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\[\#336dec\]{fill:#336dec}.fill-\[\#afafaf\]{fill:#afafaf}.fill-current{fill:currentColor}.\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\[1rem\]{padding:1rem}.p-\[5px\]{padding:5px}.p-\[auto\]{padding:auto}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[0\.4rem\]{padding-inline:.4rem}.px-\[1\.4rem\]{padding-inline:1.4rem}.px-\[10px\]{padding-inline:10px}.px-\[12px\]{padding-inline:12px}.px-\[auto\]{padding-inline:auto}.\!py-0{padding-block:calc(var(--spacing) * 0)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:calc(var(--spacing) * 0)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\[0\.4rem\]{padding-block:.4rem}.py-\[0\.32rem\]{padding-block:.32rem}.py-\[0\.33rem\]{padding-block:.33rem}.py-\[0\.4375rem\]{padding-block:.4375rem}.py-\[1px\]{padding-block:1px}.py-\[5px\]{padding-block:5px}.py-\[10px\]{padding-block:10px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\[0\.37rem\]{padding-top:.37rem}.pt-\[6px\]{padding-top:6px}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\[24px\]{padding-right:24px}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\[5px\]{padding-bottom:5px}.pl-0{padding-left:calc(var(--spacing) * 0)}.pl-1{padding-left:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[1\.5rem\]{padding-left:1.5rem}.pl-\[8px\]{padding-left:8px}.pl-\[18px\]{padding-left:18px}.pl-\[50px\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\[-0\.125em\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[2\.5rem\]{font-size:2.5rem}.text-\[3\.75rem\]{font-size:3.75rem}.text-\[10px\]{font-size:10px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[34px\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[2\.15\]{--tw-leading:2.15;line-height:2.15}.leading-\[40px\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.00833em\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\[\.1rem\],.tracking-\[0\.1rem\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\[1\.7px\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-\[\#14a44d\]{color:#14a44d!important}.\!text-\[\#dc4c64\]{color:#dc4c64!important}.\!text-danger-700{color:#b0233a!important}.\!text-gray-50{color:var(--color-gray-50)!important}.\!text-primary{color:#3b71ca!important}.\!text-primary-700{color:#285192!important}.\!text-success-700{color:#0e7537!important}.text-\[\#3b71ca\]{color:#3b71ca}.text-\[\#4f4f4f\]{color:#4f4f4f}.text-\[\#14a44d\]{color:#14a44d}.text-\[\#212529\]{color:#212529}.text-\[\#b3afaf\]{color:#b3afaf}.text-\[\#b3b3b3\]{color:#b3b3b3}.text-\[\#dc4c64\]{color:#dc4c64}.text-\[\#ffffff8a\]{color:#ffffff8a}.text-\[rgb\(220\,76\,100\)\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\/\[64\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\/\[64\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\!opacity-0{opacity:0!important}.\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\[\.53\]{opacity:.53}.opacity-\[\.54\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0px_3px_0_rgba\(0\,0\,0\,0\.07\)\,0_2px_2px_0_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_5px_0_rgba\(0\,0\,0\,0\.16\)\,_0_2px_10px_0_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_9px_-4px_\#3b71ca\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_10px_15px_-3px_rgba\(0\,0\,0\,0\.07\)\,0_4px_6px_-2px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_2px_15px_-3px_rgba\(0\,0\,0\,\.07\)\,_0px_10px_20px_-2px_rgba\(0\,0\,0\,\.04\)\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\/login,.shadow\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,_opacity\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,box-shadow\,border\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,_opacity\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,height\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\[0ms\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\[150ms\]{--tw-duration:.15s;transition-duration:.15s}.duration-\[200ms\]{--tw-duration:.2s;transition-duration:.2s}.duration-\[250ms\]{--tw-duration:.25s;transition-duration:.25s}.duration-\[350ms\]{--tw-duration:.35s;transition-duration:.35s}.duration-\[400ms\]{--tw-duration:.4s;transition-duration:.4s}.duration-\[1000ms\]{--tw-duration:1s;transition-duration:1s}.ease-\[cubic-bezier\(0\,0\,0\.15\,1\)\,_cubic-bezier\(0\,0\,0\.15\,1\)\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\[cubic-bezier\(0\.4\,0\,0\.2\,1\)\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\)\],.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\.0\)\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\[ease\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\!\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)!important}.\[bash\:1221\]{bash:1221}.\[check\:5737\]{check:5737}.\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)}.\[direction\:ltr\]{direction:ltr}.\[drm\:hdmiphy_enable\.part\.0\]{drm:hdmiphy enable.part0}.\[drm\:samsung_dsim_host_attach\]{drm:samsung dsim host attach}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[pid\:5118\,cpu4\,QThread\,0\]{pid:5118,cpu4,QThread,0}.\[pid\:5118\,cpu4\,QThread\,1\]{pid:5118,cpu4,QThread,1}.\[pid\:5118\,cpu4\,QThread\,2\]{pid:5118,cpu4,QThread,2}.\[pid\:5118\,cpu4\,QThread\,3\]{pid:5118,cpu4,QThread,3}.\[pid\:5118\,cpu4\,QThread\,4\]{pid:5118,cpu4,QThread,4}.\[pid\:5118\,cpu4\,QThread\,9\]{pid:5118,cpu4,QThread,9}.\[transition\:background-color_\.2s_linear\,_height_\.2s_ease-in-out\]{transition:background-color .2s linear,height .2s ease-in-out}.\[transition\:background-color_\.2s_linear\,_width_\.2s_ease-in-out\,_opacity\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\[transition\:background-color_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,box-shadow_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,border_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\/ps\:opacity-60:is(:where(.group\/ps):hover *){opacity:.6}.group-hover\/x\:h-\[11px\]:is(:where(.group\/x):hover *){height:11px}.group-hover\/x\:bg-\[\#999\]:is(:where(.group\/x):hover *){background-color:#999}.group-hover\/y\:w-\[11px\]:is(:where(.group\/y):hover *){width:11px}.group-hover\/y\:bg-\[\#999\]:is(:where(.group\/y):hover *){background-color:#999}}.group-focus\/ps\:opacity-60:is(:where(.group\/ps):focus *){opacity:.6}.group-focus\/ps\:opacity-100:is(:where(.group\/ps):focus *){opacity:1}.group-focus\/x\:h-\[0\.6875rem\]:is(:where(.group\/x):focus *){height:.6875rem}.group-focus\/x\:bg-\[\#999\]:is(:where(.group\/x):focus *){background-color:#999}.group-focus\/y\:w-\[0\.6875rem\]:is(:where(.group\/y):focus *){width:.6875rem}.group-focus\/y\:bg-\[\#999\]:is(:where(.group\/y):focus *){background-color:#999}.group-active\/ps\:opacity-100:is(:where(.group\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:calc(var(--spacing) * 0)}.group-data-te-collapse-collapsed\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\:fill-\[\#212529\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\[te-input-focused\]\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-focused\]\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-focused\]\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-focused\]\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-focused\]\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-focused\]\:border-\[\#14a44d\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\[te-input-focused\]\:border-\[\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\[te-input-focused\]\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\[te-input-focused\]\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\[te-input-focused\]\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-state-active\]\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-state-active\]\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-state-active\]\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-state-active\]\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-state-active\]\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-state-active\]\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\[te-select-option-group-ref\]\/opt\:pl-7:is(:where(.group\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\[te-was-validated\]\/validation\:mb-4:is(:where(.group\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\[\&\.ps--active-x\]\/ps\:block:is(:where(.group\/ps).ps--active-x *){display:block}.group-\[\&\.ps--active-x\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-x *){background-color:#0000}.group-\[\&\.ps--active-y\]\/ps\:block:is(:where(.group\/ps).ps--active-y *){display:block}.group-\[\&\.ps--active-y\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-y *){background-color:#0000}.group-\[\&\.ps--clicking\]\/x\:h-\[11px\]:is(:where(.group\/x).ps--clicking *){height:11px}.group-\[\&\.ps--clicking\]\/x\:bg-\[\#999\]:is(:where(.group\/x).ps--clicking *){background-color:#999}.group-\[\&\.ps--clicking\]\/y\:w-\[11px\]:is(:where(.group\/y).ps--clicking *){width:11px}.group-\[\&\.ps--clicking\]\/y\:bg-\[\#999\]:is(:where(.group\/y).ps--clicking *){background-color:#999}.group-\[\&\.ps--scrolling-x\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-x *),.group-\[\&\.ps--scrolling-y\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-y *){opacity:.6}.group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\[\[data-te-datepicker-cell-current\]\]\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\[\[data-te-datepicker-cell-current\]\]\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\[\[data-te-datepicker-cell-current\]\]\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\[\[data-te-datepicker-cell-selected\]\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\[\[data-te-datepicker-cell-selected\]\]\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\[te-was-validated\]\/validation\:peer-valid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-valid\:text-green-600:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:text-\[rgb\(220\,76\,100\)\]:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\:-translate-y-\[0\.9rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[0\.75rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[1\.15rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:scale-\[0\.8\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\:\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\[te-input-focused\]\:\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\[te-input-focused\]\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:scale-\[0\.8\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\:bg-transparent ::selection{background-color:#0000}.selection\:bg-transparent::selection{background-color:#0000}.before\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:h-\[0\.875rem\]:before{content:var(--tw-content);height:.875rem}.before\:w-\[0\.875rem\]:before{content:var(--tw-content);width:.875rem}.before\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:shadow-\[0px_0px_0px_13px_transparent\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.odd\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\:\!border-\[\#14a44d\]:checked{border-color:#14a44d!important}.checked\:\!border-\[\#dc4c64\]:checked{border-color:#dc4c64!important}.checked\:border-primary:checked{border-color:#3b71ca}.checked\:\!bg-\[\#14a44d\]:checked{background-color:#14a44d!important}.checked\:\!bg-\[\#dc4c64\]:checked{background-color:#dc4c64!important}.checked\:bg-primary:checked{background-color:#3b71ca}.checked\:before\:opacity-\[0\.16\]:checked:before{content:var(--tw-content);opacity:.16}.checked\:after\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\:after\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\:after\:ml-\[0\.25rem\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\:after\:block:checked:after{content:var(--tw-content);display:block}.checked\:after\:h-\[0\.8125rem\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\:after\:w-\[0\.375rem\]:checked:after{content:var(--tw-content);width:.375rem}.checked\:after\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\:after\:border-\[0\.125rem\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:after\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:after\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:after\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:after\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:after\:\!bg-\[\#14a44d\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\:after\:\!bg-\[\#dc4c64\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\:after\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\:after\:content-\[\'\'\]:checked:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}@media (hover:hover){.hover\:z-2:hover{z-index:2}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:rounded-\[50\%\]:hover{border-radius:50%}.hover\:\!bg-\[\#eee\]:hover{background-color:#eee!important}.hover\:bg-\[\#00000014\]:hover{background-color:#00000014}.hover\:bg-\[\#00000026\]:hover{background-color:#00000026}.hover\:bg-\[unset\]:hover{background-color:unset}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-primary-600:hover{background-color:#3061af}.hover\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\:fill-\[\#8b8b8b\]:hover{fill:#8b8b8b}.hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.hover\:text-\[\#8b8b8b\]:hover{color:#8b8b8b}.hover\:text-primary:hover{color:#3b71ca}.hover\:text-primary-600:hover{color:#3061af}.hover\:text-white:hover{color:var(--color-white)}.hover\:\!opacity-90:hover{opacity:.9!important}.hover\:opacity-100:hover{opacity:1}.hover\:\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\:before\:opacity-\[0\.04\]:hover:before{content:var(--tw-content);opacity:.04}.hover\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:z-3:focus{z-index:3}.focus\:rounded-\[50\%\]:focus{border-radius:50%}.focus\:\!border-\[\#14a44d\]:focus{border-color:#14a44d!important}.focus\:\!border-\[\#dc4c64\]:focus{border-color:#dc4c64!important}.focus\:border-primary:focus{border-color:#3b71ca}.focus\:\!bg-\[\#eee\]:focus{background-color:#eee!important}.focus\:bg-\[\#00000014\]:focus{background-color:#00000014}.focus\:bg-\[\#00000026\]:focus{background-color:#00000026}.focus\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\:bg-primary-600:focus{background-color:#3061af}.focus\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.focus\:text-gray-700:focus{color:var(--color-gray-700)}.focus\:text-primary:focus{color:#3b71ca}.focus\:text-primary-600:focus{color:#3061af}.focus\:text-white:focus{color:var(--color-white)}.focus\:\!opacity-90:focus{opacity:.9!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#14a44d\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#dc4c64\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:transition-\[border-color_0\.2s\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:placeholder\:opacity-100:focus::placeholder{opacity:1}.focus\:before\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\:before\:opacity-\[0\.12\]:focus:before{content:var(--tw-content);opacity:.12}.focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:after\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\:after\:z-\[1\]:focus:after{content:var(--tw-content);z-index:1}.focus\:after\:block:focus:after{content:var(--tw-content);display:block}.focus\:after\:h-\[0\.875rem\]:focus:after{content:var(--tw-content);height:.875rem}.focus\:after\:w-\[0\.875rem\]:focus:after{content:var(--tw-content);width:.875rem}.focus\:after\:rounded-\[0\.125rem\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\:after\:content-\[\'\'\]:focus:after{--tw-content:"";content:var(--tw-content)}.checked\:focus\:before\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\:focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\:focus\:after\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\:focus\:after\:ml-\[0\.25rem\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\:focus\:after\:h-\[0\.8125rem\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\:focus\:after\:w-\[0\.375rem\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\:focus\:after\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\:focus\:after\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\:focus\:after\:border-\[0\.125rem\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:focus\:after\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:focus\:after\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:focus\:after\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:focus\:after\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:focus\:after\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\:z-60:active{z-index:60}.active\:bg-\[\#c4d4ef\]:active{background-color:#c4d4ef}.active\:bg-\[\#cacfd1\]:active{background-color:#cacfd1}.active\:bg-primary-700:active{background-color:#285192}.active\:bg-primary-accent-200:active{background-color:#cedbee}.active\:text-primary-700:active{color:#285192}.active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\:grid[data-te-dropdown-show]{display:grid}.data-\[data-te-autocomplete-option-disabled\]\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\[data-te-autocomplete-option-disabled\]\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\[popper-reference-hidden\]\:hidden[data-popper-reference-hidden]{display:none}.data-\[te-active\]\:-top-\[38px\][data-te-active]{top:-38px}.data-\[te-active\]\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-autocomplete-state-open\]\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-state-open\]\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\[te-carousel-fade\]\:z-0[data-te-carousel-fade]{z-index:0}.data-\[te-carousel-fade\]\:z-\[1\][data-te-carousel-fade]{z-index:1}.data-\[te-carousel-fade\]\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\[te-carousel-fade\]\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\[te-carousel-fade\]\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\[te-carousel-fade\]\:duration-\[600ms\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\[te-datepicker-cell-disabled\]\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\[te-datepicker-cell-disabled\]\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\[te-datepicker-cell-disabled\]\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\[te-datepicker-cell-disabled\]\:hover\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\[\[data-te-datepicker-cell-focused\]\]\:data-\[te-datepicker-cell-selected\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\[te-input-disabled\]\:cursor-default[data-te-input-disabled]{cursor:default}.data-\[te-input-disabled\]\:bg-\[\#e9ecef\][data-te-input-disabled]{background-color:#e9ecef}.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:block[data-te-input-state-active]{display:block}.data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:scale-\[0\.8\][data-te-input-state-active]{scale:.8}.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:placeholder\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\[te-select-open\]\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-select-open\]\:opacity-100[data-te-select-open]{opacity:1}.data-\[te-select-option-disabled\]\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}.motion-reduce\:animate-\[spin_1\.5s_linear_infinite\]{animation:1.5s linear infinite spin}.motion-reduce\:animate-\[spinner-grow_1\.5s_linear_infinite\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\:break-words{overflow-wrap:break-word}.sm\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\:order-none{order:0}.md\:my-0{margin-block:calc(var(--spacing) * 0)}.md\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:pr-1{padding-right:calc(var(--spacing) * 1)}.md\:pr-\[17px\]{padding-right:17px}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:calc(var(--spacing) * 32)}.lg\:w-36{width:calc(var(--spacing) * 36)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\:w-52{width:calc(var(--spacing) * 52)}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\[320px\]\:max-\[825px\]\:landscape\:h-auto{height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[305px\]{min-height:305px}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[auto\]{min-height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-w-\[auto\]{min-width:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:\!flex-row{flex-direction:row!important}.min-\[320px\]\:max-\[825px\]\:landscape\:flex-col{flex-direction:column}.min-\[320px\]\:max-\[825px\]\:landscape\:\!justify-around{justify-content:space-around!important}.min-\[320px\]\:max-\[825px\]\:landscape\:overflow-y-auto{overflow-y:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-lg{border-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-tr-none{border-top-right-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-none{border-bottom-left-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:p-\[10px\]{padding:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:pr-\[10px\]{padding-right:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\[320px\]\:max-\[825px\]\:landscape\:text-\[3rem\]{font-size:3rem}.min-\[320px\]\:max-\[825px\]\:landscape\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\:max-md\:landscape\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\:max-md\:landscape\:h-8{height:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:h-\[360px\]{height:360px}.xs\:max-md\:landscape\:h-full{height:100%}.xs\:max-md\:landscape\:w-8{width:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:w-\[475px\]{width:475px}.xs\:max-md\:landscape\:flex-row{flex-direction:row}}}}.rtl\:\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\:\!origin-\[50\%_50\%_0\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\:\[direction\:rtl\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\:border-\[\#4f4f4f\]{border-color:#4f4f4f}.dark\:border-\[\#14a44d\]{border-color:#14a44d}.dark\:border-\[\#dc4c64\]{border-color:#dc4c64}.dark\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-primary-400{border-color:#8faee0}.dark\:\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\:bg-\[\#4f4f4f\]{background-color:#4f4f4f}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-primary-600{background-color:#3061af}.dark\:bg-transparent{background-color:#0000}.dark\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\:bg-zinc-600\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:fill-gray-400{fill:var(--color-gray-400)}.dark\:\!text-primary-400{color:#8faee0!important}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-primary-400{color:#8faee0}.dark\:text-white{color:var(--color-white)}.dark\:shadow-\[0_4px_9px_-4px_rgba\(59\,113\,202\,0\.5\)\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\[data-te-datepicker-cell-current\]\]\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\:group-\[\[data-te-datepicker-cell-disabled\]\]\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\:peer-focus\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\:peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\:placeholder\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\:checked\:border-primary:checked{border-color:#3b71ca}.dark\:checked\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\:hover\:\!bg-\[\#555\]:hover{background-color:#555!important}.dark\:hover\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\:hover\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\:hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.dark\:hover\:text-primary-400:hover{color:#8faee0}.dark\:hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\:focus\:\!bg-\[\#555\]:focus{background-color:#555!important}.dark\:focus\:bg-white\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:focus\:bg-white\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.dark\:focus\:text-primary-400:focus{color:#8faee0}.dark\:focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(255\,255\,255\,0\.4\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:disabled\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\:disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-buttons-timepicker\]\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\:data-\[te-input-disabled\]\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{--tw-border-style:none;border-style:none}.print\:border-black{border-color:var(--color-black)}.print\:bg-white{background-color:var(--color-white)}.print\:text-left{text-align:left}.print\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\[\&\.ps--clicking\]\:\!bg-\[\#eee\].ps--clicking{background-color:#eee!important}.\[\&\.ps--clicking\]\:\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\:\[\&\.ps--clicking\]\:\!bg-\[\#555\].ps--clicking{background-color:#555!important}}.\[\&\:\:-webkit-scrollbar\]\:h-1::-webkit-scrollbar{height:calc(var(--spacing) * 1)}.\[\&\:\:-webkit-scrollbar\]\:w-1::-webkit-scrollbar{width:calc(var(--spacing) * 1)}.\[\&\:\:-webkit-scrollbar-button\]\:block::-webkit-scrollbar-button{display:block}.\[\&\:\:-webkit-scrollbar-button\]\:h-0::-webkit-scrollbar-button{height:calc(var(--spacing) * 0)}.\[\&\:\:-webkit-scrollbar-button\]\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\[\&\:\:-webkit-scrollbar-thumb\]\:h-\[50px\]::-webkit-scrollbar-thumb{height:50px}.\[\&\:\:-webkit-scrollbar-thumb\]\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\[\&\:\:-webkit-scrollbar-thumb\]\:bg-\[\#999\]::-webkit-scrollbar-thumb{background-color:#999}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\[\&\:\:-webkit-scrollbar-track-piece\]\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\,231\,235\)\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\[\&\:not\(\[data-te-input-placeholder-active\]\)\]\:placeholder\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:nth-child\(odd\)\]\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\[\&\:nth-child\(odd\)\]\:dark\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:mx-auto>svg{margin-inline:auto}.\[\&\>svg\]\:h-4>svg{height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:h-5>svg{height:calc(var(--spacing) * 5)}.\[\&\>svg\]\:h-6>svg{height:calc(var(--spacing) * 6)}.\[\&\>svg\]\:w-4>svg{width:calc(var(--spacing) * 4)}.\[\&\>svg\]\:w-5>svg{width:calc(var(--spacing) * 5)}.\[\&\>svg\]\:w-6>svg{width:calc(var(--spacing) * 6)}.\[\&\>svg\]\:rotate-180>svg{rotate:180deg}.\[\&\>svg\]\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\:\[\&\>svg\]\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}} \ No newline at end of file +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 none);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\!absolute{position:absolute!important}.\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-\[18px\]{top:-18px}.-top-\[21px\]{top:-21px}.-top-\[35px\]{top:-35px}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\[11px\]{top:11px}.top-\[13px\]{top:13px}.top-\[50\%\]{top:50%}.top-\[50px\]{top:50px}.top-full{top:100%}.right-0{right:0}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\[47px\]{bottom:-47px}.bottom-0{bottom:0}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:var(--spacing)}.bottom-1\/2{bottom:50%}.-left-\[15px\]{left:-15px}.-left-\[9999px\]{left:-9999px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.left-\[50px\]{left:50px}.left-\[calc\(50\%-1px\)\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[2\]{z-index:2}.z-\[999\]{z-index:999}.z-\[1035\]{z-index:1035}.z-\[1040\]{z-index:1040}.z-\[1065\]{z-index:1065}.z-\[1066\]{z-index:1066}.z-\[1070\]{z-index:1070}.z-\[1080\]{z-index:1080}.z-\[1100\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:320px){.container\!{max-width:320px!important}}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:0}.m-1{margin:var(--spacing)}.m-auto{margin:auto}.mx-0{margin-inline:0}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\[10px\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\!my-0{margin-block:0!important}.my-0{margin-block:0}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\[5px\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:0}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\[0\.15rem\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\[6px\]{margin-right:6px}.mr-\[8px\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:0}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\[0\.125rem\]{margin-bottom:.125rem}.mb-\[10px\]{margin-bottom:10px}.-ml-\[1\.5rem\]{margin-left:-1.5rem}.ml-0{margin-left:0}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\[3px\]{margin-left:3px}.ml-\[30px\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\!h-0{height:0!important}.\!h-px{height:1px!important}.h-0{height:0}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\[0\.9375rem\]{height:.9375rem}.h-\[1\.4rem\]{height:1.4rem}.h-\[1\.125rem\]{height:1.125rem}.h-\[2px\]{height:2px}.h-\[4px\]{height:4px}.h-\[6px\]{height:6px}.h-\[10px\]{height:10px}.h-\[30px\]{height:30px}.h-\[32px\]{height:32px}.h-\[40px\]{height:40px}.h-\[42px\]{height:42px}.h-\[48px\]{height:48px}.h-\[50px\]{height:50px}.h-\[56px\]{height:56px}.h-\[72px\]{height:72px}.h-\[100px\]{height:100px}.h-\[120px\]{height:120px}.h-\[160px\]{height:160px}.h-\[260px\]{height:260px}.h-\[380px\]{height:380px}.h-\[512px\]{height:512px}.h-\[calc\(100\%-100px\)\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[calc\(100\%-64px\)\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\[1\.5rem\]{min-height:1.5rem}.min-h-\[40px\]{min-height:40px}.min-h-\[305px\]{min-height:305px}.min-h-\[325px\]{min-height:325px}.min-h-\[auto\]{min-height:auto}.\!w-px{width:1px!important}.w-0{width:0}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[0\.9375rem\]{width:.9375rem}.w-\[1\.4rem\]{width:1.4rem}.w-\[1\.125rem\]{width:1.125rem}.w-\[2px\]{width:2px}.w-\[4px\]{width:4px}.w-\[6px\]{width:6px}.w-\[15px\]{width:15px}.w-\[30px\]{width:30px}.w-\[32px\]{width:32px}.w-\[45\%\]{width:45%}.w-\[50px\]{width:50px}.w-\[70px\]{width:70px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[304px\]{width:304px}.w-\[328px\]{width:328px}.w-\[calc\(100\%-100px\)\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\[90\%\]{max-width:90%}.max-w-\[200px\]{max-width:200px}.max-w-\[267px\]{max-width:267px}.max-w-\[325px\]{max-width:325px}.max-w-\[calc\(100\%-1rem\)\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\[48px\]{min-width:48px}.min-w-\[64px\]{min-width:64px}.min-w-\[100px\]{min-width:100px}.min-w-\[310px\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\[0_0\]{transform-origin:0 0}.origin-\[50\%_50\%\]{transform-origin:50%}.origin-\[center_bottom_0\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[6px\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[50\%\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[150\%\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\[50\%\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[6px\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\[0\.8\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[0\.25\]{scale:.25}.scale-\[1\.02\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\[-180deg\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\[fade-in_0\.3s_both\]{animation:.3s both fade-in}.animate-\[fade-in_0\.15s_both\]{animation:.15s both fade-in}.animate-\[fade-in_350ms_ease-in-out\]{animation:.35s ease-in-out fade-in}.animate-\[fade-out_0\.3s_both\]{animation:.3s both fade-out}.animate-\[fade-out_0\.15s_both\]{animation:.15s both fade-out}.animate-\[fade-out_350ms_ease-in-out\]{animation:.35s ease-in-out fade-out}.animate-\[progress_3s_ease-in-out_infinite\]{animation:3s ease-in-out infinite progress}.animate-\[show-up-clock_350ms_linear\]{animation:.35s linear show-up-clock}.animate-\[slide-in-left_0\.8s_both\]{animation:.8s both slide-in-left}.animate-\[slide-in-right_0\.8s_both\]{animation:.8s both slide-in-right}.animate-\[slide-out-left_0\.8s_both\]{animation:.8s both slide-out-left}.animate-\[slide-out-right_0\.8s_both\]{animation:.8s both slide-out-right}.animate-\[spinner-grow_0\.75s_linear_infinite\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\[0\.5rem\]{border-radius:.5rem}.rounded-\[0\.6rem\]{border-radius:.6rem}.rounded-\[0\.25rem\]{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[16px\]{border-radius:16px}.rounded-\[50\%\]{border-radius:50%}.rounded-\[100\%\]{border-radius:100%}.rounded-\[999px\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\[0\.6rem\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\[0\.25rem\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\[0\.25rem\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\!border-\[3px\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\[\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[0\.15em\]{border-style:var(--tw-border-style);border-width:.15em}.border-\[0\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[1px\]{border-style:var(--tw-border-style);border-width:1px}.border-\[14px\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\[0\.125rem\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\!border-\[\#14a44d\]{border-color:#14a44d!important}.\!border-\[\#b2b3b4\]{border-color:#b2b3b4!important}.\!border-\[\#dc4c64\]{border-color:#dc4c64!important}.border-\[\#3b71ca\]{border-color:#3b71ca}.border-\[\#14a44d\]{border-color:#14a44d}.border-\[\#dc4c64\]{border-color:#dc4c64}.border-\[\#eee\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\!bg-\[\#858585\]{background-color:#858585!important}.\!bg-danger-100{background-color:#fae5e9!important}.\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\!bg-primary-100{background-color:#e3ebf7!important}.\!bg-success-100{background-color:#d6fae4!important}.bg-\[\#000000e6\]{background-color:#000000e6}.bg-\[\#3b71ca\]{background-color:#3b71ca}.bg-\[\#6d6d6d\]{background-color:#6d6d6d}.bg-\[\#00000012\]{background-color:#00000012}.bg-\[\#00000066\]{background-color:#0006}.bg-\[\#aaa\]{background-color:#aaa}.bg-\[\#eceff1\]{background-color:#eceff1}.bg-\[\#eee\]{background-color:#eee}.bg-\[rgba\(0\,0\,0\,0\.4\)\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\[\#336dec\]{fill:#336dec}.fill-\[\#afafaf\]{fill:#afafaf}.fill-current{fill:currentColor}.\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\[1rem\]{padding:1rem}.p-\[5px\]{padding:5px}.p-\[auto\]{padding:auto}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[0\.4rem\]{padding-inline:.4rem}.px-\[1\.4rem\]{padding-inline:1.4rem}.px-\[10px\]{padding-inline:10px}.px-\[12px\]{padding-inline:12px}.px-\[auto\]{padding-inline:auto}.\!py-0{padding-block:0!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:0}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\[0\.4rem\]{padding-block:.4rem}.py-\[0\.32rem\]{padding-block:.32rem}.py-\[0\.33rem\]{padding-block:.33rem}.py-\[0\.4375rem\]{padding-block:.4375rem}.py-\[1px\]{padding-block:1px}.py-\[5px\]{padding-block:5px}.py-\[10px\]{padding-block:10px}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\[0\.37rem\]{padding-top:.37rem}.pt-\[6px\]{padding-top:6px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\[24px\]{padding-right:24px}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\[5px\]{padding-bottom:5px}.pl-0{padding-left:0}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[1\.5rem\]{padding-left:1.5rem}.pl-\[8px\]{padding-left:8px}.pl-\[18px\]{padding-left:18px}.pl-\[50px\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\[-0\.125em\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[2\.5rem\]{font-size:2.5rem}.text-\[3\.75rem\]{font-size:3.75rem}.text-\[10px\]{font-size:10px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[34px\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[2\.15\]{--tw-leading:2.15;line-height:2.15}.leading-\[40px\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.00833em\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\[\.1rem\],.tracking-\[0\.1rem\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\[1\.7px\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-\[\#14a44d\]{color:#14a44d!important}.\!text-\[\#dc4c64\]{color:#dc4c64!important}.\!text-danger-700{color:#b0233a!important}.\!text-gray-50{color:var(--color-gray-50)!important}.\!text-primary{color:#3b71ca!important}.\!text-primary-700{color:#285192!important}.\!text-success-700{color:#0e7537!important}.text-\[\#3b71ca\]{color:#3b71ca}.text-\[\#4f4f4f\]{color:#4f4f4f}.text-\[\#14a44d\]{color:#14a44d}.text-\[\#212529\]{color:#212529}.text-\[\#b3afaf\]{color:#b3afaf}.text-\[\#b3b3b3\]{color:#b3b3b3}.text-\[\#dc4c64\]{color:#dc4c64}.text-\[\#ffffff8a\]{color:#ffffff8a}.text-\[rgb\(220\,76\,100\)\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\/\[64\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\/\[64\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\!opacity-0{opacity:0!important}.\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\[\.53\]{opacity:.53}.opacity-\[\.54\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0px_3px_0_rgba\(0\,0\,0\,0\.07\)\,0_2px_2px_0_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_5px_0_rgba\(0\,0\,0\,0\.16\)\,_0_2px_10px_0_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_9px_-4px_\#3b71ca\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_10px_15px_-3px_rgba\(0\,0\,0\,0\.07\)\,0_4px_6px_-2px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_2px_15px_-3px_rgba\(0\,0\,0\,\.07\)\,_0px_10px_20px_-2px_rgba\(0\,0\,0\,\.04\)\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\/login,.shadow\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,_opacity\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,box-shadow\,border\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,_opacity\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,height\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\[0ms\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\[150ms\]{--tw-duration:.15s;transition-duration:.15s}.duration-\[200ms\]{--tw-duration:.2s;transition-duration:.2s}.duration-\[250ms\]{--tw-duration:.25s;transition-duration:.25s}.duration-\[350ms\]{--tw-duration:.35s;transition-duration:.35s}.duration-\[400ms\]{--tw-duration:.4s;transition-duration:.4s}.duration-\[1000ms\]{--tw-duration:1s;transition-duration:1s}.ease-\[cubic-bezier\(0\,0\,0\.15\,1\)\,_cubic-bezier\(0\,0\,0\.15\,1\)\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\[cubic-bezier\(0\.4\,0\,0\.2\,1\)\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\)\],.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\.0\)\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\[ease\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\!\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)!important}.\[bash\:1221\]{bash:1221}.\[check\:5737\]{check:5737}.\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)}.\[direction\:ltr\]{direction:ltr}.\[drm\:hdmiphy_enable\.part\.0\]{drm:hdmiphy enable.part0}.\[drm\:samsung_dsim_host_attach\]{drm:samsung dsim host attach}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[pid\:5118\,cpu4\,QThread\,0\]{pid:5118,cpu4,QThread,0}.\[pid\:5118\,cpu4\,QThread\,1\]{pid:5118,cpu4,QThread,1}.\[pid\:5118\,cpu4\,QThread\,2\]{pid:5118,cpu4,QThread,2}.\[pid\:5118\,cpu4\,QThread\,3\]{pid:5118,cpu4,QThread,3}.\[pid\:5118\,cpu4\,QThread\,4\]{pid:5118,cpu4,QThread,4}.\[pid\:5118\,cpu4\,QThread\,9\]{pid:5118,cpu4,QThread,9}.\[transition\:background-color_\.2s_linear\,_height_\.2s_ease-in-out\]{transition:background-color .2s linear,height .2s ease-in-out}.\[transition\:background-color_\.2s_linear\,_width_\.2s_ease-in-out\,_opacity\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\[transition\:background-color_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,box-shadow_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,border_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\/ps\:opacity-60:is(:where(.group\/ps):hover *){opacity:.6}.group-hover\/x\:h-\[11px\]:is(:where(.group\/x):hover *){height:11px}.group-hover\/x\:bg-\[\#999\]:is(:where(.group\/x):hover *){background-color:#999}.group-hover\/y\:w-\[11px\]:is(:where(.group\/y):hover *){width:11px}.group-hover\/y\:bg-\[\#999\]:is(:where(.group\/y):hover *){background-color:#999}}.group-focus\/ps\:opacity-60:is(:where(.group\/ps):focus *){opacity:.6}.group-focus\/ps\:opacity-100:is(:where(.group\/ps):focus *){opacity:1}.group-focus\/x\:h-\[0\.6875rem\]:is(:where(.group\/x):focus *){height:.6875rem}.group-focus\/x\:bg-\[\#999\]:is(:where(.group\/x):focus *){background-color:#999}.group-focus\/y\:w-\[0\.6875rem\]:is(:where(.group\/y):focus *){width:.6875rem}.group-focus\/y\:bg-\[\#999\]:is(:where(.group\/y):focus *){background-color:#999}.group-active\/ps\:opacity-100:is(:where(.group\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:0}.group-data-te-collapse-collapsed\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\:fill-\[\#212529\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\[te-input-focused\]\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-focused\]\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-focused\]\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-focused\]\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-focused\]\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-focused\]\:border-\[\#14a44d\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\[te-input-focused\]\:border-\[\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\[te-input-focused\]\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\[te-input-focused\]\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\[te-input-focused\]\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-state-active\]\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-state-active\]\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-state-active\]\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-state-active\]\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-state-active\]\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-state-active\]\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\[te-select-option-group-ref\]\/opt\:pl-7:is(:where(.group\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\[te-was-validated\]\/validation\:mb-4:is(:where(.group\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\[\&\.ps--active-x\]\/ps\:block:is(:where(.group\/ps).ps--active-x *){display:block}.group-\[\&\.ps--active-x\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-x *){background-color:#0000}.group-\[\&\.ps--active-y\]\/ps\:block:is(:where(.group\/ps).ps--active-y *){display:block}.group-\[\&\.ps--active-y\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-y *){background-color:#0000}.group-\[\&\.ps--clicking\]\/x\:h-\[11px\]:is(:where(.group\/x).ps--clicking *){height:11px}.group-\[\&\.ps--clicking\]\/x\:bg-\[\#999\]:is(:where(.group\/x).ps--clicking *){background-color:#999}.group-\[\&\.ps--clicking\]\/y\:w-\[11px\]:is(:where(.group\/y).ps--clicking *){width:11px}.group-\[\&\.ps--clicking\]\/y\:bg-\[\#999\]:is(:where(.group\/y).ps--clicking *){background-color:#999}.group-\[\&\.ps--scrolling-x\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-x *),.group-\[\&\.ps--scrolling-y\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-y *){opacity:.6}.group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\[\[data-te-datepicker-cell-current\]\]\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\[\[data-te-datepicker-cell-current\]\]\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\[\[data-te-datepicker-cell-current\]\]\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\[\[data-te-datepicker-cell-selected\]\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\[\[data-te-datepicker-cell-selected\]\]\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\[te-was-validated\]\/validation\:peer-valid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-valid\:text-green-600:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:text-\[rgb\(220\,76\,100\)\]:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\:-translate-y-\[0\.9rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[0\.75rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[1\.15rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:scale-\[0\.8\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\:\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\[te-input-focused\]\:\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\[te-input-focused\]\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:scale-\[0\.8\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\:bg-transparent ::selection{background-color:#0000}.selection\:bg-transparent::selection{background-color:#0000}.before\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:h-\[0\.875rem\]:before{content:var(--tw-content);height:.875rem}.before\:w-\[0\.875rem\]:before{content:var(--tw-content);width:.875rem}.before\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:shadow-\[0px_0px_0px_13px_transparent\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.odd\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\:\!border-\[\#14a44d\]:checked{border-color:#14a44d!important}.checked\:\!border-\[\#dc4c64\]:checked{border-color:#dc4c64!important}.checked\:border-primary:checked{border-color:#3b71ca}.checked\:\!bg-\[\#14a44d\]:checked{background-color:#14a44d!important}.checked\:\!bg-\[\#dc4c64\]:checked{background-color:#dc4c64!important}.checked\:bg-primary:checked{background-color:#3b71ca}.checked\:before\:opacity-\[0\.16\]:checked:before{content:var(--tw-content);opacity:.16}.checked\:after\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\:after\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\:after\:ml-\[0\.25rem\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\:after\:block:checked:after{content:var(--tw-content);display:block}.checked\:after\:h-\[0\.8125rem\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\:after\:w-\[0\.375rem\]:checked:after{content:var(--tw-content);width:.375rem}.checked\:after\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\:after\:border-\[0\.125rem\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:after\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:after\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:after\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:after\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:after\:\!bg-\[\#14a44d\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\:after\:\!bg-\[\#dc4c64\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\:after\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\:after\:content-\[\'\'\]:checked:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}@media (hover:hover){.hover\:z-2:hover{z-index:2}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:rounded-\[50\%\]:hover{border-radius:50%}.hover\:\!bg-\[\#eee\]:hover{background-color:#eee!important}.hover\:bg-\[\#00000014\]:hover{background-color:#00000014}.hover\:bg-\[\#00000026\]:hover{background-color:#00000026}.hover\:bg-\[unset\]:hover{background-color:unset}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-primary-600:hover{background-color:#3061af}.hover\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\:fill-\[\#8b8b8b\]:hover{fill:#8b8b8b}.hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.hover\:text-\[\#8b8b8b\]:hover{color:#8b8b8b}.hover\:text-primary:hover{color:#3b71ca}.hover\:text-primary-600:hover{color:#3061af}.hover\:text-white:hover{color:var(--color-white)}.hover\:\!opacity-90:hover{opacity:.9!important}.hover\:opacity-100:hover{opacity:1}.hover\:\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\:before\:opacity-\[0\.04\]:hover:before{content:var(--tw-content);opacity:.04}.hover\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:z-3:focus{z-index:3}.focus\:rounded-\[50\%\]:focus{border-radius:50%}.focus\:\!border-\[\#14a44d\]:focus{border-color:#14a44d!important}.focus\:\!border-\[\#dc4c64\]:focus{border-color:#dc4c64!important}.focus\:border-primary:focus{border-color:#3b71ca}.focus\:\!bg-\[\#eee\]:focus{background-color:#eee!important}.focus\:bg-\[\#00000014\]:focus{background-color:#00000014}.focus\:bg-\[\#00000026\]:focus{background-color:#00000026}.focus\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\:bg-primary-600:focus{background-color:#3061af}.focus\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.focus\:text-gray-700:focus{color:var(--color-gray-700)}.focus\:text-primary:focus{color:#3b71ca}.focus\:text-primary-600:focus{color:#3061af}.focus\:text-white:focus{color:var(--color-white)}.focus\:\!opacity-90:focus{opacity:.9!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#14a44d\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#dc4c64\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:transition-\[border-color_0\.2s\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:placeholder\:opacity-100:focus::placeholder{opacity:1}.focus\:before\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\:before\:opacity-\[0\.12\]:focus:before{content:var(--tw-content);opacity:.12}.focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:after\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\:after\:z-\[1\]:focus:after{content:var(--tw-content);z-index:1}.focus\:after\:block:focus:after{content:var(--tw-content);display:block}.focus\:after\:h-\[0\.875rem\]:focus:after{content:var(--tw-content);height:.875rem}.focus\:after\:w-\[0\.875rem\]:focus:after{content:var(--tw-content);width:.875rem}.focus\:after\:rounded-\[0\.125rem\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\:after\:content-\[\'\'\]:focus:after{--tw-content:"";content:var(--tw-content)}.checked\:focus\:before\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\:focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\:focus\:after\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\:focus\:after\:ml-\[0\.25rem\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\:focus\:after\:h-\[0\.8125rem\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\:focus\:after\:w-\[0\.375rem\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\:focus\:after\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\:focus\:after\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\:focus\:after\:border-\[0\.125rem\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:focus\:after\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:focus\:after\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:focus\:after\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:focus\:after\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:focus\:after\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\:z-60:active{z-index:60}.active\:bg-\[\#c4d4ef\]:active{background-color:#c4d4ef}.active\:bg-\[\#cacfd1\]:active{background-color:#cacfd1}.active\:bg-primary-700:active{background-color:#285192}.active\:bg-primary-accent-200:active{background-color:#cedbee}.active\:text-primary-700:active{color:#285192}.active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\:grid[data-te-dropdown-show]{display:grid}.data-\[data-te-autocomplete-option-disabled\]\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\[data-te-autocomplete-option-disabled\]\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\[popper-reference-hidden\]\:hidden[data-popper-reference-hidden]{display:none}.data-\[te-active\]\:-top-\[38px\][data-te-active]{top:-38px}.data-\[te-active\]\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-autocomplete-state-open\]\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-state-open\]\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\[te-carousel-fade\]\:z-0[data-te-carousel-fade]{z-index:0}.data-\[te-carousel-fade\]\:z-\[1\][data-te-carousel-fade]{z-index:1}.data-\[te-carousel-fade\]\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\[te-carousel-fade\]\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\[te-carousel-fade\]\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\[te-carousel-fade\]\:duration-\[600ms\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\[te-datepicker-cell-disabled\]\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\[te-datepicker-cell-disabled\]\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\[te-datepicker-cell-disabled\]\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\[te-datepicker-cell-disabled\]\:hover\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\[\[data-te-datepicker-cell-focused\]\]\:data-\[te-datepicker-cell-selected\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\[te-input-disabled\]\:cursor-default[data-te-input-disabled]{cursor:default}.data-\[te-input-disabled\]\:bg-\[\#e9ecef\][data-te-input-disabled]{background-color:#e9ecef}.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:block[data-te-input-state-active]{display:block}.data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:scale-\[0\.8\][data-te-input-state-active]{scale:.8}.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:placeholder\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\[te-select-open\]\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-select-open\]\:opacity-100[data-te-select-open]{opacity:1}.data-\[te-select-option-disabled\]\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}.motion-reduce\:animate-\[spin_1\.5s_linear_infinite\]{animation:1.5s linear infinite spin}.motion-reduce\:animate-\[spinner-grow_1\.5s_linear_infinite\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\:break-words{overflow-wrap:break-word}.sm\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\:order-none{order:0}.md\:my-0{margin-block:0}.md\:mb-0{margin-bottom:0}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:pr-1{padding-right:var(--spacing)}.md\:pr-\[17px\]{padding-right:17px}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:calc(var(--spacing) * 32)}.lg\:w-36{width:calc(var(--spacing) * 36)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\:w-52{width:calc(var(--spacing) * 52)}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\[320px\]\:max-\[825px\]\:landscape\:h-auto{height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[305px\]{min-height:305px}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[auto\]{min-height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-w-\[auto\]{min-width:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:\!flex-row{flex-direction:row!important}.min-\[320px\]\:max-\[825px\]\:landscape\:flex-col{flex-direction:column}.min-\[320px\]\:max-\[825px\]\:landscape\:\!justify-around{justify-content:space-around!important}.min-\[320px\]\:max-\[825px\]\:landscape\:overflow-y-auto{overflow-y:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-lg{border-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-tr-none{border-top-right-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-none{border-bottom-left-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:p-\[10px\]{padding:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:pr-\[10px\]{padding-right:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\[320px\]\:max-\[825px\]\:landscape\:text-\[3rem\]{font-size:3rem}.min-\[320px\]\:max-\[825px\]\:landscape\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\:max-md\:landscape\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\:max-md\:landscape\:h-8{height:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:h-\[360px\]{height:360px}.xs\:max-md\:landscape\:h-full{height:100%}.xs\:max-md\:landscape\:w-8{width:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:w-\[475px\]{width:475px}.xs\:max-md\:landscape\:flex-row{flex-direction:row}}}}.rtl\:\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\:\!origin-\[50\%_50\%_0\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\:\[direction\:rtl\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\:border-\[\#4f4f4f\]{border-color:#4f4f4f}.dark\:border-\[\#14a44d\]{border-color:#14a44d}.dark\:border-\[\#dc4c64\]{border-color:#dc4c64}.dark\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-primary-400{border-color:#8faee0}.dark\:\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\:bg-\[\#4f4f4f\]{background-color:#4f4f4f}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-primary-600{background-color:#3061af}.dark\:bg-transparent{background-color:#0000}.dark\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\:bg-zinc-600\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:fill-gray-400{fill:var(--color-gray-400)}.dark\:\!text-primary-400{color:#8faee0!important}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-primary-400{color:#8faee0}.dark\:text-white{color:var(--color-white)}.dark\:shadow-\[0_4px_9px_-4px_rgba\(59\,113\,202\,0\.5\)\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\[data-te-datepicker-cell-current\]\]\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\:group-\[\[data-te-datepicker-cell-disabled\]\]\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\:peer-focus\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\:peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\:placeholder\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\:checked\:border-primary:checked{border-color:#3b71ca}.dark\:checked\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\:hover\:\!bg-\[\#555\]:hover{background-color:#555!important}.dark\:hover\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\:hover\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\:hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.dark\:hover\:text-primary-400:hover{color:#8faee0}.dark\:hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\:focus\:\!bg-\[\#555\]:focus{background-color:#555!important}.dark\:focus\:bg-white\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:focus\:bg-white\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.dark\:focus\:text-primary-400:focus{color:#8faee0}.dark\:focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(255\,255\,255\,0\.4\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:disabled\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\:disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-buttons-timepicker\]\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\:data-\[te-input-disabled\]\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{--tw-border-style:none;border-style:none}.print\:border-black{border-color:var(--color-black)}.print\:bg-white{background-color:var(--color-white)}.print\:text-left{text-align:left}.print\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\[\&\.ps--clicking\]\:\!bg-\[\#eee\].ps--clicking{background-color:#eee!important}.\[\&\.ps--clicking\]\:\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\:\[\&\.ps--clicking\]\:\!bg-\[\#555\].ps--clicking{background-color:#555!important}}.\[\&\:\:-webkit-scrollbar\]\:h-1::-webkit-scrollbar{height:var(--spacing)}.\[\&\:\:-webkit-scrollbar\]\:w-1::-webkit-scrollbar{width:var(--spacing)}.\[\&\:\:-webkit-scrollbar-button\]\:block::-webkit-scrollbar-button{display:block}.\[\&\:\:-webkit-scrollbar-button\]\:h-0::-webkit-scrollbar-button{height:0}.\[\&\:\:-webkit-scrollbar-button\]\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\[\&\:\:-webkit-scrollbar-thumb\]\:h-\[50px\]::-webkit-scrollbar-thumb{height:50px}.\[\&\:\:-webkit-scrollbar-thumb\]\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\[\&\:\:-webkit-scrollbar-thumb\]\:bg-\[\#999\]::-webkit-scrollbar-thumb{background-color:#999}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\[\&\:\:-webkit-scrollbar-track-piece\]\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\,231\,235\)\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\[\&\:not\(\[data-te-input-placeholder-active\]\)\]\:placeholder\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:nth-child\(odd\)\]\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\[\&\:nth-child\(odd\)\]\:dark\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:mx-auto>svg{margin-inline:auto}.\[\&\>svg\]\:h-4>svg{height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:h-5>svg{height:calc(var(--spacing) * 5)}.\[\&\>svg\]\:h-6>svg{height:calc(var(--spacing) * 6)}.\[\&\>svg\]\:w-4>svg{width:calc(var(--spacing) * 4)}.\[\&\>svg\]\:w-5>svg{width:calc(var(--spacing) * 5)}.\[\&\>svg\]\:w-6>svg{width:calc(var(--spacing) * 6)}.\[\&\>svg\]\:rotate-180>svg{rotate:180deg}.\[\&\>svg\]\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\:\[\&\>svg\]\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}} \ No newline at end of file diff --git a/libs/hdf-converters/package.json b/libs/hdf-converters/package.json index fbeb2c11f3..7fd0a50e0e 100644 --- a/libs/hdf-converters/package.json +++ b/libs/hdf-converters/package.json @@ -34,6 +34,7 @@ "scripts": { "prebuild": "tailwindcss -i data/reverse-html-mapper/tailwind.css -o data/reverse-html-mapper/style.css --minify && node data/reverse-html-mapper/convert-to-embedded-strings.ts", "build": "tsc -p tsconfig.build.json", + "validate-generated": "git diff --exit-code HEAD -- data/reverse-html-mapper/style.css src/converters-from-hdf/html/embedded-assets.ts", "lint": "eslint --fix", "lint:ci": "eslint --max-warnings 0", "prepack": "run-script-os", @@ -54,7 +55,7 @@ "@microsoft/microsoft-graph-types": "^2.40.0", "@mitre/jsonix": "^3.0.7", "@smithy/node-http-handler": "^4.0.0", - "@tailwindcss/cli": "^4.0.6", + "@tailwindcss/cli": "4.3.3", "@types/csv2json": "^1.4.2", "@types/mustache": "^4.1.2", "@types/papaparse": "^5.3.2", @@ -79,7 +80,7 @@ "run-script-os": "^1.1.6", "sanitize-html": "^2.17.2", "semver": "^7.6.0", - "tailwindcss": "^4.0.6", + "tailwindcss": "4.3.3", "tw-elements": "^2.0.0", "validator": "^13.12.0", "winston": "^3.6.0", diff --git a/libs/hdf-converters/src/converters-from-hdf/html/embedded-assets.ts b/libs/hdf-converters/src/converters-from-hdf/html/embedded-assets.ts index ef457bedef..02c87e550b 100644 --- a/libs/hdf-converters/src/converters-from-hdf/html/embedded-assets.ts +++ b/libs/hdf-converters/src/converters-from-hdf/html/embedded-assets.ts @@ -1,4 +1,4 @@ /* AUTO-GENERATED. DO NOT EDIT. */ export const html = "\n\n \n \n \n {{exportType}} Report\n \n \n \n\n \n \n \n \n\n \n
\n
\n
Profile Status
\n
\n \n
\n
Count
\n
\n
\n {{{icons.circleCheck}}} Passed: {{statistics.passed}} ({{statistics.passedTests}} individual checks passed)\n
\n
\n {{{icons.circleCross}}} Failed: {{statistics.failed}} ({{statistics.passingTestsFailedResult}} individual checks passed, {{statistics.failedTests}} failed out of {{statistics.totalTests}} total checks)\n
\n
\n {{{icons.circleMinus}}} Not Applicable: {{statistics.notApplicable}}\n
\n
\n {{{icons.circleAlert}}} Not Reviewed: {{statistics.notReviewed}}\n
\n
\n {{{icons.triangleAlert}}} Profile Error: {{statistics.profileError}}\n
\n
\n {{{icons.squareEqual}}} Total: {{statistics.totalResults}}\n
\n
\n
\n\n \n
\n
Severity
\n
\n
\n {{{icons.circleNone}}} None: {{severity.none}}\n
\n
\n {{{icons.circleLow}}} Low: {{severity.low}}\n
\n
\n {{{icons.circleMedium}}} Medium: {{severity.medium}}\n
\n
\n {{{icons.circleHigh}}} High: {{severity.high}}\n
\n
\n {{{icons.circleCritical}}} Critical: {{severity.critical}}\n
\n
\n
\n\n \n
\n
Compliance
\n
\n
\n {{compliance.level}}\n
\n [Passed/(Passed + Failed + Not Reviewed + Profile Error) * 100]\n
\n
\n
\n
\n
\n\n \n
\n
\n
Profile Info
\n
\n \n {{#files}}\n
\n
Filename: {{filename}}
\n
Tool Version: {{toolVersion}}
\n
Platform: {{platform}}
\n
Duration: {{duration}}
\n
\n {{/files}}\n
\n
\n
\n\n
\n \n \n \n {{#showResultSets}} {{#resultSets}}\n \n \n \n \n
\n \n \n {{filename}}\n \n \n \n \n \n \n
\n\n \n \n \n \n
\n
\n \n \n \n \n ID\n
\n \n Status\n
\n \n Severity\n
\n \n 800-53 Controls & CCIs\n
\n \n ID\n \n Severity\n \n Title\n \n 800-53 Controls & CCIs\n
\n
\n\n \n {{#results}}\n
\n
\n \n \n \n \n
\n \n
\n {{hdf.wraps.id}}\n
\n \n
\n {{{resultStatus.icon}}}\n {{resultStatus.status}}\n
\n \n
\n {{{resultSeverity.icon}}}\n {{resultSeverity.severity}}\n
\n \n {{#controlTags}}\n
\n \n {{.}}\n \n
\n {{/controlTags}}\n
\n \n \n {{hdf.wraps.id}}\n \n \n
\n {{{resultSeverity.icon}}}\n {{resultSeverity.severity}}\n
\n \n \n {{{hdf.wraps.title}}}\n \n \n
\n {{#controlTags}}\n
\n \n {{.}}\n \n
\n {{/controlTags}}\n
\n\n \n \n \n \n \n \n
\n\n \n \n \n {{{data.desc}}}\n
\n\n \n
\n \n
Test Results
\n
\n \n
\n \n {{#hdf.segments}}\n
\n Status\n {{status}}\n
\n
\n Test\n {{{code_desc}}}\n
\n
\n Result\n {{message}}\n
\n {{/hdf.segments}}\n
\n\n \n
Result Details
\n
\n \n
\n \n {{#details}}\n
\n {{name}}\n {{{value}}}\n
\n {{/details}}\n
\n
\n\n \n \n {{#showCode}}\n
\n {{full_code}}\n {{/showCode}}\n
\n
\n {{/results}}\n \n \n\n \n
\n \n
\n {{filename}}\n
\n \n {{#results}}\n
\n \n \n \n \n Status\n ID\n Severity\n Title\n \n \n \n \n \n \n
\n {{resultStatus.status}}\n
\n \n \n \n
{{hdf.wraps.id}}
\n \n \n \n
\n {{resultSeverity.severity}}\n
\n \n \n {{{hdf.wraps.title}}}\n \n \n \n
\n \n \n \n \n 800-53 Controls & CCIs\n \n \n \n \n \n \n
\n {{#controlTags}}\n
\n {{.}}\n
\n {{/controlTags}}\n
\n \n \n \n \n
\n\n \n {{{data.desc}}}\n
\n\n \n
\n \n
Test Results
\n
\n \n \n Name\n Value\n \n \n {{#hdf.segments}}\n \n \n Status\n {{status}}\n \n \n Test\n {{{code_desc}}}\n \n \n Result\n {{message}}\n \n \n {{/hdf.segments}}\n \n\n \n
Result Details
\n
\n \n \n Name\n Value\n \n \n \n {{#details}}\n \n {{name}}\n {{{value}}}\n \n {{/details}}\n \n \n
\n\n \n \n {{#showCode}}\n
\n {{full_code}}\n {{/showCode}}\n
\n {{/results}}\n
\n {{/resultSets}} {{/showResultSets}}\n \n \n \n\n" as const; export const js = "/*!\n* TW Elements 1.1.0\n* \n* TW Elements is an open-source UI kit of advanced components for TailwindCSS.\n* Copyright © 2023 MDBootstrap.com\n* \n* Unless a custom, individually assigned license has been granted, this program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.\n* In addition, a custom license may be available upon request, subject to the terms and conditions of that license. Please contact tailwind@mdbootstrap.com for more information on obtaining a custom license.\n* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.\n* \n* If you would like to purchase a COMMERCIAL, non-AGPL license for TWE, please check out our pricing: https://tw-elements.com/pro/\n*/\n(function(N,mt){typeof exports==\"object\"&&typeof module<\"u\"?mt(exports):typeof define==\"function\"&&define.amd?define([\"exports\"],mt):(N=typeof globalThis<\"u\"?globalThis:N||self,mt(N.te={}))})(this,function(N){\"use strict\";var xL=Object.defineProperty;var CL=(N,mt,O)=>mt in N?xL(N,mt,{enumerable:!0,configurable:!0,writable:!0,value:O}):N[mt]=O;var ke=(N,mt,O)=>(CL(N,typeof mt!=\"symbol\"?mt+\"\":mt,O),O);const mt=(()=>{const s={};let t=1;return{set(e,i,n){typeof e[i]>\"u\"&&(e[i]={key:i,id:t},t++),s[e[i].id]=n},get(e,i){if(!e||typeof e[i]>\"u\")return null;const n=e[i];return n.key===i?s[n.id]:null},delete(e,i){if(typeof e[i]>\"u\")return;const n=e[i];n.key===i&&(delete s[n.id],delete e[i])}}})(),O={setData(s,t,e){mt.set(s,t,e)},getData(s,t){return mt.get(s,t)},removeData(s,t){mt.delete(s,t)}},lm=1e6,cm=1e3,xa=\"transitionend\",hm=s=>s==null?`${s}`:{}.toString.call(s).match(/\\s([a-z]+)/i)[1].toLowerCase(),bt=s=>{do s+=Math.floor(Math.random()*lm);while(document.getElementById(s));return s},ch=s=>{let t=s.getAttribute(\"data-te-target\");if(!t||t===\"#\"){let e=s.getAttribute(\"href\");if(!e||!e.includes(\"#\")&&!e.startsWith(\".\"))return null;e.includes(\"#\")&&!e.startsWith(\"#\")&&(e=`#${e.split(\"#\")[1]}`),t=e&&e!==\"#\"?e.trim():null}return t},Ca=s=>{const t=ch(s);return t&&document.querySelector(t)?t:null},Ne=s=>{const t=ch(s);return t?document.querySelector(t):null},oo=s=>{if(!s)return 0;let{transitionDuration:t,transitionDelay:e}=window.getComputedStyle(s);const i=Number.parseFloat(t),n=Number.parseFloat(e);return!i&&!n?0:(t=t.split(\",\")[0],e=e.split(\",\")[0],(Number.parseFloat(t)+Number.parseFloat(e))*cm)},hh=s=>{s.dispatchEvent(new Event(xa))},Wi=s=>!s||typeof s!=\"object\"?!1:(typeof s.jquery<\"u\"&&(s=s[0]),typeof s.nodeType<\"u\"),Be=s=>Wi(s)?s.jquery?s[0]:s:typeof s==\"string\"&&s.length>0?document.querySelector(s):null,L=(s,t,e)=>{Object.keys(e).forEach(i=>{const n=e[i],o=t[i],r=o&&Wi(o)?\"element\":hm(o);if(!new RegExp(n).test(r))throw new Error(`${s.toUpperCase()}: Option \"${i}\" provided type \"${r}\" but expected type \"${n}\".`)})},ae=s=>{if(!s||s.getClientRects().length===0)return!1;if(s.style&&s.parentNode&&s.parentNode.style){const t=getComputedStyle(s),e=getComputedStyle(s.parentNode);return getComputedStyle(s).getPropertyValue(\"visibility\")===\"visible\"||t.display!==\"none\"&&e.display!==\"none\"&&t.visibility!==\"hidden\"}return!1},ci=s=>!s||s.nodeType!==Node.ELEMENT_NODE||s.classList.contains(\"disabled\")?!0:typeof s.disabled<\"u\"?s.disabled:s.hasAttribute(\"disabled\")&&s.getAttribute(\"disabled\")!==\"false\",dh=s=>{if(!document.documentElement.attachShadow)return null;if(typeof s.getRootNode==\"function\"){const t=s.getRootNode();return t instanceof ShadowRoot?t:null}return s instanceof ShadowRoot?s:s.parentNode?dh(s.parentNode):null},ro=()=>function(){},zi=s=>{s.offsetHeight},uh=()=>{const{jQuery:s}=window;return s&&!document.body.hasAttribute(\"data-te-no-jquery\")?s:null},Aa=[],ph=s=>{document.readyState===\"loading\"?(Aa.length||document.addEventListener(\"DOMContentLoaded\",()=>{Aa.forEach(t=>t())}),Aa.push(s)):s()},et=()=>document.documentElement.dir===\"rtl\",dm=s=>Array.from(s),$=s=>document.createElement(s),hi=s=>{typeof s==\"function\"&&s()},fh=(s,t,e=!0)=>{if(!e){hi(s);return}const i=5,n=oo(t)+i;let o=!1;const r=({target:a})=>{a===t&&(o=!0,t.removeEventListener(xa,r),hi(s))};t.addEventListener(xa,r),setTimeout(()=>{o||hh(t)},n)},_h=(s,t,e,i)=>{let n=s.indexOf(t);if(n===-1)return s[!e&&i?s.length-1:0];const o=s.length;return n+=e?1:-1,i&&(n=(n+o)%o),s[Math.max(0,Math.min(n,o-1))]},um=/[^.]*(?=\\..*)\\.|.*/,pm=/\\..*/,fm=/::\\d+$/,wa={};let gh=1;const _m={mouseenter:\"mouseover\",mouseleave:\"mouseout\"},gm=/^(mouseenter|mouseleave)/i,mh=new Set([\"click\",\"dblclick\",\"mouseup\",\"mousedown\",\"contextmenu\",\"mousewheel\",\"DOMMouseScroll\",\"mouseover\",\"mouseout\",\"mousemove\",\"selectstart\",\"selectend\",\"keydown\",\"keypress\",\"keyup\",\"orientationchange\",\"touchstart\",\"touchmove\",\"touchend\",\"touchcancel\",\"pointerdown\",\"pointermove\",\"pointerup\",\"pointerleave\",\"pointercancel\",\"gesturestart\",\"gesturechange\",\"gestureend\",\"focus\",\"blur\",\"change\",\"reset\",\"select\",\"submit\",\"focusin\",\"focusout\",\"load\",\"unload\",\"beforeunload\",\"resize\",\"move\",\"DOMContentLoaded\",\"readystatechange\",\"error\",\"abort\",\"scroll\"]);function bh(s,t){return t&&`${t}::${gh++}`||s.uidEvent||gh++}function vh(s){const t=bh(s);return s.uidEvent=t,wa[t]=wa[t]||{},wa[t]}function mm(s,t){return function e(i){return i.delegateTarget=s,e.oneOff&&_.off(s,i.type,t),t.apply(s,[i])}}function bm(s,t,e){return function i(n){const o=s.querySelectorAll(t);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;\"\")if(o[a]===r)return n.delegateTarget=r,i.oneOff&&_.off(s,n.type,e),e.apply(r,[n]);return null}}function yh(s,t,e=null){const i=Object.keys(s);for(let n=0,o=i.length;nfunction(b){if(!b.relatedTarget||b.relatedTarget!==b.delegateTarget&&!b.delegateTarget.contains(b.relatedTarget))return f.call(this,b)};i?i=p(i):e=p(e)}const[o,r,a]=Th(t,e,i),l=vh(s),c=l[a]||(l[a]={}),h=yh(c,r,o?e:null);if(h){h.oneOff=h.oneOff&&n;return}const d=bh(r,t.replace(um,\"\")),u=o?bm(s,e,i):mm(s,e);u.delegationSelector=o?e:null,u.originalHandler=r,u.oneOff=n,u.uidEvent=d,c[d]=u,s.addEventListener(a,u,o)}function ka(s,t,e,i,n){const o=yh(t[e],i,n);o&&(s.removeEventListener(e,o,!!n),delete t[e][o.uidEvent])}function vm(s,t,e,i){const n=t[e]||{};Object.keys(n).forEach(o=>{if(o.includes(i)){const r=n[o];ka(s,t,e,r.originalHandler,r.delegationSelector)}})}function xh(s){return s=s.replace(pm,\"\"),_m[s]||s}const _={on(s,t,e,i){Eh(s,t,e,i,!1)},one(s,t,e,i){Eh(s,t,e,i,!0)},off(s,t,e,i){if(typeof t!=\"string\"||!s)return;const[n,o,r]=Th(t,e,i),a=r!==t,l=vh(s),c=t.startsWith(\".\");if(typeof o<\"u\"){if(!l||!l[r])return;ka(s,l,r,o,n?e:null);return}c&&Object.keys(l).forEach(d=>{vm(s,l,d,t.slice(1))});const h=l[r]||{};Object.keys(h).forEach(d=>{const u=d.replace(fm,\"\");if(!a||t.includes(u)){const p=h[d];ka(s,l,r,p.originalHandler,p.delegationSelector)}})},trigger(s,t,e){if(typeof t!=\"string\"||!s)return null;const i=uh(),n=xh(t),o=t!==n,r=mh.has(n);let a,l=!0,c=!0,h=!1,d=null;return o&&i&&(a=i.Event(t,e),i(s).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent(\"HTMLEvents\"),d.initEvent(n,l,!0)):d=new CustomEvent(t,{bubbles:l,cancelable:!0}),typeof e<\"u\"&&Object.keys(e).forEach(u=>{Object.defineProperty(d,u,{get(){return e[u]}})}),h&&d.preventDefault(),c&&s.dispatchEvent(d),d.defaultPrevented&&typeof a<\"u\"&&a.preventDefault(),d}},ct={on(s,t,e,i){const n=t.split(\" \");for(let o=0;o{this[t]=null})}_queueCallback(t,e,i=!0){fh(t,e,i)}static getInstance(t){return O.getData(Be(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static get VERSION(){return ym}static get NAME(){throw new Error('You have to implement the static method \"NAME\", for each component!')}static get DATA_KEY(){return`te.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const Tm=\"button\",Em=\"active\";class ao extends Mt{static get NAME(){return Tm}toggle(){this._element.setAttribute(\"aria-pressed\",this._element.classList.toggle(Em))}static jQueryInterface(t){return this.each(function(){const e=ao.getOrCreateInstance(this);t===\"toggle\"&&e[t]()})}}var vt=\"top\",Lt=\"bottom\",$t=\"right\",yt=\"left\",Ps=\"auto\",ji=[vt,Lt,$t,yt],di=\"start\",Yi=\"end\",Ch=\"clippingParents\",Sa=\"viewport\",Ki=\"popper\",Ah=\"reference\",Oa=ji.reduce(function(s,t){return s.concat([t+\"-\"+di,t+\"-\"+Yi])},[]),Ia=[].concat(ji,[Ps]).reduce(function(s,t){return s.concat([t,t+\"-\"+di,t+\"-\"+Yi])},[]),wh=\"beforeRead\",kh=\"read\",Sh=\"afterRead\",Oh=\"beforeMain\",Ih=\"main\",Dh=\"afterMain\",Mh=\"beforeWrite\",Lh=\"write\",$h=\"afterWrite\",lo=[wh,kh,Sh,Oh,Ih,Dh,Mh,Lh,$h];function le(s){return s?(s.nodeName||\"\").toLowerCase():null}function Rt(s){if(s==null)return window;if(s.toString()!==\"[object Window]\"){var t=s.ownerDocument;return t&&t.defaultView||window}return s}function ui(s){var t=Rt(s).Element;return s instanceof t||s instanceof Element}function Pt(s){var t=Rt(s).HTMLElement;return s instanceof t||s instanceof HTMLElement}function Da(s){if(typeof ShadowRoot>\"u\")return!1;var t=Rt(s).ShadowRoot;return s instanceof t||s instanceof ShadowRoot}function xm(s){var t=s.state;Object.keys(t.elements).forEach(function(e){var i=t.styles[e]||{},n=t.attributes[e]||{},o=t.elements[e];!Pt(o)||!le(o)||(Object.assign(o.style,i),Object.keys(n).forEach(function(r){var a=n[r];a===!1?o.removeAttribute(r):o.setAttribute(r,a===!0?\"\":a)}))})}function Cm(s){var t=s.state,e={popper:{position:t.options.strategy,left:\"0\",top:\"0\",margin:\"0\"},arrow:{position:\"absolute\"},reference:{}};return Object.assign(t.elements.popper.style,e.popper),t.styles=e,t.elements.arrow&&Object.assign(t.elements.arrow.style,e.arrow),function(){Object.keys(t.elements).forEach(function(i){var n=t.elements[i],o=t.attributes[i]||{},r=Object.keys(t.styles.hasOwnProperty(i)?t.styles[i]:e[i]),a=r.reduce(function(l,c){return l[c]=\"\",l},{});!Pt(n)||!le(n)||(Object.assign(n.style,a),Object.keys(o).forEach(function(l){n.removeAttribute(l)}))})}}const Ma={name:\"applyStyles\",enabled:!0,phase:\"write\",fn:xm,effect:Cm,requires:[\"computeStyles\"]};function Qt(s){return s.split(\"-\")[0]}var pi=Math.max,co=Math.min,Ui=Math.round;function La(){var s=navigator.userAgentData;return s!=null&&s.brands&&Array.isArray(s.brands)?s.brands.map(function(t){return t.brand+\"/\"+t.version}).join(\" \"):navigator.userAgent}function Rh(){return!/^((?!chrome|android).)*safari/i.test(La())}function Xi(s,t,e){t===void 0&&(t=!1),e===void 0&&(e=!1);var i=s.getBoundingClientRect(),n=1,o=1;t&&Pt(s)&&(n=s.offsetWidth>0&&Ui(i.width)/s.offsetWidth||1,o=s.offsetHeight>0&&Ui(i.height)/s.offsetHeight||1);var r=ui(s)?Rt(s):window,a=r.visualViewport,l=!Rh()&&e,c=(i.left+(l&&a?a.offsetLeft:0))/n,h=(i.top+(l&&a?a.offsetTop:0))/o,d=i.width/n,u=i.height/o;return{width:d,height:u,top:h,right:c+d,bottom:h+u,left:c,x:c,y:h}}function $a(s){var t=Xi(s),e=s.offsetWidth,i=s.offsetHeight;return Math.abs(t.width-e)<=1&&(e=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:s.offsetLeft,y:s.offsetTop,width:e,height:i}}function Ph(s,t){var e=t.getRootNode&&t.getRootNode();if(s.contains(t))return!0;if(e&&Da(e)){var i=t;do{if(i&&s.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function Jt(s){return Rt(s).getComputedStyle(s)}function Am(s){return[\"table\",\"td\",\"th\"].indexOf(le(s))>=0}function He(s){return((ui(s)?s.ownerDocument:s.document)||window.document).documentElement}function ho(s){return le(s)===\"html\"?s:s.assignedSlot||s.parentNode||(Da(s)?s.host:null)||He(s)}function Nh(s){return!Pt(s)||Jt(s).position===\"fixed\"?null:s.offsetParent}function wm(s){var t=/firefox/i.test(La()),e=/Trident/i.test(La());if(e&&Pt(s)){var i=Jt(s);if(i.position===\"fixed\")return null}var n=ho(s);for(Da(n)&&(n=n.host);Pt(n)&&[\"html\",\"body\"].indexOf(le(n))<0;){var o=Jt(n);if(o.transform!==\"none\"||o.perspective!==\"none\"||o.contain===\"paint\"||[\"transform\",\"perspective\"].indexOf(o.willChange)!==-1||t&&o.willChange===\"filter\"||t&&o.filter&&o.filter!==\"none\")return n;n=n.parentNode}return null}function Ns(s){for(var t=Rt(s),e=Nh(s);e&&Am(e)&&Jt(e).position===\"static\";)e=Nh(e);return e&&(le(e)===\"html\"||le(e)===\"body\"&&Jt(e).position===\"static\")?t:e||wm(s)||t}function Ra(s){return[\"top\",\"bottom\"].indexOf(s)>=0?\"x\":\"y\"}function Bs(s,t,e){return pi(s,co(t,e))}function km(s,t,e){var i=Bs(s,t,e);return i>e?e:i}function Bh(){return{top:0,right:0,bottom:0,left:0}}function Hh(s){return Object.assign({},Bh(),s)}function Vh(s,t){return t.reduce(function(e,i){return e[i]=s,e},{})}var Sm=function(t,e){return t=typeof t==\"function\"?t(Object.assign({},e.rects,{placement:e.placement})):t,Hh(typeof t!=\"number\"?t:Vh(t,ji))};function Om(s){var t,e=s.state,i=s.name,n=s.options,o=e.elements.arrow,r=e.modifiersData.popperOffsets,a=Qt(e.placement),l=Ra(a),c=[yt,$t].indexOf(a)>=0,h=c?\"height\":\"width\";if(!(!o||!r)){var d=Sm(n.padding,e),u=$a(o),p=l===\"y\"?vt:yt,f=l===\"y\"?Lt:$t,b=e.rects.reference[h]+e.rects.reference[l]-r[l]-e.rects.popper[h],v=r[l]-e.rects.reference[l],y=Ns(o),T=y?l===\"y\"?y.clientHeight||0:y.clientWidth||0:0,x=b/2-v/2,E=d[p],C=T-u[h]-d[f],A=T/2-u[h]/2+x,w=Bs(E,A,C),S=l;e.modifiersData[i]=(t={},t[S]=w,t.centerOffset=w-A,t)}}function Im(s){var t=s.state,e=s.options,i=e.element,n=i===void 0?\"[data-popper-arrow]\":i;if(n!=null&&!(typeof n==\"string\"&&(n=t.elements.popper.querySelector(n),!n))){if({}.NODE_ENV!==\"production\"&&(Pt(n)||console.error(['Popper: \"arrow\" element must be an HTMLElement (not an SVGElement).',\"To use an SVG arrow, wrap it in an HTMLElement that will be used as\",\"the arrow.\"].join(\" \"))),!Ph(t.elements.popper,n)){({}).NODE_ENV!==\"production\"&&console.error(['Popper: \"arrow\" modifier\\'s `element` must be a child of the popper',\"element.\"].join(\" \"));return}t.elements.arrow=n}}const Fh={name:\"arrow\",enabled:!0,phase:\"main\",fn:Om,effect:Im,requires:[\"popperOffsets\"],requiresIfExists:[\"preventOverflow\"]};function Gi(s){return s.split(\"-\")[1]}var Dm={top:\"auto\",right:\"auto\",bottom:\"auto\",left:\"auto\"};function Mm(s,t){var e=s.x,i=s.y,n=t.devicePixelRatio||1;return{x:Ui(e*n)/n||0,y:Ui(i*n)/n||0}}function Wh(s){var t,e=s.popper,i=s.popperRect,n=s.placement,o=s.variation,r=s.offsets,a=s.position,l=s.gpuAcceleration,c=s.adaptive,h=s.roundOffsets,d=s.isFixed,u=r.x,p=u===void 0?0:u,f=r.y,b=f===void 0?0:f,v=typeof h==\"function\"?h({x:p,y:b}):{x:p,y:b};p=v.x,b=v.y;var y=r.hasOwnProperty(\"x\"),T=r.hasOwnProperty(\"y\"),x=yt,E=vt,C=window;if(c){var A=Ns(e),w=\"clientHeight\",S=\"clientWidth\";if(A===Rt(e)&&(A=He(e),Jt(A).position!==\"static\"&&a===\"absolute\"&&(w=\"scrollHeight\",S=\"scrollWidth\")),A=A,n===vt||(n===yt||n===$t)&&o===Yi){E=Lt;var k=d&&A===C&&C.visualViewport?C.visualViewport.height:A[w];b-=k-i.height,b*=l?1:-1}if(n===yt||(n===vt||n===Lt)&&o===Yi){x=$t;var D=d&&A===C&&C.visualViewport?C.visualViewport.width:A[S];p-=D-i.width,p*=l?1:-1}}var I=Object.assign({position:a},c&&Dm),M=h===!0?Mm({x:p,y:b},Rt(e)):{x:p,y:b};if(p=M.x,b=M.y,l){var P;return Object.assign({},I,(P={},P[E]=T?\"0\":\"\",P[x]=y?\"0\":\"\",P.transform=(C.devicePixelRatio||1)<=1?\"translate(\"+p+\"px, \"+b+\"px)\":\"translate3d(\"+p+\"px, \"+b+\"px, 0)\",P))}return Object.assign({},I,(t={},t[E]=T?b+\"px\":\"\",t[x]=y?p+\"px\":\"\",t.transform=\"\",t))}function Lm(s){var t=s.state,e=s.options,i=e.gpuAcceleration,n=i===void 0?!0:i,o=e.adaptive,r=o===void 0?!0:o,a=e.roundOffsets,l=a===void 0?!0:a;if({}.NODE_ENV!==\"production\"){var c=Jt(t.elements.popper).transitionProperty||\"\";r&&[\"transform\",\"top\",\"right\",\"bottom\",\"left\"].some(function(d){return c.indexOf(d)>=0})&&console.warn([\"Popper: Detected CSS transitions on at least one of the following\",'CSS properties: \"transform\", \"top\", \"right\", \"bottom\", \"left\".',`\n\n`,'Disable the \"computeStyles\" modifier\\'s `adaptive` option to allow',\"for smooth transitions, or remove these properties from the CSS\",\"transition declaration on the popper element if only transitioning\",\"opacity or background-color for example.\",`\n\n`,\"We recommend using the popper element as a wrapper around an inner\",\"element that can have any CSS property transitioned for animations.\"].join(\" \"))}var h={placement:Qt(t.placement),variation:Gi(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:n,isFixed:t.options.strategy===\"fixed\"};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,Wh(Object.assign({},h,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:r,roundOffsets:l})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,Wh(Object.assign({},h,{offsets:t.modifiersData.arrow,position:\"absolute\",adaptive:!1,roundOffsets:l})))),t.attributes.popper=Object.assign({},t.attributes.popper,{\"data-popper-placement\":t.placement})}const Pa={name:\"computeStyles\",enabled:!0,phase:\"beforeWrite\",fn:Lm,data:{}};var uo={passive:!0};function $m(s){var t=s.state,e=s.instance,i=s.options,n=i.scroll,o=n===void 0?!0:n,r=i.resize,a=r===void 0?!0:r,l=Rt(t.elements.popper),c=[].concat(t.scrollParents.reference,t.scrollParents.popper);return o&&c.forEach(function(h){h.addEventListener(\"scroll\",e.update,uo)}),a&&l.addEventListener(\"resize\",e.update,uo),function(){o&&c.forEach(function(h){h.removeEventListener(\"scroll\",e.update,uo)}),a&&l.removeEventListener(\"resize\",e.update,uo)}}const Na={name:\"eventListeners\",enabled:!0,phase:\"write\",fn:function(){},effect:$m,data:{}};var Rm={left:\"right\",right:\"left\",bottom:\"top\",top:\"bottom\"};function po(s){return s.replace(/left|right|bottom|top/g,function(t){return Rm[t]})}var Pm={start:\"end\",end:\"start\"};function zh(s){return s.replace(/start|end/g,function(t){return Pm[t]})}function Ba(s){var t=Rt(s),e=t.pageXOffset,i=t.pageYOffset;return{scrollLeft:e,scrollTop:i}}function Ha(s){return Xi(He(s)).left+Ba(s).scrollLeft}function Nm(s,t){var e=Rt(s),i=He(s),n=e.visualViewport,o=i.clientWidth,r=i.clientHeight,a=0,l=0;if(n){o=n.width,r=n.height;var c=Rh();(c||!c&&t===\"fixed\")&&(a=n.offsetLeft,l=n.offsetTop)}return{width:o,height:r,x:a+Ha(s),y:l}}function Bm(s){var t,e=He(s),i=Ba(s),n=(t=s.ownerDocument)==null?void 0:t.body,o=pi(e.scrollWidth,e.clientWidth,n?n.scrollWidth:0,n?n.clientWidth:0),r=pi(e.scrollHeight,e.clientHeight,n?n.scrollHeight:0,n?n.clientHeight:0),a=-i.scrollLeft+Ha(s),l=-i.scrollTop;return Jt(n||e).direction===\"rtl\"&&(a+=pi(e.clientWidth,n?n.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}function Va(s){var t=Jt(s),e=t.overflow,i=t.overflowX,n=t.overflowY;return/auto|scroll|overlay|hidden/.test(e+n+i)}function jh(s){return[\"html\",\"body\",\"#document\"].indexOf(le(s))>=0?s.ownerDocument.body:Pt(s)&&Va(s)?s:jh(ho(s))}function Hs(s,t){var e;t===void 0&&(t=[]);var i=jh(s),n=i===((e=s.ownerDocument)==null?void 0:e.body),o=Rt(i),r=n?[o].concat(o.visualViewport||[],Va(i)?i:[]):i,a=t.concat(r);return n?a:a.concat(Hs(ho(r)))}function Fa(s){return Object.assign({},s,{left:s.x,top:s.y,right:s.x+s.width,bottom:s.y+s.height})}function Hm(s,t){var e=Xi(s,!1,t===\"fixed\");return e.top=e.top+s.clientTop,e.left=e.left+s.clientLeft,e.bottom=e.top+s.clientHeight,e.right=e.left+s.clientWidth,e.width=s.clientWidth,e.height=s.clientHeight,e.x=e.left,e.y=e.top,e}function Yh(s,t,e){return t===Sa?Fa(Nm(s,e)):ui(t)?Hm(t,e):Fa(Bm(He(s)))}function Vm(s){var t=Hs(ho(s)),e=[\"absolute\",\"fixed\"].indexOf(Jt(s).position)>=0,i=e&&Pt(s)?Ns(s):s;return ui(i)?t.filter(function(n){return ui(n)&&Ph(n,i)&&le(n)!==\"body\"}):[]}function Fm(s,t,e,i){var n=t===\"clippingParents\"?Vm(s):[].concat(t),o=[].concat(n,[e]),r=o[0],a=o.reduce(function(l,c){var h=Yh(s,c,i);return l.top=pi(h.top,l.top),l.right=co(h.right,l.right),l.bottom=co(h.bottom,l.bottom),l.left=pi(h.left,l.left),l},Yh(s,r,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function Kh(s){var t=s.reference,e=s.element,i=s.placement,n=i?Qt(i):null,o=i?Gi(i):null,r=t.x+t.width/2-e.width/2,a=t.y+t.height/2-e.height/2,l;switch(n){case vt:l={x:r,y:t.y-e.height};break;case Lt:l={x:r,y:t.y+t.height};break;case $t:l={x:t.x+t.width,y:a};break;case yt:l={x:t.x-e.width,y:a};break;default:l={x:t.x,y:t.y}}var c=n?Ra(n):null;if(c!=null){var h=c===\"y\"?\"height\":\"width\";switch(o){case di:l[c]=l[c]-(t[h]/2-e[h]/2);break;case Yi:l[c]=l[c]+(t[h]/2-e[h]/2);break}}return l}function qi(s,t){t===void 0&&(t={});var e=t,i=e.placement,n=i===void 0?s.placement:i,o=e.strategy,r=o===void 0?s.strategy:o,a=e.boundary,l=a===void 0?Ch:a,c=e.rootBoundary,h=c===void 0?Sa:c,d=e.elementContext,u=d===void 0?Ki:d,p=e.altBoundary,f=p===void 0?!1:p,b=e.padding,v=b===void 0?0:b,y=Hh(typeof v!=\"number\"?v:Vh(v,ji)),T=u===Ki?Ah:Ki,x=s.rects.popper,E=s.elements[f?T:u],C=Fm(ui(E)?E:E.contextElement||He(s.elements.popper),l,h,r),A=Xi(s.elements.reference),w=Kh({reference:A,element:x,strategy:\"absolute\",placement:n}),S=Fa(Object.assign({},x,w)),k=u===Ki?S:A,D={top:C.top-k.top+y.top,bottom:k.bottom-C.bottom+y.bottom,left:C.left-k.left+y.left,right:k.right-C.right+y.right},I=s.modifiersData.offset;if(u===Ki&&I){var M=I[n];Object.keys(D).forEach(function(P){var X=[$t,Lt].indexOf(P)>=0?1:-1,R=[vt,Lt].indexOf(P)>=0?\"y\":\"x\";D[P]+=M[R]*X})}return D}function Wm(s,t){t===void 0&&(t={});var e=t,i=e.placement,n=e.boundary,o=e.rootBoundary,r=e.padding,a=e.flipVariations,l=e.allowedAutoPlacements,c=l===void 0?Ia:l,h=Gi(i),d=h?a?Oa:Oa.filter(function(f){return Gi(f)===h}):ji,u=d.filter(function(f){return c.indexOf(f)>=0});u.length===0&&(u=d,{}.NODE_ENV!==\"production\"&&console.error([\"Popper: The `allowedAutoPlacements` option did not allow any\",\"placements. Ensure the `placement` option matches the variation\",\"of the allowed placements.\",'For example, \"auto\" cannot be used to allow \"bottom-start\".','Use \"auto-start\" instead.'].join(\" \")));var p=u.reduce(function(f,b){return f[b]=qi(s,{placement:b,boundary:n,rootBoundary:o,padding:r})[Qt(b)],f},{});return Object.keys(p).sort(function(f,b){return p[f]-p[b]})}function zm(s){if(Qt(s)===Ps)return[];var t=po(s);return[zh(s),t,zh(t)]}function jm(s){var t=s.state,e=s.options,i=s.name;if(!t.modifiersData[i]._skip){for(var n=e.mainAxis,o=n===void 0?!0:n,r=e.altAxis,a=r===void 0?!0:r,l=e.fallbackPlacements,c=e.padding,h=e.boundary,d=e.rootBoundary,u=e.altBoundary,p=e.flipVariations,f=p===void 0?!0:p,b=e.allowedAutoPlacements,v=t.options.placement,y=Qt(v),T=y===v,x=l||(T||!f?[po(v)]:zm(v)),E=[v].concat(x).reduce(function(we,Zt){return we.concat(Qt(Zt)===Ps?Wm(t,{placement:Zt,boundary:h,rootBoundary:d,padding:c,flipVariations:f,allowedAutoPlacements:b}):Zt)},[]),C=t.rects.reference,A=t.rects.popper,w=new Map,S=!0,k=E[0],D=0;D=0,R=X?\"width\":\"height\",z=qi(t,{placement:I,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),Y=X?P?$t:yt:P?Lt:vt;C[R]>A[R]&&(Y=po(Y));var Gt=po(Y),oe=[];if(o&&oe.push(z[M]<=0),a&&oe.push(z[Y]<=0,z[Gt]<=0),oe.every(function(we){return we})){k=I,S=!1;break}w.set(I,oe)}if(S)for(var re=f?3:1,li=function(Zt){var Pe=E.find(function(Ta){var Vi=w.get(Ta);if(Vi)return Vi.slice(0,Zt).every(function(rh){return rh})});if(Pe)return k=Pe,\"break\"},qt=re;qt>0;qt--){var Ae=li(qt);if(Ae===\"break\")break}t.placement!==k&&(t.modifiersData[i]._skip=!0,t.placement=k,t.reset=!0)}}const Uh={name:\"flip\",enabled:!0,phase:\"main\",fn:jm,requiresIfExists:[\"offset\"],data:{_skip:!1}};function Xh(s,t,e){return e===void 0&&(e={x:0,y:0}),{top:s.top-t.height-e.y,right:s.right-t.width+e.x,bottom:s.bottom-t.height+e.y,left:s.left-t.width-e.x}}function Gh(s){return[vt,$t,Lt,yt].some(function(t){return s[t]>=0})}function Ym(s){var t=s.state,e=s.name,i=t.rects.reference,n=t.rects.popper,o=t.modifiersData.preventOverflow,r=qi(t,{elementContext:\"reference\"}),a=qi(t,{altBoundary:!0}),l=Xh(r,i),c=Xh(a,n,o),h=Gh(l),d=Gh(c);t.modifiersData[e]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},t.attributes.popper=Object.assign({},t.attributes.popper,{\"data-popper-reference-hidden\":h,\"data-popper-escaped\":d})}const qh={name:\"hide\",enabled:!0,phase:\"main\",requiresIfExists:[\"preventOverflow\"],fn:Ym};function Km(s,t,e){var i=Qt(s),n=[yt,vt].indexOf(i)>=0?-1:1,o=typeof e==\"function\"?e(Object.assign({},t,{placement:s})):e,r=o[0],a=o[1];return r=r||0,a=(a||0)*n,[yt,$t].indexOf(i)>=0?{x:a,y:r}:{x:r,y:a}}function Um(s){var t=s.state,e=s.options,i=s.name,n=e.offset,o=n===void 0?[0,0]:n,r=Ia.reduce(function(h,d){return h[d]=Km(d,t.rects,o),h},{}),a=r[t.placement],l=a.x,c=a.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=l,t.modifiersData.popperOffsets.y+=c),t.modifiersData[i]=r}const Zh={name:\"offset\",enabled:!0,phase:\"main\",requires:[\"popperOffsets\"],fn:Um};function Xm(s){var t=s.state,e=s.name;t.modifiersData[e]=Kh({reference:t.rects.reference,element:t.rects.popper,strategy:\"absolute\",placement:t.placement})}const Wa={name:\"popperOffsets\",enabled:!0,phase:\"read\",fn:Xm,data:{}};function Gm(s){return s===\"x\"?\"y\":\"x\"}function qm(s){var t=s.state,e=s.options,i=s.name,n=e.mainAxis,o=n===void 0?!0:n,r=e.altAxis,a=r===void 0?!1:r,l=e.boundary,c=e.rootBoundary,h=e.altBoundary,d=e.padding,u=e.tether,p=u===void 0?!0:u,f=e.tetherOffset,b=f===void 0?0:f,v=qi(t,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),y=Qt(t.placement),T=Gi(t.placement),x=!T,E=Ra(y),C=Gm(E),A=t.modifiersData.popperOffsets,w=t.rects.reference,S=t.rects.popper,k=typeof b==\"function\"?b(Object.assign({},t.rects,{placement:t.placement})):b,D=typeof k==\"number\"?{mainAxis:k,altAxis:k}:Object.assign({mainAxis:0,altAxis:0},k),I=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,M={x:0,y:0};if(A){if(o){var P,X=E===\"y\"?vt:yt,R=E===\"y\"?Lt:$t,z=E===\"y\"?\"height\":\"width\",Y=A[E],Gt=Y+v[X],oe=Y-v[R],re=p?-S[z]/2:0,li=T===di?w[z]:S[z],qt=T===di?-S[z]:-w[z],Ae=t.elements.arrow,we=p&&Ae?$a(Ae):{width:0,height:0},Zt=t.modifiersData[\"arrow#persistent\"]?t.modifiersData[\"arrow#persistent\"].padding:Bh(),Pe=Zt[X],Ta=Zt[R],Vi=Bs(0,w[z],we[z]),rh=x?w[z]/2-re-Vi-Pe-D.mainAxis:li-Vi-Pe-D.mainAxis,mL=x?-w[z]/2+re+Vi+Ta+D.mainAxis:qt+Vi+Ta+D.mainAxis,ah=t.elements.arrow&&Ns(t.elements.arrow),bL=ah?E===\"y\"?ah.clientTop||0:ah.clientLeft||0:0,Zg=(P=I==null?void 0:I[E])!=null?P:0,vL=Y+rh-Zg-bL,yL=Y+mL-Zg,Qg=Bs(p?co(Gt,vL):Gt,Y,p?pi(oe,yL):oe);A[E]=Qg,M[E]=Qg-Y}if(a){var Jg,TL=E===\"x\"?vt:yt,EL=E===\"x\"?Lt:$t,Fi=A[C],Ea=C===\"y\"?\"height\":\"width\",tm=Fi+v[TL],em=Fi-v[EL],lh=[vt,yt].indexOf(y)!==-1,im=(Jg=I==null?void 0:I[C])!=null?Jg:0,sm=lh?tm:Fi-w[Ea]-S[Ea]-im+D.altAxis,nm=lh?Fi+w[Ea]+S[Ea]-im-D.altAxis:em,om=p&&lh?km(sm,Fi,nm):Bs(p?sm:tm,Fi,p?nm:em);A[C]=om,M[C]=om-Fi}t.modifiersData[i]=M}}const Qh={name:\"preventOverflow\",enabled:!0,phase:\"main\",fn:qm,requiresIfExists:[\"offset\"]};function Zm(s){return{scrollLeft:s.scrollLeft,scrollTop:s.scrollTop}}function Qm(s){return s===Rt(s)||!Pt(s)?Ba(s):Zm(s)}function Jm(s){var t=s.getBoundingClientRect(),e=Ui(t.width)/s.offsetWidth||1,i=Ui(t.height)/s.offsetHeight||1;return e!==1||i!==1}function tb(s,t,e){e===void 0&&(e=!1);var i=Pt(t),n=Pt(t)&&Jm(t),o=He(t),r=Xi(s,n,e),a={scrollLeft:0,scrollTop:0},l={x:0,y:0};return(i||!i&&!e)&&((le(t)!==\"body\"||Va(o))&&(a=Qm(t)),Pt(t)?(l=Xi(t,!0),l.x+=t.clientLeft,l.y+=t.clientTop):o&&(l.x=Ha(o))),{x:r.left+a.scrollLeft-l.x,y:r.top+a.scrollTop-l.y,width:r.width,height:r.height}}function eb(s){var t=new Map,e=new Set,i=[];s.forEach(function(o){t.set(o.name,o)});function n(o){e.add(o.name);var r=[].concat(o.requires||[],o.requiresIfExists||[]);r.forEach(function(a){if(!e.has(a)){var l=t.get(a);l&&n(l)}}),i.push(o)}return s.forEach(function(o){e.has(o.name)||n(o)}),i}function ib(s){var t=eb(s);return lo.reduce(function(e,i){return e.concat(t.filter(function(n){return n.phase===i}))},[])}function sb(s){var t;return function(){return t||(t=new Promise(function(e){Promise.resolve().then(function(){t=void 0,e(s())})})),t}}function Ve(s){for(var t=arguments.length,e=new Array(t>1?t-1:0),i=1;i100)){console.error(lb);break}if(h.reset===!0){h.reset=!1,C=-1;continue}var A=h.orderedModifiers[C],w=A.fn,S=A.options,k=S===void 0?{}:S,D=A.name;typeof w==\"function\"&&(h=w({state:h,options:k,name:D,instance:p})||h)}}},update:sb(function(){return new Promise(function(v){p.forceUpdate(),v(h)})}),destroy:function(){b(),u=!0}};if(!id(a,l))return{}.NODE_ENV!==\"production\"&&console.error(td),p;p.setOptions(c).then(function(v){!u&&c.onFirstUpdate&&c.onFirstUpdate(v)});function f(){h.orderedModifiers.forEach(function(v){var y=v.name,T=v.options,x=T===void 0?{}:T,E=v.effect;if(typeof E==\"function\"){var C=E({state:h,name:y,instance:p,options:x}),A=function(){};d.push(C||A)}})}function b(){d.forEach(function(v){return v()}),d=[]}return p}}var cb=fo(),hb=[Na,Wa,Pa,Ma],db=fo({defaultModifiers:hb}),ub=[Na,Wa,Pa,Ma,Zh,Uh,Qh,Fh,qh],Fe=fo({defaultModifiers:ub});const sd=Object.freeze(Object.defineProperty({__proto__:null,afterMain:Dh,afterRead:Sh,afterWrite:$h,applyStyles:Ma,arrow:Fh,auto:Ps,basePlacements:ji,beforeMain:Oh,beforeRead:wh,beforeWrite:Mh,bottom:Lt,clippingParents:Ch,computeStyles:Pa,createPopper:Fe,createPopperBase:cb,createPopperLite:db,detectOverflow:qi,end:Yi,eventListeners:Na,flip:Uh,hide:qh,left:yt,main:Ih,modifierPhases:lo,offset:Zh,placements:Ia,popper:Ki,popperGenerator:fo,popperOffsets:Wa,preventOverflow:Qh,read:kh,reference:Ah,right:$t,start:di,top:vt,variationPlacements:Oa,viewport:Sa,write:Lh},Symbol.toStringTag,{value:\"Module\"}));function za(s){return s===\"true\"?!0:s===\"false\"?!1:s===Number(s).toString()?Number(s):s===\"\"||s===\"null\"?null:s}function ja(s){return s.replace(/[A-Z]/g,t=>`-${t.toLowerCase()}`)}const g={setDataAttribute(s,t,e){s.setAttribute(`data-te-${ja(t)}`,e)},removeDataAttribute(s,t){s.removeAttribute(`data-te-${ja(t)}`)},getDataAttributes(s){if(!s)return{};const t={};return Object.keys(s.dataset).filter(e=>e.startsWith(\"te\")).forEach(e=>{if(e.startsWith(\"teClass\"))return;let i=e.replace(/^te/,\"\");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),t[i]=za(s.dataset[e])}),t},getDataClassAttributes(s){if(!s)return{};const t={...s.dataset};return Object.keys(t).filter(e=>e.startsWith(\"teClass\")).forEach(e=>{let i=e.replace(/^teClass/,\"\");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),t[i]=za(t[e])}),t},getDataAttribute(s,t){return za(s.getAttribute(`data-te-${ja(t)}`))},offset(s){const t=s.getBoundingClientRect();return{top:t.top+document.body.scrollTop,left:t.left+document.body.scrollLeft}},position(s){return{top:s.offsetTop,left:s.offsetLeft}},style(s,t){Object.assign(s.style,t)},toggleClass(s,t){s&&Ya(t).forEach(e=>{s.classList.contains(e)?s.classList.remove(e):s.classList.add(e)})},addClass(s,t){Ya(t).forEach(e=>!s.classList.contains(e)&&s.classList.add(e))},addStyle(s,t){Object.keys(t).forEach(e=>{s.style[e]=t[e]})},removeClass(s,t){Ya(t).forEach(e=>s.classList.contains(e)&&s.classList.remove(e))},hasClass(s,t){return s.classList.contains(t)},maxOffset(s){const t=s.getBoundingClientRect();return{top:t.top+Math.max(document.body.scrollTop,document.documentElement.scrollTop,window.scrollY),left:t.left+Math.max(document.body.scrollLeft,document.documentElement.scrollLeft,window.scrollX)}}};function Ya(s){return typeof s==\"string\"?s.split(\" \"):Array.isArray(s)?s:!1}const pb=3,m={closest(s,t){return s.closest(t)},matches(s,t){return s.matches(t)},find(s,t=document.documentElement){return[].concat(...Element.prototype.querySelectorAll.call(t,s))},findOne(s,t=document.documentElement){return Element.prototype.querySelector.call(t,s)},children(s,t){return[].concat(...s.children).filter(i=>i.matches(t))},parents(s,t){const e=[];let i=s.parentNode;for(;i&&i.nodeType===Node.ELEMENT_NODE&&i.nodeType!==pb;)this.matches(i,t)&&e.push(i),i=i.parentNode;return e},prev(s,t){let e=s.previousElementSibling;for(;e;){if(e.matches(t))return[e];e=e.previousElementSibling}return[]},next(s,t){let e=s.nextElementSibling;for(;e;){if(this.matches(e,t))return[e];e=e.nextElementSibling}return[]},focusableChildren(s){const t=[\"a\",\"button\",\"input\",\"textarea\",\"select\",\"details\",\"[tabindex]\",'[contenteditable=\"true\"]'].map(e=>`${e}:not([tabindex^=\"-\"])`).join(\", \");return this.find(t,s).filter(e=>!ci(e)&&ae(e))}},Ka=\"dropdown\",_i=\".te.dropdown\",Ua=\".data-api\",_o=\"Escape\",nd=\"Space\",od=\"Tab\",Xa=\"ArrowUp\",go=\"ArrowDown\",fb=2,_b=new RegExp(`${Xa}|${go}|${_o}`),gb=`hide${_i}`,mb=`hidden${_i}`,bb=`show${_i}`,vb=`shown${_i}`,yb=`click${_i}${Ua}`,rd=`keydown${_i}${Ua}`,Tb=`keyup${_i}${Ua}`,We=\"show\",Eb=\"dropup\",xb=\"dropend\",Cb=\"dropstart\",Ab=\"[data-te-navbar-ref]\",mo=\"[data-te-dropdown-toggle-ref]\",Ga=\"[data-te-dropdown-menu-ref]\",wb=\"[data-te-navbar-nav-ref]\",kb=\"[data-te-dropdown-menu-ref] [data-te-dropdown-item-ref]:not(.disabled):not(:disabled)\",Sb=et()?\"top-end\":\"top-start\",Ob=et()?\"top-start\":\"top-end\",Ib=et()?\"bottom-end\":\"bottom-start\",Db=et()?\"bottom-start\":\"bottom-end\",Mb=et()?\"left-start\":\"right-start\",Lb=et()?\"right-start\":\"left-start\",$b=[{opacity:\"0\"},{opacity:\"1\"}],Rb=[{opacity:\"1\"},{opacity:\"0\"}],ad={iterations:1,easing:\"ease\",fill:\"both\"},Pb={offset:[0,2],boundary:\"clippingParents\",reference:\"toggle\",display:\"dynamic\",popperConfig:null,autoClose:!0,dropdownAnimation:\"on\",animationDuration:550},Nb={offset:\"(array|string|function)\",boundary:\"(string|element)\",reference:\"(string|element|object)\",display:\"string\",popperConfig:\"(null|object|function)\",autoClose:\"(boolean|string)\",dropdownAnimation:\"string\",animationDuration:\"number\"};class Ft extends Mt{constructor(t,e){super(t),this._popper=null,this._config=this._getConfig(e),this._menu=this._getMenuElement(),this._inNavbar=this._detectNavbar(),this._fadeOutAnimate=null;const i=window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;this._animationCanPlay=this._config.dropdownAnimation===\"on\"&&!i,this._didInit=!1,this._init()}static get Default(){return Pb}static get DefaultType(){return Nb}static get NAME(){return Ka}toggle(){return this._isShown()?this.hide():this.show()}show(){if(ci(this._element)||this._isShown(this._menu))return;const t={relatedTarget:this._element};if(_.trigger(this._element,bb,t).defaultPrevented)return;const i=Ft.getParentFromElement(this._element);this._inNavbar?g.setDataAttribute(this._menu,\"popper\",\"none\"):this._createPopper(i),\"ontouchstart\"in document.documentElement&&!i.closest(wb)&&[].concat(...document.body.children).forEach(n=>_.on(n,\"mouseover\",ro)),this._element.focus(),this._element.setAttribute(\"aria-expanded\",!0),this._menu.setAttribute(`data-te-dropdown-${We}`,\"\"),this._animationCanPlay&&this._menu.animate($b,{...ad,duration:this._config.animationDuration}),this._element.setAttribute(`data-te-dropdown-${We}`,\"\"),setTimeout(()=>{_.trigger(this._element,vb,t)},this._animationCanPlay?this._config.animationDuration:0)}hide(){if(ci(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_init(){this._didInit||(_.on(document,rd,mo,Ft.dataApiKeydownHandler),_.on(document,rd,Ga,Ft.dataApiKeydownHandler),_.on(document,yb,Ft.clearMenus),_.on(document,Tb,Ft.clearMenus),this._didInit=!0)}_completeHide(t){this._fadeOutAnimate&&this._fadeOutAnimate.playState===\"running\"||_.trigger(this._element,gb,t).defaultPrevented||(\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(i=>_.off(i,\"mouseover\",ro)),this._animationCanPlay&&(this._fadeOutAnimate=this._menu.animate(Rb,{...ad,duration:this._config.animationDuration})),setTimeout(()=>{this._popper&&this._popper.destroy(),this._menu.removeAttribute(`data-te-dropdown-${We}`),this._element.removeAttribute(`data-te-dropdown-${We}`),this._element.setAttribute(\"aria-expanded\",\"false\"),g.removeDataAttribute(this._menu,\"popper\"),_.trigger(this._element,mb,t)},this._animationCanPlay?this._config.animationDuration:0))}_getConfig(t){if(t={...this.constructor.Default,...g.getDataAttributes(this._element),...t},L(Ka,t,this.constructor.DefaultType),typeof t.reference==\"object\"&&!Wi(t.reference)&&typeof t.reference.getBoundingClientRect!=\"function\")throw new TypeError(`${Ka.toUpperCase()}: Option \"reference\" provided type \"object\" without a required \"getBoundingClientRect\" method.`);return t}_createPopper(t){if(typeof sd>\"u\")throw new TypeError(\"Bootstrap's dropdowns require Popper (https://popper.js.org)\");let e=this._element;this._config.reference===\"parent\"?e=t:Wi(this._config.reference)?e=Be(this._config.reference):typeof this._config.reference==\"object\"&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find(o=>o.name===\"applyStyles\"&&o.enabled===!1);this._popper=Fe(e,this._menu,i),n&&g.setDataAttribute(this._menu,\"popper\",\"static\")}_isShown(t=this._element){return t.dataset[`teDropdown${We.charAt(0).toUpperCase()+We.slice(1)}`]===\"\"}_getMenuElement(){return m.next(this._element,Ga)[0]}_getPlacement(){const t=this._element.parentNode;if(t.dataset.teDropdownPosition===xb)return Mb;if(t.dataset.teDropdownPosition===Cb)return Lb;const e=t.dataset.teDropdownAlignment===\"end\";return t.dataset.teDropdownPosition===Eb?e?Ob:Sb:e?Db:Ib}_detectNavbar(){return this._element.closest(Ab)!==null}_getOffset(){const{offset:t}=this._config;return typeof t==\"string\"?t.split(\",\").map(e=>Number.parseInt(e,10)):typeof t==\"function\"?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:\"preventOverflow\",options:{boundary:this._config.boundary}},{name:\"offset\",options:{offset:this._getOffset()}}]};return this._config.display===\"static\"&&(t.modifiers=[{name:\"applyStyles\",enabled:!1}]),{...t,...typeof this._config.popperConfig==\"function\"?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=m.find(kb,this._menu).filter(ae);i.length&&_h(i,e,t===go,!i.includes(e)).focus()}static jQueryInterface(t){return this.each(function(){const e=Ft.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}static clearMenus(t){if(t&&(t.button===fb||t.type===\"keyup\"&&t.key!==od))return;const e=m.find(mo);for(let i=0,n=e.length;ih===this._element);l!==null&&c.length&&(this._selector=l,this._triggerArray.push(a))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return cd}static get NAME(){return qa}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[],e;if(this._config.parent){const h=m.find(dd,this._config.parent);t=m.find(Kb,this._config.parent).filter(d=>!h.includes(d))}const i=m.findOne(this._selector);if(t.length){const h=t.find(d=>i!==d);if(e=h?ce.getInstance(h):null,e&&e._isTransitioning)return}if(_.trigger(this._element,Hb).defaultPrevented)return;t.forEach(h=>{i!==h&&ce.getOrCreateInstance(h,{toggle:!1}).hide(),e||O.setData(h,ld,null)});const o=this._getDimension(),r=o===\"height\"?this._classes.collapsing:this._classes.collapsingHorizontal;g.removeClass(this._element,this._classes.visible),g.removeClass(this._element,this._classes.hidden),g.addClass(this._element,r),this._element.removeAttribute(Zi),this._element.setAttribute(vo,\"\"),this._element.style[o]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const a=()=>{this._isTransitioning=!1,g.removeClass(this._element,this._classes.hidden),g.removeClass(this._element,r),g.addClass(this._element,this._classes.visible),this._element.removeAttribute(vo),this._element.setAttribute(Zi,\"\"),this._element.setAttribute(Za,\"\"),this._element.style[o]=\"\",_.trigger(this._element,Vb)},c=`scroll${o[0].toUpperCase()+o.slice(1)}`;this._queueCallback(a,this._element,!0),this._element.style[o]=`${this._element[c]}px`}hide(){if(this._isTransitioning||!this._isShown()||_.trigger(this._element,Fb).defaultPrevented)return;const e=this._getDimension(),i=e===\"height\"?this._classes.collapsing:this._classes.collapsingHorizontal;this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,zi(this._element),g.addClass(this._element,i),g.removeClass(this._element,this._classes.visible),g.removeClass(this._element,this._classes.hidden),this._element.setAttribute(vo,\"\"),this._element.removeAttribute(Zi),this._element.removeAttribute(Za);const n=this._triggerArray.length;for(let r=0;r{this._isTransitioning=!1,g.removeClass(this._element,i),g.addClass(this._element,this._classes.visible),g.addClass(this._element,this._classes.hidden),this._element.removeAttribute(vo),this._element.setAttribute(Zi,\"\"),_.trigger(this._element,Wb)};this._element.style[e]=\"\",this._queueCallback(o,this._element,!0)}_isShown(t=this._element){return t.hasAttribute(Za)}_getConfig(t){return t={...cd,...g.getDataAttributes(this._element),...t},t.toggle=!!t.toggle,t.parent=Be(t.parent),L(qa,t,Bb),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Ub,...e,...t},L(qa,t,Xb),t}_getDimension(){return this._element.hasAttribute(zb)?jb:Yb}_initializeChildren(){if(!this._config.parent)return;const t=m.find(dd,this._config.parent);m.find(ud,this._config.parent).filter(e=>!t.includes(e)).forEach(e=>{const i=Ne(e);i&&this._addAriaAndCollapsedClass([e],this._isShown(i))})}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach(i=>{e?i.removeAttribute(hd):i.setAttribute(`${hd}`,\"\"),i.setAttribute(\"aria-expanded\",e)})}static jQueryInterface(t){return this.each(function(){const e={};typeof t==\"string\"&&/show|hide/.test(t)&&(e.toggle=!1);const i=ce.getOrCreateInstance(this,e);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t]()}})}}const pd=\".fixed-top, .fixed-bottom, .is-fixed, .sticky-top\",fd=\".sticky-top\";class Qi{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,\"paddingRight\",e=>e+t),this._setElementAttributes(pd,\"paddingRight\",e=>e+t),this._setElementAttributes(fd,\"marginRight\",e=>e-t)}_disableOverFlow(){this._saveInitialAttribute(this._element,\"overflow\"),this._element.style.overflow=\"hidden\"}_setElementAttributes(t,e,i){const n=this.getWidth(),o=r=>{if(r!==this._element&&window.innerWidth>r.clientWidth+n)return;this._saveInitialAttribute(r,e);const a=window.getComputedStyle(r)[e];r.style[e]=`${i(Number.parseFloat(a))}px`};this._applyManipulationCallback(t,o)}reset(){this._resetElementAttributes(this._element,\"overflow\"),this._resetElementAttributes(this._element,\"paddingRight\"),this._resetElementAttributes(pd,\"paddingRight\"),this._resetElementAttributes(fd,\"marginRight\")}_saveInitialAttribute(t,e){const i=t.style[e];i&&g.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){const i=n=>{const o=g.getDataAttribute(n,e);typeof o>\"u\"?n.style.removeProperty(e):(g.removeDataAttribute(n,e),n.style[e]=o)};this._applyManipulationCallback(t,i)}_applyManipulationCallback(t,e){Wi(t)?e(t):m.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const Gb={isVisible:!0,isAnimated:!1,rootElement:\"body\",clickCallback:null,backdropClasses:null},qb={isVisible:\"boolean\",isAnimated:\"boolean\",rootElement:\"(element|string)\",clickCallback:\"(function|null)\",backdropClasses:\"(array|string|null)\"},_d=\"backdrop\",gd=`mousedown.te.${_d}`;class Qa{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){if(!this._config.isVisible){hi(t);return}this._append(),this._config.isAnimated&&zi(this._getElement());const e=this._config.backdropClasses||[\"opacity-50\",\"transition-all\",\"duration-300\",\"ease-in-out\",\"fixed\",\"top-0\",\"left-0\",\"z-[1040]\",\"bg-black\",\"w-screen\",\"h-screen\"];g.removeClass(this._getElement(),\"opacity-0\"),g.addClass(this._getElement(),e),this._element.setAttribute(\"data-te-backdrop-show\",\"\"),this._emulateAnimation(()=>{hi(t)})}hide(t){if(!this._config.isVisible){hi(t);return}this._element.removeAttribute(\"data-te-backdrop-show\"),this._getElement().classList.add(\"opacity-0\"),this._getElement().classList.remove(\"opacity-50\"),this._emulateAnimation(()=>{this.dispose(),hi(t)})}_getElement(){if(!this._element){const t=document.createElement(\"div\");t.className=this._config.className,this._config.isAnimated&&t.classList.add(\"opacity-50\"),this._element=t}return this._element}_getConfig(t){return t={...Gb,...typeof t==\"object\"?t:{}},t.rootElement=Be(t.rootElement),L(_d,t,qb),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),_.on(this._getElement(),gd,()=>{hi(this._config.clickCallback)}),this._isAppended=!0)}dispose(){this._isAppended&&(_.off(this._element,gd),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){fh(t,this._getElement(),this._config.isAnimated)}}class Vs{constructor(t,e={},i){this._element=t,this._toggler=i,this._event=e.event||\"blur\",this._condition=e.condition||(()=>!0),this._selector=e.selector||'button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',this._onlyVisible=e.onlyVisible||!1,this._focusableElements=[],this._firstElement=null,this._lastElement=null,this.handler=n=>{this._condition(n)&&!n.shiftKey&&n.target===this._lastElement?(n.preventDefault(),this._firstElement.focus()):this._condition(n)&&n.shiftKey&&n.target===this._firstElement&&(n.preventDefault(),this._lastElement.focus())}}trap(){this._setElements(),this._init(),this._setFocusTrap()}disable(){this._focusableElements.forEach(t=>{t.removeEventListener(this._event,this.handler)}),this._toggler&&this._toggler.focus()}update(){this._setElements(),this._setFocusTrap()}_init(){const t=e=>{!this._firstElement||e.key!==\"Tab\"||this._focusableElements.includes(e.target)||(e.preventDefault(),this._firstElement.focus(),window.removeEventListener(\"keydown\",t))};window.addEventListener(\"keydown\",t)}_filterVisible(t){return t.filter(e=>{if(!ae(e))return!1;const i=m.parents(e,\"*\");for(let n=0;n{e===this._focusableElements.length-1||e===0?t.addEventListener(this._event,this.handler):t.removeEventListener(this._event,this.handler)})}}let md=[];const yo=(s,t=\"hide\")=>{const e=`click.dismiss${s.EVENT_KEY}`,i=s.NAME;md.includes(i)||(md.push(i),_.on(document,e,`[data-te-${i}-dismiss]`,function(n){if([\"A\",\"AREA\"].includes(this.tagName)&&n.preventDefault(),ci(this))return;const o=Ne(this)||this.closest(`.${i}`)||this.closest(`[data-te-${i}-init]`);if(!o)return;s.getOrCreateInstance(o)[t]()}))},bd=\"offcanvas\",Ji=\".te.offcanvas\",Zb=`load${Ji}.data-api`,Qb=\"Escape\",vd={backdrop:!0,keyboard:!0,scroll:!1},Jb={backdrop:\"boolean\",keyboard:\"boolean\",scroll:\"boolean\"},yd=\"show\",tv=\"[data-te-offcanvas-init][data-te-offcanvas-show]\",ev=`show${Ji}`,iv=`shown${Ji}`,sv=`hide${Ji}`,nv=`hidden${Ji}`,ov=`keydown.dismiss${Ji}`;class ts extends Mt{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners(),this._didInit=!1,this._init()}static get NAME(){return bd}static get Default(){return vd}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){if(this._isShown||_.trigger(this._element,ev,{relatedTarget:t}).defaultPrevented)return;this._isShown=!0,this._element.style.visibility=\"visible\",this._backdrop.show(),this._config.scroll||new Qi().hide(),this._element.removeAttribute(\"aria-hidden\"),this._element.setAttribute(\"aria-modal\",!0),this._element.setAttribute(\"role\",\"dialog\"),this._element.setAttribute(`data-te-offcanvas-${yd}`,\"\");const i=()=>{this._config.scroll||this._focustrap.trap(),_.trigger(this._element,iv,{relatedTarget:t})};this._queueCallback(i,this._element,!0)}hide(){if(!this._isShown||_.trigger(this._element,sv).defaultPrevented)return;this._focustrap.disable(),this._element.blur(),this._isShown=!1,this._element.removeAttribute(`data-te-offcanvas-${yd}`),this._backdrop.hide();const e=()=>{this._element.setAttribute(\"aria-hidden\",!0),this._element.removeAttribute(\"aria-modal\"),this._element.removeAttribute(\"role\"),this._element.style.visibility=\"hidden\",this._config.scroll||new Qi().reset(),_.trigger(this._element,nv)};this._queueCallback(e,this._element,!0)}dispose(){this._backdrop.dispose(),this._focustrap.disable(),super.dispose()}_init(){this._didInit||(_.on(window,Zb,()=>m.find(tv).forEach(t=>ts.getOrCreateInstance(t).show())),this._didInit=!0,yo(ts))}_getConfig(t){return t={...vd,...g.getDataAttributes(this._element),...typeof t==\"object\"?t:{}},L(bd,t,Jb),t}_initializeBackDrop(){return new Qa({isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Vs(this._element,{event:\"keydown\",condition:t=>t.key===\"Tab\"})}_addEventListeners(){_.on(this._element,ov,t=>{this._config.keyboard&&t.key===Qb&&this.hide()})}static jQueryInterface(t){return this.each(function(){const e=ts.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Ja=\"alert\",Td=\".te.alert\",rv=`close${Td}`,av=`closed${Td}`,Fs=\"data-te-alert-show\",lv={animation:\"boolean\",autohide:\"boolean\",autoclose:\"boolean\",delay:\"number\"},Ed={animation:!0,autohide:!0,autoclose:!1,delay:1e3},cv={fadeIn:\"animate-[fade-in_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\",fadeOut:\"animate-[fade-out_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\"},hv={fadeIn:\"string\",fadeOut:\"string\"};class Ws extends Mt{constructor(t,e,i){super(t),this._element=t,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._didInit=!1,this._init()}static get DefaultType(){return lv}static get Default(){return Ed}static get NAME(){return Ja}close(){if(_.trigger(this._element,rv).defaultPrevented)return;let e=0;this._config.animation&&(e=300,g.addClass(this._element,this._classes.fadeOut)),this._element.removeAttribute(Fs),setTimeout(()=>{this._queueCallback(()=>this._destroyElement(),this._element,this._config.animation)},e)}show(){if(this._element){if(this._config.autohide&&this._setupAutohide(),(this._config.autoclose||this._config.autoclose&&this._config.autohide)&&this._setupAutoclose(),!this._element.hasAttribute(Fs)&&(g.removeClass(this._element,\"hidden\"),g.addClass(this._element,\"block\"),ae(this._element))){const t=e=>{g.removeClass(this._element,\"hidden\"),g.addClass(this._element,\"block\"),_.off(e.target,\"animationend\",t)};this._element.setAttribute(Fs,\"\"),_.on(this._element,\"animationend\",t)}this._config.animation&&(g.removeClass(this._element,this._classes.fadeOut),g.addClass(this._element,this._classes.fadeIn))}}hide(){if(this._element&&this._element.hasAttribute(Fs)){this._element.removeAttribute(Fs);const t=e=>{g.addClass(this._element,\"hidden\"),g.removeClass(this._element,\"block\"),this._timeout!==null&&(clearTimeout(this._timeout),this._timeout=null),_.off(e.target,\"animationend\",t)};_.on(this._element,\"animationend\",t),g.removeClass(this._element,this._classes.fadeIn),g.addClass(this._element,this._classes.fadeOut)}}_init(){this._didInit||(yo(Ws,\"close\"),this._didInit=!0)}_getConfig(t){return t={...Ed,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(Ja,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cv,...e,...t},L(Ja,t,hv),t}_setupAutohide(){this._timeout=setTimeout(()=>{this.hide()},this._config.delay)}_setupAutoclose(){this._timeout=setTimeout(()=>{this.close()},this._config.delay)}_destroyElement(){this._element.remove(),_.trigger(this._element,av),this.dispose()}static jQueryInterface(t){return this.each(function(){const e=Ws.getOrCreateInstance(this);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const tl=\"carousel\",Nt=\".te.carousel\",xd=\".data-api\",dv=\"ArrowLeft\",uv=\"ArrowRight\",pv=500,fv=40,Cd={interval:5e3,keyboard:!0,ride:!1,pause:\"hover\",wrap:!0,touch:!0},_v={interval:\"(number|boolean)\",keyboard:\"boolean\",ride:\"(boolean|string)\",pause:\"(string|boolean)\",wrap:\"boolean\",touch:\"boolean\"},gv={pointer:\"touch-pan-y\",block:\"!block\",visible:\"data-[te-carousel-fade]:opacity-100 data-[te-carousel-fade]:z-[1]\",invisible:\"data-[te-carousel-fade]:z-0 data-[te-carousel-fade]:opacity-0 data-[te-carousel-fade]:duration-[600ms] data-[te-carousel-fade]:delay-600\",slideRight:\"translate-x-full\",slideLeft:\"-translate-x-full\"},mv={pointer:\"string\",block:\"string\",visible:\"string\",invisible:\"string\",slideRight:\"string\",slideLeft:\"string\"},gi=\"next\",mi=\"prev\",bi=\"left\",zs=\"right\",bv={[dv]:zs,[uv]:bi},vv=`slide${Nt}`,el=`slid${Nt}`,yv=`keydown${Nt}`,Tv=`mouseenter${Nt}`,Ev=`mouseleave${Nt}`,xv=`touchstart${Nt}`,Cv=`touchmove${Nt}`,Av=`touchend${Nt}`,wv=`pointerdown${Nt}`,kv=`pointerup${Nt}`,Sv=`dragstart${Nt}`,Ov=`load${Nt}${xd}`,Iv=`click${Nt}${xd}`,Ad=\"data-te-carousel-init\",vi=\"data-te-carousel-active\",Dv=\"data-te-carousel-item-end\",il=\"data-te-carousel-item-start\",Mv=\"data-te-carousel-item-next\",Lv=\"data-te-carousel-item-prev\",$v=\"data-te-carousel-pointer-event\",Rv=\"[data-te-carousel-init]\",wd=\"[data-te-carousel-active]\",sl=\"[data-te-carousel-item]\",es=`${wd}${sl}`,Pv=`${sl} img`,Nv=\"[data-te-carousel-item-next], [data-te-carousel-item-prev]\",Bv=\"[data-te-carousel-indicators]\",Hv=\"[data-te-target]\",Vv=\"[data-te-slide], [data-te-slide-to]\",Fv=\"touch\",Wv=\"pen\";class he extends Mt{constructor(t,e,i){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._indicatorsElement=m.findOne(Bv,this._element),this._touchSupported=\"ontouchstart\"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=!!window.PointerEvent,this._setActiveElementClass(),this._addEventListeners(),this._didInit=!1,this._init(),this._config.ride===\"carousel\"&&this.cycle()}static get Default(){return Cd}static get NAME(){return tl}next(){this._slide(gi)}nextWhenVisible(){!document.hidden&&ae(this._element)&&this.next()}prev(){this._slide(mi)}pause(t){t||(this._isPaused=!0),m.findOne(Nv,this._element)&&(hh(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=m.findOne(es,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding){_.one(this._element,el,()=>this.to(t));return}if(e===t){this.pause(),this.cycle();return}const i=t>e?gi:mi;this._slide(i,this._items[t])}_init(){this._didInit||(_.on(document,Iv,Vv,he.dataApiClickHandler),_.on(window,Ov,()=>{const t=m.find(Rv);for(let e=0,i=t.length;ethis.cycle());return}this.cycle()}}_applyInitialClasses(){const t=m.findOne(es,this._element);t.classList.add(this._classes.block,...this._classes.visible.split(\" \")),this._setActiveIndicatorElement(t)}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=fv)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?zs:bi)}_setActiveElementClass(){this._activeElement=m.findOne(es,this._element),g.addClass(this._activeElement,\"hidden\")}_addEventListeners(){this._config.keyboard&&_.on(this._element,yv,t=>this._keydown(t)),this._config.pause===\"hover\"&&(_.on(this._element,Tv,t=>this.pause(t)),_.on(this._element,Ev,t=>this._enableCycle(t))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners(),this._applyInitialClasses()}_addTouchEventListeners(){const t=o=>this._pointerEvent&&(o.pointerType===Wv||o.pointerType===Fv),e=o=>{t(o)?this.touchStartX=o.clientX:this._pointerEvent||(this.touchStartX=o.touches[0].clientX)},i=o=>{this.touchDeltaX=o.touches&&o.touches.length>1?0:o.touches[0].clientX-this.touchStartX},n=o=>{t(o)&&(this.touchDeltaX=o.clientX-this.touchStartX),this._handleSwipe(),this._config.pause===\"hover\"&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(r=>this._enableCycle(r),pv+this._config.interval))};m.find(Pv,this._element).forEach(o=>{_.on(o,Sv,r=>r.preventDefault())}),this._pointerEvent?(_.on(this._element,wv,o=>e(o)),_.on(this._element,kv,o=>n(o)),this._element.classList.add(this._classes.pointer),this._element.setAttribute(`${$v}`,\"\")):(_.on(this._element,xv,o=>e(o)),_.on(this._element,Cv,o=>i(o)),_.on(this._element,Av,o=>n(o)))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=bv[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?m.find(sl,t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===gi;return _h(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(m.findOne(es,this._element));return _.trigger(this._element,vv,{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=m.findOne(wd,this._indicatorsElement);e.removeAttribute(vi),e.removeAttribute(\"aria-current\"),e.classList.remove(\"!opacity-100\");const i=m.find(Hv,this._indicatorsElement);for(let n=0;n{_.trigger(this._element,el,{relatedTarget:r,direction:u,from:o,to:a})};if(this._element.hasAttribute(Ad)){r.setAttribute(`${d}`,\"\"),r.classList.add(this._classes.block,f),zi(r),n.setAttribute(`${h}`,\"\"),n.classList.add(p,...this._classes.invisible.split(\" \")),n.classList.remove(...this._classes.visible.split(\" \")),r.setAttribute(`${h}`,\"\"),r.classList.add(...this._classes.visible.split(\" \")),r.classList.remove(this._classes.slideRight,this._classes.slideLeft);const y=()=>{r.removeAttribute(h),r.removeAttribute(d),r.setAttribute(`${vi}`,\"\"),n.removeAttribute(vi),n.classList.remove(p,...this._classes.invisible.split(\" \"),this._classes.block),n.removeAttribute(d),n.removeAttribute(h),this._isSliding=!1,setTimeout(v,0)};this._queueCallback(y,n,!0)}else n.removeAttribute(vi),n.classList.remove(this._classes.block),r.setAttribute(`${vi}`,\"\"),r.classList.add(this._classes.block),this._isSliding=!1,v();l&&this.cycle()}_directionToOrder(t){return[zs,bi].includes(t)?et()?t===bi?mi:gi:t===bi?gi:mi:t}_orderToDirection(t){return[gi,mi].includes(t)?et()?t===mi?bi:zs:t===mi?zs:bi:t}static carouselInterface(t,e){const i=he.getOrCreateInstance(t,e);let{_config:n}=i;typeof e==\"object\"&&(n={...n,...e});const o=typeof e==\"string\"?e:e.slide;if(typeof e==\"number\"){i.to(e);return}if(typeof o==\"string\"){if(typeof i[o]>\"u\")throw new TypeError(`No method named \"${o}\"`);i[o]()}else n.interval&&n.ride===!0&&i.pause()}static jQueryInterface(t){return this.each(function(){he.carouselInterface(this,t)})}static dataApiClickHandler(t){const e=Ne(this);if(!e||!e.hasAttribute(Ad))return;const i={...g.getDataAttributes(e),...g.getDataAttributes(this)},n=this.getAttribute(\"data-te-slide-to\");n&&(i.interval=!1),he.carouselInterface(e,i),n&&he.getInstance(e).to(n),t.preventDefault()}}const nl=\"modal\",te=\".te.modal\",kd=\"Escape\",Sd={backdrop:!0,keyboard:!0,focus:!0,modalNonInvasive:!1},zv={backdrop:\"(boolean|string)\",keyboard:\"boolean\",focus:\"boolean\",modalNonInvasive:\"boolean\"},jv={show:\"transform-none\",static:\"scale-[1.02]\",staticProperties:\"transition-scale duration-300 ease-in-out\",backdrop:\"opacity-50 transition-all duration-300 ease-in-out fixed top-0 left-0 z-[1040] bg-black w-screen h-screen\"},Yv={show:\"string\",static:\"string\",staticProperties:\"string\",backdrop:\"string\"},Kv=`hide${te}`,Uv=`hidePrevented${te}`,Xv=`hidden${te}`,Gv=`show${te}`,qv=`shown${te}`,Od=`resize${te}`,Id=`click.dismiss${te}`,Dd=`keydown.dismiss${te}`,Zv=`mouseup.dismiss${te}`,Md=`mousedown.dismiss${te}`,Ld=\"data-te-modal-open\",$d=\"data-te-open\",js=\"[data-te-modal-dialog-ref]\",Qv=\"[data-te-modal-body-ref]\";class Ys extends Mt{constructor(t,e,i){super(t),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._dialog=m.findOne(js,this._element),this._backdrop=this._config.modalNonInvasive?null:this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new Qi,this._didInit=!1,this._init()}static get Default(){return Sd}static get NAME(){return nl}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||_.trigger(this._element,Gv,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),!this._config.modalNonInvasive&&this._scrollBar.hide(),document.body.setAttribute(Ld,\"true\"),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),_.on(this._dialog,Md,()=>{_.one(this._element,Zv,i=>{i.target===this._element&&(this._ignoreBackdropClick=!0)})}),this._showElement(t),!this._config.modalNonInvasive&&this._showBackdrop())}hide(){if(!this._isShown||this._isTransitioning||_.trigger(this._element,Kv).defaultPrevented)return;this._isShown=!1;const e=this._isAnimated();e&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.disable(),m.findOne(js,this._element).classList.remove(this._classes.show),_.off(this._element,Id),_.off(this._dialog,Md),this._queueCallback(()=>this._hideModal(),this._element,e),this._element.removeAttribute($d)}dispose(){[window,document,this._dialog].forEach(t=>_.off(t,te)),this._backdrop&&this._backdrop.dispose(),this._focustrap.disable(),super.dispose()}handleUpdate(){this._adjustDialog()}_init(){this._didInit||(yo(Ys),this._didInit=!0)}_initializeBackDrop(){return new Qa({isVisible:!!this._config.backdrop,isAnimated:this._isAnimated(),backdropClasses:this._classes.backdrop})}_initializeFocusTrap(){return new Vs(this._element,{event:\"keydown\",condition:t=>t.key===\"Tab\"})}_getConfig(t){return t={...Sd,...g.getDataAttributes(this._element),...typeof t==\"object\"?t:{}},L(nl,t,zv),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...jv,...e,...t},L(nl,t,Yv),t}_showElement(t){const e=this._isAnimated(),i=m.findOne(Qv,this._dialog);(!this._element.parentNode||this._element.parentNode.nodeType!==Node.ELEMENT_NODE)&&document.body.append(this._element),this._element.style.display=\"block\",this._element.classList.remove(\"hidden\"),this._element.removeAttribute(\"aria-hidden\"),this._element.setAttribute(\"aria-modal\",!0),this._element.setAttribute(\"role\",\"dialog\"),this._element.setAttribute(`${$d}`,\"true\"),this._element.scrollTop=0;const n=m.findOne(js,this._element);n.classList.add(this._classes.show),n.classList.remove(\"opacity-0\"),n.classList.add(\"opacity-100\"),i&&(i.scrollTop=0),e&&zi(this._element);const o=()=>{this._config.focus&&this._focustrap.trap(),this._isTransitioning=!1,_.trigger(this._element,qv,{relatedTarget:t})};this._queueCallback(o,this._dialog,e)}_setEscapeEvent(){this._isShown?_.on(document,Dd,t=>{this._config.keyboard&&t.key===kd?(t.preventDefault(),this.hide()):!this._config.keyboard&&t.key===kd&&this._triggerBackdropTransition()}):_.off(this._element,Dd)}_setResizeEvent(){this._isShown?_.on(window,Od,()=>this._adjustDialog()):_.off(window,Od)}_hideModal(){const t=m.findOne(js,this._element);t.classList.remove(this._classes.show),t.classList.remove(\"opacity-100\"),t.classList.add(\"opacity-0\");const e=oo(t);setTimeout(()=>{this._element.style.display=\"none\"},e),this._element.setAttribute(\"aria-hidden\",!0),this._element.removeAttribute(\"aria-modal\"),this._element.removeAttribute(\"role\"),this._isTransitioning=!1,this._backdrop&&this._backdrop.hide(()=>{document.body.removeAttribute(Ld),this._resetAdjustments(),!this._config.modalNonInvasive&&this._scrollBar.reset(),_.trigger(this._element,Xv)})}_showBackdrop(t){_.on(this._element,Id,e=>{if(this._ignoreBackdropClick){this._ignoreBackdropClick=!1;return}e.target===e.currentTarget&&(this._config.backdrop===!0?this.hide():this._config.backdrop===\"static\"&&this._triggerBackdropTransition())}),this._backdrop&&this._backdrop.show(t)}_isAnimated(){return!!m.findOne(js,this._element)}_triggerBackdropTransition(){if(_.trigger(this._element,Uv).defaultPrevented)return;const{classList:e,scrollHeight:i,style:n}=this._element,o=i>document.documentElement.clientHeight;if(!o&&n.overflowY===\"hidden\"||e.contains(this._classes.static))return;o||(n.overflowY=\"hidden\"),e.add(...this._classes.static.split(\" \")),e.add(...this._classes.staticProperties.split(\" \"));const r=oo(this._element);this._queueCallback(()=>{e.remove(this._classes.static),setTimeout(()=>{e.remove(...this._classes.staticProperties.split(\" \"))},r),o||this._queueCallback(()=>{n.overflowY=\"\"},this._dialog)},this._dialog),this._element.focus()}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!et()||i&&!t&&et())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!et()||!i&&t&&et())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft=\"\",this._element.style.paddingRight=\"\"}static jQueryInterface(t,e){return this.each(function(){const i=Ys.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}}const Jv=new Set([\"background\",\"cite\",\"href\",\"itemtype\",\"longdesc\",\"poster\",\"src\",\"xlink:href\"]),Rd=/^aria-[\\w-]*$/i,t0=/^data-te-[\\w-]*$/i,e0=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,i0=/^data:(?:image\\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\\/(?:mpeg|mp4|ogg|webm)|audio\\/(?:mp3|oga|ogg|opus));base64,[\\d+/a-z]+=*$/i,s0=(s,t)=>{const e=s.nodeName.toLowerCase();if(t.includes(e))return Jv.has(e)?!!(e0.test(s.nodeValue)||i0.test(s.nodeValue)):!0;const i=t.filter(n=>n instanceof RegExp);for(let n=0,o=i.length;n{s0(u,d)||l.removeAttribute(u.nodeName)})}return n.body.innerHTML}const Nd=\"tooltip\",de=\".te.tooltip\",o0=\"te-tooltip\",r0=new Set([\"sanitize\",\"allowList\",\"sanitizeFn\"]),a0={animation:\"boolean\",template:\"string\",title:\"(string|element|function)\",trigger:\"string\",delay:\"(number|object)\",html:\"boolean\",selector:\"(string|boolean)\",placement:\"(string|function)\",offset:\"(array|string|function)\",container:\"(string|element|boolean)\",fallbackPlacements:\"array\",boundary:\"(string|element)\",customClass:\"(string|function)\",sanitize:\"boolean\",sanitizeFn:\"(null|function)\",allowList:\"object\",popperConfig:\"(null|object|function)\"},l0={AUTO:\"auto\",TOP:\"top\",RIGHT:et()?\"left\":\"right\",BOTTOM:\"bottom\",LEFT:et()?\"right\":\"left\"},c0={animation:!0,template:'
',trigger:\"hover focus\",title:\"\",delay:0,html:!1,selector:!1,placement:\"top\",offset:[0,0],container:!1,fallbackPlacements:[\"top\",\"right\",\"bottom\",\"left\"],boundary:\"clippingParents\",customClass:\"\",sanitize:!0,sanitizeFn:null,allowList:n0,popperConfig:{hide:!0}},h0={HIDE:`hide${de}`,HIDDEN:`hidden${de}`,SHOW:`show${de}`,SHOWN:`shown${de}`,INSERTED:`inserted${de}`,CLICK:`click${de}`,FOCUSIN:`focusin${de}`,FOCUSOUT:`focusout${de}`,MOUSEENTER:`mouseenter${de}`,MOUSELEAVE:`mouseleave${de}`},d0=\"fade\",u0=\"modal\",ol=\"show\",Ks=\"show\",rl=\"out\",Bd=\".tooltip-inner\",Hd=`.${u0}`,Vd=\"hide.te.modal\",Us=\"hover\",al=\"focus\",p0=\"click\",f0=\"manual\";let is=class rm extends Mt{constructor(t,e){if(typeof sd>\"u\")throw new TypeError(\"Bootstrap's tooltips require Popper (https://popper.js.org)\");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState=\"\",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return c0}static get NAME(){return Nd}static get Event(){return h0}static get DefaultType(){return a0}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(ol)){this._leave(null,this);return}this._enter(null,this)}}dispose(){clearTimeout(this._timeout),_.off(this._element.closest(Hd),Vd,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if(this._element.style.display===\"none\")throw new Error(\"Please use show on visible elements\");if(!(this.isWithContent()&&this._isEnabled))return;const t=_.trigger(this._element,this.constructor.Event.SHOW),e=dh(this._element),i=e===null?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;this.constructor.NAME===\"tooltip\"&&this.tip&&this.getTitle()!==this.tip.querySelector(Bd).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),o=bt(this.constructor.NAME);n.setAttribute(\"id\",o),this._element.setAttribute(\"aria-describedby\",o),this._config.animation&&setTimeout(()=>{this.tip.classList.add(\"opacity-100\"),this.tip.classList.remove(\"opacity-0\")},100);const r=typeof this._config.placement==\"function\"?this._config.placement.call(this,n,this._element):this._config.placement,a=this._getAttachment(r);this._addAttachmentClass(a);const{container:l}=this._config;if(O.setData(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(l.append(n),_.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=Fe(this._element,n,this._getPopperConfig(a)),n.getAttribute(\"id\").includes(\"tooltip\"))switch(r){case\"bottom\":n.classList.add(\"py-[0.4rem]\");break;case\"left\":n.classList.add(\"px-[0.4rem]\");break;case\"right\":n.classList.add(\"px-[0.4rem]\");break;default:n.classList.add(\"py-[0.4rem]\");break}const h=this._resolvePossibleFunction(this._config.customClass);h&&n.classList.add(...h.split(\" \")),\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(p=>{_.on(p,\"mouseover\",ro)});const d=()=>{const p=this._hoverState;this._hoverState=null,_.trigger(this._element,this.constructor.Event.SHOWN),p===rl&&this._leave(null,this)},u=this.tip.classList.contains(\"transition-opacity\");this._queueCallback(d,this.tip,u)}hide(){if(!this._popper)return;const t=this.getTipElement(),e=()=>{this._isWithActiveTrigger()||(this._hoverState!==Ks&&t.remove(),this._cleanTipClass(),this._element.removeAttribute(\"aria-describedby\"),_.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())};if(_.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.add(\"opacity-0\"),t.classList.remove(\"opacity-100\"),\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(o=>_.off(o,\"mouseover\",ro)),this._activeTrigger[p0]=!1,this._activeTrigger[al]=!1,this._activeTrigger[Us]=!1;const n=this.tip.classList.contains(\"opacity-0\");this._queueCallback(e,this.tip,n),this._hoverState=\"\"}update(){this._popper!==null&&this._popper.update()}isWithContent(){return!!this.getTitle()}getTipElement(){if(this.tip)return this.tip;const t=document.createElement(\"div\");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(d0,ol),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),Bd)}_sanitizeAndSetContent(t,e,i){const n=m.findOne(i,t);if(!e&&n){n.remove();return}this.setElementContent(n,e)}setElementContent(t,e){if(t!==null){if(Wi(e)){e=Be(e),this._config.html?e.parentNode!==t&&(t.innerHTML=\"\",t.append(e)):t.textContent=e.textContent;return}this._config.html?(this._config.sanitize&&(e=To(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e}}getTitle(){const t=this._element.getAttribute(\"data-te-original-title\")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return t===\"right\"?\"end\":t===\"left\"?\"start\":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return typeof t==\"string\"?t.split(\",\").map(e=>Number.parseInt(e,10)):typeof t==\"function\"?e=>t(e,this._element):t}_resolvePossibleFunction(t){return typeof t==\"function\"?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:\"flip\",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:\"offset\",options:{offset:this._getOffset()}},{name:\"preventOverflow\",options:{boundary:this._config.boundary}},{name:\"arrow\",options:{element:`.${this.constructor.NAME}-arrow`}},{name:\"onChange\",enabled:!0,phase:\"afterWrite\",fn:i=>this._handlePopperPlacementChange(i)}],onFirstUpdate:i=>{i.options.placement!==i.placement&&this._handlePopperPlacementChange(i)}};return{...e,...typeof this._config.popperConfig==\"function\"?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return l0[t.toUpperCase()]}_setListeners(){this._config.trigger.split(\" \").forEach(e=>{if(e===\"click\")_.on(this._element,this.constructor.Event.CLICK,this._config.selector,i=>this.toggle(i));else if(e!==f0){const i=e===Us?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,n=e===Us?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;_.on(this._element,i,this._config.selector,o=>this._enter(o)),_.on(this._element,n,this._config.selector,o=>this._leave(o))}}),this._hideModalHandler=()=>{this._element&&this.hide()},_.on(this._element.closest(Hd),Vd,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:\"manual\",selector:\"\"}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute(\"title\"),e=typeof this._element.getAttribute(\"data-te-original-title\");(t||e!==\"string\")&&(this._element.setAttribute(\"data-te-original-title\",t||\"\"),t&&!this._element.getAttribute(\"aria-label\")&&!this._element.textContent&&this._element.setAttribute(\"aria-label\",t),this._element.setAttribute(\"title\",\"\"))}_enter(t,e){if(e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger[t.type===\"focusin\"?al:Us]=!0),e.getTipElement().classList.contains(ol)||e._hoverState===Ks){e._hoverState=Ks;return}if(clearTimeout(e._timeout),e._hoverState=Ks,!e._config.delay||!e._config.delay.show){e.show();return}e._timeout=setTimeout(()=>{e._hoverState===Ks&&e.show()},e._config.delay.show)}_leave(t,e){if(e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger[t.type===\"focusout\"?al:Us]=e._element.contains(t.relatedTarget)),!e._isWithActiveTrigger()){if(clearTimeout(e._timeout),e._hoverState=rl,!e._config.delay||!e._config.delay.hide){e.hide();return}e._timeout=setTimeout(()=>{e._hoverState===rl&&e.hide()},e._config.delay.hide)}}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=g.getDataAttributes(this._element);return Object.keys(e).forEach(i=>{r0.has(i)&&delete e[i]}),t={...this.constructor.Default,...e,...typeof t==\"object\"&&t?t:{}},t.container=t.container===!1?document.body:Be(t.container),typeof t.delay==\"number\"&&(t.delay={show:t.delay,hide:t.delay}),typeof t.title==\"number\"&&(t.title=t.title.toString()),typeof t.content==\"number\"&&(t.content=t.content.toString()),L(Nd,t,this.constructor.DefaultType),t.sanitize&&(t.template=To(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\\\s)${this._getBasicClassPrefix()}\\\\S+`,\"g\"),i=t.getAttribute(\"class\").match(e);i!==null&&i.length>0&&i.map(n=>n.trim()).forEach(n=>t.classList.remove(n))}_getBasicClassPrefix(){return o0}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each(function(){const e=rm.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}};const _0=\"popover\",ue=\".te.popover\",g0=\"te-popover\",m0={...is.Default,placement:\"right\",offset:[0,8],trigger:\"click\",content:\"\",template:'

'},b0={...is.DefaultType,content:\"(string|element|function)\"},v0={HIDE:`hide${ue}`,HIDDEN:`hidden${ue}`,SHOW:`show${ue}`,SHOWN:`shown${ue}`,INSERTED:`inserted${ue}`,CLICK:`click${ue}`,FOCUSIN:`focusin${ue}`,FOCUSOUT:`focusout${ue}`,MOUSEENTER:`mouseenter${ue}`,MOUSELEAVE:`mouseleave${ue}`},y0=\".popover-header\",T0=\".popover-body\";class Eo extends is{static get Default(){return m0}static get NAME(){return _0}static get Event(){return v0}static get DefaultType(){return b0}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),y0),this._sanitizeAndSetContent(t,this._getContent(),T0)}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return g0}static jQueryInterface(t){return this.each(function(){const e=Eo.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const ll=\"scrollspy\",cl=\".te.scrollspy\",Fd={offset:10,method:\"auto\",target:\"\"},E0={offset:\"number\",method:\"string\",target:\"(string|element)\"},x0={active:\"!text-primary dark:!text-primary-400 font-semibold border-l-[0.125rem] border-solid border-primary dark:border-primary-400\"},C0={active:\"string\"},A0=`activate${cl}`,w0=`scroll${cl}`,hl=\"data-te-nav-link-active\",Wd=\"[data-te-dropdown-item-ref]\",k0=\"[data-te-nav-list-ref]\",dl=\"[data-te-nav-link-ref]\",S0=\"[data-te-nav-item-ref]\",zd=\"[data-te-list-group-item-ref]\",ul=`${dl}, ${zd}, ${Wd}`,O0=\"[data-te-dropdown-ref]\",I0=\"[data-te-dropdown-toggle-ref]\",D0=\"maxOffset\",jd=\"position\";class xo extends Mt{constructor(t,e,i){super(t),this._scrollElement=this._element.tagName===\"BODY\"?window:this._element,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,_.on(this._scrollElement,w0,()=>this._process()),this.refresh(),this._process()}static get Default(){return Fd}static get NAME(){return ll}refresh(){const t=this._scrollElement===this._scrollElement.window?D0:jd,e=this._config.method===\"auto\"?t:this._config.method,i=e===jd?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),m.find(ul,this._config.target).map(o=>{const r=Ca(o),a=r?m.findOne(r):null;if(a){const l=a.getBoundingClientRect();if(l.width||l.height)return[g[e](a).top+i,r]}return null}).filter(o=>o).sort((o,r)=>o[0]-r[0]).forEach(o=>{this._offsets.push(o[0]),this._targets.push(o[1])})}dispose(){_.off(this._scrollElement,cl),super.dispose()}_getConfig(t){return t={...Fd,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},t.target=Be(t.target)||document.documentElement,L(ll,t,E0),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...x0,...e,...t},L(ll,t,C0),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const n=this._targets[this._targets.length-1];this._activeTarget!==n&&this._activate(n);return}if(this._activeTarget&&t0){this._activeTarget=null,this._clear();return}for(let n=this._offsets.length;n--;)this._activeTarget!==this._targets[n]&&t>=this._offsets[n]&&(typeof this._offsets[n+1]>\"u\"||t`${n}[data-te-target=\"${t}\"],${n}[href=\"${t}\"]`),i=m.findOne(e.join(\",\"),this._config.target);i.classList.add(...this._classes.active.split(\" \")),i.setAttribute(hl,\"\"),i.getAttribute(Wd)?m.findOne(I0,i.closest(O0)).classList.add(...this._classes.active.split(\" \")):m.parents(i,k0).forEach(n=>{m.prev(n,`${dl}, ${zd}`).forEach(o=>{o.classList.add(...this._classes.active.split(\" \")),o.setAttribute(hl,\"\")}),m.prev(n,S0).forEach(o=>{m.children(o,dl).forEach(r=>r.classList.add(...this._classes.active.split(\" \")))})}),_.trigger(this._scrollElement,A0,{relatedTarget:t})}_clear(){m.find(ul,this._config.target).filter(t=>t.classList.contains(...this._classes.active.split(\" \"))).forEach(t=>{t.classList.remove(...this._classes.active.split(\" \")),t.removeAttribute(hl)})}static jQueryInterface(t){return this.each(function(){const e=xo.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const Yd=\"tab\",Co=\".te.tab\",M0=`hide${Co}`,L0=`hidden${Co}`,$0=`show${Co}`,R0=`shown${Co}`,P0=\"data-te-dropdown-menu-ref\",ss=\"data-te-tab-active\",Ao=\"data-te-nav-active\",N0=\"[data-te-dropdown-ref]\",B0=\"[data-te-nav-ref]\",Kd=`[${ss}]`,H0=`[${Ao}]`,Ud=\":scope > li > .active\",V0=\"[data-te-dropdown-toggle-ref]\",F0=\":scope > [data-te-dropdown-menu-ref] [data-te-dropdown-show]\",W0={show:\"opacity-100\",hide:\"opacity-0\"},z0={show:\"string\",hide:\"string\"};class wo extends Mt{constructor(t,e){super(t),this._classes=this._getClasses(e)}static get NAME(){return Yd}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.getAttribute(Ao)===\"\")return;let t;const e=Ne(this._element),i=this._element.closest(B0),n=m.findOne(H0,i);if(i){const l=i.nodeName===\"UL\"||i.nodeName===\"OL\"?Ud:Kd;t=m.find(l,i),t=t[t.length-1]}const o=t?_.trigger(t,M0,{relatedTarget:this._element}):null;if(_.trigger(this._element,$0,{relatedTarget:t}).defaultPrevented||o!==null&&o.defaultPrevented)return;this._activate(this._element,i,null,n,this._element);const a=()=>{_.trigger(t,L0,{relatedTarget:this._element}),_.trigger(this._element,R0,{relatedTarget:t})};e?this._activate(e,e.parentNode,a,n,this._element):a()}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...W0,...e,...t},L(Yd,t,z0),t}_activate(t,e,i,n,o){const a=(e&&(e.nodeName===\"UL\"||e.nodeName===\"OL\")?m.find(Ud,e):m.children(e,Kd))[0],l=i&&a&&a.hasAttribute(ss),c=()=>this._transitionComplete(t,a,i,n,o);a&&l?(g.removeClass(a,this._classes.show),g.addClass(a,this._classes.hide),this._queueCallback(c,t,!0)):c()}_transitionComplete(t,e,i,n,o){if(e&&n){e.removeAttribute(ss),n.removeAttribute(Ao);const a=m.findOne(F0,e.parentNode);a&&a.removeAttribute(ss),e.getAttribute(\"role\")===\"tab\"&&e.setAttribute(\"aria-selected\",!1)}t.setAttribute(ss,\"\"),o.setAttribute(Ao,\"\"),t.getAttribute(\"role\")===\"tab\"&&t.setAttribute(\"aria-selected\",!0),zi(t),t.classList.contains(this._classes.hide)&&(g.removeClass(t,this._classes.hide),g.addClass(t,this._classes.show));let r=t.parentNode;if(r&&r.nodeName===\"LI\"&&(r=r.parentNode),r&&r.hasAttribute(P0)){const a=t.closest(N0);a&&m.find(V0,a).forEach(l=>l.setAttribute(ss,\"\")),t.setAttribute(\"aria-expanded\",!0)}i&&i()}static jQueryInterface(t){return this.each(function(){const e=wo.getOrCreateInstance(this);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const pl=\"toast\",ze=\".te.toast\",j0=`mouseover${ze}`,Y0=`mouseout${ze}`,K0=`focusin${ze}`,U0=`focusout${ze}`,X0=`hide${ze}`,G0=`hidden${ze}`,q0=`show${ze}`,Z0=`shown${ze}`,Xd=\"data-te-toast-hide\",fl=\"data-te-toast-show\",ko=\"data-te-toast-showing\",Q0={animation:\"boolean\",autohide:\"boolean\",delay:\"number\"},Gd={animation:!0,autohide:!0,delay:5e3},J0={fadeIn:\"animate-[fade-in_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\",fadeOut:\"animate-[fade-out_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\"},ty={fadeIn:\"string\",fadeOut:\"string\"};class Xs extends Mt{constructor(t,e,i){super(t),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners(),this._didInit=!1,this._init()}static get DefaultType(){return Q0}static get Default(){return Gd}static get NAME(){return pl}show(){if(_.trigger(this._element,q0).defaultPrevented)return;this._clearTimeout(),this._config.animation&&(g.removeClass(this._element,this._classes.fadeOut),g.addClass(this._element,this._classes.fadeIn));const e=()=>{this._element.removeAttribute(ko),_.trigger(this._element,Z0),this._maybeScheduleHide()};this._element.removeAttribute(Xd),zi(this._element),this._element.setAttribute(fl,\"\"),this._element.setAttribute(ko,\"\"),this._queueCallback(e,this._element,this._config.animation)}hide(){if(!this._element||this._element.dataset.teToastShow===void 0||_.trigger(this._element,X0).defaultPrevented)return;const e=()=>{let i=0;this._config.animation&&(i=300,g.removeClass(this._element,this._classes.fadeIn),g.addClass(this._element,this._classes.fadeOut)),setTimeout(()=>{this._element.setAttribute(Xd,\"\"),this._element.removeAttribute(ko),this._element.removeAttribute(fl),_.trigger(this._element,G0)},i)};this._element.setAttribute(ko,\"\"),this._queueCallback(e,this._element,this._config.animation)}dispose(){this._clearTimeout(),this._element.dataset.teToastShow!==void 0&&this._element.removeAttribute(fl),super.dispose()}_init(){this._didInit||(yo(Xs),this._didInit=!0)}_getConfig(t){return t={...Gd,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(pl,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...J0,...e,...t},L(pl,t,ty),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case\"mouseover\":case\"mouseout\":this._hasMouseInteraction=e;break;case\"focusin\":case\"focusout\":this._hasKeyboardInteraction=e;break}if(e){this._clearTimeout();return}const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){_.on(this._element,j0,t=>this._onInteraction(t,!0)),_.on(this._element,Y0,t=>this._onInteraction(t,!1)),_.on(this._element,K0,t=>this._onInteraction(t,!0)),_.on(this._element,U0,t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each(function(){const e=Xs.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}(()=>{var s={454:(i,n,o)=>{o.d(n,{Z:()=>l});var r=o(645),a=o.n(r)()(function(c){return c[1]});a.push([i.id,\"INPUT:-webkit-autofill,SELECT:-webkit-autofill,TEXTAREA:-webkit-autofill{animation-name:onautofillstart}INPUT:not(:-webkit-autofill),SELECT:not(:-webkit-autofill),TEXTAREA:not(:-webkit-autofill){animation-name:onautofillcancel}@keyframes onautofillstart{}@keyframes onautofillcancel{}\",\"\"]);const l=a},645:i=>{i.exports=function(n){var o=[];return o.toString=function(){return this.map(function(r){var a=n(r);return r[2]?\"@media \".concat(r[2],\" {\").concat(a,\"}\"):a}).join(\"\")},o.i=function(r,a,l){typeof r==\"string\"&&(r=[[null,r,\"\"]]);var c={};if(l)for(var h=0;h{(function(){if(typeof window<\"u\")try{var i=new window.CustomEvent(\"test\",{cancelable:!0});if(i.preventDefault(),i.defaultPrevented!==!0)throw new Error(\"Could not prevent default\")}catch{var n=function(r,a){var l,c;return(a=a||{}).bubbles=!!a.bubbles,a.cancelable=!!a.cancelable,(l=document.createEvent(\"CustomEvent\")).initCustomEvent(r,a.bubbles,a.cancelable,a.detail),c=l.preventDefault,l.preventDefault=function(){c.call(this);try{Object.defineProperty(this,\"defaultPrevented\",{get:function(){return!0}})}catch{this.defaultPrevented=!0}},l};n.prototype=window.Event.prototype,window.CustomEvent=n}})()},379:(i,n,o)=>{var r,a=function(){var x={};return function(E){if(x[E]===void 0){var C=document.querySelector(E);if(window.HTMLIFrameElement&&C instanceof window.HTMLIFrameElement)try{C=C.contentDocument.head}catch{C=null}x[E]=C}return x[E]}}(),l=[];function c(x){for(var E=-1,C=0;C{var n=i&&i.__esModule?()=>i.default:()=>i;return e.d(n,{a:n}),n},e.d=(i,n)=>{for(var o in n)e.o(n,o)&&!e.o(i,o)&&Object.defineProperty(i,o,{enumerable:!0,get:n[o]})},e.o=(i,n)=>Object.prototype.hasOwnProperty.call(i,n),(()=>{var i=e(379),n=e.n(i),o=e(454);function r(l){if(!l.hasAttribute(\"autocompleted\")){l.setAttribute(\"autocompleted\",\"\");var c=new window.CustomEvent(\"onautocomplete\",{bubbles:!0,cancelable:!0,detail:null});l.dispatchEvent(c)||(l.value=\"\")}}function a(l){l.hasAttribute(\"autocompleted\")&&(l.removeAttribute(\"autocompleted\"),l.dispatchEvent(new window.CustomEvent(\"onautocomplete\",{bubbles:!0,cancelable:!1,detail:null})))}n()(o.Z,{insert:\"head\",singleton:!1}),o.Z.locals,e(810),document.addEventListener(\"animationstart\",function(l){l.animationName===\"onautofillstart\"?r(l.target):a(l.target)},!0),document.addEventListener(\"input\",function(l){l.inputType!==\"insertReplacementText\"&&\"data\"in l?a(l.target):r(l.target)},!0)})()})();const _l=\"input\",So=\"te.input\",qd=\"data-te-input-wrapper-init\",Zd=\"data-te-input-notch-ref\",Qd=\"data-te-input-notch-leading-ref\",Jd=\"data-te-input-notch-middle-ref\",ey=\"data-te-input-notch-trailing-ref\",iy=\"data-te-input-helper-ref\",sy=\"data-te-input-placeholder-active\",je=\"data-te-input-state-active\",tu=\"data-te-input-focused\",eu=\"data-te-input-form-counter\",Oo=`[${qd}] input`,Io=`[${qd}] textarea`,ns=`[${Zd}]`,iu=`[${Qd}]`,su=`[${Jd}]`,ny=`[${iy}]`,oy={inputFormWhite:!1},ry={inputFormWhite:\"(boolean)\"},nu={notch:\"group flex absolute left-0 top-0 w-full max-w-full h-full text-left pointer-events-none\",notchLeading:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none left-0 top-0 h-full w-2 border-r-0 rounded-l-[0.25rem] group-data-[te-input-focused]:border-r-0 group-data-[te-input-state-active]:border-r-0\",notchLeadingNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[-1px_0_0_#3b71ca,_0_1px_0_0_#3b71ca,_0_-1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchLeadingWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[-1px_0_0_#ffffff,_0_1px_0_0_#ffffff,_0_-1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",notchMiddle:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none grow-0 shrink-0 basis-auto w-auto max-w-[calc(100%-1rem)] h-full border-r-0 border-l-0 group-data-[te-input-focused]:border-x-0 group-data-[te-input-state-active]:border-x-0 group-data-[te-input-focused]:border-t group-data-[te-input-state-active]:border-t group-data-[te-input-focused]:border-solid group-data-[te-input-state-active]:border-solid group-data-[te-input-focused]:border-t-transparent group-data-[te-input-state-active]:border-t-transparent\",notchMiddleNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[0_1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchMiddleWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[0_1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",notchTrailing:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none grow h-full border-l-0 rounded-r-[0.25rem] group-data-[te-input-focused]:border-l-0 group-data-[te-input-state-active]:border-l-0\",notchTrailingNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[1px_0_0_#3b71ca,_0_-1px_0_0_#3b71ca,_0_1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchTrailingWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[1px_0_0_#ffffff,_0_-1px_0_0_#ffffff,_0_1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",counter:\"text-right leading-[1.6]\"},ay={notch:\"string\",notchLeading:\"string\",notchLeadingNormal:\"string\",notchLeadingWhite:\"string\",notchMiddle:\"string\",notchMiddleNormal:\"string\",notchMiddleWhite:\"string\",notchTrailing:\"string\",notchTrailingNormal:\"string\",notchTrailingWhite:\"string\",counter:\"string\"};class Z{constructor(t,e,i){this._config=this._getConfig(e,t),this._element=t,this._classes=this._getClasses(i),this._label=null,this._labelWidth=0,this._labelMarginLeft=0,this._notchLeading=null,this._notchMiddle=null,this._notchTrailing=null,this._initiated=!1,this._helper=null,this._counter=!1,this._counterElement=null,this._maxLength=0,this._leadingIcon=null,this._element&&(O.setData(t,So,this),this.init())}static get NAME(){return _l}get input(){return m.findOne(\"input\",this._element)||m.findOne(\"textarea\",this._element)}init(){this._initiated||(this._getLabelData(),this._applyDivs(),this._applyNotch(),this._activate(),this._getHelper(),this._getCounter(),this._getEvents(),this._initiated=!0)}update(){this._getLabelData(),this._getNotchData(),this._applyNotch(),this._activate(),this._getHelper(),this._getCounter()}forceActive(){this.input.setAttribute(je,\"\"),m.findOne(ns,this.input.parentNode).setAttribute(je,\"\")}forceInactive(){this.input.removeAttribute(je),m.findOne(ns,this.input.parentNode).removeAttribute(je)}dispose(){this._removeBorder(),O.removeData(this._element,So),this._element=null}_getConfig(t,e){return t={...oy,...g.getDataAttributes(e),...typeof t==\"object\"?t:{}},L(_l,t,ry),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...nu,...e,...t},L(_l,t,ay),t}_getLabelData(){this._label=m.findOne(\"label\",this._element),this._label===null?this._showPlaceholder():(this._getLabelWidth(),this._getLabelPositionInInputGroup(),this._toggleDefaultDatePlaceholder())}_getHelper(){this._helper=m.findOne(ny,this._element)}_getCounter(){this._counter=g.getDataAttribute(this.input,\"inputShowcounter\"),this._counter&&(this._maxLength=this.input.maxLength,this._showCounter())}_getEvents(){_.on(this._element,\"focus\",\"input\",Z.activate(new Z)),_.on(this._element,\"input\",\"input\",Z.activate(new Z)),_.on(this._element,\"blur\",\"input\",Z.deactivate(new Z)),_.on(this._element,\"focus\",\"textarea\",Z.activate(new Z)),_.on(this._element,\"input\",\"textarea\",Z.activate(new Z)),_.on(this._element,\"blur\",\"textarea\",Z.deactivate(new Z)),_.on(window,\"shown.te.modal\",t=>{m.find(Oo,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.update()}),m.find(Io,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.update()})}),_.on(window,\"shown.te.dropdown\",t=>{const e=t.target.parentNode.querySelector(\"[data-te-dropdown-menu-ref]\");e&&(m.find(Oo,e).forEach(i=>{const n=Z.getInstance(i.parentNode);n&&n.update()}),m.find(Io,e).forEach(i=>{const n=Z.getInstance(i.parentNode);n&&n.update()}))}),_.on(window,\"shown.te.tab\",t=>{let e;t.target.href?e=t.target.href.split(\"#\")[1]:e=g.getDataAttribute(t.target,\"target\").split(\"#\")[1];const i=m.findOne(`#${e}`);m.find(Oo,i).forEach(n=>{const o=Z.getInstance(n.parentNode);o&&o.update()}),m.find(Io,i).forEach(n=>{const o=Z.getInstance(n.parentNode);o&&o.update()})}),_.on(window,\"reset\",t=>{m.find(Oo,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.forceInactive()}),m.find(Io,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.forceInactive()})}),_.on(window,\"onautocomplete\",t=>{const e=Z.getInstance(t.target.parentNode);!e||!t.cancelable||e.forceActive()})}_showCounter(){if(m.find(`[${eu}]`,this._element).length>0)return;this._counterElement=document.createElement(\"div\"),g.addClass(this._counterElement,this._classes.counter),this._counterElement.setAttribute(eu,\"\");const e=this.input.value.length;this._counterElement.innerHTML=`${e} / ${this._maxLength}`,this._helper.appendChild(this._counterElement),this._bindCounter()}_bindCounter(){_.on(this.input,\"input\",()=>{const t=this.input.value.length;this._counterElement.innerHTML=`${t} / ${this._maxLength}`})}_toggleDefaultDatePlaceholder(t=this.input){if(!(t.getAttribute(\"type\")===\"date\"))return;!(document.activeElement===t)&&!t.value?t.style.opacity=0:t.style.opacity=1}_showPlaceholder(){this.input.setAttribute(sy,\"\")}_getNotchData(){this._notchMiddle=m.findOne(su,this._element),this._notchLeading=m.findOne(iu,this._element)}_getLabelWidth(){this._labelWidth=this._label.clientWidth*.8+8}_getLabelPositionInInputGroup(){if(this._labelMarginLeft=0,!this._element.hasAttribute(\"data-te-input-group-ref\"))return;const t=this.input,e=m.prev(t,\"[data-te-input-group-text-ref]\")[0];e===void 0?this._labelMarginLeft=0:this._labelMarginLeft=e.offsetWidth-1}_applyDivs(){const t=this._config.inputFormWhite?this._classes.notchLeadingWhite:this._classes.notchLeadingNormal,e=this._config.inputFormWhite?this._classes.notchMiddleWhite:this._classes.notchMiddleNormal,i=this._config.inputFormWhite?this._classes.notchTrailingWhite:this._classes.notchTrailingNormal,n=m.find(ns,this._element),o=$(\"div\");g.addClass(o,this._classes.notch),o.setAttribute(Zd,\"\"),this._notchLeading=$(\"div\"),g.addClass(this._notchLeading,`${this._classes.notchLeading} ${t}`),this._notchLeading.setAttribute(Qd,\"\"),this._notchMiddle=$(\"div\"),g.addClass(this._notchMiddle,`${this._classes.notchMiddle} ${e}`),this._notchMiddle.setAttribute(Jd,\"\"),this._notchTrailing=$(\"div\"),g.addClass(this._notchTrailing,`${this._classes.notchTrailing} ${i}`),this._notchTrailing.setAttribute(ey,\"\"),!(n.length>=1)&&(o.append(this._notchLeading),o.append(this._notchMiddle),o.append(this._notchTrailing),this._element.append(o))}_applyNotch(){this._notchMiddle.style.width=`${this._labelWidth}px`,this._notchLeading.style.width=`${this._labelMarginLeft+9}px`,this._label!==null&&(this._label.style.marginLeft=`${this._labelMarginLeft}px`)}_removeBorder(){const t=m.findOne(ns,this._element);t&&t.remove()}_activate(t){ph(()=>{this._getElements(t);const e=t?t.target:this.input,i=m.findOne(ns,this._element);t&&t.type===\"focus\"&&i&&i.setAttribute(tu,\"\"),e.value!==\"\"&&(e.setAttribute(je,\"\"),i&&i.setAttribute(je,\"\")),this._toggleDefaultDatePlaceholder(e)})}_getElements(t){if(t&&(this._element=t.target.parentNode,this._label=m.findOne(\"label\",this._element)),t&&this._label){const e=this._labelWidth;this._getLabelData(),e!==this._labelWidth&&(this._notchMiddle=m.findOne(su,t.target.parentNode),this._notchLeading=m.findOne(iu,t.target.parentNode),this._applyNotch())}}_deactivate(t){const e=t?t.target:this.input,i=m.findOne(ns,e.parentNode);i.removeAttribute(tu),e.value===\"\"&&(e.removeAttribute(je),i.removeAttribute(je)),this._toggleDefaultDatePlaceholder(e)}static activate(t){return function(e){t._activate(e)}}static deactivate(t){return function(e){t._deactivate(e)}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,So);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Z(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,So)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const ou=\"animation\",gl=\"te.animation\",ly={animation:\"string\",animationStart:\"string\",animationShowOnLoad:\"boolean\",onStart:\"(null|function)\",onEnd:\"(null|function)\",onHide:\"(null|function)\",onShow:\"(null|function)\",animationOnScroll:\"(string)\",animationWindowHeight:\"number\",animationOffset:\"(number|string)\",animationDelay:\"(number|string)\",animationReverse:\"boolean\",animationInterval:\"(number|string)\",animationRepeat:\"(number|boolean)\",animationReset:\"boolean\"},cy={animation:\"fade\",animationStart:\"onClick\",animationShowOnLoad:!0,onStart:null,onEnd:null,onHide:null,onShow:null,animationOnScroll:\"once\",animationWindowHeight:0,animationOffset:0,animationDelay:0,animationReverse:!1,animationInterval:0,animationRepeat:!1,animationReset:!1};class Gs{constructor(t,e){this._element=t,this._animateElement=this._getAnimateElement(),this._isFirstScroll=!0,this._repeatAnimateOnScroll=!0,this._options=this._getConfig(e),this._element&&(O.setData(t,gl,this),this._init())}static get NAME(){return ou}init(){this._init()}startAnimation(){this._startAnimation()}stopAnimation(){this._clearAnimationClass()}changeAnimationType(t){this._options.animation=t}dispose(){_.off(this._element,\"mousedown\"),_.off(this._animateElement,\"animationend\"),_.off(window,\"scroll\"),_.off(this._element,\"mouseover\"),O.removeData(this._element,gl),this._element=null,this._animateElement=null,this._isFirstScroll=null,this._repeatAnimateOnScroll=null,this._options=null}_init(){switch(this._options.animationStart){case\"onHover\":this._bindHoverEvents();break;case\"onLoad\":this._startAnimation();break;case\"onScroll\":this._bindScrollEvents();break;case\"onClick\":this._bindClickEvents();break}this._bindTriggerOnEndCallback(),this._options.animationReset&&this._bindResetAnimationAfterFinish()}_getAnimateElement(){const t=g.getDataAttribute(this._element,\"animation-target\");return t?m.find(t)[0]:this._element}_getConfig(t){const e=g.getDataAttributes(this._animateElement);return t={...cy,...e,...t},L(ou,t,ly),t}_animateOnScroll(){const t=g.offset(this._animateElement).top,e=this._animateElement.offsetHeight,i=window.innerHeight,n=t+this._options.animationOffset<=i&&t+this._options.animationOffset+e>=0,o=this._animateElement.style.visibility===\"visible\";switch(!0){case(n&&this._isFirstScroll):this._isFirstScroll=!1,this._startAnimation();break;case(!n&&this._isFirstScroll):this._isFirstScroll=!1,this._hideAnimateElement();break;case(n&&!o&&this._repeatAnimateOnScroll):this._options.animationOnScroll!==\"repeat\"&&(this._repeatAnimateOnScroll=!1),this._callback(this._options.onShow),this._showAnimateElement(),this._startAnimation();break;case(!n&&o&&this._repeatAnimateOnScroll):this._hideAnimateElement(),this._clearAnimationClass(),this._callback(this._options.onHide);break}}_addAnimatedClass(){g.addClass(this._animateElement,`animate-${this._options.animation}`)}_clearAnimationClass(){this._animateElement.classList.remove(`animate-${this._options.animation}`)}_startAnimation(){this._callback(this._options.onStart),this._addAnimatedClass(),this._options.animationRepeat&&!this._options.animationInterval&&this._setAnimationRepeat(),this._options.animationReverse&&this._setAnimationReverse(),this._options.animationDelay&&this._setAnimationDelay(),this._options.animationDuration&&this._setAnimationDuration(),this._options.animationInterval&&this._setAnimationInterval()}_setAnimationReverse(){g.style(this._animateElement,{animationIterationCount:this._options.animationRepeat===!0?\"infinite\":\"2\",animationDirection:\"alternate\"})}_setAnimationDuration(){g.style(this._animateElement,{animationDuration:`${this._options.animationDuration}ms`})}_setAnimationDelay(){g.style(this._animateElement,{animationDelay:`${this._options.animationDelay}ms`})}_setAnimationRepeat(){g.style(this._animateElement,{animationIterationCount:this._options.animationRepeat===!0?\"infinite\":this._options.animationRepeat})}_setAnimationInterval(){_.on(this._animateElement,\"animationend\",()=>{this._clearAnimationClass(),setTimeout(()=>{this._addAnimatedClass()},this._options.animationInterval)})}_hideAnimateElement(){g.style(this._animateElement,{visibility:\"hidden\"})}_showAnimateElement(){g.style(this._animateElement,{visibility:\"visible\"})}_bindResetAnimationAfterFinish(){_.on(this._animateElement,\"animationend\",()=>{this._clearAnimationClass()})}_bindTriggerOnEndCallback(){_.on(this._animateElement,\"animationend\",()=>{this._callback(this._options.onEnd)})}_bindScrollEvents(){this._options.animationShowOnLoad||this._animateOnScroll(),_.on(window,\"scroll\",()=>{this._animateOnScroll()})}_bindClickEvents(){_.on(this._element,\"mousedown\",()=>{this._startAnimation()})}_bindHoverEvents(){_.one(this._element,\"mouseover\",()=>{this._startAnimation()}),_.one(this._animateElement,\"animationend\",()=>{setTimeout(()=>{this._bindHoverEvents()},100)})}_callback(t){t instanceof Function&&t()}static autoInit(t){t._init()}static jQueryInterface(t){new Gs(this[0],t).init()}static getInstance(t){return O.getData(t,gl)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const hy={property:\"color\",defaultValue:null,inherit:!0},os=(s,t)=>{const{property:e,defaultValue:i,inherit:n}={...hy,...t},o=document.createElement(\"div\");o.classList.add(s),document.body.appendChild(o);const a=window.getComputedStyle(o)[e]||i,c=window.getComputedStyle(o.parentElement)[e];return document.body.removeChild(o),!n&&c&&a===c?i:a||i},ml=\"ripple\",Do=\"te.ripple\",dy=\"rgba({{color}}, 0.2) 0, rgba({{color}}, 0.3) 40%, rgba({{color}}, 0.4) 50%, rgba({{color}}, 0.5) 60%, rgba({{color}}, 0) 70%\",uy=[\"[data-te-ripple-init]\"],Mo=[0,0,0],py=[{name:\"primary\",gradientColor:os(\"text-primary\",{defaultValue:\"#3B71CA\",inherit:!1})},{name:\"secondary\",gradientColor:os(\"text-secondary\",{defaultValue:\"#9FA6B2\",inherit:!1})},{name:\"success\",gradientColor:os(\"text-success\",{defaultValue:\"#14A44D\",inherit:!1})},{name:\"danger\",gradientColor:os(\"text-danger\",{defaultValue:\"#DC4C64\",inherit:!1})},{name:\"warning\",gradientColor:os(\"text-warning\",{defaultValue:\"#E4A11B\",inherit:!1})},{name:\"info\",gradientColor:os(\"text-info\",{defaultValue:\"#54B4D3\",inherit:!1})},{name:\"light\",gradientColor:\"#fbfbfb\"},{name:\"dark\",gradientColor:\"#262626\"}],ru=.5,fy={rippleCentered:!1,rippleColor:\"\",rippleColorDark:\"\",rippleDuration:\"500ms\",rippleRadius:0,rippleUnbound:!1},_y={rippleCentered:\"boolean\",rippleColor:\"string\",rippleColorDark:\"string\",rippleDuration:\"string\",rippleRadius:\"number\",rippleUnbound:\"boolean\"},gy={ripple:\"relative overflow-hidden inline-block align-bottom\",rippleWave:\"rounded-[50%] opacity-50 pointer-events-none absolute touch-none scale-0 transition-[transform,_opacity] ease-[cubic-bezier(0,0,0.15,1),_cubic-bezier(0,0,0.15,1)] z-[999]\",unbound:\"overflow-visible\"},my={ripple:\"string\",rippleWave:\"string\",unbound:\"string\"};class Ye{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._element&&(O.setData(t,Do,this),g.addClass(this._element,this._classes.ripple)),this._clickHandler=this._createRipple.bind(this),this._rippleTimer=null,this._isMinWidthSet=!1,this._initialClasses=null,this.init()}static get NAME(){return ml}init(){this._addClickEvent(this._element)}dispose(){O.removeData(this._element,Do),_.off(this._element,\"click\",this._clickHandler),this._element=null,this._options=null}_autoInit(t){uy.forEach(e=>{m.closest(t.target,e)&&(this._element=m.closest(t.target,e))}),this._element.style.minWidth||(g.style(this._element,{\"min-width\":getComputedStyle(this._element).width}),this._isMinWidthSet=!0),this._options=this._getConfig(),this._classes=this._getClasses(),this._initialClasses=[...this._element.classList],g.addClass(this._element,this._classes.ripple),this._createRipple(t)}_addClickEvent(t){_.on(t,\"mousedown\",this._clickHandler)}_createRipple(t){this._element.className.indexOf(this._classes.ripple)<0&&g.addClass(this._element,this._classes.ripple);const{layerX:e,layerY:i}=t,n=t.offsetX||e,o=t.offsetY||i,r=this._element.offsetHeight,a=this._element.offsetWidth,l=this._durationToMsNumber(this._options.rippleDuration),c={offsetX:this._options.rippleCentered?r/2:n,offsetY:this._options.rippleCentered?a/2:o,height:r,width:a},h=this._getDiameter(c),d=this._options.rippleRadius||h/2,u={delay:l*ru,duration:l-l*ru},p={left:this._options.rippleCentered?`${a/2-d}px`:`${n-d}px`,top:this._options.rippleCentered?`${r/2-d}px`:`${o-d}px`,height:`${this._options.rippleRadius*2||h}px`,width:`${this._options.rippleRadius*2||h}px`,transitionDelay:`0s, ${u.delay}ms`,transitionDuration:`${l}ms, ${u.duration}ms`},f=$(\"div\");this._createHTMLRipple({wrapper:this._element,ripple:f,styles:p}),this._removeHTMLRipple({ripple:f,duration:l})}_createHTMLRipple({wrapper:t,ripple:e,styles:i}){Object.keys(i).forEach(n=>e.style[n]=i[n]),g.addClass(e,this._classes.rippleWave),e.setAttribute(\"data-te-ripple-ref\",\"\"),this._addColor(e,t),this._toggleUnbound(t),this._appendRipple(e,t)}_removeHTMLRipple({ripple:t,duration:e}){this._rippleTimer&&(clearTimeout(this._rippleTimer),this._rippleTimer=null),t&&setTimeout(()=>{t.classList.add(\"!opacity-0\")},10),this._rippleTimer=setTimeout(()=>{if(t&&(t.remove(),this._element)){m.find(\"[data-te-ripple-ref]\",this._element).forEach(n=>{n.remove()}),this._isMinWidthSet&&(g.style(this._element,{\"min-width\":\"\"}),this._isMinWidthSet=!1);const i=this._initialClasses?this._addedNewRippleClasses(this._classes.ripple,this._initialClasses):this._classes.ripple.split(\" \");g.removeClass(this._element,i)}},e)}_addedNewRippleClasses(t,e){return t.split(\" \").filter(i=>e.findIndex(n=>i===n)===-1)}_durationToMsNumber(t){return Number(t.replace(\"ms\",\"\").replace(\"s\",\"000\"))}_getConfig(t={}){const e=g.getDataAttributes(this._element);return t={...fy,...e,...t},L(ml,t,_y),t}_getClasses(t={}){const e=g.getDataClassAttributes(this._element);return t={...gy,...e,...t},L(ml,t,my),t}_getDiameter({offsetX:t,offsetY:e,height:i,width:n}){const o=e<=i/2,r=t<=n/2,a=(u,p)=>Math.sqrt(u**2+p**2),l=e===i/2&&t===n/2,c={first:o===!0&&r===!1,second:o===!0&&r===!0,third:o===!1&&r===!0,fourth:o===!1&&r===!1},h={topLeft:a(t,e),topRight:a(n-t,e),bottomLeft:a(t,i-e),bottomRight:a(n-t,i-e)};let d=0;return l||c.fourth?d=h.topLeft:c.third?d=h.topRight:c.second?d=h.bottomRight:c.first&&(d=h.bottomLeft),d*2}_appendRipple(t,e){e.appendChild(t),setTimeout(()=>{g.addClass(t,\"opacity-0 scale-100\")},50)}_toggleUnbound(t){this._options.rippleUnbound===!0?g.addClass(t,this._classes.unbound):g.removeClass(t,this._classes.unbound)}_addColor(t){let e=this._options.rippleColor||\"rgb(0,0,0)\";(localStorage.theme===\"dark\"||!(\"theme\"in localStorage)&&window.matchMedia(\"(prefers-color-scheme: dark)\").matches)&&(e=this._options.rippleColorDark||this._options.rippleColor);const i=py.find(r=>r.name===e.toLowerCase()),n=i?this._colorToRGB(i.gradientColor).join(\",\"):this._colorToRGB(e).join(\",\"),o=dy.split(\"{{color}}\").join(`${n}`);t.style.backgroundImage=`radial-gradient(circle, ${o})`}_colorToRGB(t){function e(o){return o.length<7&&(o=`#${o[1]}${o[1]}${o[2]}${o[2]}${o[3]}${o[3]}`),[parseInt(o.substr(1,2),16),parseInt(o.substr(3,2),16),parseInt(o.substr(5,2),16)]}function i(o){const r=document.body.appendChild(document.createElement(\"fictum\")),a=\"rgb(1, 2, 3)\";return r.style.color=a,r.style.color!==a||(r.style.color=o,r.style.color===a||r.style.color===\"\")?Mo:(o=getComputedStyle(r).color,document.body.removeChild(r),o)}function n(o){return o=o.match(/[.\\d]+/g).map(r=>+Number(r)),o.length=3,o}return t.toLowerCase()===\"transparent\"?Mo:t[0]===\"#\"?e(t):(t.indexOf(\"rgb\")===-1&&(t=i(t)),t.indexOf(\"rgb\")===0?n(t):Mo)}static autoInitial(t){return function(e){t._autoInit(e)}}static jQueryInterface(t){return this.each(function(){return O.getData(this,Do)?null:new Ye(this,t)})}static getInstance(t){return O.getData(t,Do)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}function Tt(s){return s.getDate()}function Lo(s){return s.getDay()}function ot(s){return s.getMonth()}function K(s){return s.getFullYear()}function by(s,t,e){const i=e.startDay,n=i>0?7-i:0,r=new Date(s,t).getDay()+n;return r>=7?r-7:r}function bl(s){return vy(s).getDate()}function vy(s){return ee(s.getFullYear(),s.getMonth()+1,0)}function rs(){return new Date}function kt(s,t){return St(s,t*12)}function St(s,t){const e=ee(s.getFullYear(),s.getMonth()+t,s.getDate()),i=Tt(s),n=Tt(e);return i!==n&&e.setDate(0),e}function as(s,t){return ee(s.getFullYear(),s.getMonth(),s.getDate()+t)}function ee(s,t,e){const i=new Date(s,t,e);return s>=0&&s<100&&i.setFullYear(i.getFullYear()-1900),i}function au(s){const t=s.split(\"-\"),e=t[0],i=t[1],n=t[2];return ee(e,i,n)}function yy(s){return!Number.isNaN(s.getTime())}function ls(s,t){return K(s)-K(t)||ot(s)-ot(t)||Tt(s)-Tt(t)}function yi(s,t){return s.setHours(0,0,0,0),t.setHours(0,0,0,0),s.getTime()===t.getTime()}function $o(s,t){const i=K(s)-Ey();return Ty(i,t)}function Ty(s,t){return(s%t+t)%t}function Ey(s,t,e){let i=0;return e?i=K(e)-s+1:t&&(i=K(t)),i}function Ro(s,t,e,i,n,o){const r=new Date;r.setHours(0,0,0,0);const a=t&&ls(s,t)<=-1,l=e&&ls(s,e)>=1,c=n&&ls(s,r)<=-1,h=o&&ls(s,r)>=1,d=i&&i(s)===!1;return a||l||d||c||h}function lu(s,t,e,i,n,o){const r=new Date,a=i&&K(i),l=i&&ot(i),c=e&&K(e),h=e&&ot(e),d=K(r),u=ot(r),p=l&&a&&(t>a||t===a&&s>l),f=h&&c&&(td||t===d&&s>u);return p||f||b||v}function vl(s,t,e,i,n){const o=t&&K(t),r=e&&K(e),a=K(new Date),l=r&&s>r,c=o&&sa;return l||c||h||d}function xy(s,t,e,i,n,o,r,a){const l=new Date;return l.setHours(0,0,0,0),(s&&o&&ls(o,l)<0||s)&&(o=l),o&&qs(t,o,e,i,n,o,r,a)}function Cy(s,t,e,i,n,o,r,a){const l=new Date;return l.setHours(0,0,0,0),(s&&n&&ls(n,l)<0||s)&&(n=l),n&&qs(t,n,e,i,n,o,r,a)}function qs(s,t,e,i,n,o,r,a){return e===\"days\"?K(s)===K(t)&&ot(s)===ot(t):e===\"months\"?K(s)===K(t):e===\"years\"?K(t)>=a&&K(t)<=r:!1}const Ay=\"data-te-datepicker-modal-container-ref\",wy=\"data-te-datepicker-dropdown-container-ref\",ky=\"data-te-dropdown-backdrop-ref\",Sy=\"data-te-datepicker-date-text-ref\",cu=\"data-te-datepicker-view-ref\",Oy=\"data-te-datepicker-previous-button-ref\",Iy=\"data-te-datepicker-next-button-ref\",Dy=\"data-te-datepicker-ok-button-ref\",My=\"data-te-datepicker-cancel-button-ref\",Ly=\"data-te-datepicker-clear-button-ref\",$y=\"data-te-datepicker-view-change-button-ref\";function Ry(s,t,e,i,n,o,r,a,l,c){const h=ot(s),d=K(s),u=Tt(s),p=Lo(s),f=$(\"div\"),b=`\n ${hu(s,h,d,t,e,i,n,o,r,a,c)}\n `,v=`\n ${Ny(u,p,h,n,c)}\n ${hu(s,h,d,t,e,i,n,o,r,a,c)}\n `;return n.inline?(g.addClass(f,c.datepickerDropdownContainer),f.setAttribute(wy,l),f.innerHTML=b):(g.addClass(f,c.modalContainer),f.setAttribute(Ay,l),f.innerHTML=v),f}function Py(s){const t=$(\"div\");return g.addClass(t,s),t.setAttribute(ky,\"\"),t}function Ny(s,t,e,i,n){return`\n
\n
\n ${i.title}\n
\n
\n ${i.weekdaysShort[t]}, ${i.monthsShort[e]} ${s}\n
\n
\n `}function hu(s,t,e,i,n,o,r,a,l,c,h){let d;return r.inline?d=`\n
\n ${uu(t,e,r,h)}\n
\n ${du(s,e,i,n,o,r,a,l,c,h)}\n
\n
\n `:d=`\n
\n ${uu(t,e,r,h)}\n
\n ${du(s,e,i,n,o,r,a,l,c,h)}\n
\n ${By(r,h)}\n
\n `,d}function du(s,t,e,i,n,o,r,a,l,c){let h;return o.view===\"days\"?h=Po(s,e,o,c):o.view===\"months\"?h=No(t,i,n,o,r,c):h=Bo(s,i,o,a,l,c),h}function uu(s,t,e,i){return`\n
\n \n
\n \n \n
\n
\n `}function pe(s,t){return`\n \n ${s.viewChangeIconTemplate}\n \n `}function By(s,t){const e=``,i=``,n=``;return`\n
\n \n ${s.removeClearBtn?\"\":n}\n ${s.removeCancelBtn?\"\":i}\n ${s.removeOkBtn?\"\":e}\n
\n `}function Po(s,t,e,i){const n=Hy(s,t,e),r=`\n \n ${e.weekdaysNarrow.map((l,c)=>`${l}`).join(\"\")}\n \n `,a=n.map(l=>`\n \n ${l.map(c=>`\n \n \n ${c.dayNumber}\n \n \n `).join(\"\")}\n \n `).join(\"\");return`\n \n \n ${r}\n \n \n ${a}\n \n
\n `}function Hy(s,t,e){const i=[],n=ot(s),o=ot(St(s,-1)),r=ot(St(s,1)),a=K(s),l=by(a,n,e),c=bl(s),h=bl(St(s,-1)),d=7;let u=1,p=!1;for(let f=1;fc&&(u=1,p=!1);const y=ee(a,p?n:r,u);b.push({date:y,currentMonth:p,isSelected:t&&yi(y,t),isToday:yi(y,rs()),dayNumber:Tt(y),disabled:Ro(y,e.min,e.max,e.filter,e.disablePast,e.disableFuture)}),u++}i.push(b)}return i}function No(s,t,e,i,n,o){const r=Vy(i,n),a=ot(rs()),l=K(rs()),c=`\n ${r.map(h=>`\n \n ${h.map(d=>{const u=i.monthsShort.indexOf(d);return`\n \n
${d}
\n \n `}).join(\"\")}\n \n `).join(\"\")}\n `;return`\n \n \n ${c}\n \n
\n `}function Vy(s,t){const e=[];let i=[];for(let n=0;n`\n \n ${c.map(h=>`\n \n
${h}
\n \n `).join(\"\")}\n \n `).join(\"\")}\n `;return`\n \n \n ${l}\n \n
\n `}function Fy(s,t,e){const i=[],n=K(s),o=$o(s,t),r=n-o;let a=[];for(let l=0;l\n \n \n \n \n `}const cs=37,ut=38,hs=39,ht=40,Ti=36,Ei=35,yl=33,Tl=34,Et=13,Ho=32,xi=27,Ci=9,zy=8,jy=46,ie=24,Vo=4,Fo=4,El=\"datepicker\",Wo=\"te.datepicker\",zo=`.${Wo}`,Yy=\".data-api\",Ky=`close${zo}`,Uy=`open${zo}`,Xy=`dateChange${zo}`,jo=`click${zo}${Yy}`,pu=\"data-te-datepicker-modal-container-ref\",fu=\"data-te-datepicker-dropdown-container-ref\",Yo=\"[data-te-datepicker-toggle-ref]\",Gy=`[${pu}]`,qy=`[${fu}]`,Zy=\"[data-te-datepicker-view-change-button-ref]\",Qy=\"[data-te-datepicker-previous-button-ref]\",Jy=\"[data-te-datepicker-next-button-ref]\",tT=\"[data-te-datepicker-ok-button-ref]\",eT=\"[data-te-datepicker-cancel-button-ref]\",iT=\"[data-te-datepicker-clear-button-ref]\",sT=\"[data-te-datepicker-view-ref]\",nT=\"[data-te-datepicker-toggle-button-ref]\",oT=\"[data-te-datepicker-date-text-ref]\",rT=\"[data-te-dropdown-backdrop-ref]\",aT=\"animate-[fade-in_0.3s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",lT=\"animate-[fade-out_0.3s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",cT=\"animate-[fade-in_0.15s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",hT=\"animate-[fade-out_0.15s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",dT=\"flex flex-col fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[328px] h-[512px] bg-white rounded-[0.6rem] shadow-lg z-[1066] xs:max-md:landscape:w-[475px] xs:max-md:landscape:h-[360px] xs:max-md:landscape:flex-row dark:bg-zinc-700\",uT=\"w-full h-full fixed top-0 right-0 left-0 bottom-0 bg-black/40 z-[1065]\",pT=\"relative h-full\",fT=\"xs:max-md:landscape:h-full h-[120px] px-6 bg-primary flex flex-col rounded-t-lg dark:bg-zinc-800\",_T=\"h-8 flex flex-col justify-end\",gT=\"text-[10px] font-normal uppercase tracking-[1.7px] text-white\",mT=\"xs:max-md:landscape:mt-24 h-[72px] flex flex-col justify-end\",bT=\"text-[34px] font-normal text-white\",vT=\"outline-none px-3\",yT=\"px-3 pt-2.5 pb-0 flex justify-between text-black/[64]\",TT=\"flex items-center outline-none p-2.5 text-neutral-500 font-medium text-[0.9rem] rounded-xl shadow-none bg-transparent m-0 border-none hover:bg-neutral-200 focus:bg-neutral-200 dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10\",ET=\"mt-2.5\",xT=\"p-0 w-10 h-10 leading-10 border-none outline-none m-0 text-gray-600 bg-transparent mr-6 hover:bg-neutral-200 hover:rounded-[50%] focus:bg-neutral-200 focus:rounded-[50%] dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:mx-auto\",CT=\"p-0 w-10 h-10 leading-10 border-none outline-none m-0 text-gray-600 bg-transparent hover:bg-neutral-200 hover:rounded-[50%] focus:bg-neutral-200 focus:rounded-[50%] dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:rotate-180 [&>svg]:mx-auto\",AT=\"h-14 flex absolute w-full bottom-0 justify-end items-center px-3\",wT=\"outline-none bg-white text-primary border-none cursor-pointer py-0 px-2.5 uppercase text-[0.8rem] leading-10 font-medium h-10 tracking-[.1rem] rounded-[10px] mb-2.5 hover:bg-neutral-200 focus:bg-neutral-200 dark:bg-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10\",kT=\"mr-auto\",ST=\"w-10 h-10 text-center text-[12px] font-normal dark:text-white\",OT=\"text-center data-[te-datepicker-cell-disabled]:text-neutral-300 data-[te-datepicker-cell-disabled]:cursor-default data-[te-datepicker-cell-disabled]:pointer-events-none data-[te-datepicker-cell-disabled]:hover:cursor-default hover:cursor-pointer group\",IT=\"w-10 h-10 xs:max-md:landscape:w-8 xs:max-md:landscape:h-8\",DT=\"w-[76px] h-[42px]\",MT=\"mx-auto group-[:not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover]:bg-neutral-300 group-[[data-te-datepicker-cell-selected]]:bg-primary group-[[data-te-datepicker-cell-selected]]:text-white group-[:not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused]]:bg-neutral-100 group-[[data-te-datepicker-cell-focused]]:data-[te-datepicker-cell-selected]:bg-primary group-[[data-te-datepicker-cell-current]]:border-solid group-[[data-te-datepicker-cell-current]]:border-black group-[[data-te-datepicker-cell-current]]:border dark:group-[:not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover]:bg-white/10 dark:group-[[data-te-datepicker-cell-current]]:border-white dark:text-white dark:group-[:not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused]]:bg-white/10 dark:group-[[data-te-datepicker-cell-disabled]]:text-neutral-500\",LT=\"w-9 h-9 leading-9 rounded-[50%] text-[13px]\",$T=\"w-[72px] h-10 leading-10 py-[1px] px-0.5 rounded-[999px]\",RT=\"mx-auto w-[304px]\",PT=\"flex items-center justify-content-center [&>svg]:w-5 [&>svg]:h-5 absolute outline-none border-none bg-transparent right-0.5 top-1/2 -translate-x-1/2 -translate-y-1/2 hover:text-primary focus:text-primary dark:hover:text-primary-400 dark:focus:text-primary-400 dark:text-neutral-200\",NT=\"inline-block pointer-events-none ml-[3px] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:fill-neutral-500 dark:[&>svg]:fill-white\",BT=\"w-[328px] h-[380px] bg-white rounded-lg shadow-[0px_2px_15px_-3px_rgba(0,0,0,.07),_0px_10px_20px_-2px_rgba(0,0,0,.04)] z-[1066] dark:bg-zinc-700\",HT={title:\"Select date\",container:\"body\",disablePast:!1,disableFuture:!1,monthsFull:[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"],monthsShort:[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"],weekdaysFull:[\"Sunday\",\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\"],weekdaysShort:[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],weekdaysNarrow:[\"S\",\"M\",\"T\",\"W\",\"T\",\"F\",\"S\"],okBtnText:\"Ok\",clearBtnText:\"Clear\",cancelBtnText:\"Cancel\",okBtnLabel:\"Confirm selection\",clearBtnLabel:\"Clear selection\",cancelBtnLabel:\"Cancel selection\",nextMonthLabel:\"Next month\",prevMonthLabel:\"Previous month\",nextYearLabel:\"Next year\",prevYearLabel:\"Previous year\",changeMonthIconTemplate:`\n \n \n `,nextMultiYearLabel:\"Next 24 years\",prevMultiYearLabel:\"Previous 24 years\",switchToMultiYearViewLabel:\"Choose year and month\",switchToMonthViewLabel:\"Choose date\",switchToDayViewLabel:\"Choose date\",startDate:null,startDay:0,format:\"dd/mm/yyyy\",view:\"days\",viewChangeIconTemplate:`\n \n \n `,min:null,max:null,filter:null,inline:!1,toggleButton:!0,disableToggleButton:!1,disableInput:!1,animations:!0,confirmDateOnSelect:!1,removeOkBtn:!1,removeCancelBtn:!1,removeClearBtn:!1},VT={title:\"string\",container:\"string\",disablePast:\"boolean\",disableFuture:\"boolean\",monthsFull:\"array\",monthsShort:\"array\",weekdaysFull:\"array\",weekdaysShort:\"array\",weekdaysNarrow:\"array\",okBtnText:\"string\",clearBtnText:\"string\",cancelBtnText:\"string\",okBtnLabel:\"string\",clearBtnLabel:\"string\",cancelBtnLabel:\"string\",nextMonthLabel:\"string\",prevMonthLabel:\"string\",nextYearLabel:\"string\",prevYearLabel:\"string\",nextMultiYearLabel:\"string\",prevMultiYearLabel:\"string\",changeMonthIconTemplate:\"string\",switchToMultiYearViewLabel:\"string\",switchToMonthViewLabel:\"string\",switchToDayViewLabel:\"string\",startDate:\"(null|string|date)\",startDay:\"number\",format:\"string\",view:\"string\",viewChangeIconTemplate:\"string\",min:\"(null|string|date)\",max:\"(null|string|date)\",filter:\"(null|function)\",inline:\"boolean\",toggleButton:\"boolean\",disableToggleButton:\"boolean\",disableInput:\"boolean\",animations:\"boolean\",confirmDateOnSelect:\"boolean\",removeOkBtn:\"boolean\",removeCancelBtn:\"boolean\",removeClearBtn:\"boolean\"},FT={fadeIn:aT,fadeOut:lT,fadeInShort:cT,fadeOutShort:hT,modalContainer:dT,datepickerBackdrop:uT,datepickerMain:pT,datepickerHeader:fT,datepickerTitle:_T,datepickerTitleText:gT,datepickerDate:mT,datepickerDateText:bT,datepickerView:vT,datepickerDateControls:yT,datepickerViewChangeButton:TT,datepickerViewChangeIcon:NT,datepickerArrowControls:ET,datepickerPreviousButton:xT,datepickerNextButton:CT,datepickerFooter:AT,datepickerFooterBtn:wT,datepickerClearBtn:kT,datepickerDayHeading:ST,datepickerCell:OT,datepickerCellSmall:IT,datepickerCellLarge:DT,datepickerCellContent:MT,datepickerCellContentSmall:LT,datepickerCellContentLarge:$T,datepickerTable:RT,datepickerToggleButton:PT,datepickerDropdownContainer:BT},WT={fadeIn:\"string\",fadeOut:\"string\",fadeInShort:\"string\",fadeOutShort:\"string\",modalContainer:\"string\",datepickerBackdrop:\"string\",datepickerMain:\"string\",datepickerHeader:\"string\",datepickerTitle:\"string\",datepickerTitleText:\"string\",datepickerDate:\"string\",datepickerDateText:\"string\",datepickerView:\"string\",datepickerDateControls:\"string\",datepickerViewChangeButton:\"string\",datepickerArrowControls:\"string\",datepickerPreviousButton:\"string\",datepickerNextButton:\"string\",datepickerFooter:\"string\",datepickerFooterBtn:\"string\",datepickerClearBtn:\"string\",datepickerDayHeading:\"string\",datepickerCell:\"string\",datepickerCellSmall:\"string\",datepickerCellLarge:\"string\",datepickerCellContent:\"string\",datepickerCellContentSmall:\"string\",datepickerCellContentLarge:\"string\",datepickerTable:\"string\",datepickerToggleButton:\"string\",datepickerDropdownContainer:\"string\"};class xl{constructor(t,e,i){this._element=t,this._input=m.findOne(\"input\",this._element),this._options=this._getConfig(e),this._classes=this._getClasses(i),this._activeDate=new Date,this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this._headerDate=null,this._headerYear=null,this._headerMonth=null,this._view=this._options.view,this._popper=null,this._focusTrap=null,this._isOpen=!1,this._toggleButtonId=bt(\"datepicker-toggle-\"),this._animations=!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&this._options.animations,this._scrollBar=new Qi,this._element&&O.setData(t,Wo,this),this._init(),this.toggleButton&&this._options.disableToggle&&(this.toggleButton.disabled=\"true\"),this._options.disableInput&&(this._input.disabled=\"true\")}static get NAME(){return El}get container(){return m.findOne(`[${pu}='${this._toggleButtonId}']`)||m.findOne(`[${fu}='${this._toggleButtonId}']`)}get options(){return this._options}get activeCell(){let t;return this._view===\"days\"&&(t=this._getActiveDayCell()),this._view===\"months\"&&(t=this._getActiveMonthCell()),this._view===\"years\"&&(t=this._getActiveYearCell()),t}get activeDay(){return Tt(this._activeDate)}get activeMonth(){return ot(this._activeDate)}get activeYear(){return K(this._activeDate)}get firstYearInView(){return this.activeYear-$o(this._activeDate,ie)}get lastYearInView(){return this.firstYearInView+ie-1}get viewChangeButton(){return m.findOne(Zy,this.container)}get previousButton(){return m.findOne(Qy,this.container)}get nextButton(){return m.findOne(Jy,this.container)}get okButton(){return m.findOne(tT,this.container)}get cancelButton(){return m.findOne(eT,this.container)}get clearButton(){return m.findOne(iT,this.container)}get datesContainer(){return m.findOne(sT,this.container)}get toggleButton(){return m.findOne(nT,this._element)}update(t={}){this._options=this._getConfig({...this._options,...t})}_getConfig(t){const e=g.getDataAttributes(this._element);if(t={...HT,...e,...t},L(El,t,VT),t.max&&typeof t.max==\"string\"&&(t.max=new Date(t.max)),t.min&&typeof t.min==\"string\"&&(t.min=new Date(t.min)),t.startDay&&t.startDay!==0){const i=this._getNewDaysOrderArray(t);t.weekdaysNarrow=i}return t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...FT,...e,...t},L(El,t,WT),t}_getContainer(){return m.findOne(this._options.container)}_getNewDaysOrderArray(t){const e=t.startDay,i=t.weekdaysNarrow;return i.slice(e).concat(i.slice(0,e))}_init(){!this.toggleButton&&this._options.toggleButton&&(this._appendToggleButton(),(this._input.readOnly||this._input.disabled)&&(this.toggleButton.style.pointerEvents=\"none\")),this._listenToUserInput(),this._listenToToggleClick(),this._listenToToggleKeydown()}_appendToggleButton(){const t=Wy(this._toggleButtonId,this._classes.datepickerToggleButton);this._element.insertAdjacentHTML(\"beforeend\",t)}open(){if(this._input.readOnly||this._input.disabled)return;const t=_.trigger(this._element,Uy);if(this._isOpen||t.defaultPrevented)return;this._setInitialDate();const e=Py(this._classes.datepickerBackdrop),i=Ry(this._activeDate,this._selectedDate,this._selectedYear,this._selectedMonth,this._options,Fo,ie,Vo,this._toggleButtonId,this._classes);this._options.inline?this._openDropdown(i):(this._openModal(e,i),this._scrollBar.hide()),this._animations&&(g.addClass(this.container,this._classes.fadeIn),g.addClass(e,this._classes.fadeInShort)),this._setFocusTrap(this.container),this._listenToDateSelection(),this._addControlsListeners(),this._updateControlsDisabledState(),this._listenToEscapeClick(),this._listenToKeyboardNavigation(),this._listenToDatesContainerFocus(),this._listenToDatesContainerBlur(),this._asyncFocusDatesContainer(),this._updateViewControlsAndAttributes(this._view),this._isOpen=!0,setTimeout(()=>{this._listenToOutsideClick()},0)}_openDropdown(t){this._popper=Fe(this._input,t,{placement:\"bottom-start\"}),this._getContainer().appendChild(t)}_openModal(t,e){const i=this._getContainer();i.appendChild(t),i.appendChild(e)}_setFocusTrap(t){this._focusTrap=new Vs(t,{event:\"keydown\",condition:e=>e.key===\"Tab\"}),this._focusTrap.trap()}_listenToUserInput(){_.on(this._input,\"input\",t=>{this._handleUserInput(t.target.value)})}_listenToToggleClick(){_.on(this._element,jo,Yo,t=>{t.preventDefault(),this.open()})}_listenToToggleKeydown(){_.on(this._element,\"keydown\",Yo,t=>{t.keyCode===Et&&!this._isOpen&&this.open()})}_listenToDateSelection(){_.on(this.datesContainer,\"click\",t=>{this._handleDateSelection(t)})}_handleDateSelection(t){const e=t.target.nodeName===\"DIV\"?t.target.parentNode.dataset:t.target.dataset,i=t.target.nodeName===\"DIV\"?t.target.parentNode:t.target;if(e.teDate&&this._pickDay(e.teDate,i),e.teMonth&&e.teYear){const n=parseInt(e.teMonth,10),o=parseInt(e.teYear,10);this._pickMonth(n,o)}if(e.teYear&&!e.teMonth){const n=parseInt(e.teYear,10);this._pickYear(n)}this._options.inline||this._updateHeaderDate(this._activeDate,this._options.monthsShort,this._options.weekdaysShort)}_updateHeaderDate(t,e,i){const n=m.findOne(oT,this.container),o=ot(t),r=Tt(t),a=Lo(t);n.innerHTML=`${i[a]}, ${e[o]} ${r}`}_addControlsListeners(){_.on(this.nextButton,\"click\",()=>{this._view===\"days\"?this.nextMonth():this._view===\"years\"?this.nextYears():this.nextYear(),this._updateControlsDisabledState()}),_.on(this.previousButton,\"click\",()=>{this._view===\"days\"?this.previousMonth():this._view===\"years\"?this.previousYears():this.previousYear(),this._updateControlsDisabledState()}),_.on(this.viewChangeButton,\"click\",()=>{this._view===\"days\"?this._changeView(\"years\"):(this._view===\"years\"||this._view===\"months\")&&this._changeView(\"days\")}),this._options.inline||this._listenToFooterButtonsClick()}_listenToFooterButtonsClick(){_.on(this.okButton,\"click\",()=>this.handleOk()),_.on(this.cancelButton,\"click\",()=>this.handleCancel()),_.on(this.clearButton,\"click\",()=>this.handleClear())}_listenToOutsideClick(){_.on(document,jo,t=>{const e=t.target===this.container,i=this.container&&this.container.contains(t.target);!e&&!i&&this.close()})}_listenToEscapeClick(){_.on(document,\"keydown\",t=>{t.keyCode===xi&&this._isOpen&&this.close()})}_listenToKeyboardNavigation(){_.on(this.datesContainer,\"keydown\",t=>{this._handleKeydown(t)})}_listenToDatesContainerFocus(){_.on(this.datesContainer,\"focus\",()=>{this._focusActiveCell(this.activeCell)})}_listenToDatesContainerBlur(){_.on(this.datesContainer,\"blur\",()=>{this._removeCurrentFocusStyles()})}_handleKeydown(t){this._view===\"days\"&&this._handleDaysViewKeydown(t),this._view===\"months\"&&this._handleMonthsViewKeydown(t),this._view===\"years\"&&this._handleYearsViewKeydown(t)}_handleDaysViewKeydown(t){const e=this._activeDate,i=this.activeCell;switch(t.keyCode){case cs:this._activeDate=as(this._activeDate,et()?1:-1);break;case hs:this._activeDate=as(this._activeDate,et()?-1:1);break;case ut:this._activeDate=as(this._activeDate,-7);break;case ht:this._activeDate=as(this._activeDate,7);break;case Ti:this._activeDate=as(this._activeDate,1-Tt(this._activeDate));break;case Ei:this._activeDate=as(this._activeDate,bl(this._activeDate)-Tt(this._activeDate));break;case yl:this._activeDate=St(this._activeDate,-1);break;case Tl:this._activeDate=St(this._activeDate,1);break;case Et:case Ho:this._selectDate(this._activeDate),this._handleDateSelection(t),t.preventDefault();return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"days\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_asyncFocusDatesContainer(){setTimeout(()=>{this.datesContainer.focus()},0)}_focusActiveCell(t){t&&t.setAttribute(\"data-te-datepicker-cell-focused\",\"\")}_removeHighlightFromCell(t){t&&t.removeAttribute(\"data-te-datepicker-cell-focused\")}_getActiveDayCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>{const n=au(i.dataset.teDate);return yi(n,this._activeDate)})}_handleMonthsViewKeydown(t){const e=this._activeDate,i=this.activeCell;switch(t.keyCode){case cs:this._activeDate=St(this._activeDate,et()?1:-1);break;case hs:this._activeDate=St(this._activeDate,et()?-1:1);break;case ut:this._activeDate=St(this._activeDate,-4);break;case ht:this._activeDate=St(this._activeDate,4);break;case Ti:this._activeDate=St(this._activeDate,-this.activeMonth);break;case Ei:this._activeDate=St(this._activeDate,11-this.activeMonth);break;case yl:this._activeDate=kt(this._activeDate,-1);break;case Tl:this._activeDate=kt(this._activeDate,1);break;case Et:case Ho:this._selectMonth(this.activeMonth);return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"months\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_getActiveMonthCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>{const n=parseInt(i.dataset.teYear,10),o=parseInt(i.dataset.teMonth,10);return n===this.activeYear&&o===this.activeMonth})}_handleYearsViewKeydown(t){const e=this._activeDate,i=this.activeCell,n=4,o=24;switch(t.keyCode){case cs:this._activeDate=kt(this._activeDate,et()?1:-1);break;case hs:this._activeDate=kt(this._activeDate,et()?-1:1);break;case ut:this._activeDate=kt(this._activeDate,-n);break;case ht:this._activeDate=kt(this._activeDate,n);break;case Ti:this._activeDate=kt(this._activeDate,-$o(this._activeDate,o));break;case Ei:this._activeDate=kt(this._activeDate,o-$o(this._activeDate,o)-1);break;case yl:this._activeDate=kt(this._activeDate,-o);break;case Tl:this._activeDate=kt(this._activeDate,o);break;case Et:case Ho:this._selectYear(this.activeYear);return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"years\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_getActiveYearCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>parseInt(i.dataset.teYear,10)===this.activeYear)}_setInitialDate(){this._input.value?this._handleUserInput(this._input.value):this._options.startDate?this._activeDate=new Date(this._options.startDate):this._activeDate=new Date}close(){const t=_.trigger(this._element,Ky);!this._isOpen||t.defaultPrevented||(this._removeDatepickerListeners(),this._animations&&g.addClass(this.container,this._classes.fadeOut),this._options.inline?this._closeDropdown():this._closeModal(),this._isOpen=!1,this._view=this._options.view,this.toggleButton?this.toggleButton.focus():this._input.focus())}_closeDropdown(){const t=m.findOne(qy),e=this._getContainer();window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&(t&&e.removeChild(t),this._popper&&this._popper.destroy()),t.addEventListener(\"animationend\",()=>{t&&e.removeChild(t),this._popper&&this._popper.destroy()}),this._removeFocusTrap()}_closeModal(){const t=m.findOne(rT),e=m.findOne(Gy);!e||!t||(this._animations?(g.addClass(t,this._classes.fadeOutShort),t.addEventListener(\"animationend\",()=>{this._removePicker(t,e),this._scrollBar.reset()})):(this._removePicker(t,e),this._scrollBar.reset()))}_removePicker(t,e){const i=this._getContainer();i.removeChild(t),i.removeChild(e)}_removeFocusTrap(){this._focusTrap&&(this._focusTrap.disable(),this._focusTrap=null)}_removeDatepickerListeners(){_.off(this.nextButton,\"click\"),_.off(this.previousButton,\"click\"),_.off(this.viewChangeButton,\"click\"),_.off(this.okButton,\"click\"),_.off(this.cancelButton,\"click\"),_.off(this.clearButton,\"click\"),_.off(this.datesContainer,\"click\"),_.off(this.datesContainer,\"keydown\"),_.off(this.datesContainer,\"focus\"),_.off(this.datesContainer,\"blur\"),_.off(document,jo)}dispose(){this._isOpen&&this.close(),this._removeInputAndToggleListeners();const t=m.findOne(`#${this._toggleButtonId}`);t&&this._element.removeChild(t),O.removeData(this._element,Wo),this._element=null,this._input=null,this._options=null,this._activeDate=null,this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this._headerDate=null,this._headerYear=null,this._headerMonth=null,this._view=null,this._popper=null,this._focusTrap=null}_removeInputAndToggleListeners(){_.off(this._input,\"input\"),_.off(this._element,jo,Yo),_.off(this._element,\"keydown\",Yo)}handleOk(){this._confirmSelection(this._headerDate),this.close()}_selectDate(t,e=this.activeCell){const{min:i,max:n,filter:o,disablePast:r,disableFuture:a}=this._options;Ro(t,i,n,o,r,a)||(this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._selectedDate=t,this._selectedYear=K(t),this._selectedMonth=ot(t),this._headerDate=t,(this._options.inline||this.options.confirmDateOnSelect)&&(this._confirmSelection(t),this.close()))}_selectYear(t,e=this.activeCell){this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._headerYear=t,this._asyncChangeView(\"months\")}_selectMonth(t,e=this.activeCell){this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._headerMonth=t,this._asyncChangeView(\"days\")}_removeSelectedStyles(t){t&&t.removeAttribute(\"data-te-datepicker-cell-selected\")}_addSelectedStyles(t){t&&t.setAttribute(\"data-te-datepicker-cell-selected\",\"\")}_confirmSelection(t){if(t){const e=this.formatDate(t);this._input.value=e,_.trigger(this._element,Xy,{date:t}),_.trigger(this._input,\"input\")}}handleCancel(){this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this.close()}handleClear(){this._selectedDate=null,this._selectedMonth=null,this._selectedYear=null,this._headerDate=null,this._headerMonth=null,this._headerYear=null,this._removeCurrentSelectionStyles(),this._input.value=\"\",this._setInitialDate(),this._changeView(\"days\"),this._updateHeaderDate(this._activeDate,this._options.monthsShort,this._options.weekdaysShort)}_removeCurrentSelectionStyles(){const t=m.findOne(\"[data-te-datepicker-cell-selected]\",this.container);t&&t.removeAttribute(\"data-te-datepicker-cell-selected\")}_removeCurrentFocusStyles(){const t=m.findOne(\"[data-te-datepicker-cell-focused]\",this.container);t&&t.removeAttribute(\"data-te-datepicker-cell-focused\")}formatDate(t){const e=Tt(t),i=this._addLeadingZero(Tt(t)),n=this._options.weekdaysShort[Lo(t)],o=this._options.weekdaysFull[Lo(t)],r=ot(t)+1,a=this._addLeadingZero(ot(t)+1),l=this._options.monthsShort[ot(t)],c=this._options.monthsFull[ot(t)],h=K(t).toString().length===2?K(t):K(t).toString().slice(2,4),d=K(t),u=this._options.format.split(/(d{1,4}|m{1,4}|y{4}|yy|!.)/g);let p=\"\";return u.forEach(f=>{switch(f){case\"dddd\":f=f.replace(f,o);break;case\"ddd\":f=f.replace(f,n);break;case\"dd\":f=f.replace(f,i);break;case\"d\":f=f.replace(f,e);break;case\"mmmm\":f=f.replace(f,c);break;case\"mmm\":f=f.replace(f,l);break;case\"mm\":f=f.replace(f,a);break;case\"m\":f=f.replace(f,r);break;case\"yyyy\":f=f.replace(f,d);break;case\"yy\":f=f.replace(f,h);break}p+=f}),p}_addLeadingZero(t){return parseInt(t,10)<10?`0${t}`:t}_pickDay(t,e){const i=au(t),{min:n,max:o,filter:r,disablePast:a,disableFuture:l}=this._options;Ro(i,n,o,r,a,l)||(this._activeDate=i,this._selectDate(i,e))}_pickYear(t){const{min:e,max:i,disablePast:n,disableFuture:o}=this._options;if(vl(t,e,i,n,o))return;const r=ee(t,this.activeMonth,this.activeDay);this._activeDate=r,this._selectedDate=r,this._selectYear(t)}_pickMonth(t,e){const{min:i,max:n,disablePast:o,disableFuture:r}=this._options;if(lu(t,e,i,n,o,r)||vl(e,i,n,o,r))return;const a=ee(e,t,this.activeDay);this._activeDate=a,this._selectMonth(t)}nextMonth(){const t=St(this._activeDate,1),e=Po(t,this._headerDate,this._options,this._classes);this._activeDate=t,this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}previousMonth(){const t=St(this._activeDate,-1);this._activeDate=t;const e=Po(t,this._headerDate,this._options,this._classes);this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}nextYear(){const t=kt(this._activeDate,1);this._activeDate=t,this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes);const e=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes);this.datesContainer.innerHTML=e}previousYear(){const t=kt(this._activeDate,-1);this._activeDate=t,this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes);const e=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes);this.datesContainer.innerHTML=e}nextYears(){const t=kt(this._activeDate,24);this._activeDate=t;const e=Bo(t,this._selectedYear,this._options,ie,Vo,this._classes);this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}previousYears(){const t=kt(this._activeDate,-24);this._activeDate=t;const e=Bo(t,this._selectedYear,this._options,ie,Vo,this._classes);this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}_asyncChangeView(t){setTimeout(()=>{this._changeView(t)},0)}_changeView(t){this._view=t,this.datesContainer.blur(),t===\"days\"&&(this.datesContainer.innerHTML=Po(this._activeDate,this._headerDate,this._options,this._classes)),t===\"months\"&&(this.datesContainer.innerHTML=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes)),t===\"years\"&&(this.datesContainer.innerHTML=Bo(this._activeDate,this._selectedYear,this._options,ie,Vo,this._classes)),this.datesContainer.focus(),this._updateViewControlsAndAttributes(t),this._updateControlsDisabledState()}_updateViewControlsAndAttributes(t){t===\"days\"&&(this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToMultiYearViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevMonthLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextMonthLabel)),t===\"months\"&&(this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToDayViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevYearLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextYearLabel)),t===\"years\"&&(this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToMonthViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevMultiYearLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextMultiYearLabel))}_updateControlsDisabledState(){xy(this._options.disableFuture,this._activeDate,this._view,ie,this._options.min,this._options.max,this.lastYearInView,this.firstYearInView)?this.nextButton.disabled=!0:this.nextButton.disabled=!1,Cy(this._options.disablePast,this._activeDate,this._view,ie,this._options.min,this._options.max,this.lastYearInView,this.firstYearInView)?this.previousButton.disabled=!0:this.previousButton.disabled=!1}_handleUserInput(t){const e=this._getDelimeters(this._options.format),i=this._parseDate(t,this._options.format,e);yy(i)?(this._activeDate=i,this._selectedDate=i,this._selectedYear=K(i),this._selectedMonth=ot(i),this._headerDate=i):(this._activeDate=new Date,this._selectedDate=null,this._selectedMonth=null,this._selectedYear=null,this._headerDate=null,this._headerMonth=null,this._headerYear=null)}_getDelimeters(t){return t.match(/[^(dmy)]{1,}/g)}_parseDate(t,e,i){let n;i[0]!==i[1]?n=i[0]+i[1]:n=i[0];const o=new RegExp(`[${n}]`),r=t.split(o),a=e.split(o),l=e.indexOf(\"mmm\")!==-1,c=[];for(let b=0;bi===t)}static getInstance(t){return O.getData(t,Wo)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zT=({format24:s,okLabel:t,cancelLabel:e,headID:i,footerID:n,bodyID:o,pickerID:r,clearLabel:a,inline:l,showClearBtn:c,amLabel:h,pmLabel:d},u)=>{const p=`
\n
\n
\n
\n
\n
\n \n \n \n \n \n \n \n
\n ${s?\"\":`
\n \n \n
`}\n
\n
\n ${l?\"\":`
\n
\n \n
\n
\n
\n ${s?'
':\"\"}\n
\n
`}\n
\n
\n
\n ${c?``:\"\"}\n \n \n
\n
\n
\n
`,f=`
\n
\n
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${s?\"\":`
\n \n \n \n
`}\n ${s?``:\"\"}\n
\n
\n
\n
\n
`;return l?f:p},jT=(s,t,e)=>{const{iconSVG:i}=s;return`\n \n`},Ko=\"data-te-timepicker-disabled\",Uo=\"data-te-timepicker-active\",Ai=s=>{if(s===\"\")return;let t,e,i,n;return _u(s)?(t=s.getHours(),n=t,e=s.getMinutes(),t%=12,n===0&&t===0&&(i=\"AM\"),t=t||12,i===void 0&&(i=Number(n)>=12?\"PM\":\"AM\"),e=e<10?`0${e}`:e):([t,e,i]=j(s,!1),n=t,t%=12,n===0&&t===0&&(i=\"AM\"),t=t||12,i===void 0&&(i=Number(n)>=12?\"PM\":\"AM\")),{hours:t,minutes:e,amOrPm:i}},_u=s=>s&&Object.prototype.toString.call(s)===\"[object Date]\"&&!Number.isNaN(s),gu=s=>{if(s===\"\")return;let t,e;return _u(s)?(t=s.getHours(),e=s.getMinutes()):[t,e]=j(s,!1),e=Number(e)<10?`0${Number(e)}`:e,{hours:t,minutes:e}},YT=(s,t,e)=>_.on(document,s,t,({target:i})=>{if(i.hasAttribute(Uo))return;document.querySelectorAll(t).forEach(o=>{o.hasAttribute(Uo)&&(g.removeClass(o,e.opacity),o.removeAttribute(Uo))}),g.addClass(i,e.opacity),i.setAttribute(Uo,\"\")}),mu=({clientX:s,clientY:t,touches:e},i,n=!1)=>{const{left:o,top:r}=i.getBoundingClientRect();let a={};return!n||!e?a={x:s-o,y:t-r}:n&&Object.keys(e).length>0&&(a={x:e[0].clientX-o,y:e[0].clientY-r}),a},Xo=()=>navigator.maxTouchPoints&&navigator.maxTouchPoints>2&&/MacIntel/.test(navigator.platform)||/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),j=(s,t=!0)=>t?s.value.replace(/:/gi,\" \").split(\" \"):s.replace(/:/gi,\" \").split(\" \"),bu=(s,t)=>{const[e,i,n]=j(s,!1),[o,r,a]=j(t,!1);return n===\"PM\"&&a===\"AM\"||n===a&&e>o||i>r},vu=()=>{const s=new Date,t=s.getHours(),e=s.getMinutes();return`${t}:${e<10?`0${e}`:e}`},Ke=(s,t,e)=>{if(!t)return s;let i=vu();return e&&(i=`${Ai(i).hours}:${Ai(i).minutes} ${Ai(i).amOrPm}`),(s!==\"\"&&bu(i,s)||s===\"\")&&(s=i),s},Ue=(s,t,e)=>{if(!t)return s;let i=vu();return e&&(i=`${Ai(i).hours}:${Ai(i).minutes} ${Ai(i).amOrPm}`),(s!==\"\"&&!bu(i,s)||s===\"\")&&(s=i),s},KT=({format12:s,maxTime:t,minTime:e,disablePast:i,disableFuture:n},o,r)=>{const a=j(o)[1];e=Ke(e,i,s),t=Ue(t,n,s);const[l,c,h]=j(t,!1),[d,u,p]=j(e,!1);if(h!==void 0||p!==void 0)return[r,a];if(!(l!==\"\"&&d===\"\"&&Number(r)>Number(l))&&!(l===\"\"&&d!==\"\"&&c===void 0&&u!==\"\"&&Number(r){s.forEach(n=>{t=t===\"12\"&&i?\"0\":t,(n.textContent===\"00\"||Number(n.textContent===\"12\"&&i?\"0\":n.textContent)>t)&&(g.addClass(n,e.tipsDisabled),n.setAttribute(Ko,\"\"))})},Tu=(s,t,e,i)=>{s.forEach(n=>{t=t===\"12\"&&i?\"0\":t,n.textContent!==\"00\"&&Number(n.textContent===\"12\"&&i?\"0\":n.textContent){if(t===\"12\"||t===\"24\")return;const n=e?12:24;return i===\"max\"?(Number(s)===n?0:Number(s))>Number(t):(Number(s)===n?0:Number(s)){s.forEach(r=>{(Eu(i,e,o,\"max\")||Number(r.textContent)>t&&Number(i)===Number(e))&&(g.addClass(r,n.tipsDisabled),r.setAttribute(Ko,\"\"))})},XT=(s,t,e,i,n,o)=>{s.forEach(r=>{(Eu(i,e,o,\"min\")||Number(r.textContent)s.startsWith(\"0\")?Number(s.slice(1)):Number(s),Zs=\"timepicker\",W=`data-te-${Zs}`,xu=\"[data-te-toggle]\",Go=`te.${Zs}`,fe=`.${Go}`,_e=\".data-api\",Cu=`click${fe}${_e}`,qo=`keydown${fe}${_e}`,Au=`mousedown${fe}${_e}`,wu=`mouseup${fe}${_e}`,ku=`mousemove${fe}${_e}`,Su=`mouseleave${fe}${_e}`,Ou=`mouseover${fe}${_e}`,Iu=`touchmove${fe}${_e}`,Du=`touchend${fe}${_e}`,Mu=`touchstart${fe}${_e}`,qT=`[${W}-am]`,ZT=`[${W}-pm]`,QT=`[${W}-format24]`,Zo=`[${W}-current]`,Qo=`[${W}-hour-mode]`,JT=`[${W}-toggle-button]`,Cl=`${W}-cancel`,Lu=`${W}-clear`,Al=`${W}-submit`,tE=`${W}-icon`,wl=`${W}-icon-up`,kl=`${W}-icon-down`,eE=`${W}-icon-inline-hour`,iE=`${W}-icon-inline-minute`,$u=`${W}-inline-hour-icons`,sE=`${W}-current-inline`,nE=\"readonly\",oE=`${W}-invalid-feedback`,Sl=`${W}-is-invalid`,Xe=`${W}-disabled`,J=`${W}-active`,rE=`${W}-input`,wi=`${W}-clock`,Qs=`${W}-clock-inner`,Ol=`${W}-wrapper`,Ru=`${W}-clock-wrapper`,Jo=`${W}-hour`,Il=`${W}-minute`,tr=`${W}-tips-element`,_t=`${W}-tips-hours`,xt=`${W}-tips-minutes`,Bt=`${W}-tips-inner`,er=`${W}-tips-inner-element`,Pu=`${W}-middle-dot`,Dl=`${W}-hand-pointer`,Ml=`${W}-circle`,Nu=`${W}-modal`,aE={appendValidationInfo:!0,bodyID:\"\",cancelLabel:\"Cancel\",clearLabel:\"Clear\",closeModalOnBackdropClick:!0,closeModalOnMinutesClick:!1,container:\"body\",defaultTime:\"\",disabled:!1,disablePast:!1,disableFuture:!1,enableValidation:!0,focusInputAfterApprove:!1,footerID:\"\",format12:!0,format24:!1,headID:\"\",increment:!1,inline:!1,invalidLabel:\"Invalid Time Format\",maxTime:\"\",minTime:\"\",modalID:\"\",okLabel:\"Ok\",overflowHidden:!0,pickerID:\"\",readOnly:!1,showClearBtn:!0,switchHoursToMinutesOnClick:!0,iconSVG:`\n \n`,withIcon:!0,pmLabel:\"PM\",amLabel:\"AM\",animations:!0},lE={appendValidationInfo:\"boolean\",bodyID:\"string\",cancelLabel:\"string\",clearLabel:\"string\",closeModalOnBackdropClick:\"boolean\",closeModalOnMinutesClick:\"boolean\",container:\"string\",disabled:\"boolean\",disablePast:\"boolean\",disableFuture:\"boolean\",enableValidation:\"boolean\",footerID:\"string\",format12:\"boolean\",format24:\"boolean\",headID:\"string\",increment:\"boolean\",inline:\"boolean\",invalidLabel:\"string\",modalID:\"string\",okLabel:\"string\",overflowHidden:\"boolean\",pickerID:\"string\",readOnly:\"boolean\",showClearBtn:\"boolean\",switchHoursToMinutesOnClick:\"boolean\",defaultTime:\"(string|date|number)\",iconSVG:\"string\",withIcon:\"boolean\",pmLabel:\"string\",amLabel:\"string\",animations:\"boolean\"},cE={tips:\"absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] rounded-[100%] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent\",tipsActive:\"text-white bg-[#3b71ca] font-normal\",tipsDisabled:\"text-[#b3afaf] pointer-events-none bg-transparent\",transform:\"transition-[transform,height] ease-in-out duration-[400ms]\",modal:\"z-[1065]\",clockAnimation:\"animate-[show-up-clock_350ms_linear]\",opacity:\"!opacity-100\",timepickerWrapper:\"touch-none opacity-100 z-[1065] inset-0 bg-[#00000066] h-full flex items-center justify-center flex-col fixed\",timepickerContainer:\"flex items-center justify-center flex-col max-h-[calc(100%-64px)] overflow-y-auto shadow-[0_10px_15px_-3px_rgba(0,0,0,0.07),0_4px_6px_-2px_rgba(0,0,0,0.05)] min-[320px]:max-[825px]:landscape:rounded-lg\",timepickerElements:\"flex flex-col min-w-[310px] min-h-[325px] bg-white rounded-t-[0.6rem] min-[320px]:max-[825px]:landscape:!flex-row min-[320px]:max-[825px]:landscape:min-w-[auto] min-[320px]:max-[825px]:landscape:min-h-[auto] min-[320px]:max-[825px]:landscape:overflow-y-auto justify-around\",timepickerHead:\"bg-[#3b71ca] dark:bg-zinc-700 h-[100px] rounded-t-lg pr-[24px] pl-[50px] py-[10px] min-[320px]:max-[825px]:landscape:rounded-tr-none min-[320px]:max-[825px]:landscape:rounded-bl-none min-[320px]:max-[825px]:landscape:p-[10px] min-[320px]:max-[825px]:landscape:pr-[10px] min-[320px]:max-[825px]:landscape:h-auto min-[320px]:max-[825px]:landscape:min-h-[305px] flex flex-row items-center justify-center\",timepickerHeadContent:\"min-[320px]:max-[825px]:landscape:flex-col flex w-full justify-evenly\",timepickerCurrentWrapper:\"[direction:ltr] rtl:[direction:rtl]\",timepickerCurrentButtonWrapper:\"relative h-full\",timepickerCurrentButton:\"text-[3.75rem] font-light leading-[1.2] tracking-[-0.00833em] text-white opacity-[.54] border-none bg-transparent p-0 min-[320px]:max-[825px]:landscape:text-5xl min-[320px]:max-[825px]:landscape:font-normal cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none \",timepickerDot:\"font-light leading-[1.2] tracking-[-0.00833em] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white min-[320px]:max-[825px]:landscape:text-[3rem] min-[320px]:max-[825px]:landscape:font-normal\",timepickerModeWrapper:\"flex flex-col justify-center text-[18px] text-[#ffffff8a] min-[320px]:max-[825px]:landscape:!justify-around min-[320px]:max-[825px]:landscape:!flex-row\",timepickerModeAm:\"p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none\",timepickerModePm:\"p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none\",timepickerClockWrapper:\"min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center flex-col items-center dark:bg-zinc-500\",timepickerClock:\"relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50\",timepickerMiddleDot:\"top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute\",timepickerHandPointer:\"bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute\",timepickerPointerCircle:\"-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute\",timepickerClockInner:\"absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 w-[160px] h-[160px] rounded-[100%]\",timepickerFooterWrapper:\"rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500\",timepickerFooter:\"w-full flex justify-between\",timepickerFooterButton:\"text-[0.8rem] min-w-[64px] box-border font-medium leading-[40px] rounded-[10px] tracking-[0.1rem] uppercase text-[#3b71ca] dark:text-white border-none bg-transparent transition-[background-color,box-shadow,border] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] delay-[0ms] outline-none py-0 px-[10px] h-[40px] mb-[10px] hover:bg-[#00000014] focus:bg-[#00000014] focus:outline-none\",timepickerInlineWrapper:\"touch-none opacity-100 z-[1065] inset-0 bg-[#00000066] h-full flex items-center justify-center flex-col rounded-lg\",timepickerInlineContainer:\"flex items-center justify-center flex-col max-h-[calc(100%-64px)] overflow-y-auto shadow-[0_10px_15px_-3px_rgba(0,0,0,0.07),0_4px_6px_-2px_rgba(0,0,0,0.05)]\",timepickerInlineElements:\"flex flex-col min-h-[auto] min-w-[310px] bg-white rounded-[0.6rem] min-[320px]:max-[825px]:landscape:!flex-row min-[320px]:max-[825px]:landscape:rounded-bl-lg min-[320px]:max-[825px]:landscape:min-w-[auto] min-[320px]:max-[825px]:landscape::min-h-[auto] min-[320px]:max-[825px]:landscape:overflow-y-auto justify-around\",timepickerInlineHead:\"bg-[#3b71ca] dark:bg-zinc-700 h-[100px] rounded-t-lg min-[320px]:max-[825px]:landscape:rounded-tr-none min-[320px]:max-[825px]:landscape:rounded-bl-none min-[320px]:max-[825px]:landscape:p-[10px] min-[320px]:max-[825px]:landscape:pr-[10px] min-[320px]:max-[825px]:landscape:h-auto min-[320px]:max-[825px]:landscape:min-h-[305px] flex flex-row items-center justify-center p-0 rounded-b-lg\",timepickerInlineHeadContent:\"min-[320px]:max-[825px]:landscape:flex-col flex w-full justify-evenly items-center\",timepickerInlineHourWrapper:\"relative h-full !opacity-100\",timepickerCurrentMinuteWrapper:\"relative h-full\",timepickerInlineIconUp:\"absolute text-white -top-[35px] opacity-0 hover:opacity-100 transition-all duration-200 ease-[ease] cursor-pointer -translate-x-1/2 -translate-y-1/2 left-1/2 w-[30px] h-[30px] flex justify-center items-center\",timepickerInlineIconSvg:\"h-4 w-4\",timepickerInlineCurrentButton:\"font-light leading-[1.2] tracking-[-0.00833em] text-white border-none bg-transparent p-0 min-[320px]:max-[825px]:landscape:text-5xl min-[320px]:max-[825px]:landscape:font-normal !opacity-100 cursor-pointer focus:bg-[#00000026] hover:outline-none focus:outline-none text-[2.5rem] hover:bg-[unset]\",timepickerInlineIconDown:\"absolute text-white -bottom-[47px] opacity-0 hover:opacity-100 transition-all duration-200 ease-[ease] cursor-pointer -translate-x-1/2 -translate-y-1/2 left-1/2 w-[30px] h-[30px] flex justify-center items-center\",timepickerInlineDot:\"font-light leading-[1.2] tracking-[-0.00833em] opacity-[.54] border-none bg-transparent p-0 text-white min-[320px]:max-[825px]:landscape:text-[3rem] min-[320px]:max-[825px]:landscape:font-normal text-[2.5rem]\",timepickerInlineModeWrapper:\"flex justify-center text-[18px] text-[#ffffff8a] min-[320px]:max-[825px]:landscape:!justify-around min-[320px]:max-[825px]:landscape:!flex-row\",timepickerInlineModeAm:\"hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer mr-2 ml-6\",timepickerInlineModePm:\"hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer\",timepickerInlineSubmitButton:\"hover:bg-[#00000014] focus:bg-[#00000014] focus:outline-none text-[0.8rem] box-border font-medium leading-[40px] tracking-[.1rem] uppercase border-none bg-transparent [transition:background-color_250ms_cubic-bezier(0.4,0,0.2,1)_0ms,box-shadow_250ms_cubic-bezier(0.4,0,0.2,1)_0ms,border_250ms_cubic-bezier(0.4,0,0.2,1)_0ms] outline-none rounded-[100%] h-[48px] min-w-[48px] inline-block ml-[30px] text-white py-1 px-2 mb-0\",timepickerToggleButton:\"h-4 w-4 ml-auto absolute outline-none border-none bg-transparent right-1.5 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer hover:text-[#3b71ca] focus:text-[#3b71ca] dark:hover:text-[#3b71ca] dark:focus:text-[#3b71ca] dark:text-white\"},hE={tips:\"string\",tipsActive:\"string\",tipsDisabled:\"string\",transform:\"string\",modal:\"string\",clockAnimation:\"string\",opacity:\"string\",timepickerWrapper:\"string\",timepickerContainer:\"string\",timepickerElements:\"string\",timepickerHead:\"string\",timepickerHeadContent:\"string\",timepickerCurrentWrapper:\"string\",timepickerCurrentButtonWrapper:\"string\",timepickerCurrentButton:\"string\",timepickerDot:\"string\",timepickerModeWrapper:\"string\",timepickerModeAm:\"string\",timepickerModePm:\"string\",timepickerClockWrapper:\"string\",timepickerClock:\"string\",timepickerMiddleDot:\"string\",timepickerHandPointer:\"string\",timepickerPointerCircle:\"string\",timepickerClockInner:\"string\",timepickerFooterWrapper:\"string\",timepickerFooterButton:\"string\",timepickerInlineWrapper:\"string\",timepickerInlineContainer:\"string\",timepickerInlineElements:\"string\",timepickerInlineHead:\"string\",timepickerInlineHeadContent:\"string\",timepickerInlineHourWrapper:\"string\",timepickerCurrentMinuteWrapper:\"string\",timepickerInlineIconUp:\"string\",timepickerInlineIconSvg:\"string\",timepickerInlineCurrentButton:\"string\",timepickerInlineIconDown:\"string\",timepickerInlineDot:\"string\",timepickerInlineModeWrapper:\"string\",timepickerInlineModeAm:\"string\",timepickerInlineModePm:\"string\",timepickerInlineSubmitButton:\"string\",timepickerToggleButton:\"string\"};class Ll{constructor(t,e={},i){ke(this,\"_toggleAmPm\",t=>{t===\"PM\"?(this._isPmEnabled=!0,this._isAmEnabled=!1):t===\"AM\"&&(this._isPmEnabled=!1,this._isAmEnabled=!0)});ke(this,\"_toggleBackgroundColorCircle\",t=>{if(this._modal.querySelector(`${t}[${J}]`)!==null){g.addStyle(this._circle,{backgroundColor:\"#1976d2\"});return}g.addStyle(this._circle,{backgroundColor:\"transparent\"})});ke(this,\"_toggleClassActive\",(t,{textContent:e},i)=>{const n=[...t].find(o=>Number(o)===Number(e));return i.forEach(o=>{if(!o.hasAttribute(Xe)){if(o.textContent===n){g.addClass(o,this._classes.tipsActive),o.setAttribute(J,\"\");return}g.removeClass(o,this._classes.tipsActive),o.removeAttribute(J)}})});ke(this,\"_makeMinutesDegrees\",(t,e)=>{const{increment:i}=this._options;return t<0?(e=Math.round(360+t/6)%60,t=360+Math.round(t/6)*6):(e=Math.round(t/6)%60,t=Math.round(t/6)*6),i&&(t=Math.round(t/30)*30,e=Math.round(t/6)*6/6,e===60&&(e=\"00\")),t>=360&&(t=0),{degrees:t,minute:e,addDegrees:i?30:6}});ke(this,\"_makeHourDegrees\",(t,e,i)=>{if(t)return this._hasTargetInnerClass(t)?e<0?(i=Math.round(360+e/30)%24,e=360+e):(i=Math.round(e/30)+12,i===12&&(i=\"00\")):e<0?(i=Math.round(360+e/30)%12,e=360+e):(i=Math.round(e/30)%12,(i===0||i>12)&&(i=12)),e>=360&&(e=0),{degrees:e,hour:i,addDegrees:30}});ke(this,\"_makeInnerHoursDegrees\",(t,e)=>(t<0?(e=Math.round(360+t/30)%24,t=360+t):(e=Math.round(t/30)+12,e===12&&(e=\"00\")),{degrees:t,hour:e,addDegrees:30}));ke(this,\"_getAppendClock\",(t=[],e=`[${wi}]`,i)=>{let{minTime:n,maxTime:o}=this._options;const{inline:r,format12:a,disablePast:l,disableFuture:c}=this._options;n=Ke(n,l,a),o=Ue(o,c,a);const[h,d,u]=j(o,!1),[p,f,b]=j(n,!1);!r&&a&&this._isInvalidTimeFormat&&!this._AM.hasAttribute(J)&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\"));const v=m.findOne(e),y=360/t.length;function T(A){return A*(Math.PI/180)}if(v===null)return;const x=(v.offsetWidth-32)/2,E=(v.offsetHeight-32)/2,C=x-4;setTimeout(()=>{let A;a&&(A=m.findOne(`${Qo}[${J}]`).textContent),this._handleDisablingTipsMinTime(A,b,f,p),this._handleDisablingTipsMaxTime(A,u,d,h)},0),[...t].forEach((A,w)=>{const S=T(w*y),k=$(\"span\"),D=$(\"span\");D.innerHTML=A,g.addClass(k,this._classes.tips),k.setAttribute(i,\"\");const I=k.offsetWidth,M=k.offsetHeight;return g.addStyle(k,{left:`${x+Math.sin(S)*C-I}px`,bottom:`${E+Math.cos(S)*C-M}px`}),t.includes(\"05\")&&k.setAttribute(xt,\"\"),t.includes(\"13\")?D.setAttribute(er,\"\"):D.setAttribute(tr,\"\"),k.appendChild(D),v.appendChild(k)})});this._element=t,this._element&&O.setData(t,Go,this),this._document=document,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._currentTime=null,this._toggleButtonId=bt(\"timepicker-toggle-\"),this.hoursArray=[\"12\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\",\"10\",\"11\"],this.innerHours=[\"00\",\"13\",\"14\",\"15\",\"16\",\"17\",\"18\",\"19\",\"20\",\"21\",\"22\",\"23\"],this.minutesArray=[\"00\",\"05\",\"10\",\"15\",\"20\",\"25\",\"30\",\"35\",\"40\",\"45\",\"50\",\"55\"],this.input=m.findOne(\"input\",this._element),this.dataWithIcon=t.dataset.withIcon,this.dataToggle=t.dataset.toggle,this.customIcon=m.findOne(JT,this._element),this._checkToggleButton(),this.inputFormatShow=m.findOne(QT,this._element),this.inputFormat=this.inputFormatShow===null?\"\":Object.values(this.inputFormatShow.dataset)[0],this.elementToggle=m.findOne(xu,this._element),this.toggleElement=Object.values(t.querySelector(xu).dataset)[0],this._hour=null,this._minutes=null,this._AM=null,this._PM=null,this._wrapper=null,this._modal=null,this._hand=null,this._circle=null,this._focusTrap=null,this._popper=null,this._interval=null,this._timeoutInterval=null,this._inputValue=this._options.defaultTime!==\"\"?this._options.defaultTime:this.input.value,this._options.format24&&(this._options.format12=!1,this._currentTime=gu(this._inputValue)),this._options.format12&&(this._options.format24=!1,this._currentTime=Ai(this._inputValue)),this._options.readOnly&&this.input.setAttribute(nE,!0),this.inputFormat===\"true\"&&this.inputFormat!==\"\"&&(this._options.format12=!1,this._options.format24=!0,this._currentTime=gu(this._inputValue)),this._animations=!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&this._options.animations,this.init(),this._isHours=!0,this._isMinutes=!1,this._isInvalidTimeFormat=!1,this._isMouseMove=!1,this._isInner=!1,this._isAmEnabled=!1,this._isPmEnabled=!1,this._options.format12&&!this._options.defaultTime&&(this._isPmEnabled=!0),this._objWithDataOnChange={degrees:null},this._scrollBar=new Qi}static get NAME(){return Zs}init(){const{format12:t,format24:e,enableValidation:i}=this._options;let n,o,r;if(this.input.setAttribute(rE,\"\"),this._currentTime!==void 0){const{hours:a,minutes:l,amOrPm:c}=this._currentTime;n=Number(a)<10?0:\"\",o=`${n}${Number(a)}:${l}`,r=c,t?this.input.value=`${o} ${r}`:e&&(this.input.value=`${o}`)}else n=\"\",o=\"\",r=\"\",this.input.value=\"\";this.input.value.length>0&&this.input.value!==\"\"&&(this.input.setAttribute(J,\"\"),_.trigger(this.input,\"input\")),!(this._options===null&&this._element===null)&&(i&&this._getValidate(\"keydown change blur focus\"),this._handleOpen(),this._listenToToggleKeydown())}dispose(){this._removeModal(),this._element!==null&&O.removeData(this._element,Go),setTimeout(()=>{this._element=null,this._options=null,this.input=null,this._focusTrap=null},350),_.off(this._element,\"click\",`[data-te-toggle='${this.toggleElement}']`),_.off(this._element,\"keydown\",`[data-te-toggle='${this.toggleElement}']`)}update(t={}){this._options=this._getConfig({...this._options,...t})}_checkToggleButton(){this.customIcon===null&&(this.dataWithIcon!==void 0&&(this._options.withIcon=null,this.dataWithIcon===\"true\"&&this._appendToggleButton(this._options)),this._options.withIcon&&this._appendToggleButton(this._options))}_appendToggleButton(){const t=jT(this._options,this._toggleButtonId,this._classes);this.input.insertAdjacentHTML(\"afterend\",t)}_getDomElements(){this._hour=m.findOne(`[${Jo}]`),this._minutes=m.findOne(`[${Il}]`),this._AM=m.findOne(qT),this._PM=m.findOne(ZT),this._wrapper=m.findOne(`[${Ol}]`),this._modal=m.findOne(`[${Nu}]`),this._hand=m.findOne(`[${Dl}]`),this._circle=m.findOne(`[${Ml}]`),this._clock=m.findOne(`[${wi}]`),this._clockInner=m.findOne(`[${Qs}]`)}_handlerMaxMinHoursOptions(t,e,i,n,o,r){if(!e&&!i)return!0;const{format24:a,format12:l,disablePast:c,disableFuture:h}=this._options,{_isAmEnabled:d,_isPmEnabled:u}=this,p=r.keyCode,f=r.target.hasAttribute(Qs)||r.target.hasAttribute(Bt)||r.target.hasAttribute(er);i=Ke(i,c,l),e=Ue(e,h,l),typeof e!=\"number\"&&(e=j(e,!1)[0]);const b=e!==\"\"?e*30:\"\",v=i!==\"\"?i*30:\"\";t<0&&(t=360+t),t=t===360?0:t;const y=()=>{const w=document.querySelectorAll(`[${tr}]`),S=document.querySelectorAll(`[${er}]`),k=GT(this._hour.innerText);let D,I,M;return p===ut?I=1:p===ht&&(I=-1),k===12&&p===ut?M=1:k===0&&p===ut?M=13:k===0&&p===ht?M=23:k===13&&p===ht?M=0:k===1&&p===ht?M=12:M=k+I,w.forEach(P=>{Number(P.textContent)===M&&(D=P)}),S.forEach(P=>{Number(P.textContent)===M&&(D=P)}),!D.parentElement.hasAttribute(Xe)},T=()=>{const w=i!==\"\"&&i>12?(i-12)*30:\"\",S=e!==\"\"&&e>12?(e-12)*30:\"\";if(!(w&&tS||e&&e<12))return!0};if(a&&r.type!==\"keydown\"&&f)return T();if(r.type===\"keydown\")return y();const x=!o||o===\"PM\"&&u||i!==\"\"&&o===\"AM\"&&d,E=!n||n===\"PM\"&&u||e!==\"\"&&n===\"AM\"&&d,C=()=>{const w=v===360&&l?0:v;if(i){if(o===\"PM\"&&d||x&&t{const w=b===360&&l?0:b;if(e){if(n===\"AM\"&&u||E&&t>w)return}else return!0;return!0};return C()&&A()}_handleKeyboard(){_.on(this._document,qo,\"\",t=>{let e,i,n;const{increment:o,maxTime:r,minTime:a,format12:l,disablePast:c,disableFuture:h}=this._options;let d=j(a,!1)[0],u=j(r,!1)[0];const p=j(a,!1)[2],f=j(r,!1)[2];d=Ke(d,c,l),u=Ue(u,h,l),typeof u!=\"number\"&&(u=j(u,!1)[0]);const b=m.findOne(`[${xt}]`)===null,v=m.findOne(`[${Bt}]`)!==null,y=Number(this._hand.style.transform.replace(/[^\\d-]/g,\"\")),T=m.find(`[${xt}]`,this._modal),x=m.find(`[${_t}]`,this._modal),E=m.find(`[${Bt}]`,this._modal);let C=this._makeHourDegrees(t.target,y,e).hour;const{degrees:A,addDegrees:w}=this._makeHourDegrees(t.target,y,e);let{minute:S,degrees:k}=this._makeMinutesDegrees(y,i);const D=this._makeMinutesDegrees(y,i).addDegrees;let{hour:I}=this._makeInnerHoursDegrees(y,n);if(t.keyCode===xi){const M=m.findOne(`[${Cl}]`,this._modal);_.trigger(M,\"click\")}else if(b){if(v&&(t.keyCode===hs&&(this._isInner=!1,g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),this._hour.textContent=this._setHourOrMinute(C>12?1:C),this._toggleClassActive(this.hoursArray,this._hour,x),this._toggleClassActive(this.innerHours,this._hour,E)),t.keyCode===cs&&(this._isInner=!0,g.addStyle(this._hand,{height:\"21.5%\"}),this._hour.textContent=this._setHourOrMinute(I>=24||I===\"00\"?0:I),this._toggleClassActive(this.innerHours,this._hour,E),this._toggleClassActive(this.hoursArray,this._hour-1,x))),t.keyCode===ut){if(!this._handlerMaxMinHoursOptions(A+30,u,d,f,p,t))return;g.addStyle(this._hand,{transform:`rotateZ(${A+w}deg)`}),this._isInner?(I+=1,I===24?I=0:(I===25||I===\"001\")&&(I=13),this._hour.textContent=this._setHourOrMinute(I),this._toggleClassActive(this.innerHours,this._hour,E)):(C+=1,this._hour.textContent=this._setHourOrMinute(C>12?1:C),this._toggleClassActive(this.hoursArray,this._hour,x))}if(t.keyCode===ht){if(!this._handlerMaxMinHoursOptions(A-30,u,d,f,p,t))return;g.addStyle(this._hand,{transform:`rotateZ(${A-w}deg)`}),this._isInner?(I-=1,I===12?I=0:I===-1&&(I=23),this._hour.textContent=this._setHourOrMinute(I),this._toggleClassActive(this.innerHours,this._hour,E)):(C-=1,this._hour.textContent=this._setHourOrMinute(C===0?12:C),this._toggleClassActive(this.hoursArray,this._hour,x))}}else t.keyCode===ut&&(k+=D,g.addStyle(this._hand,{transform:`rotateZ(${k}deg)`}),S+=1,o&&(S+=4,S===\"0014\"&&(S=5)),this._minutes.textContent=this._setHourOrMinute(S>59?0:S),this._toggleClassActive(this.minutesArray,this._minutes,T),this._toggleBackgroundColorCircle(`[${xt}]`)),t.keyCode===ht&&(k-=D,g.addStyle(this._hand,{transform:`rotateZ(${k}deg)`}),o?S-=5:S-=1,S===-1?S=59:S===-5&&(S=55),this._minutes.textContent=this._setHourOrMinute(S),this._toggleClassActive(this.minutesArray,this._minutes,T),this._toggleBackgroundColorCircle(`[${xt}]`))})}_setActiveClassToTipsOnOpen(t,...e){if(!this._isInvalidTimeFormat)if(this._options.format24){const i=m.find(`[${_t}]`,this._modal),n=m.find(`[${Bt}]`,this._modal);this._addActiveClassToTip(i,t),this._addActiveClassToTip(n,t)}else{[...e].filter(n=>(n.toLowerCase()===\"pm\"?(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\")):n.toLowerCase()===\"am\"?(g.addClass(this._AM,this._classes.opacity),this._AM.setAttribute(J,\"\")):(g.removeClass(this._AM,this._classes.opacity),g.removeClass(this._PM,this._classes.opacity),this._AM.removeAttribute(J),this._PM.removeAttribute(J)),n));const i=m.find(`[${_t}]`,this._modal);this._addActiveClassToTip(i,t)}}_setTipsAndTimesDependOnInputValue(t,e){const{inline:i,format12:n}=this._options;if(this._isInvalidTimeFormat)this._hour.textContent=\"12\",this._minutes.textContent=\"00\",i||g.addStyle(this._hand,{transform:\"rotateZ(0deg)\"}),n&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\"));else{const o=t>12?t*30-360:t*30;this._hour.textContent=t,this._minutes.textContent=e,i||(g.addStyle(this._hand,{transform:`rotateZ(${o}deg)`}),g.addStyle(this._circle,{backgroundColor:\"#1976d2\"}),(Number(t)>12||t===\"00\")&&g.addStyle(this._hand,{height:\"21.5%\"}))}}_listenToToggleKeydown(){_.on(this._element,\"keydown\",`[data-te-toggle='${this.toggleElement}']`,t=>{t.keyCode===Et&&(t.preventDefault(),_.trigger(this.elementToggle,\"click\"))})}_handleOpen(){const t=this._getContainer();ct.on(this._element,\"click\",`[data-te-toggle='${this.toggleElement}']`,e=>{if(this._options===null)return;const i=g.getDataAttribute(this.input,\"toggle\")!==null?200:0;setTimeout(()=>{g.addStyle(this.elementToggle,{pointerEvents:\"none\"}),this.elementToggle.blur();let n;j(this.input)[0]===\"\"?n=[\"12\",\"00\",\"PM\"]:n=j(this.input);const{modalID:o,inline:r,format12:a}=this._options,[l,c,h]=n,d=$(\"div\");if((Number(l)>12||l===\"00\")&&(this._isInner=!0),this.input.blur(),e.target.blur(),d.innerHTML=zT(this._options,this._classes),g.addClass(d,this._classes.modal),d.setAttribute(Nu,\"\"),d.setAttribute(\"role\",\"dialog\"),d.setAttribute(\"tabIndex\",\"-1\"),d.setAttribute(\"id\",o),r?(this._popper=Fe(this.input,d,{placement:\"bottom-start\"}),t.appendChild(d)):(t.appendChild(d),this._scrollBar.hide()),this._getDomElements(),this._animations?this._toggleBackdropAnimation():g.addClass(this._wrapper,this._classes.opacity),this._setActiveClassToTipsOnOpen(l,c,h),this._appendTimes(),this._setActiveClassToTipsOnOpen(l,c,h),this._setTipsAndTimesDependOnInputValue(l,c),this.input.value===\"\"){const u=m.find(`[${_t}]`,this._modal);a&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\")),this._hour.textContent=\"12\",this._minutes.textContent=\"00\",this._addActiveClassToTip(u,Number(this._hour.textContent))}if(this._handleSwitchTimeMode(),this._handleOkButton(),this._handleClose(),r)this._handleHoverInlineBtn(),this._handleDocumentClickInline(),this._handleInlineClicks();else{this._handleSwitchHourMinute(),this._handleClockClick(),this._handleKeyboard();const u=document.querySelector(`${Zo}[${J}]`);g.addClass(u,this._classes.opacity),g.addStyle(this._hour,{pointerEvents:\"none\"}),g.addStyle(this._minutes,{pointerEvents:\"\"})}this._focusTrap=new Vs(this._wrapper,{event:\"keydown\",condition:({key:u})=>u===\"Tab\"}),this._focusTrap.trap()},i)})}_handleInlineClicks(){let t,e;const i=p=>{let f=p;return f>59?f=0:f<0&&(f=59),f},n=p=>{let f=p;return this._options.format24?(f>24?f=1:f<0&&(f=23),f>23&&(f=0)):(f>12?f=1:f<1&&(f=12),f>12&&(f=1)),f},o=p=>{const f=n(p);this._hour.textContent=this._setHourOrMinute(f)},r=p=>{const f=i(p);this._minutes.textContent=this._setHourOrMinute(f)},a=()=>{t=n(t)+1,o(t)},l=()=>{e=i(e)+1,r(e)},c=()=>{t=n(t)-1,o(t)},h=()=>{e=i(e)-1,r(e)},d=()=>{clearInterval(this._interval),clearTimeout(this._timeoutInterval)},u=p=>{d(),this._timeoutInterval=setTimeout(()=>{this._interval=setInterval(p,100)},500)};ct.on(this._modal,\"click mousedown mouseup touchstart touchend contextmenu\",`[${wl}], [${kl}]`,p=>{t=Number(this._hour.textContent),e=Number(this._minutes.textContent);const{target:f,type:b}=p,v=b===\"mousedown\"||b===\"touchstart\";f.closest(`[${wl}]`)?f.closest(`[${wl}]`).parentNode.hasAttribute($u)?v?u(a):b===\"mouseup\"||b===\"touchend\"||b===\"contextmenu\"?d():a():v?u(l):b===\"mouseup\"||b===\"touchend\"||b===\"contextmenu\"?d():l():f.closest(`[${kl}]`)&&(f.closest(`[${kl}]`).parentNode.hasAttribute($u)?v?u(c):b===\"mouseup\"||b===\"touchend\"?d():c():v?u(h):b===\"mouseup\"||b===\"touchend\"?d():h())}),_.on(window,qo,p=>{const f=p.code,b=document.activeElement.hasAttribute(Jo),v=document.activeElement.hasAttribute(Il),y=document.activeElement===document.body;switch(t=Number(this._hour.textContent),e=Number(this._minutes.textContent),f){case\"ArrowUp\":p.preventDefault(),y||b?(this._hour.focus(),a()):v&&l();break;case\"ArrowDown\":p.preventDefault(),y||b?(this._hour.focus(),c()):v&&h();break}})}_handleClose(){_.on(this._modal,\"click\",`[${Ol}], [${Cl}], [${Lu}]`,({target:t})=>{const{closeModalOnBackdropClick:e}=this._options,i=()=>{var n;g.addStyle(this.elementToggle,{pointerEvents:\"auto\"}),this._animations&&this._toggleBackdropAnimation(!0),this._removeModal(),(n=this._focusTrap)==null||n.disable(),this._focusTrap=null,this.elementToggle?this.elementToggle.focus():this.input&&this.input.focus()};if(t.hasAttribute(Lu)){this._toggleAmPm(\"PM\"),this.input.value=\"\",this.input.removeAttribute(J);let n;j(this.input)[0]===\"\"?n=[\"12\",\"00\",\"PM\"]:n=j(this.input);const[o,r,a]=n;this._setTipsAndTimesDependOnInputValue(\"12\",\"00\"),this._setActiveClassToTipsOnOpen(o,r,a),this._hour.click()}else(t.hasAttribute(Cl)||t.hasAttribute(Al)||t.hasAttribute(Ol)&&e)&&i()})}showValueInput(){return this.input.value}_handleOkButton(){ct.on(this._modal,\"click\",`[${Al}]`,()=>{let{maxTime:t,minTime:e}=this._options;const{format12:i,format24:n,readOnly:o,focusInputAfterApprove:r,disablePast:a,disableFuture:l}=this._options,c=this._document.querySelector(`${Qo}[${J}]`),h=`${this._hour.textContent}:${this._minutes.textContent}`,d=Number(this._hour.textContent),u=d===12&&i?0:d,p=Number(this._minutes.textContent);e=Ke(e,a,i),t=Ue(t,l,i);let[f,b,v]=j(t,!1),[y,T,x]=j(e,!1);y=y===\"12\"&&i?\"00\":y,f=f===\"12\"&&i?\"00\":f;const E=uNumber(f);let A=!0;c&&(A=v===c.textContent);let w=!0;c&&(w=x===c.textContent);const S=p>b&&u===Number(f),k=p{const i=m.find(`[${eE}]`,this._modal),n=m.find(`[${iE}]`,this._modal),o=(l,c)=>l.forEach(h=>{if(c){g.addClass(h,this._classes.opacity),h.setAttribute(J,\"\");return}g.removeClass(h,this._classes.opacity),h.removeAttribute(J)}),a=e.hasAttribute(Jo)?i:n;o(a,t===\"mouseover\")})}_handleDocumentClickInline(){_.on(document,Cu,({target:t})=>{if(this._modal&&!this._modal.contains(t)&&!t.hasAttribute(tE)){if(clearInterval(this._interval),g.addStyle(this.elementToggle,{pointerEvents:\"auto\"}),this._removeModal(),!this._animations)return;this._toggleBackdropAnimation(!0)}})}_handleSwitchHourMinute(){YT(\"click\",Zo,this._classes),_.on(this._modal,\"click\",Zo,()=>{const{format24:t}=this._options,e=m.find(Zo,this._modal),i=m.find(`[${xt}]`,this._modal),n=m.find(`[${_t}]`,this._modal),o=m.find(`[${Bt}]`,this._modal),r=Number(this._hour.textContent),a=Number(this._minutes.textContent),l=(c,h)=>{n.forEach(u=>u.remove()),i.forEach(u=>u.remove()),g.addClass(this._hand,this._classes.transform),setTimeout(()=>{g.removeClass(this._hand,this._classes.transform)},401),this._getAppendClock(c,`[${wi}]`,h);const d=()=>{const u=m.find(`[${_t}]`,this._modal),p=m.find(`[${xt}]`,this._modal);this._addActiveClassToTip(u,r),this._addActiveClassToTip(p,a)};if(!t)setTimeout(()=>{d()},401);else{const u=m.find(`[${Bt}]`,this._modal);setTimeout(()=>{this._addActiveClassToTip(u,r),d()},401)}};e.forEach(c=>{c.hasAttribute(J)&&(c.hasAttribute(Il)?(g.addClass(this._hand,this._classes.transform),g.addStyle(this._hand,{transform:`rotateZ(${this._minutes.textContent*6}deg)`,height:\"calc(40% + 1px)\"}),t&&o.length>0&&o.forEach(h=>h.remove()),l(this.minutesArray,xt),this._hour.style.pointerEvents=\"\",this._minutes.style.pointerEvents=\"none\"):c.hasAttribute(Jo)&&(g.addStyle(this._hand,{transform:`rotateZ(${this._hour.textContent*30}deg)`}),Number(this._hour.textContent)>12?(g.addStyle(this._hand,{transform:`rotateZ(${this._hour.textContent*30-360}deg)`,height:\"21.5%\"}),Number(this._hour.textContent)>12&&g.addStyle(this._hand,{height:\"21.5%\"})):g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),t&&this._getAppendClock(this.innerHours,`[${Qs}]`,Bt),o.length>0&&o.forEach(h=>h.remove()),l(this.hoursArray,_t),g.addStyle(this._hour,{pointerEvents:\"none\"}),g.addStyle(this._minutes,{pointerEvents:\"\"})))})})}_handleDisablingTipsMaxTime(t,e,i,n){if(!this._options.maxTime&&!this._options.disableFuture)return;const o=m.find(`[${_t}]`),r=m.find(`[${Bt}]`),a=m.find(`[${xt}]`);if(!e||e===t){yu(r,n,this._classes,this._options.format12),yu(o,n,this._classes,this._options.format12),UT(a,i,n,this._hour.textContent,this._classes,this._options.format12);return}e===\"AM\"&&t===\"PM\"&&(o.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}),a.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}))}_handleDisablingTipsMinTime(t,e,i,n){if(!this._options.minTime&&!this._options.disablePast)return;const o=m.find(`[${_t}]`),r=m.find(`[${Bt}]`),a=m.find(`[${xt}]`);!e||e===t?(Tu(o,n,this._classes,this._options.format12),Tu(r,n,this._classes,this._options.format12),XT(a,i,n,this._hour.textContent,this._classes,this._options.format12)):e===\"PM\"&&t===\"AM\"&&(o.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}),a.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}))}_handleSwitchTimeMode(){_.on(document,\"click\",Qo,({target:t})=>{let{maxTime:e,minTime:i}=this._options;const{disablePast:n,disableFuture:o,format12:r}=this._options;i=Ke(i,n,r),e=Ue(e,o,r);const[a,l,c]=j(e,!1),[h,d,u]=j(i,!1),p=m.find(`[${_t}]`),f=m.find(`[${xt}]`);(()=>{p.forEach(v=>{g.removeClass(v,this._classes.tipsDisabled),v.removeAttribute(Xe)}),f.forEach(v=>{g.removeClass(v,this._classes.tipsDisabled),v.removeAttribute(Xe)})})(),this._handleDisablingTipsMinTime(t.textContent,u,d,h),this._handleDisablingTipsMaxTime(t.textContent,c,l,a),this._toggleAmPm(t.textContent),t.hasAttribute(J)||(m.find(Qo).forEach(y=>{y.hasAttribute(J)&&(g.removeClass(y,this._classes.opacity),y.removeAttribute(J))}),g.addClass(t,this._classes.opacity),t.setAttribute(J,\"\"))})}_handleClockClick(){let{maxTime:t,minTime:e}=this._options;const{disablePast:i,disableFuture:n,format12:o}=this._options;e=Ke(e,i,o),t=Ue(t,n,o);const r=j(t,!1)[2],a=j(e,!1)[2],l=j(t,!1)[0],c=j(e,!1)[0],h=m.findOne(`[${Ru}]`);ct.on(document,`${Au} ${wu} ${ku} ${Su} ${Ou} ${Mu} ${Iu} ${Du}`,\"\",d=>{Xo()||d.preventDefault();const{type:u,target:p}=d,{closeModalOnMinutesClick:f,switchHoursToMinutesOnClick:b}=this._options,v=m.findOne(`[${xt}]`,this._modal)!==null,y=m.findOne(`[${_t}]`,this._modal)!==null,T=m.findOne(`[${Bt}]`,this._modal)!==null,x=m.find(`[${xt}]`,this._modal),E=mu(d,h),C=h.offsetWidth/2;let A=Math.atan2(E.y-C,E.x-C);if(Xo()){const D=mu(d,h,!0);A=Math.atan2(D.y-C,D.x-C)}let w=null,S=null,k=null;if(u===\"mousedown\"||u===\"mousemove\"||u===\"touchmove\"||u===\"touchstart\")(u===\"mousedown\"||u===\"touchstart\"||u===\"touchmove\")&&(this._hasTargetInnerClass(p)||p.hasAttribute(Ru)||p.hasAttribute(wi)||p.hasAttribute(xt)||p.hasAttribute(_t)||p.hasAttribute(Ml)||p.hasAttribute(Dl)||p.hasAttribute(Pu)||p.hasAttribute(tr))&&(this._isMouseMove=!0,Xo()&&d.touches&&(w=d.touches[0].clientX,S=d.touches[0].clientY,k=document.elementFromPoint(w,S)));else if(u===\"mouseup\"||u===\"touchend\"){if(this._isMouseMove=!1,this._hasTargetInnerClass(p)||p.hasAttribute(wi)||p.hasAttribute(_t)||p.hasAttribute(Ml)||p.hasAttribute(Dl)||p.hasAttribute(Pu)||p.hasAttribute(tr)){if((y||T)&&b){const D=Number(this._hour.textContent)>l||Number(this._hour.textContent)R>=10||R===\"00\"?R:`0${R}`;this._minutes.textContent=z(),this._toggleClassActive(this.minutesArray,this._minutes,x),this._toggleBackgroundColorCircle(`[${xt}]`),this._objWithDataOnChange.degreesMinutes=X,this._objWithDataOnChange.minutes=R}}if(y||T){let D,I=Math.trunc(A*180/Math.PI)+90;if(I=Math.round(I/30)*30,g.addStyle(this._circle,{backgroundColor:\"#1976d2\"}),this._makeHourDegrees(p,I,D)===void 0)return;const M=()=>{if(Xo()&&I&&k){const{degrees:P,hour:X}=this._makeHourDegrees(k,I,D);return this._handleMoveHand(k,X,P)}else{const{degrees:P,hour:X}=this._makeHourDegrees(p,I,D);return this._handleMoveHand(p,X,P)}};this._objWithDataOnChange.degreesHours=I,this._handlerMaxMinHoursOptions(I,l,c,r,a,d)&&M()}d.stopPropagation()})}_hasTargetInnerClass(t){return t.hasAttribute(Qs)||t.hasAttribute(Bt)||t.hasAttribute(er)}_handleMoveHand(t,e,i){const n=m.find(`[${_t}]`,this._modal),o=m.find(`[${Bt}]`,this._modal);this._isMouseMove&&(this._hasTargetInnerClass(t)?g.addStyle(this._hand,{height:\"21.5%\"}):g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),g.addStyle(this._hand,{transform:`rotateZ(${i}deg)`}),this._hour.textContent=e>=10||e===\"00\"?e:`0${e}`,this._toggleClassActive(this.hoursArray,this._hour,n),this._toggleClassActive(this.innerHours,this._hour,o),this._objWithDataOnChange.hour=e>=10||e===\"00\"?e:`0${e}`)}_handlerMaxMinMinutesOptions(t,e){let{maxTime:i,minTime:n}=this._options;const{format12:o,increment:r,disablePast:a,disableFuture:l}=this._options;n=Ke(n,a,o),i=Ue(i,l,o);const c=j(i,!1)[1],h=j(n,!1)[1],d=j(i,!1)[0],u=j(n,!1)[0],p=u===\"12\"&&o?\"0\":u,f=d===\"12\"&&o?\"0\":d,b=j(i,!1)[2],v=j(n,!1)[2],y=c!==\"\"?c*6:\"\",T=h!==\"\"?h*6:\"\",x=Number(this._hour.textContent),E=x===12&&o?0:x;if(!b&&!v){if(i!==\"\"&&n!==\"\"){if(Number(f)===E&&t>y||Number(p)===E&&t=Number(f)&&t>=y+6)return t}else{if(n!==\"\"){if(v===\"PM\"&&this._isAmEnabled)return;if(v===\"PM\"&&this._isPmEnabled){if(E=Number(f)&&t>=y+6)return t}else if(b===\"AM\"&&this._isAmEnabled&&E>=Number(f)&&t>=y+6)return t}}return r&&(t=Math.round(t/30)*30),t<0?t=360+t:t>=360&&(t=0),{degrees:t,minute:e}}_removeModal(){this._animations?setTimeout(()=>{this._removeModalElements(),this._scrollBar.reset()},300):(this._removeModalElements(),this._scrollBar.reset()),ct.off(this._document,`${Cu} ${qo} ${Au} ${wu} ${ku} ${Su} ${Ou} ${Mu} ${Iu} ${Du}`),_.off(window,qo)}_removeModalElements(){this._modal&&this._modal.remove()}_toggleBackdropAnimation(t=!1){t?this._wrapper.classList.add(\"animate-[fade-out_350ms_ease-in-out]\"):(this._wrapper.classList.add(\"animate-[fade-in_350ms_ease-in-out]\"),this._options.inline||g.addClass(this._clock,this._classes.clockAnimation)),setTimeout(()=>{this._wrapper.classList.remove(\"animate-[fade-out_350ms_ease-in-out]\",\"animate-[fade-in_350ms_ease-in-out]\")},351)}_addActiveClassToTip(t,e){t.forEach(i=>{Number(i.textContent)===Number(e)&&(g.addClass(i,this._classes.tipsActive),i.setAttribute(J,\"\"))})}_setHourOrMinute(t){return t<10?`0${t}`:t}_appendTimes(){const{format24:t}=this._options;if(t){this._getAppendClock(this.hoursArray,`[${wi}]`,_t),this._getAppendClock(this.innerHours,`[${Qs}]`,Bt);return}this._getAppendClock(this.hoursArray,`[${wi}]`,_t)}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...aE,...e,...t},L(Zs,t,lE),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cE,...e,...t},L(Zs,t,hE),t}_getContainer(){return m.findOne(this._options.container)}_getValidate(t){const{format24:e,format12:i,appendValidationInfo:n}=this._options;ct.on(this.input,t,({target:o})=>{if(this._options===null||this.input.value===\"\")return;const r=/^(0?[1-9]|1[012])(:[0-5]\\d) [APap][mM]$/,a=/^([01]\\d|2[0-3])(:[0-5]\\d)$/,l=r.test(o.value);if(a.test(o.value)!==!0&&e||l!==!0&&i){n&&this.input.setAttribute(Sl,\"\"),g.addStyle(o,{marginBottom:0}),this._isInvalidTimeFormat=!0;return}this.input.removeAttribute(Sl),this._isInvalidTimeFormat=!1;const h=m.findOne(`[${oE}]`);h!==null&&h.remove()})}static getInstance(t){return O.getData(t,Go)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const dE={threshold:10,direction:\"all\"};let uE=class{constructor(t,e){this._element=t,this._startPosition=null,this._options={...dE,...e}}handleTouchStart(t){this._startPosition=this._getCoordinates(t)}handleTouchMove(t){if(!this._startPosition)return;const e=this._getCoordinates(t),i={x:e.x-this._startPosition.x,y:e.y-this._startPosition.y},n=this._getDirection(i);if(this._options.direction===\"all\"){if(n.y.valuen.x.value?n.y.direction:n.x.direction;_.trigger(this._element,`swipe${r}`),_.trigger(this._element,\"swipe\",{direction:r}),this._startPosition=null;return}const o=this._options.direction===\"left\"||this._options===\"right\"?\"x\":\"y\";n[o].direction===this._options.direction&&n[o].value>this._options.threshold&&(_.trigger(this._element,`swipe${n[o].direction}`),this._startPosition=null)}handleTouchEnd(){this._startPosition=null}_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection(t){return{x:{direction:t.x<0?\"left\":\"right\",value:Math.abs(t.x)},y:{direction:t.y<0?\"up\":\"down\",value:Math.abs(t.y)}}}},pE=class{constructor(t,e=\"swipe\",i={}){this._element=t,this._event=e,this.swipe=new uE(t,i),this._touchStartHandler=this._handleTouchStart.bind(this),this._touchMoveHandler=this._handleTouchMove.bind(this),this._touchEndHandler=this._handleTouchEnd.bind(this)}dispose(){this._element.removeEventListener(\"touchstart\",this._touchStartHandler),this._element.removeEventListener(\"touchmove\",this._touchMoveHandler),window.removeEventListener(\"touchend\",this._touchEndHandler)}init(){this._element.addEventListener(\"touchstart\",t=>this._handleTouchStart(t)),this._element.addEventListener(\"touchmove\",t=>this._handleTouchMove(t)),window.addEventListener(\"touchend\",t=>this._handleTouchEnd(t))}_handleTouchStart(t){this[this._event].handleTouchStart(t)}_handleTouchMove(t){this[this._event].handleTouchMove(t)}_handleTouchEnd(t){this[this._event].handleTouchEnd(t)}};const $l=\"stepper\",ir=\"te.stepper\",ds=`.${ir}`,Js=`data-te-${$l}`,tn=\"horizontal\",ge=\"vertical\",fE=`onChangeStep${ds}`,_E=`onChangedStep${ds}`,gE={stepperType:\"string\",stepperLinear:\"boolean\",stepperNoEditable:\"boolean\",stepperActive:\"string\",stepperCompleted:\"string\",stepperInvalid:\"string\",stepperDisabled:\"string\",stepperVerticalBreakpoint:\"number\",stepperMobileBreakpoint:\"number\",stepperMobileBarBreakpoint:\"number\",stepperAnimationDuration:\"number\",slideInLeftAnimation:\"string\",slideOutLeftAnimation:\"string\",slideInRightAnimation:\"string\",slideOutRightAnimation:\"string\"},mE={stepperType:tn,stepperLinear:!1,stepperNoEditable:!1,stepperActive:\"\",stepperCompleted:\"\",stepperInvalid:\"\",stepperDisabled:\"\",stepperVerticalBreakpoint:0,stepperMobileBreakpoint:0,stepperMobileBarBreakpoint:4,stepperAnimationDuration:800,slideInLeftAnimation:\"animate-[slide-in-left_0.8s_both]\",slideOutLeftAnimation:\"animate-[slide-out-left_0.8s_both]\",slideInRightAnimation:\"animate-[slide-in-right_0.8s_both]\",slideOutRightAnimation:\"animate-[slide-out-right_0.8s_both]\"},Bu=`mousedown${ds}`,Hu=`keydown${ds}`,bE=`keyup${ds}`,Vu=`resize${ds}`,Ge=`[${Js}-step-ref]`,Ct=`[${Js}-head-ref]`,Fu=`[${Js}-head-text-ref]`,sr=`[${Js}-head-icon-ref]`,At=`[${Js}-content-ref]`;class Wu{constructor(t,e){this._element=t,this._options=this._getConfig(e),this._elementHeight=0,this._steps=m.find(`${Ge}`,this._element),this._currentView=\"\",this._activeStepIndex=0,this._verticalStepperStyles=[],this._timeout=0,this._element&&(O.setData(t,ir,this),this._init())}static get NAME(){return $l}get activeStep(){return this._steps[this._activeStepIndex]}get activeStepIndex(){return this._activeStepIndex}dispose(){this._steps.forEach(t=>{_.off(t,Bu),_.off(t,Hu)}),_.off(window,Vu),O.removeData(this._element,ir),this._element=null}changeStep(t){this._toggleStep(t)}nextStep(){this._toggleStep(this._activeStepIndex+1)}previousStep(){this._toggleStep(this._activeStepIndex-1)}_init(){const t=m.find(`${Ge}`,this._element)[this._activeStepIndex].setAttribute(\"data-te\",\"active-step\"),e=m.find(`${Fu}`,this._element),i=m.find(`${sr}`,this._element);switch(t?(this._activeStepIndex=this._steps.indexOf(t),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperActive),e[this._activeStepIndex].classList.add(\"font-medium\"),i[this._activeStepIndex].classList.add(\"!bg-primary-100\"),i[this._activeStepIndex].classList.add(\"!text-primary-700\")):(e[this._activeStepIndex].classList.add(\"font-medium\"),i[this._activeStepIndex].classList.add(\"!bg-primary-100\"),i[this._activeStepIndex].classList.add(\"!text-primary-700\"),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperActive)),this._bindMouseDown(),this._bindKeysNavigation(),this._options.stepperType){case ge:this._toggleVertical();break;default:this._toggleHorizontal();break}(this._options.stepperVerticalBreakpoint||this._options.stepperMobileBreakpoint)&&this._toggleStepperView(),this._bindResize()}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...mE,...e,...t},L($l,t,gE),t}_bindMouseDown(){this._steps.forEach(t=>{const e=m.findOne(`${Ct}`,t);_.on(e,Bu,i=>{const n=m.parents(i.target,`${Ge}`)[0],o=this._steps.indexOf(n);i.preventDefault(),this._toggleStep(o)})})}_bindResize(){_.on(window,Vu,()=>{this._currentView===ge&&this._setSingleStepHeight(this.activeStep),this._currentView===tn&&this._setHeight(this.activeStep),(this._options.stepperVerticalBreakpoint||this._options.stepperMobileBreakpoint)&&this._toggleStepperView()})}_toggleStepperView(){const t=this._options.stepperVerticalBreakpointwindow.innerWidth,i=this._options.stepperMobileBreakpoint>window.innerWidth;t&&this._currentView!==tn&&this._toggleHorizontal(),e&&!i&&this._currentView!==ge&&(this._steps.forEach(n=>{const o=m.findOne(`${At}`,n);this._resetStepperHeight(),this._showElement(o)}),this._toggleVertical())}_toggleStep(t){if(this._activeStepIndex===t)return;this._options.stepperNoEditable&&this._toggleDisabled();const e=this._activeStepIndex,i=_.trigger(this.activeStep,fE,{currentStep:this._activeStepIndex,nextStep:t});t>this._activeStepIndex&&i.defaultPrevented||(this._showElement(m.findOne(`${At}`,this._steps[t])),this._toggleActive(t),t>this._activeStepIndex&&this._toggleCompleted(this._activeStepIndex),this._currentView===tn?this._animateHorizontalStep(t):(this._animateVerticalStep(t),this._setSingleStepHeight(this._steps[t])),this._toggleStepTabIndex(m.findOne(`${Ct}`,this.activeStep),m.findOne(`${Ct}`,this._steps[t])),this._activeStepIndex=t,this._steps[this._activeStepIndex].setAttribute(\"data-te\",\"active-step\"),this._steps.forEach((n,o)=>{n[this._activeStepIndex]!==o&&n.removeAttribute(\"data-te\")}),_.trigger(this.activeStep,_E,{currentStep:this._activeStepIndex,prevStep:e}))}_resetStepperHeight(){this._element.style.height=\"\"}_setStepsHeight(){this._steps.forEach(t=>{const e=m.findOne(`${At}`,t),i=window.getComputedStyle(e);this._verticalStepperStyles.push({paddingTop:parseFloat(i.paddingTop),paddingBottom:parseFloat(i.paddingBottom)});const n=e.scrollHeight;e.style.height=`${n}px`})}_setSingleStepHeight(t){const e=m.findOne(`${At}`,t),i=this.activeStep===t,n=this._steps.indexOf(t);let o;i?(e.style.height=\"\",o=e.scrollHeight):o=e.scrollHeight+this._verticalStepperStyles[n].paddingTop+this._verticalStepperStyles[n].paddingBottom,e.style.height=`${o}px`}_toggleVertical(){this._currentView=ge,this._setStepsHeight(),this._hideInactiveSteps()}_toggleHorizontal(){this._currentView=tn,this._setHeight(this.activeStep),this._hideInactiveSteps()}_toggleStepperClass(){m.findOne(\"[data-te-stepper-type]\",this._element)!==null&&this._steps.forEach(e=>{m.findOne(`${At}`,e).classList.remove(\"!my-0\"),m.findOne(`${At}`,e).classList.remove(\"!py-0\"),m.findOne(`${At}`,e).classList.remove(\"!h-0\")})}_toggleStepClass(t,e,i){i&&this._steps[t].classList[e](i)}_bindKeysNavigation(){this._toggleStepTabIndex(!1,m.findOne(`${Ct}`,this.activeStep)),this._steps.forEach(t=>{const e=m.findOne(`${Ct}`,t);_.on(e,Hu,i=>{const n=m.parents(i.currentTarget,`${Ge}`)[0],o=m.next(n,`${Ge}`)[0],r=m.prev(n,`${Ge}`)[0],a=m.findOne(`${Ct}`,n),l=m.findOne(`${Ct}`,this.activeStep);let c=null,h=null;if(o&&(c=m.findOne(`${Ct}`,o)),r&&(h=m.findOne(`${Ct}`,r)),i.keyCode===cs&&this._currentView!==ge&&(h?(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus()):c&&(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus())),i.keyCode===hs&&this._currentView!==ge&&(c?(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus()):h&&(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus())),i.keyCode===ht&&this._currentView===ge&&(i.preventDefault(),c&&(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus())),i.keyCode===ut&&this._currentView===ge&&(i.preventDefault(),h&&(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus())),i.keyCode===Ti){const d=m.findOne(`${Ct}`,this._steps[0]);this._toggleStepTabIndex(a,d),this._toggleOutlineStyles(a,d),d.focus()}if(i.keyCode===Ei){const d=this._steps[this._steps.length-1],u=m.findOne(`${Ct}`,d);this._toggleStepTabIndex(a,u),this._toggleOutlineStyles(a,u),u.focus()}(i.keyCode===Et||i.keyCode===Ho)&&(i.preventDefault(),this.changeStep(this._steps.indexOf(n))),i.keyCode===Ci&&(this._toggleStepTabIndex(a,l),this._toggleOutlineStyles(a,!1),l.focus())}),_.on(e,bE,i=>{const n=m.parents(i.currentTarget,`${Ge}`)[0],o=m.findOne(`${Ct}`,n),r=m.findOne(`${Ct}`,this.activeStep);i.keyCode===Ci&&(this._toggleStepTabIndex(o,r),this._toggleOutlineStyles(!1,r),r.focus())})})}_toggleStepTabIndex(t,e){t&&t.setAttribute(\"tabIndex\",-1),e&&e.setAttribute(\"tabIndex\",0)}_toggleOutlineStyles(t,e){t&&(t.style.outline=\"\"),e&&(e.style.outline=\"revert\")}_toggleDisabled(){const t=m.find(`${Ct}`,this._element),e=m.find(`${sr}`,this._element);t[this._activeStepIndex].classList.add(\"color-[#858585]\"),t[this._activeStepIndex].classList.add(\"cursor-default\"),e[this._activeStepIndex].classList.add(\"!bg-[#858585]\"),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperDisabled)}_toggleActive(t){const e=m.find(`${Fu}`,this._element),i=m.find(`${sr}`,this._element);e[t].classList.add(\"font-medium\"),i[t].classList.add(\"!bg-primary-100\"),i[t].classList.add(\"!text-primary-700\"),i[t].classList.remove(\"!bg-success-100\"),i[t].classList.remove(\"!text-success-700\"),e[this._activeStepIndex].classList.remove(\"font-medium\"),i[this._activeStepIndex].classList.remove(\"!bg-primary-100\"),i[this._activeStepIndex].classList.remove(\"!text-primary-700\"),this._toggleStepClass(t,\"add\",this._options.stepperActive),this._toggleStepClass(this._activeStepIndex,\"remove\",this._options.stepperActive)}_toggleCompleted(t){const e=m.find(`${sr}`,this._element);this._options.stepperNoEditable?this._steps[t].classList.add(\"pointer-events-none\"):(e[t].classList.add(\"!bg-success-100\"),e[t].classList.add(\"!text-success-700\")),e[t].classList.remove(\"!bg-danger-100\"),e[t].classList.remove(\"!text-danger-700\"),this._toggleStepClass(t,\"add\",this._options.stepperCompleted),this._toggleStepClass(t,\"remove\",this._options.stepperInvalid)}_hideInactiveSteps(){this._steps.forEach(t=>{if(!t.getAttribute(\"data-te\")){const e=m.findOne(`${At}`,t);e.classList.remove(\"translate-x-[150%]\"),this._hideElement(e)}})}_setHeight(t){const e=m.findOne(`${At}`,t),i=getComputedStyle(e),n=m.findOne(`${Ct}`,t),o=getComputedStyle(n),r=e.offsetHeight+parseFloat(i.marginTop)+parseFloat(i.marginBottom),a=n.offsetHeight+parseFloat(o.marginTop)+parseFloat(o.marginBottom);this._element.style.height=`${a+r}px`}_hideElement(t){!m.parents(t,`${Ge}`)[0].getAttribute(\"data-te\")&&this._currentView!==ge?t.style.display=\"none\":(t.classList.add(\"!my-0\"),t.classList.add(\"!py-0\"),t.classList.add(\"!h-0\"))}_showElement(t){this._currentView===ge?(t.classList.remove(\"!my-0\"),t.classList.remove(\"!py-0\"),t.classList.remove(\"!h-0\")):t.style.display=\"block\"}_animateHorizontalStep(t){clearTimeout(this._timeout),this._clearStepsAnimation();const e=t>this._activeStepIndex,i=m.findOne(`${At}`,this._steps[t]),n=m.findOne(`${At}`,this.activeStep);let o,r;this._steps.forEach((a,l)=>{const c=m.findOne(`${At}`,a);l!==t&&l!==this._activeStepIndex&&this._hideElement(c)}),e?(r=this._options.slideOutLeftAnimation,o=this._options.slideInRightAnimation):(r=this._options.slideOutRightAnimation,o=this._options.slideInLeftAnimation),n.classList.add(r),i.classList.add(o),this._setHeight(this._steps[t]),this._timeout=setTimeout(()=>{this._hideElement(n),this._clearStepsAnimation()},this._options.stepperAnimationDuration)}_clearStepsAnimation(){this._steps.forEach(t=>{m.findOne(`${At}`,t).classList.remove(this._options.slideInLeftAnimation,this._options.slideOutLeftAnimation,this._options.slideInRightAnimation,this._options.slideOutRightAnimation)})}_animateVerticalStep(t){const e=m.findOne(`${At}`,this._steps[t]),i=m.findOne(`${At}`,this.activeStep);this._hideElement(i),this._showElement(e)}static getInstance(t){return O.getData(t,ir)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zu=\"data-te-input-state-active\",nr=\"data-te-input-selected\",ju=\"data-te-input-multiple-active\",Yu=\"[data-te-form-check-input]\";class Ku{constructor(t,e,i,n,o,r,a,l,c,h,d){this.id=t,this.nativeOption=e,this.multiple=i,this.value=n,this.label=o,this.selected=r,this.disabled=a,this.hidden=l,this.secondaryText=c,this.groupId=h,this.icon=d,this.node=null,this.active=!1}select(){this.multiple?this._selectMultiple():this._selectSingle()}_selectSingle(){this.selected||(this.node.setAttribute(nr,\"\"),this.node.setAttribute(\"aria-selected\",!0),this.selected=!0,this.nativeOption&&(this.nativeOption.selected=!0))}_selectMultiple(){if(!this.selected){const t=m.findOne(Yu,this.node);t.checked=!0,this.node.setAttribute(nr,\"\"),this.node.setAttribute(\"aria-selected\",!0),this.selected=!0,this.nativeOption&&(this.nativeOption.selected=!0)}}deselect(){this.multiple?this._deselectMultiple():this._deselectSingle()}_deselectSingle(){this.selected&&(this.node.removeAttribute(nr),this.node.setAttribute(\"aria-selected\",!1),this.selected=!1,this.nativeOption&&(this.nativeOption.selected=!1))}_deselectMultiple(){if(this.selected){const t=m.findOne(Yu,this.node);t.checked=!1,this.node.removeAttribute(nr),this.node.setAttribute(\"aria-selected\",!1),this.selected=!1,this.nativeOption&&(this.nativeOption.selected=!1)}}setNode(t){this.node=t}setActiveStyles(){if(!this.active){if(this.multiple){this.node.setAttribute(ju,\"\");return}this.active=!0,this.node.setAttribute(zu,\"\")}}removeActiveStyles(){this.active&&(this.active=!1,this.node.removeAttribute(zu)),this.multiple&&this.node.removeAttribute(ju)}}class vE{constructor(t=!1){this._multiple=t,this._selections=[]}select(t){this._multiple?this._selections.push(t):this._selections=[t]}deselect(t){if(this._multiple){const e=this._selections.findIndex(i=>t===i);this._selections.splice(e,1)}else this._selections=[]}clear(){this._selections=[]}get selection(){return this._selections[0]}get selections(){return this._selections}get label(){return this._selections[0]&&this.selection.label}get labels(){return this._selections.map(t=>t.label).join(\", \")}get value(){return this.selections[0]&&this.selection.value}get values(){return this._selections.map(t=>t.value)}}function Rl(s){return s.filter(t=>!t.disabled).every(t=>t.selected)}const yE=\"data-te-select-form-outline-ref\",TE=\"data-te-select-wrapper-ref\",EE=\"data-te-select-input-ref\",xE=\"data-te-select-clear-btn-ref\",CE=\"data-te-select-dropdown-container-ref\",AE=\"data-te-select-dropdown-ref\",wE=\"data-te-select-options-wrapper-ref\",kE=\"data-te-select-options-list-ref\",SE=\"data-te-select-input-filter-ref\",Uu=\"data-te-select-option-ref\",OE=\"data-te-select-option-all-ref\",IE=\"data-te-select-option-text-ref\",DE=\"data-te-form-check-input\",ME=\"data-te-select-option-group-ref\",LE=\"data-te-select-option-group-label-ref\",Xu=\"data-te-select-selected\",$E=`\n\n \n\n`,RE=s=>{s.code===\"Tab\"||s.code===\"Esc\"||s.preventDefault()};function or(s,t,e,i,n){t.selectSize===\"default\"&&g.addClass(s,e),t.selectSize===\"sm\"&&g.addClass(s,i),t.selectSize===\"lg\"&&g.addClass(s,n)}function PE(s,t,e,i,n){const o=document.createElement(\"div\");o.setAttribute(\"id\",s),o.setAttribute(TE,\"\");const r=$(\"div\");r.setAttribute(yE,\"\"),g.addClass(r,i.formOutline);const a=$(\"input\"),l=t.selectFilter?\"combobox\":\"listbox\",c=t.multiple?\"true\":\"false\",h=t.disabled?\"true\":\"false\";a.setAttribute(EE,\"\"),g.addClass(a,i.selectInput),or(a,t,i.selectInputSizeDefault,i.selectInputSizeSm,i.selectInputSizeLg),t.selectFormWhite&&g.addClass(a,i.selectInputWhite),a.setAttribute(\"type\",\"text\"),a.setAttribute(\"role\",l),a.setAttribute(\"aria-multiselectable\",c),a.setAttribute(\"aria-disabled\",h),a.setAttribute(\"aria-haspopup\",\"true\"),a.setAttribute(\"aria-expanded\",!1),t.tabIndex&&a.setAttribute(\"tabIndex\",t.tabIndex),t.disabled&&a.setAttribute(\"disabled\",\"\"),t.selectPlaceholder!==\"\"&&a.setAttribute(\"placeholder\",t.selectPlaceholder),t.selectValidation?(g.addStyle(a,{\"pointer-events\":\"none\",\"caret-color\":\"transparent\"}),g.addStyle(r,{cursor:\"pointer\"})):a.setAttribute(\"readonly\",\"true\"),t.selectValidation&&(a.setAttribute(\"required\",\"true\"),a.setAttribute(\"aria-required\",\"true\"),a.addEventListener(\"keydown\",RE));const d=$(\"div\");g.addClass(d,i.selectValidationValid);const u=document.createTextNode(`${t.selectValidFeedback}`);d.appendChild(u);const p=$(\"div\");g.addClass(p,i.selectValidationInvalid);const f=document.createTextNode(`${t.selectInvalidFeedback}`);p.appendChild(f);const b=$(\"span\");b.setAttribute(xE,\"\"),g.addClass(b,i.selectClearBtn),or(b,t,i.selectClearBtnDefault,i.selectClearBtnSm,i.selectClearBtnLg),t.selectFormWhite&&g.addClass(b,i.selectClearBtnWhite);const v=document.createTextNode(\"✕\");b.appendChild(v),b.setAttribute(\"tabindex\",\"0\");const y=$(\"span\");return g.addClass(y,i.selectArrow),or(y,t,i.selectArrowDefault,i.selectArrowSm,i.selectArrowLg),t.selectFormWhite&&g.addClass(y,i.selectArrowWhite),y.innerHTML=n||$E,r.appendChild(a),e&&(g.addClass(e,i.selectLabel),or(e,t,i.selectLabelSizeDefault,i.selectLabelSizeSm,i.selectLabelSizeLg),t.selectFormWhite&&g.addClass(e,i.selectLabelWhite),r.appendChild(e)),t.selectValidation&&(r.appendChild(d),r.appendChild(p)),t.selectClearButton&&r.appendChild(b),r.appendChild(y),o.appendChild(r),o}function Gu(s,t,e,i,n,o,r,a){const l=document.createElement(\"div\");l.setAttribute(CE,\"\"),g.addClass(l,a.selectDropdownContainer),l.setAttribute(\"id\",`${s}`),l.style.width=`${e}px`;const c=document.createElement(\"div\");c.setAttribute(\"tabindex\",0),c.setAttribute(AE,\"\"),g.addClass(c,a.dropdown);const h=$(\"div\");h.setAttribute(wE,\"\"),g.addClass(h,a.optionsWrapper),g.addClass(h,a.optionsWrapperScrollbar),h.style.maxHeight=`${i}px`;const d=qu(o,n,t,a);return h.appendChild(d),t.selectFilter&&c.appendChild(NE(t.selectSearchPlaceholder,a)),c.appendChild(h),r&&c.appendChild(r),l.appendChild(c),l}function qu(s,t,e,i){const n=$(\"div\");n.setAttribute(kE,\"\"),g.addClass(n,i.optionsList);let o;return e.multiple?o=HE(s,t,e,i):o=BE(s,e,i),o.forEach(r=>{n.appendChild(r)}),n}function NE(s,t){const e=$(\"div\");g.addClass(e,t.inputGroup);const i=$(\"input\");return i.setAttribute(SE,\"\"),g.addClass(i,t.selectFilterInput),i.placeholder=s,i.setAttribute(\"role\",\"searchbox\"),i.setAttribute(\"type\",\"text\"),e.appendChild(i),e}function BE(s,t,e){return Zu(s,t,e)}function HE(s,t,e,i){let n=null;e.selectAll&&(n=VE(t,s,e,i));const o=Zu(s,e,i);return n?[n,...o]:o}function Zu(s,t,e){const i=[];return s.forEach(n=>{if(Object.prototype.hasOwnProperty.call(n,\"options\")){const r=jE(n,t,e);i.push(r)}else i.push(Qu(n,t,e))}),i}function VE(s,t,e,i){const n=Rl(t),o=$(\"div\");o.setAttribute(Uu,\"\");const r=i.selectAllOption||i.selectOption;return g.addClass(o,r),o.setAttribute(OE,\"\"),g.addStyle(o,{height:`${e.selectOptionHeight}px`}),o.setAttribute(\"role\",\"option\"),o.setAttribute(\"aria-selected\",n),n&&o.setAttribute(Xu,\"\"),o.appendChild(Ju(s,e,i)),s.setNode(o),o}function Qu(s,t,e){if(s.node)return s.node;const i=$(\"div\");return i.setAttribute(Uu,\"\"),g.addClass(i,e.selectOption),g.addStyle(i,{height:`${t.selectOptionHeight}px`}),g.setDataAttribute(i,\"id\",s.id),i.setAttribute(\"role\",\"option\"),i.setAttribute(\"aria-selected\",s.selected),i.setAttribute(\"aria-disabled\",s.disabled),s.selected&&i.setAttribute(Xu,\"\"),s.disabled&&i.setAttribute(\"data-te-select-option-disabled\",!0),s.hidden&&g.addClass(i,\"hidden\"),i.appendChild(Ju(s,t,e)),s.icon&&i.appendChild(zE(s,e)),s.setNode(i),i}function Ju(s,t,e){const i=$(\"span\");i.setAttribute(IE,\"\"),g.addClass(i,e.selectOptionText);const n=document.createTextNode(s.label);return t.multiple&&i.appendChild(WE(s,e)),i.appendChild(n),(s.secondaryText||typeof s.secondaryText==\"number\")&&i.appendChild(FE(s.secondaryText,e)),i}function FE(s,t){const e=$(\"span\");g.addClass(e,t.selectOptionSecondaryText);const i=document.createTextNode(s);return e.appendChild(i),e}function WE(s,t){const e=$(\"input\");e.setAttribute(\"type\",\"checkbox\"),g.addClass(e,t.formCheckInput),e.setAttribute(DE,\"\");const i=$(\"label\");return s.selected&&e.setAttribute(\"checked\",!0),s.disabled&&e.setAttribute(\"disabled\",!0),e.appendChild(i),e}function zE(s,t){const e=$(\"span\"),i=$(\"img\");return g.addClass(i,t.selectOptionIcon),i.src=s.icon,e.appendChild(i),e}function jE(s,t,e){const i=$(\"div\");i.setAttribute(ME,\"\"),g.addClass(i,e.selectOptionGroup),i.setAttribute(\"role\",\"group\"),i.setAttribute(\"id\",s.id),s.hidden&&g.addClass(i,\"hidden\");const n=$(\"label\");return n.setAttribute(LE,\"\"),g.addClass(n,e.selectOptionGroupLabel),g.addStyle(n,{height:`${t.selectOptionHeight}px`}),n.setAttribute(\"for\",s.id),n.textContent=s.label,i.appendChild(n),s.options.forEach(o=>{i.appendChild(Qu(o,t,e))}),i}function YE(s,t){const e=$(\"div\");return e.textContent=s,g.addClass(e,t.selectLabel),g.addClass(e,t.selectFakeValue),e}const Pl=\"select\",en=\"te.select\",sn=`.${en}`,KE=`close${sn}`,UE=`open${sn}`,tp=`optionSelect${sn}`,ep=`optionDeselect${sn}`,XE=`valueChange${sn}`,GE=\"change\",ip=\"data-te-select-init\",sp=\"data-te-select-no-results-ref\",np=\"data-te-select-open\",wt=\"data-te-input-state-active\",qe=\"data-te-input-focused\",Nl=\"data-te-input-disabled\",qE=\"data-te-select-option-group-label-ref\",ZE=\"data-te-select-option-all-ref\",nn=\"data-te-select-selected\",QE=\"[data-te-select-label-ref]\",op=\"[data-te-select-input-ref]\",JE=\"[data-te-select-input-filter-ref]\",tx=\"[data-te-select-dropdown-ref]\",ex=\"[data-te-select-options-wrapper-ref]\",rp=\"[data-te-select-options-list-ref]\",ix=\"[data-te-select-option-ref]\",sx=\"[data-te-select-clear-btn-ref]\",nx=\"[data-te-select-custom-content-ref]\",ox=`[${sp}]`,ap=\"[data-te-select-form-outline-ref]\",rx=\"[data-te-select-toggle]\",Bl=\"[data-te-input-notch-ref]\",ax={selectAutoSelect:!1,selectContainer:\"body\",selectClearButton:!1,disabled:!1,selectDisplayedLabels:5,selectFormWhite:!1,multiple:!1,selectOptionsSelectedLabel:\"options selected\",selectOptionHeight:38,selectAll:!0,selectAllLabel:\"Select all\",selectSearchPlaceholder:\"Search...\",selectSize:\"default\",selectVisibleOptions:5,selectFilter:!1,selectFilterDebounce:300,selectNoResultText:\"No results\",selectValidation:!1,selectValidFeedback:\"Valid\",selectInvalidFeedback:\"Invalid\",selectPlaceholder:\"\"},lx={selectAutoSelect:\"boolean\",selectContainer:\"string\",selectClearButton:\"boolean\",disabled:\"boolean\",selectDisplayedLabels:\"number\",selectFormWhite:\"boolean\",multiple:\"boolean\",selectOptionsSelectedLabel:\"string\",selectOptionHeight:\"number\",selectAll:\"boolean\",selectAllLabel:\"string\",selectSearchPlaceholder:\"string\",selectSize:\"string\",selectVisibleOptions:\"number\",selectFilter:\"boolean\",selectFilterDebounce:\"number\",selectNoResultText:\"string\",selectValidation:\"boolean\",selectValidFeedback:\"string\",selectInvalidFeedback:\"string\",selectPlaceholder:\"string\"},cx={dropdown:\"relative outline-none min-w-[100px] m-0 scale-y-[0.8] opacity-0 bg-white shadow-[0_2px_5px_0_rgba(0,0,0,0.16),_0_2px_10px_0_rgba(0,0,0,0.12)] transition duration-200 motion-reduce:transition-none data-[te-select-open]:scale-100 data-[te-select-open]:opacity-100 dark:bg-zinc-700\",formCheckInput:\"relative float-left mt-[0.15rem] mr-[8px] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 dark:border-neutral-600 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary dark:checked:border-primary checked:bg-primary dark:checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:ml-[0.25rem] checked:after:-mt-px checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-t-0 checked:after:border-l-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:ml-[0.25rem] checked:focus:after:-mt-px checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-t-0 checked:focus:after:border-l-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent\",formOutline:\"relative\",initialized:\"hidden\",inputGroup:\"flex items-center whitespace-nowrap p-2.5 text-center text-base font-normal leading-[1.6] text-gray-700 dark:bg-zinc-800 dark:text-gray-200 dark:placeholder:text-gray-200\",noResult:\"flex items-center px-4\",optionsList:\"list-none m-0 p-0\",optionsWrapper:\"overflow-y-auto\",optionsWrapperScrollbar:\"[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-button]:block [&::-webkit-scrollbar-button]:h-0 [&::-webkit-scrollbar-button]:bg-transparent [&::-webkit-scrollbar-track-piece]:bg-transparent [&::-webkit-scrollbar-track-piece]:rounded-none [&::-webkit-scrollbar-track-piece]: [&::-webkit-scrollbar-track-piece]:rounded-l [&::-webkit-scrollbar-thumb]:h-[50px] [&::-webkit-scrollbar-thumb]:bg-[#999] [&::-webkit-scrollbar-thumb]:rounded\",selectArrow:\"absolute right-3 text-[0.8rem] cursor-pointer peer-focus:text-primary peer-data-[te-input-focused]:text-primary group-data-[te-was-validated]/validation:peer-valid:text-green-600 group-data-[te-was-validated]/validation:peer-invalid:text-[rgb(220,76,100)] w-5 h-5\",selectArrowWhite:\"text-gray-50 peer-focus:!text-white peer-data-[te-input-focused]:!text-white\",selectArrowDefault:\"top-2\",selectArrowLg:\"top-[13px]\",selectArrowSm:\"top-1\",selectClearBtn:\"absolute top-2 right-9 text-black cursor-pointer focus:text-primary outline-none dark:text-gray-200\",selectClearBtnWhite:\"!text-gray-50\",selectClearBtnDefault:\"top-2 text-base\",selectClearBtnLg:\"top-[11px] text-base\",selectClearBtnSm:\"top-1 text-[0.8rem]\",selectDropdownContainer:\"z-[1070]\",selectFakeValue:\"transform-none hidden data-[te-input-state-active]:block\",selectFilterInput:\"relative m-0 block w-full min-w-0 flex-auto rounded border border-solid border-gray-300 bg-transparent bg-clip-padding px-3 py-1.5 text-base font-normal text-gray-700 transition duration-300 ease-in-out motion-reduce:transition-none focus:border-primary focus:text-gray-700 focus:shadow-te-primary focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-200\",selectInput:\"peer block min-h-[auto] w-full rounded border-0 bg-transparent outline-none transition-all duration-200 ease-linear focus:placeholder:opacity-100 data-[te-input-state-active]:placeholder:opacity-100 motion-reduce:transition-none dark:text-gray-200 dark:placeholder:text-gray-200 [&:not([data-te-input-placeholder-active])]:placeholder:opacity-0 cursor-pointer data-[te-input-disabled]:bg-[#e9ecef] data-[te-input-disabled]:cursor-default group-data-[te-was-validated]/validation:mb-4 dark:data-[te-input-disabled]:bg-zinc-600\",selectInputWhite:\"!text-gray-50\",selectInputSizeDefault:\"py-[0.32rem] px-3 leading-[1.6]\",selectInputSizeLg:\"py-[0.32rem] px-3 leading-[2.15]\",selectInputSizeSm:\"py-[0.33rem] px-3 text-xs leading-[1.5]\",selectLabel:\"pointer-events-none absolute top-0 left-3 mb-0 max-w-[90%] origin-[0_0] truncate text-gray-500 transition-all duration-200 ease-out peer-focus:scale-[0.8] peer-focus:text-primary peer-data-[te-input-state-active]:scale-[0.8] motion-reduce:transition-none dark:text-gray-200 dark:peer-focus:text-gray-200 data-[te-input-state-active]:scale-[0.8] dark:peer-focus:text-primary\",selectLabelWhite:\"!text-gray-50\",selectLabelSizeDefault:\"pt-[0.37rem] leading-[1.6] peer-focus:-translate-y-[0.9rem] peer-data-[te-input-state-active]:-translate-y-[0.9rem] data-[te-input-state-active]:-translate-y-[0.9rem]\",selectLabelSizeLg:\"pt-[0.37rem] leading-[2.15] peer-focus:-translate-y-[1.15rem] peer-data-[te-input-state-active]:-translate-y-[1.15rem] data-[te-input-state-active]:-translate-y-[1.15rem]\",selectLabelSizeSm:\"pt-[0.37rem] text-xs leading-[1.5] peer-focus:-translate-y-[0.75rem] peer-data-[te-input-state-active]:-translate-y-[0.75rem] data-[te-input-state-active]:-translate-y-[0.75rem]\",selectOption:\"flex flex-row items-center justify-between w-full px-4 truncate text-gray-700 bg-transparent select-none cursor-pointer data-[te-input-multiple-active]:bg-black/5 hover:[&:not([data-te-select-option-disabled])]:bg-black/5 data-[te-input-state-active]:bg-black/5 data-[te-select-option-selected]:data-[te-input-state-active]:bg-black/5 data-[te-select-selected]:data-[te-select-option-disabled]:cursor-default data-[te-select-selected]:data-[te-select-option-disabled]:text-gray-400 data-[te-select-selected]:data-[te-select-option-disabled]:bg-transparent data-[te-select-option-selected]:bg-black/[0.02] data-[te-select-option-disabled]:text-gray-400 data-[te-select-option-disabled]:cursor-default group-data-[te-select-option-group-ref]/opt:pl-7 dark:text-gray-200 dark:hover:[&:not([data-te-select-option-disabled])]:bg-white/30 dark:data-[te-input-state-active]:bg-white/30 dark:data-[te-select-option-selected]:data-[te-input-state-active]:bg-white/30 dark:data-[te-select-option-disabled]:text-gray-400 dark:data-[te-input-multiple-active]:bg-white/30\",selectAllOption:\"\",selectOptionGroup:\"group/opt\",selectOptionGroupLabel:\"flex flex-row items-center w-full px-4 truncate bg-transparent text-black/50 select-none dark:text-gray-300\",selectOptionIcon:\"w-7 h-7 rounded-full\",selectOptionSecondaryText:\"block text-[0.8rem] text-gray-500 dark:text-gray-300\",selectOptionText:\"group\",selectValidationValid:\"hidden absolute -mt-3 w-auto text-sm text-green-600 cursor-pointer group-data-[te-was-validated]/validation:peer-valid:block\",selectValidationInvalid:\"hidden absolute -mt-3 w-auto text-sm text-[rgb(220,76,100)] cursor-pointer group-data-[te-was-validated]/validation:peer-invalid:block\"},hx={dropdown:\"string\",formCheckInput:\"string\",formOutline:\"string\",initialized:\"string\",inputGroup:\"string\",noResult:\"string\",optionsList:\"string\",optionsWrapper:\"string\",optionsWrapperScrollbar:\"string\",selectArrow:\"string\",selectArrowDefault:\"string\",selectArrowLg:\"string\",selectArrowSm:\"string\",selectClearBtn:\"string\",selectClearBtnDefault:\"string\",selectClearBtnLg:\"string\",selectClearBtnSm:\"string\",selectDropdownContainer:\"string\",selectFakeValue:\"string\",selectFilterInput:\"string\",selectInput:\"string\",selectInputSizeDefault:\"string\",selectInputSizeLg:\"string\",selectInputSizeSm:\"string\",selectLabel:\"string\",selectLabelSizeDefault:\"string\",selectLabelSizeLg:\"string\",selectLabelSizeSm:\"string\",selectOption:\"string\",selectAllOption:\"string\",selectOptionGroup:\"string\",selectOptionGroupLabel:\"string\",selectOptionIcon:\"string\",selectOptionSecondaryText:\"string\",selectOptionText:\"string\"};class on{constructor(t,e,i){this._element=t,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._config.selectPlaceholder&&!this._config.multiple&&this._addPlaceholderOption(),this._optionsToRender=this._getOptionsToRender(t),this._plainOptions=this._getPlainOptions(this._optionsToRender),this._filteredOptionsList=null,this._selectionModel=new vE(this.multiple),this._activeOptionIndex=-1,this._activeOption=null,this._wrapperId=bt(\"select-wrapper-\"),this._dropdownContainerId=bt(\"select-dropdown-container-\"),this._selectAllId=bt(\"select-all-\"),this._debounceTimeoutId=null,this._dropdownHeight=this._config.selectOptionHeight*this._config.selectVisibleOptions,this._popper=null,this._input=null,this._label=m.next(this._element,QE)[0],this._notch=null,this._fakeValue=null,this._isFakeValueActive=!1,this._customContent=m.next(t,nx)[0],this._toggleButton=null,this._elementToggle=null,this._wrapper=null,this._inputEl=null,this._dropdownContainer=null,this._container=null,this._selectAllOption=null,this._init(),this._mutationObserver=null,this._isOpen=!1,this._addMutationObserver(),this._element&&O.setData(t,en,this)}static get NAME(){return Pl}get filterInput(){return m.findOne(JE,this._dropdownContainer)}get dropdown(){return m.findOne(tx,this._dropdownContainer)}get optionsList(){return m.findOne(rp,this._dropdownContainer)}get optionsWrapper(){return m.findOne(ex,this._dropdownContainer)}get clearButton(){return m.findOne(sx,this._wrapper)}get options(){return this._filteredOptionsList?this._filteredOptionsList:this._plainOptions}get value(){return this.multiple?this._selectionModel.values:this._selectionModel.value}get multiple(){return this._config.multiple}get hasSelectAll(){return this.multiple&&this._config.selectAll}get hasSelection(){return this._selectionModel.selection||this._selectionModel.selections.length>0}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...ax,...e,...t},this._element.hasAttribute(\"multiple\")&&(t.multiple=!0),this._element.hasAttribute(\"disabled\")&&(t.disabled=!0),this._element.tabIndex&&(t.tabIndex=this._element.getAttribute(\"tabIndex\")),L(Pl,t,lx),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cx,...e,...t},L(Pl,t,hx),t}_addPlaceholderOption(){const t=new Option(\"\",\"\",!0,!0);t.hidden=!0,t.selected=!0,this._element.prepend(t)}_getOptionsToRender(t){const e=[];return t.childNodes.forEach(n=>{if(n.nodeName===\"OPTGROUP\"){const o={id:bt(\"group-\"),label:n.label,disabled:n.hasAttribute(\"disabled\"),hidden:n.hasAttribute(\"hidden\"),options:[]};n.childNodes.forEach(a=>{a.nodeName===\"OPTION\"&&o.options.push(this._createOptionObject(a,o))}),e.push(o)}else n.nodeName===\"OPTION\"&&e.push(this._createOptionObject(n))}),e}_getPlainOptions(t){if(!m.findOne(\"optgroup\",this._element))return t;const i=[];return t.forEach(n=>{Object.prototype.hasOwnProperty.call(n,\"options\")?n.options.forEach(r=>{i.push(r)}):i.push(n)}),i}_createOptionObject(t,e={}){const i=bt(\"option-\"),n=e.id?e.id:null,o=e.disabled?e.disabled:!1,r=t.selected||t.hasAttribute(nn),a=t.hasAttribute(\"disabled\")||o,l=t.hasAttribute(\"hidden\")||e&&e.hidden,c=this.multiple,h=t.value,d=t.label,u=g.getDataAttribute(t,\"selectSecondaryText\"),p=g.getDataAttribute(t,\"select-icon\");return new Ku(i,t,c,h,d,r,a,l,u,n,p)}_getNavigationOptions(){const t=this.options.filter(e=>!e.hidden);return this.hasSelectAll?[this._selectAllOption,...t]:t}_init(){this._renderMaterialWrapper(),this._wrapper=m.findOne(`#${this._wrapperId}`),this._input=m.findOne(op,this._wrapper),this._config.disabled&&this._input.setAttribute(Nl,\"\");const t=this._config.selectContainer;t===\"body\"?this._container=document.body:this._container=m.findOne(t),this._initOutlineInput(),this._setDefaultSelections(),this._updateInputValue(),this._appendFakeValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._bindComponentEvents(),this.hasSelectAll&&(this._selectAllOption=this._createSelectAllOption()),this._dropdownContainer=Gu(this._dropdownContainerId,this._config,this._input.offsetWidth,this._dropdownHeight,this._selectAllOption,this._optionsToRender,this._customContent,this._classes),this._setFirstActiveOption(),this._listenToFocusChange()}_renderMaterialWrapper(){const t=PE(this._wrapperId,this._config,this._label,this._classes,this._config.customArrow);this._element.parentNode.insertBefore(t,this._element),g.addClass(this._element,this._classes.initialized),t.appendChild(this._element)}_initOutlineInput(){const t=m.findOne(ap,this._wrapper);new Z(t,{inputFormWhite:this._config.selectFormWhite},this._classes).init(),this._notch=m.findOne(Bl,this._wrapper)}_bindComponentEvents(){this._listenToComponentKeydown(),this._listenToWrapperClick(),this._listenToClearBtnClick(),this._listenToClearBtnKeydown()}_setDefaultSelections(){this.options.forEach(t=>{t.selected&&this._selectionModel.select(t)})}_listenToComponentKeydown(){_.on(this._wrapper,\"keydown\",this._handleKeydown.bind(this))}_handleKeydown(t){this._isOpen&&!this._config.selectFilter?this._handleOpenKeydown(t):this._handleClosedKeydown(t)}_handleOpenKeydown(t){const e=t.keyCode,i=e===xi||e===ut&&t.altKey||e===Ci;if(e===Ci&&this._config.selectAutoSelect&&!this.multiple&&this._handleAutoSelection(this._activeOption),i){this.close(),this._input.focus();return}switch(e){case ht:this._setNextOptionActive(),this._scrollToOption(this._activeOption);break;case ut:this._setPreviousOptionActive(),this._scrollToOption(this._activeOption);break;case Ti:this._setFirstOptionActive(),this._scrollToOption(this._activeOption);break;case Ei:this._setLastOptionActive(),this._scrollToOption(this._activeOption);break;case Et:t.preventDefault(),this._activeOption&&(this.hasSelectAll&&this._activeOptionIndex===0?this._handleSelectAll():this._handleSelection(this._activeOption));return;default:return}t.preventDefault()}_handleClosedKeydown(t){const e=t.keyCode;if(e===Et&&t.preventDefault(),(e===Et||e===ht&&t.altKey||e===ht&&this.multiple)&&this.open(),this.multiple)switch(e){case ht:this.open();break;case ut:this.open();break;default:return}else switch(e){case ht:this._setNextOptionActive(),this._handleSelection(this._activeOption);break;case ut:this._setPreviousOptionActive(),this._handleSelection(this._activeOption);break;case Ti:this._setFirstOptionActive(),this._handleSelection(this._activeOption);break;case Ei:this._setLastOptionActive(),this._handleSelection(this._activeOption);break;default:return}t.preventDefault()}_scrollToOption(t){if(!t)return;let e;const i=this.options.filter(h=>!h.hidden);this.hasSelectAll?e=i.indexOf(t)+1:e=i.indexOf(t);const n=this._getNumberOfGroupsBeforeOption(e),o=e+n,r=this.optionsWrapper,a=r.offsetHeight,l=this._config.selectOptionHeight,c=r.scrollTop;if(e>-1){const h=o*l,d=h+l>c+a;h!r.hidden),i=this._optionsToRender.filter(r=>!r.hidden),n=this.hasSelectAll?t-1:t;let o=0;for(let r=0;r<=n;r++)e[r].groupId&&i[o]&&i[o].id&&e[r].groupId===i[o].id&&o++;return o}_setNextOptionActive(){let t=this._activeOptionIndex+1;const e=this._getNavigationOptions();if(e[t]){for(;e[t].disabled;)if(t+=1,!e[t])return;this._updateActiveOption(e[t],t)}}_setPreviousOptionActive(){let t=this._activeOptionIndex-1;const e=this._getNavigationOptions();if(e[t]){for(;e[t].disabled;)if(t-=1,!e[t])return;this._updateActiveOption(e[t],t)}}_setFirstOptionActive(){const e=this._getNavigationOptions();this._updateActiveOption(e[0],0)}_setLastOptionActive(){const t=this._getNavigationOptions(),e=t.length-1;this._updateActiveOption(t[e],e)}_updateActiveOption(t,e){const i=this._activeOption;i&&i.removeActiveStyles(),t.setActiveStyles(),this._activeOptionIndex=e,this._activeOption=t}_listenToWrapperClick(){_.on(this._wrapper,\"click\",()=>{this.toggle()})}_listenToClearBtnClick(){_.on(this.clearButton,\"click\",t=>{t.preventDefault(),t.stopPropagation(),this._handleClear()})}_listenToClearBtnKeydown(){_.on(this.clearButton,\"keydown\",t=>{t.keyCode===Et&&(this._handleClear(),t.preventDefault(),t.stopPropagation())})}_handleClear(){if(this.multiple)this._selectionModel.clear(),this._deselectAllOptions(this.options),this.hasSelectAll&&this._updateSelectAllState();else{const t=this._selectionModel.selection;this._selectionModel.clear(),t.deselect()}this._fakeValue.textContent=\"\",this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._emitValueChangeEvent(null),this._emitNativeChangeEvent()}_listenToOptionsClick(){_.on(this.optionsWrapper,\"click\",t=>{if(t.target.hasAttribute(qE))return;const i=t.target.nodeName===\"DIV\"?t.target:m.closest(t.target,ix);if(i.hasAttribute(ZE)){this._handleSelectAll();return}const o=i.dataset.teId,r=this.options.find(a=>a.id===o);r&&!r.disabled&&this._handleSelection(r)})}_handleSelectAll(){this._selectAllOption.selected?(this._deselectAllOptions(this.options),this._selectAllOption.deselect()):(this._selectAllOptions(this.options),this._selectAllOption.select()),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent()}_selectAllOptions(t){t.forEach(e=>{!e.selected&&!e.disabled&&(this._selectionModel.select(e),e.select())})}_deselectAllOptions(t){t.forEach(e=>{e.selected&&!e.disabled&&(this._selectionModel.deselect(e),e.deselect())})}_handleSelection(t){this.multiple?(this._handleMultiSelection(t),this.hasSelectAll&&this._updateSelectAllState()):this._handleSingleSelection(t),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility()}_handleAutoSelection(t){this._singleOptionSelect(t),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility()}_handleSingleSelection(t){this._singleOptionSelect(t),this.close(),this._input.focus()}_singleOptionSelect(t){const e=this._selectionModel.selections[0];e&&e!==t&&(this._selectionModel.deselect(e),e.deselect(),e.node.setAttribute(nn,!1),_.trigger(this._element,ep,{value:e.value})),(!e||e&&t!==e)&&(this._selectionModel.select(t),t.select(),t.node.setAttribute(nn,!0),_.trigger(this._element,tp,{value:t.value}),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent())}_handleMultiSelection(t){t.selected?(this._selectionModel.deselect(t),t.deselect(),t.node.setAttribute(nn,!1),_.trigger(this._element,ep,{value:t.value})):(this._selectionModel.select(t),t.select(),t.node.setAttribute(nn,!0),_.trigger(this._element,tp,{value:t.value})),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent()}_emitValueChangeEvent(t){_.trigger(this._element,XE,{value:t})}_emitNativeChangeEvent(){_.trigger(this._element,GE)}_updateInputValue(){const t=this.multiple?this._selectionModel.labels:this._selectionModel.label;let e;this.multiple&&this._config.selectDisplayedLabels!==-1&&this._selectionModel.selections.length>this._config.selectDisplayedLabels?e=`${this._selectionModel.selections.length} ${this._config.selectOptionsSelectedLabel}`:e=t,!this.multiple&&!this._isSelectionValid(this._selectionModel.selection)?this._input.value=\"\":this._isLabelEmpty(this._selectionModel.selection)?this._input.value=\" \":e?this._input.value=e:this.multiple||!this._optionsToRender[0]?this._input.value=\"\":this._input.value=this._optionsToRender[0].label}_isSelectionValid(t){return!(t&&(t.disabled||t.value===\"\"))}_isLabelEmpty(t){return!!(t&&t.label===\"\")}_appendFakeValue(){if(!this._selectionModel.selection||this._selectionModel._multiple)return;const t=this._selectionModel.selection.label;this._fakeValue=YE(t,this._classes),m.findOne(ap,this._wrapper).appendChild(this._fakeValue)}_updateLabelPosition(){const t=this._element.hasAttribute(ip),e=this._input.value!==\"\";this._label&&(t&&(e||this._isOpen||this._isFakeValueActive)?(this._label.setAttribute(wt,\"\"),this._notch.setAttribute(wt,\"\")):(this._label.removeAttribute(wt),this._notch.removeAttribute(wt,\"\")))}_updateLabelPositionWhileClosing(){this._label&&(this._input.value!==\"\"||this._isFakeValueActive?(this._label.setAttribute(wt,\"\"),this._notch.setAttribute(wt,\"\")):(this._label.removeAttribute(wt),this._notch.removeAttribute(wt)))}_updateFakeLabelPosition(){this._fakeValue&&(this._input.value===\"\"&&this._fakeValue.innerHTML!==\"\"&&!this._config.selectPlaceholder?(this._isFakeValueActive=!0,this._fakeValue.setAttribute(wt,\"\")):(this._isFakeValueActive=!1,this._fakeValue.removeAttribute(wt)))}_updateClearButtonVisibility(){if(!this.clearButton)return;this._selectionModel.selection||this._selectionModel.selections.length>0?g.addStyle(this.clearButton,{display:\"block\"}):g.addStyle(this.clearButton,{display:\"none\"})}_updateSelectAllState(){const t=this._selectAllOption.selected,e=Rl(this.options);!e&&t?this._selectAllOption.deselect():e&&!t&&this._selectAllOption.select()}toggle(){this._isOpen?this.close():this.open()}open(){const t=this._config.disabled,e=_.trigger(this._element,UE);this._isOpen||t||e.defaultPrevented||(this._openDropdown(),this._updateDropdownWidth(),this._setFirstActiveOption(),this._scrollToOption(this._activeOption),this._config.selectFilter&&(setTimeout(()=>{this.filterInput.focus()},0),this._listenToSelectSearch(),this._listenToDropdownKeydown()),this._listenToOptionsClick(),this._listenToOutsideClick(),this._listenToWindowResize(),this._isOpen=!0,this._updateLabelPosition(),this._setInputActiveStyles())}_openDropdown(){this._popper=Fe(this._input,this._dropdownContainer,{placement:\"bottom-start\",modifiers:[{name:\"offset\",options:{offset:[0,1]}}]}),this._container.appendChild(this._dropdownContainer),setTimeout(()=>{this.dropdown.setAttribute(np,\"\")},0)}_updateDropdownWidth(){const t=this._input.offsetWidth;g.addStyle(this._dropdownContainer,{width:`${t}px`})}_setFirstActiveOption(){const t=this._getNavigationOptions(),e=this._activeOption;e&&e.removeActiveStyles();const i=this.multiple?this._selectionModel.selections[0]:this._selectionModel.selection;i?(this._activeOption=i,i.setActiveStyles(),this._activeOptionIndex=t.findIndex(n=>n===i)):(this._activeOption=null,this._activeOptionIndex=-1)}_setInputActiveStyles(){this._input.setAttribute(qe,\"\"),m.findOne(Bl,this._wrapper).setAttribute(qe,\"\")}_listenToWindowResize(){_.on(window,\"resize\",this._handleWindowResize.bind(this))}_handleWindowResize(){this._dropdownContainer&&this._updateDropdownWidth()}_listenToSelectSearch(){this.filterInput.addEventListener(\"input\",t=>{const e=t.target.value,i=this._config.selectFilterDebounce;this._debounceFilter(e,i)})}_debounceFilter(t,e){this._debounceTimeoutId&&clearTimeout(this._debounceTimeoutId),this._debounceTimeoutId=setTimeout(()=>{this._filterOptions(t)},e)}_filterOptions(t){const e=[];this._optionsToRender.forEach(o=>{const r=Object.prototype.hasOwnProperty.call(o,\"options\"),a=!r&&o.label.toLowerCase().includes(t.toLowerCase()),l={};r&&(l.label=o.label,l.options=this._filter(t,o.options),l.options.length>0&&e.push(l)),a&&e.push(o)});const i=this._config.selectNoResultText!==\"\",n=e.length!==0;if(n)this._updateOptionsListTemplate(e),this._popper.forceUpdate(),this._filteredOptionsList=this._getPlainOptions(e),this.hasSelectAll&&this._updateSelectAllState(),this._setFirstActiveOption();else if(!n&&i){const o=this._getNoResultTemplate();this.optionsWrapper.innerHTML=o}}_updateOptionsListTemplate(t){const e=m.findOne(rp,this._dropdownContainer)||m.findOne(ox,this._dropdownContainer),i=qu(t,this._selectAllOption,this._config,this._classes);this.optionsWrapper.removeChild(e),this.optionsWrapper.appendChild(i)}_getNoResultTemplate(){return`
${this._config.selectNoResultText}
`}_filter(t,e){const i=t.toLowerCase();return e.filter(n=>n.label.toLowerCase().includes(i))}_listenToDropdownKeydown(){_.on(this.dropdown,\"keydown\",this._handleOpenKeydown.bind(this))}_listenToOutsideClick(){this._outsideClick=this._handleOutSideClick.bind(this),_.on(document,\"click\",this._outsideClick)}_listenToFocusChange(t=!0){if(t===!1){_.off(this._input,\"focus\",()=>this._notch.setAttribute(qe,\"\")),_.off(this._input,\"blur\",()=>this._notch.removeAttribute(qe));return}_.on(this._input,\"focus\",()=>this._notch.setAttribute(qe,\"\")),_.on(this._input,\"blur\",()=>this._notch.removeAttribute(qe))}_handleOutSideClick(t){const e=this._wrapper&&this._wrapper.contains(t.target),i=t.target===this._dropdownContainer,n=this._dropdownContainer&&this._dropdownContainer.contains(t.target);let o;this._toggleButton||(this._elementToggle=m.find(rx)),this._elementToggle&&this._elementToggle.forEach(r=>{const a=g.getDataAttribute(r,\"select-toggle\");(a===this._element.id||this._element.classList.contains(a))&&(this._toggleButton=r,o=this._toggleButton.contains(t.target))}),!e&&!i&&!n&&!o&&this.close()}close(){const t=_.trigger(this._element,KE),e=oo(this._dropdownContainer.children[0]);!this._isOpen||t.defaultPrevented||(this._config.selectFilter&&this.hasSelectAll&&(this._resetFilterState(),this._updateOptionsListTemplate(this._optionsToRender),this._config.multiple&&this._updateSelectAllState()),this._removeDropdownEvents(),this.dropdown.removeAttribute(np),setTimeout(()=>{this._input.removeAttribute(qe),this._input.blur(),m.findOne(Bl,this._wrapper).removeAttribute(qe),this._label&&!this.hasSelection&&(this._label.removeAttribute(wt),this._notch.setAttribute(wt,\"\"),this._input.removeAttribute(wt),this._notch.removeAttribute(wt)),this._updateLabelPositionWhileClosing()},0),setTimeout(()=>{this._container&&this._dropdownContainer.parentNode===this._container&&this._container.removeChild(this._dropdownContainer),this._popper.destroy(),this._isOpen=!1,_.off(this.dropdown,\"transitionend\")},e))}_resetFilterState(){this.filterInput.value=\"\",this._filteredOptionsList=null}_removeDropdownEvents(){_.off(document,\"click\",this._outsideClick),this._config.selectFilter&&_.off(this.dropdown,\"keydown\"),_.off(this.optionsWrapper,\"click\")}_addMutationObserver(){this._mutationObserver=new MutationObserver(()=>{this._wrapper&&(this._updateSelections(),this._updateDisabledState())}),this._observeMutationObserver()}_updateSelections(){this._optionsToRender=this._getOptionsToRender(this._element),this._plainOptions=this._getPlainOptions(this._optionsToRender),this._selectionModel.clear(),this._setDefaultSelections(),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this.hasSelectAll&&this._updateSelectAllState();const t=this._config.filter&&this.filterInput&&this.filterInput.value;this._isOpen&&!t?(this._updateOptionsListTemplate(this._optionsToRender),this._setFirstActiveOption()):this._isOpen&&t?(this._filterOptions(this.filterInput.value),this._setFirstActiveOption()):this._dropdownContainer=Gu(this._dropdownContainerId,this._config,this._input.offsetWidth,this._dropdownHeight,this._selectAllOption,this._optionsToRender,this._customContent,this._classes)}_updateDisabledState(){const t=m.findOne(op,this._wrapper);this._element.hasAttribute(\"disabled\")?(this._config.disabled=!0,t.setAttribute(\"disabled\",\"\"),t.setAttribute(Nl,\"\")):(this._config.disabled=!1,t.removeAttribute(\"disabled\"),t.removeAttribute(Nl))}_observeMutationObserver(){this._mutationObserver&&this._mutationObserver.observe(this._element,{attributes:!0,childList:!0,characterData:!0,subtree:!0})}_disconnectMutationObserver(){this.mutationObserver&&(this._mutationObserver.disconnect(),this._mutationObserver=null)}_createSelectAllOption(){const t=this._selectAllId,e=null,i=!0,n=\"select-all\",o=this._config.selectAllLabel,r=Rl(this.options),a=!1,l=!1,c=null,h=null,d=null;return new Ku(t,e,i,n,o,r,a,l,c,h,d)}dispose(){this._removeComponentEvents(),this._destroyMaterialSelect(),this._listenToFocusChange(!1),O.removeData(this._element,en)}_removeComponentEvents(){_.off(this.input,\"click\"),_.off(this.wrapper,this._handleKeydown.bind(this)),_.off(this.clearButton,\"click\"),_.off(this.clearButton,\"keydown\"),_.off(window,\"resize\",this._handleWindowResize.bind(this))}_destroyMaterialSelect(){this._isOpen&&this.close(),this._destroyMaterialTemplate()}_destroyMaterialTemplate(){const t=this._wrapper.parentNode,e=m.find(\"label\",this._wrapper);t.appendChild(this._element),e.forEach(i=>{t.appendChild(i)}),e.forEach(i=>{i.removeAttribute(wt)}),g.removeClass(this._element,this._classes.initialized),this._element.removeAttribute(ip),t.removeChild(this._wrapper)}setValue(t){this.options.filter(i=>i.selected).forEach(i=>i.nativeOption.selected=!1),Array.isArray(t)?t.forEach(i=>{this._selectByValue(i)}):this._selectByValue(t),this._updateSelections(),this._emitValueChangeEvent(this.value)}_selectByValue(t){const e=this.options.find(i=>i.value===t);return e?(e.nativeOption.selected=!0,!0):!1}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,en);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new on(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,en)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const dx=({inputID:s,labelText:t},e)=>`
\n \n ${t}\n \n
\n `,ux=({text:s,iconSVG:t},e)=>`
\n ${s} \n \n ${t}\n \n
`,rr=\"chip\",px=`te.${rr}`,lp=\"data-te-chip-close\",Hl=`[${lp}]`,fx=\"delete.te.chips\",_x=\"select.te.chip\",gx=' ',mx={text:\"string\",closeIcon:\"boolean\",img:\"object\",iconSVG:\"string\"},bx={text:\"\",closeIcon:!1,img:{path:\"\",alt:\"\"},iconSVG:gx},vx={icon:\"float-right pl-[8px] text-[16px] opacity-[.53] cursor-pointer fill-[#afafaf] hover:text-[#8b8b8b] transition-all duration-200 ease-in-out\",chipElement:\"flex justify-between items-center h-[32px] leading-loose py-[5px] px-[12px] mr-4 my-[5px] text-[13px] font-normal text-[#4f4f4f] cursor-pointer bg-[#eceff1] dark:text-white dark:bg-neutral-600 rounded-[16px] transition-[opacity] duration-300 ease-linear [word-wrap: break-word] shadow-none normal-case hover:!shadow-none active:bg-[#cacfd1] inline-block font-medium leading-normal text-[#4f4f4f] text-center no-underline align-middle cursor-pointer select-none border-[.125rem] border-solid border-transparent py-1.5 px-3 text-xs rounded\",chipCloseIcon:\"w-4 float-right pl-[8px] text-[16px] opacity-[.53] cursor-pointer fill-[#afafaf] hover:fill-[#8b8b8b] dark:fill-gray-400 dark:hover:fill-gray-100 transition-all duration-200 ease-in-out\"},yx={icon:\"string\",chipElement:\"string\",chipCloseIcon:\"string\"};class ki{constructor(t,e={},i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i)}static get NAME(){return rr}init(){this._appendCloseIcon(),this._handleDelete(),this._handleTextChip(),this._handleClickOnChip()}dispose(){this._element=null,this._options=null,_.off(this._element,\"click\")}appendChip(){const{text:t,closeIcon:e,iconSVG:i}=this._options;return ux({text:t,closeIcon:e,iconSVG:i},this._classes)}_appendCloseIcon(t=this._element){if(!(m.find(Hl,this._element).length>0)&&this._options.closeIcon){const e=$(\"span\");e.classList=this._classes.icon,e.setAttribute(lp),e.innerHTML=this._options.iconSVG,t.insertAdjacentElement(\"beforeend\",e)}}_handleClickOnChip(){_.on(this._element,\"click\",t=>{const{textContent:e}=t.target,i={};i.tag=e.trim(),_.trigger(_x,{event:t,obj:i})})}_handleDelete(){m.find(Hl,this._element).length!==0&&_.on(this._element,\"click\",Hl,()=>{_.trigger(this._element,fx),this._element.remove()})}_handleTextChip(){this._element.innerText===\"\"&&(this._element.innerText=this._options.text)}_getConfig(t){const e={...bx,...g.getDataAttributes(this._element),...t};return L(rr,e,mx),e}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...vx,...e,...t},L(rr,t,yx),t}static getInstance(t){return O.getData(t,px)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const rn=\"chips\",an=`data-te-${rn}`,cp=`te.${rn}`,Tx=`${an}-input-init`,Wt=`${an}-active`,hp=`${an}-initial`,dp=`${an}-placeholder`,Ex=`${an}-input-wrapper`,Vl=\"data-te-chip-init\",up=\"data-te-chip-close\",pp=\"data-te-chip-text\",xx=`[${Wt}]`,Fl=`[${Vl}]`,Cx=`${Fl}${xx}`,Wl=`[${up}]`,Ax=`[${Ex}]`,wx=`[${pp}]`,kx=`[${dp}]`,Sx=\"data-te-input-notch-leading-ref\",Ox=\"data-te-input-notch-middle-ref\",Ix=`[${Sx}]`,Dx=`[${Ox}]`,us=\"data-te-input-state-active\",zl=\"[data-te-input-notch-ref]\",Mx=\"add.te.chips\",Lx=\"arrowDown.te.chips\",$x=\"arrowLeft.te.chips\",Rx=\"arrowRight.te.chips\",Px=\"arrowUp.te.chips\",fp=\"delete.te.chips\",_p=\"select.te.chips\",Nx={inputID:\"string\",parentSelector:\"string\",initialValues:\"array\",editable:\"boolean\",labelText:\"string\",inputClasses:\"object\",inputOptions:\"object\"},Bx={inputID:bt(\"chips-input-\"),parentSelector:\"\",initialValues:[{tag:\"init1\"},{tag:\"init2\"}],editable:!1,labelText:\"Example label\",inputClasses:{},inputOptions:{}},Hx={opacity:\"opacity-0\",inputWrapperPadding:\"p-[5px]\",transition:\"transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]\",contentEditable:\"outline-none !border-[3px] !border-solid !border-[#b2b3b4]\",chipsInputWrapper:\"relative flex items-center flex-wrap transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]\",chipsInput:\"peer block min-h-[auto] w-[150px] rounded border-0 bg-transparent py-[0.32rem] px-3 leading-[1.6] outline-none transition-all duration-200 ease-linear focus:placeholder:opacity-100 data-[te-input-state-active]:placeholder:opacity-100 motion-reduce:transition-none dark:text-gray-200 dark:placeholder:text-gray-200 [&:not([data-te-input-placeholder-active])]:placeholder:opacity-0\",chipsLabel:\"pointer-events-none absolute top-0 left-3 mb-0 max-w-[90%] origin-[0_0] truncate pt-[0.37rem] leading-[1.6] text-gray-500 transition-all duration-200 ease-out peer-focus:-translate-y-[0.9rem] peer-focus:scale-[0.8] peer-focus:text-primary peer-data-[te-input-state-active]:-translate-y-[0.9rem] peer-data-[te-input-state-active]:scale-[0.8] motion-reduce:transition-none dark:text-gray-200 dark:peer-focus:text-gray-200\"},Vx={opacity:\"string\",inputWrapperPadding:\"string\",transition:\"string\",contentEditable:\"string\",chipsInputWrapper:\"string\",chipsInput:\"string\",chipsLabel:\"string\"};class gp extends ki{constructor(e,i={},n){super(e,i);ke(this,\"_handleBlurInput\",({target:e})=>{e.value.length>0&&this._handleCreateChip(e,e.value),this.allChips.length>0?(e.setAttribute(Wt,\"\"),this.input.setAttribute(us,\"\"),m.findOne(zl,this.input.parentNode).setAttribute(us,\"\"),this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \"))):(e.removeAttribute(Wt),this.input.removeAttribute(us),m.findOne(zl,this.input.parentNode).removeAttribute(us),this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \"))),this.allChips.forEach(i=>i.removeAttribute(Wt))});this._element=e,this._inputInstance=null,this._element&&O.setData(e,cp,this),this._options=this._getConfig(i),this._classes=this._getClasses(n),this.numberClicks=0,this.init()}static get NAME(){return rn}get activeChip(){return m.findOne(Cx,this._element)}get input(){return m.findOne(\"input\",this._element)}get allChips(){return m.find(Fl,this._element)}get chipsInputWrapper(){return m.findOne(Ax,this._element)}init(){this._setChipsClass(),this._appendInputToElement(dp),this._handleInitialValue(),this._handleInputText(),this._handleKeyboard(),this._handleChipsOnSelect(),this._handleEditable(),this._handleChipsFocus(),this._handleClicksOnChips(),this._inputInstance._getLabelWidth(),this._inputInstance._applyNotch()}dispose(){this._element=null,this._options=null}_getNotchData(){this._notchMiddle=m.findOne(Dx,this._element),this._notchLeading=m.findOne(Ix,this._element)}_setChipsClass(){this._element.setAttribute(Tx,\"\")}_handleDeleteEvents(e){const[i]=this.allChips.slice(-1);if(this.activeChip===null)i.remove(),this._handleEvents(e,fp);else{const n=this.allChips.findIndex(a=>a===this.activeChip),o=this._handleActiveChipAfterRemove(n),r=[];if(this.activeChip===null)return;this.activeChip.remove(),this._handleEvents(e,fp),this.numberClicks=n,o.setAttribute(Wt,\"\"),this.allChips.forEach(a=>{a.hasAttribute(Wt)&&(r.push(a),r.length>1&&this.allChips.forEach(l=>l.remove()))})}}_handleUpEvents(e){this.numberClicks+=1,this.numberClicks===this.allChips.length+1&&(this.numberClicks=0),this._handleRightKeyboardArrow(this.numberClicks),this._handleEvents(e,Rx),this._handleEvents(e,Px)}_handleDownEvents(e){this.numberClicks-=1,this.numberClicks<=0&&(this.numberClicks=this.allChips.length),this._handleLeftKeyboardArrow(this.numberClicks),this._handleEvents(e,$x),this._handleEvents(e,Lx)}_keyboardEvents(e){const{target:i,keyCode:n,ctrlKey:o}=e;i.value.length>0||this.allChips.length===0||(n===zy||n===jy?this._handleDeleteEvents(e):n===hs||n===ut?this._handleUpEvents(e):n===cs||n===ht?this._handleDownEvents(e):n===65&&o&&this._handleAddActiveClass())}_handleKeyboard(){_.on(this.input,\"keydown\",e=>this._keyboardEvents(e))}_handleEditable(){const{editable:e}=this._options;e&&this.allChips.forEach(i=>{_.on(i,\"dblclick\",n=>{const o=m.findOne(Wl,i);i.classList.add(...this._classes.contentEditable.split(\" \")),i.contentEditable=!0,i.focus(),setTimeout(()=>{g.addStyle(o,{display:\"none\"})},200),o.classList.add(...this._classes.opacity.split(\" \")),n.target.textContent,_.trigger(i,_p,{event:n,allChips:this.allChips})}),_.on(document,\"click\",({target:n})=>{const o=m.findOne(Wl,i),r=m.findOne(wx,i),a=n===i,l=i&&i.contains(n);!a&&!l&&(i.contentEditable=!1,i.classList.remove(...this._classes.contentEditable.split(\" \")),r.textContent!==\"\"&&setTimeout(()=>{g.addStyle(o,{display:\"block\"}),o.classList.remove(...this._classes.opacity.split(\" \"))},160)),r.textContent===\"\"&&(setTimeout(()=>{i.classList.add(...this._classes.opacity.split(\" \"))},200),setTimeout(()=>{i.remove()},300))})})}_handleRemoveActiveClass(){this.allChips.forEach(e=>e.removeAttribute(Wt))}_handleAddActiveClass(){this.allChips.forEach(e=>e.setAttribute(Wt,\"\"))}_handleRightKeyboardArrow(e){this._handleRemoveActiveClass(),e===0&&(e=1),this._handleAddActiveClassWithKebyboard(e)}_handleLeftKeyboardArrow(e){this._handleRemoveActiveClass(),this._handleAddActiveClassWithKebyboard(e)}_handleActiveChipAfterRemove(e){const i=e===0?1:e-1;return this.allChips[i]}_handleClicksOnChips(){_.on(this._element,\"click\",()=>{this.allChips.length===0&&(this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \")),this.input.removeAttribute(Wt))})}_handleTextContent(){const e=[];return this.allChips.forEach(i=>e.push({tag:i.textContent.trim()})),e}_handleEvents(e,i){const n=this._handleTextContent(),o=this.allChips.filter(r=>r.hasAttribute(Wt)&&r);_.trigger(this._element,i,{event:e,allChips:this.allChips,arrOfObjects:n,active:o,activeObj:{tag:o.length<=0?\"\":o[0].textContent.trim()}})}_handleChipsFocus(){_.on(this._element,\"click\",({target:{attributes:e}})=>{const i=[...e].map(n=>n.name);i.includes(Vl)||i.includes(up)||i.includes(pp)||this.input.focus()})}_handleInitialValue(){if(this._appendInputToElement(hp),this._element.hasAttribute(hp)){const{initialValues:e}=this._options;e.forEach(({tag:i})=>this._handleCreateChip(this.input,i)),m.findOne(zl,this.input.parentNode).setAttribute(us,\"\"),this.input.setAttribute(Wt,\"\"),this.input.setAttribute(us,\"\")}this.allChips.length>0&&(this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \")),this.chipsInputWrapper.classList.add(...this._classes.transition.split(\" \")))}_handleKeysInputToElement(e){const{keyCode:i,target:n}=e;if(n.hasAttribute(Vl)){const o=m.findOne(Wl,n);i===Et&&(n.contentEditable=!1,n.classList.remove(...this._classes.contentEditable.split(\" \")),n.textContent!==\"\"?setTimeout(()=>{g.addStyle(o,{display:\"block\"}),o.classList.remove(...this._classes.opacity.split(\" \"))},160):n.textContent===\"\"&&(setTimeout(()=>{n.classList.add(...this._classes.opacity.split(\" \"))},200),setTimeout(()=>{n.remove()},300)));return}if(i===Et){if(n.value===\"\")return;this._handleCreateChip(n,n.value),this._handleRemoveActiveClass(),this.numberClicks=this.allChips.length+1,this._handleEvents(e,Mx)}this.allChips.length>0?(this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \")),this.chipsInputWrapper.classList.add(...this._classes.transition.split(\" \"))):this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \"))}_handleInputText(){const e=m.findOne(kx,this._element);_.on(this._element,\"keyup\",e,i=>this._handleKeysInputToElement(i)),_.on(this.input,\"blur\",i=>this._handleBlurInput(i))}_appendInputToElement(e){if(!this._element.hasAttribute(e))return;const i=dx(this._options,this._classes);this._element.insertAdjacentHTML(\"beforeend\",i);const n=m.findOne(\"[data-te-chips-input-wrapper]\",this._element);this._inputInstance=new Z(n,this._options.inputOptions,this._options.inputClasses)}_handleCreateChip(e,i){const n=$(\"div\"),o=ki.getInstance(n),r=new ki(o,{text:i},this._classes);this._options.parentSelector!==\"\"?document.querySelector(this._options.parentSelector).insertAdjacentHTML(\"beforeend\",r.appendChip()):e.insertAdjacentHTML(\"beforebegin\",r.appendChip()),e.value=\"\",m.find(Fl).forEach(a=>{let l=ki.getInstance(a);return l||(l=new ki(a,{},this._classes)),l.init()}),this._handleEditable()}_handleChipsOnSelect(){this.allChips.forEach(e=>{_.on(this._element,\"click\",i=>{_.trigger(e,_p,{event:i,allChips:this.allChips})})})}_handleAddActiveClassWithKebyboard(e){let i;this.allChips[e-1]===void 0?i=this.allChips[e-2]:i=this.allChips[e-1],i.setAttribute(Wt)}_getConfig(e){const i={...Bx,...g.getDataAttributes(this._element),...e};return L(rn,i,Nx),i}_getClasses(e){const i=g.getDataClassAttributes(this._element);return e={...Hx,...i,...e},L(rn,e,Vx),e}static getInstance(e){return O.getData(e,cp)}static getOrCreateInstance(e,i={}){return this.getInstance(e)||new this(e,typeof i==\"object\"?i:null)}}const Ze={plugins:{legend:{labels:{color:\"rgb(102,102,102)\"}}}},ln={line:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.0)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2,tension:0},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},datasets:{borderColor:\"red\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!1,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},bar:{options:{...Ze,backgroundColor:\"rgb(59, 112, 202)\",borderWidth:0,responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!0,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},pie:{options:{...Ze,elements:{arc:{backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},doughnut:{options:{...Ze,elements:{arc:{backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},polarArea:{options:{...Ze,elements:{arc:{backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0}}},radar:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.5)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},scatter:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.5)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2,tension:0},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},datasets:{borderColor:\"red\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!1,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},bubble:{options:{...Ze,elements:{point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0},scales:{x:{grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}}};var Fx=function(t){return Wx(t)&&!zx(t)};function Wx(s){return!!s&&typeof s==\"object\"}function zx(s){var t=Object.prototype.toString.call(s);return t===\"[object RegExp]\"||t===\"[object Date]\"||Kx(s)}var jx=typeof Symbol==\"function\"&&Symbol.for,Yx=jx?Symbol.for(\"react.element\"):60103;function Kx(s){return s.$$typeof===Yx}function Ux(s){return Array.isArray(s)?[]:{}}function cn(s,t){return t.clone!==!1&&t.isMergeableObject(s)?ps(Ux(s),s,t):s}function Xx(s,t,e){return s.concat(t).map(function(i){return cn(i,e)})}function Gx(s,t){if(!t.customMerge)return ps;var e=t.customMerge(s);return typeof e==\"function\"?e:ps}function qx(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter(function(t){return Object.propertyIsEnumerable.call(s,t)}):[]}function mp(s){return Object.keys(s).concat(qx(s))}function bp(s,t){try{return t in s}catch{return!1}}function Zx(s,t){return bp(s,t)&&!(Object.hasOwnProperty.call(s,t)&&Object.propertyIsEnumerable.call(s,t))}function Qx(s,t,e){var i={};return e.isMergeableObject(s)&&mp(s).forEach(function(n){i[n]=cn(s[n],e)}),mp(t).forEach(function(n){Zx(s,n)||(bp(s,n)&&e.isMergeableObject(t[n])?i[n]=Gx(n,e)(s[n],t[n],e):i[n]=cn(t[n],e))}),i}function ps(s,t,e){e=e||{},e.arrayMerge=e.arrayMerge||Xx,e.isMergeableObject=e.isMergeableObject||Fx,e.cloneUnlessOtherwiseSpecified=cn;var i=Array.isArray(t),n=Array.isArray(s),o=i===n;return o?i?e.arrayMerge(s,t,e):Qx(s,t,e):cn(t,e)}ps.all=function(t,e){if(!Array.isArray(t))throw new Error(\"first argument should be an array\");return t.reduce(function(i,n){return ps(i,n,e)},{})};var Jx=ps,jl=Jx;const vp=\"chart\",ar=\"te.chart\",tC=\"chart\",Yl=(s,t,e)=>{const i=(n,o,r)=>{const a=n.slice();return o.forEach((l,c)=>{typeof a[c]>\"u\"?a[c]=r.cloneUnlessOtherwiseSpecified(l,r):r.isMergeableObject(l)?a[c]=jl(n[c],l,r):n.indexOf(l)===-1&&a.push(l)}),a};return jl(e[t],s,{arrayMerge:i})},eC={darkTicksColor:\"#fff\",darkLabelColor:\"#fff\",darkGridLinesColor:\"#555\",darkmodeOff:\"undefined\",darkMode:null,darkBgColor:\"#262626\",darkBgColorLight:\"#fff\",options:null},iC={darkTicksColor:\"string\",darkLabelColor:\"string\",darkGridLinesColor:\"string\",darkmodeOff:\"(string|null)\",darkMode:\"(string|null)\",darkBgColor:\"string\",darkBgColorLight:\"string\",options:\"(object|null)\"};let yp=class am{constructor(t,e,i={},n={}){this._waitForCharts(t,e,i,n)}async _getChartjs(){const{Chart:t,ArcElement:e,LineElement:i,BarElement:n,PointElement:o,BarController:r,BubbleController:a,DoughnutController:l,LineController:c,PieController:h,PolarAreaController:d,RadarController:u,ScatterController:p,CategoryScale:f,LinearScale:b,LogarithmicScale:v,RadialLinearScale:y,TimeScale:T,TimeSeriesScale:x,Decimation:E,Filler:C,Legend:A,Title:w,Tooltip:S,SubTitle:k}=await Promise.resolve().then(()=>UM);return t.register(e,i,n,o,r,a,l,c,h,d,u,p,f,b,v,y,T,x,E,C,A,w,S,k),t}async _getChartDataLabels(){return await Promise.resolve().then(()=>gL)}async _waitForCharts(t,e,i={},n={}){if(this._Chartjs=await this._getChartjs(),this._ChartDataLabels=await this._getChartDataLabels(),this._element=t,this._data=e,this._options=i,this._type=e.type,this._canvas=null,this._chart=null,this._darkOptions=this._getDarkConfig(n),this._darkModeClassContainer=document.querySelector(\"html\"),this._prevConfig=null,this._observer=null,this._element&&(O.setData(t,ar,this),g.addClass(this._element,tC),this._chartConstructor()),this._darkOptions.darkmodeOff!==null){const o=this._darkOptions.darkMode===\"dark\"?\"dark\":this._darkOptions.darkMode===\"light\"?\"light\":this.systemColorMode;this._handleMode(o),this._observer=new MutationObserver(this._observerCallback.bind(this)),this._observer.observe(this._darkModeClassContainer,{attributes:!0})}}static get NAME(){return vp}get systemColorMode(){return localStorage.theme||(this._darkModeClassContainer.classList.contains(\"dark\")?\"dark\":\"light\")}dispose(){this._observer.disconnect(),O.removeData(this._element,ar),this._element=null}update(t,e){t&&(this._data={...this._data,...t},this._chart.data=this._data);const i=Object.prototype.hasOwnProperty.call(e,\"options\")?e:{options:{...e}};this._options=jl(this._options,i),this._chart.options=Yl(this._options,this._type,ln).options,this._chart.update()}setTheme(t){t!==\"dark\"&&t!==\"light\"||!this._data||this._handleMode(t)}_getDarkConfig(t){let e={};const i=g.getDataAttributes(this._element);Object.keys(i).forEach(c=>c.startsWith(\"dark\")&&(e[c]=i[c])),e={...eC,...e};const n={y:{ticks:{color:e.darkTicksColor},grid:{color:e.darkGridLinesColor}},x:{ticks:{color:e.darkTicksColor},grid:{color:e.darkGridLinesColor}}},o={r:{ticks:{color:e.darkTicksColor,backdropColor:e.darkBgColor},grid:{color:e.darkGridLinesColor},pointLabels:{color:e.darkTicksColor}}},l={scales:[\"pie\",\"doughnut\",\"polarArea\",\"radar\"].includes(this._type)?[\"polarArea\",\"radar\"].includes(this._type)?o:{}:n,plugins:{legend:{labels:{color:e.darkLabelColor}}}};return t={...e,options:{...l},...t},L(vp,t,iC),t}_chartConstructor(){if(this._data){this._createCanvas();const t=Yl(this._options,this._type,ln),e=[];t.dataLabelsPlugin&&e.push(this._ChartDataLabels.default),this._prevConfig=t,this._chart=new this._Chartjs(this._canvas,{...this._data,...t,plugins:e})}}_createCanvas(){this._canvas||(this._element.nodeName===\"CANVAS\"?this._canvas=this._element:(this._canvas=$(\"canvas\"),this._element.appendChild(this._canvas)))}_handleMode(t){t===\"dark\"?(this._changeDatasetBorderColor(),this.update(null,this._darkOptions.options)):(this._changeDatasetBorderColor(!1),this._prevConfig&&this.update(null,this._prevConfig))}_observerCallback(t){for(const e of t)e.type===\"attributes\"&&this._handleMode(this.systemColorMode)}_changeDatasetBorderColor(t=!0){[...this._data.data.datasets].forEach(e=>[\"pie\",\"doughnut\",\"polarArea\"].includes(this._type)&&(e.borderColor=t?this._darkOptions.darkBgColor:this._darkOptions.darkBgColorLight))}static jQueryInterface(t,e,i){return this.each(function(){let n=O.getData(this,ar);if(!(!n&&/dispose/.test(t))){if(!n){const o=e?Yl(e,i,ln):ln[i];n=new am(this,{...t,...o})}if(typeof t==\"string\"){if(typeof n[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);n[t](e,i)}}})}static getInstance(t){return O.getData(t,ar)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}};/*!\n * perfect-scrollbar v1.5.3\n * Copyright 2021 Hyunje Jun, MDBootstrap and Contributors\n * Licensed under MIT\n */function me(s){return getComputedStyle(s)}function Ot(s,t){for(var e in t){var i=t[e];typeof i==\"number\"&&(i=i+\"px\"),s.style[e]=i}return s}function lr(s){var t=document.createElement(\"div\");return t.className=s,t}var Tp=typeof Element<\"u\"&&(Element.prototype.matches||Element.prototype.webkitMatchesSelector||Element.prototype.mozMatchesSelector||Element.prototype.msMatchesSelector);function Qe(s,t){if(!Tp)throw new Error(\"No element matching method supported\");return Tp.call(s,t)}function fs(s){s.remove?s.remove():s.parentNode&&s.parentNode.removeChild(s)}function Ep(s,t){return Array.prototype.filter.call(s.children,function(e){return Qe(e,t)})}var at={main:\"ps\",rtl:\"ps__rtl\",element:{thumb:function(s){return\"ps__thumb-\"+s},rail:function(s){return\"ps__rail-\"+s},consuming:\"ps__child--consume\"},state:{focus:\"ps--focus\",clicking:\"ps--clicking\",active:function(s){return\"ps--active-\"+s},scrolling:function(s){return\"ps--scrolling-\"+s}}},xp={x:null,y:null};function Cp(s,t){var e=s.element.classList,i=at.state.scrolling(t);e.contains(i)?clearTimeout(xp[t]):e.add(i)}function Ap(s,t){xp[t]=setTimeout(function(){return s.isAlive&&s.element.classList.remove(at.state.scrolling(t))},s.settings.scrollingThreshold)}function sC(s,t){Cp(s,t),Ap(s,t)}var hn=function(t){this.element=t,this.handlers={}},wp={isEmpty:{configurable:!0}};hn.prototype.bind=function(t,e){typeof this.handlers[t]>\"u\"&&(this.handlers[t]=[]),this.handlers[t].push(e),this.element.addEventListener(t,e,!1)},hn.prototype.unbind=function(t,e){var i=this;this.handlers[t]=this.handlers[t].filter(function(n){return e&&n!==e?!0:(i.element.removeEventListener(t,n,!1),!1)})},hn.prototype.unbindAll=function(){for(var t in this.handlers)this.unbind(t)},wp.isEmpty.get=function(){var s=this;return Object.keys(this.handlers).every(function(t){return s.handlers[t].length===0})},Object.defineProperties(hn.prototype,wp);var _s=function(){this.eventElements=[]};_s.prototype.eventElement=function(t){var e=this.eventElements.filter(function(i){return i.element===t})[0];return e||(e=new hn(t),this.eventElements.push(e)),e},_s.prototype.bind=function(t,e,i){this.eventElement(t).bind(e,i)},_s.prototype.unbind=function(t,e,i){var n=this.eventElement(t);n.unbind(e,i),n.isEmpty&&this.eventElements.splice(this.eventElements.indexOf(n),1)},_s.prototype.unbindAll=function(){this.eventElements.forEach(function(t){return t.unbindAll()}),this.eventElements=[]},_s.prototype.once=function(t,e,i){var n=this.eventElement(t),o=function(r){n.unbind(e,o),i(r)};n.bind(e,o)};function cr(s){if(typeof window.CustomEvent==\"function\")return new CustomEvent(s);var t=document.createEvent(\"CustomEvent\");return t.initCustomEvent(s,!1,!1,void 0),t}function hr(s,t,e,i,n){i===void 0&&(i=!0),n===void 0&&(n=!1);var o;if(t===\"top\")o=[\"contentHeight\",\"containerHeight\",\"scrollTop\",\"y\",\"up\",\"down\"];else if(t===\"left\")o=[\"contentWidth\",\"containerWidth\",\"scrollLeft\",\"x\",\"left\",\"right\"];else throw new Error(\"A proper axis should be provided\");nC(s,e,o,i,n)}function nC(s,t,e,i,n){var o=e[0],r=e[1],a=e[2],l=e[3],c=e[4],h=e[5];i===void 0&&(i=!0),n===void 0&&(n=!1);var d=s.element;s.reach[l]=null,d[a]<1&&(s.reach[l]=\"start\"),d[a]>s[o]-s[r]-1&&(s.reach[l]=\"end\"),t&&(d.dispatchEvent(cr(\"ps-scroll-\"+l)),t<0?d.dispatchEvent(cr(\"ps-scroll-\"+c)):t>0&&d.dispatchEvent(cr(\"ps-scroll-\"+h)),i&&sC(s,l)),s.reach[l]&&(t||n)&&d.dispatchEvent(cr(\"ps-\"+l+\"-reach-\"+s.reach[l]))}function st(s){return parseInt(s,10)||0}function oC(s){return Qe(s,\"input,[contenteditable]\")||Qe(s,\"select,[contenteditable]\")||Qe(s,\"textarea,[contenteditable]\")||Qe(s,\"button,[contenteditable]\")}function rC(s){var t=me(s);return st(t.width)+st(t.paddingLeft)+st(t.paddingRight)+st(t.borderLeftWidth)+st(t.borderRightWidth)}var gs={isWebKit:typeof document<\"u\"&&\"WebkitAppearance\"in document.documentElement.style,supportsTouch:typeof window<\"u\"&&(\"ontouchstart\"in window||\"maxTouchPoints\"in window.navigator&&window.navigator.maxTouchPoints>0||window.DocumentTouch&&document instanceof window.DocumentTouch),supportsIePointer:typeof navigator<\"u\"&&navigator.msMaxTouchPoints,isChrome:typeof navigator<\"u\"&&/Chrome/i.test(navigator&&navigator.userAgent)};function Se(s){var t=s.element,e=Math.floor(t.scrollTop),i=t.getBoundingClientRect();s.containerWidth=Math.round(i.width),s.containerHeight=Math.round(i.height),s.contentWidth=t.scrollWidth,s.contentHeight=t.scrollHeight,t.contains(s.scrollbarXRail)||(Ep(t,at.element.rail(\"x\")).forEach(function(n){return fs(n)}),t.appendChild(s.scrollbarXRail)),t.contains(s.scrollbarYRail)||(Ep(t,at.element.rail(\"y\")).forEach(function(n){return fs(n)}),t.appendChild(s.scrollbarYRail)),!s.settings.suppressScrollX&&s.containerWidth+s.settings.scrollXMarginOffset=s.railXWidth-s.scrollbarXWidth&&(s.scrollbarXLeft=s.railXWidth-s.scrollbarXWidth),s.scrollbarYTop>=s.railYHeight-s.scrollbarYHeight&&(s.scrollbarYTop=s.railYHeight-s.scrollbarYHeight),aC(t,s),s.scrollbarXActive?t.classList.add(at.state.active(\"x\")):(t.classList.remove(at.state.active(\"x\")),s.scrollbarXWidth=0,s.scrollbarXLeft=0,t.scrollLeft=s.isRtl===!0?s.contentWidth:0),s.scrollbarYActive?t.classList.add(at.state.active(\"y\")):(t.classList.remove(at.state.active(\"y\")),s.scrollbarYHeight=0,s.scrollbarYTop=0,t.scrollTop=0)}function kp(s,t){return s.settings.minScrollbarLength&&(t=Math.max(t,s.settings.minScrollbarLength)),s.settings.maxScrollbarLength&&(t=Math.min(t,s.settings.maxScrollbarLength)),t}function aC(s,t){var e={width:t.railXWidth},i=Math.floor(s.scrollTop);t.isRtl?e.left=t.negativeScrollAdjustment+s.scrollLeft+t.containerWidth-t.contentWidth:e.left=s.scrollLeft,t.isScrollbarXUsingBottom?e.bottom=t.scrollbarXBottom-i:e.top=t.scrollbarXTop+i,Ot(t.scrollbarXRail,e);var n={top:i,height:t.railYHeight};t.isScrollbarYUsingRight?t.isRtl?n.right=t.contentWidth-(t.negativeScrollAdjustment+s.scrollLeft)-t.scrollbarYRight-t.scrollbarYOuterWidth-9:n.right=t.scrollbarYRight-s.scrollLeft:t.isRtl?n.left=t.negativeScrollAdjustment+s.scrollLeft+t.containerWidth*2-t.contentWidth-t.scrollbarYLeft-t.scrollbarYOuterWidth:n.left=t.scrollbarYLeft+s.scrollLeft,Ot(t.scrollbarYRail,n),Ot(t.scrollbarX,{left:t.scrollbarXLeft,width:t.scrollbarXWidth-t.railBorderXWidth}),Ot(t.scrollbarY,{top:t.scrollbarYTop,height:t.scrollbarYHeight-t.railBorderYWidth})}function lC(s){s.element,s.event.bind(s.scrollbarY,\"mousedown\",function(t){return t.stopPropagation()}),s.event.bind(s.scrollbarYRail,\"mousedown\",function(t){var e=t.pageY-window.pageYOffset-s.scrollbarYRail.getBoundingClientRect().top,i=e>s.scrollbarYTop?1:-1;s.element.scrollTop+=i*s.containerHeight,Se(s),t.stopPropagation()}),s.event.bind(s.scrollbarX,\"mousedown\",function(t){return t.stopPropagation()}),s.event.bind(s.scrollbarXRail,\"mousedown\",function(t){var e=t.pageX-window.pageXOffset-s.scrollbarXRail.getBoundingClientRect().left,i=e>s.scrollbarXLeft?1:-1;s.element.scrollLeft+=i*s.containerWidth,Se(s),t.stopPropagation()})}function cC(s){Sp(s,[\"containerWidth\",\"contentWidth\",\"pageX\",\"railXWidth\",\"scrollbarX\",\"scrollbarXWidth\",\"scrollLeft\",\"x\",\"scrollbarXRail\"]),Sp(s,[\"containerHeight\",\"contentHeight\",\"pageY\",\"railYHeight\",\"scrollbarY\",\"scrollbarYHeight\",\"scrollTop\",\"y\",\"scrollbarYRail\"])}function Sp(s,t){var e=t[0],i=t[1],n=t[2],o=t[3],r=t[4],a=t[5],l=t[6],c=t[7],h=t[8],d=s.element,u=null,p=null,f=null;function b(T){T.touches&&T.touches[0]&&(T[n]=T.touches[0].pageY),d[l]=u+f*(T[n]-p),Cp(s,c),Se(s),T.stopPropagation(),T.type.startsWith(\"touch\")&&T.changedTouches.length>1&&T.preventDefault()}function v(){Ap(s,c),s[h].classList.remove(at.state.clicking),s.event.unbind(s.ownerDocument,\"mousemove\",b)}function y(T,x){u=d[l],x&&T.touches&&(T[n]=T.touches[0].pageY),p=T[n],f=(s[i]-s[e])/(s[o]-s[a]),x?s.event.bind(s.ownerDocument,\"touchmove\",b):(s.event.bind(s.ownerDocument,\"mousemove\",b),s.event.once(s.ownerDocument,\"mouseup\",v),T.preventDefault()),s[h].classList.add(at.state.clicking),T.stopPropagation()}s.event.bind(s[r],\"mousedown\",function(T){y(T)}),s.event.bind(s[r],\"touchstart\",function(T){y(T,!0)})}function hC(s){var t=s.element,e=function(){return Qe(t,\":hover\")},i=function(){return Qe(s.scrollbarX,\":focus\")||Qe(s.scrollbarY,\":focus\")};function n(o,r){var a=Math.floor(t.scrollTop);if(o===0){if(!s.scrollbarYActive)return!1;if(a===0&&r>0||a>=s.contentHeight-s.containerHeight&&r<0)return!s.settings.wheelPropagation}var l=t.scrollLeft;if(r===0){if(!s.scrollbarXActive)return!1;if(l===0&&o<0||l>=s.contentWidth-s.containerWidth&&o>0)return!s.settings.wheelPropagation}return!0}s.event.bind(s.ownerDocument,\"keydown\",function(o){if(!(o.isDefaultPrevented&&o.isDefaultPrevented()||o.defaultPrevented)&&!(!e()&&!i())){var r=document.activeElement?document.activeElement:s.ownerDocument.activeElement;if(r){if(r.tagName===\"IFRAME\")r=r.contentDocument.activeElement;else for(;r.shadowRoot;)r=r.shadowRoot.activeElement;if(oC(r))return}var a=0,l=0;switch(o.which){case 37:o.metaKey?a=-s.contentWidth:o.altKey?a=-s.containerWidth:a=-30;break;case 38:o.metaKey?l=s.contentHeight:o.altKey?l=s.containerHeight:l=30;break;case 39:o.metaKey?a=s.contentWidth:o.altKey?a=s.containerWidth:a=30;break;case 40:o.metaKey?l=-s.contentHeight:o.altKey?l=-s.containerHeight:l=-30;break;case 32:o.shiftKey?l=s.containerHeight:l=-s.containerHeight;break;case 33:l=s.containerHeight;break;case 34:l=-s.containerHeight;break;case 36:l=s.contentHeight;break;case 35:l=-s.contentHeight;break;default:return}s.settings.suppressScrollX&&a!==0||s.settings.suppressScrollY&&l!==0||(t.scrollTop-=l,t.scrollLeft+=a,Se(s),n(a,l)&&o.preventDefault())}})}function dC(s){var t=s.element;function e(r,a){var l=Math.floor(t.scrollTop),c=t.scrollTop===0,h=l+t.offsetHeight===t.scrollHeight,d=t.scrollLeft===0,u=t.scrollLeft+t.offsetWidth===t.scrollWidth,p;return Math.abs(a)>Math.abs(r)?p=c||h:p=d||u,p?!s.settings.wheelPropagation:!0}function i(r){var a=r.deltaX,l=-1*r.deltaY;return(typeof a>\"u\"||typeof l>\"u\")&&(a=-1*r.wheelDeltaX/6,l=r.wheelDeltaY/6),r.deltaMode&&r.deltaMode===1&&(a*=10,l*=10),a!==a&&l!==l&&(a=0,l=r.wheelDelta),r.shiftKey?[-l,-a]:[a,l]}function n(r,a,l){if(!gs.isWebKit&&t.querySelector(\"select:focus\"))return!0;if(!t.contains(r))return!1;for(var c=r;c&&c!==t;){if(c.classList.contains(at.element.consuming))return!0;var h=me(c);if(l&&h.overflowY.match(/(scroll|auto)/)){var d=c.scrollHeight-c.clientHeight;if(d>0&&(c.scrollTop>0&&l<0||c.scrollTop0))return!0}if(a&&h.overflowX.match(/(scroll|auto)/)){var u=c.scrollWidth-c.clientWidth;if(u>0&&(c.scrollLeft>0&&a<0||c.scrollLeft0))return!0}c=c.parentNode}return!1}function o(r){var a=i(r),l=a[0],c=a[1];if(!n(r.target,l,c)){var h=!1;s.settings.useBothWheelAxes?s.scrollbarYActive&&!s.scrollbarXActive?(c?t.scrollTop-=c*s.settings.wheelSpeed:t.scrollTop+=l*s.settings.wheelSpeed,h=!0):s.scrollbarXActive&&!s.scrollbarYActive&&(l?t.scrollLeft+=l*s.settings.wheelSpeed:t.scrollLeft-=c*s.settings.wheelSpeed,h=!0):(t.scrollTop-=c*s.settings.wheelSpeed,t.scrollLeft+=l*s.settings.wheelSpeed),Se(s),h=h||e(l,c),h&&!r.ctrlKey&&(r.stopPropagation(),r.preventDefault())}}typeof window.onwheel<\"u\"?s.event.bind(t,\"wheel\",o):typeof window.onmousewheel<\"u\"&&s.event.bind(t,\"mousewheel\",o)}function uC(s){if(!gs.supportsTouch&&!gs.supportsIePointer)return;var t=s.element;function e(f,b){var v=Math.floor(t.scrollTop),y=t.scrollLeft,T=Math.abs(f),x=Math.abs(b);if(x>T){if(b<0&&v===s.contentHeight-s.containerHeight||b>0&&v===0)return window.scrollY===0&&b>0&&gs.isChrome}else if(T>x&&(f<0&&y===s.contentWidth-s.containerWidth||f>0&&y===0))return!0;return!0}function i(f,b){t.scrollTop-=b,t.scrollLeft-=f,Se(s)}var n={},o=0,r={},a=null;function l(f){return f.targetTouches?f.targetTouches[0]:f}function c(f){return f.pointerType&&f.pointerType===\"pen\"&&f.buttons===0?!1:!!(f.targetTouches&&f.targetTouches.length===1||f.pointerType&&f.pointerType!==\"mouse\"&&f.pointerType!==f.MSPOINTER_TYPE_MOUSE)}function h(f){if(c(f)){var b=l(f);n.pageX=b.pageX,n.pageY=b.pageY,o=new Date().getTime(),a!==null&&clearInterval(a)}}function d(f,b,v){if(!t.contains(f))return!1;for(var y=f;y&&y!==t;){if(y.classList.contains(at.element.consuming))return!0;var T=me(y);if(v&&T.overflowY.match(/(scroll|auto)/)){var x=y.scrollHeight-y.clientHeight;if(x>0&&(y.scrollTop>0&&v<0||y.scrollTop0))return!0}if(b&&T.overflowX.match(/(scroll|auto)/)){var E=y.scrollWidth-y.clientWidth;if(E>0&&(y.scrollLeft>0&&b<0||y.scrollLeft0))return!0}y=y.parentNode}return!1}function u(f){if(c(f)){var b=l(f),v={pageX:b.pageX,pageY:b.pageY},y=v.pageX-n.pageX,T=v.pageY-n.pageY;if(d(f.target,y,T))return;i(y,T),n=v;var x=new Date().getTime(),E=x-o;E>0&&(r.x=y/E,r.y=T/E,o=x),e(y,T)&&f.preventDefault()}}function p(){s.settings.swipeEasing&&(clearInterval(a),a=setInterval(function(){if(s.isInitialized){clearInterval(a);return}if(!r.x&&!r.y){clearInterval(a);return}if(Math.abs(r.x)<.01&&Math.abs(r.y)<.01){clearInterval(a);return}if(!s.element){clearInterval(a);return}i(r.x*30,r.y*30),r.x*=.8,r.y*=.8},10))}gs.supportsTouch?(s.event.bind(t,\"touchstart\",h),s.event.bind(t,\"touchmove\",u),s.event.bind(t,\"touchend\",p)):gs.supportsIePointer&&(window.PointerEvent?(s.event.bind(t,\"pointerdown\",h),s.event.bind(t,\"pointermove\",u),s.event.bind(t,\"pointerup\",p)):window.MSPointerEvent&&(s.event.bind(t,\"MSPointerDown\",h),s.event.bind(t,\"MSPointerMove\",u),s.event.bind(t,\"MSPointerUp\",p)))}var pC=function(){return{handlers:[\"click-rail\",\"drag-thumb\",\"keyboard\",\"wheel\",\"touch\"],maxScrollbarLength:null,minScrollbarLength:null,scrollingThreshold:1e3,scrollXMarginOffset:0,scrollYMarginOffset:0,suppressScrollX:!1,suppressScrollY:!1,swipeEasing:!0,useBothWheelAxes:!1,wheelPropagation:!0,wheelSpeed:1}},fC={\"click-rail\":lC,\"drag-thumb\":cC,keyboard:hC,wheel:dC,touch:uC},dn=function(t,e){var i=this;if(e===void 0&&(e={}),typeof t==\"string\"&&(t=document.querySelector(t)),!t||!t.nodeName)throw new Error(\"no element is specified to initialize PerfectScrollbar\");this.element=t,t.classList.add(at.main),this.settings=pC();for(var n in e)this.settings[n]=e[n];this.containerWidth=null,this.containerHeight=null,this.contentWidth=null,this.contentHeight=null;var o=function(){return t.classList.add(at.state.focus)},r=function(){return t.classList.remove(at.state.focus)};this.isRtl=me(t).direction===\"rtl\",this.isRtl===!0&&t.classList.add(at.rtl),this.isNegativeScroll=function(){var c=t.scrollLeft,h=null;return t.scrollLeft=-1,h=t.scrollLeft<0,t.scrollLeft=c,h}(),this.negativeScrollAdjustment=this.isNegativeScroll?t.scrollWidth-t.clientWidth:0,this.event=new _s,this.ownerDocument=t.ownerDocument||document,this.scrollbarXRail=lr(at.element.rail(\"x\")),t.appendChild(this.scrollbarXRail),this.scrollbarX=lr(at.element.thumb(\"x\")),this.scrollbarXRail.appendChild(this.scrollbarX),this.scrollbarX.setAttribute(\"tabindex\",0),this.event.bind(this.scrollbarX,\"focus\",o),this.event.bind(this.scrollbarX,\"blur\",r),this.scrollbarXActive=null,this.scrollbarXWidth=null,this.scrollbarXLeft=null;var a=me(this.scrollbarXRail);this.scrollbarXBottom=parseInt(a.bottom,10),isNaN(this.scrollbarXBottom)?(this.isScrollbarXUsingBottom=!1,this.scrollbarXTop=st(a.top)):this.isScrollbarXUsingBottom=!0,this.railBorderXWidth=st(a.borderLeftWidth)+st(a.borderRightWidth),Ot(this.scrollbarXRail,{display:\"block\"}),this.railXMarginWidth=st(a.marginLeft)+st(a.marginRight),Ot(this.scrollbarXRail,{display:\"\"}),this.railXWidth=null,this.railXRatio=null,this.scrollbarYRail=lr(at.element.rail(\"y\")),t.appendChild(this.scrollbarYRail),this.scrollbarY=lr(at.element.thumb(\"y\")),this.scrollbarYRail.appendChild(this.scrollbarY),this.scrollbarY.setAttribute(\"tabindex\",0),this.event.bind(this.scrollbarY,\"focus\",o),this.event.bind(this.scrollbarY,\"blur\",r),this.scrollbarYActive=null,this.scrollbarYHeight=null,this.scrollbarYTop=null;var l=me(this.scrollbarYRail);this.scrollbarYRight=parseInt(l.right,10),isNaN(this.scrollbarYRight)?(this.isScrollbarYUsingRight=!1,this.scrollbarYLeft=st(l.left)):this.isScrollbarYUsingRight=!0,this.scrollbarYOuterWidth=this.isRtl?rC(this.scrollbarY):null,this.railBorderYWidth=st(l.borderTopWidth)+st(l.borderBottomWidth),Ot(this.scrollbarYRail,{display:\"block\"}),this.railYMarginHeight=st(l.marginTop)+st(l.marginBottom),Ot(this.scrollbarYRail,{display:\"\"}),this.railYHeight=null,this.railYRatio=null,this.reach={x:t.scrollLeft<=0?\"start\":t.scrollLeft>=this.contentWidth-this.containerWidth?\"end\":null,y:t.scrollTop<=0?\"start\":t.scrollTop>=this.contentHeight-this.containerHeight?\"end\":null},this.isAlive=!0,this.settings.handlers.forEach(function(c){return fC[c](i)}),this.lastScrollTop=Math.floor(t.scrollTop),this.lastScrollLeft=t.scrollLeft,this.event.bind(this.element,\"scroll\",function(c){return i.onScroll(c)}),Se(this)};dn.prototype.update=function(){this.isAlive&&(this.negativeScrollAdjustment=this.isNegativeScroll?this.element.scrollWidth-this.element.clientWidth:0,Ot(this.scrollbarXRail,{display:\"block\"}),Ot(this.scrollbarYRail,{display:\"block\"}),this.railXMarginWidth=st(me(this.scrollbarXRail).marginLeft)+st(me(this.scrollbarXRail).marginRight),this.railYMarginHeight=st(me(this.scrollbarYRail).marginTop)+st(me(this.scrollbarYRail).marginBottom),Ot(this.scrollbarXRail,{display:\"none\"}),Ot(this.scrollbarYRail,{display:\"none\"}),Se(this),hr(this,\"top\",0,!1,!0),hr(this,\"left\",0,!1,!0),Ot(this.scrollbarXRail,{display:\"\"}),Ot(this.scrollbarYRail,{display:\"\"}))},dn.prototype.onScroll=function(t){this.isAlive&&(Se(this),hr(this,\"top\",this.element.scrollTop-this.lastScrollTop),hr(this,\"left\",this.element.scrollLeft-this.lastScrollLeft),this.lastScrollTop=Math.floor(this.element.scrollTop),this.lastScrollLeft=this.element.scrollLeft)},dn.prototype.destroy=function(){this.isAlive&&(this.event.unbindAll(),fs(this.scrollbarX),fs(this.scrollbarY),fs(this.scrollbarXRail),fs(this.scrollbarYRail),this.removePsClasses(),this.element=null,this.scrollbarX=null,this.scrollbarY=null,this.scrollbarXRail=null,this.scrollbarYRail=null,this.isAlive=!1)},dn.prototype.removePsClasses=function(){this.element.className=this.element.className.split(\" \").filter(function(t){return!t.match(/^ps([-_].+|)$/)}).join(\" \")};const Kl=\"perfectScrollbar\",_C=\"perfect-scrollbar\",dr=\"te.perfectScrollbar\",be=\"te\",ve=\"ps\",Ul=[{te:`scrollX.${be}.${ve}`,ps:\"ps-scroll-x\"},{te:`scrollY.${be}.${ve}`,ps:\"ps-scroll-y\"},{te:`scrollUp.${be}.${ve}`,ps:\"ps-scroll-up\"},{te:`scrollDown.${be}.${ve}`,ps:\"ps-scroll-down\"},{te:`scrollLeft.${be}.${ve}`,ps:\"ps-scroll-left\"},{te:`scrollRight.${be}.${ve}`,ps:\"ps-scroll-right\"},{te:`scrollXEnd.${be}.${ve}`,ps:\"ps-x-reach-end\"},{te:`scrollYEnd.${be}.${ve}`,ps:\"ps-y-reach-end\"},{te:`scrollXStart.${be}.${ve}`,ps:\"ps-x-reach-start\"},{te:`scrollYStart.${be}.${ve}`,ps:\"ps-y-reach-start\"}],gC={handlers:[\"click-rail\",\"drag-thumb\",\"keyboard\",\"wheel\",\"touch\"],wheelSpeed:1,wheelPropagation:!0,swipeEasing:!0,minScrollbarLength:null,maxScrollbarLength:null,scrollingThreshold:1e3,useBothWheelAxes:!1,suppressScrollX:!1,suppressScrollY:!1,scrollXMarginOffset:0,scrollYMarginOffset:0,positionRight:!0},mC={handlers:\"(string|array)\",wheelSpeed:\"number\",wheelPropagation:\"boolean\",swipeEasing:\"boolean\",minScrollbarLength:\"(number|null)\",maxScrollbarLength:\"(number|null)\",scrollingThreshold:\"number\",useBothWheelAxes:\"boolean\",suppressScrollX:\"boolean\",suppressScrollY:\"boolean\",scrollXMarginOffset:\"number\",scrollYMarginOffset:\"number\",positionRight:\"boolean\"},bC={ps:\"group/ps overflow-hidden [overflow-anchor:none] touch-none\",railX:\"group/x absolute bottom-0 h-[0.9375rem] hidden opacity-0 transition-[background-color,_opacity] duration-200 ease-linear motion-reduce:transition-none z-[1035] group-[&.ps--active-x]/ps:block group-hover/ps:opacity-60 group-focus/ps:opacity-60 group-[&.ps--scrolling-x]/ps:opacity-60 hover:!opacity-90 focus:!opacity-90 [&.ps--clicking]:!opacity-90 outline-none\",railXColors:\"group-[&.ps--active-x]/ps:bg-transparent hover:!bg-[#eee] focus:!bg-[#eee] [&.ps--clicking]:!bg-[#eee] dark:hover:!bg-[#555] dark:focus:!bg-[#555] dark:[&.ps--clicking]:!bg-[#555]\",railXThumb:\"absolute bottom-0.5 rounded-md h-1.5 group-focus/ps:opacity-100 group-active/ps:opacity-100 [transition:background-color_.2s_linear,_height_.2s_ease-in-out] group-hover/x:h-[11px] group-focus/x:h-[0.6875rem] group-[&.ps--clicking]/x:bg-[#999] group-[&.ps--clicking]/x:h-[11px] outline-none\",railXThumbColors:\"bg-[#aaa] group-hover/x:bg-[#999] group-focus/x:bg-[#999]\",railY:\"group/y absolute right-0 w-[0.9375rem] hidden opacity-0 transition-[background-color,_opacity] duration-200 ease-linear motion-reduce:transition-none z-[1035] group-[&.ps--active-y]/ps:block group-hover/ps:opacity-60 group-focus/ps:opacity-60 group-[&.ps--scrolling-y]/ps:opacity-60 hover:!opacity-90 focus:!opacity-90 [&.ps--clicking]:!opacity-90 outline-none\",railYColors:\"group-[&.ps--active-y]/ps:bg-transparent hover:!bg-[#eee] focus:!bg-[#eee] [&.ps--clicking]:!bg-[#eee] dark:hover:!bg-[#555] dark:focus:!bg-[#555] dark:[&.ps--clicking]:!bg-[#555]\",railYThumb:\"absolute right-0.5 rounded-md w-1.5 group-focus/ps:opacity-100 group-active/ps:opacity-100 [transition:background-color_.2s_linear,_width_.2s_ease-in-out,_opacity] group-hover/y:w-[11px] group-focus/y:w-[0.6875rem] group-[&.ps--clicking]/y:w-[11px] outline-none\",railYThumbColors:\"bg-[#aaa] group-hover/y:bg-[#999] group-focus/y:bg-[#999] group-[&.ps--clicking]/y:bg-[#999]\"},vC={ps:\"string\",railX:\"string\",railXColors:\"string\",railXThumb:\"string\",railXThumbColors:\"string\",railY:\"string\",railYColors:\"string\",railYThumb:\"string\",railYThumbColors:\"string\"};class ms{constructor(t,e={},i={}){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this.perfectScrollbar=null,this._observer=null,this._psClasses=[{ps:\"ps__rail-x\",te:this._classes.railX,teColor:this._classes.railXColors},{ps:\"ps__rail-y\",te:this._classes.railY,teColor:this._classes.railYColors},{ps:\"ps__thumb-x\",te:this._classes.railXThumb,teColor:this._classes.railXThumbColors},{ps:\"ps__thumb-y\",te:this._classes.railYThumb,teColor:this._classes.railYThumbColors}],this._element&&(O.setData(t,dr,this),g.addClass(this._element,_C)),this.init()}static get NAME(){return Kl}get railX(){return m.findOne(\".ps__rail-x\",this._element)}get railY(){return m.findOne(\".ps__rail-y\",this._element)}_getConfig(t){const e=g.getDataAttributes(this._element);return e.handlers!==void 0&&(e.handlers=e.handlers.split(\" \")),t={...gC,...e,...t},L(Kl,t,mC),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...bC,...e,...t},L(Kl,t,vC),t}dispose(){this._options.positionRight&&this._observer.disconnect(),O.removeData(this._element,dr),this._element=null,this._dataAttrOptions=null,this._options=null,this.perfectScrollbar.destroy(),this.removeEvent(Ul),this.perfectScrollbar=null}init(){if(this.perfectScrollbar=new dn(this._element,this._options),this._addPerfectScrollbarStyles(),this._updateScrollPosition(),this.perfectScrollbar.update(),this._initEvents(Ul),this._options.positionRight){this._observer=new ResizeObserver(()=>{setTimeout(()=>{this._updateScrollPosition()},100)});const t={attributes:!0,attributeFilter:[\"class\",\"className\"]};this._observer.observe(this._element,t)}}_updateScrollPosition(){const t=getComputedStyle(this._element).getPropertyValue(\"height\"),e=getComputedStyle(this._element).getPropertyValue(\"width\");this.railX&&(this.railX.style.transform=`translateY(calc(-100% + ${this._canTransform(t)?t:\"0px\"}))`),this.railY&&(this.railY.style.transform=`translateX(calc(-100% + ${this._canTransform(e)?e:\"0px\"}))`)}_canTransform(t){return t&&t.includes(\"px\")}update(){return this.perfectScrollbar.update()}_initEvents(t=[]){t.forEach(({ps:e,te:i})=>_.on(this._element,e,n=>_.trigger(this._element,i,{e:n})))}_addPerfectScrollbarStyles(){this._psClasses.forEach(t=>{const e=m.findOne(`.${t.ps}`,this._element);g.addClass(e,t.te),g.addClass(e,t.teColor)}),g.addClass(this._element,this._classes.ps),g.removeClass(this._element,\"ps\")}removeEvent(t){let e=[];typeof t==\"string\"&&(e=Ul.filter(({te:i})=>i===t)),e.forEach(({ps:i,te:n})=>{_.off(this._element,i),_.off(this._element,n)})}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,dr);const i=typeof t==\"object\"&&t;if(!(!e&&/dispose|hide/.test(t))&&(e||(e=new ms(this,i)),typeof t==\"string\")){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}static getInstance(t){return O.getData(t,dr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const yC=\"data-te-datatable-select-ref\",TC=\"data-te-datatable-pagination-nav-ref\",EC=\"data-te-datatable-pagination-right-ref\",xC=\"data-te-datatable-pagination-left-ref\",CC=\"data-te-datatable-pagination-start-ref\",AC=\"data-te-datatable-pagination-end-ref\",wC=({text:s,entries:t,entriesOptions:e,fullPagination:i,rowsText:n,allText:o,paginationStartIconTemplate:r,paginationLeftIconTemplate:a,paginationRightIconTemplate:l,paginationEndIconTemplate:c,classes:h},d,u)=>{const p=e.map(f=>f===\"All\"?``:``).join(`\n`);return`\n
\n
\n

${n}

\n
\n \n
\n
\n
\n ${s}\n
\n
\n ${i?``:\"\"}\n \n \n ${i?``:\"\"}\n
\n
\n`},kC=\"data-te-datatable-sort-icon-ref\",SC=\"data-te-datatable-header-checkbox-ref\",OC=(s,t,e,i,n,o,r,a)=>{const l=e?`\n \n
\n \n
\n \n `:'',c=s.map((h,d)=>{const u=h.fixed?s.filter((p,f)=>p.fixed===h.fixed&&fp+f.width,0):null;return`${h.sort?`
${r}`:\"\"} ${h.label}
`});return[t?l:\"\",...c].join(`\n`)},IC=\"data-te-datatable-row-ref\",DC=\"data-te-datatable-row-checkbox-ref\",MC=\"data-te-datatable-cell-ref\",LC=({rows:s,columns:t,noFoundMessage:e,edit:i,selectable:n,loading:o,bordered:r,borderless:a,striped:l,hover:c,sm:h,classes:d})=>{const u=s.map(p=>{const f=`\n \n
\n \n
\n `,b=t.map((v,y)=>{const T={};if(v.width&&(T[\"min-width\"]=`${v.width-1}px`,T[\"max-width\"]=`${v.width}px`,T.width=`${v.width}px`),v.fixed){const E=t.filter((C,A)=>C.fixed===v.fixed&&AC+A.width,0);T[v.fixed===\"right\"?\"right\":\"left\"]=`${E}px`}return``${E}: ${T[E]}`).join(\"; \")}\" class=\"${d.rowItem} ${d.borderColor} ${i?`${d.edit}`:\"\"} ${r?`${d.tableBordered}`:\"\"} ${h?`${d.sm}`:\"\"} ${v.fixed?`${d.fixedHeader} ${d.color}`:\"\"}\" ${MC} data-te-field=\"${v.field}\" ${i&&'contenteditable=\"true\"'}>${p[v.field]}`}).join(\"\");return`${n?f:\"\"}${b}`});return s.length>0||o?u.join(`\n`):`${e}`},$C=\"data-te-datatable-inner-ref\",RC=\"data-te-datatable-header-ref\",Op=({columns:s,rows:t,noFoundMessage:e,edit:i,multi:n,selectable:o,loading:r,loadingMessage:a,pagination:l,bordered:c,borderless:h,striped:d,hover:u,fixedHeader:p,sm:f,sortIconTemplate:b,classes:v})=>{const y=LC({rows:t,columns:s,noFoundMessage:e,edit:i,loading:r,selectable:o,bordered:c,borderless:h,striped:d,hover:u,sm:f,classes:v}),T=OC(s,o,n,c,f,r,b,v);return{table:`\n
\n \n \n \n ${T}\n \n \n \n ${r?\"\":y}\n \n
\n
\n${r?`\n
\n
\n
\n
\n
\n

${a}

\n`:\"\"}\n${l.enable?wC(l,r,c):\"\"}\n `,rows:y,column:T}},PC=({rows:s,field:t,order:e})=>s.sort((n,o)=>{let r=n[t],a=o[t];return typeof r==\"string\"&&(r=r.toLowerCase()),typeof a==\"string\"&&(a=a.toLowerCase()),ra?e===\"desc\"?-1:1:0}),NC=(s,t,e)=>{if(!t)return s;const i=n=>{const o=document.createElement(\"div\");return o.innerHTML=n,n=o.textContent||o.innerText||\"\",n.toString().toLowerCase().match(t.toLowerCase())};return s.filter(n=>{if(e&&typeof e==\"string\")return i(n[e]);let o=Object.values(n);return e&&Array.isArray(e)&&(o=Object.keys(n).filter(r=>e.includes(r)).map(r=>n[r])),o.filter(r=>i(r)).length>0})},Ip=({rows:s,entries:t,activePage:e})=>{const i=e*t;return s.slice(i,i+Number(t))},un=\"datatable\",Ht=`data-te-${un}`,pn=`te.${un}`,ur=`.${pn}`,BC=`[${Ht}-inner-ref]`,Xl=`[${Ht}-cell-ref]`,HC=`[${Ht}-header-ref]`,VC=`[${Ht}-header-checkbox-ref]`,FC=`[${Ht}-pagination-right-ref]`,WC=`[${Ht}-pagination-left-ref]`,zC=`[${Ht}-pagination-start-ref]`,jC=`[${Ht}-pagination-end-ref]`,YC=`[${Ht}-pagination-nav-ref]`,KC=`[${Ht}-select-ref]`,Gl=`[${Ht}-sort-icon-ref]`,fn=`[${Ht}-row-ref]`,ql=`[${Ht}-row-checkbox-ref]`,UC=`selectRows${ur}`,Dp=`render${ur}`,XC=`rowClick${ur}`,GC=`update${ur}`,qC=`\n \n`,ZC=`\n \n`,QC=`\n \n`,JC=`\n \n`,tA=`\n \n`,eA=\"border-neutral-200 dark:border-neutral-500\",iA=\"border-none\",sA=\"relative float-left -ml-[1.5rem] mr-[6px] mt-[0.15rem] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:-mt-px checked:after:ml-[0.25rem] checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:-mt-px checked:focus:after:ml-[0.25rem] checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-l-0 checked:focus:after:border-t-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent dark:border-neutral-600 dark:checked:border-primary dark:checked:bg-primary dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:border-neutral-400\",nA=\"mb-[0.125rem] min-h-[1.5rem] pl-[1.5rem] ml-3 flex items-center\",oA=\"relative float-left -ml-[1.5rem] mr-[6px] mt-[0.15rem] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:-mt-px checked:after:ml-[0.25rem] checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:-mt-px checked:focus:after:ml-[0.25rem] checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-l-0 checked:focus:after:border-t-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent dark:border-neutral-600 dark:checked:border-primary dark:checked:bg-primary dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:border-neutral-400\",rA=\"mb-[0.125rem] min-h-[1.5rem] pl-[1.5rem] ml-3 flex items-center\",aA=\"bg-white dark:bg-neutral-800\",lA=\"py-4 pl-1 text-clip overflow-hidden text-[#212529] dark:text-white\",cA=\"focus:outline-none\",hA=\"sticky top-0 z-30\",dA=\"sticky z-10 bg-inherit\",uA=\"hover:bg-neutral-100 dark:hover:bg-neutral-700\",pA=\"pointer-events-none cursor-none text-neutral-400 dark:text-neutral-300\",fA=\"h-[2px] relative w-full overflow-hidden\",_A=\"text-center text-neutral-500 font-ligh text-sm my-4 dark:text-neutral-400\",gA=\"text-neutral-500 dark:text-neutral-300\",mA=\"text-neutral-500 dark:text-neutral-300\",bA=\"pointer-events-none cursor-none\",vA=\"h-full w-[45%] bg-primary-400 dark:bg-primary-600\",yA=\"h-full animate-[progress_3s_ease-in-out_infinite]\",TA=\"pl-2 py-3 font-light text-sm dark:text-neutral-300\",EA=\"border-b\",xA=\"flex md:flex-row justify-end items-center py-2 space-x-4 text-sm flex-col leading-[1.6]\",CA=\"border border-t-0\",AA=\"order-1 my-3 md:order-none md:my-0 md:pr-1\",wA=\"inline-block rounded p-2.5 text-xs font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",kA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",SA=\"font-normal order-2 mb-3 md:order-none md:mb-0\",OA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",IA=\"font-light\",DA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",MA=\"border-b\",LA=\"transition ease-in-out duration-300 motion-reduce:transition-none\",$A=\"whitespace-nowrap text-clip overflow-auto px-[1.4rem] py-4\",RA=\"relative\",PA=\"!bg-neutral-100 dark:!bg-neutral-600\",NA=\"flex items-center space-x-4 order-3 md:order-none\",BA=\"w-[70px]\",HA=\"!py-2\",VA=\"w-[15px] h-[10px] origin-bottom font-black mr-1 opacity-0 text-neutral-500 group-hover:opacity-100 transition hover:ease-in-out transform ease-linear duration-300 motion-reduce:transition-none dark:text-neutral-400\",FA=\"flex flex-row group\",WA=\"[&:nth-child(odd)]:bg-neutral-50 [&:nth-child(odd)]:dark:bg-neutral-700\",zA=\"border\",jA=\"border-b font-normal px-[1.4rem]\",YA=\"text-left text-sm font-light w-full leading-[1.6]\",KA={bordered:\"boolean\",borderless:\"boolean\",clickableRows:\"boolean\",defaultValue:\"string\",edit:\"boolean\",entries:\"(number|string)\",entriesOptions:\"array\",fullPagination:\"boolean\",hover:\"boolean\",loading:\"boolean\",loadingMessage:\"string\",maxWidth:\"(null|number|string)\",maxHeight:\"(null|number|string)\",multi:\"boolean\",noFoundMessage:\"string\",pagination:\"boolean\",selectable:\"boolean\",sm:\"boolean\",sortField:\"(null|string)\",sortOrder:\"string\",fixedHeader:\"boolean\",striped:\"boolean\",rowsText:\"string\",ofText:\"string\",allText:\"string\",forceSort:\"boolean\",sortIconTemplate:\"string\",paginationStartIconTemplate:\"string\",paginationEndIconTemplate:\"string\",paginationLeftIconTemplate:\"string\",paginationRightIconTemplate:\"string\"},UA={bordered:!1,borderless:!1,clickableRows:!1,defaultValue:\"-\",edit:!1,entries:10,entriesOptions:[10,25,50,200],fixedHeader:!1,fullPagination:!1,hover:!1,loading:!1,loadingMessage:\"Loading results...\",maxWidth:null,maxHeight:null,multi:!1,noFoundMessage:\"No matching results found\",pagination:!0,selectable:!1,sm:!1,sortField:null,sortOrder:\"asc\",striped:!1,rowsText:\"Rows per page:\",ofText:\"of\",allText:\"All\",forceSort:!1,sortIconTemplate:qC,paginationStartIconTemplate:ZC,paginationEndIconTemplate:tA,paginationLeftIconTemplate:QC,paginationRightIconTemplate:JC},XA={label:\"string\",field:\"string\",fixed:\"(boolean|string)\",format:\"(function|null)\",width:\"(number|null)\",sort:\"boolean\",columnIndex:\"number\"},GA={label:\"\",field:\"\",fixed:!1,format:null,width:null,sort:!0,columnIndex:0},qA={table:YA,tableHeader:jA,column:lA,pagination:xA,selectWrapper:BA,scroll:RA,tableBordered:zA,paginationBordered:CA,borderless:iA,checkboxRowWrapper:rA,checkboxRow:oA,checkboxHeaderWrapper:nA,checkboxHeader:sA,row:MA,rowItem:$A,striped:WA,sortIconWrapper:FA,sortIcon:VA,paginationRowsText:IA,paginationNav:SA,paginationButtonsWrapper:AA,hoverRow:uA,borderColor:eA,color:aA,fixedHeader:hA,fixedHeaderBody:dA,selectableRow:PA,rowAnimation:LA,sm:HA,edit:cA,selectItemsWrapper:NA,paginationStartButton:DA,paginationLeftButton:kA,paginationRightButton:OA,paginationEndButton:wA,loadingItemsWrapper:fA,loadingProgressBarWrapper:yA,loadingProgressBar:vA,loadingMessage:_A,loadingPaginationRowsText:mA,loadingPaginationSelectWrapper:bA,loadingPaginationNav:gA,loadingColumn:pA,noFoundMessageWrapper:EA,noFoundMessage:TA},ZA={table:\"string\",tableHeader:\"string\",column:\"string\",pagination:\"string\",selectWrapper:\"string\",scroll:\"string\",tableBordered:\"string\",paginationBordered:\"string\",borderless:\"string\",checkboxRowWrapper:\"string\",checkboxRow:\"string\",checkboxHeaderWrapper:\"string\",checkboxHeader:\"string\",row:\"string\",rowItem:\"string\",striped:\"string\",sortIconWrapper:\"string\",sortIcon:\"string\",paginationRowsText:\"string\",paginationNav:\"string\",paginationButtonsWrapper:\"string\",hoverRow:\"string\",borderColor:\"string\",color:\"string\",fixedHeader:\"string\",fixedHeaderBody:\"string\",selectableRow:\"string\",rowAnimation:\"string\",sm:\"string\",edit:\"string\",selectItemsWrapper:\"string\",paginationStartButton:\"string\",paginationLeftButton:\"string\",paginationRightButton:\"string\",paginationEndButton:\"string\",loadingItemsWrapper:\"string\",loadingProgressBarWrapper:\"string\",loadingProgressBar:\"string\",loadingMessage:\"string\",loadingPaginationRowsText:\"string\",loadingPaginationSelectWrapper:\"string\",loadingPaginationNav:\"string\",loadingColumn:\"string\",noFoundMessageWrapper:\"string\",noFoundMessage:\"string\"};class pr{constructor(t,e={},i={},n={}){this._element=t,this._options=this._getOptions(i),this._classes=this._getClasses(n),this._sortReverse=!1,this._activePage=0,this._search=\"\",this._searchColumn=null,this._paginationLeft=null,this._paginationRight=null,this._paginationStart=null,this._paginationEnd=null,this._select=null,this._selectInstance=null,this._selected=[],this._checkboxes=null,this._headerCheckbox=null,this._rows=this._getRows(e.rows),this._columns=this._getColumns(e.columns),this._element&&(O.setData(t,pn,this),this._perfectScrollbar=null,this._setup())}static get NAME(){return un}get columns(){return this._columns.map((t,e)=>{let i={...GA,field:`field_${e}`,columnIndex:e};return typeof t==\"string\"?i.label=t:typeof t==\"object\"&&(i={...i,...t}),L(\"column\",i,XA),i})}get rows(){return this._rows.map((t,e)=>{const i={rowIndex:e};return Array.isArray(t)?this.columns.forEach((n,o)=>{t[o]===0?i[n.field]=t[o]:i[n.field]=t[o]||this._options.defaultValue}):typeof t==\"object\"&&this.columns.forEach(n=>{t[n.field]===0?i[n.field]=t[n.field]:i[n.field]=t[n.field]||this._options.defaultValue}),i})}get searchResult(){return NC(this.rows,this._search,this._searchColumn)}get computedRows(){let t=[...this.searchResult];return this._options.sortOrder&&(t=PC({rows:t,field:this._options.sortField,order:this._options.sortOrder})),this._options.pagination&&(this._options.entries===\"All\"?t=Ip({rows:t,entries:t.length,activePage:this._activePage}):t=Ip({rows:t,entries:this._options.entries,activePage:this._activePage})),t}get pages(){return this._options.entries===\"All\"?1:Math.ceil(this.searchResult.length/this._options.entries)}get navigationText(){const t=this._activePage*this._options.entries;return this.searchResult.length===0?`0 ${this._options.ofText} 0`:this._options.entries===\"All\"?`1 - ${this.searchResult.length} ${this._options.ofText} ${this.searchResult.length}`:`${t+1} - ${this.computedRows.length+t} ${this._options.ofText} ${this.searchResult.length}`}get tableOptions(){return{classes:this._classes,columns:this.columns,rows:this.computedRows,noFoundMessage:this._options.noFoundMessage,edit:this._options.edit,loading:this._options.loading,loaderClass:this._options.loaderClass,loadingMessage:this._options.loadingMessage,selectable:this._options.selectable,multi:this._options.multi,bordered:this._options.bordered,borderless:this._options.borderless,striped:this._options.striped,hover:this._options.hover,fixedHeader:this._options.fixedHeader,sm:this._options.sm,sortIconTemplate:this._options.sortIconTemplate,pagination:{enable:this._options.pagination,text:this.navigationText,entries:this._options.entries,entriesOptions:this._options.entriesOptions,fullPagination:this._options.fullPagination,rowsText:this._options.rowsText,ofText:this._options.ofText,allText:this._options.allText,paginationStartIconTemplate:this._options.paginationStartIconTemplate,paginationLeftIconTemplate:this._options.paginationLeftIconTemplate,paginationRightIconTemplate:this._options.paginationRightIconTemplate,paginationEndIconTemplate:this._options.paginationEndIconTemplate,classes:this._classes},forceSort:this._options.forceSort}}update(t,e={}){t&&t.rows&&(this._rows=t.rows),t&&t.columns&&(this._columns=t.columns),this._clearClassList(e),this._options=this._getOptions({...this._options,...e}),this._setup(),this._performSort()}dispose(){this._selectInstance&&this._selectInstance.dispose(),O.removeData(this._element,pn),this._removeEventListeners(),this._perfectScrollbar.destroy(),this._element=null}search(t,e){this._search=t,this._searchColumn=e,this._activePage=0,this._options.pagination&&this._toggleDisableState(),this._renderRows(),this._options.maxHeight&&(this._perfectScrollbar.element.scrollTop=0,this._perfectScrollbar.update())}sort(t,e=\"asc\"){this._options.sortOrder=e,typeof t==\"string\"?this._options.sortField=this.columns.find(n=>n.label===t).field:this._options.sortField=t.field;const i=m.findOne(`[data-te-sort=\"${this._options.sortField}\"]`,this._element);this._activePage=0,this._toggleDisableState(),this._renderRows(),this._setActiveSortIcon(i)}setActivePage(t){t{this._options[e]&&!t[e]&&g.removeDataAttribute(`data-te-${e}`)})}_emitSelectEvent(){_.trigger(this._element,UC,{selectedRows:this.rows.filter(t=>this._selected.indexOf(t.rowIndex)!==-1),selectedIndexes:this._selected,allSelected:this._selected.length===this.rows.length})}_getRows(t=[]){const e=m.findOne(\"tbody\",this._element);return e?[...m.find(\"tr\",e).map(n=>m.find(\"td\",n).map(o=>o.innerHTML)),...t]:t}_getColumns(t=[]){const e=m.findOne(\"thead\",this._element);if(!e)return t;const i=m.findOne(\"tr\",e);return[...m.find(\"th\",i).map(o=>({label:o.innerHTML,...g.getDataAttributes(o)})),...t]}_getCSSValue(t){return typeof t==\"string\"?t:`${t}px`}_getOptions(t){const e={...UA,...g.getDataAttributes(this._element),...t};return L(un,e,KA),e}_setActiveRows(){m.find(fn,this._element).forEach(t=>{this._selected.includes(g.getDataAttribute(t,\"index\"))?g.addClass(t,`active ${this._classes.selectableRow}`):g.removeClass(t,`active ${this._classes.selectableRow}`)})}_setEntries(t){this._options=this._getOptions({...this._options,entries:t.target.value}),this._activePage>this.pages-1&&(this._activePage=this.pages-1),this._toggleDisableState(),this._renderRows()}_setSelected(){m.find(ql,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"rowIndex\");t.checked=this._selected.includes(e)}),this._setActiveRows()}_setActiveSortIcon(t){m.find(Gl,this._element).forEach(e=>{const i=this._options.sortOrder===\"desc\"&&e===t?180:0;g.style(e,{transform:`rotate(${i}deg)`}),e===t&&this._options.sortOrder?g.addClass(e,\"opacity-100\"):g.removeClass(e,\"opacity-100\")})}_setup(){this._renderTable(),this._options.pagination&&this._setupPagination(),this._options.edit&&this._setupEditable(),this._options.clickableRows&&this._setupClickableRows(),this._options.selectable&&this._setupSelectable(),this._setupScroll(),this._setupSort()}_setupClickableRows(){m.find(fn,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"index\");g.addClass(t,\"cursor-pointer\"),_.on(t,\"click\",i=>{m.matches(i.target,ql)||_.trigger(this._element,XC,{index:e,row:this.rows[e]})})})}_setupEditable(){m.find(fn,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"index\");m.find(Xl,t).forEach(i=>{_.on(i,\"input\",n=>this._updateRow(n,e))})})}_setupScroll(){const t=m.findOne(BC,this._element),e={};if(this._options.maxHeight&&(e.maxHeight=this._getCSSValue(this._options.maxHeight)),this._options.maxWidth){const i=this._getCSSValue(this._options.maxWidth);e.maxWidth=i,g.style(this._element,{maxWidth:i})}if(g.style(t,e),g.addClass(t,`${this._classes.scroll}`),this._options.fixedHeader){let i=m.find(HC,this._element);this._options.selectable&&(i=i.filter((n,o)=>(g.addClass(n,`${this._classes.fixedHeader} ${this._classes.color}`),o!==0))),i.forEach((n,o)=>{g.addClass(n,`${this._classes.fixedHeader} ${this._classes.color}`),this.columns[o].fixed&&g.addClass(n,\"!z-40\")})}this._perfectScrollbar=new ms(t)}_setupSort(){m.find(Gl,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"sort\"),[i]=m.parents(t,\"th\");if(this.columns.sort)g.addClass(i,\"cursor-pointer\");else return;e===this._options.sortField&&this._setActiveSortIcon(t),_.on(i,\"click\",()=>{this._options.sortField===e&&this._options.sortOrder===\"asc\"?this._options.sortOrder=\"desc\":this._options.sortField===e&&this._options.sortOrder===\"desc\"?this._options.sortOrder=this._options.forceSort?\"asc\":null:this._options.sortOrder=\"asc\",this._options.sortField=e,this._activePage=0,this._performSort(),this._setActiveSortIcon(t)})})}_performSort(){this._toggleDisableState(),this._renderRows()}_setupSelectable(){this._checkboxes=m.find(ql,this._element),this._headerCheckbox=m.findOne(VC,this._element),_.on(this._headerCheckbox,\"input\",t=>this._toggleSelectAll(t)),this._checkboxes.forEach(t=>{const e=g.getDataAttribute(t,\"rowIndex\");_.on(t,\"input\",i=>this._toggleSelectRow(i,e))})}_setupPagination(){this._paginationRight=m.findOne(FC,this._element),this._paginationLeft=m.findOne(WC,this._element),_.on(this._paginationRight,\"click\",()=>this._changeActivePage(this._activePage+1)),_.on(this._paginationLeft,\"click\",()=>this._changeActivePage(this._activePage-1)),this._options.fullPagination&&(this._paginationStart=m.findOne(zC,this._element),this._paginationEnd=m.findOne(jC,this._element),_.on(this._paginationStart,\"click\",()=>this._changeActivePage(0)),_.on(this._paginationEnd,\"click\",()=>this._changeActivePage(this.pages-1))),this._toggleDisableState(),this._setupPaginationSelect()}_setupPaginationSelect(){this._select=m.findOne(KC,this._element),this._selectInstance=new on(this._select),_.on(this._select,\"valueChange.te.select\",t=>this._setEntries(t))}_removeEventListeners(){this._options.pagination&&(_.off(this._paginationRight,\"click\"),_.off(this._paginationLeft,\"click\"),_.off(this._select,\"valueChange.te.select\"),this._options.fullPagination&&(_.off(this._paginationStart,\"click\"),_.off(this._paginationEnd,\"click\"))),this._options.edit&&m.find(Xl,this._element).forEach(t=>{_.off(t,\"input\")}),this._options.clickableRows&&m.find(fn,this._element).forEach(t=>{_.off(t,\"click\")}),m.find(Gl,this._element).forEach(t=>{const[e]=m.parents(t,\"th\");_.off(e,\"click\")}),this._options.selectable&&(_.off(this._headerCheckbox,\"input\"),this._checkboxes.forEach(t=>{_.off(t,\"input\")}))}_renderTable(){this._element.innerHTML=Op(this.tableOptions).table,this._formatCells(),_.trigger(this._element,Dp)}_renderRows(){const t=m.findOne(\"tbody\",this._element);if(this._options.pagination){const e=m.findOne(YC,this._element);e.innerText=this.navigationText}t.innerHTML=Op(this.tableOptions).rows,this._formatCells(),this._options.edit&&this._setupEditable(),this._options.selectable&&(this._setupSelectable(),this._setSelected()),this._options.clickableRows&&this._setupClickableRows(),_.trigger(this._element,Dp)}_formatCells(){m.find(fn,this._element).forEach(e=>{const i=g.getDataAttribute(e,\"index\");m.find(Xl,e).forEach(o=>{const r=g.getDataAttribute(o,\"field\"),a=this.columns.find(l=>l.field===r);a&&a.format!==null&&a.format(o,this.rows[i][r])})})}_toggleDisableState(){this._options.pagination!==!1&&(this._activePage===0||this._options.loading?(this._paginationLeft.setAttribute(\"disabled\",\"\"),this._options.fullPagination&&this._paginationStart.setAttribute(\"disabled\",\"\")):(this._paginationLeft.removeAttribute(\"disabled\"),this._options.fullPagination&&this._paginationStart.removeAttribute(\"disabled\")),this._activePage===this.pages-1||this._options.loading||this.pages===0?(this._paginationRight.setAttribute(\"disabled\",\"\"),this._options.fullPagination&&this._paginationEnd.setAttribute(\"disabled\",\"\")):(this._paginationRight.removeAttribute(\"disabled\"),this._options.fullPagination&&this._paginationEnd.removeAttribute(\"disabled\")))}_toggleSelectAll(t){t.target.checked?this._selected=this.rows.map(e=>e.rowIndex):this._selected=[],this._setSelected(),this._emitSelectEvent()}_toggleSelectRow(t,e){t.target.checked?this._options.multi&&!this._selected.includes(e)?this._selected=[...this._selected,e]:(this._selected=[e],this._checkboxes.forEach(i=>{i!==t.target&&(i.checked=!1)})):this._selected=this._selected.filter(i=>i!==e),this._options.multi&&!t.target.checked&&(this._headerCheckbox.checked=!1),this._setActiveRows(),this._emitSelectEvent()}_updateRow(t,e){const i=g.getDataAttribute(t.target,\"field\"),n=t.target.textContent,o=this._rows[e];if(Array.isArray(o)){const a=this.columns.find(l=>l.field===i).columnIndex;o[a]=n}else o[i]=n;_.trigger(this._element,GC,{rows:this._rows,columns:this._columns})}static jQueryInterface(t,e,i){return this.each(function(){let n=O.getData(this,pn);const o=typeof t==\"object\"&&t;if(!(!n&&/dispose/.test(t))&&(n||(n=new pr(this,o,e)),typeof t==\"string\")){if(typeof n[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);n[t](e,i)}})}static getInstance(t){return O.getData(t,pn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Mp=\"rating\",fr=\"te.rating\",QA=\"data-te-rating-init\",JA=\"[data-te-rating-icon-ref]\",bs=`.${fr}`,tw=\"ArrowLeft\",ew=\"ArrowRight\",iw={tooltip:\"string\",value:\"(string|number)\",readonly:\"boolean\",after:\"string\",before:\"string\",dynamic:\"boolean\",active:\"string\"},sw={tooltip:\"top\",value:\"\",readonly:!1,after:\"\",before:\"\",dynamic:!1,active:\"fill-current\"},Lp=`onSelect${bs}`,nw=`onHover${bs}`,$p=`keyup${bs}`,Rp=`focusout${bs}`,Pp=`keydown${bs}`,Np=`mousedown${bs}`;class Bp{constructor(t,e){this._element=t,this._icons=m.find(JA,this._element),this._options=this._getConfig(e),this._index=-1,this._savedIndex=null,this._originalClassList=[],this._originalIcons=[],this._fn={},this._tooltips=[],this._element&&(O.setData(t,fr,this),this._init())}static get NAME(){return Mp}dispose(){O.removeData(this._element,fr),this._options.readonly||(_.off(this._element,$p),_.off(this._element,Rp),_.off(this._element,Pp),this._element.removeEventListener(\"mouseleave\",this._fn.mouseleave),this._icons.forEach((t,e)=>{_.off(t,Np),t.removeEventListener(\"mouseenter\",this._fn.mouseenter[e]),g.removeClass(t,\"cursor-pointer\")}),this._tooltips.forEach(t=>{t._element.removeAttribute(QA),t.dispose()}),this._icons.forEach(t=>t.removeAttribute(\"tabIndex\"))),this._element=null}_init(){this._options.readonly||(this._bindMouseEnter(),this._bindMouseLeave(),this._bindMouseDown(),this._bindKeyDown(),this._bindKeyUp(),this._bindFocusLost(),this._icons.forEach(t=>{g.addClass(t,\"cursor-pointer\")})),this._options.dynamic&&(this._saveOriginalClassList(),this._saveOriginalIcons()),this._setCustomText(),this._setToolTips(),this._options.value&&(this._index=this._options.value-1,this._updateRating(this._index))}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...sw,...e,...t},L(Mp,t,iw),t}_bindMouseEnter(){this._fn.mouseenter=[],this._icons.forEach((t,e)=>{t.addEventListener(\"mouseenter\",this._fn.mouseenter[e]=i=>{this._index=this._icons.indexOf(i.target),this._updateRating(this._index),this._triggerEvents(t,nw)})})}_bindMouseLeave(){this._element.addEventListener(\"mouseleave\",this._fn.mouseleave=()=>{this._savedIndex!==null?(this._updateRating(this._savedIndex),this._index=this._savedIndex):this._options.value?(this._updateRating(this._options.value-1),this._index=this._options.value-1):(this._index=-1,this._clearRating())})}_bindMouseDown(){this._icons.forEach(t=>{_.on(t,Np,()=>{this._setElementOutline(\"none\"),this._savedIndex=this._index,this._triggerEvents(t,Lp)})})}_bindKeyDown(){this._element.tabIndex=0,_.on(this._element,Pp,t=>this._updateAfterKeyDown(t))}_bindKeyUp(){_.on(this._element,$p,()=>this._setElementOutline(\"auto\"))}_bindFocusLost(){_.on(this._element,Rp,()=>this._setElementOutline(\"none\"))}_setElementOutline(t){this._element.style.outline=t}_triggerEvents(t,e){_.trigger(t,e,{value:this._index+1})}_updateAfterKeyDown(t){const e=this._icons.length-1,i=this._index;t.key===ew&&this._index-1&&(this._index-=1),i!==this._index&&(this._savedIndex=this._index,this._updateRating(this._savedIndex),this._triggerEvents(this._icons[this._savedIndex],Lp))}_updateRating(t){this._clearRating(),this._options.dynamic&&this._restoreOriginalIcon(t),this._icons.forEach((e,i)=>{i<=t&&g.addClass(e.querySelector(\"svg\"),this._options.active)})}_clearRating(){this._icons.forEach((t,e)=>{const i=t.querySelector(\"svg\");this._options.dynamic&&(t.classList=this._originalClassList[e],i.innerHTML=this._originalIcons[e]),g.removeClass(i,this._options.active)})}_setToolTips(){this._icons.forEach((t,e)=>{const i=g.getDataAttribute(t,\"toggle\");t.title&&!i&&(g.setDataAttribute(t,\"toggle\",\"tooltip\"),this._tooltips[e]=new is(t,{placement:this._options.tooltip}))})}_setCustomText(){this._icons.forEach(t=>{const e=g.getDataAttribute(t,\"after\"),i=g.getDataAttribute(t,\"before\");e&&t.insertAdjacentHTML(\"afterEnd\",e),i&&t.insertAdjacentHTML(\"beforeBegin\",i)})}_saveOriginalClassList(){this._icons.forEach(t=>{const e=t.classList.value;this._originalClassList.push(e)})}_saveOriginalIcons(){this._icons.forEach(t=>{const e=t.querySelector(\"svg\").innerHTML;this._originalIcons.push(e)})}_restoreOriginalIcon(t){const e=this._originalClassList[t],i=this._originalIcons[t];this._icons.forEach((n,o)=>{if(o<=t){const r=n.querySelector(\"svg\");r.innerHTML=i,n.classList=e}})}static getInstance(t){return O.getData(t,fr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Zl=\"popconfirm\",_n=\"te.popconfirm\",Hp=`.${_n}`,ow=`cancel${Hp}`,rw=`confirm${Hp}`,aw=\"[data-te-popconfirm-body]\",Ql=\"data-te-popconfirm-popover\",lw=\"data-te-popconfirm-modal\",Vp=\"data-te-popconfirm-backdrop\",cw={popconfirmMode:\"string\",message:\"string\",cancelText:\"(null|string)\",okText:\"(null|string)\",popconfirmIconTemplate:\"string\",cancelLabel:\"(null|string)\",confirmLabel:\"(null|string)\",position:\"(null|string)\"},hw={popconfirmMode:\"inline\",message:\"Are you sure?\",cancelText:\"Cancel\",okText:\"OK\",popconfirmIconTemplate:\"\",cancelLabel:\"Cancel\",confirmLabel:\"Confirm\",position:\"bottom\"},dw={backdrop:\"string\",body:\"string\",btnCancel:\"string\",btnConfirm:\"string\",btnsContainer:\"string\",fade:\"string\",icon:\"string\",message:\"string\",messageText:\"string\",modal:\"string\",popover:\"string\"},uw={backdrop:\"h-full w-full z-[1070] fixed top-0 left-0 bg-[#00000066] flex justify-center items-center\",body:\"p-[1rem] bg-white rounded-[0.5rem] opacity-0 dark:bg-neutral-700\",btnCancel:\"inline-block rounded bg-primary-100 px-4 pb-[5px] pt-[6px] text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200\",btnConfirm:\"inline-block rounded bg-primary px-4 pb-[5px] pt-[6px] text-xs font-medium uppercase leading-normal text-white shadow-[0_4px_9px_-4px_#3b71ca] transition duration-150 ease-in-out hover:bg-primary-600 hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:bg-primary-600 focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:outline-none focus:ring-0 active:bg-primary-700 active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] dark:shadow-[0_4px_9px_-4px_rgba(59,113,202,0.5)] dark:hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)] dark:focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)] dark:active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)]\",btnsContainer:\"flex justify-end space-x-2\",fade:\"transition-opacity duration-[150ms] ease-linear\",icon:\"pr-2\",message:\"flex mb-3\",messageText:\"text-neutral-600 dark:text-white\",modal:\"absolute w-[300px] z-[1080] shadow-sm rounded-[0.5rem]\",popover:\"w-[300px] border-0 rounded-[0.5rem] z-[1080] shadow-sm\"};class _r{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._popper=null,this._cancelButton=\"\",this._confirmButton=\"\",this._isOpen=!1,this._uid=this._element.id?`popconfirm-${this._element.id}`:bt(\"popconfirm-\"),t&&O.setData(t,_n,this),this._clickHandler=this.open.bind(this),_.on(this._element,\"click\",this._clickHandler)}static get NAME(){return Zl}get container(){return m.findOne(`#${this._uid}`)}get popconfirmBody(){return m.findOne(aw,this.container)}dispose(){(this._isOpen||this.container!==null)&&this.close(),O.removeData(this._element,_n),_.off(this._element,\"click\",this._clickHandler),this._element=null}open(){this._isOpen||(this._options.popconfirmMode===\"inline\"?this._openPopover(this._getPopoverTemplate()):this._openModal(this._getModalTemplate()),this._handleCancelButtonClick(),this._handleConfirmButtonClick(),this._listenToEscapeKey(),this._listenToOutsideClick())}close(){if(this._isOpen){if(this._popper!==null||m.findOne(`[${Ql}]`)!==null)_.on(this.popconfirmBody,\"transitionend\",this._handlePopconfirmTransitionEnd.bind(this)),g.removeClass(this.popconfirmBody,\"opacity-100\");else{const t=m.findOne(`[${Vp}]`);g.removeClass(this.popconfirmBody,\"opacity-100\"),document.body.removeChild(t),this._isOpen=!1}_.off(document,\"click\",this._handleOutsideClick.bind(this)),_.off(document,\"keydown\",this._handleEscapeKey.bind(this))}}_handlePopconfirmTransitionEnd(t){if(t.target!==this.popconfirmBody)return;const e=m.findOne(`[${Ql}]`);_.off(this.popconfirmBody,\"transitionend\"),this._isOpen&&t&&t.propertyName===\"opacity\"&&(this._popper.destroy(),e&&document.body.removeChild(e),this._isOpen=!1)}_getPopoverTemplate(){const t=$(\"div\"),e=this._getPopconfirmTemplate();return t.setAttribute(Ql,\"\"),g.addClass(t,this._classes.popover),t.id=this._uid,t.innerHTML=e,t}_getModalTemplate(){const t=$(\"div\"),e=this._getPopconfirmTemplate();return t.setAttribute(lw,\"\"),g.addClass(t,`${this._classes.modal}`),t.id=this._uid,t.innerHTML=e,t}_getPopconfirmTemplate(){return`
\n

\n ${this._options.popconfirmIconTemplate?`${this._options.popconfirmIconTemplate}`:\"\"}\n ${this._options.message}\n

\n
\n ${this._options.cancelText?``:\"\"}\n \n
\n
`}_getConfig(t){return t={...hw,...g.getDataAttributes(this._element),...t},L(Zl,t,cw),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...uw,...e,...t},L(Zl,t,dw),t}_openPopover(t){this._popper=Fe(this._element,t,{placement:this._translatePositionValue(),modifiers:[{name:\"offset\",options:{offset:[0,5]}}]}),document.body.appendChild(t),setTimeout(()=>{g.addClass(this.popconfirmBody,`${this._classes.fade} opacity-100`),this._isOpen=!0},0)}_openModal(t){const e=$(\"div\");e.setAttribute(Vp,\"\"),g.addClass(e,this._classes.backdrop),document.body.appendChild(e),e.appendChild(t),g.addClass(this.popconfirmBody,\"opacity-100\"),this._isOpen=!0}_handleCancelButtonClick(){const t=this.container;this._cancelButton=m.findOne(\"#popconfirm-button-cancel\",t),Ye.getOrCreateInstance(this._cancelButton,{rippleColor:\"light\"}),this._cancelButton!==null&&_.on(this._cancelButton,\"click\",()=>{this.close(),_.trigger(this._element,ow)})}_handleConfirmButtonClick(){const t=this.container;this._confirmButton=m.findOne(\"#popconfirm-button-confirm\",t),Ye.getOrCreateInstance(this._confirmButton,{rippleColor:\"light\"}),_.on(this._confirmButton,\"click\",()=>{this.close(),_.trigger(this._element,rw)})}_listenToEscapeKey(){_.on(document,\"keydown\",this._handleEscapeKey.bind(this))}_handleEscapeKey(t){t.keyCode===xi&&this.close()}_listenToOutsideClick(){_.on(document,\"click\",this._handleOutsideClick.bind(this))}_handleOutsideClick(t){const e=this.container,i=t.target===e,n=e&&e.contains(t.target),o=t.target===this._element,r=this._element&&this._element.contains(t.target);!i&&!n&&!o&&!r&&this.close()}_translatePositionValue(){switch(this._options.position){case\"top left\":return\"top-end\";case\"top\":return\"top\";case\"top right\":return\"top-start\";case\"bottom left\":return\"bottom-end\";case\"bottom\":return\"bottom\";case\"bottom right\":return\"bottom-start\";case\"left\":return\"left\";case\"left top\":return\"left-end\";case\"left bottom\":return\"left-start\";case\"right\":return\"right\";case\"right top\":return\"right-end\";case\"right bottom\":return\"right-start\";case void 0:return\"bottom\";default:return\"bottom\"}}static jQueryInterface(t,e){return this.each(function(){const i=O.getData(this,_n),n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))){if(!i)return new _r(this,n);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}}})}static getInstance(t){return O.getData(t,_n)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Jl=\"lightbox\",gn=\"te.lightbox\",vs=`click${`.${gn}`}.data-api`,Fp=\"[data-te-lightbox-init]\",pw=`${Fp} img:not([data-te-lightbox-disabled])`,Wp=\"data-te-lightbox-caption\",fw=\"data-te-lightbox-disabled\",ye=\"data-te-lightbox-active\",_w=`\n \n\n`,gw=`\n \n\n`,mw=`\n \n\n`,bw=`\n \n\n`,vw=`\n \n\n`,yw=`\n\n\n`,Tw=`\n \n\n`,Ew={container:\"string\",zoomLevel:\"(number|string)\",prevIconTemplate:\"string\",nextIconTemplate:\"string\",showFullscreenIconTemplate:\"string\",hideFullscreenIconTemplate:\"string\",zoomInIconTemplate:\"string\",closeIconTemplate:\"string\",zoomOutIconTemplate:\"string\",spinnerContent:\"string\"},xw={container:\"body\",zoomLevel:1,prevIconTemplate:_w,nextIconTemplate:gw,showFullscreenIconTemplate:mw,hideFullscreenIconTemplate:bw,zoomInIconTemplate:vw,zoomOutIconTemplate:yw,closeIconTemplate:Tw,spinnerContent:\"Loading...\"},Cw={caption:\"text-white text-ellipsis overflow-hidden whitespace-nowrap mx-[10px] text-center\",captionWrapper:\"fixed left-0 bottom-0 w-full h-[50px] flex justify-center items-center\",closeBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",fullscreenBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",gallery:\"invisible fixed left-0 top-0 w-full h-full z-[1100] pointer-events-none opacity-0 bg-[#000000e6] transition-all duration-[400ms] motion-reduce:transition-none\",galleryContent:\"fixed top-[50px] left-[50px] w-[calc(100%-100px)] h-[calc(100%-100px)]\",galleryCounter:\"flex justify-center items-center px-[10px] mb-0 h-full text-[#b3b3b3]\",img:\"absolute left-0 top-0 w-full max-h-full h-auto cursor-pointer pointer-events-auto\",imgWrapper:\"absolute top-0 left-0 w-full h-full opacity-0 transform scale-[0.25] transition-all duration-[400ms] ease-out pointer-events-none motion-reduce:transition-none motion-reduce:transform-none\",leftTools:\"float-left h-full\",loader:\"fixed left-0 top-0 z-[2] w-full h-full text-neutral-50 opacity-1 flex justify-center items-center pointer-events-none transition-opacity duration-[1000ms] motion-reduce:transition-none\",nextBtn:\"border-none bg-transparent w-full h-[50px] flex justify-center items-center text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",nextBtnWrapper:\"fixed right-0 top-0 w-[50px] h-full flex justify-center items-center transition-opacity duration-[400ms] motion-reduce:transition-none\",prevBtn:\"border-none bg-transparent w-full h-[50px] flex justify-center items-center text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",prevBtnWrapper:\"fixed left-0 top-0 w-[50px] h-full flex justify-center items-center transition-opacity duration-[400ms] motion-reduce:transition-none\",rightTools:\"float-right\",spinner:\"inline-block h-8 w-8 animate-[spinner-grow_0.75s_linear_infinite] rounded-full bg-current align-[-0.125em] motion-reduce:animate-[spinner-grow_1.5s_linear_infinite]\",spinnerContent:\"!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]\",toolbar:\"absolute top-0 left-0 w-full h-[50px] z-20 transition-opacity duration-[400ms] motion-reduce:transition-none\",vertical:\"h-full max-h-full w-auto\",zoomBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\"},Aw={caption:\"string\",captionWrapper:\"string\",closeBtn:\"string\",fullscreenBtn:\"string\",gallery:\"string\",galleryContent:\"string\",galleryCounter:\"string\",img:\"string\",imgWrapper:\"string\",leftTools:\"string\",loader:\"string\",nextBtn:\"string\",nextBtnWrapper:\"string\",prevBtn:\"string\",prevBtnWrapper:\"string\",rightTools:\"string\",spinner:\"string\",spinnerContent:\"string\",toolbar:\"string\",vertical:\"string\",zoomBtn:\"string\"};class ys{constructor(t,e={},i){this._element=t,this._options=e,this._classes=this._getClasses(i),this._getContainer(),this._id=`lightbox-${Math.random().toString(36).substr(2,9)}`,this._activeImg=0,this._images=[],this._zoom=1,this._gallery=null,this._galleryToolbar=null,this._galleryContent=null,this._loader=null,this._imgCounter=null,this._animating=!1,this._fullscreen=!1,this._zoomBtn=null,this._fullscreenBtn=null,this._toolsToggleTimer=0,this._mousedown=!1,this._mousedownPositionX=0,this._mousedownPositionY=0,this._originalPositionX=0,this._originalPositionY=0,this._positionX=0,this._positionY=0,this._zoomTimer=0,this._tapCounter=0,this._tapTime=0,this._rightArrow=null,this._leftArrowWrapper=null,this._rightArrowWrapper=null,this._initiated=!1,this._multitouch=!1,this._touchZoomPosition=[],this._element&&(O.setData(t,gn,this),this.init())}static get NAME(){return Jl}get activeImg(){return this._activeImg}get currentImg(){return m.findOne(`[${ye}]`,this._galleryContent)}get options(){const t={...xw,...g.getDataAttributes(this._element),...this._options};return L(Jl,t,Ew),t}init(){this._initiated||(this._appendTemplate(),this._initiated=!0)}open(t=0){this._getImages(),this._setActiveImg(t),this._sortImages(),this._triggerEvents(\"open\",\"opened\"),this._loadImages().then(e=>{this._resizeImages(e),this._toggleTemplate(),this._addEvents(),this._focusFullscreenBtn()})}close(){this.reset(),this._removeEvents(),this._toggleTemplate(),this._triggerEvents(\"close\",\"closed\")}slide(t=\"right\"){this._animating===!0||this._images.length<=1||(this._triggerEvents(\"slide\",\"slided\"),this._beforeSlideEvents(),t===\"right\"&&this._slideHorizontally(t),t===\"left\"&&this._slideHorizontally(t),t===\"first\"&&this._slideToTarget(t),t===\"last\"&&this._slideToTarget(t),this._afterSlideEvents())}zoomIn(){this._zoom>=3||(this._triggerEvents(\"zoomIn\",\"zoomedIn\"),this._zoom+=parseFloat(this.options.zoomLevel),g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn())}zoomOut(){this._zoom<=1||(this._triggerEvents(\"zoomOut\",\"zoomedOut\"),this._zoom-=parseFloat(this.options.zoomLevel),g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn(),this._updateImgPosition())}toggleFullscreen(){this._fullscreen===!1?(this._fullscreenBtn.setAttribute(ye,\"\"),this._fullscreenBtn.innerHTML=this.options.hideFullscreenIconTemplate,this._gallery.requestFullscreen&&this._gallery.requestFullscreen(),this._fullscreen=!0):(this._fullscreenBtn.removeAttribute(ye),document.exitFullscreen&&document.exitFullscreen(),this._fullscreen=!1)}reset(){this._restoreDefaultFullscreen(),this._restoreDefaultPosition(),this._restoreDefaultZoom(),clearTimeout(this._toolsToggleTimer),clearTimeout(this._doubleTapTimer)}dispose(){_.off(document,vs,pw,this.toggle),this._galleryContent&&this._removeEvents(),this._gallery&&this._gallery.remove(),O.removeData(this._element,gn),this._element=null}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Cw,...e,...t},L(Jl,t,Aw),t}_getImages(){const e=m.find(\"img\",this._element).filter(i=>!i.hasAttribute(fw));this._images=e}_getContainer(){this._container=m.findOne(this.options.container)}_setActiveImg(t){this._activeImg=typeof t==\"number\"?t:this._images.indexOf(t.target)}_appendTemplate(){this._gallery=$(\"div\"),g.addClass(this._gallery,`${this._classes.gallery}`),this._element.dataset.id=this._id,this._gallery.id=this._id,this._appendLoader(),this._appendToolbar(),this._appendContent(),this._appendArrows(),this._appendCaption(),this._container.append(this._gallery)}_appendToolbar(){this._galleryToolbar=$(\"div\"),this._imgCounter=$(\"p\"),this._fullscreenBtn=$(\"button\"),this._zoomBtn=$(\"button\");const t=$(\"button\"),e=$(\"div\"),i=$(\"div\");g.addClass(this._galleryToolbar,`${this._classes.toolbar}`),g.addClass(this._imgCounter,`${this._classes.galleryCounter}`),g.addClass(this._fullscreenBtn,`${this._classes.fullscreenBtn}`),g.addClass(this._zoomBtn,`${this._classes.zoomInBtn}`),g.addClass(this._zoomBtn,this._classes.zoomBtn),g.addClass(e,`${this._classes.leftTools}`),g.addClass(i,`${this._classes.rightTools}`),g.addClass(t,`${this._classes.closeBtn}`),this._fullscreenBtn.innerHTML=this.options.showFullscreenIconTemplate,t.innerHTML=this.options.closeIconTemplate,this._zoomBtn.innerHTML=this.options.zoomInIconTemplate,this._fullscreenBtn.setAttribute(\"aria-label\",\"Toggle fullscreen\"),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom in\"),t.setAttribute(\"aria-label\",\"Close\"),_.on(this._fullscreenBtn,vs,()=>this.toggleFullscreen()),_.on(this._zoomBtn,vs,()=>this._toggleZoom()),_.on(t,vs,()=>this.close()),e.append(this._imgCounter),i.append(this._fullscreenBtn),i.append(this._zoomBtn),i.append(t),this._galleryToolbar.append(e),this._galleryToolbar.append(i),this._gallery.append(this._galleryToolbar)}_appendContent(){this._galleryContent=$(\"div\"),g.addClass(this._galleryContent,`${this._classes.galleryContent}`),this._gallery.append(this._galleryContent)}_appendLoader(){this._loader=$(\"div\");const t=$(\"div\"),e=$(\"span\");g.addClass(this._loader,`${this._classes.loader}`),g.addClass(t,`${this._classes.spinner}`),g.addClass(e,`${this._classes.spinnerContent}`),t.setAttribute(\"role\",\"status\"),e.innerHTML=this.options.spinnerContent,t.append(e),this._loader.append(t),this._gallery.append(this._loader)}_appendArrows(){this._leftArrowWrapper=$(\"div\"),g.addClass(this._leftArrowWrapper,`${this._classes.prevBtnWrapper}`);const t=$(\"button\");t.setAttribute(\"aria-label\",\"Previous\"),g.addClass(t,`${this._classes.prevBtn}`),_.on(t,vs,()=>this.slide(\"left\")),this._leftArrowWrapper.append(t),this._rightArrowWrapper=$(\"div\"),g.addClass(this._rightArrowWrapper,`${this._classes.nextBtnWrapper}`),this._rightArrow=$(\"button\"),this._rightArrow.setAttribute(\"aria-label\",\"Next\"),g.addClass(this._rightArrow,`${this._classes.nextBtn}`),_.on(this._rightArrow,vs,()=>this.slide()),this._rightArrowWrapper.append(this._rightArrow),this._rightArrow.innerHTML=this.options.nextIconTemplate,t.innerHTML=this.options.prevIconTemplate,this._getImages(),!(this._images.length<=1)&&(this._gallery.append(this._leftArrowWrapper),this._gallery.append(this._rightArrowWrapper))}_appendCaption(){const t=$(\"div\"),e=$(\"p\");e.setAttribute(Wp,\"\"),g.addClass(t,`${this._classes.captionWrapper}`),g.addClass(e,`${this._classes.caption}`),t.append(e),this._gallery.append(t)}_sortImages(){for(let t=0;t{t.push(new Promise(r=>{const a=new Image,l=$(\"div\");g.addClass(l,`${this._classes.imgWrapper}`),g.addClass(a,`${this._classes.img}`),this._addImgStyles(a,l,i,o,n),l.append(a),this._galleryContent.append(l),a.onload=r,a.src=n.dataset.teImg||n.src,e.push(a),i+=100}))}),await Promise.all(t),e}_addImgStyles(t,e,i,n,o){t.alt=o.alt,t.draggable=!1,g.style(e,{position:\"absolute\",left:`${i}%`,top:0}),(o.dataset.teCaption||o.dataset.teCaption===\"\")&&(t.dataset.caption=o.dataset.teCaption),i===0?(o.width1&&g.style(e,{left:\"-100%\"})}_resizeImages(t){t.forEach(e=>{this._calculateImgSize(e)})}_calculateImgSize(t){t.width>=t.height?(t.style.width=\"100%\",t.style.maxWidth=\"100%\",t.style.height=\"auto\",t.style.top=`${(t.parentNode.offsetHeight-t.height)/2}px`,t.style.left=0):(t.style.height=\"100%\",t.style.maxHeight=\"100%\",t.style.width=\"auto\",t.style.left=`${(t.parentNode.offsetWidth-t.width)/2}px`,t.style.top=0),t.width>=t.parentNode.offsetWidth&&(t.style.width=`${t.parentNode.offsetWidth}px`,t.style.height=\"auto\",t.style.left=0,t.style.top=`${(t.parentNode.offsetHeight-t.height)/2}px`),t.height>=t.parentNode.offsetHeight&&(t.style.height=`${t.parentNode.offsetHeight}px`,t.style.width=\"auto\",t.style.top=0,t.style.left=`${(t.parentNode.offsetWidth-t.width)/2}px`),this._positionX=parseFloat(t.style.left)||0,this._positionY=parseFloat(t.style.top)||0}_onResize(){this._images=m.find(\"img\",this._galleryContent),this._images.forEach(t=>{this._calculateImgSize(t)})}_onFullscreenChange(){(document.webkitIsFullScreen||document.mozFullScreen||document.msFullscreenElement)===void 0&&(this._fullscreen=!1,this._fullscreenBtn.innerHTML=this.options.showFullscreenIconTemplate,this._fullscreenBtn.removeAttribute(ye))}_beforeSlideEvents(){this._animationStart(),this._restoreDefaultZoom(),this._restoreDefaultPosition(),this._resetDoubleTap()}_slideHorizontally(t){this._images=m.find(\"img\",this._galleryContent),this._images.forEach(e=>{let i;t===\"right\"?(i=parseInt(e.parentNode.style.left,10)-100,i<-100&&(i=(this._images.length-2)*100)):(i=parseInt(e.parentNode.style.left,10)+100,i===(this._images.length-1)*100&&(i=-100)),this._slideImg(e,i)}),this._updateActiveImg(t)}_slideImg(t,e){e===0?(t.setAttribute(ye,\"\"),g.style(t.parentNode,{opacity:1,transform:\"scale(1)\"})):(t.removeAttribute(ye),g.style(t.parentNode,{opacity:0,transform:\"scale(0.25)\"})),t.parentNode.style.left=`${e}%`}_slideToTarget(t){t===\"first\"&&this._activeImg===0||t===\"last\"&&this._activeImg===this._images.length-1||(this.reset(),this._removeEvents(),this._showLoader(),this._getImages(),this._activeImg=t===\"first\"?0:this._images.length-1,this._sortImages(),g.style(this.currentImg.parentNode,{transform:\"scale(0.25)\",opacity:0}),setTimeout(()=>{this._loadImages().then(e=>{this._resizeImages(e),this._addEvents(),this._updateCaption(),this._hideLoader(),setTimeout(()=>{g.style(this.currentImg.parentNode,{transform:\"scale(1)\",opacity:1})},10)})},400))}_updateActiveImg(t){t===\"right\"&&(this._activeImg===this._images.length-1?this._activeImg=0:this._activeImg++),t===\"left\"&&(this._activeImg===0?this._activeImg=this._images.length-1:this._activeImg--)}_afterSlideEvents(){this._updateCounter(),this._updateCaption()}_updateCounter(){this._images.length<=1||setTimeout(()=>{this._imgCounter.innerHTML=`${this._activeImg+1} / ${this._images.length}`},200)}_updateCaption(){setTimeout(()=>{let t=this.currentImg.alt;(this.currentImg.dataset.caption||this.currentImg.dataset.caption===\"\")&&(t=this.currentImg.dataset.caption),m.findOne(`[${Wp}]`,this._gallery).innerHTML=t},200)}_toggleTemplate(){this._gallery.style.visibility===\"visible\"?(g.style(this.currentImg.parentNode,{transform:\"scale(0.25)\"}),setTimeout(()=>{this._hideGallery(),this._enableScroll(),this._showLoader()},100)):(this._showGallery(),this._disableScroll(),this._updateCounter(),this._updateCaption(),this._setToolsToggleTimout(),this._hideLoader())}_showLoader(){g.style(this._loader,{opacity:1})}_hideLoader(){g.style(this._loader,{opacity:0})}_hideGallery(){g.style(this._gallery,{opacity:0,pointerEvents:\"none\",visibility:\"hidden\"})}_showGallery(){g.style(this._gallery,{opacity:1,pointerEvents:\"initial\",visibility:\"visible\"}),setTimeout(()=>{g.style(this.currentImg.parentNode,{transform:\"scale(1)\"})},50)}_toggleZoom(){this._zoom!==1?this.zoomOut():this.zoomIn()}_updateZoomBtn(){this._zoom>1?(this._zoomBtn.setAttribute(ye,\"\"),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom out\"),this._zoomBtn.innerHTML=this.options.zoomOutIconTemplate):(this._zoomBtn.removeAttribute(ye),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom in\"),this._zoomBtn.innerHTML=this.options.zoomInIconTemplate)}_updateImgPosition(){this._zoom===1&&this._restoreDefaultPosition()}_addEvents(){const t=m.find(\"img\",this._galleryContent);this._onWindowTouchmove=this._onWindowTouchmove.bind(this),this._onWindowTouchstart=this._onWindowTouchstart.bind(this),this._onImgMousedown=this._onMousedown.bind(this),this._onImgMousemove=this._onMousemove.bind(this),this._onImgWheel=this._onZoom.bind(this),this._onImgMouseup=this._onMouseup.bind(this),this._onImgTouchend=this._onTouchend.bind(this),this._onImgDoubleClick=this._onDoubleClick.bind(this),this._onWindowResize=this._onResize.bind(this),this._onWindowFullscreenChange=this._onFullscreenChange.bind(this),this._onAnyImgAction=this._resetToolsToggler.bind(this),this._onGalleryClick=this._onBackdropClick.bind(this),this._onKeyupEvent=this._onKeyup.bind(this),this._onRightArrowKeydownEvent=this._onRightArrowKeydown.bind(this),this._onFullscreenBtnKeydownEvent=this._onFullscreenBtnKeydown.bind(this),t.forEach(e=>{_.on(e,\"mousedown\",this._onImgMousedown,{passive:!0}),_.on(e,\"touchstart\",this._onImgMousedown,{passive:!0}),_.on(e,\"mousemove\",this._onImgMousemove,{passive:!0}),_.on(e,\"touchmove\",this._onImgMousemove,{passive:!0}),_.on(e,\"wheel\",this._onImgWheel,{passive:!0}),_.on(e,\"dblclick\",this._onImgDoubleClick,{passive:!0})}),document.addEventListener(\"touchmove\",this._onWindowTouchmove,{passive:!1}),_.on(window,\"touchstart\",this._onWindowTouchstart),_.on(window,\"mouseup\",this._onImgMouseup),_.on(window,\"touchend\",this._onImgTouchend),_.on(window,\"resize\",this._onWindowResize),_.on(window,\"orientationchange\",this._onWindowResize),_.on(window,\"keyup\",this._onKeyupEvent),_.on(window,\"fullscreenchange\",this._onWindowFullscreenChange),_.on(this._gallery,\"mousemove\",this._onAnyImgAction),_.on(this._gallery,\"click\",this._onGalleryClick),_.on(this._rightArrow,\"keydown\",this._onRightArrowKeydownEvent),_.on(this._fullscreenBtn,\"keydown\",this._onFullscreenBtnKeydownEvent)}_removeEvents(){m.find(\"img\",this._galleryContent).forEach(e=>{_.off(e,\"mousedown\",this._onImgMousedown),_.off(e,\"touchstart\",this._onImgMousedown),_.off(e,\"mousemove\",this._onImgMousemove),_.off(e,\"touchmove\",this._onImgMousemove),_.off(e,\"wheel\",this._onImgWheel),_.off(e,\"dblclick\",this._onImgDoubleClick)}),document.removeEventListener(\"touchmove\",this._onWindowTouchmove,{passive:!1}),_.off(window,\"touchstart\",this._onWindowTouchstart),_.off(window,\"mouseup\",this._onImgMouseup),_.off(window,\"touchend\",this._onImgTouchend),_.off(window,\"resize\",this._onWindowResize),_.off(window,\"orientationchange\",this._onWindowResize),_.off(window,\"keyup\",this._onKeyupEvent),_.off(window,\"fullscreenchange\",this._onWindowFullscreenChange),_.off(this._gallery,\"mousemove\",this._onAnyImgAction),_.off(this._gallery,\"click\",this._onGalleryClick),_.off(this._rightArrow,\"keydown\",this._onRightArrowKeydownEvent),_.off(this._fullscreenBtn,\"keydown\",this._onFullscreenBtnKeydownEvent)}_onMousedown(t){const e=t.touches,i=e?e[0].clientX:t.clientX,n=e?e[0].clientY:t.clientY;this._originalPositionX=parseFloat(this.currentImg.style.left)||0,this._originalPositionY=parseFloat(this.currentImg.style.top)||0,this._positionX=this._originalPositionX,this._positionY=this._originalPositionY,this._mousedownPositionX=i*(1/this._zoom)-this._positionX,this._mousedownPositionY=n*(1/this._zoom)-this._positionY,this._mousedown=!0,t.type===\"touchstart\"&&t.touches.length>1&&(this._multitouch=!0,this._touchZoomPosition=t.touches)}_onMousemove(t){if(!this._mousedown)return;const e=t.touches,i=e?e[0].clientX:t.clientX,n=e?e[0].clientY:t.clientY;if(e&&this._resetToolsToggler(),!this._multitouch)if(this._zoom!==1)this._positionX=i*(1/this._zoom)-this._mousedownPositionX,this._positionY=n*(1/this._zoom)-this._mousedownPositionY,g.style(this.currentImg,{left:`${this._positionX}px`,top:`${this._positionY}px`});else{if(this._images.length<=1)return;this._positionX=i*(1/this._zoom)-this._mousedownPositionX,g.style(this.currentImg,{left:`${this._positionX}px`})}}_onMouseup(t){this._mousedown=!1,this._moveImg(t.target)}_onTouchend(t){this._mousedown=!1,this._multitouch?t.targetTouches.length===0&&(this._multitouch=!1,this._touchZoomPosition=[]):this._multitouch||(this._checkDoubleTap(t),this._moveImg(t.target))}_calculateTouchZoom(t){const e=Math.hypot(this._touchZoomPosition[1].pageX-this._touchZoomPosition[0].pageX,this._touchZoomPosition[1].pageY-this._touchZoomPosition[0].pageY),i=Math.hypot(t.touches[1].pageX-t.touches[0].pageX,t.touches[1].pageY-t.touches[0].pageY),n=Math.abs(e-i),o=t.view.screen.width;n>o*.03&&(e<=i?this.zoomIn():this.zoomOut(),this._touchZoomPosition=t.touches)}_onWindowTouchstart(t){t.touches.length>1&&(this._multitouch=!0,this._touchZoomPosition=t.touches)}_onWindowTouchmove(t){t.preventDefault(),t.type===\"touchmove\"&&t.targetTouches.length>1&&this._calculateTouchZoom(t)}_onRightArrowKeydown(t){switch(t.keyCode){case 9:if(t.shiftKey)break;t.preventDefault(),this._focusFullscreenBtn();break}}_onFullscreenBtnKeydown(t){switch(t.keyCode){case 9:if(!t.shiftKey)break;t.preventDefault(),this._focusRightArrow();break}}_onKeyup(t){switch(this._resetToolsToggler(),t.keyCode){case 39:this.slide();break;case 37:this.slide(\"left\");break;case 27:this.close();break;case 36:this.slide(\"first\");break;case 35:this.slide(\"last\");break;case 38:this.zoomIn();break;case 40:this.zoomOut();break}}_focusFullscreenBtn(){setTimeout(()=>{this._fullscreenBtn.focus()},100)}_focusRightArrow(){this._rightArrow.focus()}_moveImg(t){if(this._multitouch||this._zoom!==1||t!==this.currentImg||this._images.length<=1)return;const e=this._positionX-this._originalPositionX;e>0?this.slide(\"left\"):e<0&&this.slide()}_checkDoubleTap(t){clearTimeout(this._doubleTapTimer);const i=new Date().getTime()-this._tapTime;this._tapCounter>0&&i<500?(this._onDoubleClick(t),this._doubleTapTimer=setTimeout(()=>{this._tapTime=new Date().getTime(),this._tapCounter=0},300)):(this._tapCounter++,this._tapTime=new Date().getTime())}_resetDoubleTap(){this._tapTime=0,this._tapCounter=0,clearTimeout(this._doubleTapTimer)}_onDoubleClick(t){this._multitouch||(t.touches||this._setNewPositionOnZoomIn(t),this._zoom!==1?this._restoreDefaultZoom():this.zoomIn())}_onZoom(t){if(t.deltaY>0)this.zoomOut();else{if(this._zoom>=3)return;this._setNewPositionOnZoomIn(t),this.zoomIn()}}_onBackdropClick(t){this._resetToolsToggler(),t.target.tagName===\"DIV\"&&this.close()}_setNewPositionOnZoomIn(t){clearTimeout(this._zoomTimer),this._positionX=window.innerWidth/2-t.offsetX-50,this._positionY=window.innerHeight/2-t.offsetY-50,this.currentImg.style.transition=\"all 0.5s ease-out\",this.currentImg.style.left=`${this._positionX}px`,this.currentImg.style.top=`${this._positionY}px`,this._zoomTimer=setTimeout(()=>{this.currentImg.style.transition=\"none\"},500)}_resetToolsToggler(){this._showTools(),clearTimeout(this._toolsToggleTimer),this._setToolsToggleTimout()}_setToolsToggleTimout(){this._toolsToggleTimer=setTimeout(()=>{this._hideTools(),clearTimeout(this._toolsToggleTimer)},4e3)}_hideTools(){g.style(this._galleryToolbar,{opacity:0}),g.style(this._leftArrowWrapper,{opacity:0}),g.style(this._rightArrowWrapper,{opacity:0})}_showTools(){g.style(this._galleryToolbar,{opacity:1}),g.style(this._leftArrowWrapper,{opacity:1}),g.style(this._rightArrowWrapper,{opacity:1})}_disableScroll(){g.addClass(document.body,\"overflow-y-hidden relative\"),document.documentElement.scrollHeight>document.documentElement.clientHeight&&g.addClass(document.body,\"md:pr-[17px]\")}_enableScroll(){setTimeout(()=>{g.removeClass(document.body,\"overflow-y-hidden relative\"),g.removeClass(document.body,\"md:pr-[17px]\")},300)}_animationStart(){this._animating=!0,setTimeout(()=>{this._animating=!1},400)}_restoreDefaultZoom(){this._zoom!==1&&(this._zoom=1,g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn(),this._updateImgPosition())}_restoreDefaultFullscreen(){this._fullscreen&&this.toggleFullscreen()}_restoreDefaultPosition(){clearTimeout(this._zoomTimer);const t=this.currentImg;g.style(this.currentImg.parentNode,{left:0,top:0}),g.style(this.currentImg,{transition:\"all 0.5s ease-out\",left:0,top:0}),this._calculateImgSize(t),setTimeout(()=>{g.style(this.currentImg,{transition:\"none\"})},500)}async _triggerEvents(t,e){_.trigger(this._element,`${t}.te.lightbox`),e&&await setTimeout(()=>{_.trigger(this._element,`${e}.te.lightbox`)},505)}static getInstance(t){return O.getData(t,gn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static toggle(){return function(t){const e=m.closest(t.target,`${Fp}`);(ys.getInstance(e)||new ys(e)).open(t)}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,gn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new ys(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}}const ww={isRequired:\"This is required\",isEmail:\"Please enter a valid email address\",isLongerThan:\"This field must be longer than {length} characters\",isShorterThan:\"This field must be shorter than {length} characters\",isChecked:\"This is required\",isPhone:\"Please enter a valid phone number\",isNumber:\"Expected value with type Number\",isString:\"Expected value with type String\",isBoolean:\"Expected value with type Boolean\",isDate:\"Please enter a valid date\",is12hFormat:\"Please enter a valid time in 12h format\",is24hFormat:\"Please enter a valid time in 24h format\"},kw={isRequired:(s,t)=>(s==null?void 0:s.trim())?!0:t,isEmail:(s,t)=>/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/.test(s)?!0:t,isLongerThan:(s,t,e)=>s.length>e?!0:t.replace(\"{length}\",e),isShorterThan:(s,t,e)=>s.lengths?!0:\"This is required\",isPhone:(s,t)=>s.length===9?!0:t,isNumber:(s,t)=>s&&!isNaN(Number(s))?!0:t,isString:(s,t)=>typeof s==\"string\"?!0:t,isBoolean:(s,t)=>typeof s==\"boolean\"?!0:t,isDate:(s,t)=>{const e=/^([0-9]{1,2})\\/([0-9]{1,2})\\/([0-9]{4})$/;return s.match(e)?!0:t},is12hFormat:(s,t)=>{const e=/^(0?[1-9]|1[0-2]):[0-5][0-9] [APap][mM]$/;return s.match(e)?!0:t},is24hFormat:(s,t)=>{const e=/^(?:[01]\\d|2[0-3]):[0-5][0-9]$/;return s.match(e)?!0:t}},tc=\"validation\",ec=\"te.validation\",gr=`.${ec}`,zp=\"data-te-validate\",mr=\"data-te-validated\",br=\"data-te-validation-state\",vr=\"data-te-validation-feedback\",ic=\"data-te-valid-feedback\",yr=\"data-te-invalid-feedback\",jp=\"data-te-validation-ruleset\",Sw=\"data-te-submit-btn-ref\",Ow=`[${zp}]`,Iw=\"[data-te-input-notch-ref] div\",Dw=`[${Sw}]`,Mw=`validated${gr}`,Lw=`valid${gr}`,$w=`invalid${gr}`,Rw=`changed${gr}`,Pw={validFeedback:\"string\",invalidFeedback:\"string\",disableFeedback:\"boolean\",customRules:\"object\",customErrorMessages:\"object\",activeValidation:\"boolean\",submitCallback:\"(function|null)\"},Yp={validFeedback:\"Looks good!\",invalidFeedback:\"Something is wrong!\",disableFeedback:!1,customRules:{},customErrorMessages:{},activeValidation:!1,submitCallback:null},Nw={notchLeadingValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[-1px_0_0_#14a44d,_0_1px_0_0_#14a44d,_0_-1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchMiddleValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[0_1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchTrailingValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[1px_0_0_#14a44d,_0_-1px_0_0_#14a44d,_0_1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchLeadingInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[-1px_0_0_#dc4c64,_0_1px_0_0_#dc4c64,_0_-1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",notchMiddleInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[0_1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",notchTrailingInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[1px_0_0_#dc4c64,_0_-1px_0_0_#dc4c64,_0_1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",basicInputValid:\"!border-[#14a44d] focus:!border-[#14a44d] focus:!shadow-[inset_0_0_0_1px_#14a44d]\",basicInputInvalid:\"!border-[#dc4c64] focus:!border-[#dc4c64] focus:!shadow-[inset_0_0_0_1px_#dc4c64]\",checkboxValid:\"checked:!border-[#14a44d] checked:!bg-[#14a44d] checked:after:!bg-[#14a44d]\",checkboxInvalid:\"checked:!border-[#dc4c64] checked:!bg-[#dc4c64] checked:after:!bg-[#dc4c64]\",radioValid:\"checked:!border-[#14a44d] checked:after:!bg-[#14a44d]\",radioInvalid:\"checked:!border-[#dc4c64] checked:after:!bg-[#dc4c64]\",labelValid:\"!text-[#14a44d]\",labelInvalid:\"!text-[#dc4c64]\",validFeedback:\"absolute top-full left-0 m-1 w-auto text-sm text-[#14a44d] animate-[fade-in_0.3s_both]\",invalidFeedback:\"absolute top-full left-0 m-1 w-auto text-sm text-[#dc4c64] animate-[fade-in_0.3s_both]\",elementValidated:\"mb-8\"},Bw={notchLeadingValid:\"string\",notchMiddleValid:\"string\",notchTrailingValid:\"string\",notchLeadingInvalid:\"string\",notchMiddleInvalid:\"string\",notchTrailingInvalid:\"string\",basicInputValid:\"string\",basicInputInvalid:\"string\",checkboxValid:\"string\",checkboxInvalid:\"string\",radioValid:\"string\",radioInvalid:\"string\",labelValid:\"string\",labelInvalid:\"string\",validFeedback:\"string\",invalidFeedback:\"string\",elementValidated:\"string\"};class Tr extends Mt{constructor(t,e,i){super(t),this._element=t,this._element&&O.setData(t,ec,this),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._isValid=!0,this._shouldApplyInputEvents=!0,this._submitCallback=null,this._errorMessages={...ww,...this._config.customErrorMessages},this._validationElements=this._getValidationElements(),this._validationElements.forEach(({element:n,input:o})=>{this._createFeedbackWrapper(n,o)}),this._validationObserver=this._watchForValidationChanges(),this._validationObserver.observe(this._element,{attributes:!0}),this._submitButton=null,this._handleSubmitButton(),this._validationResult=[]}static get DefaultType(){return Pw}static get Default(){return Yp}static get NAME(){return tc}dispose(){var t;(t=this._validationObserver)==null||t.disconnect(),this._validationObserver=null,this._submitCallback=null,this._element.removeAttribute(mr),this._removeInputEvents(),this._removeValidationTraces(),this._validationResult=[],this._submitButton&&_.off(this._submitButton,\"click\"),this._config.activeValidation&&(this._validationElements.forEach(e=>{const{input:i}=e;_.off(i,\"input\")}),this._shouldApplyInputEvents=!0)}_removeValidationTraces(){this._removeFeedbackWrapper(),this._validationElements.forEach(({element:t,classes:e,initialHTML:i})=>{t.className=e,t.innerHTML=i,t.removeAttribute(br),t.removeAttribute(yr),t.removeAttribute(ic)}),this._validationElements=[]}_getValidationElements(){return m.find(Ow,this._element).map(e=>{const i=m.findOne(\"input\",e)||m.findOne(\"textarea\",e),n=m.findOne(\"select\",e);return{id:i.name||i.id||(n==null?void 0:n.name)||bt(\"validation-\"),element:e,type:e.getAttribute(zp),input:i,validFeedback:e.getAttribute(ic),invalidFeedback:e.getAttribute(yr),classes:e.className,initialHTML:e.innerHTML,ruleset:e.getAttribute(jp)}})}_createFeedbackWrapper(t,e){if(t.querySelectorAll(`[${vr}]`).length>0)return;const i=document.createElement(\"span\");i.setAttribute(vr,\"\"),e.parentNode.appendChild(i)}_removeFeedbackWrapper(){m.find(`[${vr}]`,this._element).forEach(e=>{e.remove()})}_watchForValidationChanges(){return new MutationObserver(e=>{e.forEach(i=>{const{attributeName:n}=i;n===mr&&(this._handleValidation(),this._config.activeValidation&&this._shouldApplyInputEvents&&this._applyInputEvents())})})}_handleValidation(){this._element.getAttribute(mr)&&(this._validationResult=[],this._isValid=!0,this._validationElements.forEach(t=>this._validateSingleElement(t)),this._emitEvents(this._isValid),this._submitCallback&&this._submitCallback(this._isValid))}_validateSingleElement(t){var c;const{element:e,type:i,input:n,ruleset:o,id:r}=t;o&&this._validateByRuleset(t);const a=e.getAttribute(br);if(a!==\"valid\"&&a!==\"invalid\")return;const l=a.replace(a.charAt(0),a.charAt(0).toUpperCase());i===\"input\"&&this._restyleNotches(e,l),i===\"basic\"&&this._restyleBasicInputs(n,l),(i===\"checkbox\"||i===\"radio\")&&this._restyleCheckboxes(n,l,i),this._restyleLabels(e,l),a===\"invalid\"&&(this._isValid=!1),this._config.disableFeedback||this._applyFeedback(e,a),_.trigger(this._element,Rw,{value:{name:r,result:a,validation:(c=this._validationResult[r])==null?void 0:c.validation}})}_validateByRuleset({element:t,type:e,invalidFeedback:i,input:n,id:o}){const r=this._getRuleset(t);if(!r.length)return;const a=e===\"checkbox\"||e===\"radio\"?n.checked:n.value;let l=\"\",c=[];for(const h of r){const d=h.callback(a,this._errorMessages[h.name]||this._config.invalidFeedback,h.parameter);c.push({result:d===!0,name:h.name,fullName:h.fullName}),typeof d==\"string\"&&!l&&(l=d)}if(this._validationResult[o]={element:t,validation:c},!l){t.setAttribute(br,\"valid\");return}t.setAttribute(br,\"invalid\"),i||t.setAttribute(yr,l)}_handleInputChange(t){this._validateSingleElement(t)}_getRuleset(t){const i=t.getAttribute(jp).split(\"|\");let n=[];const o={...kw,...this._config.customRules};return i.forEach(r=>{const a=this._getRuleData(r,o);a.callback?n.push(a):console.warn(`Rule ${r} does not exist`)}),n}_getRuleData(t,e){const i=t.split(\"(\");return{callback:e[i[0]],parameter:i[1]?i[1].split(\")\")[0]:null,name:i[0],fullName:t}}_applyFeedback(t,e){const i=m.findOne(`[${vr}]`,t),n=t.getAttribute(ic)||this._config.validFeedback,o=t.getAttribute(yr)||this._config.invalidFeedback;g.addClass(t,this._classes.elementValidated),i.textContent=e===\"valid\"?n:o,i.className=this._classes[e===\"valid\"?\"validFeedback\":\"invalidFeedback\"]}_restyleCheckboxes(t,e,i){g.removeClass(t,this._classes.checkboxValid),g.removeClass(t,this._classes.checkboxInvalid),g.addClass(t,this._classes[`${i}${e}`])}_restyleBasicInputs(t,e){g.removeClass(t,this._classes.basicInputValid),g.removeClass(t,this._classes.basicInputInvalid),g.addClass(t,this._classes[`basicInput${e}`])}_restyleNotches(t,e){m.find(Iw,t).forEach((n,o)=>{let r=o===0?\"notchLeading\":o===1?\"notchMiddle\":\"notchTrailing\";n.className=\"\",g.addClass(n,nu[r]),r+=e,g.addClass(n,this._classes[r])})}_restyleLabels(t,e){const i=m.find(\"label\",t);i.length&&i.forEach(n=>{g.removeClass(n,this._classes.labelValid),g.removeClass(n,this._classes.labelInvalid),g.addClass(n,this._classes[`label${e}`])})}_emitEvents(t){if(_.trigger(this._element,Mw),t){_.trigger(this._element,Lw,{value:this._validationResult});return}_.trigger(this._element,$w,{value:this._validationResult})}_applyInputEvents(){this._validationElements.forEach(t=>{const{input:e,element:i}=t;_.on(e,\"input\",()=>this._handleInputChange(t)),_.on(i,\"valueChange.te.select\",()=>this._delayedInputChange(t)),_.on(i,\"itemSelect.te.autocomplete\",()=>this._delayedInputChange(t))}),this._shouldApplyInputEvents=!1}_removeInputEvents(){this._validationElements.forEach(t=>{const{input:e,element:i}=t;_.off(e,\"input\",()=>this._handleInputChange(t)),_.off(i,\"valueChange.te.select\",()=>this._delayedInputChange(t)),_.off(i,\"itemSelect.te.autocomplete\",()=>this._delayedInputChange(t))})}_delayedInputChange(t){setTimeout(()=>{this._handleInputChange(t)},10)}_handleSubmitButton(){this._submitButton=m.findOne(Dw,this._element),this._submitButton&&_.on(this._submitButton,\"click\",t=>this._handleSubmitButtonClick(t))}_handleSubmitButtonClick(t){if(this._element.setAttribute(mr,!0),this._config.submitCallback){this._submitCallback=e=>this._config.submitCallback(t,e);return}}_getConfig(t){return t={...Yp,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(tc,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Nw,...e,...t},L(tc,t,Bw),t}static getInstance(t){return O.getData(t,ec)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){const e=Tr.getOrCreateInstance(this);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}class mn{_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection({x:t,y:e}){return{x:{direction:t<0?\"left\":\"right\",value:Math.abs(t)},y:{direction:e<0?\"up\":\"down\",value:Math.abs(e)}}}_getOrigin({x:t,y:e},{x:i,y:n}){return{x:t-i,y:e-n}}_getDistanceBetweenTwoPoints(t,e,i,n){return Math.hypot(e-t,n-i)}_getMidPoint({x1:t,x2:e,y1:i,y2:n}){return{x:(t+e)/2,y:(i+n)/2}}_getVectorLength({x1:t,x2:e,y1:i,y2:n}){return Math.sqrt((e-t)**2+(n-i)**2)}_getRightMostTouch(t){let e=null;const i=Number.MIN_VALUE;return t.forEach(n=>{n.clientX>i&&(e=n)}),e}_getAngle(t,e,i,n){return Math.atan2(n-e,i-t)}_getAngularDistance(t,e){return e-t}_getCenterXY({x1:t,x2:e,y1:i,y2:n}){return{x:t+(e-t)/2,y:i+(n-i)/2}}_getPinchTouchOrigin(t){const[e,i]=t,n={x1:e.clientX,x2:i.clientX,y1:e.clientY,y2:i.clientY};return[this._getVectorLength(n),this._getCenterXY(n)]}_getPosition({x1:t,x2:e,y1:i,y2:n}){return{x1:t,x2:e,y1:i,y2:n}}}const sc=\"press\",Hw=\"pressup\",Vw={time:\"number\",pointers:\"number\"},Fw={time:250,pointers:1};class Ww extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._timer=null}static get NAME(){return sc}handleTouchStart(t){const{time:e,pointers:i}=this._options;t.touches.length===i&&(this._timer=setTimeout(()=>{_.trigger(this._element,sc,{touch:t,time:e}),_.trigger(this._element,Hw,{touch:t})},e))}handleTouchEnd(){clearTimeout(this._timer)}_getConfig(t){const e={...Fw,...g.getDataAttributes(this._element),...t};return L(sc,e,Vw),e}}const zw=\"swipe\",jw={threshold:\"number\",direction:\"string\"},Yw={threshold:10,direction:\"all\"};class Kw{constructor(t,e){this._element=t,this._startPosition=null,this._options=this._getConfig(e)}handleTouchStart(t){this._startPosition=this._getCoordinates(t)}handleTouchMove(t){if(!this._startPosition)return;const e=this._getCoordinates(t),i={x:e.x-this._startPosition.x,y:e.y-this._startPosition.y},n=this._getDirection(i);if(this._options.direction===\"all\"){if(n.y.valuen.x.value?n.y.direction:n.x.direction;_.trigger(this._element,`swipe${r}`,{touch:t}),_.trigger(this._element,\"swipe\",{touch:t,direction:r}),this._startPosition=null;return}const o=this._options.direction===\"left\"||this._options===\"right\"?\"x\":\"y\";n[o].direction===this._options.direction&&n[o].value>this._options.threshold&&(_.trigger(this._element,`swipe${n[o].direction}`,{touch:t}),this._startPosition=null)}handleTouchEnd(){this._startPosition=null}_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection(t){return{x:{direction:t.x<0?\"left\":\"right\",value:Math.abs(t.x)},y:{direction:t.y<0?\"up\":\"down\",value:Math.abs(t.y)}}}_getConfig(t){const e={...Yw,...g.getDataAttributes(this._element),...t};return L(zw,e,jw),e}}const Je=\"pan\",Uw=`${Je}start`,Xw=`${Je}end`,Gw=`${Je}move`,qw=\"left\",Zw=\"right\",Qw={threshold:\"number\",direction:\"string\",pointers:\"number\"},Jw={threshold:20,direction:\"all\",pointers:1};class tk extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._startTouch=null}static get NAME(){return Je}handleTouchStart(t){this._startTouch=this._getCoordinates(t),this._movedTouch=t,_.trigger(this._element,Uw,{touch:t})}handleTouchMove(t){t.type===\"touchmove\"&&t.preventDefault();const{threshold:e,direction:i}=this._options,n=this._getCoordinates(t),o=this._getCoordinates(this._movedTouch),r=this._getOrigin(n,this._startTouch),a=this._getOrigin(n,o),l=this._getDirection(r),c=this._getDirection(a),{x:h,y:d}=l;if(i===\"all\"&&(d.value>e||h.value>e)){const p=d.value>h.value?d.direction:h.direction;_.trigger(this._element,`${Je}${p}`,{touch:t}),_.trigger(this._element,Je,{...a,touch:t})}const u=i===qw||i===Zw?\"x\":\"y\";c[u].direction===i&&l[u].value>e&&_.trigger(this._element,`${Je}${i}`,{touch:t,[u]:n[u]-o[u]}),this._movedTouch=t,_.trigger(this._element,Gw,{touch:t})}handleTouchEnd(t){t.type===\"touchend\"&&t.preventDefault(),this._movedTouch=null,this._startTouch=null,_.trigger(this._element,Xw,{touch:t})}_getConfig(t){const e={...Jw,...g.getDataAttributes(this._element),...t};return L(Je,e,Qw),e}}const Ts=\"pinch\",ek=`${Ts}end`,ik=`${Ts}start`,sk=`${Ts}move`,nk={threshold:\"number\",pointers:\"number\"},ok={threshold:10,pointers:2};class rk extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._startTouch=null,this._origin=null,this._touch=null,this._math=null,this._ratio=null}static get NAME(){return Ts}get isNumber(){return typeof this._startTouch==\"number\"&&typeof this._touch==\"number\"&&!isNaN(this._startTouch)&&!isNaN(this._touch)}handleTouchStart(t){if(t.touches.length!==this._options.pointers)return;t.type===\"touchstart\"&&t.preventDefault();const[e,i]=this._getPinchTouchOrigin(t.touches);this._touch=e,this._origin=i,this._startTouch=this._touch,_.trigger(this._element,ik,{touch:t,ratio:this._ratio,origin:this._origin})}handleTouchMove(t){const{threshold:e,pointers:i}=this._options;t.touches.length===i&&(t.type===\"touchmove\"&&t.preventDefault(),this._touch=this._getPinchTouchOrigin(t.touches)[0],this._ratio=this._touch/this._startTouch,this.isNumber&&(this._origin.x>e||this._origin.y>e)&&(this._startTouch=this._touch,_.trigger(this._element,Ts,{touch:t,ratio:this._ratio,origin:this._origin}),_.trigger(this._element,sk,{touch:t,ratio:this._ratio,origin:this._origin})))}handleTouchEnd(t){this.isNumber&&(this._startTouch=null,_.trigger(this._element,ek,{touch:t,ratio:this._ratio,origin:this._origin}))}_getConfig(t){const e={...ok,...g.getDataAttributes(this._element),...t};return L(Ts,e,nk),e}}const nc=\"tap\",ak={interval:\"number\",time:\"number\",taps:\"number\",pointers:\"number\"},lk={interval:500,time:250,taps:1,pointers:1};class ck extends mn{constructor(t,e){super(),this._element=t,this._options=this._getConfig(e),this._timer=null,this._tapCount=0}static get NAME(){return nc}handleTouchStart(t){const{x:e,y:i}=this._getCoordinates(t),{interval:n,taps:o,pointers:r}=this._options;return t.touches.length===r&&(this._tapCount+=1,this._tapCount===1&&(this._timer=setTimeout(()=>{this._tapCount=0},n)),this._tapCount===o&&(clearTimeout(this._timer),this._tapCount=0,_.trigger(this._element,nc,{touch:t,origin:{x:e,y:i}}))),t}handleTouchEnd(){}handleTouchMove(){}_getConfig(t){const e={...lk,...g.getDataAttributes(this._element),...t};return L(nc,e,ak),e}}const bn=\"rotate\",hk=`${bn}end`,dk=`${bn}start`,uk={angle:\"number\",pointers:\"number\"},pk={angle:0,pointers:2};class fk extends mn{constructor(t,e){super(),this._element=t,this._options=this._getConfig(e),this._origin={}}static get NAME(){return bn}handleTouchStart(t){t.type===\"touchstart\"&&t.preventDefault(),!(t.touches.length<2)&&(this._startTouch=t,this._origin={},_.trigger(this._element,dk,{touch:t}))}handleTouchMove(t){t.type===\"touchmove\"&&t.preventDefault();let e,i;const n=t.touches;if(n.length===1&&this._options.pointers===1){const{left:o,top:r,width:a,height:l}=this._element.getBoundingClientRect();e={x:o+a/2,y:r+l/2},i=n[0]}else if(t.touches.length===2&&this._options.pointers===2){const[o,r]=t.touches,a={x1:r.clientX,x2:o.clientX,y1:r.clientY,y2:o.clientY};e=this._getMidPoint(a),i=this._getRightMostTouch(t.touches)}else return;this.currentAngle=this._getAngle(e.x,e.y,i.clientX,i.clientY),this._origin.initialAngle?(this._origin.change=this._getAngularDistance(this._origin.previousAngle,this.currentAngle),this._origin.distance+=this._origin.change):(this._origin.initialAngle=this._origin.previousAngle=this.currentAngle,this._origin.distance=this._origin.change=0),this._origin.previousAngle=this.currentAngle,this.rotate={currentAngle:this.currentAngle,distance:this._origin.distance,change:this._origin.change},_.trigger(this._element,bn,{...this.rotate,touch:t})}handleTouchEnd(t){t.type===\"touchend\"&&t.preventDefault(),this._origin={},_.trigger(this._element,hk,{touch:t})}_getConfig(t){const e={...pk,...g.getDataAttributes(this._element),...t};return L(bn,e,uk),e}}const oc=\"touch\",rc=`te.${oc}`,_k={event:\"string\"},gk={event:\"swipe\"};class Er{constructor(t,e={}){this._element=t,this._options=this._getConfig(e),this._event=this._options.event,this.swipe=this._event===\"swipe\"?new Kw(t,e):null,this.press=this._event===\"press\"?new Ww(t,e):null,this.pan=this._event===\"pan\"?new tk(t,e):null,this.pinch=this._event===\"pinch\"?new rk(t,e):null,this.tap=this._event===\"tap\"?new ck(t,e):null,this.rotate=this._event===\"rotate\"?new fk(t,e):null,this._touchStartHandler=i=>this._handleTouchStart(i),this._touchMoveHandler=i=>this._handleTouchMove(i),this._touchEndHandler=i=>this._handleTouchEnd(i),_.on(this._element,\"touchstart\",this._touchStartHandler),_.on(this._element,\"touchmove\",this._touchMoveHandler),_.on(this._element,\"touchend\",this._touchEndHandler),this._element&&O.setData(t,rc,this)}static get NAME(){return oc}dispose(){_.off(this._element,\"touchstart\",this._touchStartHandler),_.off(this._element,\"touchmove\",this._touchMoveHandler),_.off(this._element,\"touchend\",this._touchEndHandler),this.swipe=null,this.press=null,this.pan=null,this.pinch=null,this.tap=null,this.rotate=null}_getConfig(t){const e={...gk,...g.getDataAttributes(this._element),...t};return L(oc,e,_k),e}_handleTouchStart(t){this[this._event].handleTouchStart(t)}_handleTouchMove(t){this[this._event].handleTouchMove&&this[this._event].handleTouchMove(t)}_handleTouchEnd(t){this[this._event].handleTouchEnd(t)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,rc);const i=typeof t==\"object\"&&t;if(!(!e&&/dispose/.test(t))&&(e||(e=new Er(this,i)),typeof t==\"string\")){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);return e[t]}})}static getInstance(t){return O.getData(t,rc)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const ac=\"smoothScroll\",vn=`te.${ac}`,lc=`.${vn}`,mk={container:\"string\",offset:\"number\",easing:\"string\",duration:\"number\"},bk={container:\"body\",offset:0,easing:\"linear\",duration:500},vk=`scrollStart${lc}`,yk=`scrollEnd${lc}`,Tk=`scrollCancel${lc}`;class xr{constructor(t,e={}){this._element=t,this._options=this._getConfig(e),this._href=this._element.getAttribute(\"href\"),this.isCancel=!1,this._element&&(O.setData(t,vn,this),this._setup())}static get NAME(){return ac}get isWindow(){return this._options.container===\"body\"}get containerToScroll(){return this.isWindow?document.documentElement:m.findOne(this._options.container,document.documentElement)}get elFromHrefExist(){return!!m.findOne(this._href,this.containerToScroll)}get offsetFromEl(){const t=this.containerToScroll.scrollTop,e=m.findOne(this._href,this.containerToScroll);if(this.isWindow)return g.offset(e).top-this._options.offset+t;const i=e.getBoundingClientRect().y,n=this.containerToScroll.getBoundingClientRect().y;return i-n-this._options.offset+t}get easingFunction(){const t=this._options.easing,e=`_motion${t[0].toUpperCase()}${t.slice(1)}`;return this[e]?this[e]:this._motionLinear}dispose(){_.off(this._element,\"click\",this._handleClick),O.removeData(this._element,vn),this._element=null}cancelScroll(){this.isCancel=!0}_getConfig(t){const e={...bk,...g.getDataAttributes(this._element),...t};return L(ac,e,mk),e}_inViewport(){if(this.isWindow)return!0;const t=this.containerToScroll.getBoundingClientRect();return t.top>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)}_setup(){const t=typeof this._href<\"u\",e=this._href.includes(\"#\");t&&e&&this.elFromHrefExist&&(this._scrollOnClickEvent(),this._preventNativeScroll())}_scrollOnClickEvent(){_.on(this._element,\"click\",t=>{this._handleClick(t)})}_handleClick(t){t.preventDefault(),this.isCancel=!1,_.trigger(this._element,vk);const e=this.containerToScroll,i=this.containerToScroll.scrollTop,n=this.offsetFromEl,o=0,r=1/this._options.duration,a=4.25,l=this.easingFunction;this._inViewport()?this._scrollOnNextTick(e,i,n,o,r,a,l):(this._scrollOnNextTick(document.documentElement,document.documentElement.scrollTop,this.containerToScroll.offsetTop,o,r,a,l),setTimeout(()=>{this._scrollOnNextTick(e,i,n,o,r,a,l),this.isCancel=!1},this._options.duration))}_scrollOnNextTick(t,e,i,n,o,r,a){const l=n<0,c=n>1,h=o<=0;if(l||c||h||this.isCancel){if(this.isCancel){this.isInViewport&&(this.isCancel=!1),_.trigger(this._element,Tk);return}_.trigger(this._element,yk),t.scrollTop=i;return}t.scrollTo({top:e-(e-i)*a(n)}),n+=o*r,setTimeout(()=>{this._scrollOnNextTick(t,e,i,n,o,r,a)})}_preventDefault(t){t.preventDefault()}_preventNativeScroll(){let t=!1;try{window.addEventListener(\"test\",null,Object.defineProperty({},\"passive\",{get:()=>t=!0}))}catch(n){this._scrollError=n}const e=t?{passive:!1}:!1,i=\"onwheel\"in $(\"div\")?\"wheel\":\"mousewheel\";this.isWindow&&(this._deleteScrollOnStart(e,i),this._addScrollOnEnd(e,i),this._addScrollOnCancel(e,i))}_deleteScrollOnStart(t,e){_.on(this._element,\"scrollStart.te.smoothScroll\",()=>{window.addEventListener(e,this._preventDefault,t),window.addEventListener(\"touchmove\",this._preventDefault,t)})}_addScrollOnEnd(t,e){_.on(this._element,\"scrollEnd.te.smoothScroll\",()=>{window.removeEventListener(e,this._preventDefault,t),window.removeEventListener(\"touchmove\",this._preventDefault,t)})}_addScrollOnCancel(t,e){_.on(this._element,\"scrollCancel.te.smoothScroll\",()=>{window.removeEventListener(e,this._preventDefault,t),window.removeEventListener(\"touchmove\",this._preventDefault,t)})}_motionLinear(t){return t}_motionEaseInQuad(t){return t*t}_motionEaseInCubic(t){return t*t*t}_motionEaseInQuart(t){return t*t*t*t}_motionEaseInQuint(t){return t*t*t*t*t}_motionEaseInOutQuad(t){return t<.5?2*t*t:-1+(4-2*t)*t}_motionEaseInOutCubic(t){return t/=.5,t<1?t*t*t/2:(t-=2,(t*t*t+2)/2)}_motionEaseInOutQuart(t){return t/=.5,t<1?.5*t*t*t*t:(t-=2,-(t*t*t*t-2)/2)}_motionEaseInOutQuint(t){return t/=.5,t<1?t*t*t*t*t/2:(t-=2,(t*t*t*t*t+2)/2)}_motionEaseOutQuad(t){return-t*(t-2)}_motionEaseOutCubic(t){return t--,t*t*t+1}_motionEaseOutQuart(t){return t--,-(t*t*t*t-1)}_motionEaseOutQuint(t){return t--,t*t*t*t*t+1}static getInstance(t){return O.getData(t,vn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,vn);const i=typeof t==\"object\"&&t;if(e||(e=new xr(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Kp=\"lazyLoad\",Cr=\"te.lazyLoad\",Ek=\"[data-te-lazy-load-init]\",Up=\"data-te-lazy-load\",xk=\"onLoad.te.lazy\",Ck=\"onError.te.lazy\",Xp=[\"img\",\"video\"],Ak={lazySrc:\"(string|null)\",lazyDelay:\"number\",lazyAnimation:\"string\",lazyOffset:\"number\",lazyPlaceholder:\"(string|undefined)\",lazyError:\"(string|undefined)\"},wk={lazySrc:null,lazyDelay:500,lazyAnimation:\"[fade-in_1s_ease-in-out]\",lazyOffset:0};class yn{constructor(t,e){this._element=t,this._element&&O.setData(t,Cr,this),this._options=this._getConfig(e),this.scrollHandler=this._scrollHandler.bind(this),this.errorHandler=this._setElementError.bind(this),this._childrenInstances=null,this._init()}static get NAME(){return Kp}get offsetValues(){return this._element.getBoundingClientRect()}get inViewport(){if(this.parent){const t=this.parent.getBoundingClientRect();return t.y>0&&t.y=t.y&&this.offsetValues.y<=t.y+t.height&&this.offsetValues.y<=window.innerHeight}return this.offsetValues.top+this._options.lazyOffset<=window.innerHeight&&this.offsetValues.bottom>=0}get parent(){const[t]=m.parents(this._element,Ek);return t}get node(){return this._element.nodeName}get isContainer(){return!m.matches(this._element,Xp)}dispose(){O.removeData(this._element,Cr),this._animation&&(this._animation.dispose(),this._animation=null),this._element=null,this._childrenInstances&&this._childrenInstances.forEach(t=>t.dispose())}_init(){if(this._element.setAttribute(Up,\"\"),this.isContainer){this._setupContainer();return}this._setupElement()}_setupElement(){_.one(this._element,\"error\",this.errorHandler),this._options.lazyPlaceholder&&this._setPlaceholder(),this._animation=new Gs(this._element,{animation:`${this._options.lazyAnimation}`,animationStart:\"onLoad\"}),_.one(this._element,\"load\",()=>this._scrollHandler()),this.parent&&_.on(this.parent,\"scroll\",this.scrollHandler),_.on(window,\"scroll\",this.scrollHandler)}_scrollHandler(){this.inViewport&&(this._timeout=setTimeout(()=>{this._setSrc(),this._element.removeAttribute(Up),this._removeAttrs(),this._animation.init()},this._options.lazyDelay),this.parent&&_.off(this.parent,\"scroll\",this.scrollHandler),_.off(window,\"scroll\",this.scrollHandler))}_setElementError(){!this._options.lazyError||this._element.src===this._options.lazyError?this._element.alt=\"404 not found\":this._element.setAttribute(\"src\",this._options.lazyError),_.trigger(this._element,Ck)}_setSrc(){this._element.setAttribute(\"src\",this._options.lazySrc),_.trigger(this._element,xk)}_setPlaceholder(){this.node===\"IMG\"?this._element.setAttribute(\"src\",this._options.lazyPlaceholder):this.node===\"VIDEO\"&&this._element.setAttribute(\"poster\",this._options.lazyPlaceholder)}_removeAttrs(){[\"src\",\"delay\",\"animation\",\"placeholder\",\"offset\",\"error\"].forEach(t=>{g.removeDataAttribute(this._element,`lazy-${t}`)})}_setupContainer(){this._childrenInstances=m.children(this._element,Xp).map(t=>new yn(t,this._options))}_getConfig(t){const e={...wk,...t,...g.getDataAttributes(this._element)};return L(Kp,e,Ak),e}static getInstance(t){return O.getData(t,Cr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Cr);const i=typeof t==\"object\"&&t;if(e||(e=new yn(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Gp=\"clipboard\",Tn=\"te.clipboard\",kk=`.${Tn}`,Sk={clipboardTarget:null},Ok={clipboardTarget:\"null|string\"},Ik=`copy${kk}`;class Ar{constructor(t,e={}){this._element=t,this._options=e,this._element&&(O.setData(t,Tn,this),this._initCopy=this._initCopy.bind(this),this._setup())}static get NAME(){return Gp}get options(){const t={...Sk,...g.getDataAttributes(this._element),...this._options};return L(Gp,t,Ok),t}get clipboardTarget(){return m.findOne(this.options.clipboardTarget)}get copyText(){const t=this.clipboardTarget.hasAttribute(\"data-te-clipboard-text\"),e=this.clipboardTarget.value,i=this.clipboardTarget.textContent;return t?this.clipboardTarget.getAttribute(\"data-te-clipboard-text\"):e||i}dispose(){_.off(this._element,\"click\",this._initCopy),O.removeData(this._element,Tn),this._element=null}_setup(){_.on(this._element,\"click\",this._initCopy)}_initCopy(){const t=this._createNewInput();document.body.appendChild(t),this._selectInput(t),_.trigger(this._element,Ik,{copyText:this.copyText}),t.remove()}_createNewInput(){const t=this.clipboardTarget.tagName===\"TEXTAREA\"?\"textarea\":\"input\",e=$(t);return e.value=this.copyText,g.addClass(e,\"-left-[9999px] absolute\"),e}_selectInput(t){t.select(),t.focus(),t.setSelectionRange(0,99999),document.execCommand(\"copy\")}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Tn);const i=typeof t==\"object\"&&t;if(e||(e=new Ar(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}static getInstance(t){return O.getData(t,Tn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const cc=\"infiniteScroll\",wr=`te.${cc}`,Dk={infiniteDirection:\"y\"},Mk={infiniteDirection:\"string\"};class kr{constructor(t,e){this._element=t,this._element&&O.setData(t,wr,this),this._options=this._getConfig(e),this.scrollHandler=this._scrollHandler.bind(this),this._init()}static get NAME(){return cc}get rect(){return this._element.getBoundingClientRect()}get condition(){return this._element===window?Math.abs(window.scrollY+window.innerHeight-document.documentElement.scrollHeight)<1:this._options.infiniteDirection===\"x\"?this.rect.width+this._element.scrollLeft+10>=this._element.scrollWidth:Math.ceil(this.rect.height+this._element.scrollTop)>=this._element.scrollHeight}dispose(){_.off(this._element,\"scroll\",this.scrollHandler),O.removeData(this._element,wr),this._element=null}_init(){_.on(this._element,\"scroll\",()=>this._scrollHandler())}_scrollHandler(){this.condition&&_.trigger(this._element,\"complete.te.infiniteScroll\"),_.off(this._element,\"scroll\",this.scrollHandler)}_getConfig(t){const e={...Dk,...this._element!==window?g.getDataAttributes(this._element):{},...t};return L(cc,e,Mk),e}static getInstance(t){return O.getData(t,wr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,wr);const i=typeof t==\"object\"&&t;if(e||(e=new kr(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}function Lk({backdropID:s},t){const e=$(\"div\");return g.addClass(e,`${t.backdrop} ${t.backdropColor}`),e.id=s,e}const En=\"loadingManagement\",Sr=`te.${En}`,$k=\"[data-te-loading-icon-ref]\",Rk=\"[data-te-loading-text-ref]\",Pk=`show.te.${En}`,Nk={backdrop:\"(null|boolean)\",backdropID:\"(null|string|number)\",delay:\"(null|number)\",loader:\"(null|string|number)\",parentSelector:\"(null|string)\",loadingIcon:\"boolean\",loadingText:\"boolean\",scroll:\"boolean\"},Bk={backdrop:!0,backdropID:null,delay:0,loader:\"\",parentSelector:null,scroll:!0,loadingText:!0,loadingIcon:!0},Hk={loadingSpinner:\"absolute top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] flex flex-col justify-center items-center z-40\",spinnerColor:\"text-primary dark:text-primary-400\",backdrop:\"w-full h-full fixed top-0 left-0 bottom-0 right-0 z-30\",backdropColor:\"bg-[rgba(0,0,0,0.4)]\"},Vk={loadingSpinner:\"string\",spinnerColor:\"string\",backdrop:\"string\",backdropColor:\"string\"};class Or{constructor(t,e={},i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._element&&O.setData(t,Sr,this),this._backdropElement=null,this._parentElement=m.findOne(this._options.parentSelector),this._loadingIcon=m.findOne($k,this._element),this._loadingText=m.findOne(Rk,this._element),this.init()}static get NAME(){return En}init(){const t=this._loadingIcon.cloneNode(!0),e=this._loadingText.cloneNode(!0);this._removeElementsOnStart(),setTimeout(()=>{g.addClass(this._element,`${this._classes.loadingSpinner} ${this._classes.spinnerColor}`),this._setBackdrop(),this._setLoadingIcon(t),this._setLoadingText(e),this._setScrollOption(),_.trigger(this._element,Pk)},this._options.delay)}dispose(){O.removeData(this._element,Sr),g.removeClass(this._element,`${this._classes.loadingSpinner} ${this._classes.spinnerColor}`);const t=this._options.delay;setTimeout(()=>{this._removeBackdrop(),this._backdropElement=null,this._element=null,this._options=null},t)}_setBackdrop(){const{backdrop:t}=this._options;t&&(this._backdropElement=Lk(this._options,this._classes),this._parentElement!==null?(g.addClass(this._element,\"absolute\"),g.addClass(this._parentElement,\"relative\"),g.addClass(this._backdropElement,\"absolute\"),this._parentElement.appendChild(this._backdropElement)):(g.addClass(this._element,\"!fixed\"),document.body.appendChild(this._backdropElement),document.body.appendChild(this._element)))}_removeBackdrop(){const{backdrop:t}=this._options;t&&(this._parentElement!==null?(g.removeClass(this._element,\"absolute\"),g.removeClass(this._parentElement,\"relative\"),this._backdropElement.remove()):(this._backdropElement.remove(),this._element.remove()))}_setLoadingIcon(t){if(!this._options.loadingIcon){t.remove();return}this._element.appendChild(t),t.id=this._options.loader}_setLoadingText(t){if(!this._options.loadingText){t.remove();return}this._element.appendChild(t)}_removeElementsOnStart(){this._element!==null&&(this._loadingIcon.remove(),this._loadingText.remove())}_setScrollOption(){if(this._options.scroll){if(this._parentElement===null){g.addClass(document.body,\"overflow-auto\");return}g.addClass(this._parentElement,\"overflow-auto\")}else{if(this._parentElement===null){g.addClass(document.body,\"overflow-hidden\");return}g.addClass(this._parentElement,\"overflow-hidden\")}}_getConfig(t){const e={...Bk,...g.getDataAttributes(this._element),...t};return L(En,e,Nk),e}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Hk,...e,...t},L(En,t,Vk),t}static getInstance(t){return O.getData(t,Sr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Sr);const i=typeof t==\"object\"&&t;if(e||(e=new Or(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Fk=s=>{const t=/^(0?[1-9]|1[012])(:[0-5]\\d) [APap][mM]$/,e=/^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;return s.match(t)||s.match(e)},Wk=s=>s&&Object.prototype.toString.call(s)===\"[object Date]\"&&!isNaN(s),zk=s=>s.getMonth(),jk=s=>s.getFullYear(),Yk=s=>s.match(/[^(dmy)]{1,}/g),Kk=(s,t,e,i)=>{let n;e[0]!==e[1]?n=e[0]+e[1]:n=e[0];const o=new RegExp(`[${n}]`),r=s.split(o),a=t.split(o),l=t.indexOf(\"mmm\")!==-1,c=[];for(let b=0;bt.findIndex(e=>e===s),Xk=(s,t,e)=>`\n \n \n`,Gk=(s,t)=>`\n \n`,Ir=\"datetimepicker\",xn=`te.${Ir}`,hc=`.${xn}`,qp=\"data-te-datepicker-init\",Zp=\"data-te-timepicker-init\",qk=\"data-te-datepicker-header\",Zk=\"data-te-datepicker-cancel-button-ref\",Qk=\"data-te-datepicker-ok-button-ref\",dc=\"data-te-timepicker-wrapper\",Qp=\"data-te-timepicker-cancel\",Jk=\"data-te-timepicker-submit\",tS=\"data-te-timepicker-clear\",Jp=\"data-te-buttons-timepicker\",eS=\"data-te-date-timepicker-toggle-ref\",iS=\"data-te-datepicker-toggle-button-ref\",sS=\"data-te-timepicker-toggle-button-ref\",nS=`[${Zp}]`,oS=`[${qp}]`,rS=`[${eS}]`,aS=`[${sS}]`,lS=\"[data-te-input-notch-ref]\",cS=\"[data-te-date-timepicker-toggle-ref]\",hS=\"[data-te-timepicker-elements-wrapper]\",dS=\"[data-te-timepicker-clock-wrapper]\",uS=`open${hc}`,pS=`close${hc}`,fS=`datetimeChange${hc}`,tf=\"close.te.datepicker\",ef=\"input.te.timepicker\",Es=$(\"div\"),sf={inline:!1,toggleButton:!0,container:\"body\",disabled:!1,disablePast:!1,disableFuture:!1,defaultTime:\"\",defaultDate:\"\",timepicker:{},datepicker:{},showFormat:!1,dateTimepickerToggleIconTemplate:`\n \n `,datepickerToggleIconTemplate:`\n \n `,timepickerToggleIconTemplate:`\n \n `},_S={inline:\"boolean\",toggleButton:\"boolean\",container:\"string\",disabled:\"boolean\",disablePast:\"boolean\",disableFuture:\"boolean\",defaultTime:\"(string|date|number)\",defaultDate:\"(string|date|number)\",timepicker:\"object\",datepicker:\"object\",showFormat:\"boolean\",dateTimepickerToggleIconTemplate:\"string\",datepickerToggleIconTemplate:\"string\",timepickerToggleIconTemplate:\"string\"},gS={toggleButton:\"flex items-center justify-content-center [&>svg]:w-5 [&>svg]:h-5 absolute outline-none border-none bg-transparent right-0.5 top-1/2 -translate-x-1/2 -translate-y-1/2 hover:text-primary focus:text-primary dark:hover:text-primary-400 dark:focus:text-primary-400 dark:text-neutral-200\",pickerIcon:\"[&>svg]:w-6 [&>svg]:h-6 [&>svg]:mx-auto [&>svg]:pointer-events-none w-1/2 px-1.5 py-[1px] rounded-[10px] min-h-[40px] cursor-pointer outline-none border-none text-white hover:bg-primary-600 dark:hover:bg-neutral-600\",buttonsContainer:\"flex justify-evenly items-end bg-primary dark:bg-zinc-800 dark:data-[te-buttons-timepicker]:bg-zinc-700\",timepicker:{},datepicker:{}},mS={toggleButton:\"string\",pickerIcon:\"string\",buttonsContainer:\"string\",timepicker:\"object\",datepicker:\"object\"};class Dr{constructor(t,e,i){this._element=t,this._input=m.findOne(\"input\",this._element),this._options=this._getConfig(e),this._classes=this._getClasses(i),this._timepicker=null,this._datepicker=null,this._dateValue=this._options.defaultDate?this._options.defaultDate:\"\",this._timeValue=this._options.defaultTime?this._options.defaultTime:\"\",this._isInvalidTimeFormat=!1,this._format=this._options.datepicker.format?this._options.datepicker.format:\"dd/mm/yyyy\",this._cancel=!1,this._scrollBar=new Qi,this._element&&O.setData(t,xn,this),this._init()}static get NAME(){return Ir}get toggleButton(){return m.findOne(rS,this._element)}get notch(){return m.findOne(lS,this._element)}dispose(){_.off(this._element,\"click\",this._openDatePicker),_.off(this._input,\"input\",this._handleInput),_.off(this._element,\"click\"),O.removeData(this._element,xn),this._removeTimePicker(),this._removeDatepicker(),this.toggleButton.remove(),this._options=sf,this._timepicker=null,this._datepicker=null,this._dateValue=null,this._timeValue=null,this._isInvalidTimeFormat=null}update(t={}){const e=this._getConfig({...this._options,...t});this.dispose(),this._options=e,this._init()}_init(){this._addDatepicker(),this._addTimePicker(),this._appendToggleButton(),this._listenToToggleClick(),this._listenToUserInput(),this._disableInput(),this._setInitialDefaultInput(),this._applyFormatPlaceholder(),this._options.disablePast&&this._handleTimepickerDisablePast(),this._options.disableFuture&&this._handleTimepickerDisableFuture()}_removeDatepicker(){const t=this._element.querySelector(oS);t&&t.remove()}_addDatepicker(){const t=$(\"div\");t.id=this._element.id?`datepicker-${this._element.id}`:bt(\"datepicker-\");const e='';t.innerHTML=e,t.setAttribute(qp,\"\"),this._element.appendChild(t),g.addClass(t,\"hidden\");let i={...this._options.datepicker,container:this._options.container,disablePast:this._options.disablePast,disableFuture:this._options.disableFuture};(this._options.inline||this._options.datepicker.inline)&&(i={...i,inline:!0}),this._datepicker=new xl(t,i,{...this._classes.datepicker}),this._datepicker._input.value=this._dateValue}_removeTimePicker(){const t=this._element.querySelector(nS);t&&(t.remove(),this._scrollBar.reset())}_addTimePicker(){const t=$(\"div\");t.id=this._element.id?`timepicker-${this._element.id}`:bt(\"timepicker-\");const e='';t.innerHTML=e,t.setAttribute(Zp,\"\"),this._element.appendChild(t),g.addClass(t,\"hidden\");let i={...this._options.timepicker,container:this._options.container};(this._options.inline||this._options.timepicker.inline)&&(i={...i,inline:!0}),this._timepicker=new Ll(t,i,{...this._classes.timepicker}),this._timepicker.input.value=this._timeValue}_addIconButtons(){if(g.addClass(Es,this._classes.buttonsContainer),Es.innerHTML=Xk(this._options.datepickerToggleIconTemplate,this._options.timepickerToggleIconTemplate,this._classes),Es.removeAttribute(Jp),!(this._options.inline||this._options.datepicker.inline)){if(this._scrollBar.hide(),this._datepicker._isOpen)m.findOne(`[${qk}]`,document.body).appendChild(Es);else if(this._timepicker._modal&&!this._options.timepicker.inline){const t=m.findOne(hS,document.body),e=m.findOne(dS,document.body);Es.setAttribute(Jp,\"\"),t.insertBefore(Es,e)}}}_enableOrDisableToggleButton(){this._options.disabled?(this.toggleButton.disabled=!0,g.addClass(this.toggleButton,\"pointer-events-none\")):(this.toggleButton.disabled=!1,g.removeClass(this.toggleButton,\"pointer-events-none\"))}_appendToggleButton(){this._options.toggleButton&&(this._element.insertAdjacentHTML(\"beforeend\",Gk(this._options.dateTimepickerToggleIconTemplate,this._classes)),this._enableOrDisableToggleButton())}_applyFormatPlaceholder(){this._options.showFormat&&(this._input.placeholder=this._format)}_listenToCancelClick(){const t=m.findOne(`[${Zk}]`,document.body);_.one(t,\"mousedown\",()=>{this._cancel=!0,this._scrollBar.reset(),_.off(t,\"mousedown\")})}_listenToToggleClick(){_.on(this._element,\"click\",cS,t=>{t.preventDefault(),this._openDatePicker()})}_listenToUserInput(){_.on(this._input,\"input\",t=>{this._handleInput(t.target.value)})}_disableInput(){this._options.disabled&&(this._input.disabled=\"true\")}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...sf,...e,...t},L(Ir,t,_S),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...gS,...e,...t},L(Ir,t,mS),t}_handleInput(t){const e=t.split(\", \"),i=Yk(this._format),n=e[0],o=e[1]||\"\",r=Kk(n,this._format,i,this._datepicker._options);e.length===2&&(Wk(r)&&Fk(o)?(this._dateValue=n,this._timeValue=o,this._datepicker._input.value=this._dateValue,this._datepicker._activeDate=this._dateValue,this._datepicker._selectedYear=jk(r),this._datepicker._selectedMonth=zk(r),this._datepicker._headerDate=r,this._timepicker.input.value=this._timeValue,this._timepicker._isInvalidTimeFormat=!1):(this._datepicker._activeDate=new Date,this._datepicker._selectedDate=null,this._datepicker._selectedMonth=null,this._datepicker._selectedYear=null,this._datepicker._headerDate=null,this._datepicker._headerMonth=null,this._datepicker._headerYear=null,this._timepicker._isInvalidTimeFormat=!0))}_openDatePicker(){if(_.trigger(this._element,uS).defaultPrevented)return;this._datepicker.open(),this._options.inline||this._scrollBar.hide(),(this._options.inline||this._options.datepicker.inline)&&this._openDropdownDate(),this._addIconButtons(),this._listenToCancelClick(),this._options.inline&&this._datepicker._isOpen&&g.addClass(this.toggleButton,\"pointer-events-none\"),_.one(this._datepicker._element,tf,()=>{if(this._dateValue=this._datepicker._input.value,this._updateInputValue(),this._cancel){this._cancel=!1;return}let i=!1;_.on(this._datepicker.container,\"click\",n=>{!this._datepicker._selectedDate&&n.target.hasAttribute(Qk)||i||(this._openTimePicker(),i=!0,setTimeout(()=>{i=!1},500))}),setTimeout(()=>{m.findOne(`[${dc}]`,document.body)||this._scrollBar.reset()},10),this._options.inline&&g.removeClass(this.toggleButton,\"pointer-events-none\")});const e=m.findOne(aS,document.body);_.on(e,\"click\",()=>{this._datepicker.close(),this._scrollBar.hide(),_.trigger(this._datepicker._element,tf)})}_handleTimepickerDisablePast(){const t=new Date;t.setHours(0,0,0,0),_.on(this._datepicker._element,\"dateChange.te.datepicker\",()=>{this._datepicker._selectedDate.getTime()===t.getTime()?this._timepicker.update({disablePast:!0}):this._timepicker.update({disablePast:!1})})}_handleTimepickerDisableFuture(){const t=new Date;t.setHours(0,0,0,0),_.on(this._datepicker._element,\"dateChange.te.datepicker\",()=>{this._datepicker._selectedDate.getTime()===t.getTime()?this._timepicker.update({disableFuture:!0}):this._timepicker.update({disableFuture:!1})})}_handleEscapeKey(){_.one(document.body,\"keyup\",()=>{setTimeout(()=>{m.findOne(`[${dc}]`,document.body)||this._scrollBar.reset()},250)})}_handleCancelButton(){const t=m.findOne(`[${Qp}]`,document.body);_.one(t,\"mousedown\",()=>{this._scrollBar.reset()})}_openDropdownDate(){const t=this._datepicker._popper;t.state.elements.reference=this._input,this._scrollBar.reset()}_openTimePicker(){_.trigger(this._timepicker.elementToggle,\"click\"),setTimeout(()=>{if(this._addIconButtons(),(this._options.inline||this._options.timepicker.inline)&&this._openDropdownTime(),this._timepicker._modal){const t=m.findOne(`[${Qp}]`,document.body);this._handleEscapeKey(),this._handleCancelButton(),_.on(this._timepicker._modal,\"click\",e=>{(e.target.hasAttribute(dc)||e.target.hasAttribute(Jk))&&setTimeout(()=>{this._scrollBar.reset()},200),e.target.hasAttribute(tS)&&_.trigger(this._timepicker._element,ef),e.target.hasAttribute(iS)&&(_.trigger(t,\"click\"),setTimeout(()=>{this._openDatePicker(),this._scrollBar.hide()},200))})}}),_.one(this._timepicker._element,ef,()=>{this._timeValue=this._timepicker.input.value,this._updateInputValue(),_.trigger(this._element,pS)})}_openDropdownTime(){const t=this._timepicker._popper;t.state.elements.reference=this._input,t.update(),this._scrollBar.reset()}_setInitialDefaultInput(){(this._options.defaultDate||this._options.defaultTime)&&this._updateInputValue()}_updateInputValue(){this._timeValue&&this._dateValue&&(this._input.value=`${this._dateValue}, ${this._timeValue}`,_.trigger(this._element,fS,{value:this._input.value}).defaultPrevented)||(_.trigger(this._input,\"focus\"),this.notch&&this.notch.removeAttribute(\"data-te-input-focused\"))}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,xn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Dr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,xn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Mr=\"sticky\",Cn=`te.${Mr}`,nf=`.${Cn}`,bS=`active${nf}`,vS=`inactive${nf}`,yS={stickyAnimationSticky:\"\",stickyAnimationUnsticky:\"\",stickyBoundary:!1,stickyDelay:0,stickyDirection:\"down\",stickyMedia:0,stickyOffset:0,stickyPosition:\"top\",stickyZIndex:100},TS={stickyAnimationSticky:\"string\",stickyAnimationUnsticky:\"string\",stickyBoundary:\"(boolean|string)\",stickyDelay:\"number\",stickyDirection:\"string\",stickyMedia:\"number\",stickyOffset:\"number\",stickyPosition:\"string\",stickyZIndex:\"(string|number)\"},ES={stickyActive:\"\"},xS={stickyActive:\"string\"};class Lr{constructor(t,e,i){this._element=t,this._hiddenElement=null,this._elementPositionStyles={},this._scrollDirection=\"\",this._isSticked=!1,this._elementOffsetTop=null,this._scrollTop=0,this._pushPoint=\"\",this._manuallyDeactivated=!1,this._element&&(this._options=this._getConfig(e),this._classes=this._getClasses(i),O.setData(t,Cn,this),this._init())}static get NAME(){return Mr}dispose(){const{stickyAnimationUnsticky:t}=this._options;let{animationDuration:e}=getComputedStyle(this._element);e=t!==\"\"?parseFloat(e)*1e3:0,this._disableSticky(),setTimeout(()=>{O.removeData(this._element,Cn),this._element=null,this._options=null,this._hiddenElement=null,this._elementPositionStyles=null,this._scrollDirection=null,this._isSticked=null,this._elementOffsetTop=null,this._scrollTop=null,this._pushPoint=null,this._manuallyDeactivated=null},e)}active(){this._isSticked||(this._createHiddenElement(),this._enableSticky(),this._changeBoundaryPosition(),this._isSticked=!0,this._manuallyDeactivated=!1)}inactive(){this._isSticked&&(this._disableSticky(),this._isSticked=!1,this._manuallyDeactivated=!0)}_init(){this._userActivityListener()}_userActivityListener(){_.on(window,\"resize\",()=>{this._updateElementPosition(),this._updateElementOffset()}),_.on(window,\"scroll\",()=>{if(!this._element||window.innerWidth<=this._options.stickyMedia||this._manuallyDeactivated)return;const t=document.documentElement,{stickyDirection:e}=this._options,i=window.pageYOffset||t.scrollTop;this._updateElementOffset(),this._updatePushPoint(),this._updateScrollDirection(i),this._clearInProgressAnimations();const n=[this._scrollDirection,\"both\"].includes(e),o=this._pushPoint<=i,r=o&&!this._isSticked&&n,a=(!o||!n)&&this._isSticked;r&&(this._createHiddenElement(),this._enableSticky(),this._changeBoundaryPosition(),this._isSticked=!0),a&&(this._disableSticky(),this._isSticked=!1),this._isSticked&&(this._updatePosition({styles:this._elementPositionStyles}),this._changeBoundaryPosition()),this._scrollTop=i<=0?0:i})}_updatePushPoint(){this._options.stickyPosition===\"top\"?this._pushPoint=this._elementOffsetTop-this._options.stickyDelay:this._pushPoint=this._elementOffsetTop+this._element.height-document.body.scrollHeight+this._options.stickyDelay}_updateElementOffset(){this._hiddenElement?this._elementOffsetTop=this._hiddenElement.offsetTop:this._elementOffsetTop=this._element.offsetTop,this._options.stickyAnimationUnsticky&&(this._elementOffsetTop+=this._element.height||0)}_updateElementPosition(){if(this._hiddenElement){const{left:t}=this._hiddenElement.getBoundingClientRect();this._elementPositionStyles={left:`${t}px`}}else this._elementPositionStyles={};this._setStyle(this._element,this._elementPositionStyles)}_updateScrollDirection(t){t>this._scrollTop?this._scrollDirection=\"down\":this._scrollDirection=\"up\"}_clearInProgressAnimations(){const t=this._scrollDirection===\"up\",e=this._element.classList.contains(this._options.stickyAnimationUnsticky),i=window.scrollY<=this._elementOffsetTop-this._element.height;t&&e&&i&&(this._removeUnstickyAnimation(),this._resetStyles(),this._removeHiddenElement())}_enableSticky(){const{stickyAnimationSticky:t,stickyAnimationUnsticky:e,stickyOffset:i,stickyPosition:n,stickyZIndex:o}=this._options,{height:r,left:a,width:l}=this._element.getBoundingClientRect();t!==\"\"&&this._toggleClass(t,e,this._element),this._toggleClass(this._classes.stickyActive,\"\",this._element),this._setStyle(this._element,{top:n===\"top\"&&`${0+i}px`,bottom:n===\"bottom\"&&`${0+i}px`,height:`${r}px`,width:`${l}px`,left:`${a}px`,zIndex:`${o}`,position:\"fixed\"}),this._hiddenElement.hidden=!1,_.trigger(this._element,bS)}_changeBoundaryPosition(){const{stickyPosition:t,stickyBoundary:e,stickyOffset:i}=this._options,{height:n}=this._element.getBoundingClientRect(),o={height:this._element.parentElement.getBoundingClientRect().height,...this._getOffset(this._element.parentElement)};let r;const a=m.findOne(e);a?r=this._getOffset(a).top-n-i:r=o.height+o[t]-n-i;const l=t===\"top\",c=t===\"bottom\",h=e,d=r<0,u=r>o.height-n;let p;l&&(d&&h?p={top:`${i+r}px`}:p={top:`${i+0}px`}),c&&(d&&h?p={bottom:`${i+r}px`}:u&&h?p={bottom:`${i+o.bottom}px`}:p={bottom:`${i+0}px`}),this._setStyle(this._element,p)}_disableSticky(){const{stickyAnimationUnsticky:t,stickyAnimationSticky:e}=this._options;let{animationDuration:i}=getComputedStyle(this._element);i=t!==\"\"?parseFloat(i)*1e3:0,this._options.stickyAnimationUnsticky!==\"\"&&this._toggleClass(t,e,this._element),setTimeout(()=>{this._element.classList.contains(e)||(this._removeUnstickyAnimation(),this._resetStyles(),this._removeHiddenElement(),this._toggleClass(\"\",this._classes.stickyActive,this._element),_.trigger(this._element,vS))},i)}_createHiddenElement(){this._hiddenElement||(this._hiddenElement=this._copyElement(this._element))}_removeHiddenElement(){this._hiddenElement&&(this._hiddenElement.remove(),this._hiddenElement=null)}_removeUnstickyAnimation(){this._toggleClass(\"\",this._options.stickyAnimationUnsticky,this._element)}_resetStyles(){this._setStyle(this._element,{top:null,bottom:null,position:null,left:null,zIndex:null,width:null,height:null})}_updatePosition({styles:t}){this._setStyle(this._element,t)}_toggleClass(t,e,i){t&&g.addClass(i,t),e&&g.removeClass(i,e)}_getOffset(t){const e=g.offset(t),i=t.getBoundingClientRect(),n=e.left===0&&e.top===0?0:window.innerHeight-i.bottom;return{...e,bottom:n}}_copyElement(t){const{height:e,width:i}=t.getBoundingClientRect(),n=t.cloneNode(!1);return n.hidden=!0,this._setStyle(n,{height:`${e}px`,width:`${i}px`,opacity:\"0\"}),t.parentElement.insertBefore(n,t),n}_getConfig(t={}){const e=g.getDataAttributes(this._element);return t={...yS,...e,...t},L(Mr,t,TS),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...ES,...e,...t},L(Mr,t,xS),t}_setStyle(t,e){Object.keys(e).forEach(i=>{t.style[i]=e[i]})}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,Cn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose|hide/.test(t))&&(i||(i=new Lr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Cn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const CS=\"data-te-autocomplete-dropdown-ref\",AS=\"data-te-autocomplete-items-list-ref\",wS=\"data-te-autocomplete-item-ref\",kS=\"data-te-autocomplete-loader-ref\";function SS(s,t){const{id:e,items:i,width:n,options:o}=s,r=$(\"div\");g.addClass(r,t.dropdownContainer),g.addStyle(r,{width:`${n}px`}),r.setAttribute(\"id\",e);const a=$(\"div\");a.setAttribute(CS,\"\"),g.addClass(a,t.dropdown);const l=$(\"ul\"),c=o.listHeight;l.setAttribute(AS,\"\"),g.addClass(l,t.autocompleteList),g.addClass(l,t.scrollbar),g.addStyle(l,{maxHeight:`${c}px`}),l.setAttribute(\"role\",\"listbox\");const h=of(i,o);return l.innerHTML=h,a.appendChild(l),r.appendChild(a),r}function of(s=[],t,e){const i=t.displayValue,n=t.itemContent;return`\n ${s.map((o,r)=>{const a=typeof n==\"function\"?To(n(o),Pd,null):i(o);return`
  • ${a}
  • `}).join(\"\")}\n `}function OS(s){const t=$(\"div\");t.setAttribute(kS,\"\"),g.addClass(t,s.autocompleteLoader),g.addClass(t,s.spinnerIcon),t.setAttribute(\"role\",\"status\");const e='Loading...';return t.innerHTML=e,t}function IS(s,t){return`
  • ${s}
  • `}const uc=\"autocomplete\",An=\"te.autocomplete\",xs=\"data-te-input-state-active\",pc=\"data-te-autocomplete-item-active\",rf=\"data-te-input-focused\",af=\"data-te-autocomplete-state-open\",DS=\"data-te-autocomplete-custom-content-ref\",MS=\"[data-te-autocomplete-dropdown-ref]\",$r=\"[data-te-autocomplete-items-list-ref]\",lf=\"[data-te-autocomplete-item-ref]\",LS=\"[data-te-autocomplete-loader-ref]\",$S=`[${DS}]`,RS=\"[data-te-input-notch-ref]\",Rr=`.${An}`,PS=`close${Rr}`,NS=`open${Rr}`,cf=`itemSelect${Rr}`,BS=`update${Rr}`,HS={autoSelect:!1,container:\"body\",customContent:\"\",debounce:300,displayValue:s=>s,filter:null,itemContent:null,listHeight:190,loaderCloseDelay:300,noResults:\"No results found\",threshold:0},VS={autoSelect:\"boolean\",container:\"string\",customContent:\"string\",debounce:\"number\",displayValue:\"function\",filter:\"(null|function)\",itemContent:\"(null|function)\",listHeight:\"number\",loaderCloseDelay:\"number\",noResults:\"string\",threshold:\"number\"},FS={autocompleteItem:\"flex flex-row items-center justify-between w-full px-4 py-[0.4375rem] truncate text-gray-700 bg-transparent select-none cursor-pointer hover:[&:not([data-te-autocomplete-option-disabled])]:bg-black/5 data-[te-autocomplete-item-active]:bg-black/5 data-[data-te-autocomplete-option-disabled]:text-gray-400 data-[data-te-autocomplete-option-disabled]:cursor-default dark:text-gray-200 dark:hover:[&:not([data-te-autocomplete-option-disabled])]:bg-white/30 dark:data-[te-autocomplete-item-active]:bg-white/30\",autocompleteList:\"list-none m-0 p-0 overflow-y-auto\",autocompleteLoader:\"absolute right-1 top-2 w-[1.4rem] h-[1.4rem] border-[0.15em]\",dropdown:\"relative outline-none min-w-[100px] m-0 scale-y-[0.8] opacity-0 bg-white shadow-[0_2px_5px_0_rgba(0,0,0,0.16),_0_2px_10px_0_rgba(0,0,0,0.12)] transition duration-200 motion-reduce:transition-none data-[te-autocomplete-state-open]:scale-y-100 data-[te-autocomplete-state-open]:opacity-100 dark:bg-zinc-700\",dropdownContainer:\"z-[1070]\",scrollbar:\"[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-button]:block [&::-webkit-scrollbar-button]:h-0 [&::-webkit-scrollbar-button]:bg-transparent [&::-webkit-scrollbar-track-piece]:bg-transparent [&::-webkit-scrollbar-track-piece]:rounded-none [&::-webkit-scrollbar-track-piece]: [&::-webkit-scrollbar-track-piece]:rounded-l [&::-webkit-scrollbar-thumb]:h-[50px] [&::-webkit-scrollbar-thumb]:bg-[#999] [&::-webkit-scrollbar-thumb]:rounded\",spinnerIcon:\"inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]\"},WS={autocompleteItem:\"string\",autocompleteList:\"string\",autocompleteLoader:\"string\",dropdown:\"string\",dropdownContainer:\"string\",scrollbar:\"string\",spinnerIcon:\"string\"};class Pr{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._getContainer(),this._input=m.findOne(\"input\",t),this._notch=m.findOne(RS,t),this._customContent=m.findOne($S,t),this._loader=OS(this._classes),this._popper=null,this._debounceTimeoutId=null,this._loaderTimeout=null,this._activeItemIndex=-1,this._activeItem=null,this._filteredResults=null,this._lastQueryValue=null,this._canOpenOnFocus=!0,this._isOpen=!1,this._outsideClickHandler=this._handleOutsideClick.bind(this),this._inputFocusHandler=this._handleInputFocus.bind(this),this._userInputHandler=this._handleUserInput.bind(this),this._keydownHandler=this._handleKeydown.bind(this),t&&O.setData(t,An,this),this._init()}static get NAME(){return uc}get filter(){return this._options.filter}get dropdown(){return m.findOne(MS,this._dropdownContainer)}get items(){return m.find(lf,this._dropdownContainer)}get itemsList(){return m.findOne($r,this._dropdownContainer)}initSearch(t){this._filterResults(t)}_getContainer(){this._container=m.findOne(this._options.container)}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...HS,...e,...t},L(uc,t,VS),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...FS,...e,...t},L(uc,t,WS),t}_init(){this._initDropdown(),this._updateInputState(),this._setInputAriaAttributes(),this._listenToInputFocus(),this._listenToUserInput(),this._listenToKeydown()}_initDropdown(){this._dropdownContainerId=this._element.id?`autocomplete-dropdown-${this._element.id}`:bt(\"autocomplete-dropdown-\");const t={id:this._dropdownContainerId,items:[],width:this._input.offsetWidth,options:this._options};if(this._dropdownContainer=SS(t,this._classes),this._options.customContent!==\"\"){const e=this._options.customContent,i=To(e,Pd,null);this.dropdown.insertAdjacentHTML(\"beforeend\",i)}}_setInputAriaAttributes(){this._input.setAttribute(\"role\",\"combobox\"),this._input.setAttribute(\"aria-expanded\",!1),this._input.setAttribute(\"aria-owns\",this._dropdownContainerId),this._input.setAttribute(\"aria-haspopup\",!0),this._input.setAttribute(\"autocomplete\",\"off\")}_updateInputState(){var t,e;this._input.value!==\"\"||this._isOpen?(this._input.setAttribute(xs,\"\"),(t=this._notch)==null||t.setAttribute(xs,\"\")):(this._input.removeAttribute(xs),(e=this._notch)==null||e.removeAttribute(xs))}_listenToInputFocus(){_.on(this._input,\"focus\",this._inputFocusHandler)}_handleInputFocus(t){const{value:e}=t.target,i=this._options.threshold;if(!this._canOpenOnFocus){this._canOpenOnFocus=!0;return}e.length{this._filterResults(t)},e)}_filterResults(t){this._lastQueryValue=t;const e=this.filter(t);this._isPromise(e)?this._asyncUpdateResults(e):this._updateResults(e)}_isPromise(t){return!!t&&typeof t.then==\"function\"}_asyncUpdateResults(t){this._resetActiveItem(),this._showLoader(),t.then(e=>{this._updateResults(e),this._loaderTimeout=setTimeout(()=>{this._hideLoader(),this._loaderTimeout=null},this._options.loaderCloseDelay)})}_resetActiveItem(){const t=this._activeItem;t&&(t.removeAttribute(pc),this._activeItem=null,this._activeItemIndex=-1)}_showLoader(){this._element.appendChild(this._loader)}_hideLoader(){m.findOne(LS,this._element)&&this._element.removeChild(this._loader)}_updateResults(t){this._resetActiveItem(),this._filteredResults=t,_.trigger(this._element,BS,{results:t});const e=m.findOne($r,this._dropdownContainer),i=of(t,this._options,this._classes.autocompleteItem),n=IS(this._options.noResults,this._classes);t.length===0&&this._options.noResults!==\"\"?e.innerHTML=n:e.innerHTML=i,this._isOpen||this.open(),this._popper&&this._popper.forceUpdate()}_listenToKeydown(){_.on(this._element,\"keydown\",this._keydownHandler)}_handleKeydown(t){this._isOpen?this._handleOpenKeydown(t):this._handleClosedKeydown(t)}_handleOpenKeydown(t){const e=t.keyCode;if(e===Ci&&this._options.autoSelect&&this._selectActiveItem(),e===xi||e===ut&&t.altKey){this.close(),this._input.focus();return}if(e===xi||e===ut&&t.altKey||e===Ci){this.close(),this._input.focus();return}switch(e){case ht:this._setActiveItem(this._activeItemIndex+1),this._scrollToItem(this._activeItem);break;case ut:this._setActiveItem(this._activeItemIndex-1),this._scrollToItem(this._activeItem);break;case Ti:this._activeItemIndex>-1?(this._setActiveItem(0),this._scrollToItem(this._activeItem)):this._input.setSelectionRange(0,0);break;case Ei:if(this._activeItemIndex>-1)this._setActiveItem(this.items.length-1),this._scrollToItem(this._activeItem);else{const n=this._input.value.length;this._input.setSelectionRange(n,n)}break;case Et:if(t.preventDefault(),this._activeItemIndex>-1){const n=this._filteredResults[this._activeItemIndex];this._handleSelection(n)}return;default:return}t.preventDefault()}_setActiveItem(t){const e=this.items;e[t]&&this._updateActiveItem(e[t],t)}_updateActiveItem(t,e){const i=this._activeItem;i&&i.removeAttribute(pc),t.setAttribute(pc,\"\"),this._activeItemIndex=e,this._activeItem=t}_scrollToItem(t){if(!t)return;const e=this.itemsList,i=e.offsetHeight,n=this.items.indexOf(t),o=t.offsetHeight,r=e.scrollTop;if(n>-1){const a=n*o,l=a+o>r+i;a{this.dropdown.setAttribute(af,\"\"),this._isOpen=!0,this._setInputActiveStyles(),this._updateInputState()},0))}_listenToOutsideClick(){_.on(document,\"click\",this._outsideClickHandler)}_handleOutsideClick(t){const e=this._input===t.target,i=t.target===this._dropdownContainer,n=this._dropdownContainer&&this._dropdownContainer.contains(t.target);!e&&!i&&!n&&this.close()}_listenToItemsClick(){const t=m.findOne($r,this._dropdownContainer);_.on(t,\"click\",this._handleItemsClick.bind(this))}_handleItemsClick(t){const e=m.closest(t.target,lf),i=g.getDataAttribute(e,\"index\"),n=this._filteredResults[i];this._handleSelection(n)}_selectActiveItem(){const t=this._filteredResults[this._activeItemIndex];if(!t)return;const e=this._options.displayValue(t);_.trigger(this._element,cf,{value:t}).defaultPrevented||setTimeout(()=>{this._canOpenOnFocus=!1,this._updateInputValue(e),this._updateInputState()},0)}_handleSelection(t){const e=this._options.displayValue(t),i=_.trigger(this._element,cf,{value:t});t!==void 0&&(i.defaultPrevented||setTimeout(()=>{this._canOpenOnFocus=!1,this._updateInputValue(e),this._updateInputState(),this._input.focus(),this.close()},0))}_updateInputValue(t){this._input.value=t}_setInputActiveStyles(){this._input.setAttribute(rf,\"\")}close(){var e;const t=_.trigger(this._element,PS);!this._isOpen||t.defaultPrevented||(this._resetActiveItem(),this._removeDropdownEvents(),this.dropdown.removeAttribute(af),_.on(this.dropdown,\"transitionend\",this._handleDropdownTransitionEnd.bind(this)),this._input.removeAttribute(rf),this._input.value||(this._input.removeAttribute(xs),(e=this._notch)==null||e.removeAttribute(xs)))}_removeDropdownEvents(){const t=m.findOne($r,this._dropdownContainer);_.off(t,\"click\"),_.off(document,\"click\",this._outsideClickHandler),_.off(window,\"resize\",this._handleWindowResize.bind(this))}_handleDropdownTransitionEnd(t){this._isOpen&&t&&t.propertyName===\"opacity\"&&(this._popper.destroy(),this._dropdownContainer&&this._container.removeChild(this._dropdownContainer),this._isOpen=!1,_.off(this.dropdown,\"transitionend\"),this._canOpenOnFocus=!0)}dispose(){this._isOpen&&this.close(),this._removeInputAndElementEvents(),this._dropdownContainer.remove(),O.removeData(this._element,An)}_removeInputAndElementEvents(){_.off(this._input,\"focus\",this._inputFocusHandler),_.off(this._input,\"input\",this._userInputHandler),_.off(this._element,\"keydown\",this._keydownHandler)}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,An);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Pr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,An)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zS=(s,t)=>`
    \n
    \n
    `,jS=(s,t)=>`
    \n \n
    `,YS=(s,t)=>`\n \n \n \n `,Si=s=>s.type===\"touchmove\"?s.touches[0].clientX:s.clientX,Nr=\"multiRangeSlider\",Br=`te.${Nr}`,hf=`valueChanged${`.${Br}`}`,Oi=\"data-te-active\",df=\"data-te-multi-range-slider-hand-ref\",uf=\"data-te-multi-range-slider-connect-ref\",pf=\"data-te-multi-range-slider-tooltip-ref\",KS={max:\"number\",min:\"number\",numberOfRanges:\"number\",startValues:\"(array|string)\",step:\"(string|null|number)\",tooltip:\"boolean\"},US={max:100,min:0,numberOfRanges:2,startValues:[0,100],step:null,tooltip:!1},XS={connect:\"z-10 h-full w-full bg-[#eee] will-change-transform dark:bg-[#4f4f4f]\",connectContainer:\"relative border-[1px] border-[#eee] z-0 h-full w-full overflow-hidden dark:border-[#4f4f4f]\",container:\"apperance-none relative m-auto w-full cursor-pointer h-1 border-0 bg-transparent p-0 focus:outline-none dark:border-[#4f4f4f]\",hand:\"apperance-none absolute top-[50%] border-0 -mt-1 h-4 w-4 cursor-pointer rounded-[50%] border-0 bg-primary transition-colors ease-in-out will-change-transform active:bg-[#c4d4ef] active:z-60\",tooltip:\"absolute -top-[18px] origin-[50%_50%] -translate-x-[6px] -rotate-45 scale-0 rounded-bl-none rounded-br-2xl rounded-tl-2xl rounded-tr-2xl bg-primary text-white transition-all duration-[200ms] data-[te-active]:-top-[38px] data-[te-active]:scale-100\",tooltipValue:\"block h-[30px] w-[30px] -translate-x-[6px] translate-y-[6px] rotate-45 text-center text-[10px]\"},GS={container:\"string\",connectContainer:\"string\",connect:\"string\",hand:\"string\",tooltip:\"string\",tooltipValue:\"string\"};class Hr extends Mt{constructor(t,e,i){super(t),this._options=this._getConfig(e),this._mousemove=!1,this._classes=this._getClasses(i),this._maxTranslation=null,this._minTranslation=null,this._currentStepValue=null,this._canChangeStep=!1,this.init()}static get NAME(){return Nr}get hands(){return m.find(`[${df}]`,this._element)}get connect(){return m.findOne(`[${uf}]`,this._element)}get leftConnectRect(){return this.connect.getBoundingClientRect().left}get handActive(){return m.findOne(`[${Oi}]`)}get activeTooltipValue(){return m.find(`[${pf}]`).filter(n=>n.hasAttribute(Oi))[0].children[0]}init(){this._setContainerClasses(),this._setRangeConnectsElement(),this._setRangeHandleElements(),this._setMaxAndMinTranslation(),this._setTransofrmationOnStart(),this._handleClickEventOnHand(),this._handleEndMoveEventDocument(),this._handleClickOnRange(),this._setTooltipToHand()}dispose(){O.removeData(this._element,Br),this._options=null,this._mousemove=null,this._maxTranslation=null,this._minTranslation=null,this._currentStepValue=null,this._canChangeStep=null,this.hands.forEach(t=>{ct.off(t,\"mousedown touchstart\"),ct.off(t,\"mouseup touchend\")}),ct.off(document,\"mousemove touchmove\"),ct.off(document,\"mouseup touchend\"),ct.off(this.connect,\"mousedown touchstart\")}_setMaxAndMinTranslation(){this._maxTranslation=this.connect.offsetWidth-this.hands[0].offsetWidth/2,this._minTranslation=this.connect.offsetLeft-this.hands[0].offsetWidth/2}_setTransofrmationOnStart(){const{max:t,min:e}=this._options;let{startValues:i}=this._options;typeof i==\"string\"&&(i=JSON.parse(i.replace(/'/g,'\"'))),i.length===0?this.hands.forEach(n=>{g.setDataAttribute(n,\"translation\",Math.round(this._minTranslation)),g.addStyle(n,{transform:`translate(${this._minTranslation}px,-25%)`})}):this.hands.forEach((n,o)=>{if(i[o]>t||i[o]{ct.on(n,\"mousedown touchstart\",o=>{if(this._mousemove=!0,n.setAttribute(Oi,\"\"),this._options.tooltip&&n.children[1].setAttribute(Oi,\"\"),this._handleMoveEvent(n),this._handleEndMoveEvent(n,o),!this._canChangeStep&&i!==null)return;const r=Si(o)-this.leftConnectRect-n.offsetWidth/2,a=(Si(o)-this.leftConnectRect)/(this.connect.offsetWidth/(t-e))%(t-e);r>=this._maxTranslation?this._handleOutOfMaxRangeValue(n,t):r<=this._minTranslation?this._handleOutOfMinRangeValue(n,e):this._handleNormalMove(n,r,a)})})}_setContainerClasses(){g.addClass(this._element,this._classes.container)}_setRangeConnectsElement(){this._element.insertAdjacentHTML(\"afterbegin\",zS({connectContainer:this._classes.connectContainer,connect:this._classes.connect},uf))}_setRangeHandleElements(){for(let t=0;t{t.setAttribute(\"aria-orientation\",\"horizontal\"),t.setAttribute(\"role\",\"slider\"),g.setDataAttribute(t,\"handle\",e)})}_setTooltipToHand(){this._options.tooltip&&this.hands.forEach(t=>t.insertAdjacentHTML(\"beforeend\",YS({tooltip:this._classes.tooltip,tooltipValue:this._classes.tooltipValue},pf)))}_handleMoveEvent(t){const{tooltip:e,step:i}=this._options;ct.on(document,\"mousemove touchmove\",n=>{n.type===\"mousemove\"&&n.preventDefault();const{max:o,min:r,numberOfRanges:a}=this._options;if(t.hasAttribute(Oi)){const l=(Si(n)-this.leftConnectRect)/this.connect.offsetWidth*o;let c=(Si(n)-this.leftConnectRect)/(this.connect.offsetWidth/(o-r))%(o-r)+r;if((this._currentStepValue===Math.round(c)||Math.round(c)%i!==0)&&i!==null){this._canChangeStep=!1;return}this._canChangeStep=!0;let h=Si(n)-this.leftConnectRect-t.offsetWidth/2;const d=g.getDataAttribute(this.handActive,\"handle\"),u=g.getDataAttribute(this.handActive,\"translation\");if(c=o)return;const p=this.hands.map(f=>g.getDataAttribute(f,\"translation\"));if(a<2)Math.round(c)%i===0&&i!==null?(this._currentStepValue=Math.round(c),g.addStyle(t,{transform:`translate(${h}px,-25%)`}),e&&(this.activeTooltipValue.innerText=Math.round(c))):i===null&&(g.addStyle(t,{transform:`translate(${h}px,-25%)`}),e&&(this.activeTooltipValue.innerText=Math.round(c))),g.setDataAttribute(t,\"translation\",h);else{const f=d>0&&d=y?(b=y,v=h<=b):d===a-1&&u<=T?(b=T,v=h>=b):f&&(u>=y||u<=T)&&(b=u>=y?y:T,v=b===y?h<=b:h>=b),Math.round(c)%i===0&&i!==null?(this._currentStepValue=Math.round(c),g.addStyle(t,{transform:`translate(${b}px,-25%)`}),e&&b===h&&this.activeTooltipValue!==null&&(this.activeTooltipValue.innerText=Math.round(c))):i===null&&(g.addStyle(t,{transform:`translate(${b}px,-25%)`}),e&&b===h&&this.activeTooltipValue!==null&&(this.activeTooltipValue.innerText=Math.round(c))),g.setDataAttribute(t,\"translation\",v?h:b)}this._canChangeStep&&this._handleEventChangeValuesOnRange()}})}_handleEventChangeValuesOnRange(){const{max:t,min:e,numberOfRanges:i}=this._options,n=r=>{const a=r.getBoundingClientRect().left-this.leftConnectRect+r.offsetWidth/2;let l=a/(this.connect.offsetWidth/(t-e))%(t-e);return a===this.connect.offsetWidth?l=t:l+=e,g.setDataAttribute(r,\"value\",Math.round(l*10)/10),{value:l}};if(i<2){const{value:r}=n(this.hands[0]);_.trigger(this._element,hf,{values:{value:r+e,rounded:Math.round(r+e)}});return}const o=this.hands.map(r=>n(r));_.trigger(this._element,hf,{values:{value:o.map(({value:r})=>r+e),rounded:o.map(({value:r})=>Math.round(r+e))}})}_resetHandState(t,e){_.off(t,e),t.removeAttribute(Oi),this._options.tooltip&&t.children[1].removeAttribute(Oi)}_handleEndMoveEventDocument(){ct.on(document,\"mouseup touchend\",()=>{this._mousemove&&(this.hands.forEach(t=>{this._resetHandState(t,\"mousemove\")}),ct.off(document,\"mousemove touchmove\"),this._mousemove=!1)})}_handleEndMoveEvent(t){ct.on(t,\"mouseup touchend\",()=>{this._resetHandState(t,\"mousemove\"),ct.off(document,\"mousemove touchmove\"),this._mousemove=!1})}_handleClickOnRange(){this._options.step===null&&ct.on(this.connect,\"mousedown touchstart\",t=>{const e=[];let i=0;if(this.hands.forEach(n=>{this._mousemove=!0;const o=Si(t),r=n.offsetWidth,a=g.getDataAttribute(n,\"translation\"),l=o-this.leftConnectRect-r/2;this._options.numberOfRanges<2?this._updateHand(n,l):(e.push(Math.abs(l-a)),e.forEach((c,h)=>{c=2){const n=Si(t)-this.leftConnectRect-this.hands[i].offsetWidth/2;this._updateAdjacentHands(i,n)}this._handleEventChangeValuesOnRange()})}_updateHand(t,e){g.addStyle(t,{transform:`translate(${e}px,-25%)`}),g.setDataAttribute(t,\"translation\",e)}_updateAdjacentHands(t,e){const i=this.hands[t+1],n=this.hands[t-1],o=i?g.getDataAttribute(i,\"translation\"):void 0,r=n?g.getDataAttribute(n,\"translation\"):void 0;i&&e>o?this._updateHand(i,e):n&&e\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Br)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const qS=s=>{ph(()=>{const t=uh();if(t){const e=s.NAME,i=t.fn[e];t.fn[e]=s.jQueryInterface,t.fn[e].Constructor=s,t.fn[e].noConflict=()=>(t.fn[e]=i,s.jQueryInterface)}})},ZS=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,function(e){e.preventDefault(),s.getOrCreateInstance(this).toggle()})},QS=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){if([\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),ci(this))return;s.getOrCreateInstance(this).show()})},JS=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){const i=Ne(this);if([\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),ci(this))return;_.one(i,s.EVENT_HIDDEN,()=>{ae(this)&&this.focus()});const n=m.findOne(s.OPEN_SELECTOR);n&&n!==i&&s.getInstance(n).hide(),s.getOrCreateInstance(i).toggle(this)})},tO=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,e=>{e.preventDefault();const i=e.target.closest(t);s.getOrCreateInstance(i).toggle()})},eO=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,function(e){const i=Ne(this);[\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),_.one(i,s.EVENT_SHOW,r=>{r.defaultPrevented||_.one(i,s.EVENT_HIDDEN,()=>{ae(this)&&this.focus()})});const n=m.findOne(`[${s.OPEN_SELECTOR}=\"true\"]`);n&&s.getInstance(n).hide(),s.getOrCreateInstance(i).toggle(this)})},iO=(s,t)=>{_.one(document,\"mousedown\",t,s.autoInitial(new s))},sO=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){(e.target.tagName===\"A\"||e.delegateTarget&&e.delegateTarget.tagName===\"A\")&&e.preventDefault();const i=Ca(this);m.find(i).forEach(o=>{s.getOrCreateInstance(o,{toggle:!1}).toggle()})})},nO=(s,t)=>{[].slice.call(document.querySelectorAll(t)).map(function(i){return new s(i)})},oO=(s,t)=>{[].slice.call(document.querySelectorAll(t)).map(function(i){return new s(i)})},rO=(s,t)=>{m.find(t).forEach(e=>{new s(e)}),_.on(document,`click.te.${s.NAME}.data-api`,`${t} img:not([data-te-lightbox-disabled])`,s.toggle())},aO=(s,t)=>{const e=o=>o[0]===\"{\"&&o[o.length-1]===\"}\"||o[0]===\"[\"&&o[o.length-1]===\"]\",i=o=>typeof o!=\"string\"?o:e(o)?JSON.parse(o.replace(/'/g,'\"')):o,n=o=>{const r={};return Object.keys(o).forEach(a=>{if(a.match(/dataset.*/)){const l=a.slice(7,8).toLowerCase().concat(a.slice(8));r[l]=i(o[a])}}),r};m.find(t).forEach(o=>{if(g.getDataAttribute(o,\"chart\")!==\"bubble\"&&g.getDataAttribute(o,\"chart\")!==\"scatter\"){const r=g.getDataAttributes(o),a={data:{datasets:[n(r)]}};return r.chart&&(a.type=r.chart),r.labels&&(a.data.labels=JSON.parse(r.labels.replace(/'/g,'\"'))),new s(o,{...a,...ln[a.type]})}return null})};class lO{constructor(){this.inits=[]}get initialized(){return this.inits}isInited(t){return this.inits.includes(t)}add(t){this.isInited(t)||this.inits.push(t)}}const fc=new lO,wn={alert:{name:\"Alert\",selector:\"[data-te-alert-init]\",isToggler:!1},animation:{name:\"Animate\",selector:\"[data-te-animation-init]\",isToggler:!1},carousel:{name:\"Carousel\",selector:\"[data-te-carousel-init]\",isToggler:!1},chips:{name:\"ChipsInput\",selector:\"[data-te-chips-input-init]\",isToggler:!1},chip:{name:\"Chip\",selector:\"[data-te-chip-init]\",isToggler:!1,onInit:\"init\"},datepicker:{name:\"Datepicker\",selector:\"[data-te-datepicker-init]\",isToggler:!1},datetimepicker:{name:\"Datetimepicker\",selector:\"[data-te-date-timepicker-init]\",isToggler:!1},input:{name:\"Input\",selector:\"[data-te-input-wrapper-init]\",isToggler:!1},perfectScrollbar:{name:\"PerfectScrollbar\",selector:\"[data-te-perfect-scrollbar-init]\",isToggler:!1},rating:{name:\"Rating\",selector:\"[data-te-rating-init]\",isToggler:!1},scrollspy:{name:\"ScrollSpy\",selector:\"[data-te-spy='scroll']\",isToggler:!1},select:{name:\"Select\",selector:\"[data-te-select-init]\",isToggler:!1},sidenav:{name:\"Sidenav\",selector:\"[data-te-sidenav-init]\",isToggler:!1},stepper:{name:\"Stepper\",selector:\"[data-te-stepper-init]\",isToggler:!1},timepicker:{name:\"Timepicker\",selector:\"[data-te-timepicker-init]\",isToggler:!1},toast:{name:\"Toast\",selector:\"[data-te-toast-init]\",isToggler:!1},datatable:{name:\"Datatable\",selector:\"[data-te-datatable-init]\"},popconfirm:{name:\"Popconfirm\",selector:\"[data-te-toggle='popconfirm']\"},validation:{name:\"Validation\",selector:\"[data-te-validation-init]\"},smoothScroll:{name:\"SmoothScroll\",selector:\"a[data-te-smooth-scroll-init]\"},lazyLoad:{name:\"LazyLoad\",selector:\"[data-te-lazy-load-init]\"},clipboard:{name:\"Clipboard\",selector:\"[data-te-clipboard-init]\"},infiniteScroll:{name:\"InfiniteScroll\",selector:\"[data-te-infinite-scroll-init]\"},loadingManagement:{name:\"LoadingManagement\",selector:\"[data-te-loading-management-init]\"},sticky:{name:\"Sticky\",selector:\"[data-te-sticky-init]\"},multiRangeSlider:{name:\"MultiRangeSlider\",selector:\"[data-te-multi-range-slider-init]\"},chart:{name:\"Chart\",selector:\"[data-te-chart]\",isToggler:!1,advanced:aO},button:{name:\"Button\",selector:\"[data-te-toggle='button']\",isToggler:!0,callback:tO},collapse:{name:\"Collapse\",selector:\"[data-te-collapse-init]\",isToggler:!0,callback:sO},dropdown:{name:\"Dropdown\",selector:\"[data-te-dropdown-toggle-ref]\",isToggler:!0,callback:ZS},modal:{name:\"Modal\",selector:\"[data-te-toggle='modal']\",isToggler:!0,callback:eO},ripple:{name:\"Ripple\",selector:\"[data-te-ripple-init]\",isToggler:!0,callback:iO},offcanvas:{name:\"Offcanvas\",selector:\"[data-te-offcanvas-toggle]\",isToggler:!0,callback:JS},tab:{name:\"Tab\",selector:\"[data-te-toggle='tab'], [data-te-toggle='pill'], [data-te-toggle='list']\",isToggler:!0,callback:QS},tooltip:{name:\"Tooltip\",selector:\"[data-te-toggle='tooltip']\",isToggler:!1,callback:nO},popover:{name:\"Popover\",selector:\"[data-te-toggle='popover']\",isToggler:!0,callback:oO},lightbox:{name:\"Lightbox\",selector:\"[data-te-lightbox-init]\",isToggler:!0,callback:rO},touch:{name:\"Touch\",selector:\"[data-te-touch-init]\"}},cO=s=>wn[s.NAME]||null,hO=(s,t)=>{if(!s||!t.allowReinits&&fc.isInited(s.NAME))return;fc.add(s.NAME);const e=cO(s),i=(e==null?void 0:e.isToggler)||!1;if(qS(s),e!=null&&e.advanced){e==null||e.advanced(s,e==null?void 0:e.selector);return}if(i){e==null||e.callback(s,e==null?void 0:e.selector);return}m.find(e==null?void 0:e.selector).forEach(n=>{let o=s.getInstance(n);o||(o=new s(n),e!=null&&e.onInit&&o[e.onInit]())})},dO=(s,t)=>{s.forEach(e=>hO(e,t))},uO={allowReinits:!1,checkOtherImports:!1},ff=(s,t={})=>{t={...uO,...t};const e=Object.keys(wn).map(i=>{if(!!document.querySelector(wn[i].selector)){const o=s[wn[i].name];return!o&&!fc.isInited(i)&&t.checkOtherImports&&console.warn(`Please import ${wn[i].name} from \"tw-elements\" package and add it to a object parameter inside \"initTE\" function`),o}});dO(e,t)},_f=\"sidenav\",Vr=\"te.sidenav\",pO=\"data-te-sidenav-rotate-icon-ref\",_c=\"[data-te-sidenav-toggle-ref]\",fO=\"[data-te-collapse-init]\",_O='[data-te-sidenav-slim=\"true\"]',gO='[data-te-sidenav-slim=\"false\"]',mO=\"[data-te-sidenav-menu-ref]\",Cs=\"[data-te-sidenav-collapse-ref]\",kn=\"[data-te-sidenav-link-ref]\",bO=et()?100:-100,vO=et()?-100:100,yO={sidenavAccordion:\"(boolean)\",sidenavBackdrop:\"(boolean)\",sidenavBackdropClass:\"(null|string)\",sidenavCloseOnEsc:\"(boolean)\",sidenavColor:\"(string)\",sidenavContent:\"(null|string)\",sidenavExpandable:\"(boolean)\",sidenavExpandOnHover:\"(boolean)\",sidenavFocusTrap:\"(boolean)\",sidenavHidden:\"(boolean)\",sidenavMode:\"(string)\",sidenavModeBreakpointOver:\"(null|string|number)\",sidenavModeBreakpointSide:\"(null|string|number)\",sidenavModeBreakpointPush:\"(null|string|number)\",sidenavBreakpointSm:\"(number)\",sidenavBreakpointMd:\"(number)\",sidenavBreakpointLg:\"(number)\",sidenavBreakpointXl:\"(number)\",sidenavBreakpoint2xl:\"(number)\",sidenavScrollContainer:\"(null|string)\",sidenavSlim:\"(boolean)\",sidenavSlimCollapsed:\"(boolean)\",sidenavSlimWidth:\"(number)\",sidenavPosition:\"(string)\",sidenavRight:\"(boolean)\",sidenavTransitionDuration:\"(number)\",sidenavWidth:\"(number)\"},TO={sidenavAccordion:!1,sidenavBackdrop:!0,sidenavBackdropClass:null,sidenavCloseOnEsc:!0,sidenavColor:\"primary\",sidenavContent:null,sidenavExpandable:!0,sidenavExpandOnHover:!1,sidenavFocusTrap:!0,sidenavHidden:!0,sidenavMode:\"over\",sidenavModeBreakpointOver:null,sidenavModeBreakpointSide:null,sidenavModeBreakpointPush:null,sidenavBreakpointSm:640,sidenavBreakpointMd:768,sidenavBreakpointLg:1024,sidenavBreakpointXl:1280,sidenavBreakpoint2xl:1536,sidenavScrollContainer:null,sidenavSlim:!1,sidenavSlimCollapsed:!1,sidenavSlimWidth:77,sidenavPosition:\"fixed\",sidenavRight:!1,sidenavTransitionDuration:300,sidenavWidth:240};class Ii{constructor(t,e={}){ke(this,\"_addBackdropOnInit\",()=>{this._options.sidenavHidden||(this._backdrop.show(),_.off(this._element,\"transitionend\",this._addBackdropOnInit))});this._element=t,this._options=e,this._ID=bt(\"\"),this._content=null,this._initialContentStyle=null,this._slimCollapsed=!1,this._activeNode=null,this._tempSlim=!1,this._backdrop=this._initializeBackDrop(),this._focusTrap=null,this._perfectScrollbar=null,this._touch=null,this._setModeFromBreakpoints(),this.escHandler=i=>{i.keyCode===xi&&this.toggler&&ae(this.toggler)&&(this._update(!1),_.off(window,\"keydown\",this.escHandler))},this.hashHandler=()=>{this._setActiveElements()},t&&(O.setData(t,Vr,this),this._setup()),this.options.sidenavBackdrop&&!this.options.sidenavHidden&&this.options.sidenavMode===\"over\"&&_.on(this._element,\"transitionend\",this._addBackdropOnInit),this._didInit=!1,this._init()}static get NAME(){return _f}get container(){if(this.options.sidenavPosition===\"fixed\")return m.findOne(\"body\");const t=e=>!e.parentNode||e.parentNode===document?e:e.parentNode.style.position===\"relative\"||e.parentNode.classList.contains(\"relative\")?e.parentNode:t(e.parentNode);return t(this._element)}get isVisible(){let t=0,e=window.innerWidth;if(this.options.sidenavPosition!==\"fixed\"){const n=this.container.getBoundingClientRect();t=n.x,e=n.x+n.width}const{x:i}=this._element.getBoundingClientRect();if(this.options.sidenavRight&&!et()||!this.options.sidenavRight&&et()){let n=0;if(this.container.scrollHeight>this.container.clientHeight&&(n=this.container.offsetWidth-this.container.clientWidth),this.container.tagName===\"BODY\"){const o=document.documentElement.clientWidth;n=Math.abs(window.innerWidth-o)}return Math.abs(i+n-e)>10}return Math.abs(i-t)<10}get links(){return m.find(kn,this._element)}get navigation(){return m.find(mO,this._element)}get options(){const t={...TO,...g.getDataAttributes(this._element),...this._options};return L(_f,t,yO),t}get sidenavStyle(){return{width:`${this.width}px`,height:this.options.sidenavPosition===\"fixed\"?\"100vh\":\"100%\",position:this.options.sidenavPosition,transition:`all ${this.transitionDuration} linear`}}get toggler(){return m.find(_c).find(e=>{const i=g.getDataAttribute(e,\"target\");return m.findOne(i)===this._element})}get transitionDuration(){return`${this.options.sidenavTransitionDuration/1e3}s`}get translation(){return this.options.sidenavRight?vO:bO}get width(){return this._slimCollapsed?this.options.sidenavSlimWidth:this.options.sidenavWidth}get isBackdropVisible(){return!!this._backdrop._element}changeMode(t){this._setMode(t)}dispose(){_.off(window,\"keydown\",this.escHandler),this.options.sidenavBackdrop&&this._backdrop.dispose(),_.off(window,\"hashchange\",this.hashHandler),this._touch.dispose(),O.removeData(this._element,Vr),this._element=null}hide(){this._emitEvents(!1),this._update(!1),this._options.sidenavBackdrop&&this.isBackdropVisible&&this._backdrop.hide()}show(){this._emitEvents(!0),this._update(!0),this._options.sidenavBackdrop&&this._options.sidenavMode===\"over\"&&this._backdrop.show()}toggle(){this._emitEvents(!this.isVisible),this._update(!this.isVisible)}toggleSlim(){this._setSlim(!this._slimCollapsed)}update(t){this._options=t,this._setup()}getBreakpoint(t){return this._transformBreakpointValuesToObject()[t]}_init(){this._didInit||(_.on(document,\"click\",_c,Ii.toggleSidenav()),this._didInit=!0)}_transformBreakpointValuesToObject(){return{sm:this.options.sidenavBreakpointSm,md:this.options.sidenavBreakpointMd,lg:this.options.sidenavBreakpointLg,xl:this.options.sidenavBreakpointXl,\"2xl\":this.options.sidenavBreakpoint2xl}}_setModeFromBreakpoints(){const t=window.innerWidth,e=this._transformBreakpointValuesToObject();if(t===void 0||!e)return;const i=typeof this.options.sidenavModeBreakpointOver==\"number\"?t-this.options.sidenavModeBreakpointOver:t-e[this.options.sidenavModeBreakpointOver],n=typeof this.options.sidenavModeBreakpointSide==\"number\"?t-this.options.sidenavModeBreakpointSide:t-e[this.options.sidenavModeBreakpointSide],o=typeof this.options.sidenavModeBreakpointPush==\"number\"?t-this.options.sidenavModeBreakpointPush:t-e[this.options.sidenavModeBreakpointPush],r=(l,c)=>l-c<0?-1:c-l<0?1:0,a=[i,n,o].filter(l=>l!=null&&l>=0).sort(r)[0];i>0&&i===a?(this._options.sidenavMode=\"over\",this._options.sidenavHidden=!0):n>0&&n===a?this._options.sidenavMode=\"side\":o>0&&o===a&&(this._options.sidenavMode=\"push\")}_collapseItems(){this.navigation.forEach(t=>{m.find(Cs,t).forEach(i=>{ce.getInstance(i).hide()})})}_getOffsetValue(t,{index:e,property:i,offsets:n}){const o=this._getPxValue(this._initialContentStyle[e][n[i].property]),r=t?n[i].value:0;return o+r}_getProperty(...t){return t.map((e,i)=>i===0?e:e[0].toUpperCase().concat(e.slice(1))).join(\"\")}_getPxValue(t){return t?parseFloat(t):0}_handleSwipe(t,e){e&&this._slimCollapsed&&this.options.sidenavSlim&&this.options.sidenavExpandable?this.toggleSlim():e||(this._slimCollapsed||!this.options.sidenavSlim||!this.options.sidenavExpandable?this.toggler&&ae(this.toggler)&&this.toggle():this.toggleSlim())}_isActive(t,e){return e?e===t:t.attributes.href?new URL(t,window.location.href).href===window.location.href:!1}_isAllToBeCollapsed(){return m.find(fO,this._element).filter(i=>i.getAttribute(\"aria-expanded\")===\"true\").length===0}_isAllCollapsed(){return m.find(Cs,this._element).filter(t=>ae(t)).length===0}_initializeBackDrop(){if(!this.options.sidenavBackdrop)return;const t=this.options.sidenavBackdropClass?this.options.sidenavBackdropClass.split(\" \"):this.options.sidenavPosition?[\"opacity-50\",\"transition-all\",\"duration-300\",\"ease-in-out\",this.options.sidenavPosition,\"top-0\",\"left-0\",\"z-50\",\"bg-black/10\",\"dark:bg-black-60\",\"w-full\",\"h-full\",this._element.id]:null;return new Qa({isVisible:this.options.sidenavBackdrop,isAnimated:!0,rootElement:this._element.parentNode,backdropClasses:t,clickCallback:()=>this.hide()})}_updateBackdrop(t){if(this.options.sidenavMode===\"over\"){t?this._backdrop.show():this.isBackdropVisible&&this._backdrop.hide();return}this.isBackdropVisible&&this._backdrop.hide()}_setup(){this._setupTouch(),this.options.sidenavFocusTrap&&this._setupFocusTrap(),this._setupCollapse(),this.options.sidenavSlim&&this._setupSlim(),this._setupInitialStyling(),this._setupScrolling(),this.options.sidenavContent&&this._setupContent(),this._setupActiveState(),this._setupRippleEffect(),this.options.sidenavHidden||this._updateOffsets(!0,!0),this.options.sidenavMode===\"over\"&&this._setTabindex(!0)}_setupActiveState(){this._setActiveElements(),this.links.forEach(t=>{_.on(t,\"click\",()=>this._setActiveElements(t)),_.on(t,\"keydown\",e=>{e.keyCode===Et&&this._setActiveElements(t)})}),_.on(window,\"hashchange\",this.hashHandler)}_setupCollapse(){this.navigation.forEach((t,e)=>{m.find(Cs,t).forEach((n,o)=>this._setupCollapseList({list:n,index:o,menu:t,menuIndex:e}))})}_generateCollpaseID(t,e){return`sidenav-collapse-${this._ID}-${e}-${t}`}_setupCollapseList({list:t,index:e,menu:i,menuIndex:n}){const o=this._generateCollpaseID(e,n);t.setAttribute(\"id\",o),t.setAttribute(\"data-te-collapse-item\",\"\");const[r]=m.prev(t,kn);g.setDataAttribute(r,\"collapse-init\",\"\"),r.setAttribute(\"href\",`#${o}`),r.setAttribute(\"role\",\"button\");const a=ce.getInstance(t)||new ce(t,{toggle:!1,parent:this.options.sidenavAccordion?i:t});(t.dataset.teSidenavStateShow===\"\"||t.dataset.teCollapseShow===\"\")&&this._rotateArrow(r,!1),_.on(r,\"click\",l=>{this._toggleCategory(l,a,t),this._tempSlim&&this._isAllToBeCollapsed()&&(this._setSlim(!0),this._tempSlim=!1),this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()}),_.on(t,\"show.te.collapse\",()=>this._rotateArrow(r,!1)),_.on(t,\"hide.te.collapse\",()=>this._rotateArrow(r,!0)),_.on(t,\"shown.te.collapse\",()=>{this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()}),_.on(t,\"hidden.te.collapse\",()=>{this._tempSlim&&this._isAllCollapsed()&&(this._setSlim(!0),this._tempSlim=!1),this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()})}_setupContent(){this._content=m.find(this.options.sidenavContent),this._content.forEach(t=>{const e=[\"!p\",\"!m\",\"!px\",\"!pl\",\"!pr\",\"!mx\",\"!ml\",\"!mr\",\"!-p\",\"!-m\",\"!-px\",\"!-pl\",\"!-pr\",\"!-mx\",\"!-ml\",\"!-mr\"];[...t.classList].filter(n=>e.findIndex(o=>n.includes(o))>=0).forEach(n=>t.classList.remove(n))}),this._initialContentStyle=this._content.map(t=>{const{paddingLeft:e,paddingRight:i,marginLeft:n,marginRight:o,transition:r}=window.getComputedStyle(t);return{paddingLeft:e,paddingRight:i,marginLeft:n,marginRight:o,transition:r}})}_setupFocusTrap(){this._focusTrap=new Vs(this._element,{event:\"keydown\",condition:t=>t.keyCode===Ci,onlyVisible:!0},this.toggler)}_setupInitialStyling(){this._setColor(),g.style(this._element,this.sidenavStyle)}_setupScrolling(){let t=this._element;if(this.options.sidenavScrollContainer){t=m.findOne(this.options.sidenavScrollContainer,this._element);const i=dm(t.parentNode.children).filter(n=>n!==t).reduce((n,o)=>n+o.clientHeight,0);g.style(t,{maxHeight:`calc(100% - ${i}px)`,position:\"relative\"})}this._perfectScrollbar=new ms(t,{suppressScrollX:!0,handlers:[\"click-rail\",\"drag-thumb\",\"wheel\",\"touch\"]})}_setupSlim(){this._slimCollapsed=this.options.sidenavSlimCollapsed,this._toggleSlimDisplay(this._slimCollapsed),this.options.sidenavExpandOnHover&&(this._element.addEventListener(\"mouseenter\",()=>{this._slimCollapsed&&this._setSlim(!1)}),this._element.addEventListener(\"mouseleave\",()=>{this._slimCollapsed||this._setSlim(!0)}))}_setupRippleEffect(){this.links.forEach(t=>{let e=Ye.getInstance(t),i=this.options.sidenavColor;if(e&&e._options.sidenavColor!==this.options.sidenavColor)e.dispose();else if(e)return;(localStorage.theme===\"dark\"||!(\"theme\"in localStorage)&&window.matchMedia(\"(prefers-color-scheme: dark)\").matches)&&(i=\"white\"),e=new Ye(t,{rippleColor:i})})}_setupTouch(){this._touch=new pE(this._element,\"swipe\",{threshold:20}),this._touch.init(),_.on(this._element,\"swipeleft\",t=>this._handleSwipe(t,this.options.sidenavRight)),_.on(this._element,\"swiperight\",t=>this._handleSwipe(t,!this.options.sidenavRight))}_setActive(t,e){t.setAttribute(\"data-te-sidebar-state-active\",\"\"),this._activeNode&&t.removeAttribute(\"data-te-sidebar-state-active\"),this._activeNode=t;const[i]=m.parents(this._activeNode,Cs);if(!i){this._setActiveCategory();return}const[n]=m.prev(i,kn);this._setActiveCategory(n),!e&&!this._slimCollapsed&&ce.getInstance(i).show()}_setActiveCategory(t){this.navigation.forEach(e=>{m.find(Cs,e).forEach(n=>{const[o]=m.prev(n,kn);o!==t?o.removeAttribute(\"data-te-sidenav-state-active\"):o.setAttribute(\"data-te-sidenav-state-active\",\"\")})})}_setActiveElements(t){this.navigation.forEach(e=>{m.find(kn,e).filter(n=>m.next(n,Cs).length===0).forEach(n=>{this._isActive(n,t)&&n!==this._activeNode&&this._setActive(n,t)})}),t&&this._updateFocus(this.isVisible)}_setColor(){const t=[\"primary\",\"secondary\",\"success\",\"info\",\"warning\",\"danger\",\"light\",\"dark\"],{sidenavColor:e}=this.options,i=t.includes(e)?e:\"primary\";t.forEach(n=>{this._element.classList.remove(`sidenav-${n}`)}),g.addClass(this._element,`sidenav-${i}`)}_setContentOffsets(t,e,i){this._content.forEach((n,o)=>{const r=this._getOffsetValue(t,{index:o,property:\"padding\",offsets:e}),a=this._getOffsetValue(t,{index:o,property:\"margin\",offsets:e}),l={};if(i||(l.transition=`all ${this.transitionDuration} linear`),l[e.padding.property]=`${r}px`,l[e.margin.property]=`${a}px`,g.style(n,l),!!t){if(i){g.style(n,{transition:this._initialContentStyle[o].transition});return}_.on(n,\"transitionend\",()=>{g.style(n,{transition:this._initialContentStyle[o].transition})})}})}_setMode(t){this.options.sidenavMode!==t&&(this._options.sidenavMode=t,this._update(this.isVisible))}_setSlim(t){const e=t?[\"collapse\",\"collapsed\"]:[\"expand\",\"expanded\"];this._triggerEvents(...e),t&&this._collapseItems(),this._slimCollapsed=t,this._toggleSlimDisplay(t),g.style(this._element,{width:`${this.width}px`}),this._updateOffsets(this.isVisible)}_setTabindex(t){this.links.forEach(e=>{e.tabIndex=t?0:-1})}_emitEvents(t){const e=t?[\"show\",\"shown\"]:[\"hide\",\"hidden\"];this._triggerEvents(...e)}_rotateArrow(t,e){const[i]=m.children(t,`[${pO}]`);i&&(e?g.removeClass(i,\"rotate-180\"):g.addClass(i,\"rotate-180\"))}_toggleCategory(t,e){t.preventDefault(),e.toggle(),this._slimCollapsed&&this.options.sidenavExpandable&&(this._tempSlim=!0,this._setSlim(!1))}_toggleSlimDisplay(t){const e=m.find(_O,this._element),i=m.find(gO,this._element),n=()=>{e.forEach(o=>{g.style(o,{display:this._slimCollapsed?\"unset\":\"none\"})}),i.forEach(o=>{g.style(o,{display:this._slimCollapsed?\"none\":\"unset\"})})};t?setTimeout(()=>n(),this.options.sidenavTransitionDuration):n()}async _triggerEvents(t,e){_.trigger(this._element,`${t}.te.sidenav`),e&&await setTimeout(()=>{_.trigger(this._element,`${e}.te.sidenav`)},this.options.sidenavTransitionDuration+5)}_isiPhone(){return/iPhone|iPod/i.test(navigator.userAgent)}_update(t){t&&this._isiPhone()&&g.addClass(this._element,\"ps--scrolling-y\"),this.toggler&&this._updateTogglerAria(t),this._updateDisplay(t),this.options.sidenavBackdrop&&this._updateBackdrop(t),this._updateOffsets(t),t&&this.options.sidenavCloseOnEsc&&this.options.sidenavMode!==\"side\"&&_.on(window,\"keydown\",this.escHandler),this.options.sidenavFocusTrap&&this._updateFocus(t)}_updateDisplay(t){const e=t?0:this.translation;g.style(this._element,{transform:`translateX(${e}%)`})}_updateFocus(t){if(this._setTabindex(t),this.options.sidenavMode===\"over\"&&this.options.sidenavFocusTrap){if(t){this._focusTrap.trap();return}this._focusTrap.disable()}this._focusTrap.disable()}_updateOffsets(t,e=!1){const[i,n]=this.options.sidenavRight?[\"right\",\"left\"]:[\"left\",\"right\"],o={property:this._getProperty(\"padding\",i),value:this.options.sidenavMode===\"over\"?0:this.width},r={property:this._getProperty(\"margin\",n),value:this.options.sidenavMode===\"push\"?-1*this.width:0};_.trigger(this._element,\"update.te.sidenav\",{margin:r,padding:o}),this._content&&(this._content.className=\"\",this._setContentOffsets(t,{padding:o,margin:r},e))}_updateTogglerAria(t){this.toggler.setAttribute(\"aria-expanded\",t)}static toggleSidenav(){return function(t){const e=m.closest(t.target,_c),i=g.getDataAttributes(e).target;m.find(i).forEach(n=>{(Ii.getInstance(n)||new Ii(n)).toggle()})}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,Vr);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Ii(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Vr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}ff({Animate:Gs,Alert:Ws,Button:ao,ChipsInput:gp,Chip:ki,Dropdown:Ft,Carousel:he,Collapse:ce,Offcanvas:ts,Modal:Ys,Popover:Eo,ScrollSpy:xo,Select:on,Tab:wo,Toast:Xs,Tooltip:is,Ripple:Ye,Datepicker:xl,Timepicker:Ll,Sidenav:Ii,Stepper:Wu,Input:Z,PerfectScrollbar:ms,Rating:Bp,Chart:yp,Datatable:pr,Popconfirm:_r,SmoothScroll:xr,Lightbox:ys,Validation:Tr,Touch:Er,LazyLoad:yn,Datetimepicker:Dr,Clipboard:Ar,InfiniteScroll:kr,LoadingManagement:Or,Autocomplete:Pr,Sticky:Lr,MultiRangeSlider:Hr});/*!\n * Chart.js v3.9.1\n * https://www.chartjs.org\n * (c) 2022 Chart.js Contributors\n * Released under the MIT License\n */function Oe(){}const EO=function(){let s=0;return function(){return s++}}();function H(s){return s===null||typeof s>\"u\"}function Q(s){if(Array.isArray&&Array.isArray(s))return!0;const t=Object.prototype.toString.call(s);return t.slice(0,7)===\"[object\"&&t.slice(-6)===\"Array]\"}function V(s){return s!==null&&Object.prototype.toString.call(s)===\"[object Object]\"}const rt=s=>(typeof s==\"number\"||s instanceof Number)&&isFinite(+s);function zt(s,t){return rt(s)?s:t}function B(s,t){return typeof s>\"u\"?t:s}const xO=(s,t)=>typeof s==\"string\"&&s.endsWith(\"%\")?parseFloat(s)/100:s/t,gf=(s,t)=>typeof s==\"string\"&&s.endsWith(\"%\")?parseFloat(s)/100*t:+s;function G(s,t,e){if(s&&typeof s.call==\"function\")return s.apply(e,t)}function U(s,t,e,i){let n,o,r;if(Q(s))if(o=s.length,i)for(n=o-1;n>=0;n--)t.call(e,s[n],n);else for(n=0;ns,x:s=>s.x,y:s=>s.y};function ti(s,t){return(bf[t]||(bf[t]=wO(t)))(s)}function wO(s){const t=kO(s);return e=>{for(const i of t){if(i===\"\")break;e=e&&e[i]}return e}}function kO(s){const t=s.split(\".\"),e=[];let i=\"\";for(const n of t)i+=n,i.endsWith(\"\\\\\")?i=i.slice(0,-1)+\".\":(e.push(i),i=\"\");return e}function gc(s){return s.charAt(0).toUpperCase()+s.slice(1)}const jt=s=>typeof s<\"u\",ei=s=>typeof s==\"function\",vf=(s,t)=>{if(s.size!==t.size)return!1;for(const e of s)if(!t.has(e))return!1;return!0};function SO(s){return s.type===\"mouseup\"||s.type===\"click\"||s.type===\"contextmenu\"}const it=Math.PI,q=2*it,OO=q+it,zr=Number.POSITIVE_INFINITY,IO=it/180,nt=it/2,On=it/4,yf=it*2/3,Yt=Math.log10,Ee=Math.sign;function Tf(s){const t=Math.round(s);s=In(s,t,s/1e3)?t:s;const e=Math.pow(10,Math.floor(Yt(s))),i=s/e;return(i<=1?1:i<=2?2:i<=5?5:10)*e}function DO(s){const t=[],e=Math.sqrt(s);let i;for(i=1;in-o).pop(),t}function As(s){return!isNaN(parseFloat(s))&&isFinite(s)}function In(s,t,e){return Math.abs(s-t)=s}function Ef(s,t,e){let i,n,o;for(i=0,n=s.length;il&&c=Math.min(t,e)-i&&s<=Math.max(t,e)+i}function vc(s,t,e){e=e||(r=>s[r]1;)o=n+i>>1,e(o)?n=o:i=o;return{lo:n,hi:i}}const De=(s,t,e,i)=>vc(s,e,i?n=>s[n][t]<=e:n=>s[n][t]vc(s,e,i=>s[i][t]>=e);function PO(s,t,e){let i=0,n=s.length;for(;ii&&s[n-1]>e;)n--;return i>0||n{const i=\"_onData\"+gc(e),n=s[e];Object.defineProperty(s,e,{configurable:!0,enumerable:!1,value(...o){const r=n.apply(this,o);return s._chartjs.listeners.forEach(a=>{typeof a[i]==\"function\"&&a[i](...o)}),r}})})}function wf(s,t){const e=s._chartjs;if(!e)return;const i=e.listeners,n=i.indexOf(t);n!==-1&&i.splice(n,1),!(i.length>0)&&(Af.forEach(o=>{delete s[o]}),delete s._chartjs)}function kf(s){const t=new Set;let e,i;for(e=0,i=s.length;e\"u\"?function(s){return s()}:window.requestAnimationFrame}();function Of(s,t,e){const i=e||(r=>Array.prototype.slice.call(r));let n=!1,o=[];return function(...r){o=i(r),n||(n=!0,Sf.call(window,()=>{n=!1,s.apply(t,o)}))}}function BO(s,t){let e;return function(...i){return t?(clearTimeout(e),e=setTimeout(s,t,i)):s.apply(this,i),t}}const yc=s=>s===\"start\"?\"left\":s===\"end\"?\"right\":\"center\",gt=(s,t,e)=>s===\"start\"?t:s===\"end\"?e:(t+e)/2,HO=(s,t,e,i)=>s===(i?\"left\":\"right\")?e:s===\"center\"?(t+e)/2:t;function If(s,t,e){const i=t.length;let n=0,o=i;if(s._sorted){const{iScale:r,_parsed:a}=s,l=r.axis,{min:c,max:h,minDefined:d,maxDefined:u}=r.getUserBounds();d&&(n=dt(Math.min(De(a,r.axis,c).lo,e?i:De(t,l,r.getPixelForValue(c)).lo),0,i-1)),u?o=dt(Math.max(De(a,r.axis,h,!0).hi+1,e?0:De(t,l,r.getPixelForValue(h),!0).hi+1),n,i)-n:o=i-n}return{start:n,count:o}}function Df(s){const{xScale:t,yScale:e,_scaleRanges:i}=s,n={xmin:t.min,xmax:t.max,ymin:e.min,ymax:e.max};if(!i)return s._scaleRanges=n,!0;const o=i.xmin!==t.min||i.xmax!==t.max||i.ymin!==e.min||i.ymax!==e.max;return Object.assign(i,n),o}const jr=s=>s===0||s===1,Mf=(s,t,e)=>-(Math.pow(2,10*(s-=1))*Math.sin((s-t)*q/e)),Lf=(s,t,e)=>Math.pow(2,-10*s)*Math.sin((s-t)*q/e)+1,Mn={linear:s=>s,easeInQuad:s=>s*s,easeOutQuad:s=>-s*(s-2),easeInOutQuad:s=>(s/=.5)<1?.5*s*s:-.5*(--s*(s-2)-1),easeInCubic:s=>s*s*s,easeOutCubic:s=>(s-=1)*s*s+1,easeInOutCubic:s=>(s/=.5)<1?.5*s*s*s:.5*((s-=2)*s*s+2),easeInQuart:s=>s*s*s*s,easeOutQuart:s=>-((s-=1)*s*s*s-1),easeInOutQuart:s=>(s/=.5)<1?.5*s*s*s*s:-.5*((s-=2)*s*s*s-2),easeInQuint:s=>s*s*s*s*s,easeOutQuint:s=>(s-=1)*s*s*s*s+1,easeInOutQuint:s=>(s/=.5)<1?.5*s*s*s*s*s:.5*((s-=2)*s*s*s*s+2),easeInSine:s=>-Math.cos(s*nt)+1,easeOutSine:s=>Math.sin(s*nt),easeInOutSine:s=>-.5*(Math.cos(it*s)-1),easeInExpo:s=>s===0?0:Math.pow(2,10*(s-1)),easeOutExpo:s=>s===1?1:-Math.pow(2,-10*s)+1,easeInOutExpo:s=>jr(s)?s:s<.5?.5*Math.pow(2,10*(s*2-1)):.5*(-Math.pow(2,-10*(s*2-1))+2),easeInCirc:s=>s>=1?s:-(Math.sqrt(1-s*s)-1),easeOutCirc:s=>Math.sqrt(1-(s-=1)*s),easeInOutCirc:s=>(s/=.5)<1?-.5*(Math.sqrt(1-s*s)-1):.5*(Math.sqrt(1-(s-=2)*s)+1),easeInElastic:s=>jr(s)?s:Mf(s,.075,.3),easeOutElastic:s=>jr(s)?s:Lf(s,.075,.3),easeInOutElastic(s){return jr(s)?s:s<.5?.5*Mf(s*2,.1125,.45):.5+.5*Lf(s*2-1,.1125,.45)},easeInBack(s){return s*s*((1.70158+1)*s-1.70158)},easeOutBack(s){return(s-=1)*s*((1.70158+1)*s+1.70158)+1},easeInOutBack(s){let t=1.70158;return(s/=.5)<1?.5*(s*s*(((t*=1.525)+1)*s-t)):.5*((s-=2)*s*(((t*=1.525)+1)*s+t)+2)},easeInBounce:s=>1-Mn.easeOutBounce(1-s),easeOutBounce(s){return s<1/2.75?7.5625*s*s:s<2/2.75?7.5625*(s-=1.5/2.75)*s+.75:s<2.5/2.75?7.5625*(s-=2.25/2.75)*s+.9375:7.5625*(s-=2.625/2.75)*s+.984375},easeInOutBounce:s=>s<.5?Mn.easeInBounce(s*2)*.5:Mn.easeOutBounce(s*2-1)*.5+.5};/*!\n * @kurkle/color v0.2.1\n * https://github.com/kurkle/color#readme\n * (c) 2022 Jukka Kurkela\n * Released under the MIT License\n */function Ln(s){return s+.5|0}const ii=(s,t,e)=>Math.max(Math.min(s,e),t);function $n(s){return ii(Ln(s*2.55),0,255)}function si(s){return ii(Ln(s*255),0,255)}function Me(s){return ii(Ln(s/2.55)/100,0,1)}function $f(s){return ii(Ln(s*100),0,100)}const Kt={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Tc=[...\"0123456789ABCDEF\"],VO=s=>Tc[s&15],FO=s=>Tc[(s&240)>>4]+Tc[s&15],Yr=s=>(s&240)>>4===(s&15),WO=s=>Yr(s.r)&&Yr(s.g)&&Yr(s.b)&&Yr(s.a);function zO(s){var t=s.length,e;return s[0]===\"#\"&&(t===4||t===5?e={r:255&Kt[s[1]]*17,g:255&Kt[s[2]]*17,b:255&Kt[s[3]]*17,a:t===5?Kt[s[4]]*17:255}:(t===7||t===9)&&(e={r:Kt[s[1]]<<4|Kt[s[2]],g:Kt[s[3]]<<4|Kt[s[4]],b:Kt[s[5]]<<4|Kt[s[6]],a:t===9?Kt[s[7]]<<4|Kt[s[8]]:255})),e}const jO=(s,t)=>s<255?t(s):\"\";function YO(s){var t=WO(s)?VO:FO;return s?\"#\"+t(s.r)+t(s.g)+t(s.b)+jO(s.a,t):void 0}const KO=/^(hsla?|hwb|hsv)\\(\\s*([-+.e\\d]+)(?:deg)?[\\s,]+([-+.e\\d]+)%[\\s,]+([-+.e\\d]+)%(?:[\\s,]+([-+.e\\d]+)(%)?)?\\s*\\)$/;function Rf(s,t,e){const i=t*Math.min(e,1-e),n=(o,r=(o+s/30)%12)=>e-i*Math.max(Math.min(r-3,9-r,1),-1);return[n(0),n(8),n(4)]}function UO(s,t,e){const i=(n,o=(n+s/60)%6)=>e-e*t*Math.max(Math.min(o,4-o,1),0);return[i(5),i(3),i(1)]}function XO(s,t,e){const i=Rf(s,1,.5);let n;for(t+e>1&&(n=1/(t+e),t*=n,e*=n),n=0;n<3;n++)i[n]*=1-t-e,i[n]+=t;return i}function GO(s,t,e,i,n){return s===n?(t-e)/i+(t.5?h/(2-o-r):h/(o+r),l=GO(e,i,n,h,o),l=l*60+.5),[l|0,c||0,a]}function xc(s,t,e,i){return(Array.isArray(t)?s(t[0],t[1],t[2]):s(t,e,i)).map(si)}function Cc(s,t,e){return xc(Rf,s,t,e)}function qO(s,t,e){return xc(XO,s,t,e)}function ZO(s,t,e){return xc(UO,s,t,e)}function Pf(s){return(s%360+360)%360}function QO(s){const t=KO.exec(s);let e=255,i;if(!t)return;t[5]!==i&&(e=t[6]?$n(+t[5]):si(+t[5]));const n=Pf(+t[2]),o=+t[3]/100,r=+t[4]/100;return t[1]===\"hwb\"?i=qO(n,o,r):t[1]===\"hsv\"?i=ZO(n,o,r):i=Cc(n,o,r),{r:i[0],g:i[1],b:i[2],a:e}}function JO(s,t){var e=Ec(s);e[0]=Pf(e[0]+t),e=Cc(e),s.r=e[0],s.g=e[1],s.b=e[2]}function tI(s){if(!s)return;const t=Ec(s),e=t[0],i=$f(t[1]),n=$f(t[2]);return s.a<255?`hsla(${e}, ${i}%, ${n}%, ${Me(s.a)})`:`hsl(${e}, ${i}%, ${n}%)`}const Nf={x:\"dark\",Z:\"light\",Y:\"re\",X:\"blu\",W:\"gr\",V:\"medium\",U:\"slate\",A:\"ee\",T:\"ol\",S:\"or\",B:\"ra\",C:\"lateg\",D:\"ights\",R:\"in\",Q:\"turquois\",E:\"hi\",P:\"ro\",O:\"al\",N:\"le\",M:\"de\",L:\"yello\",F:\"en\",K:\"ch\",G:\"arks\",H:\"ea\",I:\"ightg\",J:\"wh\"},Bf={OiceXe:\"f0f8ff\",antiquewEte:\"faebd7\",aqua:\"ffff\",aquamarRe:\"7fffd4\",azuY:\"f0ffff\",beige:\"f5f5dc\",bisque:\"ffe4c4\",black:\"0\",blanKedOmond:\"ffebcd\",Xe:\"ff\",XeviTet:\"8a2be2\",bPwn:\"a52a2a\",burlywood:\"deb887\",caMtXe:\"5f9ea0\",KartYuse:\"7fff00\",KocTate:\"d2691e\",cSO:\"ff7f50\",cSnflowerXe:\"6495ed\",cSnsilk:\"fff8dc\",crimson:\"dc143c\",cyan:\"ffff\",xXe:\"8b\",xcyan:\"8b8b\",xgTMnPd:\"b8860b\",xWay:\"a9a9a9\",xgYF:\"6400\",xgYy:\"a9a9a9\",xkhaki:\"bdb76b\",xmagFta:\"8b008b\",xTivegYF:\"556b2f\",xSange:\"ff8c00\",xScEd:\"9932cc\",xYd:\"8b0000\",xsOmon:\"e9967a\",xsHgYF:\"8fbc8f\",xUXe:\"483d8b\",xUWay:\"2f4f4f\",xUgYy:\"2f4f4f\",xQe:\"ced1\",xviTet:\"9400d3\",dAppRk:\"ff1493\",dApskyXe:\"bfff\",dimWay:\"696969\",dimgYy:\"696969\",dodgerXe:\"1e90ff\",fiYbrick:\"b22222\",flSOwEte:\"fffaf0\",foYstWAn:\"228b22\",fuKsia:\"ff00ff\",gaRsbSo:\"dcdcdc\",ghostwEte:\"f8f8ff\",gTd:\"ffd700\",gTMnPd:\"daa520\",Way:\"808080\",gYF:\"8000\",gYFLw:\"adff2f\",gYy:\"808080\",honeyMw:\"f0fff0\",hotpRk:\"ff69b4\",RdianYd:\"cd5c5c\",Rdigo:\"4b0082\",ivSy:\"fffff0\",khaki:\"f0e68c\",lavFMr:\"e6e6fa\",lavFMrXsh:\"fff0f5\",lawngYF:\"7cfc00\",NmoncEffon:\"fffacd\",ZXe:\"add8e6\",ZcSO:\"f08080\",Zcyan:\"e0ffff\",ZgTMnPdLw:\"fafad2\",ZWay:\"d3d3d3\",ZgYF:\"90ee90\",ZgYy:\"d3d3d3\",ZpRk:\"ffb6c1\",ZsOmon:\"ffa07a\",ZsHgYF:\"20b2aa\",ZskyXe:\"87cefa\",ZUWay:\"778899\",ZUgYy:\"778899\",ZstAlXe:\"b0c4de\",ZLw:\"ffffe0\",lime:\"ff00\",limegYF:\"32cd32\",lRF:\"faf0e6\",magFta:\"ff00ff\",maPon:\"800000\",VaquamarRe:\"66cdaa\",VXe:\"cd\",VScEd:\"ba55d3\",VpurpN:\"9370db\",VsHgYF:\"3cb371\",VUXe:\"7b68ee\",VsprRggYF:\"fa9a\",VQe:\"48d1cc\",VviTetYd:\"c71585\",midnightXe:\"191970\",mRtcYam:\"f5fffa\",mistyPse:\"ffe4e1\",moccasR:\"ffe4b5\",navajowEte:\"ffdead\",navy:\"80\",Tdlace:\"fdf5e6\",Tive:\"808000\",TivedBb:\"6b8e23\",Sange:\"ffa500\",SangeYd:\"ff4500\",ScEd:\"da70d6\",pOegTMnPd:\"eee8aa\",pOegYF:\"98fb98\",pOeQe:\"afeeee\",pOeviTetYd:\"db7093\",papayawEp:\"ffefd5\",pHKpuff:\"ffdab9\",peru:\"cd853f\",pRk:\"ffc0cb\",plum:\"dda0dd\",powMrXe:\"b0e0e6\",purpN:\"800080\",YbeccapurpN:\"663399\",Yd:\"ff0000\",Psybrown:\"bc8f8f\",PyOXe:\"4169e1\",saddNbPwn:\"8b4513\",sOmon:\"fa8072\",sandybPwn:\"f4a460\",sHgYF:\"2e8b57\",sHshell:\"fff5ee\",siFna:\"a0522d\",silver:\"c0c0c0\",skyXe:\"87ceeb\",UXe:\"6a5acd\",UWay:\"708090\",UgYy:\"708090\",snow:\"fffafa\",sprRggYF:\"ff7f\",stAlXe:\"4682b4\",tan:\"d2b48c\",teO:\"8080\",tEstN:\"d8bfd8\",tomato:\"ff6347\",Qe:\"40e0d0\",viTet:\"ee82ee\",JHt:\"f5deb3\",wEte:\"ffffff\",wEtesmoke:\"f5f5f5\",Lw:\"ffff00\",LwgYF:\"9acd32\"};function eI(){const s={},t=Object.keys(Bf),e=Object.keys(Nf);let i,n,o,r,a;for(i=0;i>16&255,o>>8&255,o&255]}return s}let Kr;function iI(s){Kr||(Kr=eI(),Kr.transparent=[0,0,0,0]);const t=Kr[s.toLowerCase()];return t&&{r:t[0],g:t[1],b:t[2],a:t.length===4?t[3]:255}}const sI=/^rgba?\\(\\s*([-+.\\d]+)(%)?[\\s,]+([-+.e\\d]+)(%)?[\\s,]+([-+.e\\d]+)(%)?(?:[\\s,/]+([-+.e\\d]+)(%)?)?\\s*\\)$/;function nI(s){const t=sI.exec(s);let e=255,i,n,o;if(t){if(t[7]!==i){const r=+t[7];e=t[8]?$n(r):ii(r*255,0,255)}return i=+t[1],n=+t[3],o=+t[5],i=255&(t[2]?$n(i):ii(i,0,255)),n=255&(t[4]?$n(n):ii(n,0,255)),o=255&(t[6]?$n(o):ii(o,0,255)),{r:i,g:n,b:o,a:e}}}function oI(s){return s&&(s.a<255?`rgba(${s.r}, ${s.g}, ${s.b}, ${Me(s.a)})`:`rgb(${s.r}, ${s.g}, ${s.b})`)}const Ac=s=>s<=.0031308?s*12.92:Math.pow(s,1/2.4)*1.055-.055,ws=s=>s<=.04045?s/12.92:Math.pow((s+.055)/1.055,2.4);function rI(s,t,e){const i=ws(Me(s.r)),n=ws(Me(s.g)),o=ws(Me(s.b));return{r:si(Ac(i+e*(ws(Me(t.r))-i))),g:si(Ac(n+e*(ws(Me(t.g))-n))),b:si(Ac(o+e*(ws(Me(t.b))-o))),a:s.a+e*(t.a-s.a)}}function Ur(s,t,e){if(s){let i=Ec(s);i[t]=Math.max(0,Math.min(i[t]+i[t]*e,t===0?360:1)),i=Cc(i),s.r=i[0],s.g=i[1],s.b=i[2]}}function Hf(s,t){return s&&Object.assign(t||{},s)}function Vf(s){var t={r:0,g:0,b:0,a:255};return Array.isArray(s)?s.length>=3&&(t={r:s[0],g:s[1],b:s[2],a:255},s.length>3&&(t.a=si(s[3]))):(t=Hf(s,{r:0,g:0,b:0,a:1}),t.a=si(t.a)),t}function aI(s){return s.charAt(0)===\"r\"?nI(s):QO(s)}class Xr{constructor(t){if(t instanceof Xr)return t;const e=typeof t;let i;e===\"object\"?i=Vf(t):e===\"string\"&&(i=zO(t)||iI(t)||aI(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=Hf(this._rgb);return t&&(t.a=Me(t.a)),t}set rgb(t){this._rgb=Vf(t)}rgbString(){return this._valid?oI(this._rgb):void 0}hexString(){return this._valid?YO(this._rgb):void 0}hslString(){return this._valid?tI(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,n=t.rgb;let o;const r=e===o?.5:e,a=2*r-1,l=i.a-n.a,c=((a*l===-1?a:(a+l)/(1+a*l))+1)/2;o=1-c,i.r=255&c*i.r+o*n.r+.5,i.g=255&c*i.g+o*n.g+.5,i.b=255&c*i.b+o*n.b+.5,i.a=r*i.a+(1-r)*n.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=rI(this._rgb,t._rgb,e)),this}clone(){return new Xr(this.rgb)}alpha(t){return this._rgb.a=si(t),this}clearer(t){const e=this._rgb;return e.a*=1-t,this}greyscale(){const t=this._rgb,e=Ln(t.r*.3+t.g*.59+t.b*.11);return t.r=t.g=t.b=e,this}opaquer(t){const e=this._rgb;return e.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Ur(this._rgb,2,t),this}darken(t){return Ur(this._rgb,2,-t),this}saturate(t){return Ur(this._rgb,1,t),this}desaturate(t){return Ur(this._rgb,1,-t),this}rotate(t){return JO(this._rgb,t),this}}function Ff(s){return new Xr(s)}function Wf(s){if(s&&typeof s==\"object\"){const t=s.toString();return t===\"[object CanvasPattern]\"||t===\"[object CanvasGradient]\"}return!1}function zf(s){return Wf(s)?s:Ff(s)}function wc(s){return Wf(s)?s:Ff(s).saturate(.5).darken(.1).hexString()}const Di=Object.create(null),kc=Object.create(null);function Rn(s,t){if(!t)return s;const e=t.split(\".\");for(let i=0,n=e.length;ie.chart.platform.getDevicePixelRatio(),this.elements={},this.events=[\"mousemove\",\"mouseout\",\"click\",\"touchstart\",\"touchmove\"],this.font={family:\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",size:12,style:\"normal\",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(e,i)=>wc(i.backgroundColor),this.hoverBorderColor=(e,i)=>wc(i.borderColor),this.hoverColor=(e,i)=>wc(i.color),this.indexAxis=\"x\",this.interaction={mode:\"nearest\",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t)}set(t,e){return Sc(this,t,e)}get(t){return Rn(this,t)}describe(t,e){return Sc(kc,t,e)}override(t,e){return Sc(Di,t,e)}route(t,e,i,n){const o=Rn(this,t),r=Rn(this,i),a=\"_\"+e;Object.defineProperties(o,{[a]:{value:o[e],writable:!0},[e]:{enumerable:!0,get(){const l=this[a],c=r[n];return V(l)?Object.assign({},c,l):B(l,c)},set(l){this[a]=l}}})}}var F=new lI({_scriptable:s=>!s.startsWith(\"on\"),_indexable:s=>s!==\"events\",hover:{_fallback:\"interaction\"},interaction:{_scriptable:!1,_indexable:!1}});function cI(s){return!s||H(s.size)||H(s.family)?null:(s.style?s.style+\" \":\"\")+(s.weight?s.weight+\" \":\"\")+s.size+\"px \"+s.family}function Gr(s,t,e,i,n){let o=t[n];return o||(o=t[n]=s.measureText(n).width,e.push(n)),o>i&&(i=o),i}function hI(s,t,e,i){i=i||{};let n=i.data=i.data||{},o=i.garbageCollect=i.garbageCollect||[];i.font!==t&&(n=i.data={},o=i.garbageCollect=[],i.font=t),s.save(),s.font=t;let r=0;const a=e.length;let l,c,h,d,u;for(l=0;le.length){for(l=0;l0&&s.stroke()}}function Pn(s,t,e){return e=e||.5,!t||s&&s.x>t.left-e&&s.xt.top-e&&s.y0&&o.strokeColor!==\"\";let l,c;for(s.save(),s.font=n.string,pI(s,o),l=0;l+s||0;function Ic(s,t){const e={},i=V(t),n=i?Object.keys(t):t,o=V(s)?i?r=>B(s[r],s[t[r]]):r=>s[r]:()=>s;for(const r of n)e[r]=bI(o(r));return e}function Kf(s){return Ic(s,{top:\"y\",right:\"x\",bottom:\"y\",left:\"x\"})}function $i(s){return Ic(s,[\"topLeft\",\"topRight\",\"bottomLeft\",\"bottomRight\"])}function pt(s){const t=Kf(s);return t.width=t.left+t.right,t.height=t.top+t.bottom,t}function lt(s,t){s=s||{},t=t||F.font;let e=B(s.size,t.size);typeof e==\"string\"&&(e=parseInt(e,10));let i=B(s.style,t.style);i&&!(\"\"+i).match(gI)&&(console.warn('Invalid font style specified: \"'+i+'\"'),i=\"\");const n={family:B(s.family,t.family),lineHeight:mI(B(s.lineHeight,t.lineHeight),e),size:e,style:i,weight:B(s.weight,t.weight),string:\"\"};return n.string=cI(n),n}function tt(s,t,e,i){let n=!0,o,r,a;for(o=0,r=s.length;oe&&a===0?0:a+l;return{min:r(i,-Math.abs(o)),max:r(n,o)}}function ni(s,t){return Object.assign(Object.create(s),t)}function Dc(s,t=[\"\"],e=s,i,n=()=>s[0]){jt(i)||(i=Zf(\"_fallback\",s));const o={[Symbol.toStringTag]:\"Object\",_cacheable:!0,_scopes:s,_rootScopes:e,_fallback:i,_getTarget:n,override:r=>Dc([r,...s],t,e,i)};return new Proxy(o,{deleteProperty(r,a){return delete r[a],delete r._keys,delete s[0][a],!0},get(r,a){return Xf(r,a,()=>kI(a,t,s,r))},getOwnPropertyDescriptor(r,a){return Reflect.getOwnPropertyDescriptor(r._scopes[0],a)},getPrototypeOf(){return Reflect.getPrototypeOf(s[0])},has(r,a){return Qf(r).includes(a)},ownKeys(r){return Qf(r)},set(r,a,l){const c=r._storage||(r._storage=n());return r[a]=c[a]=l,delete r._keys,!0}})}function ks(s,t,e,i){const n={_cacheable:!1,_proxy:s,_context:t,_subProxy:e,_stack:new Set,_descriptors:Uf(s,i),setContext:o=>ks(s,o,e,i),override:o=>ks(s.override(o),t,e,i)};return new Proxy(n,{deleteProperty(o,r){return delete o[r],delete s[r],!0},get(o,r,a){return Xf(o,r,()=>TI(o,r,a))},getOwnPropertyDescriptor(o,r){return o._descriptors.allKeys?Reflect.has(s,r)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(s,r)},getPrototypeOf(){return Reflect.getPrototypeOf(s)},has(o,r){return Reflect.has(s,r)},ownKeys(){return Reflect.ownKeys(s)},set(o,r,a){return s[r]=a,delete o[r],!0}})}function Uf(s,t={scriptable:!0,indexable:!0}){const{_scriptable:e=t.scriptable,_indexable:i=t.indexable,_allKeys:n=t.allKeys}=s;return{allKeys:n,scriptable:e,indexable:i,isScriptable:ei(e)?e:()=>e,isIndexable:ei(i)?i:()=>i}}const yI=(s,t)=>s?s+gc(t):t,Mc=(s,t)=>V(t)&&s!==\"adapters\"&&(Object.getPrototypeOf(t)===null||t.constructor===Object);function Xf(s,t,e){if(Object.prototype.hasOwnProperty.call(s,t))return s[t];const i=e();return s[t]=i,i}function TI(s,t,e){const{_proxy:i,_context:n,_subProxy:o,_descriptors:r}=s;let a=i[t];return ei(a)&&r.isScriptable(t)&&(a=EI(t,a,s,e)),Q(a)&&a.length&&(a=xI(t,a,s,r.isIndexable)),Mc(t,a)&&(a=ks(a,n,o&&o[t],r)),a}function EI(s,t,e,i){const{_proxy:n,_context:o,_subProxy:r,_stack:a}=e;if(a.has(s))throw new Error(\"Recursion detected: \"+Array.from(a).join(\"->\")+\"->\"+s);return a.add(s),t=t(o,r||i),a.delete(s),Mc(s,t)&&(t=Lc(n._scopes,n,s,t)),t}function xI(s,t,e,i){const{_proxy:n,_context:o,_subProxy:r,_descriptors:a}=e;if(jt(o.index)&&i(s))t=t[o.index%t.length];else if(V(t[0])){const l=t,c=n._scopes.filter(h=>h!==l);t=[];for(const h of l){const d=Lc(c,n,s,h);t.push(ks(d,o,r&&r[s],a))}}return t}function Gf(s,t,e){return ei(s)?s(t,e):s}const CI=(s,t)=>s===!0?t:typeof s==\"string\"?ti(t,s):void 0;function AI(s,t,e,i,n){for(const o of t){const r=CI(e,o);if(r){s.add(r);const a=Gf(r._fallback,e,n);if(jt(a)&&a!==e&&a!==i)return a}else if(r===!1&&jt(i)&&e!==i)return null}return!1}function Lc(s,t,e,i){const n=t._rootScopes,o=Gf(t._fallback,e,i),r=[...s,...n],a=new Set;a.add(i);let l=qf(a,r,e,o||e,i);return l===null||jt(o)&&o!==e&&(l=qf(a,r,o,l,i),l===null)?!1:Dc(Array.from(a),[\"\"],n,o,()=>wI(t,e,i))}function qf(s,t,e,i,n){for(;e;)e=AI(s,t,e,i,n);return e}function wI(s,t,e){const i=s._getTarget();t in i||(i[t]={});const n=i[t];return Q(n)&&V(e)?e:n}function kI(s,t,e,i){let n;for(const o of t)if(n=Zf(yI(o,s),e),jt(n))return Mc(s,n)?Lc(e,i,s,n):n}function Zf(s,t){for(const e of t){if(!e)continue;const i=e[s];if(jt(i))return i}}function Qf(s){let t=s._keys;return t||(t=s._keys=SI(s._scopes)),t}function SI(s){const t=new Set;for(const e of s)for(const i of Object.keys(e).filter(n=>!n.startsWith(\"_\")))t.add(i);return Array.from(t)}function Jf(s,t,e,i){const{iScale:n}=s,{key:o=\"r\"}=this._parsing,r=new Array(i);let a,l,c,h;for(a=0,l=i;ats===\"x\"?\"y\":\"x\";function II(s,t,e,i){const n=s.skip?t:s,o=t,r=e.skip?t:e,a=bc(o,n),l=bc(r,o);let c=a/(a+l),h=l/(a+l);c=isNaN(c)?0:c,h=isNaN(h)?0:h;const d=i*c,u=i*h;return{previous:{x:o.x-d*(r.x-n.x),y:o.y-d*(r.y-n.y)},next:{x:o.x+u*(r.x-n.x),y:o.y+u*(r.y-n.y)}}}function DI(s,t,e){const i=s.length;let n,o,r,a,l,c=Ss(s,0);for(let h=0;h!c.skip)),t.cubicInterpolationMode===\"monotone\")LI(s,n);else{let c=i?s[s.length-1]:s[0];for(o=0,r=s.length;owindow.getComputedStyle(s,null);function PI(s,t){return ta(s).getPropertyValue(t)}const NI=[\"top\",\"right\",\"bottom\",\"left\"];function Ri(s,t,e){const i={};e=e?\"-\"+e:\"\";for(let n=0;n<4;n++){const o=NI[n];i[o]=parseFloat(s[t+\"-\"+o+e])||0}return i.width=i.left+i.right,i.height=i.top+i.bottom,i}const BI=(s,t,e)=>(s>0||t>0)&&(!e||!e.shadowRoot);function HI(s,t){const e=s.touches,i=e&&e.length?e[0]:s,{offsetX:n,offsetY:o}=i;let r=!1,a,l;if(BI(n,o,s.target))a=n,l=o;else{const c=t.getBoundingClientRect();a=i.clientX-c.left,l=i.clientY-c.top,r=!0}return{x:a,y:l,box:r}}function Pi(s,t){if(\"native\"in s)return s;const{canvas:e,currentDevicePixelRatio:i}=t,n=ta(e),o=n.boxSizing===\"border-box\",r=Ri(n,\"padding\"),a=Ri(n,\"border\",\"width\"),{x:l,y:c,box:h}=HI(s,e),d=r.left+(h&&a.left),u=r.top+(h&&a.top);let{width:p,height:f}=t;return o&&(p-=r.width+a.width,f-=r.height+a.height),{x:Math.round((l-d)/p*e.width/i),y:Math.round((c-u)/f*e.height/i)}}function VI(s,t,e){let i,n;if(t===void 0||e===void 0){const o=$c(s);if(!o)t=s.clientWidth,e=s.clientHeight;else{const r=o.getBoundingClientRect(),a=ta(o),l=Ri(a,\"border\",\"width\"),c=Ri(a,\"padding\");t=r.width-c.width-l.width,e=r.height-c.height-l.height,i=Jr(a.maxWidth,o,\"clientWidth\"),n=Jr(a.maxHeight,o,\"clientHeight\")}}return{width:t,height:e,maxWidth:i||zr,maxHeight:n||zr}}const Rc=s=>Math.round(s*10)/10;function FI(s,t,e,i){const n=ta(s),o=Ri(n,\"margin\"),r=Jr(n.maxWidth,s,\"clientWidth\")||zr,a=Jr(n.maxHeight,s,\"clientHeight\")||zr,l=VI(s,t,e);let{width:c,height:h}=l;if(n.boxSizing===\"content-box\"){const d=Ri(n,\"border\",\"width\"),u=Ri(n,\"padding\");c-=u.width+d.width,h-=u.height+d.height}return c=Math.max(0,c-o.width),h=Math.max(0,i?Math.floor(c/i):h-o.height),c=Rc(Math.min(c,r,l.maxWidth)),h=Rc(Math.min(h,a,l.maxHeight)),c&&!h&&(h=Rc(c/2)),{width:c,height:h}}function i_(s,t,e){const i=t||1,n=Math.floor(s.height*i),o=Math.floor(s.width*i);s.height=n/i,s.width=o/i;const r=s.canvas;return r.style&&(e||!r.style.height&&!r.style.width)&&(r.style.height=`${s.height}px`,r.style.width=`${s.width}px`),s.currentDevicePixelRatio!==i||r.height!==n||r.width!==o?(s.currentDevicePixelRatio=i,r.height=n,r.width=o,s.ctx.setTransform(i,0,0,i,0,0),!0):!1}const WI=function(){let s=!1;try{const t={get passive(){return s=!0,!1}};window.addEventListener(\"test\",null,t),window.removeEventListener(\"test\",null,t)}catch{}return s}();function s_(s,t){const e=PI(s,t),i=e&&e.match(/^(\\d+)(\\.\\d+)?px$/);return i?+i[1]:void 0}function Ni(s,t,e,i){return{x:s.x+e*(t.x-s.x),y:s.y+e*(t.y-s.y)}}function zI(s,t,e,i){return{x:s.x+e*(t.x-s.x),y:i===\"middle\"?e<.5?s.y:t.y:i===\"after\"?e<1?s.y:t.y:e>0?t.y:s.y}}function jI(s,t,e,i){const n={x:s.cp2x,y:s.cp2y},o={x:t.cp1x,y:t.cp1y},r=Ni(s,n,e),a=Ni(n,o,e),l=Ni(o,t,e),c=Ni(r,a,e),h=Ni(a,l,e);return Ni(c,h,e)}const n_=new Map;function YI(s,t){t=t||{};const e=s+JSON.stringify(t);let i=n_.get(e);return i||(i=new Intl.NumberFormat(s,t),n_.set(e,i)),i}function Bn(s,t,e){return YI(t,e).format(s)}const KI=function(s,t){return{x(e){return s+s+t-e},setWidth(e){t=e},textAlign(e){return e===\"center\"?e:e===\"right\"?\"left\":\"right\"},xPlus(e,i){return e-i},leftForLtr(e,i){return e-i}}},UI=function(){return{x(s){return s},setWidth(s){},textAlign(s){return s},xPlus(s,t){return s+t},leftForLtr(s,t){return s}}};function Os(s,t,e){return s?KI(t,e):UI()}function o_(s,t){let e,i;(t===\"ltr\"||t===\"rtl\")&&(e=s.canvas.style,i=[e.getPropertyValue(\"direction\"),e.getPropertyPriority(\"direction\")],e.setProperty(\"direction\",t,\"important\"),s.prevTextDirection=i)}function r_(s,t){t!==void 0&&(delete s.prevTextDirection,s.canvas.style.setProperty(\"direction\",t[0],t[1]))}function a_(s){return s===\"angle\"?{between:Dn,compare:LO,normalize:Vt}:{between:Ie,compare:(t,e)=>t-e,normalize:t=>t}}function l_({start:s,end:t,count:e,loop:i,style:n}){return{start:s%e,end:t%e,loop:i&&(t-s+1)%e===0,style:n}}function XI(s,t,e){const{property:i,start:n,end:o}=e,{between:r,normalize:a}=a_(i),l=t.length;let{start:c,end:h,loop:d}=s,u,p;if(d){for(c+=l,h+=l,u=0,p=l;ul(n,x,y)&&a(n,x)!==0,C=()=>a(o,y)===0||l(o,x,y),A=()=>b||E(),w=()=>!b||C();for(let S=h,k=h;S<=d;++S)T=t[S%r],!T.skip&&(y=c(T[i]),y!==x&&(b=l(y,n,o),v===null&&A()&&(v=a(y,n)===0?S:k),v!==null&&w()&&(f.push(l_({start:v,end:S,loop:u,count:r,style:p})),v=null),k=S,x=y));return v!==null&&f.push(l_({start:v,end:d,loop:u,count:r,style:p})),f}function h_(s,t){const e=[],i=s.segments;for(let n=0;nn&&s[o%t].skip;)o--;return o%=t,{start:n,end:o}}function qI(s,t,e,i){const n=s.length,o=[];let r=t,a=s[t],l;for(l=t+1;l<=e;++l){const c=s[l%n];c.skip||c.stop?a.skip||(i=!1,o.push({start:t%n,end:(l-1)%n,loop:i}),t=r=c.stop?l:null):(r=l,a.skip&&(t=l)),a=c}return r!==null&&o.push({start:t%n,end:r%n,loop:i}),o}function ZI(s,t){const e=s.points,i=s.options.spanGaps,n=e.length;if(!n)return[];const o=!!s._loop,{start:r,end:a}=GI(e,n,o,i);if(i===!0)return d_(s,[{start:r,end:a,loop:o}],e,t);const l=aa({chart:t,initial:e.initial,numSteps:r,currentStep:Math.min(i-e.start,r)}))}_refresh(){this._request||(this._running=!0,this._request=Sf.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(t=Date.now()){let e=0;this._charts.forEach((i,n)=>{if(!i.running||!i.items.length)return;const o=i.items;let r=o.length-1,a=!1,l;for(;r>=0;--r)l=o[r],l._active?(l._total>i.duration&&(i.duration=l._total),l.tick(t),a=!0):(o[r]=o[o.length-1],o.pop());a&&(n.draw(),this._notify(n,i,t,\"progress\")),o.length||(i.running=!1,this._notify(n,i,t,\"complete\"),i.initial=!1),e+=o.length}),this._lastDate=t,e===0&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){!e||!e.length||this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce((i,n)=>Math.max(i,n._duration),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!(!e||!e.running||!e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let n=i.length-1;for(;n>=0;--n)i[n].cancel();e.items=[],this._notify(t,e,Date.now(),\"complete\")}remove(t){return this._charts.delete(t)}}var xe=new tD;const p_=\"transparent\",eD={boolean(s,t,e){return e>.5?t:s},color(s,t,e){const i=zf(s||p_),n=i.valid&&zf(t||p_);return n&&n.valid?n.mix(i,e).hexString():t},number(s,t,e){return s+(t-s)*e}};class f_{constructor(t,e,i,n){const o=e[i];n=tt([t.to,n,o,t.from]);const r=tt([t.from,o,n]);this._active=!0,this._fn=t.fn||eD[t.type||typeof r],this._easing=Mn[t.easing]||Mn.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=r,this._to=n,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const n=this._target[this._prop],o=i-this._start,r=this._duration-o;this._start=i,this._duration=Math.floor(Math.max(r,t.duration)),this._total+=o,this._loop=!!t.loop,this._to=tt([t.to,e,n,t.from]),this._from=tt([t.from,n,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,n=this._prop,o=this._from,r=this._loop,a=this._to;let l;if(this._active=o!==a&&(r||e1?2-l:l,l=this._easing(Math.min(1,Math.max(0,l))),this._target[n]=this._fn(o,a,l)}wait(){const t=this._promises||(this._promises=[]);return new Promise((e,i)=>{t.push({res:e,rej:i})})}_notify(t){const e=t?\"res\":\"rej\",i=this._promises||[];for(let n=0;ns!==\"onProgress\"&&s!==\"onComplete\"&&s!==\"fn\"}),F.set(\"animations\",{colors:{type:\"color\",properties:sD},numbers:{type:\"number\",properties:iD}}),F.describe(\"animations\",{_fallback:\"animation\"}),F.set(\"transitions\",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:\"transparent\"},visible:{type:\"boolean\",duration:0}}},hide:{animations:{colors:{to:\"transparent\"},visible:{type:\"boolean\",easing:\"linear\",fn:s=>s|0}}}});class Pc{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!V(t))return;const e=this._properties;Object.getOwnPropertyNames(t).forEach(i=>{const n=t[i];if(!V(n))return;const o={};for(const r of nD)o[r]=n[r];(Q(n.properties)&&n.properties||[i]).forEach(r=>{(r===i||!e.has(r))&&e.set(r,o)})})}_animateOptions(t,e){const i=e.options,n=rD(t,i);if(!n)return[];const o=this._createAnimations(n,i);return i.$shared&&oD(t.options.$animations,i).then(()=>{t.options=i},()=>{}),o}_createAnimations(t,e){const i=this._properties,n=[],o=t.$animations||(t.$animations={}),r=Object.keys(e),a=Date.now();let l;for(l=r.length-1;l>=0;--l){const c=r[l];if(c.charAt(0)===\"$\")continue;if(c===\"options\"){n.push(...this._animateOptions(t,e));continue}const h=e[c];let d=o[c];const u=i.get(c);if(d)if(u&&d.active()){d.update(u,h,a);continue}else d.cancel();if(!u||!u.duration){t[c]=h;continue}o[c]=d=new f_(u,t,c,h),n.push(d)}return n}update(t,e){if(this._properties.size===0){Object.assign(t,e);return}const i=this._createAnimations(t,e);if(i.length)return xe.add(this._chart,i),!0}}function oD(s,t){const e=[],i=Object.keys(t);for(let n=0;n0||!e&&o<0)return n.index}return null}function y_(s,t){const{chart:e,_cachedMeta:i}=s,n=e._stacks||(e._stacks={}),{iScale:o,vScale:r,index:a}=i,l=o.axis,c=r.axis,h=hD(o,r,i),d=t.length;let u;for(let p=0;pe[i].axis===t).shift()}function pD(s,t){return ni(s,{active:!1,dataset:void 0,datasetIndex:t,index:t,mode:\"default\",type:\"dataset\"})}function fD(s,t,e){return ni(s,{active:!1,dataIndex:t,parsed:void 0,raw:void 0,element:e,index:t,mode:\"default\",type:\"data\"})}function Hn(s,t){const e=s.controller.index,i=s.vScale&&s.vScale.axis;if(i){t=t||s._parsed;for(const n of t){const o=n._stacks;if(!o||o[i]===void 0||o[i][e]===void 0)return;delete o[i][e]}}}const Bc=s=>s===\"reset\"||s===\"none\",T_=(s,t)=>t?s:Object.assign({},s),_D=(s,t,e)=>s&&!t.hidden&&t._stacked&&{keys:g_(e,!0),values:null};class Ut{constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=b_(t.vScale,t),this.addElements()}updateIndex(t){this.index!==t&&Hn(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),n=(d,u,p,f)=>d===\"x\"?u:d===\"r\"?f:p,o=e.xAxisID=B(i.xAxisID,Nc(t,\"x\")),r=e.yAxisID=B(i.yAxisID,Nc(t,\"y\")),a=e.rAxisID=B(i.rAxisID,Nc(t,\"r\")),l=e.indexAxis,c=e.iAxisID=n(l,o,r,a),h=e.vAxisID=n(l,r,o,a);e.xScale=this.getScaleForId(o),e.yScale=this.getScaleForId(r),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(c),e.vScale=this.getScaleForId(h)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update(\"reset\")}_destroy(){const t=this._cachedMeta;this._data&&wf(this._data,this),t._stacked&&Hn(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(V(e))this._data=cD(e);else if(i!==e){if(i){wf(i,this);const n=this._cachedMeta;Hn(n),n._parsed=[]}e&&Object.isExtensible(e)&&NO(e,this),this._syncList=[],this._data=e}}addElements(){const t=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(t.dataset=new this.datasetElementType)}buildOrUpdateElements(t){const e=this._cachedMeta,i=this.getDataset();let n=!1;this._dataCheck();const o=e._stacked;e._stacked=b_(e.vScale,e),e.stack!==i.stack&&(n=!0,Hn(e),e.stack=i.stack),this._resyncElements(t),(n||o!==e._stacked)&&y_(this,e._parsed)}configure(){const t=this.chart.config,e=t.datasetScopeKeys(this._type),i=t.getOptionScopes(this.getDataset(),e,!0);this.options=t.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(t,e){const{_cachedMeta:i,_data:n}=this,{iScale:o,_stacked:r}=i,a=o.axis;let l=t===0&&e===n.length?!0:i._sorted,c=t>0&&i._parsed[t-1],h,d,u;if(this._parsing===!1)i._parsed=n,i._sorted=!0,u=n;else{Q(n[t])?u=this.parseArrayData(i,n,t,e):V(n[t])?u=this.parseObjectData(i,n,t,e):u=this.parsePrimitiveData(i,n,t,e);const p=()=>d[a]===null||c&&d[a]b||d=0;--u)if(!f()){this.updateRangeFromParsed(c,t,p,l);break}}return c}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let n,o,r;for(n=0,o=e.length;n=0&&tthis.getContext(i,n),b=c.resolveNamedOptions(u,p,f,d);return b.$shared&&(b.$shared=l,o[r]=Object.freeze(T_(b,l))),b}_resolveAnimations(t,e,i){const n=this.chart,o=this._cachedDataOpts,r=`animation-${e}`,a=o[r];if(a)return a;let l;if(n.options.animation!==!1){const h=this.chart.config,d=h.datasetAnimationScopeKeys(this._type,e),u=h.getOptionScopes(this.getDataset(),d);l=h.createResolver(u,this.getContext(t,i,e))}const c=new Pc(n,l&&l.animations);return l&&l._cacheable&&(o[r]=Object.freeze(c)),c}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bc(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),n=this._sharedOptions,o=this.getSharedOptions(i),r=this.includeOptions(e,o)||o!==n;return this.updateSharedOptions(o,e,i),{sharedOptions:o,includeOptions:r}}updateElement(t,e,i,n){Bc(n)?Object.assign(t,i):this._resolveAnimations(e,n).update(t,i)}updateSharedOptions(t,e,i){t&&!Bc(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,n){t.active=n;const o=this.getStyle(e,n);this._resolveAnimations(e,i,n).update(t,{options:!n&&this.getSharedOptions(o)||o})}removeHoverStyle(t,e,i){this._setStyle(t,i,\"active\",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,\"active\",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,\"active\",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,\"active\",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[a,l,c]of this._syncList)this[a](l,c);this._syncList=[];const n=i.length,o=e.length,r=Math.min(o,n);r&&this.parse(0,r),o>n?this._insertElements(n,o-n,t):o{for(c.length+=e,a=c.length-1;a>=r;a--)c[a]=c[a-e]};for(l(o),a=t;an-o))}return s._cache.$bar}function mD(s){const t=s.iScale,e=gD(t,s.type);let i=t._length,n,o,r,a;const l=()=>{r===32767||r===-32768||(jt(a)&&(i=Math.min(i,Math.abs(r-a)||i)),a=r)};for(n=0,o=e.length;n0?n[s-1]:null,a=sMath.abs(a)&&(l=a,c=r),t[e.axis]=c,t._custom={barStart:l,barEnd:c,start:n,end:o,min:r,max:a}}function E_(s,t,e,i){return Q(s)?yD(s,t,e,i):t[e.axis]=e.parse(s,i),t}function x_(s,t,e,i){const n=s.iScale,o=s.vScale,r=n.getLabels(),a=n===o,l=[];let c,h,d,u;for(c=e,h=e+i;c=e?1:-1)}function ED(s){let t,e,i,n,o;return s.horizontal?(t=s.base>s.x,e=\"left\",i=\"right\"):(t=s.basel.controller.options.grouped),o=i.options.stacked,r=[],a=l=>{const c=l.controller.getParsed(e),h=c&&c[l.vScale.axis];if(H(h)||isNaN(h))return!0};for(const l of n)if(!(e!==void 0&&a(l))&&((o===!1||r.indexOf(l.stack)===-1||o===void 0&&l.stack===void 0)&&r.push(l.stack),l.index===t))break;return r.length||r.push(void 0),r}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const n=this._getStacks(t,i),o=e!==void 0?n.indexOf(e):-1;return o===-1?n.length-1:o}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,n=[];let o,r;for(o=0,r=e.data.length;o=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,{xScale:i,yScale:n}=e,o=this.getParsed(t),r=i.getLabelForValue(o.x),a=n.getLabelForValue(o.y),l=o._custom;return{label:e.label,value:\"(\"+r+\", \"+a+(l?\", \"+l:\"\")+\")\"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,n){const o=n===\"reset\",{iScale:r,vScale:a}=this._cachedMeta,{sharedOptions:l,includeOptions:c}=this._getSharedOptions(e,n),h=r.axis,d=a.axis;for(let u=e;uDn(x,a,l,!0)?1:Math.max(E,E*e,C,C*e),f=(x,E,C)=>Dn(x,a,l,!0)?-1:Math.min(E,E*e,C,C*e),b=p(0,c,d),v=p(nt,h,u),y=f(it,c,d),T=f(it+nt,h,u);i=(b-y)/2,n=(v-T)/2,o=-(b+y)/2,r=-(v+T)/2}return{ratioX:i,ratioY:n,offsetX:o,offsetY:r}}class Bi extends Ut{constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,n=this._cachedMeta;if(this._parsing===!1)n._parsed=i;else{let o=l=>+i[l];if(V(i[t])){const{key:l=\"value\"}=this._parsing;o=c=>+ti(i[c],l)}let r,a;for(r=t,a=t+e;r0&&!isNaN(t)?q*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,n=i.data.labels||[],o=Bn(e._parsed[t],i.options.locale);return{label:n[t]||\"\",value:o}}getMaxBorderWidth(t){let e=0;const i=this.chart;let n,o,r,a,l;if(!t){for(n=0,o=i.data.datasets.length;ns!==\"spacing\",_indexable:s=>s!==\"spacing\"},Bi.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:e}}=s.legend.options;return t.labels.map((i,n)=>{const r=s.getDatasetMeta(0).controller.getStyle(n);return{text:i,fillStyle:r.backgroundColor,strokeStyle:r.borderColor,lineWidth:r.borderWidth,pointStyle:e,hidden:!s.getDataVisibility(n),index:n}})}return[]}},onClick(s,t,e){e.chart.toggleDataVisibility(t.index),e.chart.update()}},tooltip:{callbacks:{title(){return\"\"},label(s){let t=s.label;const e=\": \"+s.formattedValue;return Q(t)?(t=t.slice(),t[0]+=e):t+=e,t}}}}};class Wn extends Ut{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:i,data:n=[],_dataset:o}=e,r=this.chart._animationsDisabled;let{start:a,count:l}=If(e,n,r);this._drawStart=a,this._drawCount=l,Df(e)&&(a=0,l=n.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!o._decimated,i.points=n;const c=this.resolveDatasetElementOptions(t);this.options.showLine||(c.borderWidth=0),c.segment=this.options.segment,this.updateElement(i,void 0,{animated:!r,options:c},t),this.updateElements(n,a,l,t)}updateElements(t,e,i,n){const o=n===\"reset\",{iScale:r,vScale:a,_stacked:l,_dataset:c}=this._cachedMeta,{sharedOptions:h,includeOptions:d}=this._getSharedOptions(e,n),u=r.axis,p=a.axis,{spanGaps:f,segment:b}=this.options,v=As(f)?f:Number.POSITIVE_INFINITY,y=this.chart._animationsDisabled||o||n===\"none\";let T=e>0&&this.getParsed(e-1);for(let x=e;x0&&Math.abs(C[u]-T[u])>v,b&&(A.parsed=C,A.raw=c.data[x]),d&&(A.options=h||this.resolveDataElementOptions(x,E.active?\"active\":n)),y||this.updateElement(E,x,A,n),T=C}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,n=t.data||[];if(!n.length)return i;const o=n[0].size(this.resolveDataElementOptions(0)),r=n[n.length-1].size(this.resolveDataElementOptions(n.length-1));return Math.max(i,o,r)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}}Wn.id=\"line\",Wn.defaults={datasetElementType:\"line\",dataElementType:\"point\",showLine:!0,spanGaps:!1},Wn.overrides={scales:{_index_:{type:\"category\"},_value_:{type:\"linear\"}}};class zn extends Ut{constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,n=i.data.labels||[],o=Bn(e._parsed[t].r,i.options.locale);return{label:n[t]||\"\",value:o}}parseObjectData(t,e,i,n){return Jf.bind(this)(t,e,i,n)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach((i,n)=>{const o=this.getParsed(n).r;!isNaN(o)&&this.chart.getDataVisibility(n)&&(oe.max&&(e.max=o))}),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,n=Math.min(e.right-e.left,e.bottom-e.top),o=Math.max(n/2,0),r=Math.max(i.cutoutPercentage?o/100*i.cutoutPercentage:1,0),a=(o-r)/t.getVisibleDatasetCount();this.outerRadius=o-a*this.index,this.innerRadius=this.outerRadius-a}updateElements(t,e,i,n){const o=n===\"reset\",r=this.chart,l=r.options.animation,c=this._cachedMeta.rScale,h=c.xCenter,d=c.yCenter,u=c.getIndexAngle(0)-.5*it;let p=u,f;const b=360/this.countVisibleElements();for(f=0;f{!isNaN(this.getParsed(n).r)&&this.chart.getDataVisibility(n)&&e++}),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?se(this.resolveDataElementOptions(t,e).angle||i):0}}zn.id=\"polarArea\",zn.defaults={dataElementType:\"arc\",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:\"number\",properties:[\"x\",\"y\",\"startAngle\",\"endAngle\",\"innerRadius\",\"outerRadius\"]}},indexAxis:\"r\",startAngle:0},zn.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:e}}=s.legend.options;return t.labels.map((i,n)=>{const r=s.getDatasetMeta(0).controller.getStyle(n);return{text:i,fillStyle:r.backgroundColor,strokeStyle:r.borderColor,lineWidth:r.borderWidth,pointStyle:e,hidden:!s.getDataVisibility(n),index:n}})}return[]}},onClick(s,t,e){e.chart.toggleDataVisibility(t.index),e.chart.update()}},tooltip:{callbacks:{title(){return\"\"},label(s){return s.chart.data.labels[s.dataIndex]+\": \"+s.formattedValue}}}},scales:{r:{type:\"radialLinear\",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};class ea extends Bi{}ea.id=\"pie\",ea.defaults={cutout:0,rotation:0,circumference:360,radius:\"100%\"};class jn extends Ut{getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:\"\"+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,n){return Jf.bind(this)(t,e,i,n)}update(t){const e=this._cachedMeta,i=e.dataset,n=e.data||[],o=e.iScale.getLabels();if(i.points=n,t!==\"resize\"){const r=this.resolveDatasetElementOptions(t);this.options.showLine||(r.borderWidth=0);const a={_loop:!0,_fullLoop:o.length===n.length,options:r};this.updateElement(i,void 0,a,t)}this.updateElements(n,0,n.length,t)}updateElements(t,e,i,n){const o=this._cachedMeta.rScale,r=n===\"reset\";for(let a=e;a{n[o]=i[o]&&i[o].active()?i[o]._to:this[o]}),n}};Xt.defaults={},Xt.defaultRoutes=void 0;const w_={values(s){return Q(s)?s:\"\"+s},numeric(s,t,e){if(s===0)return\"0\";const i=this.chart.options.locale;let n,o=s;if(e.length>1){const c=Math.max(Math.abs(e[0].value),Math.abs(e[e.length-1].value));(c<1e-4||c>1e15)&&(n=\"scientific\"),o=kD(s,e)}const r=Yt(Math.abs(o)),a=Math.max(Math.min(-1*Math.floor(r),20),0),l={notation:n,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),Bn(s,i,l)},logarithmic(s,t,e){if(s===0)return\"0\";const i=s/Math.pow(10,Math.floor(Yt(s)));return i===1||i===2||i===5?w_.numeric.call(this,s,t,e):\"\"}};function kD(s,t){let e=t.length>3?t[2].value-t[1].value:t[1].value-t[0].value;return Math.abs(e)>=1&&s!==Math.floor(s)&&(e=s-Math.floor(s)),e}var Yn={formatters:w_};F.set(\"scale\",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:\"ticks\",grace:0,grid:{display:!0,lineWidth:1,drawBorder:!0,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(s,t)=>t.lineWidth,tickColor:(s,t)=>t.color,offset:!1,borderDash:[],borderDashOffset:0,borderWidth:1},title:{display:!1,text:\"\",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:\"\",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Yn.formatters.values,minor:{},major:{},align:\"center\",crossAlign:\"near\",showLabelBackdrop:!1,backdropColor:\"rgba(255, 255, 255, 0.75)\",backdropPadding:2}}),F.route(\"scale.ticks\",\"color\",\"\",\"color\"),F.route(\"scale.grid\",\"color\",\"\",\"borderColor\"),F.route(\"scale.grid\",\"borderColor\",\"\",\"borderColor\"),F.route(\"scale.title\",\"color\",\"\",\"color\"),F.describe(\"scale\",{_fallback:!1,_scriptable:s=>!s.startsWith(\"before\")&&!s.startsWith(\"after\")&&s!==\"callback\"&&s!==\"parser\",_indexable:s=>s!==\"borderDash\"&&s!==\"tickBorderDash\"}),F.describe(\"scales\",{_fallback:\"scale\"}),F.describe(\"scale.ticks\",{_scriptable:s=>s!==\"backdropPadding\"&&s!==\"callback\",_indexable:s=>s!==\"backdropPadding\"});function SD(s,t){const e=s.options.ticks,i=e.maxTicksLimit||OD(s),n=e.major.enabled?DD(t):[],o=n.length,r=n[0],a=n[o-1],l=[];if(o>i)return MD(t,l,n,o/i),l;const c=ID(n,t,i);if(o>0){let h,d;const u=o>1?Math.round((a-r)/(o-1)):null;for(ia(t,l,c,H(u)?0:r-u,r),h=0,d=o-1;hn)return l}return Math.max(n,1)}function DD(s){const t=[];let e,i;for(e=0,i=s.length;es===\"left\"?\"right\":s===\"right\"?\"left\":s,k_=(s,t,e)=>t===\"top\"||t===\"left\"?s[t]+e:s[t]-e;function S_(s,t){const e=[],i=s.length/t,n=s.length;let o=0;for(;or+a)))return l}function PD(s,t){U(s,e=>{const i=e.gc,n=i.length/2;let o;if(n>t){for(o=0;oi?i:e,i=n&&e>i?e:i,{min:zt(e,zt(i,e)),max:zt(i,zt(e,i))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){G(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:n,grace:o,ticks:r}=this.options,a=r.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=vI(this,o,n),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const l=a=o||i<=1||!this.isHorizontal()){this.labelRotation=n;return}const h=this._getLabelSizes(),d=h.widest.width,u=h.highest.height,p=dt(this.chart.width-d,0,this.maxWidth);a=t.offset?this.maxWidth/i:p/(i-1),d+6>a&&(a=p/(i-(t.offset?.5:1)),l=this.maxHeight-Kn(t.grid)-e.padding-O_(t.title,this.chart.options.font),c=Math.sqrt(d*d+u*u),r=mc(Math.min(Math.asin(dt((h.highest.height+6)/a,-1,1)),Math.asin(dt(l/c,-1,1))-Math.asin(dt(u/c,-1,1)))),r=Math.max(n,Math.min(o,r))),this.labelRotation=r}afterCalculateLabelRotation(){G(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){G(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:n,grid:o}}=this,r=this._isVisible(),a=this.isHorizontal();if(r){const l=O_(n,e.options.font);if(a?(t.width=this.maxWidth,t.height=Kn(o)+l):(t.height=this.maxHeight,t.width=Kn(o)+l),i.display&&this.ticks.length){const{first:c,last:h,widest:d,highest:u}=this._getLabelSizes(),p=i.padding*2,f=se(this.labelRotation),b=Math.cos(f),v=Math.sin(f);if(a){const y=i.mirror?0:v*d.width+b*u.height;t.height=Math.min(this.maxHeight,t.height+y+p)}else{const y=i.mirror?0:b*d.width+v*u.height;t.width=Math.min(this.maxWidth,t.width+y+p)}this._calculatePadding(c,h,v,b)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,n){const{ticks:{align:o,padding:r},position:a}=this.options,l=this.labelRotation!==0,c=a!==\"top\"&&this.axis===\"x\";if(this.isHorizontal()){const h=this.getPixelForTick(0)-this.left,d=this.right-this.getPixelForTick(this.ticks.length-1);let u=0,p=0;l?c?(u=n*t.width,p=i*e.height):(u=i*t.height,p=n*e.width):o===\"start\"?p=e.width:o===\"end\"?u=t.width:o!==\"inner\"&&(u=t.width/2,p=e.width/2),this.paddingLeft=Math.max((u-h+r)*this.width/(this.width-h),0),this.paddingRight=Math.max((p-d+r)*this.width/(this.width-d),0)}else{let h=e.height/2,d=t.height/2;o===\"start\"?(h=0,d=t.height):o===\"end\"&&(h=e.height,d=0),this.paddingTop=h+r,this.paddingBottom=d+r}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){G(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return e===\"top\"||e===\"bottom\"||t===\"x\"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){this.beforeTickToLabelConversion(),this.generateTickLabels(t);let e,i;for(e=0,i=t.length;e({width:o[w]||0,height:r[w]||0});return{first:A(0),last:A(e-1),widest:A(E),highest:A(C),widths:o,heights:r}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return $O(this._alignToPixels?Mi(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*n?a/i:l/n:l*n0}_computeGridLineItems(t){const e=this.axis,i=this.chart,n=this.options,{grid:o,position:r}=n,a=o.offset,l=this.isHorizontal(),h=this.ticks.length+(a?1:0),d=Kn(o),u=[],p=o.setContext(this.getContext()),f=p.drawBorder?p.borderWidth:0,b=f/2,v=function(R){return Mi(i,R,f)};let y,T,x,E,C,A,w,S,k,D,I,M;if(r===\"top\")y=v(this.bottom),A=this.bottom-d,S=y-b,D=v(t.top)+b,M=t.bottom;else if(r===\"bottom\")y=v(this.top),D=t.top,M=v(t.bottom)-b,A=y+b,S=this.top+d;else if(r===\"left\")y=v(this.right),C=this.right-d,w=y-b,k=v(t.left)+b,I=t.right;else if(r===\"right\")y=v(this.left),k=t.left,I=v(t.right)-b,C=y+b,w=this.left+d;else if(e===\"x\"){if(r===\"center\")y=v((t.top+t.bottom)/2+.5);else if(V(r)){const R=Object.keys(r)[0],z=r[R];y=v(this.chart.scales[R].getPixelForValue(z))}D=t.top,M=t.bottom,A=y+b,S=A+d}else if(e===\"y\"){if(r===\"center\")y=v((t.left+t.right)/2);else if(V(r)){const R=Object.keys(r)[0],z=r[R];y=v(this.chart.scales[R].getPixelForValue(z))}C=y-b,w=C-d,k=t.left,I=t.right}const P=B(n.ticks.maxTicksLimit,h),X=Math.max(1,Math.ceil(h/P));for(T=0;To.value===t);return n>=0?e.setContext(this.getContext(n)).lineWidth:0}drawGrid(t){const e=this.options.grid,i=this.ctx,n=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let o,r;const a=(l,c,h)=>{!h.width||!h.color||(i.save(),i.lineWidth=h.width,i.strokeStyle=h.color,i.setLineDash(h.borderDash||[]),i.lineDashOffset=h.borderDashOffset,i.beginPath(),i.moveTo(l.x,l.y),i.lineTo(c.x,c.y),i.stroke(),i.restore())};if(e.display)for(o=0,r=n.length;o{this.draw(n)}}]:[{z:i,draw:n=>{this.drawBackground(),this.drawGrid(n),this.drawTitle()}},{z:i+1,draw:()=>{this.drawBorder()}},{z:e,draw:n=>{this.drawLabels(n)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+\"AxisID\",n=[];let o,r;for(o=0,r=e.length;o{const i=e.split(\".\"),n=i.pop(),o=[s].concat(i).join(\".\"),r=t[e].split(\".\"),a=r.pop(),l=r.join(\".\");F.route(o,n,l,a)})}function zD(s){return\"id\"in s&&\"defaults\"in s}class jD{constructor(){this.controllers=new sa(Ut,\"datasets\",!0),this.elements=new sa(Xt,\"elements\"),this.plugins=new sa(Object,\"plugins\"),this.scales=new sa(oi,\"scales\"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each(\"register\",t)}remove(...t){this._each(\"unregister\",t)}addControllers(...t){this._each(\"register\",t,this.controllers)}addElements(...t){this._each(\"register\",t,this.elements)}addPlugins(...t){this._each(\"register\",t,this.plugins)}addScales(...t){this._each(\"register\",t,this.scales)}getController(t){return this._get(t,this.controllers,\"controller\")}getElement(t){return this._get(t,this.elements,\"element\")}getPlugin(t){return this._get(t,this.plugins,\"plugin\")}getScale(t){return this._get(t,this.scales,\"scale\")}removeControllers(...t){this._each(\"unregister\",t,this.controllers)}removeElements(...t){this._each(\"unregister\",t,this.elements)}removePlugins(...t){this._each(\"unregister\",t,this.plugins)}removeScales(...t){this._each(\"unregister\",t,this.scales)}_each(t,e,i){[...e].forEach(n=>{const o=i||this._getRegistryForType(n);i||o.isForType(n)||o===this.plugins&&n.id?this._exec(t,o,n):U(n,r=>{const a=i||this._getRegistryForType(r);this._exec(t,a,r)})})}_exec(t,e,i){const n=gc(t);G(i[\"before\"+n],[],i),e[t](i),G(i[\"after\"+n],[],i)}_getRegistryForType(t){for(let e=0;e0&&this.getParsed(e-1);for(let E=e;E0&&Math.abs(A[p]-x[p])>y,v&&(w.parsed=A,w.raw=c.data[E]),u&&(w.options=d||this.resolveDataElementOptions(E,C.active?\"active\":n)),T||this.updateElement(C,E,w,n),x=A}this.updateSharedOptions(d,n,h)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let a=0;for(let l=e.length-1;l>=0;--l)a=Math.max(a,e[l].size(this.resolveDataElementOptions(l))/2);return a>0&&a}const i=t.dataset,n=i.options&&i.options.borderWidth||0;if(!e.length)return n;const o=e[0].size(this.resolveDataElementOptions(0)),r=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(n,o,r)/2}}Un.id=\"scatter\",Un.defaults={datasetElementType:!1,dataElementType:\"point\",showLine:!1,fill:!1},Un.overrides={interaction:{mode:\"point\"},plugins:{tooltip:{callbacks:{title(){return\"\"},label(s){return\"(\"+s.label+\", \"+s.formattedValue+\")\"}}}},scales:{x:{type:\"linear\"},y:{type:\"linear\"}}};var I_=Object.freeze({__proto__:null,BarController:Vn,BubbleController:Fn,DoughnutController:Bi,LineController:Wn,PolarAreaController:zn,PieController:ea,RadarController:jn,ScatterController:Un});function Hi(){throw new Error(\"This method is not implemented: Check that a complete date adapter is provided.\")}class Vc{constructor(t){this.options=t||{}}init(t){}formats(){return Hi()}parse(t,e){return Hi()}format(t,e){return Hi()}add(t,e,i){return Hi()}diff(t,e,i){return Hi()}startOf(t,e,i){return Hi()}endOf(t,e){return Hi()}}Vc.override=function(s){Object.assign(Vc.prototype,s)};var D_={_date:Vc};function YD(s,t,e,i){const{controller:n,data:o,_sorted:r}=s,a=n._cachedMeta.iScale;if(a&&t===a.axis&&t!==\"r\"&&r&&o.length){const l=a._reversePixels?RO:De;if(i){if(n._sharedOptions){const c=o[0],h=typeof c.getRange==\"function\"&&c.getRange(t);if(h){const d=l(o,t,e-h),u=l(o,t,e+h);return{lo:d.lo,hi:u.hi}}}}else return l(o,t,e)}return{lo:0,hi:o.length-1}}function Xn(s,t,e,i,n){const o=s.getSortedVisibleDatasetMetas(),r=e[t];for(let a=0,l=o.length;a{l[r](t[e],n)&&(o.push({element:l,datasetIndex:c,index:h}),a=a||l.inRange(t.x,t.y,n))}),i&&!a?[]:o}var L_={evaluateInteractionItems:Xn,modes:{index(s,t,e,i){const n=Pi(t,s),o=e.axis||\"x\",r=e.includeInvisible||!1,a=e.intersect?Fc(s,n,o,i,r):Wc(s,n,o,!1,i,r),l=[];return a.length?(s.getSortedVisibleDatasetMetas().forEach(c=>{const h=a[0].index,d=c.data[h];d&&!d.skip&&l.push({element:d,datasetIndex:c.index,index:h})}),l):[]},dataset(s,t,e,i){const n=Pi(t,s),o=e.axis||\"xy\",r=e.includeInvisible||!1;let a=e.intersect?Fc(s,n,o,i,r):Wc(s,n,o,!1,i,r);if(a.length>0){const l=a[0].datasetIndex,c=s.getDatasetMeta(l).data;a=[];for(let h=0;he.pos===t)}function R_(s,t){return s.filter(e=>$_.indexOf(e.pos)===-1&&e.box.axis===t)}function qn(s,t){return s.sort((e,i)=>{const n=t?i:e,o=t?e:i;return n.weight===o.weight?n.index-o.index:n.weight-o.weight})}function GD(s){const t=[];let e,i,n,o,r,a;for(e=0,i=(s||[]).length;ec.box.fullSize),!0),i=qn(Gn(t,\"left\"),!0),n=qn(Gn(t,\"right\")),o=qn(Gn(t,\"top\"),!0),r=qn(Gn(t,\"bottom\")),a=R_(t,\"x\"),l=R_(t,\"y\");return{fullSize:e,leftAndTop:i.concat(o),rightAndBottom:n.concat(l).concat(r).concat(a),chartArea:Gn(t,\"chartArea\"),vertical:i.concat(n).concat(l),horizontal:o.concat(r).concat(a)}}function P_(s,t,e,i){return Math.max(s[e],t[e])+Math.max(s[i],t[i])}function N_(s,t){s.top=Math.max(s.top,t.top),s.left=Math.max(s.left,t.left),s.bottom=Math.max(s.bottom,t.bottom),s.right=Math.max(s.right,t.right)}function JD(s,t,e,i){const{pos:n,box:o}=e,r=s.maxPadding;if(!V(n)){e.size&&(s[n]-=e.size);const d=i[e.stack]||{size:0,count:1};d.size=Math.max(d.size,e.horizontal?o.height:o.width),e.size=d.size/d.count,s[n]+=e.size}o.getPadding&&N_(r,o.getPadding());const a=Math.max(0,t.outerWidth-P_(r,s,\"left\",\"right\")),l=Math.max(0,t.outerHeight-P_(r,s,\"top\",\"bottom\")),c=a!==s.w,h=l!==s.h;return s.w=a,s.h=l,e.horizontal?{same:c,other:h}:{same:h,other:c}}function t1(s){const t=s.maxPadding;function e(i){const n=Math.max(t[i]-s[i],0);return s[i]+=n,n}s.y+=e(\"top\"),s.x+=e(\"left\"),e(\"right\"),e(\"bottom\")}function e1(s,t){const e=t.maxPadding;function i(n){const o={left:0,top:0,right:0,bottom:0};return n.forEach(r=>{o[r]=Math.max(t[r],e[r])}),o}return i(s?[\"left\",\"right\"]:[\"top\",\"bottom\"])}function Zn(s,t,e,i){const n=[];let o,r,a,l,c,h;for(o=0,r=s.length,c=0;o{typeof b.beforeLayout==\"function\"&&b.beforeLayout()});const h=l.reduce((b,v)=>v.box.options&&v.box.options.display===!1?b:b+1,0)||1,d=Object.freeze({outerWidth:t,outerHeight:e,padding:n,availableWidth:o,availableHeight:r,vBoxMaxWidth:o/2/h,hBoxMaxHeight:r/2}),u=Object.assign({},n);N_(u,pt(i));const p=Object.assign({maxPadding:u,w:o,h:r,x:n.left,y:n.top},n),f=ZD(l.concat(c),d);Zn(a.fullSize,p,d,f),Zn(l,p,d,f),Zn(c,p,d,f)&&Zn(l,p,d,f),t1(p),B_(a.leftAndTop,p,d,f),p.x+=p.w,p.y+=p.h,B_(a.rightAndBottom,p,d,f),s.chartArea={left:p.left,top:p.top,right:p.left+p.w,bottom:p.top+p.h,height:p.h,width:p.w},U(a.chartArea,b=>{const v=b.box;Object.assign(v,s.chartArea),v.update(p.w,p.h,{left:0,top:0,right:0,bottom:0})})}};class zc{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,n){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,n?Math.floor(e/n):i)}}isAttached(t){return!0}updateConfig(t){}}class H_ extends zc{acquireContext(t){return t&&t.getContext&&t.getContext(\"2d\")||null}updateConfig(t){t.options.animation=!1}}const oa=\"$chartjs\",i1={touchstart:\"mousedown\",touchmove:\"mousemove\",touchend:\"mouseup\",pointerenter:\"mouseenter\",pointerdown:\"mousedown\",pointermove:\"mousemove\",pointerup:\"mouseup\",pointerleave:\"mouseout\",pointerout:\"mouseout\"},V_=s=>s===null||s===\"\";function s1(s,t){const e=s.style,i=s.getAttribute(\"height\"),n=s.getAttribute(\"width\");if(s[oa]={initial:{height:i,width:n,style:{display:e.display,height:e.height,width:e.width}}},e.display=e.display||\"block\",e.boxSizing=e.boxSizing||\"border-box\",V_(n)){const o=s_(s,\"width\");o!==void 0&&(s.width=o)}if(V_(i))if(s.style.height===\"\")s.height=s.width/(t||2);else{const o=s_(s,\"height\");o!==void 0&&(s.height=o)}return s}const F_=WI?{passive:!0}:!1;function n1(s,t,e){s.addEventListener(t,e,F_)}function o1(s,t,e){s.canvas.removeEventListener(t,e,F_)}function r1(s,t){const e=i1[s.type]||s.type,{x:i,y:n}=Pi(s,t);return{type:e,chart:t,native:s,x:i!==void 0?i:null,y:n!==void 0?n:null}}function ra(s,t){for(const e of s)if(e===t||e.contains(t))return!0}function a1(s,t,e){const i=s.canvas,n=new MutationObserver(o=>{let r=!1;for(const a of o)r=r||ra(a.addedNodes,i),r=r&&!ra(a.removedNodes,i);r&&e()});return n.observe(document,{childList:!0,subtree:!0}),n}function l1(s,t,e){const i=s.canvas,n=new MutationObserver(o=>{let r=!1;for(const a of o)r=r||ra(a.removedNodes,i),r=r&&!ra(a.addedNodes,i);r&&e()});return n.observe(document,{childList:!0,subtree:!0}),n}const Qn=new Map;let W_=0;function z_(){const s=window.devicePixelRatio;s!==W_&&(W_=s,Qn.forEach((t,e)=>{e.currentDevicePixelRatio!==s&&t()}))}function c1(s,t){Qn.size||window.addEventListener(\"resize\",z_),Qn.set(s,t)}function h1(s){Qn.delete(s),Qn.size||window.removeEventListener(\"resize\",z_)}function d1(s,t,e){const i=s.canvas,n=i&&$c(i);if(!n)return;const o=Of((a,l)=>{const c=n.clientWidth;e(a,l),c{const l=a[0],c=l.contentRect.width,h=l.contentRect.height;c===0&&h===0||o(c,h)});return r.observe(n),c1(s,o),r}function jc(s,t,e){e&&e.disconnect(),t===\"resize\"&&h1(s)}function u1(s,t,e){const i=s.canvas,n=Of(o=>{s.ctx!==null&&e(r1(o,s))},s,o=>{const r=o[0];return[r,r.offsetX,r.offsetY]});return n1(i,t,n),n}class j_ extends zc{acquireContext(t,e){const i=t&&t.getContext&&t.getContext(\"2d\");return i&&i.canvas===t?(s1(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[oa])return!1;const i=e[oa].initial;[\"height\",\"width\"].forEach(o=>{const r=i[o];H(r)?e.removeAttribute(o):e.setAttribute(o,r)});const n=i.style||{};return Object.keys(n).forEach(o=>{e.style[o]=n[o]}),e.width=e.width,delete e[oa],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const n=t.$proxies||(t.$proxies={}),r={attach:a1,detach:l1,resize:d1}[e]||u1;n[e]=r(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),n=i[e];if(!n)return;({attach:jc,detach:jc,resize:jc}[e]||o1)(t,e,n),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,n){return FI(t,e,i,n)}isAttached(t){const e=$c(t);return!!(e&&e.isConnected)}}function Y_(s){return!e_()||typeof OffscreenCanvas<\"u\"&&s instanceof OffscreenCanvas?H_:j_}class p1{constructor(){this._init=[]}notify(t,e,i,n){e===\"beforeInit\"&&(this._init=this._createDescriptors(t,!0),this._notify(this._init,t,\"install\"));const o=n?this._descriptors(t).filter(n):this._descriptors(t),r=this._notify(o,t,e,i);return e===\"afterDestroy\"&&(this._notify(o,t,\"stop\"),this._notify(this._init,t,\"uninstall\")),r}_notify(t,e,i,n){n=n||{};for(const o of t){const r=o.plugin,a=r[i],l=[e,n,o.options];if(G(a,l,r)===!1&&n.cancelable)return!1}return!0}invalidate(){H(this._cache)||(this._oldCache=this._cache,this._cache=void 0)}_descriptors(t){if(this._cache)return this._cache;const e=this._cache=this._createDescriptors(t);return this._notifyStateChanges(t),e}_createDescriptors(t,e){const i=t&&t.config,n=B(i.options&&i.options.plugins,{}),o=f1(i);return n===!1&&!e?[]:g1(t,o,n,e)}_notifyStateChanges(t){const e=this._oldCache||[],i=this._cache,n=(o,r)=>o.filter(a=>!r.some(l=>a.plugin.id===l.plugin.id));this._notify(n(e,i),t,\"stop\"),this._notify(n(i,e),t,\"start\")}}function f1(s){const t={},e=[],i=Object.keys(ne.plugins.items);for(let o=0;o{const l=i[a];if(!V(l))return console.error(`Invalid scale configuration for scale: ${a}`);if(l._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${a}`);const c=Kc(a,l),h=v1(c,n),d=e.scales||{};o[c]=o[c]||a,r[a]=Sn(Object.create(null),[{axis:c},l,d[c],d[h]])}),s.data.datasets.forEach(a=>{const l=a.type||s.type,c=a.indexAxis||Yc(l,t),d=(Di[l]||{}).scales||{};Object.keys(d).forEach(u=>{const p=b1(u,c),f=a[p+\"AxisID\"]||o[p]||p;r[f]=r[f]||Object.create(null),Sn(r[f],[{axis:p},i[f],d[u]])})}),Object.keys(r).forEach(a=>{const l=r[a];Sn(l,[F.scales[l.type],F.scale])}),r}function K_(s){const t=s.options||(s.options={});t.plugins=B(t.plugins,{}),t.scales=T1(s,t)}function U_(s){return s=s||{},s.datasets=s.datasets||[],s.labels=s.labels||[],s}function E1(s){return s=s||{},s.data=U_(s.data),K_(s),s}const X_=new Map,G_=new Set;function aa(s,t){let e=X_.get(s);return e||(e=t(),X_.set(s,e),G_.add(e)),e}const Jn=(s,t,e)=>{const i=ti(t,e);i!==void 0&&s.add(i)};class x1{constructor(t){this._config=E1(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=U_(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),K_(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return aa(t,()=>[[`datasets.${t}`,\"\"]])}datasetAnimationScopeKeys(t,e){return aa(`${t}.transition.${e}`,()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,\"\"]])}datasetElementScopeKeys(t,e){return aa(`${t}-${e}`,()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,\"\"]])}pluginScopeKeys(t){const e=t.id,i=this.type;return aa(`${i}-plugin-${e}`,()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]])}_cachedScopes(t,e){const i=this._scopeCache;let n=i.get(t);return(!n||e)&&(n=new Map,i.set(t,n)),n}getOptionScopes(t,e,i){const{options:n,type:o}=this,r=this._cachedScopes(t,i),a=r.get(e);if(a)return a;const l=new Set;e.forEach(h=>{t&&(l.add(t),h.forEach(d=>Jn(l,t,d))),h.forEach(d=>Jn(l,n,d)),h.forEach(d=>Jn(l,Di[o]||{},d)),h.forEach(d=>Jn(l,F,d)),h.forEach(d=>Jn(l,kc,d))});const c=Array.from(l);return c.length===0&&c.push(Object.create(null)),G_.has(e)&&r.set(e,c),c}chartOptionScopes(){const{options:t,type:e}=this;return[t,Di[e]||{},F.datasets[e]||{},{type:e},F,kc]}resolveNamedOptions(t,e,i,n=[\"\"]){const o={$shared:!0},{resolver:r,subPrefixes:a}=q_(this._resolverCache,t,n);let l=r;if(A1(r,e)){o.$shared=!1,i=ei(i)?i():i;const c=this.createResolver(t,i,a);l=ks(r,i,c)}for(const c of e)o[c]=l[c];return o}createResolver(t,e,i=[\"\"],n){const{resolver:o}=q_(this._resolverCache,t,i);return V(e)?ks(o,e,void 0,n):o}}function q_(s,t,e){let i=s.get(t);i||(i=new Map,s.set(t,i));const n=e.join();let o=i.get(n);return o||(o={resolver:Dc(t,e),subPrefixes:e.filter(a=>!a.toLowerCase().includes(\"hover\"))},i.set(n,o)),o}const C1=s=>V(s)&&Object.getOwnPropertyNames(s).reduce((t,e)=>t||ei(s[e]),!1);function A1(s,t){const{isScriptable:e,isIndexable:i}=Uf(s);for(const n of t){const o=e(n),r=i(n),a=(r||o)&&s[n];if(o&&(ei(a)||C1(a))||r&&Q(a))return!0}return!1}var w1=\"3.9.1\";const k1=[\"top\",\"bottom\",\"left\",\"right\",\"chartArea\"];function Z_(s,t){return s===\"top\"||s===\"bottom\"||k1.indexOf(s)===-1&&t===\"x\"}function Q_(s,t){return function(e,i){return e[s]===i[s]?e[t]-i[t]:e[s]-i[s]}}function J_(s){const t=s.chart,e=t.options.animation;t.notifyPlugins(\"afterRender\"),G(e&&e.onComplete,[s],t)}function S1(s){const t=s.chart,e=t.options.animation;G(e&&e.onProgress,[s],t)}function tg(s){return e_()&&typeof s==\"string\"?s=document.getElementById(s):s&&s.length&&(s=s[0]),s&&s.canvas&&(s=s.canvas),s}const la={},eg=s=>{const t=tg(s);return Object.values(la).filter(e=>e.canvas===t).pop()};function O1(s,t,e){const i=Object.keys(s);for(const n of i){const o=+n;if(o>=t){const r=s[n];delete s[n],(e>0||o>t)&&(s[o+e]=r)}}}function I1(s,t,e,i){return!e||s.type===\"mouseout\"?null:i?t:s}class Uc{constructor(t,e){const i=this.config=new x1(e),n=tg(t),o=eg(n);if(o)throw new Error(\"Canvas is already in use. Chart with ID '\"+o.id+\"' must be destroyed before the canvas with ID '\"+o.canvas.id+\"' can be reused.\");const r=i.createResolver(i.chartOptionScopes(),this.getContext());this.platform=new(i.platform||Y_(n)),this.platform.updateConfig(i);const a=this.platform.acquireContext(n,r.aspectRatio),l=a&&a.canvas,c=l&&l.height,h=l&&l.width;if(this.id=EO(),this.ctx=a,this.canvas=l,this.width=h,this.height=c,this._options=r,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new p1,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=BO(d=>this.update(d),r.resizeDelay||0),this._dataChanges=[],la[this.id]=this,!a||!l){console.error(\"Failed to create chart: can't acquire context from the given item\");return}xe.listen(this,\"complete\",J_),xe.listen(this,\"progress\",S1),this._initialize(),this.attached&&this.update()}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return H(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}_initialize(){return this.notifyPlugins(\"beforeInit\"),this.options.responsive?this.resize():i_(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins(\"afterInit\"),this}clear(){return jf(this.canvas,this.ctx),this}stop(){return xe.stop(this),this}resize(t,e){xe.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,n=this.canvas,o=i.maintainAspectRatio&&this.aspectRatio,r=this.platform.getMaximumSize(n,t,e,o),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?\"resize\":\"attach\";this.width=r.width,this.height=r.height,this._aspectRatio=this.aspectRatio,i_(this,a,!0)&&(this.notifyPlugins(\"resize\",{size:r}),G(i.onResize,[this,r],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){const e=this.options.scales||{};U(e,(i,n)=>{i.id=n})}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,n=Object.keys(i).reduce((r,a)=>(r[a]=!1,r),{});let o=[];e&&(o=o.concat(Object.keys(e).map(r=>{const a=e[r],l=Kc(r,a),c=l===\"r\",h=l===\"x\";return{options:a,dposition:c?\"chartArea\":h?\"bottom\":\"left\",dtype:c?\"radialLinear\":h?\"category\":\"linear\"}}))),U(o,r=>{const a=r.options,l=a.id,c=Kc(l,a),h=B(a.type,r.dtype);(a.position===void 0||Z_(a.position,c)!==Z_(r.dposition))&&(a.position=r.dposition),n[l]=!0;let d=null;if(l in i&&i[l].type===h)d=i[l];else{const u=ne.getScale(h);d=new u({id:l,type:h,ctx:this.ctx,chart:this}),i[d.id]=d}d.init(a,t)}),U(n,(r,a)=>{r||delete i[a]}),U(i,r=>{ft.configure(this,r,r.options),ft.addBox(this,r)})}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort((n,o)=>n.index-o.index),i>e){for(let n=e;ne.length&&delete this._stacks,t.forEach((i,n)=>{e.filter(o=>o===i._dataset).length===0&&this._destroyDatasetMeta(n)})}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,n;for(this._removeUnreferencedMetasets(),i=0,n=e.length;i{this.getDatasetMeta(e).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins(\"reset\")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),n=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins(\"beforeUpdate\",{mode:t,cancelable:!0})===!1)return;const o=this.buildOrUpdateControllers();this.notifyPlugins(\"beforeElementsUpdate\");let r=0;for(let c=0,h=this.data.datasets.length;c{c.reset()}),this._updateDatasets(t),this.notifyPlugins(\"afterUpdate\",{mode:t}),this._layers.sort(Q_(\"z\",\"_idx\"));const{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){U(this.scales,t=>{ft.removeBox(this,t)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);(!vf(e,i)||!!this._responsiveListeners!==t.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:n,count:o}of e){const r=i===\"_removeElements\"?-o:o;O1(t,n,r)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=o=>new Set(t.filter(r=>r[0]===o).map((r,a)=>a+\",\"+r.splice(1).join(\",\"))),n=i(0);for(let o=1;oo.split(\",\")).map(o=>({method:o[1],start:+o[2],count:+o[3]}))}_updateLayout(t){if(this.notifyPlugins(\"beforeLayout\",{cancelable:!0})===!1)return;ft.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],U(this.boxes,n=>{i&&n.position===\"chartArea\"||(n.configure&&n.configure(),this._layers.push(...n._layers()))},this),this._layers.forEach((n,o)=>{n._idx=o}),this.notifyPlugins(\"afterLayout\")}_updateDatasets(t){if(this.notifyPlugins(\"beforeDatasetsUpdate\",{mode:t,cancelable:!0})!==!1){for(let e=0,i=this.data.datasets.length;e=0;--e)this._drawDataset(t[e]);this.notifyPlugins(\"afterDatasetsDraw\")}_drawDataset(t){const e=this.ctx,i=t._clip,n=!i.disabled,o=this.chartArea,r={meta:t,index:t.index,cancelable:!0};this.notifyPlugins(\"beforeDatasetDraw\",r)!==!1&&(n&&qr(e,{left:i.left===!1?0:o.left-i.left,right:i.right===!1?this.width:o.right+i.right,top:i.top===!1?0:o.top-i.top,bottom:i.bottom===!1?this.height:o.bottom+i.bottom}),t.controller.draw(),n&&Zr(e),r.cancelable=!1,this.notifyPlugins(\"afterDatasetDraw\",r))}isPointInArea(t){return Pn(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,n){const o=L_.modes[e];return typeof o==\"function\"?o(this,t,i,n):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let n=i.filter(o=>o&&o._dataset===e).pop();return n||(n={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(n)),n}getContext(){return this.$context||(this.$context=ni(null,{chart:this,type:\"chart\"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return typeof i.hidden==\"boolean\"?!i.hidden:!e.hidden}setDatasetVisibility(t,e){const i=this.getDatasetMeta(t);i.hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const n=i?\"show\":\"hide\",o=this.getDatasetMeta(t),r=o.controller._resolveAnimations(void 0,n);jt(e)?(o.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),r.update(o,{visible:i}),this.update(a=>a.datasetIndex===t?n:void 0))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xe.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,o,r),t[o]=r},n=(o,r,a)=>{o.offsetX=r,o.offsetY=a,this._eventHandler(o)};U(this.options.events,o=>i(o,n))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(l,c)=>{e.addEventListener(this,l,c),t[l]=c},n=(l,c)=>{t[l]&&(e.removeEventListener(this,l,c),delete t[l])},o=(l,c)=>{this.canvas&&this.resize(l,c)};let r;const a=()=>{n(\"attach\",a),this.attached=!0,this.resize(),i(\"resize\",o),i(\"detach\",r)};r=()=>{this.attached=!1,n(\"resize\",o),this._stop(),this._resize(0,0),i(\"attach\",a)},e.isAttached(this.canvas)?a():r()}unbindEvents(){U(this._listeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._listeners={},U(this._responsiveListeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const n=i?\"set\":\"remove\";let o,r,a,l;for(e===\"dataset\"&&(o=this.getDatasetMeta(t[0].datasetIndex),o.controller[\"_\"+n+\"DatasetHoverStyle\"]()),a=0,l=t.length;a{const a=this.getDatasetMeta(o);if(!a)throw new Error(\"No dataset found at index \"+o);return{datasetIndex:o,element:a.data[r],index:r}});!Fr(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}_updateHoverStyles(t,e,i){const n=this.options.hover,o=(l,c)=>l.filter(h=>!c.some(d=>h.datasetIndex===d.datasetIndex&&h.index===d.index)),r=o(e,t),a=i?t:o(t,e);r.length&&this.updateHoverStyle(r,n.mode,!1),a.length&&n.mode&&this.updateHoverStyle(a,n.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},n=r=>(r.options.events||this.options.events).includes(t.native.type);if(this.notifyPlugins(\"beforeEvent\",i,n)===!1)return;const o=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins(\"afterEvent\",i,n),(o||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:n=[],options:o}=this,r=e,a=this._getActiveElements(t,n,i,r),l=SO(t),c=I1(t,this._lastEvent,i,l);i&&(this._lastEvent=null,G(o.onHover,[t,a,this],this),l&&G(o.onClick,[t,a,this],this));const h=!Fr(a,n);return(h||e)&&(this._active=a,this._updateHoverStyles(a,n,e)),this._lastEvent=c,h}_getActiveElements(t,e,i,n){if(t.type===\"mouseout\")return[];if(!i)return e;const o=this.options.hover;return this.getElementsAtEventForMode(t,o.mode,o,n)}}const ig=()=>U(Uc.instances,s=>s._plugins.invalidate()),ri=!0;Object.defineProperties(Uc,{defaults:{enumerable:ri,value:F},instances:{enumerable:ri,value:la},overrides:{enumerable:ri,value:Di},registry:{enumerable:ri,value:ne},version:{enumerable:ri,value:w1},getChart:{enumerable:ri,value:eg},register:{enumerable:ri,value:(...s)=>{ne.add(...s),ig()}},unregister:{enumerable:ri,value:(...s)=>{ne.remove(...s),ig()}}});function sg(s,t,e){const{startAngle:i,pixelMargin:n,x:o,y:r,outerRadius:a,innerRadius:l}=t;let c=n/a;s.beginPath(),s.arc(o,r,a,i-c,e+c),l>n?(c=n/l,s.arc(o,r,l,e+c,i-c,!0)):s.arc(o,r,n,e+nt,i-nt),s.closePath(),s.clip()}function D1(s){return Ic(s,[\"outerStart\",\"outerEnd\",\"innerStart\",\"innerEnd\"])}function M1(s,t,e,i){const n=D1(s.options.borderRadius),o=(e-t)/2,r=Math.min(o,i*t/2),a=l=>{const c=(e-Math.min(o,l))*i/2;return dt(l,0,Math.min(o,c))};return{outerStart:a(n.outerStart),outerEnd:a(n.outerEnd),innerStart:dt(n.innerStart,0,r),innerEnd:dt(n.innerEnd,0,r)}}function Is(s,t,e,i){return{x:e+s*Math.cos(t),y:i+s*Math.sin(t)}}function Xc(s,t,e,i,n,o){const{x:r,y:a,startAngle:l,pixelMargin:c,innerRadius:h}=t,d=Math.max(t.outerRadius+i+e-c,0),u=h>0?h+i+e+c:0;let p=0;const f=n-l;if(i){const R=h>0?h-i:0,z=d>0?d-i:0,Y=(R+z)/2,Gt=Y!==0?f*Y/(Y+i):f;p=(f-Gt)/2}const b=Math.max(.001,f*d-e/it)/d,v=(f-b)/2,y=l+v+p,T=n-v-p,{outerStart:x,outerEnd:E,innerStart:C,innerEnd:A}=M1(t,u,d,T-y),w=d-x,S=d-E,k=y+x/w,D=T-E/S,I=u+C,M=u+A,P=y+C/I,X=T-A/M;if(s.beginPath(),o){if(s.arc(r,a,d,k,D),E>0){const Y=Is(S,D,r,a);s.arc(Y.x,Y.y,E,D,T+nt)}const R=Is(M,T,r,a);if(s.lineTo(R.x,R.y),A>0){const Y=Is(M,X,r,a);s.arc(Y.x,Y.y,A,T+nt,X+Math.PI)}if(s.arc(r,a,u,T-A/u,y+C/u,!0),C>0){const Y=Is(I,P,r,a);s.arc(Y.x,Y.y,C,P+Math.PI,y-nt)}const z=Is(w,y,r,a);if(s.lineTo(z.x,z.y),x>0){const Y=Is(w,k,r,a);s.arc(Y.x,Y.y,x,y-nt,k)}}else{s.moveTo(r,a);const R=Math.cos(k)*d+r,z=Math.sin(k)*d+a;s.lineTo(R,z);const Y=Math.cos(D)*d+r,Gt=Math.sin(D)*d+a;s.lineTo(Y,Gt)}s.closePath()}function L1(s,t,e,i,n){const{fullCircles:o,startAngle:r,circumference:a}=t;let l=t.endAngle;if(o){Xc(s,t,e,i,r+q,n);for(let c=0;c=q||Dn(o,a,l),b=Ie(r,c+u,h+u);return f&&b}getCenterPoint(t){const{x:e,y:i,startAngle:n,endAngle:o,innerRadius:r,outerRadius:a}=this.getProps([\"x\",\"y\",\"startAngle\",\"endAngle\",\"innerRadius\",\"outerRadius\",\"circumference\"],t),{offset:l,spacing:c}=this.options,h=(n+o)/2,d=(r+a+c+l)/2;return{x:e+Math.cos(h)*d,y:i+Math.sin(h)*d}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,n=(e.offset||0)/2,o=(e.spacing||0)/2,r=e.circular;if(this.pixelMargin=e.borderAlign===\"inner\"?.33:0,this.fullCircles=i>q?Math.floor(i/q):0,i===0||this.innerRadius<0||this.outerRadius<0)return;t.save();let a=0;if(n){a=n/2;const c=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(c)*a,Math.sin(c)*a),this.circumference>=it&&(a=n)}t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor;const l=L1(t,this,a,o,r);R1(t,this,a,o,l,r),t.restore()}}Ds.id=\"arc\",Ds.defaults={borderAlign:\"center\",borderColor:\"#fff\",borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0},Ds.defaultRoutes={backgroundColor:\"backgroundColor\"};function ng(s,t,e=t){s.lineCap=B(e.borderCapStyle,t.borderCapStyle),s.setLineDash(B(e.borderDash,t.borderDash)),s.lineDashOffset=B(e.borderDashOffset,t.borderDashOffset),s.lineJoin=B(e.borderJoinStyle,t.borderJoinStyle),s.lineWidth=B(e.borderWidth,t.borderWidth),s.strokeStyle=B(e.borderColor,t.borderColor)}function P1(s,t,e){s.lineTo(e.x,e.y)}function N1(s){return s.stepped?dI:s.tension||s.cubicInterpolationMode===\"monotone\"?uI:P1}function og(s,t,e={}){const i=s.length,{start:n=0,end:o=i-1}=e,{start:r,end:a}=t,l=Math.max(n,r),c=Math.min(o,a),h=na&&o>a;return{count:i,start:l,loop:t.loop,ilen:c(r+(c?a-E:E))%o,x=()=>{b!==v&&(s.lineTo(h,v),s.lineTo(h,b),s.lineTo(h,y))};for(l&&(p=n[T(0)],s.moveTo(p.x,p.y)),u=0;u<=a;++u){if(p=n[T(u)],p.skip)continue;const E=p.x,C=p.y,A=E|0;A===f?(Cv&&(v=C),h=(d*h+E)/++d):(x(),s.lineTo(E,C),f=A,d=0,b=v=C),y=C}x()}function Gc(s){const t=s.options,e=t.borderDash&&t.borderDash.length;return!s._decimated&&!s._loop&&!t.tension&&t.cubicInterpolationMode!==\"monotone\"&&!t.stepped&&!e?H1:B1}function V1(s){return s.stepped?zI:s.tension||s.cubicInterpolationMode===\"monotone\"?jI:Ni}function F1(s,t,e,i){let n=t._path;n||(n=t._path=new Path2D,t.path(n,e,i)&&n.closePath()),ng(s,t.options),s.stroke(n)}function W1(s,t,e,i){const{segments:n,options:o}=t,r=Gc(t);for(const a of n)ng(s,o,a.style),s.beginPath(),r(s,t,a,{start:e,end:e+i-1})&&s.closePath(),s.stroke()}const z1=typeof Path2D==\"function\";function j1(s,t,e,i){z1&&!t.options.segment?F1(s,t,e,i):W1(s,t,e,i)}class Le extends Xt{constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||i.cubicInterpolationMode===\"monotone\")&&!i.stepped&&!this._pointsUpdated){const n=i.spanGaps?this._loop:this._fullLoop;RI(this._points,i,t,n,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=ZI(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,n=t[e],o=this.points,r=h_(this,{property:e,start:n,end:n});if(!r.length)return;const a=[],l=V1(i);let c,h;for(c=0,h=r.length;cs!==\"borderDash\"&&s!==\"fill\"};function rg(s,t,e,i){const n=s.options,{[e]:o}=s.getProps([e],i);return Math.abs(t-o)=e)return s.slice(t,t+e);const r=[],a=(e-2)/(o-2);let l=0;const c=t+e-1;let h=t,d,u,p,f,b;for(r[l++]=s[h],d=0;dp&&(p=f,u=s[T],b=T);r[l++]=u,h=b}return r[l++]=s[c],r}function Z1(s,t,e,i){let n=0,o=0,r,a,l,c,h,d,u,p,f,b;const v=[],y=t+e-1,T=s[t].x,E=s[y].x-T;for(r=t;rb&&(b=c,u=r),n=(o*n+a.x)/++o;else{const A=r-1;if(!H(d)&&!H(u)){const w=Math.min(d,u),S=Math.max(d,u);w!==p&&w!==A&&v.push({...s[w],x:n}),S!==p&&S!==A&&v.push({...s[S],x:n})}r>0&&A!==p&&v.push(s[A]),v.push(a),h=C,o=0,f=b=c,d=u=p=r}}return v}function cg(s){if(s._decimated){const t=s._data;delete s._decimated,delete s._data,Object.defineProperty(s,\"data\",{value:t})}}function hg(s){s.data.datasets.forEach(t=>{cg(t)})}function Q1(s,t){const e=t.length;let i=0,n;const{iScale:o}=s,{min:r,max:a,minDefined:l,maxDefined:c}=o.getUserBounds();return l&&(i=dt(De(t,o.axis,r).lo,0,e-1)),c?n=dt(De(t,o.axis,a).hi+1,i,e)-i:n=e-i,{start:i,count:n}}var dg={id:\"decimation\",defaults:{algorithm:\"min-max\",enabled:!1},beforeElementsUpdate:(s,t,e)=>{if(!e.enabled){hg(s);return}const i=s.width;s.data.datasets.forEach((n,o)=>{const{_data:r,indexAxis:a}=n,l=s.getDatasetMeta(o),c=r||n.data;if(tt([a,s.options.indexAxis])===\"y\"||!l.controller.supportsDecimation)return;const h=s.scales[l.xAxisID];if(h.type!==\"linear\"&&h.type!==\"time\"||s.options.parsing)return;let{start:d,count:u}=Q1(l,c);const p=e.threshold||4*i;if(u<=p){cg(n);return}H(r)&&(n._data=c,delete n.data,Object.defineProperty(n,\"data\",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(b){this._data=b}}));let f;switch(e.algorithm){case\"lttb\":f=q1(c,d,u,i,e);break;case\"min-max\":f=Z1(c,d,u,i);break;default:throw new Error(`Unsupported decimation algorithm '${e.algorithm}'`)}n._decimated=f})},destroy(s){hg(s)}};function J1(s,t,e){const i=s.segments,n=s.points,o=t.points,r=[];for(const a of i){let{start:l,end:c}=a;c=Jc(l,c,n);const h=Qc(e,n[l],n[c],a.loop);if(!t.segments){r.push({source:a,target:h,start:n[l],end:n[c]});continue}const d=h_(t,h);for(const u of d){const p=Qc(e,o[u.start],o[u.end],u.loop),f=c_(a,n,p);for(const b of f)r.push({source:b,target:u,start:{[e]:ug(h,p,\"start\",Math.max)},end:{[e]:ug(h,p,\"end\",Math.min)}})}}return r}function Qc(s,t,e,i){if(i)return;let n=t[s],o=e[s];return s===\"angle\"&&(n=Vt(n),o=Vt(o)),{property:s,start:n,end:o}}function tM(s,t){const{x:e=null,y:i=null}=s||{},n=t.points,o=[];return t.segments.forEach(({start:r,end:a})=>{a=Jc(r,a,n);const l=n[r],c=n[a];i!==null?(o.push({x:l.x,y:i}),o.push({x:c.x,y:i})):e!==null&&(o.push({x:e,y:l.y}),o.push({x:e,y:c.y}))}),o}function Jc(s,t,e){for(;t>s;t--){const i=e[t];if(!isNaN(i.x)&&!isNaN(i.y))break}return t}function ug(s,t,e,i){return s&&t?i(s[e],t[e]):s?s[e]:t?t[e]:0}function pg(s,t){let e=[],i=!1;return Q(s)?(i=!0,e=s):e=tM(s,t),e.length?new Le({points:e,options:{tension:0},_loop:i,_fullLoop:i}):null}function fg(s){return s&&s.fill!==!1}function eM(s,t,e){let n=s[t].fill;const o=[t];let r;if(!e)return n;for(;n!==!1&&o.indexOf(n)===-1;){if(!rt(n))return n;if(r=s[n],!r)return!1;if(r.visible)return n;o.push(n),n=r.fill}return!1}function iM(s,t,e){const i=rM(s);if(V(i))return isNaN(i.value)?!1:i;let n=parseFloat(i);return rt(n)&&Math.floor(n)===n?sM(i[0],t,n,e):[\"origin\",\"start\",\"end\",\"stack\",\"shape\"].indexOf(i)>=0&&i}function sM(s,t,e,i){return(s===\"-\"||s===\"+\")&&(e=t+e),e===t||e<0||e>=i?!1:e}function nM(s,t){let e=null;return s===\"start\"?e=t.bottom:s===\"end\"?e=t.top:V(s)?e=t.getPixelForValue(s.value):t.getBasePixel&&(e=t.getBasePixel()),e}function oM(s,t,e){let i;return s===\"start\"?i=e:s===\"end\"?i=t.options.reverse?t.min:t.max:V(s)?i=s.value:i=t.getBaseValue(),i}function rM(s){const t=s.options,e=t.fill;let i=B(e&&e.target,e);return i===void 0&&(i=!!t.backgroundColor),i===!1||i===null?!1:i===!0?\"origin\":i}function aM(s){const{scale:t,index:e,line:i}=s,n=[],o=i.segments,r=i.points,a=lM(t,e);a.push(pg({x:null,y:t.bottom},i));for(let l=0;l=0;--r){const a=n[r].$filler;a&&(a.line.updateControlPoints(o,a.axis),i&&a.fill&&th(s.ctx,a,o))}},beforeDatasetsDraw(s,t,e){if(e.drawTime!==\"beforeDatasetsDraw\")return;const i=s.getSortedVisibleDatasetMetas();for(let n=i.length-1;n>=0;--n){const o=i[n].$filler;fg(o)&&th(s.ctx,o,s.chartArea)}},beforeDatasetDraw(s,t,e){const i=t.meta.$filler;!fg(i)||e.drawTime!==\"beforeDatasetDraw\"||th(s.ctx,i,s.chartArea)},defaults:{propagate:!0,drawTime:\"beforeDatasetDraw\"}};const yg=(s,t)=>{let{boxHeight:e=t,boxWidth:i=t}=s;return s.usePointStyle&&(e=Math.min(e,t),i=s.pointStyleWidth||Math.min(i,t)),{boxWidth:i,boxHeight:e,itemHeight:Math.max(t,e)}},bM=(s,t)=>s!==null&&t!==null&&s.datasetIndex===t.datasetIndex&&s.index===t.index;class Tg extends Xt{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=G(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter(i=>t.filter(i,this.chart.data))),t.sort&&(e=e.sort((i,n)=>t.sort(i,n,this.chart.data))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display){this.width=this.height=0;return}const i=t.labels,n=lt(i.font),o=n.size,r=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=yg(i,o);let c,h;e.font=n.string,this.isHorizontal()?(c=this.maxWidth,h=this._fitRows(r,o,a,l)+10):(h=this.maxHeight,c=this._fitCols(r,o,a,l)+10),this.width=Math.min(c,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,n){const{ctx:o,maxWidth:r,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.lineWidths=[0],h=n+a;let d=t;o.textAlign=\"left\",o.textBaseline=\"middle\";let u=-1,p=-h;return this.legendItems.forEach((f,b)=>{const v=i+e/2+o.measureText(f.text).width;(b===0||c[c.length-1]+v+2*a>r)&&(d+=h,c[c.length-(b>0?0:1)]=0,p+=h,u++),l[b]={left:0,top:p,row:u,width:v,height:n},c[c.length-1]+=v+a}),d}_fitCols(t,e,i,n){const{ctx:o,maxHeight:r,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.columnSizes=[],h=r-t;let d=a,u=0,p=0,f=0,b=0;return this.legendItems.forEach((v,y)=>{const T=i+e/2+o.measureText(v.text).width;y>0&&p+n+2*a>h&&(d+=u+a,c.push({width:u,height:p}),f+=u+a,b++,u=p=0),l[y]={left:f,top:p,col:b,width:T,height:n},u=Math.max(u,T),p+=n+a}),d+=u,c.push({width:u,height:p}),d}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:n},rtl:o}}=this,r=Os(o,this.left,this.width);if(this.isHorizontal()){let a=0,l=gt(i,this.left+n,this.right-this.lineWidths[a]);for(const c of e)a!==c.row&&(a=c.row,l=gt(i,this.left+n,this.right-this.lineWidths[a])),c.top+=this.top+t+n,c.left=r.leftForLtr(r.x(l),c.width),l+=c.width+n}else{let a=0,l=gt(i,this.top+t+n,this.bottom-this.columnSizes[a].height);for(const c of e)c.col!==a&&(a=c.col,l=gt(i,this.top+t+n,this.bottom-this.columnSizes[a].height)),c.top=l,c.left+=this.left+n,c.left=r.leftForLtr(r.x(c.left),c.width),l+=c.height+n}}isHorizontal(){return this.options.position===\"top\"||this.options.position===\"bottom\"}draw(){if(this.options.display){const t=this.ctx;qr(t,this),this._draw(),Zr(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:n}=this,{align:o,labels:r}=t,a=F.color,l=Os(t.rtl,this.left,this.width),c=lt(r.font),{color:h,padding:d}=r,u=c.size,p=u/2;let f;this.drawTitle(),n.textAlign=l.textAlign(\"left\"),n.textBaseline=\"middle\",n.lineWidth=.5,n.font=c.string;const{boxWidth:b,boxHeight:v,itemHeight:y}=yg(r,u),T=function(w,S,k){if(isNaN(b)||b<=0||isNaN(v)||v<0)return;n.save();const D=B(k.lineWidth,1);if(n.fillStyle=B(k.fillStyle,a),n.lineCap=B(k.lineCap,\"butt\"),n.lineDashOffset=B(k.lineDashOffset,0),n.lineJoin=B(k.lineJoin,\"miter\"),n.lineWidth=D,n.strokeStyle=B(k.strokeStyle,a),n.setLineDash(B(k.lineDash,[])),r.usePointStyle){const I={radius:v*Math.SQRT2/2,pointStyle:k.pointStyle,rotation:k.rotation,borderWidth:D},M=l.xPlus(w,b/2),P=S+p;Yf(n,I,M,P,r.pointStyleWidth&&b)}else{const I=S+Math.max((u-v)/2,0),M=l.leftForLtr(w,b),P=$i(k.borderRadius);n.beginPath(),Object.values(P).some(X=>X!==0)?Nn(n,{x:M,y:I,w:b,h:v,radius:P}):n.rect(M,I,b,v),n.fill(),D!==0&&n.stroke()}n.restore()},x=function(w,S,k){Li(n,k.text,w,S+y/2,c,{strikethrough:k.hidden,textAlign:l.textAlign(k.textAlign)})},E=this.isHorizontal(),C=this._computeTitleHeight();E?f={x:gt(o,this.left+d,this.right-i[0]),y:this.top+d+C,line:0}:f={x:this.left+d,y:gt(o,this.top+C+d,this.bottom-e[0].height),line:0},o_(this.ctx,t.textDirection);const A=y+d;this.legendItems.forEach((w,S)=>{n.strokeStyle=w.fontColor||h,n.fillStyle=w.fontColor||h;const k=n.measureText(w.text).width,D=l.textAlign(w.textAlign||(w.textAlign=r.textAlign)),I=b+p+k;let M=f.x,P=f.y;l.setWidth(this.width),E?S>0&&M+I+d>this.right&&(P=f.y+=A,f.line++,M=f.x=gt(o,this.left+d,this.right-i[f.line])):S>0&&P+A>this.bottom&&(M=f.x=M+e[f.line].width+d,f.line++,P=f.y=gt(o,this.top+C+d,this.bottom-e[f.line].height));const X=l.x(M);T(X,P,w),M=HO(D,M+b+p,E?M+I:this.right,t.rtl),x(l.x(M),P,w),E?f.x+=I+d:f.y+=A}),r_(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=lt(e.font),n=pt(e.padding);if(!e.display)return;const o=Os(t.rtl,this.left,this.width),r=this.ctx,a=e.position,l=i.size/2,c=n.top+l;let h,d=this.left,u=this.width;if(this.isHorizontal())u=Math.max(...this.lineWidths),h=this.top+c,d=gt(t.align,d,this.right-u);else{const f=this.columnSizes.reduce((b,v)=>Math.max(b,v.height),0);h=c+gt(t.align,this.top,this.bottom-f-t.labels.padding-this._computeTitleHeight())}const p=gt(a,d,d+u);r.textAlign=o.textAlign(yc(a)),r.textBaseline=\"middle\",r.strokeStyle=e.color,r.fillStyle=e.color,r.font=i.string,Li(r,e.text,p,h,i)}_computeTitleHeight(){const t=this.options.title,e=lt(t.font),i=pt(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,n,o;if(Ie(t,this.left,this.right)&&Ie(e,this.top,this.bottom)){for(o=this.legendHitBoxes,i=0;is.chart.options.color,boxWidth:40,padding:10,generateLabels(s){const t=s.data.datasets,{labels:{usePointStyle:e,pointStyle:i,textAlign:n,color:o}}=s.legend.options;return s._getSortedDatasetMetas().map(r=>{const a=r.controller.getStyle(e?0:void 0),l=pt(a.borderWidth);return{text:t[r.index].label,fillStyle:a.backgroundColor,fontColor:o,hidden:!r.visible,lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:(l.width+l.height)/4,strokeStyle:a.borderColor,pointStyle:i||a.pointStyle,rotation:a.rotation,textAlign:n||a.textAlign,borderRadius:0,datasetIndex:r.index}},this)}},title:{color:s=>s.chart.options.color,display:!1,position:\"center\",text:\"\"}},descriptors:{_scriptable:s=>!s.startsWith(\"on\"),labels:{_scriptable:s=>![\"generateLabels\",\"filter\",\"sort\"].includes(s)}}};class eh extends Xt{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=t,this.height=this.bottom=e;const n=Q(i.text)?i.text.length:1;this._padding=pt(i.padding);const o=n*lt(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return t===\"top\"||t===\"bottom\"}_drawArgs(t){const{top:e,left:i,bottom:n,right:o,options:r}=this,a=r.align;let l=0,c,h,d;return this.isHorizontal()?(h=gt(a,i,o),d=e+t,c=o-i):(r.position===\"left\"?(h=i+t,d=gt(a,n,e),l=it*-.5):(h=o-t,d=gt(a,e,n),l=it*.5),c=n-e),{titleX:h,titleY:d,maxWidth:c,rotation:l}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=lt(e.font),o=i.lineHeight/2+this._padding.top,{titleX:r,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(o);Li(t,e.text,0,0,i,{color:e.color,maxWidth:l,rotation:c,textAlign:yc(e.align),textBaseline:\"middle\",translation:[r,a]})}}function yM(s,t){const e=new eh({ctx:s.ctx,options:t,chart:s});ft.configure(s,e,t),ft.addBox(s,e),s.titleBlock=e}var xg={id:\"title\",_element:eh,start(s,t,e){yM(s,e)},stop(s){const t=s.titleBlock;ft.removeBox(s,t),delete s.titleBlock},beforeUpdate(s,t,e){const i=s.titleBlock;ft.configure(s,i,e),i.options=e},defaults:{align:\"center\",display:!1,font:{weight:\"bold\"},fullSize:!0,padding:10,position:\"top\",text:\"\",weight:2e3},defaultRoutes:{color:\"color\"},descriptors:{_scriptable:!0,_indexable:!1}};const ca=new WeakMap;var Cg={id:\"subtitle\",start(s,t,e){const i=new eh({ctx:s.ctx,options:e,chart:s});ft.configure(s,i,e),ft.addBox(s,i),ca.set(s,i)},stop(s){ft.removeBox(s,ca.get(s)),ca.delete(s)},beforeUpdate(s,t,e){const i=ca.get(s);ft.configure(s,i,e),i.options=e},defaults:{align:\"center\",display:!1,font:{weight:\"normal\"},fullSize:!0,padding:0,position:\"top\",text:\"\",weight:1500},defaultRoutes:{color:\"color\"},descriptors:{_scriptable:!0,_indexable:!1}};const to={average(s){if(!s.length)return!1;let t,e,i=0,n=0,o=0;for(t=0,e=s.length;t-1?s.split(`\n`):s}function TM(s,t){const{element:e,datasetIndex:i,index:n}=t,o=s.getDatasetMeta(i).controller,{label:r,value:a}=o.getLabelAndValue(n);return{chart:s,label:r,parsed:o.getParsed(n),raw:s.data.datasets[i].data[n],formattedValue:a,dataset:o.getDataset(),dataIndex:n,datasetIndex:i,element:e}}function Ag(s,t){const e=s.chart.ctx,{body:i,footer:n,title:o}=s,{boxWidth:r,boxHeight:a}=t,l=lt(t.bodyFont),c=lt(t.titleFont),h=lt(t.footerFont),d=o.length,u=n.length,p=i.length,f=pt(t.padding);let b=f.height,v=0,y=i.reduce((E,C)=>E+C.before.length+C.lines.length+C.after.length,0);if(y+=s.beforeBody.length+s.afterBody.length,d&&(b+=d*c.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),y){const E=t.displayColors?Math.max(a,l.lineHeight):l.lineHeight;b+=p*E+(y-p)*l.lineHeight+(y-1)*t.bodySpacing}u&&(b+=t.footerMarginTop+u*h.lineHeight+(u-1)*t.footerSpacing);let T=0;const x=function(E){v=Math.max(v,e.measureText(E).width+T)};return e.save(),e.font=c.string,U(s.title,x),e.font=l.string,U(s.beforeBody.concat(s.afterBody),x),T=t.displayColors?r+2+t.boxPadding:0,U(i,E=>{U(E.before,x),U(E.lines,x),U(E.after,x)}),T=0,e.font=h.string,U(s.footer,x),e.restore(),v+=f.width,{width:v,height:b}}function EM(s,t){const{y:e,height:i}=t;return es.height-i/2?\"bottom\":\"center\"}function xM(s,t,e,i){const{x:n,width:o}=i,r=e.caretSize+e.caretPadding;if(s===\"left\"&&n+o+r>t.width||s===\"right\"&&n-o-r<0)return!0}function CM(s,t,e,i){const{x:n,width:o}=e,{width:r,chartArea:{left:a,right:l}}=s;let c=\"center\";return i===\"center\"?c=n<=(a+l)/2?\"left\":\"right\":n<=o/2?c=\"left\":n>=r-o/2&&(c=\"right\"),xM(c,s,t,e)&&(c=\"center\"),c}function wg(s,t,e){const i=e.yAlign||t.yAlign||EM(s,e);return{xAlign:e.xAlign||t.xAlign||CM(s,t,e,i),yAlign:i}}function AM(s,t){let{x:e,width:i}=s;return t===\"right\"?e-=i:t===\"center\"&&(e-=i/2),e}function wM(s,t,e){let{y:i,height:n}=s;return t===\"top\"?i+=e:t===\"bottom\"?i-=n+e:i-=n/2,i}function kg(s,t,e,i){const{caretSize:n,caretPadding:o,cornerRadius:r}=s,{xAlign:a,yAlign:l}=e,c=n+o,{topLeft:h,topRight:d,bottomLeft:u,bottomRight:p}=$i(r);let f=AM(t,a);const b=wM(t,l,c);return l===\"center\"?a===\"left\"?f+=c:a===\"right\"&&(f-=c):a===\"left\"?f-=Math.max(h,u)+n:a===\"right\"&&(f+=Math.max(d,p)+n),{x:dt(f,0,i.width-t.width),y:dt(b,0,i.height-t.height)}}function ha(s,t,e){const i=pt(e.padding);return t===\"center\"?s.x+s.width/2:t===\"right\"?s.x+s.width-i.right:s.x+i.left}function Sg(s){return Ce([],$e(s))}function kM(s,t,e){return ni(s,{tooltip:t,tooltipItems:e,type:\"tooltip\"})}function Og(s,t){const e=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return e?s.override(e):s}class ih extends Xt{constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart||t._chart,this._chart=this.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,i=this.options.setContext(this.getContext()),n=i.enabled&&e.options.animation&&i.animations,o=new Pc(this.chart,n);return n._cacheable&&(this._cachedAnimations=Object.freeze(o)),o}getContext(){return this.$context||(this.$context=kM(this.chart.getContext(),this,this._tooltipItems))}getTitle(t,e){const{callbacks:i}=e,n=i.beforeTitle.apply(this,[t]),o=i.title.apply(this,[t]),r=i.afterTitle.apply(this,[t]);let a=[];return a=Ce(a,$e(n)),a=Ce(a,$e(o)),a=Ce(a,$e(r)),a}getBeforeBody(t,e){return Sg(e.callbacks.beforeBody.apply(this,[t]))}getBody(t,e){const{callbacks:i}=e,n=[];return U(t,o=>{const r={before:[],lines:[],after:[]},a=Og(i,o);Ce(r.before,$e(a.beforeLabel.call(this,o))),Ce(r.lines,a.label.call(this,o)),Ce(r.after,$e(a.afterLabel.call(this,o))),n.push(r)}),n}getAfterBody(t,e){return Sg(e.callbacks.afterBody.apply(this,[t]))}getFooter(t,e){const{callbacks:i}=e,n=i.beforeFooter.apply(this,[t]),o=i.footer.apply(this,[t]),r=i.afterFooter.apply(this,[t]);let a=[];return a=Ce(a,$e(n)),a=Ce(a,$e(o)),a=Ce(a,$e(r)),a}_createItems(t){const e=this._active,i=this.chart.data,n=[],o=[],r=[];let a=[],l,c;for(l=0,c=e.length;lt.filter(h,d,u,i))),t.itemSort&&(a=a.sort((h,d)=>t.itemSort(h,d,i))),U(a,h=>{const d=Og(t.callbacks,h);n.push(d.labelColor.call(this,h)),o.push(d.labelPointStyle.call(this,h)),r.push(d.labelTextColor.call(this,h))}),this.labelColors=n,this.labelPointStyles=o,this.labelTextColors=r,this.dataPoints=a,a}update(t,e){const i=this.options.setContext(this.getContext()),n=this._active;let o,r=[];if(!n.length)this.opacity!==0&&(o={opacity:0});else{const a=to[i.position].call(this,n,this._eventPosition);r=this._createItems(i),this.title=this.getTitle(r,i),this.beforeBody=this.getBeforeBody(r,i),this.body=this.getBody(r,i),this.afterBody=this.getAfterBody(r,i),this.footer=this.getFooter(r,i);const l=this._size=Ag(this,i),c=Object.assign({},a,l),h=wg(this.chart,i,c),d=kg(i,c,h,this.chart);this.xAlign=h.xAlign,this.yAlign=h.yAlign,o={opacity:1,x:d.x,y:d.y,width:l.width,height:l.height,caretX:a.x,caretY:a.y}}this._tooltipItems=r,this.$context=void 0,o&&this._resolveAnimations().update(this,o),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,n){const o=this.getCaretPosition(t,i,n);e.lineTo(o.x1,o.y1),e.lineTo(o.x2,o.y2),e.lineTo(o.x3,o.y3)}getCaretPosition(t,e,i){const{xAlign:n,yAlign:o}=this,{caretSize:r,cornerRadius:a}=i,{topLeft:l,topRight:c,bottomLeft:h,bottomRight:d}=$i(a),{x:u,y:p}=t,{width:f,height:b}=e;let v,y,T,x,E,C;return o===\"center\"?(E=p+b/2,n===\"left\"?(v=u,y=v-r,x=E+r,C=E-r):(v=u+f,y=v+r,x=E-r,C=E+r),T=v):(n===\"left\"?y=u+Math.max(l,h)+r:n===\"right\"?y=u+f-Math.max(c,d)-r:y=this.caretX,o===\"top\"?(x=p,E=x-r,v=y-r,T=y+r):(x=p+b,E=x+r,v=y+r,T=y-r),C=x),{x1:v,x2:y,x3:T,y1:x,y2:E,y3:C}}drawTitle(t,e,i){const n=this.title,o=n.length;let r,a,l;if(o){const c=Os(i.rtl,this.x,this.width);for(t.x=ha(this,i.titleAlign,i),e.textAlign=c.textAlign(i.titleAlign),e.textBaseline=\"middle\",r=lt(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=r.string,l=0;lx!==0)?(t.beginPath(),t.fillStyle=o.multiKeyBackground,Nn(t,{x:v,y:b,w:c,h:l,radius:T}),t.fill(),t.stroke(),t.fillStyle=r.backgroundColor,t.beginPath(),Nn(t,{x:y,y:b+1,w:c-2,h:l-2,radius:T}),t.fill()):(t.fillStyle=o.multiKeyBackground,t.fillRect(v,b,c,l),t.strokeRect(v,b,c,l),t.fillStyle=r.backgroundColor,t.fillRect(y,b+1,c-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:n}=this,{bodySpacing:o,bodyAlign:r,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:h}=i,d=lt(i.bodyFont);let u=d.lineHeight,p=0;const f=Os(i.rtl,this.x,this.width),b=function(S){e.fillText(S,f.x(t.x+p),t.y+u/2),t.y+=u+o},v=f.textAlign(r);let y,T,x,E,C,A,w;for(e.textAlign=r,e.textBaseline=\"middle\",e.font=d.string,t.x=ha(this,v,i),e.fillStyle=i.bodyColor,U(this.beforeBody,b),p=a&&v!==\"right\"?r===\"center\"?c/2+h:c+2+h:0,E=0,A=n.length;E0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,n=i&&i.x,o=i&&i.y;if(n||o){const r=to[t.position].call(this,this._active,this._eventPosition);if(!r)return;const a=this._size=Ag(this,t),l=Object.assign({},r,this._size),c=wg(e,t,l),h=kg(t,l,c,e);(n._to!==h.x||o._to!==h.y)&&(this.xAlign=c.xAlign,this.yAlign=c.yAlign,this.width=a.width,this.height=a.height,this.caretX=r.x,this.caretY=r.y,this._resolveAnimations().update(this,h))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const n={width:this.width,height:this.height},o={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const r=pt(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(o,t,n,e),o_(t,e.textDirection),o.y+=r.top,this.drawTitle(o,t,e),this.drawBody(o,t,e),this.drawFooter(o,t,e),r_(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,n=t.map(({datasetIndex:a,index:l})=>{const c=this.chart.getDatasetMeta(a);if(!c)throw new Error(\"Cannot find a dataset at index \"+a);return{datasetIndex:a,element:c.data[l],index:l}}),o=!Fr(i,n),r=this._positionChanged(n,e);(o||r)&&(this._active=n,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const n=this.options,o=this._active||[],r=this._getActiveElements(t,o,e,i),a=this._positionChanged(r,t),l=e||!Fr(r,o)||a;return l&&(this._active=r,(n.enabled||n.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),l}_getActiveElements(t,e,i,n){const o=this.options;if(t.type===\"mouseout\")return[];if(!n)return e;const r=this.chart.getElementsAtEventForMode(t,o.mode,o,i);return o.reverse&&r.reverse(),r}_positionChanged(t,e){const{caretX:i,caretY:n,options:o}=this,r=to[o.position].call(this,t,e);return r!==!1&&(i!==r.x||n!==r.y)}}ih.positioners=to;var Ig={id:\"tooltip\",_element:ih,positioners:to,afterInit(s,t,e){e&&(s.tooltip=new ih({chart:s,options:e}))},beforeUpdate(s,t,e){s.tooltip&&s.tooltip.initialize(e)},reset(s,t,e){s.tooltip&&s.tooltip.initialize(e)},afterDraw(s){const t=s.tooltip;if(t&&t._willRender()){const e={tooltip:t};if(s.notifyPlugins(\"beforeTooltipDraw\",e)===!1)return;t.draw(s.ctx),s.notifyPlugins(\"afterTooltipDraw\",e)}},afterEvent(s,t){if(s.tooltip){const e=t.replay;s.tooltip.handleEvent(t.event,e,t.inChartArea)&&(t.changed=!0)}},defaults:{enabled:!0,external:null,position:\"average\",backgroundColor:\"rgba(0,0,0,0.8)\",titleColor:\"#fff\",titleFont:{weight:\"bold\"},titleSpacing:2,titleMarginBottom:6,titleAlign:\"left\",bodyColor:\"#fff\",bodySpacing:2,bodyFont:{},bodyAlign:\"left\",footerColor:\"#fff\",footerSpacing:2,footerMarginTop:6,footerFont:{weight:\"bold\"},footerAlign:\"left\",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(s,t)=>t.bodyFont.size,boxWidth:(s,t)=>t.bodyFont.size,multiKeyBackground:\"#fff\",displayColors:!0,boxPadding:0,borderColor:\"rgba(0,0,0,0)\",borderWidth:0,animation:{duration:400,easing:\"easeOutQuart\"},animations:{numbers:{type:\"number\",properties:[\"x\",\"y\",\"width\",\"height\",\"caretX\",\"caretY\"]},opacity:{easing:\"linear\",duration:200}},callbacks:{beforeTitle:Oe,title(s){if(s.length>0){const t=s[0],e=t.chart.data.labels,i=e?e.length:0;if(this&&this.options&&this.options.mode===\"dataset\")return t.dataset.label||\"\";if(t.label)return t.label;if(i>0&&t.dataIndexs!==\"filter\"&&s!==\"itemSort\"&&s!==\"external\",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:\"animation\"}},additionalOptionScopes:[\"interaction\"]},Dg=Object.freeze({__proto__:null,Decimation:dg,Filler:vg,Legend:Eg,SubTitle:Cg,Title:xg,Tooltip:Ig});const SM=(s,t,e,i)=>(typeof t==\"string\"?(e=s.push(t)-1,i.unshift({index:e,label:t})):isNaN(t)&&(e=null),e);function OM(s,t,e,i){const n=s.indexOf(t);if(n===-1)return SM(s,t,e,i);const o=s.lastIndexOf(t);return n!==o?e:n}const IM=(s,t)=>s===null?null:dt(Math.round(s),0,t);class eo extends oi{constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const i=this.getLabels();for(const{index:n,label:o}of e)i[n]===o&&i.splice(n,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(H(t))return null;const i=this.getLabels();return e=isFinite(e)&&i[e]===t?e:OM(i,t,B(e,t),this._addedLabels),IM(e,i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:n}=this.getMinMax(!0);this.options.bounds===\"ticks\"&&(t||(i=0),e||(n=this.getLabels().length-1)),this.min=i,this.max=n}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,n=[];let o=this.getLabels();o=t===0&&e===o.length-1?o:o.slice(t,e+1),this._valueRange=Math.max(o.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let r=t;r<=e;r++)n.push({value:r});return n}getLabelForValue(t){const e=this.getLabels();return t>=0&&te.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}}eo.id=\"category\",eo.defaults={ticks:{callback:eo.prototype.getLabelForValue}};function DM(s,t){const e=[],{bounds:n,step:o,min:r,max:a,precision:l,count:c,maxTicks:h,maxDigits:d,includeBounds:u}=s,p=o||1,f=h-1,{min:b,max:v}=t,y=!H(r),T=!H(a),x=!H(c),E=(v-b)/(d+1);let C=Tf((v-b)/f/p)*p,A,w,S,k;if(C<1e-14&&!y&&!T)return[{value:b},{value:v}];k=Math.ceil(v/C)-Math.floor(b/C),k>f&&(C=Tf(k*C/f/p)*p),H(l)||(A=Math.pow(10,l),C=Math.ceil(C*A)/A),n===\"ticks\"?(w=Math.floor(b/C)*C,S=Math.ceil(v/C)*C):(w=b,S=v),y&&T&&o&&MO((a-r)/o,C/1e3)?(k=Math.round(Math.min((a-r)/C,h)),C=(a-r)/k,w=r,S=a):x?(w=y?r:w,S=T?a:S,k=c-1,C=(S-w)/k):(k=(S-w)/C,In(k,Math.round(k),C/1e3)?k=Math.round(k):k=Math.ceil(k));const D=Math.max(xf(C),xf(w));A=Math.pow(10,H(l)?D:l),w=Math.round(w*A)/A,S=Math.round(S*A)/A;let I=0;for(y&&(u&&w!==r?(e.push({value:r}),wn=e?n:l,a=l=>o=i?o:l;if(t){const l=Ee(n),c=Ee(o);l<0&&c<0?a(0):l>0&&c>0&&r(0)}if(n===o){let l=1;(o>=Number.MAX_SAFE_INTEGER||n<=Number.MIN_SAFE_INTEGER)&&(l=Math.abs(o*.05)),a(o+l),t||r(n-l)}this.min=n,this.max=o}getTickLimit(){const t=this.options.ticks;let{maxTicksLimit:e,stepSize:i}=t,n;return i?(n=Math.ceil(this.max/i)-Math.floor(this.min/i)+1,n>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${i} would result generating up to ${n} ticks. Limiting to 1000.`),n=1e3)):(n=this.computeTickLimit(),e=e||11),e&&(n=Math.min(e,n)),n}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n={maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:e.includeBounds!==!1},o=this._range||this,r=DM(n,o);return t.bounds===\"ticks\"&&Ef(r,this,\"value\"),t.reverse?(r.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),r}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const n=(i-e)/Math.max(t.length-1,1)/2;e-=n,i+=n}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return Bn(t,this.chart.options.locale,this.options.ticks.format)}}class ua extends da{determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=rt(t)?t:0,this.max=rt(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=se(this.options.ticks.minRotation),n=(t?Math.sin(i):Math.cos(i))||.001,o=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,o.lineHeight/n))}getPixelForValue(t){return t===null?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}ua.id=\"linear\",ua.defaults={ticks:{callback:Yn.formatters.numeric}};function Lg(s){return s/Math.pow(10,Math.floor(Yt(s)))===1}function MM(s,t){const e=Math.floor(Yt(t.max)),i=Math.ceil(t.max/Math.pow(10,e)),n=[];let o=zt(s.min,Math.pow(10,Math.floor(Yt(t.min)))),r=Math.floor(Yt(o)),a=Math.floor(o/Math.pow(10,r)),l=r<0?Math.pow(10,Math.abs(r)):1;do n.push({value:o,major:Lg(o)}),++a,a===10&&(a=1,++r,l=r>=0?1:l),o=Math.round(a*Math.pow(10,r)*l)/l;while(r0?i:null}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=rt(t)?Math.max(0,t):null,this.max=rt(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,n=this.max;const o=l=>i=t?i:l,r=l=>n=e?n:l,a=(l,c)=>Math.pow(10,Math.floor(Yt(l))+c);i===n&&(i<=0?(o(1),r(10)):(o(a(i,-1)),r(a(n,1)))),i<=0&&o(a(n,-1)),n<=0&&r(a(i,1)),this._zero&&this.min!==this._suggestedMin&&i===a(this.min,0)&&o(a(i,-1)),this.min=i,this.max=n}buildTicks(){const t=this.options,e={min:this._userMin,max:this._userMax},i=MM(e,this);return t.bounds===\"ticks\"&&Ef(i,this,\"value\"),t.reverse?(i.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),i}getLabelForValue(t){return t===void 0?\"0\":Bn(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=Yt(t),this._valueRange=Yt(this.max)-Yt(t)}getPixelForValue(t){return(t===void 0||t===0)&&(t=this.min),t===null||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(Yt(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}pa.id=\"logarithmic\",pa.defaults={ticks:{callback:Yn.formatters.logarithmic,major:{enabled:!0}}};function sh(s){const t=s.ticks;if(t.display&&s.display){const e=pt(t.backdropPadding);return B(t.font&&t.font.size,F.font.size)+e.height}return 0}function LM(s,t,e){return e=Q(e)?e:[e],{w:hI(s,t.string,e),h:e.length*t.lineHeight}}function $g(s,t,e,i,n){return s===i||s===n?{start:t-e/2,end:t+e/2}:sn?{start:t-e,end:t}:{start:t,end:t+e}}function $M(s){const t={l:s.left+s._padding.left,r:s.right-s._padding.right,t:s.top+s._padding.top,b:s.bottom-s._padding.bottom},e=Object.assign({},t),i=[],n=[],o=s._pointLabels.length,r=s.options.pointLabels,a=r.centerPointLabels?it/o:0;for(let l=0;lt.r&&(a=(i.end-t.r)/o,s.r=Math.max(s.r,t.r+a)),n.startt.b&&(l=(n.end-t.b)/r,s.b=Math.max(s.b,t.b+l))}function PM(s,t,e){const i=[],n=s._pointLabels.length,o=s.options,r=sh(o)/2,a=s.drawingArea,l=o.pointLabels.centerPointLabels?it/n:0;for(let c=0;c270||e<90)&&(s-=t),s}function VM(s,t){const{ctx:e,options:{pointLabels:i}}=s;for(let n=t-1;n>=0;n--){const o=i.setContext(s.getPointLabelContext(n)),r=lt(o.font),{x:a,y:l,textAlign:c,left:h,top:d,right:u,bottom:p}=s._pointLabelItems[n],{backdropColor:f}=o;if(!H(f)){const b=$i(o.borderRadius),v=pt(o.backdropPadding);e.fillStyle=f;const y=h-v.left,T=d-v.top,x=u-h+v.width,E=p-d+v.height;Object.values(b).some(C=>C!==0)?(e.beginPath(),Nn(e,{x:y,y:T,w:x,h:E,radius:b}),e.fill()):e.fillRect(y,T,x,E)}Li(e,s._pointLabels[n],a,l+r.lineHeight/2,r,{color:o.color,textAlign:c,textBaseline:\"middle\"})}}function Rg(s,t,e,i){const{ctx:n}=s;if(e)n.arc(s.xCenter,s.yCenter,t,0,q);else{let o=s.getPointPosition(0,t);n.moveTo(o.x,o.y);for(let r=1;r{const n=G(this.options.pointLabels.callback,[e,i],this);return n||n===0?n:\"\"}).filter((e,i)=>this.chart.getDataVisibility(i))}fit(){const t=this.options;t.display&&t.pointLabels.display?$M(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,n){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-n)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,n))}getIndexAngle(t){const e=q/(this._pointLabels.length||1),i=this.options.startAngle||0;return Vt(t*e+se(i))}getDistanceFromCenterForValue(t){if(H(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(H(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t{if(h!==0){a=this.getDistanceFromCenterForValue(c.value);const d=n.setContext(this.getContext(h-1));FM(this,d,a,o)}}),i.display){for(t.save(),r=o-1;r>=0;r--){const c=i.setContext(this.getPointLabelContext(r)),{color:h,lineWidth:d}=c;!d||!h||(t.lineWidth=d,t.strokeStyle=h,t.setLineDash(c.borderDash),t.lineDashOffset=c.borderDashOffset,a=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(r,a),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const n=this.getIndexAngle(0);let o,r;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(n),t.textAlign=\"center\",t.textBaseline=\"middle\",this.ticks.forEach((a,l)=>{if(l===0&&!e.reverse)return;const c=i.setContext(this.getContext(l)),h=lt(c.font);if(o=this.getDistanceFromCenterForValue(this.ticks[l].value),c.showLabelBackdrop){t.font=h.string,r=t.measureText(a.label).width,t.fillStyle=c.backdropColor;const d=pt(c.backdropPadding);t.fillRect(-r/2-d.left,-o-h.size/2-d.top,r+d.width,h.size+d.height)}Li(t,a.label,0,-o,h,{color:c.color})}),t.restore()}drawTitle(){}}$s.id=\"radialLinear\",$s.defaults={display:!0,animate:!0,position:\"chartArea\",angleLines:{display:!0,lineWidth:1,borderDash:[],borderDashOffset:0},grid:{circular:!1},startAngle:0,ticks:{showLabelBackdrop:!0,callback:Yn.formatters.numeric},pointLabels:{backdropColor:void 0,backdropPadding:2,display:!0,font:{size:10},callback(s){return s},padding:5,centerPointLabels:!1}},$s.defaultRoutes={\"angleLines.color\":\"borderColor\",\"pointLabels.color\":\"color\",\"ticks.color\":\"color\"},$s.descriptors={angleLines:{_fallback:\"grid\"}};const fa={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},It=Object.keys(fa);function zM(s,t){return s-t}function Pg(s,t){if(H(t))return null;const e=s._adapter,{parser:i,round:n,isoWeekday:o}=s._parseOpts;let r=t;return typeof i==\"function\"&&(r=i(r)),rt(r)||(r=typeof i==\"string\"?e.parse(r,i):e.parse(r)),r===null?null:(n&&(r=n===\"week\"&&(As(o)||o===!0)?e.startOf(r,\"isoWeek\",o):e.startOf(r,n)),+r)}function Ng(s,t,e,i){const n=It.length;for(let o=It.indexOf(s);o=It.indexOf(e);o--){const r=It[o];if(fa[r].common&&s._adapter.diff(n,i,r)>=t-1)return r}return It[e?It.indexOf(e):0]}function YM(s){for(let t=It.indexOf(s)+1,e=It.length;t=t?e[i]:e[n];s[o]=!0}}function KM(s,t,e,i){const n=s._adapter,o=+n.startOf(t[0].value,i),r=t[t.length-1].value;let a,l;for(a=o;a<=r;a=+n.add(a,1,i))l=e[a],l>=0&&(t[l].major=!0);return t}function Hg(s,t,e){const i=[],n={},o=t.length;let r,a;for(r=0;r+t.value))}initOffsets(t){let e=0,i=0,n,o;this.options.offset&&t.length&&(n=this.getDecimalForValue(t[0]),t.length===1?e=1-n:e=(this.getDecimalForValue(t[1])-n)/2,o=this.getDecimalForValue(t[t.length-1]),t.length===1?i=o:i=(o-this.getDecimalForValue(t[t.length-2]))/2);const r=t.length<3?.5:.25;e=dt(e,0,r),i=dt(i,0,r),this._offsets={start:e,end:i,factor:1/(e+1+i)}}_generate(){const t=this._adapter,e=this.min,i=this.max,n=this.options,o=n.time,r=o.unit||Ng(o.minUnit,e,i,this._getLabelCapacity(e)),a=B(o.stepSize,1),l=r===\"week\"?o.isoWeekday:!1,c=As(l)||l===!0,h={};let d=e,u,p;if(c&&(d=+t.startOf(d,\"isoWeek\",l)),d=+t.startOf(d,c?\"day\":r),t.diff(i,e,r)>1e5*a)throw new Error(e+\" and \"+i+\" are too far apart with stepSize of \"+a+\" \"+r);const f=n.ticks.source===\"data\"&&this.getDataTimestamps();for(u=d,p=0;ub-v).map(b=>+b)}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}_tickFormatFunction(t,e,i,n){const o=this.options,r=o.time.displayFormats,a=this._unit,l=this._majorUnit,c=a&&r[a],h=l&&r[l],d=i[e],u=l&&h&&d&&d.major,p=this._adapter.format(t,n||(u?h:c)),f=o.ticks.callback;return f?G(f,[p,e,i],this):p}generateTickLabels(t){let e,i,n;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t=this._cache.data||[],e,i;if(t.length)return t;const n=this.getMatchingVisibleMetas();if(this._normalized&&n.length)return this._cache.data=n[0].controller.getAllParsedValues(this);for(e=0,i=n.length;e=s[i].pos&&t<=s[n].pos&&({lo:i,hi:n}=De(s,\"pos\",t)),{pos:o,time:a}=s[i],{pos:r,time:l}=s[n]):(t>=s[i].time&&t<=s[n].time&&({lo:i,hi:n}=De(s,\"time\",t)),{time:o,pos:a}=s[i],{time:r,pos:l}=s[n]);const c=r-o;return c?a+(l-a)*(t-o)/c:a}class ga extends Rs{constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=_a(e,this.min),this._tableRange=_a(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,n=[],o=[];let r,a,l,c,h;for(r=0,a=t.length;r=e&&c<=i&&n.push(c);if(n.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(r=0,a=n.length;re.right&&(i|=zg),te.bottom&&(i|=jg),i}function qM(s,t){for(var e=s.x0,i=s.y0,n=s.x1,o=s.y1,r=ma(e,i,t),a=ma(n,o,t),l,c,h;!(!(r|a)||r&a);)l=r||a,l&Yg?(c=e+(n-e)*(t.top-i)/(o-i),h=t.top):l&jg?(c=e+(n-e)*(t.bottom-i)/(o-i),h=t.bottom):l&zg?(h=i+(o-i)*(t.right-e)/(n-e),c=t.right):l&Wg&&(h=i+(o-i)*(t.left-e)/(n-e),c=t.left),l===r?(e=c,i=h,r=ma(e,i,t)):(n=c,o=h,a=ma(n,o,t));return{x0:e,x1:n,y0:i,y1:o}}function ba(s,t){var e=t.anchor,i=s,n,o;return t.clamp&&(i=qM(i,t.area)),e===\"start\"?(n=i.x0,o=i.y0):e===\"end\"?(n=i.x1,o=i.y1):(n=(i.x0+i.x1)/2,o=(i.y0+i.y1)/2),XM(n,o,s.vx,s.vy,t.align)}var va={arc:function(s,t){var e=(s.startAngle+s.endAngle)/2,i=Math.cos(e),n=Math.sin(e),o=s.innerRadius,r=s.outerRadius;return ba({x0:s.x+i*o,y0:s.y+n*o,x1:s.x+i*r,y1:s.y+n*r,vx:i,vy:n},t)},point:function(s,t){var e=nh(s,t.origin),i=e.x*s.options.radius,n=e.y*s.options.radius;return ba({x0:s.x-i,y0:s.y-n,x1:s.x+i,y1:s.y+n,vx:e.x,vy:e.y},t)},bar:function(s,t){var e=nh(s,t.origin),i=s.x,n=s.y,o=0,r=0;return s.horizontal?(i=Math.min(s.x,s.base),o=Math.abs(s.base-s.x)):(n=Math.min(s.y,s.base),r=Math.abs(s.base-s.y)),ba({x0:i,y0:n+r,x1:i+o,y1:n,vx:e.x,vy:e.y},t)},fallback:function(s,t){var e=nh(s,t.origin);return ba({x0:s.x,y0:s.y,x1:s.x+(s.width||0),y1:s.y+(s.height||0),vx:e.x,vy:e.y},t)}},Re=io.rasterize;function ZM(s){var t=s.borderWidth||0,e=s.padding,i=s.size.height,n=s.size.width,o=-n/2,r=-i/2;return{frame:{x:o-e.left-t,y:r-e.top-t,w:n+e.width+t*2,h:i+e.height+t*2},text:{x:o,y:r,w:n,h:i}}}function QM(s,t){var e=t.chart.getDatasetMeta(t.datasetIndex).vScale;if(!e)return null;if(e.xCenter!==void 0&&e.yCenter!==void 0)return{x:e.xCenter,y:e.yCenter};var i=e.getBasePixel();return s.horizontal?{x:i,y:null}:{x:null,y:i}}function JM(s){return s instanceof Ds?va.arc:s instanceof Ms?va.point:s instanceof Ls?va.bar:va.fallback}function tL(s,t,e,i,n,o){var r=Math.PI/2;if(o){var a=Math.min(o,n/2,i/2),l=t+a,c=e+a,h=t+i-a,d=e+n-a;s.moveTo(t,c),li.x+i.w+e*2||s.y>i.y+i.h+e*2)},intersects:function(s){var t=this._points(),e=s._points(),i=[ya(t[0],t[1]),ya(t[0],t[3])],n,o,r;for(this._rotation!==s._rotation&&i.push(ya(e[0],e[1]),ya(e[0],e[3])),n=0;n=0;--e)for(n=s[e].$layout,i=e-1;i>=0&&n._visible;--i)o=s[i].$layout,o._visible&&n._box.intersects(o._box)&&t(n,o);return s}function lL(s){var t,e,i,n,o,r,a;for(t=0,e=s.length;tl.getProps([c],!0)[c]}),o=i.geometry(),r=Gg(a,i.model(),o),n._box.update(r,o,i.rotation()));return aL(s,function(l,c){var h=l._hidable,d=c._hidable;h&&d||d?c._visible=!1:h&&(l._visible=!1)})}var no={prepare:function(s){var t=[],e,i,n,o,r;for(e=0,n=s.length;e=0;--e)if(i=s[e].$layout,i&&i._visible&&i._box.contains(t))return s[e];return null},draw:function(s,t){var e,i,n,o,r,a;for(e=0,i=t.length;e:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\\[0\\.5rem\\]{border-radius:.5rem}.rounded-\\[0\\.6rem\\]{border-radius:.6rem}.rounded-\\[0\\.25rem\\]{border-radius:.25rem}.rounded-\\[10px\\]{border-radius:10px}.rounded-\\[16px\\]{border-radius:16px}.rounded-\\[50\\%\\]{border-radius:50%}.rounded-\\[100\\%\\]{border-radius:100%}.rounded-\\[999px\\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\\[0\\.6rem\\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\\[0\\.25rem\\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\\[0\\.25rem\\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\\!border-\\[3px\\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\\[\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[0\\.15em\\]{border-style:var(--tw-border-style);border-width:.15em}.border-\\[0\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.border-\\[14px\\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\\[0\\.125rem\\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\\!border-\\[\\#14a44d\\]{border-color:#14a44d!important}.\\!border-\\[\\#b2b3b4\\]{border-color:#b2b3b4!important}.\\!border-\\[\\#dc4c64\\]{border-color:#dc4c64!important}.border-\\[\\#3b71ca\\]{border-color:#3b71ca}.border-\\[\\#14a44d\\]{border-color:#14a44d}.border-\\[\\#dc4c64\\]{border-color:#dc4c64}.border-\\[\\#eee\\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\\!bg-\\[\\#858585\\]{background-color:#858585!important}.\\!bg-danger-100{background-color:#fae5e9!important}.\\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\\!bg-primary-100{background-color:#e3ebf7!important}.\\!bg-success-100{background-color:#d6fae4!important}.bg-\\[\\#000000e6\\]{background-color:#000000e6}.bg-\\[\\#3b71ca\\]{background-color:#3b71ca}.bg-\\[\\#6d6d6d\\]{background-color:#6d6d6d}.bg-\\[\\#00000012\\]{background-color:#00000012}.bg-\\[\\#00000066\\]{background-color:#0006}.bg-\\[\\#aaa\\]{background-color:#aaa}.bg-\\[\\#eceff1\\]{background-color:#eceff1}.bg-\\[\\#eee\\]{background-color:#eee}.bg-\\[rgba\\(0\\,0\\,0\\,0\\.4\\)\\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\\[\\#336dec\\]{fill:#336dec}.fill-\\[\\#afafaf\\]{fill:#afafaf}.fill-current{fill:currentColor}.\\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\\[1rem\\]{padding:1rem}.p-\\[5px\\]{padding:5px}.p-\\[auto\\]{padding:auto}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\\[0\\.4rem\\]{padding-inline:.4rem}.px-\\[1\\.4rem\\]{padding-inline:1.4rem}.px-\\[10px\\]{padding-inline:10px}.px-\\[12px\\]{padding-inline:12px}.px-\\[auto\\]{padding-inline:auto}.\\!py-0{padding-block:calc(var(--spacing) * 0)!important}.\\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:calc(var(--spacing) * 0)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\\[0\\.4rem\\]{padding-block:.4rem}.py-\\[0\\.32rem\\]{padding-block:.32rem}.py-\\[0\\.33rem\\]{padding-block:.33rem}.py-\\[0\\.4375rem\\]{padding-block:.4375rem}.py-\\[1px\\]{padding-block:1px}.py-\\[5px\\]{padding-block:5px}.py-\\[10px\\]{padding-block:10px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\\[0\\.37rem\\]{padding-top:.37rem}.pt-\\[6px\\]{padding-top:6px}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\\[24px\\]{padding-right:24px}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\\[5px\\]{padding-bottom:5px}.pl-0{padding-left:calc(var(--spacing) * 0)}.pl-1{padding-left:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\\[1\\.5rem\\]{padding-left:1.5rem}.pl-\\[8px\\]{padding-left:8px}.pl-\\[18px\\]{padding-left:18px}.pl-\\[50px\\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\\[-0\\.125em\\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.8rem\\]{font-size:.8rem}.text-\\[0\\.9rem\\]{font-size:.9rem}.text-\\[1\\.1rem\\]{font-size:1.1rem}.text-\\[2\\.5rem\\]{font-size:2.5rem}.text-\\[3\\.75rem\\]{font-size:3.75rem}.text-\\[10px\\]{font-size:10px}.text-\\[12px\\]{font-size:12px}.text-\\[13px\\]{font-size:13px}.text-\\[16px\\]{font-size:16px}.text-\\[18px\\]{font-size:18px}.text-\\[34px\\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\\[1\\.2\\]{--tw-leading:1.2;line-height:1.2}.leading-\\[1\\.5\\]{--tw-leading:1.5;line-height:1.5}.leading-\\[1\\.6\\]{--tw-leading:1.6;line-height:1.6}.leading-\\[2\\.15\\]{--tw-leading:2.15;line-height:2.15}.leading-\\[40px\\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\\[-0\\.00833em\\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\\[\\.1rem\\],.tracking-\\[0\\.1rem\\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\\[1\\.7px\\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\\!text-\\[\\#14a44d\\]{color:#14a44d!important}.\\!text-\\[\\#dc4c64\\]{color:#dc4c64!important}.\\!text-danger-700{color:#b0233a!important}.\\!text-gray-50{color:var(--color-gray-50)!important}.\\!text-primary{color:#3b71ca!important}.\\!text-primary-700{color:#285192!important}.\\!text-success-700{color:#0e7537!important}.text-\\[\\#3b71ca\\]{color:#3b71ca}.text-\\[\\#4f4f4f\\]{color:#4f4f4f}.text-\\[\\#14a44d\\]{color:#14a44d}.text-\\[\\#212529\\]{color:#212529}.text-\\[\\#b3afaf\\]{color:#b3afaf}.text-\\[\\#b3b3b3\\]{color:#b3b3b3}.text-\\[\\#dc4c64\\]{color:#dc4c64}.text-\\[\\#ffffff8a\\]{color:#ffffff8a}.text-\\[rgb\\(220\\,76\\,100\\)\\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\\/\\[64\\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\\/\\[64\\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\\!opacity-0{opacity:0!important}.\\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\\[\\.53\\]{opacity:.53}.opacity-\\[\\.54\\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_0px_3px_0_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_2px_2px_0_rgba\\(0\\,0\\,0\\,0\\.04\\)\\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_2px_5px_0_rgba\\(0\\,0\\,0\\,0\\.16\\)\\,_0_2px_10px_0_rgba\\(0\\,0\\,0\\,0\\.12\\)\\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_4px_9px_-4px_\\#3b71ca\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_10px_15px_-3px_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_4px_6px_-2px_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0px_2px_15px_-3px_rgba\\(0\\,0\\,0\\,\\.07\\)\\,_0px_10px_20px_-2px_rgba\\(0\\,0\\,0\\,\\.04\\)\\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\\/login,.shadow\\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,_opacity\\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,box-shadow\\,border\\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[height\\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[opacity\\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,_opacity\\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,height\\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[width\\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\\[0ms\\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\\[150ms\\]{--tw-duration:.15s;transition-duration:.15s}.duration-\\[200ms\\]{--tw-duration:.2s;transition-duration:.2s}.duration-\\[250ms\\]{--tw-duration:.25s;transition-duration:.25s}.duration-\\[350ms\\]{--tw-duration:.35s;transition-duration:.35s}.duration-\\[400ms\\]{--tw-duration:.4s;transition-duration:.4s}.duration-\\[1000ms\\]{--tw-duration:1s;transition-duration:1s}.ease-\\[cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\,_cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\\[cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)\\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\)\\],.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\.0\\)\\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\\[ease\\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\\!\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)!important}.\\[bash\\:1221\\]{bash:1221}.\\[check\\:5737\\]{check:5737}.\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)}.\\[direction\\:ltr\\]{direction:ltr}.\\[drm\\:hdmiphy_enable\\.part\\.0\\]{drm:hdmiphy enable.part0}.\\[drm\\:samsung_dsim_host_attach\\]{drm:samsung dsim host attach}.\\[overflow-anchor\\:none\\]{overflow-anchor:none}.\\[pid\\:5118\\,cpu4\\,QThread\\,0\\]{pid:5118,cpu4,QThread,0}.\\[pid\\:5118\\,cpu4\\,QThread\\,1\\]{pid:5118,cpu4,QThread,1}.\\[pid\\:5118\\,cpu4\\,QThread\\,2\\]{pid:5118,cpu4,QThread,2}.\\[pid\\:5118\\,cpu4\\,QThread\\,3\\]{pid:5118,cpu4,QThread,3}.\\[pid\\:5118\\,cpu4\\,QThread\\,4\\]{pid:5118,cpu4,QThread,4}.\\[pid\\:5118\\,cpu4\\,QThread\\,9\\]{pid:5118,cpu4,QThread,9}.\\[transition\\:background-color_\\.2s_linear\\,_height_\\.2s_ease-in-out\\]{transition:background-color .2s linear,height .2s ease-in-out}.\\[transition\\:background-color_\\.2s_linear\\,_width_\\.2s_ease-in-out\\,_opacity\\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\\[transition\\:background-color_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,box-shadow_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,border_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/ps\\:opacity-60:is(:where(.group\\/ps):hover *){opacity:.6}.group-hover\\/x\\:h-\\[11px\\]:is(:where(.group\\/x):hover *){height:11px}.group-hover\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):hover *){background-color:#999}.group-hover\\/y\\:w-\\[11px\\]:is(:where(.group\\/y):hover *){width:11px}.group-hover\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):hover *){background-color:#999}}.group-focus\\/ps\\:opacity-60:is(:where(.group\\/ps):focus *){opacity:.6}.group-focus\\/ps\\:opacity-100:is(:where(.group\\/ps):focus *){opacity:1}.group-focus\\/x\\:h-\\[0\\.6875rem\\]:is(:where(.group\\/x):focus *){height:.6875rem}.group-focus\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):focus *){background-color:#999}.group-focus\\/y\\:w-\\[0\\.6875rem\\]:is(:where(.group\\/y):focus *){width:.6875rem}.group-focus\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):focus *){background-color:#999}.group-active\\/ps\\:opacity-100:is(:where(.group\\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:calc(var(--spacing) * 0)}.group-data-te-collapse-collapsed\\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\\:fill-\\[\\#212529\\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\\[te-input-focused\\]\\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-focused\\]\\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-focused\\]\\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-focused\\]\\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-focused\\]\\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-focused\\]\\:border-\\[\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\\[te-input-focused\\]\\:border-\\[\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\\[te-input-focused\\]\\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\\[te-input-focused\\]\\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\\[te-input-focused\\]\\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-state-active\\]\\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-state-active\\]\\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-state-active\\]\\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-state-active\\]\\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-state-active\\]\\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-state-active\\]\\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\\[te-select-option-group-ref\\]\\/opt\\:pl-7:is(:where(.group\\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\\[te-was-validated\\]\\/validation\\:mb-4:is(:where(.group\\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-x *){display:block}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-x *){background-color:#0000}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-y *){display:block}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-y *){background-color:#0000}.group-\\[\\&\\.ps--clicking\\]\\/x\\:h-\\[11px\\]:is(:where(.group\\/x).ps--clicking *){height:11px}.group-\\[\\&\\.ps--clicking\\]\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--clicking\\]\\/y\\:w-\\[11px\\]:is(:where(.group\\/y).ps--clicking *){width:11px}.group-\\[\\&\\.ps--clicking\\]\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--scrolling-x\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-x *),.group-\\[\\&\\.ps--scrolling-y\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-y *){opacity:.6}.group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:text-green-600:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:text-\\[rgb\\(220\\,76\\,100\\)\\]:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:scale-\\[0\\.8\\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\\:\\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\\[te-input-focused\\]\\:\\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\\[te-input-focused\\]\\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\\:bg-transparent ::selection{background-color:#0000}.selection\\:bg-transparent::selection{background-color:#0000}.before\\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\\:absolute:before{content:var(--tw-content);position:absolute}.before\\:h-\\[0\\.875rem\\]:before{content:var(--tw-content);height:.875rem}.before\\:w-\\[0\\.875rem\\]:before{content:var(--tw-content);width:.875rem}.before\\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\\:opacity-0:before{content:var(--tw-content);opacity:0}.before\\:shadow-\\[0px_0px_0px_13px_transparent\\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\\:content-\\[\\'\\'\\]:before{--tw-content:\"\";content:var(--tw-content)}.odd\\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\\:\\!border-\\[\\#14a44d\\]:checked{border-color:#14a44d!important}.checked\\:\\!border-\\[\\#dc4c64\\]:checked{border-color:#dc4c64!important}.checked\\:border-primary:checked{border-color:#3b71ca}.checked\\:\\!bg-\\[\\#14a44d\\]:checked{background-color:#14a44d!important}.checked\\:\\!bg-\\[\\#dc4c64\\]:checked{background-color:#dc4c64!important}.checked\\:bg-primary:checked{background-color:#3b71ca}.checked\\:before\\:opacity-\\[0\\.16\\]:checked:before{content:var(--tw-content);opacity:.16}.checked\\:after\\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\\:after\\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\\:after\\:ml-\\[0\\.25rem\\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\\:after\\:block:checked:after{content:var(--tw-content);display:block}.checked\\:after\\:h-\\[0\\.8125rem\\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\\:after\\:w-\\[0\\.375rem\\]:checked:after{content:var(--tw-content);width:.375rem}.checked\\:after\\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\\:after\\:border-\\[0\\.125rem\\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:after\\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:after\\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:after\\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:after\\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:after\\:\\!bg-\\[\\#14a44d\\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\\:after\\:\\!bg-\\[\\#dc4c64\\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\\:after\\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\\:after\\:content-\\[\\'\\'\\]:checked:after{--tw-content:\"\";content:var(--tw-content)}.empty\\:hidden:empty{display:none}@media (hover:hover){.hover\\:z-2:hover{z-index:2}.hover\\:cursor-pointer:hover{cursor:pointer}.hover\\:rounded-\\[50\\%\\]:hover{border-radius:50%}.hover\\:\\!bg-\\[\\#eee\\]:hover{background-color:#eee!important}.hover\\:bg-\\[\\#00000014\\]:hover{background-color:#00000014}.hover\\:bg-\\[\\#00000026\\]:hover{background-color:#00000026}.hover\\:bg-\\[unset\\]:hover{background-color:unset}.hover\\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\\:bg-primary-600:hover{background-color:#3061af}.hover\\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\\:fill-\\[\\#8b8b8b\\]:hover{fill:#8b8b8b}.hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.hover\\:text-\\[\\#8b8b8b\\]:hover{color:#8b8b8b}.hover\\:text-primary:hover{color:#3b71ca}.hover\\:text-primary-600:hover{color:#3061af}.hover\\:text-white:hover{color:var(--color-white)}.hover\\:\\!opacity-90:hover{opacity:.9!important}.hover\\:opacity-100:hover{opacity:1}.hover\\:\\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\\:before\\:opacity-\\[0\\.04\\]:hover:before{content:var(--tw-content);opacity:.04}.hover\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\\:z-3:focus{z-index:3}.focus\\:rounded-\\[50\\%\\]:focus{border-radius:50%}.focus\\:\\!border-\\[\\#14a44d\\]:focus{border-color:#14a44d!important}.focus\\:\\!border-\\[\\#dc4c64\\]:focus{border-color:#dc4c64!important}.focus\\:border-primary:focus{border-color:#3b71ca}.focus\\:\\!bg-\\[\\#eee\\]:focus{background-color:#eee!important}.focus\\:bg-\\[\\#00000014\\]:focus{background-color:#00000014}.focus\\:bg-\\[\\#00000026\\]:focus{background-color:#00000026}.focus\\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\\:bg-primary-600:focus{background-color:#3061af}.focus\\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.focus\\:text-gray-700:focus{color:var(--color-gray-700)}.focus\\:text-primary:focus{color:#3b71ca}.focus\\:text-primary-600:focus{color:#3061af}.focus\\:text-white:focus{color:var(--color-white)}.focus\\:\\!opacity-90:focus{opacity:.9!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#14a44d\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#dc4c64\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\\:transition-\\[border-color_0\\.2s\\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\\:placeholder\\:opacity-100:focus::placeholder{opacity:1}.focus\\:before\\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\\:before\\:opacity-\\[0\\.12\\]:focus:before{content:var(--tw-content);opacity:.12}.focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:after\\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\\:after\\:z-\\[1\\]:focus:after{content:var(--tw-content);z-index:1}.focus\\:after\\:block:focus:after{content:var(--tw-content);display:block}.focus\\:after\\:h-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);height:.875rem}.focus\\:after\\:w-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);width:.875rem}.focus\\:after\\:rounded-\\[0\\.125rem\\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\\:after\\:content-\\[\\'\\'\\]:focus:after{--tw-content:\"\";content:var(--tw-content)}.checked\\:focus\\:before\\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\\:focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\\:focus\\:after\\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\\:focus\\:after\\:ml-\\[0\\.25rem\\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\\:focus\\:after\\:h-\\[0\\.8125rem\\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\\:focus\\:after\\:w-\\[0\\.375rem\\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\\:focus\\:after\\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\\:focus\\:after\\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\\:focus\\:after\\:border-\\[0\\.125rem\\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:focus\\:after\\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:focus\\:after\\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:focus\\:after\\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:focus\\:after\\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:focus\\:after\\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\\:z-60:active{z-index:60}.active\\:bg-\\[\\#c4d4ef\\]:active{background-color:#c4d4ef}.active\\:bg-\\[\\#cacfd1\\]:active{background-color:#cacfd1}.active\\:bg-primary-700:active{background-color:#285192}.active\\:bg-primary-accent-200:active{background-color:#cedbee}.active\\:text-primary-700:active{color:#285192}.active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\\:grid[data-te-dropdown-show]{display:grid}.data-\\[data-te-autocomplete-option-disabled\\]\\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\\[data-te-autocomplete-option-disabled\\]\\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\\[popper-reference-hidden\\]\\:hidden[data-popper-reference-hidden]{display:none}.data-\\[te-active\\]\\:-top-\\[38px\\][data-te-active]{top:-38px}.data-\\[te-active\\]\\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-autocomplete-state-open\\]\\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-state-open\\]\\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\\[te-carousel-fade\\]\\:z-0[data-te-carousel-fade]{z-index:0}.data-\\[te-carousel-fade\\]\\:z-\\[1\\][data-te-carousel-fade]{z-index:1}.data-\\[te-carousel-fade\\]\\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\\[te-carousel-fade\\]\\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\\[te-carousel-fade\\]\\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\\[te-carousel-fade\\]\\:duration-\\[600ms\\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\\[te-datepicker-cell-disabled\\]\\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\\[te-datepicker-cell-disabled\\]\\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\\[te-datepicker-cell-disabled\\]\\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\\[te-datepicker-cell-disabled\\]\\:hover\\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\\[\\[data-te-datepicker-cell-focused\\]\\]\\:data-\\[te-datepicker-cell-selected\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\\[te-input-disabled\\]\\:cursor-default[data-te-input-disabled]{cursor:default}.data-\\[te-input-disabled\\]\\:bg-\\[\\#e9ecef\\][data-te-input-disabled]{background-color:#e9ecef}.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:block[data-te-input-state-active]{display:block}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\][data-te-input-state-active]{scale:.8}.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:placeholder\\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\\[te-select-open\\]\\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-select-open\\]\\:opacity-100[data-te-select-open]{opacity:1}.data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\\:transform-none{transform:none}.motion-reduce\\:animate-\\[spin_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spin}.motion-reduce\\:animate-\\[spinner-grow_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\\:animate-none{animation:none}.motion-reduce\\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\\:block{display:block}.sm\\:grid{display:grid}.sm\\:hidden{display:none}.sm\\:w-40{width:calc(var(--spacing) * 40)}.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\\:grid-cols-\\[10\\%_90\\%\\]{grid-template-columns:10% 90%}.sm\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\\:break-words{overflow-wrap:break-word}.sm\\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\\:order-none{order:0}.md\\:my-0{margin-block:calc(var(--spacing) * 0)}.md\\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:flex-row{flex-direction:row}.md\\:pr-1{padding-right:calc(var(--spacing) * 1)}.md\\:pr-\\[17px\\]{padding-right:17px}}@media (min-width:64rem){.lg\\:sticky{position:sticky}.lg\\:block{display:block}.lg\\:grid{display:grid}.lg\\:hidden{display:none}.lg\\:w-32{width:calc(var(--spacing) * 32)}.lg\\:w-36{width:calc(var(--spacing) * 36)}.lg\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\\:w-52{width:calc(var(--spacing) * 52)}.xl\\:grid-flow-col{grid-auto-flow:column}.xl\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:h-auto{height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[305px\\]{min-height:305px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[auto\\]{min-height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-w-\\[auto\\]{min-width:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!flex-row{flex-direction:row!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:flex-col{flex-direction:column}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!justify-around{justify-content:space-around!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:overflow-y-auto{overflow-y:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-lg{border-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-tr-none{border-top-right-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-none{border-bottom-left-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:p-\\[10px\\]{padding:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:pr-\\[10px\\]{padding-right:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-\\[3rem\\]{font-size:3rem}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\\:max-md\\:landscape\\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\\:max-md\\:landscape\\:h-8{height:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:h-\\[360px\\]{height:360px}.xs\\:max-md\\:landscape\\:h-full{height:100%}.xs\\:max-md\\:landscape\\:w-8{width:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:w-\\[475px\\]{width:475px}.xs\\:max-md\\:landscape\\:flex-row{flex-direction:row}}}}.rtl\\:\\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\\:\\!origin-\\[50\\%_50\\%_0\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\\:\\[direction\\:rtl\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\\:border-\\[\\#4f4f4f\\]{border-color:#4f4f4f}.dark\\:border-\\[\\#14a44d\\]{border-color:#14a44d}.dark\\:border-\\[\\#dc4c64\\]{border-color:#dc4c64}.dark\\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\\:border-primary-400{border-color:#8faee0}.dark\\:\\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\\:bg-\\[\\#4f4f4f\\]{background-color:#4f4f4f}.dark\\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\\:bg-primary-600{background-color:#3061af}.dark\\:bg-transparent{background-color:#0000}.dark\\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\\:bg-zinc-600\\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\\:bg-zinc-600\\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\\:fill-gray-400{fill:var(--color-gray-400)}.dark\\:\\!text-primary-400{color:#8faee0!important}.dark\\:text-gray-200{color:var(--color-gray-200)}.dark\\:text-gray-300{color:var(--color-gray-300)}.dark\\:text-neutral-200{color:var(--color-neutral-200)}.dark\\:text-neutral-300{color:var(--color-neutral-300)}.dark\\:text-neutral-400{color:var(--color-neutral-400)}.dark\\:text-primary-400{color:#8faee0}.dark\\:text-white{color:var(--color-white)}.dark\\:shadow-\\[0_4px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.5\\)\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\\:group-\\[\\[data-te-datepicker-cell-disabled\\]\\]\\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\\:peer-focus\\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\\:peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\\:placeholder\\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\\:checked\\:border-primary:checked{border-color:#3b71ca}.dark\\:checked\\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\\:hover\\:\\!bg-\\[\\#555\\]:hover{background-color:#555!important}.dark\\:hover\\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\\:hover\\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\\:hover\\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\\:hover\\:bg-white\\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:bg-white\\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:hover\\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\\:hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.dark\\:hover\\:text-primary-400:hover{color:#8faee0}.dark\\:hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\\:focus\\:\\!bg-\\[\\#555\\]:focus{background-color:#555!important}.dark\\:focus\\:bg-white\\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:focus\\:bg-white\\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.dark\\:focus\\:text-primary-400:focus{color:#8faee0}.dark\\:focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(255\\,255\\,255\\,0\\.4\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:disabled\\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\\:disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-buttons-timepicker\\]\\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\\:data-\\[te-input-disabled\\]\\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\\:block{display:block}.print\\:hidden{display:none}.print\\:border-none{--tw-border-style:none;border-style:none}.print\\:border-black{border-color:var(--color-black)}.print\\:bg-white{background-color:var(--color-white)}.print\\:text-left{text-align:left}.print\\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#eee\\].ps--clicking{background-color:#eee!important}.\\[\\&\\.ps--clicking\\]\\:\\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#555\\].ps--clicking{background-color:#555!important}}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:h-1::-webkit-scrollbar{height:calc(var(--spacing) * 1)}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:w-1::-webkit-scrollbar{width:calc(var(--spacing) * 1)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:block::-webkit-scrollbar-button{display:block}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:h-0::-webkit-scrollbar-button{height:calc(var(--spacing) * 0)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:h-\\[50px\\]::-webkit-scrollbar-thumb{height:50px}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:bg-\\[\\#999\\]::-webkit-scrollbar-thumb{background-color:#999}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:\\[box-shadow\\:inset_0_-1px_0_rgba\\(229\\,231\\,235\\)\\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\\[\\&\\:not\\(\\[data-te-input-placeholder-active\\]\\)\\]\\:placeholder\\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:nth-child\\(odd\\)\\]\\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\\[\\&\\:nth-child\\(odd\\)\\]\\:dark\\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\\[\\&\\>svg\\]\\:pointer-events-none>svg{pointer-events:none}.\\[\\&\\>svg\\]\\:mx-auto>svg{margin-inline:auto}.\\[\\&\\>svg\\]\\:h-4>svg{height:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:h-5>svg{height:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:h-6>svg{height:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:w-4>svg{width:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:w-5>svg{width:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:w-6>svg{width:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:rotate-180>svg{rotate:180deg}.\\[\\&\\>svg\\]\\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\>svg\\]\\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:\"\";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:\"\";inherits:false;initial-value:0}@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-z{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-pan-x{syntax:\"*\";inherits:false}@property --tw-pan-y{syntax:\"*\";inherits:false}@property --tw-pinch-zoom{syntax:\"*\";inherits:false}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-tracking{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}@property --tw-duration{syntax:\"*\";inherits:false}@property --tw-ease{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}}" as const; \ No newline at end of file +export const css = "/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:\"\"}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 none);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\\!absolute{position:absolute!important}.\\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-\\[18px\\]{top:-18px}.-top-\\[21px\\]{top:-21px}.-top-\\[35px\\]{top:-35px}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\\[11px\\]{top:11px}.top-\\[13px\\]{top:13px}.top-\\[50\\%\\]{top:50%}.top-\\[50px\\]{top:50px}.top-full{top:100%}.right-0{right:0}.right-0\\.5{right:calc(var(--spacing) * .5)}.right-1{right:var(--spacing)}.right-1\\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\\[47px\\]{bottom:-47px}.bottom-0{bottom:0}.bottom-0\\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:var(--spacing)}.bottom-1\\/2{bottom:50%}.-left-\\[15px\\]{left:-15px}.-left-\\[9999px\\]{left:-9999px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\\[50\\%\\]{left:50%}.left-\\[50px\\]{left:50px}.left-\\[calc\\(50\\%-1px\\)\\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\\[2\\]{z-index:2}.z-\\[999\\]{z-index:999}.z-\\[1035\\]{z-index:1035}.z-\\[1040\\]{z-index:1040}.z-\\[1065\\]{z-index:1065}.z-\\[1066\\]{z-index:1066}.z-\\[1070\\]{z-index:1070}.z-\\[1080\\]{z-index:1080}.z-\\[1100\\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\\!{width:100%!important}@media (min-width:320px){.container\\!{max-width:320px!important}}@media (min-width:40rem){.container\\!{max-width:40rem!important}}@media (min-width:48rem){.container\\!{max-width:48rem!important}}@media (min-width:64rem){.container\\!{max-width:64rem!important}}@media (min-width:80rem){.container\\!{max-width:80rem!important}}@media (min-width:96rem){.container\\!{max-width:96rem!important}}.\\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:0}.m-1{margin:var(--spacing)}.m-auto{margin:auto}.mx-0{margin-inline:0}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\\[10px\\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\\!my-0{margin-block:0!important}.my-0{margin-block:0}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\\[5px\\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:0}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\\[0\\.15rem\\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\\[6px\\]{margin-right:6px}.mr-\\[8px\\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:0}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\\[0\\.125rem\\]{margin-bottom:.125rem}.mb-\\[10px\\]{margin-bottom:10px}.-ml-\\[1\\.5rem\\]{margin-left:-1.5rem}.ml-0{margin-left:0}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\\[3px\\]{margin-left:3px}.ml-\\[30px\\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\\!h-0{height:0!important}.\\!h-px{height:1px!important}.h-0{height:0}.h-1{height:var(--spacing)}.h-1\\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\\[0\\.9375rem\\]{height:.9375rem}.h-\\[1\\.4rem\\]{height:1.4rem}.h-\\[1\\.125rem\\]{height:1.125rem}.h-\\[2px\\]{height:2px}.h-\\[4px\\]{height:4px}.h-\\[6px\\]{height:6px}.h-\\[10px\\]{height:10px}.h-\\[30px\\]{height:30px}.h-\\[32px\\]{height:32px}.h-\\[40px\\]{height:40px}.h-\\[42px\\]{height:42px}.h-\\[48px\\]{height:48px}.h-\\[50px\\]{height:50px}.h-\\[56px\\]{height:56px}.h-\\[72px\\]{height:72px}.h-\\[100px\\]{height:100px}.h-\\[120px\\]{height:120px}.h-\\[160px\\]{height:160px}.h-\\[260px\\]{height:260px}.h-\\[380px\\]{height:380px}.h-\\[512px\\]{height:512px}.h-\\[calc\\(100\\%-100px\\)\\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\\[calc\\(100\\%-64px\\)\\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\\[1\\.5rem\\]{min-height:1.5rem}.min-h-\\[40px\\]{min-height:40px}.min-h-\\[305px\\]{min-height:305px}.min-h-\\[325px\\]{min-height:325px}.min-h-\\[auto\\]{min-height:auto}.\\!w-px{width:1px!important}.w-0{width:0}.w-1{width:var(--spacing)}.w-1\\.5{width:calc(var(--spacing) * 1.5)}.w-1\\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\\[0\\.9375rem\\]{width:.9375rem}.w-\\[1\\.4rem\\]{width:1.4rem}.w-\\[1\\.125rem\\]{width:1.125rem}.w-\\[2px\\]{width:2px}.w-\\[4px\\]{width:4px}.w-\\[6px\\]{width:6px}.w-\\[15px\\]{width:15px}.w-\\[30px\\]{width:30px}.w-\\[32px\\]{width:32px}.w-\\[45\\%\\]{width:45%}.w-\\[50px\\]{width:50px}.w-\\[70px\\]{width:70px}.w-\\[72px\\]{width:72px}.w-\\[76px\\]{width:76px}.w-\\[150px\\]{width:150px}.w-\\[160px\\]{width:160px}.w-\\[260px\\]{width:260px}.w-\\[300px\\]{width:300px}.w-\\[304px\\]{width:304px}.w-\\[328px\\]{width:328px}.w-\\[calc\\(100\\%-100px\\)\\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\\[90\\%\\]{max-width:90%}.max-w-\\[200px\\]{max-width:200px}.max-w-\\[267px\\]{max-width:267px}.max-w-\\[325px\\]{max-width:325px}.max-w-\\[calc\\(100\\%-1rem\\)\\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\\[48px\\]{min-width:48px}.min-w-\\[64px\\]{min-width:64px}.min-w-\\[100px\\]{min-width:100px}.min-w-\\[310px\\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\\[0_0\\]{transform-origin:0 0}.origin-\\[50\\%_50\\%\\]{transform-origin:50%}.origin-\\[center_bottom_0\\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\\[6px\\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\\[50\\%\\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\\[150\\%\\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\\[50\\%\\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\\[6px\\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\\[0\\.8\\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\\[0\\.25\\]{scale:.25}.scale-\\[1\\.02\\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\\[-180deg\\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\\[fade-in_0\\.3s_both\\]{animation:.3s both fade-in}.animate-\\[fade-in_0\\.15s_both\\]{animation:.15s both fade-in}.animate-\\[fade-in_350ms_ease-in-out\\]{animation:.35s ease-in-out fade-in}.animate-\\[fade-out_0\\.3s_both\\]{animation:.3s both fade-out}.animate-\\[fade-out_0\\.15s_both\\]{animation:.15s both fade-out}.animate-\\[fade-out_350ms_ease-in-out\\]{animation:.35s ease-in-out fade-out}.animate-\\[progress_3s_ease-in-out_infinite\\]{animation:3s ease-in-out infinite progress}.animate-\\[show-up-clock_350ms_linear\\]{animation:.35s linear show-up-clock}.animate-\\[slide-in-left_0\\.8s_both\\]{animation:.8s both slide-in-left}.animate-\\[slide-in-right_0\\.8s_both\\]{animation:.8s both slide-in-right}.animate-\\[slide-out-left_0\\.8s_both\\]{animation:.8s both slide-out-left}.animate-\\[slide-out-right_0\\.8s_both\\]{animation:.8s both slide-out-right}.animate-\\[spinner-grow_0\\.75s_linear_infinite\\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\\[0\\.5rem\\]{border-radius:.5rem}.rounded-\\[0\\.6rem\\]{border-radius:.6rem}.rounded-\\[0\\.25rem\\]{border-radius:.25rem}.rounded-\\[10px\\]{border-radius:10px}.rounded-\\[16px\\]{border-radius:16px}.rounded-\\[50\\%\\]{border-radius:50%}.rounded-\\[100\\%\\]{border-radius:100%}.rounded-\\[999px\\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\\[0\\.6rem\\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\\[0\\.25rem\\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\\[0\\.25rem\\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\\!border-\\[3px\\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\\[\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[0\\.15em\\]{border-style:var(--tw-border-style);border-width:.15em}.border-\\[0\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.border-\\[14px\\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\\[0\\.125rem\\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\\!border-\\[\\#14a44d\\]{border-color:#14a44d!important}.\\!border-\\[\\#b2b3b4\\]{border-color:#b2b3b4!important}.\\!border-\\[\\#dc4c64\\]{border-color:#dc4c64!important}.border-\\[\\#3b71ca\\]{border-color:#3b71ca}.border-\\[\\#14a44d\\]{border-color:#14a44d}.border-\\[\\#dc4c64\\]{border-color:#dc4c64}.border-\\[\\#eee\\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\\!bg-\\[\\#858585\\]{background-color:#858585!important}.\\!bg-danger-100{background-color:#fae5e9!important}.\\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\\!bg-primary-100{background-color:#e3ebf7!important}.\\!bg-success-100{background-color:#d6fae4!important}.bg-\\[\\#000000e6\\]{background-color:#000000e6}.bg-\\[\\#3b71ca\\]{background-color:#3b71ca}.bg-\\[\\#6d6d6d\\]{background-color:#6d6d6d}.bg-\\[\\#00000012\\]{background-color:#00000012}.bg-\\[\\#00000066\\]{background-color:#0006}.bg-\\[\\#aaa\\]{background-color:#aaa}.bg-\\[\\#eceff1\\]{background-color:#eceff1}.bg-\\[\\#eee\\]{background-color:#eee}.bg-\\[rgba\\(0\\,0\\,0\\,0\\.4\\)\\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\\[\\#336dec\\]{fill:#336dec}.fill-\\[\\#afafaf\\]{fill:#afafaf}.fill-current{fill:currentColor}.\\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:calc(var(--spacing) * 2)}.p-2\\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\\[1rem\\]{padding:1rem}.p-\\[5px\\]{padding:5px}.p-\\[auto\\]{padding:auto}.px-0{padding-inline:0}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\\[0\\.4rem\\]{padding-inline:.4rem}.px-\\[1\\.4rem\\]{padding-inline:1.4rem}.px-\\[10px\\]{padding-inline:10px}.px-\\[12px\\]{padding-inline:12px}.px-\\[auto\\]{padding-inline:auto}.\\!py-0{padding-block:0!important}.\\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:0}.py-1{padding-block:var(--spacing)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\\[0\\.4rem\\]{padding-block:.4rem}.py-\\[0\\.32rem\\]{padding-block:.32rem}.py-\\[0\\.33rem\\]{padding-block:.33rem}.py-\\[0\\.4375rem\\]{padding-block:.4375rem}.py-\\[1px\\]{padding-block:1px}.py-\\[5px\\]{padding-block:5px}.py-\\[10px\\]{padding-block:10px}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\\[0\\.37rem\\]{padding-top:.37rem}.pt-\\[6px\\]{padding-top:6px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\\[24px\\]{padding-right:24px}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\\[5px\\]{padding-bottom:5px}.pl-0{padding-left:0}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\\[1\\.5rem\\]{padding-left:1.5rem}.pl-\\[8px\\]{padding-left:8px}.pl-\\[18px\\]{padding-left:18px}.pl-\\[50px\\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\\[-0\\.125em\\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.8rem\\]{font-size:.8rem}.text-\\[0\\.9rem\\]{font-size:.9rem}.text-\\[1\\.1rem\\]{font-size:1.1rem}.text-\\[2\\.5rem\\]{font-size:2.5rem}.text-\\[3\\.75rem\\]{font-size:3.75rem}.text-\\[10px\\]{font-size:10px}.text-\\[12px\\]{font-size:12px}.text-\\[13px\\]{font-size:13px}.text-\\[16px\\]{font-size:16px}.text-\\[18px\\]{font-size:18px}.text-\\[34px\\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\\[1\\.2\\]{--tw-leading:1.2;line-height:1.2}.leading-\\[1\\.5\\]{--tw-leading:1.5;line-height:1.5}.leading-\\[1\\.6\\]{--tw-leading:1.6;line-height:1.6}.leading-\\[2\\.15\\]{--tw-leading:2.15;line-height:2.15}.leading-\\[40px\\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\\[-0\\.00833em\\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\\[\\.1rem\\],.tracking-\\[0\\.1rem\\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\\[1\\.7px\\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\\!text-\\[\\#14a44d\\]{color:#14a44d!important}.\\!text-\\[\\#dc4c64\\]{color:#dc4c64!important}.\\!text-danger-700{color:#b0233a!important}.\\!text-gray-50{color:var(--color-gray-50)!important}.\\!text-primary{color:#3b71ca!important}.\\!text-primary-700{color:#285192!important}.\\!text-success-700{color:#0e7537!important}.text-\\[\\#3b71ca\\]{color:#3b71ca}.text-\\[\\#4f4f4f\\]{color:#4f4f4f}.text-\\[\\#14a44d\\]{color:#14a44d}.text-\\[\\#212529\\]{color:#212529}.text-\\[\\#b3afaf\\]{color:#b3afaf}.text-\\[\\#b3b3b3\\]{color:#b3b3b3}.text-\\[\\#dc4c64\\]{color:#dc4c64}.text-\\[\\#ffffff8a\\]{color:#ffffff8a}.text-\\[rgb\\(220\\,76\\,100\\)\\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\\/\\[64\\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\\/\\[64\\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\\!opacity-0{opacity:0!important}.\\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\\[\\.53\\]{opacity:.53}.opacity-\\[\\.54\\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_0px_3px_0_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_2px_2px_0_rgba\\(0\\,0\\,0\\,0\\.04\\)\\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_2px_5px_0_rgba\\(0\\,0\\,0\\,0\\.16\\)\\,_0_2px_10px_0_rgba\\(0\\,0\\,0\\,0\\.12\\)\\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_4px_9px_-4px_\\#3b71ca\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_10px_15px_-3px_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_4px_6px_-2px_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0px_2px_15px_-3px_rgba\\(0\\,0\\,0\\,\\.07\\)\\,_0px_10px_20px_-2px_rgba\\(0\\,0\\,0\\,\\.04\\)\\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\\/login,.shadow\\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,_opacity\\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,box-shadow\\,border\\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[height\\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[opacity\\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,_opacity\\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,height\\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[width\\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\\[0ms\\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\\[150ms\\]{--tw-duration:.15s;transition-duration:.15s}.duration-\\[200ms\\]{--tw-duration:.2s;transition-duration:.2s}.duration-\\[250ms\\]{--tw-duration:.25s;transition-duration:.25s}.duration-\\[350ms\\]{--tw-duration:.35s;transition-duration:.35s}.duration-\\[400ms\\]{--tw-duration:.4s;transition-duration:.4s}.duration-\\[1000ms\\]{--tw-duration:1s;transition-duration:1s}.ease-\\[cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\,_cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\\[cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)\\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\)\\],.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\.0\\)\\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\\[ease\\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\\!\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)!important}.\\[bash\\:1221\\]{bash:1221}.\\[check\\:5737\\]{check:5737}.\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)}.\\[direction\\:ltr\\]{direction:ltr}.\\[drm\\:hdmiphy_enable\\.part\\.0\\]{drm:hdmiphy enable.part0}.\\[drm\\:samsung_dsim_host_attach\\]{drm:samsung dsim host attach}.\\[overflow-anchor\\:none\\]{overflow-anchor:none}.\\[pid\\:5118\\,cpu4\\,QThread\\,0\\]{pid:5118,cpu4,QThread,0}.\\[pid\\:5118\\,cpu4\\,QThread\\,1\\]{pid:5118,cpu4,QThread,1}.\\[pid\\:5118\\,cpu4\\,QThread\\,2\\]{pid:5118,cpu4,QThread,2}.\\[pid\\:5118\\,cpu4\\,QThread\\,3\\]{pid:5118,cpu4,QThread,3}.\\[pid\\:5118\\,cpu4\\,QThread\\,4\\]{pid:5118,cpu4,QThread,4}.\\[pid\\:5118\\,cpu4\\,QThread\\,9\\]{pid:5118,cpu4,QThread,9}.\\[transition\\:background-color_\\.2s_linear\\,_height_\\.2s_ease-in-out\\]{transition:background-color .2s linear,height .2s ease-in-out}.\\[transition\\:background-color_\\.2s_linear\\,_width_\\.2s_ease-in-out\\,_opacity\\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\\[transition\\:background-color_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,box-shadow_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,border_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/ps\\:opacity-60:is(:where(.group\\/ps):hover *){opacity:.6}.group-hover\\/x\\:h-\\[11px\\]:is(:where(.group\\/x):hover *){height:11px}.group-hover\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):hover *){background-color:#999}.group-hover\\/y\\:w-\\[11px\\]:is(:where(.group\\/y):hover *){width:11px}.group-hover\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):hover *){background-color:#999}}.group-focus\\/ps\\:opacity-60:is(:where(.group\\/ps):focus *){opacity:.6}.group-focus\\/ps\\:opacity-100:is(:where(.group\\/ps):focus *){opacity:1}.group-focus\\/x\\:h-\\[0\\.6875rem\\]:is(:where(.group\\/x):focus *){height:.6875rem}.group-focus\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):focus *){background-color:#999}.group-focus\\/y\\:w-\\[0\\.6875rem\\]:is(:where(.group\\/y):focus *){width:.6875rem}.group-focus\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):focus *){background-color:#999}.group-active\\/ps\\:opacity-100:is(:where(.group\\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:0}.group-data-te-collapse-collapsed\\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\\:fill-\\[\\#212529\\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\\[te-input-focused\\]\\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-focused\\]\\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-focused\\]\\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-focused\\]\\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-focused\\]\\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-focused\\]\\:border-\\[\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\\[te-input-focused\\]\\:border-\\[\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\\[te-input-focused\\]\\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\\[te-input-focused\\]\\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\\[te-input-focused\\]\\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-state-active\\]\\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-state-active\\]\\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-state-active\\]\\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-state-active\\]\\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-state-active\\]\\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-state-active\\]\\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\\[te-select-option-group-ref\\]\\/opt\\:pl-7:is(:where(.group\\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\\[te-was-validated\\]\\/validation\\:mb-4:is(:where(.group\\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-x *){display:block}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-x *){background-color:#0000}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-y *){display:block}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-y *){background-color:#0000}.group-\\[\\&\\.ps--clicking\\]\\/x\\:h-\\[11px\\]:is(:where(.group\\/x).ps--clicking *){height:11px}.group-\\[\\&\\.ps--clicking\\]\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--clicking\\]\\/y\\:w-\\[11px\\]:is(:where(.group\\/y).ps--clicking *){width:11px}.group-\\[\\&\\.ps--clicking\\]\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--scrolling-x\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-x *),.group-\\[\\&\\.ps--scrolling-y\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-y *){opacity:.6}.group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:text-green-600:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:text-\\[rgb\\(220\\,76\\,100\\)\\]:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:scale-\\[0\\.8\\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\\:\\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\\[te-input-focused\\]\\:\\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\\[te-input-focused\\]\\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\\:bg-transparent ::selection{background-color:#0000}.selection\\:bg-transparent::selection{background-color:#0000}.before\\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\\:absolute:before{content:var(--tw-content);position:absolute}.before\\:h-\\[0\\.875rem\\]:before{content:var(--tw-content);height:.875rem}.before\\:w-\\[0\\.875rem\\]:before{content:var(--tw-content);width:.875rem}.before\\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\\:opacity-0:before{content:var(--tw-content);opacity:0}.before\\:shadow-\\[0px_0px_0px_13px_transparent\\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\\:content-\\[\\'\\'\\]:before{--tw-content:\"\";content:var(--tw-content)}.odd\\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\\:\\!border-\\[\\#14a44d\\]:checked{border-color:#14a44d!important}.checked\\:\\!border-\\[\\#dc4c64\\]:checked{border-color:#dc4c64!important}.checked\\:border-primary:checked{border-color:#3b71ca}.checked\\:\\!bg-\\[\\#14a44d\\]:checked{background-color:#14a44d!important}.checked\\:\\!bg-\\[\\#dc4c64\\]:checked{background-color:#dc4c64!important}.checked\\:bg-primary:checked{background-color:#3b71ca}.checked\\:before\\:opacity-\\[0\\.16\\]:checked:before{content:var(--tw-content);opacity:.16}.checked\\:after\\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\\:after\\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\\:after\\:ml-\\[0\\.25rem\\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\\:after\\:block:checked:after{content:var(--tw-content);display:block}.checked\\:after\\:h-\\[0\\.8125rem\\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\\:after\\:w-\\[0\\.375rem\\]:checked:after{content:var(--tw-content);width:.375rem}.checked\\:after\\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\\:after\\:border-\\[0\\.125rem\\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:after\\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:after\\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:after\\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:after\\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:after\\:\\!bg-\\[\\#14a44d\\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\\:after\\:\\!bg-\\[\\#dc4c64\\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\\:after\\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\\:after\\:content-\\[\\'\\'\\]:checked:after{--tw-content:\"\";content:var(--tw-content)}.empty\\:hidden:empty{display:none}@media (hover:hover){.hover\\:z-2:hover{z-index:2}.hover\\:cursor-pointer:hover{cursor:pointer}.hover\\:rounded-\\[50\\%\\]:hover{border-radius:50%}.hover\\:\\!bg-\\[\\#eee\\]:hover{background-color:#eee!important}.hover\\:bg-\\[\\#00000014\\]:hover{background-color:#00000014}.hover\\:bg-\\[\\#00000026\\]:hover{background-color:#00000026}.hover\\:bg-\\[unset\\]:hover{background-color:unset}.hover\\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\\:bg-primary-600:hover{background-color:#3061af}.hover\\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\\:fill-\\[\\#8b8b8b\\]:hover{fill:#8b8b8b}.hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.hover\\:text-\\[\\#8b8b8b\\]:hover{color:#8b8b8b}.hover\\:text-primary:hover{color:#3b71ca}.hover\\:text-primary-600:hover{color:#3061af}.hover\\:text-white:hover{color:var(--color-white)}.hover\\:\\!opacity-90:hover{opacity:.9!important}.hover\\:opacity-100:hover{opacity:1}.hover\\:\\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\\:before\\:opacity-\\[0\\.04\\]:hover:before{content:var(--tw-content);opacity:.04}.hover\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\\:z-3:focus{z-index:3}.focus\\:rounded-\\[50\\%\\]:focus{border-radius:50%}.focus\\:\\!border-\\[\\#14a44d\\]:focus{border-color:#14a44d!important}.focus\\:\\!border-\\[\\#dc4c64\\]:focus{border-color:#dc4c64!important}.focus\\:border-primary:focus{border-color:#3b71ca}.focus\\:\\!bg-\\[\\#eee\\]:focus{background-color:#eee!important}.focus\\:bg-\\[\\#00000014\\]:focus{background-color:#00000014}.focus\\:bg-\\[\\#00000026\\]:focus{background-color:#00000026}.focus\\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\\:bg-primary-600:focus{background-color:#3061af}.focus\\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.focus\\:text-gray-700:focus{color:var(--color-gray-700)}.focus\\:text-primary:focus{color:#3b71ca}.focus\\:text-primary-600:focus{color:#3061af}.focus\\:text-white:focus{color:var(--color-white)}.focus\\:\\!opacity-90:focus{opacity:.9!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#14a44d\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#dc4c64\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\\:transition-\\[border-color_0\\.2s\\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\\:placeholder\\:opacity-100:focus::placeholder{opacity:1}.focus\\:before\\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\\:before\\:opacity-\\[0\\.12\\]:focus:before{content:var(--tw-content);opacity:.12}.focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:after\\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\\:after\\:z-\\[1\\]:focus:after{content:var(--tw-content);z-index:1}.focus\\:after\\:block:focus:after{content:var(--tw-content);display:block}.focus\\:after\\:h-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);height:.875rem}.focus\\:after\\:w-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);width:.875rem}.focus\\:after\\:rounded-\\[0\\.125rem\\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\\:after\\:content-\\[\\'\\'\\]:focus:after{--tw-content:\"\";content:var(--tw-content)}.checked\\:focus\\:before\\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\\:focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\\:focus\\:after\\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\\:focus\\:after\\:ml-\\[0\\.25rem\\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\\:focus\\:after\\:h-\\[0\\.8125rem\\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\\:focus\\:after\\:w-\\[0\\.375rem\\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\\:focus\\:after\\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\\:focus\\:after\\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\\:focus\\:after\\:border-\\[0\\.125rem\\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:focus\\:after\\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:focus\\:after\\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:focus\\:after\\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:focus\\:after\\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:focus\\:after\\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\\:z-60:active{z-index:60}.active\\:bg-\\[\\#c4d4ef\\]:active{background-color:#c4d4ef}.active\\:bg-\\[\\#cacfd1\\]:active{background-color:#cacfd1}.active\\:bg-primary-700:active{background-color:#285192}.active\\:bg-primary-accent-200:active{background-color:#cedbee}.active\\:text-primary-700:active{color:#285192}.active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\\:grid[data-te-dropdown-show]{display:grid}.data-\\[data-te-autocomplete-option-disabled\\]\\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\\[data-te-autocomplete-option-disabled\\]\\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\\[popper-reference-hidden\\]\\:hidden[data-popper-reference-hidden]{display:none}.data-\\[te-active\\]\\:-top-\\[38px\\][data-te-active]{top:-38px}.data-\\[te-active\\]\\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-autocomplete-state-open\\]\\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-state-open\\]\\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\\[te-carousel-fade\\]\\:z-0[data-te-carousel-fade]{z-index:0}.data-\\[te-carousel-fade\\]\\:z-\\[1\\][data-te-carousel-fade]{z-index:1}.data-\\[te-carousel-fade\\]\\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\\[te-carousel-fade\\]\\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\\[te-carousel-fade\\]\\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\\[te-carousel-fade\\]\\:duration-\\[600ms\\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\\[te-datepicker-cell-disabled\\]\\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\\[te-datepicker-cell-disabled\\]\\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\\[te-datepicker-cell-disabled\\]\\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\\[te-datepicker-cell-disabled\\]\\:hover\\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\\[\\[data-te-datepicker-cell-focused\\]\\]\\:data-\\[te-datepicker-cell-selected\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\\[te-input-disabled\\]\\:cursor-default[data-te-input-disabled]{cursor:default}.data-\\[te-input-disabled\\]\\:bg-\\[\\#e9ecef\\][data-te-input-disabled]{background-color:#e9ecef}.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:block[data-te-input-state-active]{display:block}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\][data-te-input-state-active]{scale:.8}.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:placeholder\\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\\[te-select-open\\]\\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-select-open\\]\\:opacity-100[data-te-select-open]{opacity:1}.data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\\:transform-none{transform:none}.motion-reduce\\:animate-\\[spin_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spin}.motion-reduce\\:animate-\\[spinner-grow_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\\:animate-none{animation:none}.motion-reduce\\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\\:block{display:block}.sm\\:grid{display:grid}.sm\\:hidden{display:none}.sm\\:w-40{width:calc(var(--spacing) * 40)}.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\\:grid-cols-\\[10\\%_90\\%\\]{grid-template-columns:10% 90%}.sm\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\\:break-words{overflow-wrap:break-word}.sm\\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\\:order-none{order:0}.md\\:my-0{margin-block:0}.md\\:mb-0{margin-bottom:0}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:flex-row{flex-direction:row}.md\\:pr-1{padding-right:var(--spacing)}.md\\:pr-\\[17px\\]{padding-right:17px}}@media (min-width:64rem){.lg\\:sticky{position:sticky}.lg\\:block{display:block}.lg\\:grid{display:grid}.lg\\:hidden{display:none}.lg\\:w-32{width:calc(var(--spacing) * 32)}.lg\\:w-36{width:calc(var(--spacing) * 36)}.lg\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\\:w-52{width:calc(var(--spacing) * 52)}.xl\\:grid-flow-col{grid-auto-flow:column}.xl\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:h-auto{height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[305px\\]{min-height:305px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[auto\\]{min-height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-w-\\[auto\\]{min-width:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!flex-row{flex-direction:row!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:flex-col{flex-direction:column}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!justify-around{justify-content:space-around!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:overflow-y-auto{overflow-y:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-lg{border-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-tr-none{border-top-right-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-none{border-bottom-left-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:p-\\[10px\\]{padding:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:pr-\\[10px\\]{padding-right:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-\\[3rem\\]{font-size:3rem}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\\:max-md\\:landscape\\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\\:max-md\\:landscape\\:h-8{height:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:h-\\[360px\\]{height:360px}.xs\\:max-md\\:landscape\\:h-full{height:100%}.xs\\:max-md\\:landscape\\:w-8{width:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:w-\\[475px\\]{width:475px}.xs\\:max-md\\:landscape\\:flex-row{flex-direction:row}}}}.rtl\\:\\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\\:\\!origin-\\[50\\%_50\\%_0\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\\:\\[direction\\:rtl\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\\:border-\\[\\#4f4f4f\\]{border-color:#4f4f4f}.dark\\:border-\\[\\#14a44d\\]{border-color:#14a44d}.dark\\:border-\\[\\#dc4c64\\]{border-color:#dc4c64}.dark\\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\\:border-primary-400{border-color:#8faee0}.dark\\:\\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\\:bg-\\[\\#4f4f4f\\]{background-color:#4f4f4f}.dark\\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\\:bg-primary-600{background-color:#3061af}.dark\\:bg-transparent{background-color:#0000}.dark\\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\\:bg-zinc-600\\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\\:bg-zinc-600\\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\\:fill-gray-400{fill:var(--color-gray-400)}.dark\\:\\!text-primary-400{color:#8faee0!important}.dark\\:text-gray-200{color:var(--color-gray-200)}.dark\\:text-gray-300{color:var(--color-gray-300)}.dark\\:text-neutral-200{color:var(--color-neutral-200)}.dark\\:text-neutral-300{color:var(--color-neutral-300)}.dark\\:text-neutral-400{color:var(--color-neutral-400)}.dark\\:text-primary-400{color:#8faee0}.dark\\:text-white{color:var(--color-white)}.dark\\:shadow-\\[0_4px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.5\\)\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\\:group-\\[\\[data-te-datepicker-cell-disabled\\]\\]\\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\\:peer-focus\\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\\:peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\\:placeholder\\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\\:checked\\:border-primary:checked{border-color:#3b71ca}.dark\\:checked\\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\\:hover\\:\\!bg-\\[\\#555\\]:hover{background-color:#555!important}.dark\\:hover\\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\\:hover\\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\\:hover\\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\\:hover\\:bg-white\\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:bg-white\\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:hover\\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\\:hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.dark\\:hover\\:text-primary-400:hover{color:#8faee0}.dark\\:hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\\:focus\\:\\!bg-\\[\\#555\\]:focus{background-color:#555!important}.dark\\:focus\\:bg-white\\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:focus\\:bg-white\\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.dark\\:focus\\:text-primary-400:focus{color:#8faee0}.dark\\:focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(255\\,255\\,255\\,0\\.4\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:disabled\\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\\:disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-buttons-timepicker\\]\\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\\:data-\\[te-input-disabled\\]\\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\\:block{display:block}.print\\:hidden{display:none}.print\\:border-none{--tw-border-style:none;border-style:none}.print\\:border-black{border-color:var(--color-black)}.print\\:bg-white{background-color:var(--color-white)}.print\\:text-left{text-align:left}.print\\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#eee\\].ps--clicking{background-color:#eee!important}.\\[\\&\\.ps--clicking\\]\\:\\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#555\\].ps--clicking{background-color:#555!important}}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:h-1::-webkit-scrollbar{height:var(--spacing)}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:w-1::-webkit-scrollbar{width:var(--spacing)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:block::-webkit-scrollbar-button{display:block}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:h-0::-webkit-scrollbar-button{height:0}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:h-\\[50px\\]::-webkit-scrollbar-thumb{height:50px}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:bg-\\[\\#999\\]::-webkit-scrollbar-thumb{background-color:#999}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:\\[box-shadow\\:inset_0_-1px_0_rgba\\(229\\,231\\,235\\)\\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\\[\\&\\:not\\(\\[data-te-input-placeholder-active\\]\\)\\]\\:placeholder\\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:nth-child\\(odd\\)\\]\\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\\[\\&\\:nth-child\\(odd\\)\\]\\:dark\\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\\[\\&\\>svg\\]\\:pointer-events-none>svg{pointer-events:none}.\\[\\&\\>svg\\]\\:mx-auto>svg{margin-inline:auto}.\\[\\&\\>svg\\]\\:h-4>svg{height:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:h-5>svg{height:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:h-6>svg{height:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:w-4>svg{width:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:w-5>svg{width:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:w-6>svg{width:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:rotate-180>svg{rotate:180deg}.\\[\\&\\>svg\\]\\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\>svg\\]\\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:\"\";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:\"\";inherits:false;initial-value:0}@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-z{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-pan-x{syntax:\"*\";inherits:false}@property --tw-pan-y{syntax:\"*\";inherits:false}@property --tw-pinch-zoom{syntax:\"*\";inherits:false}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-tracking{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}@property --tw-duration{syntax:\"*\";inherits:false}@property --tw-ease{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}}" as const; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 1969dcd1a7..2e8eea8884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1677,7 +1677,7 @@ resolved "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz#4577f2c7691137d3e315018218ba2b76e4721ba9" integrity sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w== -"@emnapi/core@1.10.0", "@emnapi/core@^1.1.0", "@emnapi/core@^1.10.0", "@emnapi/core@^1.4.3": +"@emnapi/core@1.10.0", "@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3": version "1.10.0" resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== @@ -1693,7 +1693,15 @@ "@emnapi/wasi-threads" "1.0.4" tslib "^2.4.0" -"@emnapi/runtime@1.10.0", "@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.10.0", "@emnapi/runtime@^1.4.3": +"@emnapi/core@^1.11.1": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.3.tgz#5e95348a42cd1e06f0b9aa380cd74091daa4d520" + integrity sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg== + dependencies: + "@emnapi/wasi-threads" "1.2.3" + tslib "^2.4.0" + +"@emnapi/runtime@1.10.0", "@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3": version "1.10.0" resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== @@ -1707,6 +1715,13 @@ dependencies: tslib "^2.4.0" +"@emnapi/runtime@^1.11.1": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95" + integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA== + dependencies: + tslib "^2.4.0" + "@emnapi/wasi-threads@1.0.4": version "1.0.4" resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz#703fc094d969e273b1b71c292523b2f792862bf4" @@ -1714,13 +1729,20 @@ dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.2.1": +"@emnapi/wasi-threads@1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@1.2.3", "@emnapi/wasi-threads@^1.2.2": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz#c9bf72fd4be5b928aee894820e8d814ed73916e9" + integrity sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g== + dependencies: + tslib "^2.4.0" + "@esbuild/aix-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" @@ -3604,116 +3626,116 @@ dependencies: "@swc/counter" "^0.1.3" -"@tailwindcss/cli@^4.0.6": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/cli/-/cli-4.3.1.tgz#bc00e49e2b70baad223969071e4e380da7123afe" - integrity sha512-ZWPy20rF+TBfTImxDMG3Wr75Y3RpaPlo9lc+oJbInlMyjT+XPkTVKVIL5RZ7JirXuIahcfHoLNFRmDorKi+JQQ== +"@tailwindcss/cli@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/cli/-/cli-4.3.3.tgz#763edd474c2f43ce82cdcedc80c0b3b23a86ba6f" + integrity sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw== dependencies: "@parcel/watcher" "2.5.1" - "@tailwindcss/node" "4.3.1" - "@tailwindcss/oxide" "4.3.1" - enhanced-resolve "5.21.6" + "@tailwindcss/node" "4.3.3" + "@tailwindcss/oxide" "4.3.3" + enhanced-resolve "^5.24.1" mri "^1.2.0" picocolors "^1.1.1" - tailwindcss "4.3.1" + tailwindcss "4.3.3" -"@tailwindcss/node@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.1.tgz#77402afcfa29c4b48b8494d0edfc4428d0a504ba" - integrity sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A== +"@tailwindcss/node@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.3.tgz#38ff04309ff036ea3589a7bad9069c44ec9d3883" + integrity sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg== dependencies: "@jridgewell/remapping" "^2.3.5" - enhanced-resolve "5.21.6" + enhanced-resolve "^5.24.1" jiti "^2.7.0" lightningcss "1.32.0" magic-string "^0.30.21" source-map-js "^1.2.1" - tailwindcss "4.3.1" + tailwindcss "4.3.3" -"@tailwindcss/oxide-android-arm64@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz#83c6762cd383a2ebc6e01897b0f35f19225e6653" - integrity sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ== +"@tailwindcss/oxide-android-arm64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz#848f93034155daf7892185028dfb18143bfc7d07" + integrity sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw== -"@tailwindcss/oxide-darwin-arm64@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz#2558b7e835889ad721823e4dcb50dd5071d747d8" - integrity sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA== +"@tailwindcss/oxide-darwin-arm64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz#5c779e32c1c361beb75136fd8e2c627fcb9a5b28" + integrity sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw== -"@tailwindcss/oxide-darwin-x64@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz#d987957b87a26668b6d0117ccd4a8a4d1a318a2b" - integrity sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg== +"@tailwindcss/oxide-darwin-x64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz#ba292ff52fa3264139f7fe7874719ce0a4666875" + integrity sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw== -"@tailwindcss/oxide-freebsd-x64@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz#75b342c81a07b1afa437976ec82f86d372431da7" - integrity sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g== +"@tailwindcss/oxide-freebsd-x64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz#07e589487b9a636235a4ade48cefc1f9eeeec57f" + integrity sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw== -"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz#6730adc6d17187eeeff2f14f6a914d009749cb97" - integrity sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg== +"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz#0c8abc228d9e19e0065ddb707eba52e21855b3ff" + integrity sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ== -"@tailwindcss/oxide-linux-arm64-gnu@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz#869d16b3d9bd8097b797a3dd876db0368c07eae3" - integrity sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ== +"@tailwindcss/oxide-linux-arm64-gnu@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz#5067dc7afd15d2b97adc77a91177dc4bec8d1b8b" + integrity sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w== -"@tailwindcss/oxide-linux-arm64-musl@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz#ab110680ce3c7a2a135656db4402dffc1fb9c1d7" - integrity sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA== +"@tailwindcss/oxide-linux-arm64-musl@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz#29ae5f279be2ce64db368711985b6ad8e4562e57" + integrity sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA== -"@tailwindcss/oxide-linux-x64-gnu@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz#422a4175a76ae60dd9d17946eec3584cb636352f" - integrity sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg== +"@tailwindcss/oxide-linux-x64-gnu@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz#7d693b40f69875744b499481359b227fd3ac3a3c" + integrity sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w== -"@tailwindcss/oxide-linux-x64-musl@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz#f4c714a653a0e742955d2af2c53d0064b4c500d1" - integrity sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ== +"@tailwindcss/oxide-linux-x64-musl@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz#6a55d664f47c5ff9ad3f46bf05fe07d410b8cfd8" + integrity sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img== -"@tailwindcss/oxide-wasm32-wasi@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz#32172ca8b2427b9c2bb09c97756960185b7d4fc0" - integrity sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA== +"@tailwindcss/oxide-wasm32-wasi@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz#408a9bc620e68f1aaf0dfea33da7bd3b65f9e2ef" + integrity sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ== dependencies: - "@emnapi/core" "^1.10.0" - "@emnapi/runtime" "^1.10.0" - "@emnapi/wasi-threads" "^1.2.1" + "@emnapi/core" "^1.11.1" + "@emnapi/runtime" "^1.11.1" + "@emnapi/wasi-threads" "^1.2.2" "@napi-rs/wasm-runtime" "^1.1.4" "@tybys/wasm-util" "^0.10.2" tslib "^2.8.1" -"@tailwindcss/oxide-win32-arm64-msvc@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz#07a11b6eb1f578d012460e6ad6f2352a28d32514" - integrity sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg== +"@tailwindcss/oxide-win32-arm64-msvc@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz#3d582b00180fa4680e0b6c90be69fa5f4a209092" + integrity sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ== -"@tailwindcss/oxide-win32-x64-msvc@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz#60c6095d97b141c02de36bb52a16c358d9bdaa98" - integrity sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA== +"@tailwindcss/oxide-win32-x64-msvc@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz#ce4c663fef3b4fde67611a4c1391866f8c0aa79b" + integrity sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw== -"@tailwindcss/oxide@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.1.tgz#6fdd28b3abf785e2c2cac31f52c4755875826828" - integrity sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA== +"@tailwindcss/oxide@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz#6266109d025cfcb04f8e4c58954a6f630a415b7f" + integrity sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA== optionalDependencies: - "@tailwindcss/oxide-android-arm64" "4.3.1" - "@tailwindcss/oxide-darwin-arm64" "4.3.1" - "@tailwindcss/oxide-darwin-x64" "4.3.1" - "@tailwindcss/oxide-freebsd-x64" "4.3.1" - "@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.1" - "@tailwindcss/oxide-linux-arm64-gnu" "4.3.1" - "@tailwindcss/oxide-linux-arm64-musl" "4.3.1" - "@tailwindcss/oxide-linux-x64-gnu" "4.3.1" - "@tailwindcss/oxide-linux-x64-musl" "4.3.1" - "@tailwindcss/oxide-wasm32-wasi" "4.3.1" - "@tailwindcss/oxide-win32-arm64-msvc" "4.3.1" - "@tailwindcss/oxide-win32-x64-msvc" "4.3.1" + "@tailwindcss/oxide-android-arm64" "4.3.3" + "@tailwindcss/oxide-darwin-arm64" "4.3.3" + "@tailwindcss/oxide-darwin-x64" "4.3.3" + "@tailwindcss/oxide-freebsd-x64" "4.3.3" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.3" + "@tailwindcss/oxide-linux-arm64-gnu" "4.3.3" + "@tailwindcss/oxide-linux-arm64-musl" "4.3.3" + "@tailwindcss/oxide-linux-x64-gnu" "4.3.3" + "@tailwindcss/oxide-linux-x64-musl" "4.3.3" + "@tailwindcss/oxide-wasm32-wasi" "4.3.3" + "@tailwindcss/oxide-win32-arm64-msvc" "4.3.3" + "@tailwindcss/oxide-win32-x64-msvc" "4.3.3" "@tokenizer/inflate@^0.4.1": version "0.4.1" @@ -8460,7 +8482,7 @@ end-of-stream@1.4.5, end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^ dependencies: once "^1.4.0" -enhanced-resolve@5.21.6, enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1, enhanced-resolve@^5.20.0, enhanced-resolve@^5.7.0: +enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1, enhanced-resolve@^5.20.0, enhanced-resolve@^5.7.0: version "5.21.6" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz#aa207b43cf658e6ab3ba06896edc00c13c3127c6" integrity sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ== @@ -8468,6 +8490,14 @@ enhanced-resolve@5.21.6, enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1, enha graceful-fs "^4.2.4" tapable "^2.3.3" +enhanced-resolve@^5.24.1: + version "5.24.5" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz#b4dad3255b7545f07ba5535189868e9f85f47573" + integrity sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.3" + enquirer@2.3.6, enquirer@~2.3.6: version "2.3.6" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" @@ -9447,7 +9477,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.7, fast-glob@^3.2.9: +fast-glob@3.3.3, fast-glob@^3.2.7, fast-glob@^3.2.9: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -17897,10 +17927,10 @@ table-layout@^0.4.3: typical "^2.6.1" wordwrapjs "^3.0.0" -tailwindcss@4.3.1, tailwindcss@^4.0.6: - version "4.3.1" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.1.tgz#78ee06f6186bc8fb9603f8083eb703dc7dd96a10" - integrity sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q== +tailwindcss@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55" + integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ== tapable@^1.0.0: version "1.1.3" From a1beccb40f58f210356c382b595042f2725d9b59 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 15:49:37 -0400 Subject: [PATCH 046/197] feat: scaffold the isolated VitePress documentation site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-005 Phase 1. docs/ is a self-contained project with its own package.json and yarn.lock, deliberately outside the workspaces globs ("apps/*", "libs/*", "test"), so root install and the app build are untouched: VitePress and Vue 3 live only in docs/node_modules while the application keeps Vue 2. Content lives under docs/site/ with srcDir: 'site', so publishing is structural — working documents beside the site (docs/research, the ADRs) cannot be built into it. A .vitepress/target.mjs seam carries base, inApp and outboundChrome, because the documentation must ship with the application for disconnected installs and base is baked in at build time. Ten section index pages accompany the landing page: VitePress validates dead links in markdown content only, not nav, sidebar or hero actions, so the first build passed with every navigation link broken. They make the skeleton genuinely navigable and give the link check something real. docs/ is excluded from the application linter — it is an isolated project whose quality gate is its own build with dead-link checking on. ADR-005 comes onto this branch with its Phase 1 amendments recorded: the docs/site layout, the target seam, release-triggered publication, and the hosting decision for one deploy-pages artifact serving Heimdall Lite at the site root and the documentation under /docs/. Authored by: Aaron Lippold --- .dockerignore | 7 + .gitignore | 8 + docs/.vitepress/config.mjs | 135 +++ docs/.vitepress/target.mjs | 71 ++ docs/.vitepress/theme/index.js | 11 + docs/adr-005-vitepress-documentation-site.md | 387 ++++++++ docs/package.json | 16 + docs/site/about/index.md | 19 + docs/site/api/index.md | 19 + docs/site/converters/index.md | 18 + docs/site/decisions/index.md | 20 + docs/site/deployment/index.md | 26 + docs/site/developers/index.md | 26 + docs/site/getting-started/index.md | 22 + docs/site/index.md | 38 + docs/site/release-notes/index.md | 13 + docs/site/security/index.md | 15 + docs/site/user-guide/index.md | 19 + docs/yarn.lock | 890 +++++++++++++++++++ eslint.config.mjs | 14 +- 20 files changed, 1773 insertions(+), 1 deletion(-) create mode 100644 docs/.vitepress/config.mjs create mode 100644 docs/.vitepress/target.mjs create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/adr-005-vitepress-documentation-site.md create mode 100644 docs/package.json create mode 100644 docs/site/about/index.md create mode 100644 docs/site/api/index.md create mode 100644 docs/site/converters/index.md create mode 100644 docs/site/decisions/index.md create mode 100644 docs/site/deployment/index.md create mode 100644 docs/site/developers/index.md create mode 100644 docs/site/getting-started/index.md create mode 100644 docs/site/index.md create mode 100644 docs/site/release-notes/index.md create mode 100644 docs/site/security/index.md create mode 100644 docs/site/user-guide/index.md create mode 100644 docs/yarn.lock diff --git a/.dockerignore b/.dockerignore index 037d409290..cc17db240a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,10 @@ apps/frontend/tests apps/backend/test test **/node_modules + +# VitePress documentation site (ADR-005 §2.1) — the app image builds from +# explicit COPY paths, so docs/ never entered it implicitly; this makes the +# exclusion explicit. NOTE: shipping the BUILT docs inside the app image for +# offline/airgapped use (Aaron, 2026-08-11) is a separate packaging change and +# will need this entry narrowed (source + node_modules out, built output in). +docs diff --git a/.gitignore b/.gitignore index c7630119da..3d624cd3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,11 @@ data/* # Yarn 1's pack has no try/finally around the lifecycle scripts, so a failed # pack skips postpack and strands this file next to a mutated package.json. package.json.orig + +# VitePress documentation site (ADR-005). docs/ is deliberately OUTSIDE the yarn +# workspaces globs ("apps/*", "libs/*", "test"), so it has its own package.json +# and yarn.lock and root `yarn install` never sees it. Bare `node_modules` above +# already covers docs/node_modules; VitePress's cache and build output do not. +docs/node_modules +docs/.vitepress/cache +docs/.vitepress/dist diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs new file mode 100644 index 0000000000..8f732198be --- /dev/null +++ b/docs/.vitepress/config.mjs @@ -0,0 +1,135 @@ +import {defineConfig} from 'vitepress'; +import {target} from './target.mjs'; + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: 'Heimdall', + description: 'Visualize and analyze your security results', + + // Every per-target difference is declared in target.mjs, selected by + // HEIMDALL_DOCS_TARGET at build time. Nothing else in this file re-derives it. + base: target.base, + + // Publishing is STRUCTURAL: only content under site/ builds, so a tree + // outside it cannot be published and there is no exclude list to forget. + // This repo already keeps working documents beside the site — docs/research/ + // and the ADRs — and the flat layout in ADR-005 §2.3.1 would have tried to + // build them into the public site. (vulcan learned this the same way; + // ADR-005 predates their fix, so §2.3.1 is superseded — the §2.3 SECTION MAP + // below still governs.) + srcDir: 'site', + + // Clean URLs without .html extension + cleanUrls: true, + + // Last updated time (reads git timestamps — CI needs fetch-depth: 0) + lastUpdated: true, + + // Dead links FAIL the build (VitePress default; ADR-005 §5.1 makes it a + // standing rule). Deliberately no ignoreDeadLinks entry — if one becomes + // necessary it must arrive with its reason. + + head: [['meta', {name: 'theme-color', content: '#005288'}]], + + themeConfig: { + // Sections come from ADR-005 §2.3. Phase 1 ships the skeleton; Phase 3 + // migrates the 24 wiki pages into it. Section landing pages exist so the + // structure is navigable — and so the dead-link check has something real + // to check — while their content is explicitly Phase 3's. + nav: [ + {text: 'Guide', link: '/getting-started/'}, + {text: 'Deploy', link: '/deployment/'}, + {text: 'Converters', link: '/converters/'}, + {text: 'Developers', link: '/developers/'}, + {text: 'API', link: '/api/'}, + {text: 'Decisions', link: '/decisions/'} + ], + + sidebar: { + '/getting-started/': [ + { + text: 'Getting Started', + items: [{text: 'Overview', link: '/getting-started/'}] + }, + { + text: 'User Guide', + items: [{text: 'Overview', link: '/user-guide/'}] + } + ], + '/user-guide/': [ + { + text: 'Getting Started', + items: [{text: 'Overview', link: '/getting-started/'}] + }, + { + text: 'User Guide', + items: [{text: 'Overview', link: '/user-guide/'}] + } + ], + '/deployment/': [ + { + text: 'Deployment', + items: [{text: 'Overview', link: '/deployment/'}] + } + ], + '/converters/': [ + { + text: 'Converters', + items: [{text: 'Overview', link: '/converters/'}] + } + ], + '/developers/': [ + { + text: 'Developers', + items: [{text: 'Overview', link: '/developers/'}] + } + ], + '/api/': [ + {text: 'API', items: [{text: 'Overview', link: '/api/'}]} + ], + '/security/': [ + {text: 'Security', items: [{text: 'Overview', link: '/security/'}]} + ], + '/decisions/': [ + {text: 'Decisions', items: [{text: 'Overview', link: '/decisions/'}]} + ], + '/release-notes/': [ + { + text: 'Release Notes', + items: [{text: 'Overview', link: '/release-notes/'}] + } + ], + '/about/': [ + {text: 'About', items: [{text: 'Overview', link: '/about/'}]} + ] + }, + + // Built-in local search: zero dependencies, fully offline — which is the + // requirement for an airgapped install, and the wiki's biggest missing + // feature (ADR-005 §2.3.3). + search: {provider: 'local'}, + + // Outbound chrome, gated by target — dead links in an airgapped deployment. + ...(target.outboundChrome + ? { + socialLinks: [ + {icon: 'github', link: 'https://github.com/mitre/heimdall2'} + ], + editLink: { + pattern: + 'https://github.com/mitre/heimdall2/edit/master/docs/site/:path', + text: 'Edit this page on GitHub' + } + } + : {}), + + footer: { + message: 'Part of the MITRE Security Automation Framework (SAF)', + copyright: 'Copyright © 2026 MITRE Corporation' + }, + + docFooter: {prev: 'Previous', next: 'Next'}, + + outline: {level: [2, 3], label: 'On this page'} + } +}); diff --git a/docs/.vitepress/target.mjs b/docs/.vitepress/target.mjs new file mode 100644 index 0000000000..70cfab0245 --- /dev/null +++ b/docs/.vitepress/target.mjs @@ -0,0 +1,71 @@ +// What differs between builds of this documentation, in one place. +// +// Adapted from vulcan's docs/.vitepress/target.mjs (the ADR-005 §5.1 reference +// implementation). Shaped after VitePress's own `locales` table: shared +// configuration stays in config.mjs and each entry here declares ONLY what that +// target overrides. If a value is the same everywhere it does not belong here — +// the moment an entry looks like a whole configuration it has become a second +// source of truth. +// +// Targets are selected at BUILD time and are mutually exclusive, because `base` +// is baked into the generated asset URLs: a site built for GitHub Pages cannot +// be served from the application's /docs/ path, and vice versa. Offline/in-app +// docs therefore require their own build, not a copy of the published one. + +const TARGETS = { + // Published to GitHub Pages alongside Heimdall Lite: one deploy-pages artifact + // carries the Lite SPA at the site root and this site under /docs/ + // (ADR-005 §2.2.1). Same base as the in-app target — the documentation lives + // at /docs/ whether it is served by Pages or by the application itself. + pages: { + base: '/docs/', + inApp: false, + // Outbound chrome — the GitHub edit link and the social icons — is + // meaningful only where the internet is. Served in-app (and the driving + // case is a disconnected lab running the RPM install) every one of those + // links is dead, so the in-app target turns them off. + outboundChrome: true + }, + + // Local developer preview, served from the site root. + local: { + base: '/', + inApp: false, + outboundChrome: true + }, + + // Served by the Heimdall application itself, for offline/airgapped installs. + // The mount path is the application's fact — it is what defines the route — + // so it is passed in rather than restated here. + // + // HOW the application serves this build is deliberately NOT decided here: + // heimdall2 is NestJS + a Vue SPA whose ServeStaticModule answers 200 for any + // unmatched route, so the mount has to be researched before it is wired + // (Aaron, 2026-08-11). This target exists so the build is ready when that + // decision lands; nothing outside docs/ depends on it yet. + app: { + base: process.env.HEIMDALL_DOCS_BASE || '/docs/', + inApp: true, + outboundChrome: false + } +}; + +export const TARGET_NAMES = Object.keys(TARGETS); + +export function resolveTarget(name = process.env.HEIMDALL_DOCS_TARGET) { + const key = name || 'local'; + + // Object.hasOwn, not a truthiness check on TARGETS[key]: a plain object + // inherits from Object.prototype, so `constructor`, `toString` and friends + // resolve to inherited functions and would slip past the guard, yielding a + // target with base === undefined and a build with broken asset URLs. + if (!Object.hasOwn(TARGETS, key)) { + throw new Error( + `Unknown documentation target ${JSON.stringify(name)}. Expected one of: ${TARGET_NAMES.join(', ')}` + ); + } + + return {name: key, ...TARGETS[key]}; +} + +export const target = resolveTarget(); diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 0000000000..c5c07baa60 --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,11 @@ +// Minimal theme (ADR-005 §2.3.1: "minimal — SAF logo, theme color only"). +// +// Phase 1 deliberately ships a pass-through: the theme color travels as a head +// meta tag in config.mjs, and the SAF logo is an image asset that arrives with +// the rest of docs/site/public/ in the Phase 3 content migration. Declaring the +// extension point now — rather than adding it later — is what lets the in-app +// target style itself without touching the published build (vulcan applies its +// in-app stylesheet through exactly this seam). +import DefaultTheme from 'vitepress/theme'; + +export default DefaultTheme; diff --git a/docs/adr-005-vitepress-documentation-site.md b/docs/adr-005-vitepress-documentation-site.md new file mode 100644 index 0000000000..a490a6e3fe --- /dev/null +++ b/docs/adr-005-vitepress-documentation-site.md @@ -0,0 +1,387 @@ +# ADR-005: VitePress Documentation Site + +**Status:** Proposed +**Date:** 2026-07-10 +**Author:** Aaron Lippold +**Related:** ADR-004 (its Phase 9 documentation channel is the motivating problem), `mitre/vulcan` docs site (the proven reference implementation) + +--- + +## 1. Context + +### 1.1 The Problem + +Heimdall's user and operator documentation lives in the GitHub wiki (`mitre/heimdall2.wiki.git`) — a **separate git repository with no pull-request support**: no reviews, no branch protection, no CI, no forking through the UI. Anyone with write access pushes directly. Documentation changes therefore cannot ship in the same reviewed change set as the code they describe. + +ADR-004 made this concrete: its breaking change to `REGISTRATION_DISABLED` names the wiki as a **required** communication channel (ADR-004 §6.2), yet the wiki rewrite cannot ride PR #8383 — it is a separate, unreviewed push someone must remember to do at release time. Verified 2026-07-09 against a clone of the wiki repo: exactly one page documents `REGISTRATION_DISABLED` (as the pre-ADR-004 boolean), no page documents JIT provisioning at all, and the login page's help icon (`LocalLogin.vue`) deep-links users into the wiki. + +Additional forces: + +- The wiki is 26 pages (24 content pages + `_Sidebar`/`_Footer`) of plain Markdown — already portable. +- Repo-level docs (`README.md`, `apps/backend/README.md`, `libs/*/README.md`, `CODE_OF_CONDUCT.md`) and ADRs (`docs/adr-004`, this document) have no published home; ADRs in particular are invisible to deployers. +- `mitre/vulcan` solved this exact problem with VitePress; its setup was read directly this session (`docs/.vitepress/config.mjs`, `.github/workflows/docs.yml`) and serves as the reference implementation. +- **Hard constraint:** Heimdall is a Yarn-workspaces monorepo (`workspaces: ["apps/*", "libs/*", "test"]`) with a Vue 2 frontend. A docs toolchain on Vue 3 must be invisible to the app build. Modifying the root `package.json` workspaces configuration is prohibited (a past `nohoist` change broke the entire frontend build). + +### 1.2 Requirements + +1. Documentation changes are PR-reviewable and can ship in the same PR as code changes. +2. The Vue 3 docs toolchain and the Vue 2 app are mutually invisible — no shared dependency resolution, no root `package.json` changes. +3. Existing content (wiki pages, repo Markdown, ADRs) migrates rather than being rewritten. +4. Publishing is automatic on merge (no manual copy step to forget). + +--- + +## 2. Decision + +Adopt **VitePress**, following the `mitre/vulcan` pattern: a self-contained `docs/` directory inside `mitre/heimdall2` with its own `package.json` and `docs/yarn.lock`, built and deployed to GitHub Pages by a dedicated workflow, publishing the migrated wiki content, repo Markdown, and ADRs. The docs site supersedes the wiki as the canonical documentation channel; the wiki is reduced to pointer stubs. + +### 2.1 Isolation Design (the load-bearing detail) + +Verified against this repo's actual configuration: + +- Root workspaces are `["apps/*", "libs/*", "test"]` (mirrored in `lerna.json`). A top-level `docs/` **matches none of these globs**, so root `yarn install` never sees it: no hoisting, no shared resolution, no lockfile interaction. +- `docs/` gets its own `package.json` + `docs/yarn.lock`, installed only by `yarn install` inside `docs/`. VitePress 2 and Vue 3 exist solely in `docs/node_modules`. +- Node module resolution walks **up** from a file, never sideways into `docs/node_modules` — the app's Vue 2 and the docs' Vue 3 cannot meet. +- The root `package.json`, `lerna.json`, and all workspace configuration are **not modified**. This is an invariant, not an implementation detail: the acceptance proof is that root `yarn install` and the full app build behave identically before and after the scaffold. +- Docker: the Dockerfile copies specific paths (no blanket `COPY .`); `docs/` is additionally added to `.dockerignore` to make the exclusion explicit. +- Docs pages read files (or symlink root Markdown, as Vulcan does); they never `import` app code. + +### 2.2 Deployment + +> **Amended 2026-08-11 (Aaron).** The original text — "triggered on pushes to master +> touching `docs/**` … Base path `/heimdall2/` (project pages)" — was copied from +> Vulcan's `docs.yml` without reconciling it against two facts about *this* +> repository. Both are corrected below. The trigger and the base path in the +> original text are superseded; the rest of the recipe stands. + +The documentation has **two deployment targets**, selected at build time by +`docs/.vitepress/target.mjs` (§2.3.1). They are separate builds because `base` is +baked into asset URLs. + +**A. In-app (the driving requirement).** The built site ships **with the +application** so a disconnected or airgapped installation — the RPM case — has +its documentation offline. Because it travels inside the release artifact, the +in-app documentation is **release-pinned by construction**: the docs on disk +always describe exactly the version installed. How the NestJS application serves +this build (static mount, CSP, packaging paths) is Phase 7. + +**B. Published site.** A `.github/workflows/docs.yml` with `fetch-depth: 0` +(VitePress `lastUpdated` uses git timestamps), Node from `.nvmrc` (currently 22), +yarn cache keyed to `docs/yarn.lock`, and SHA-pinned actions. + +- **Trigger: `release: published`, plus `workflow_dispatch`** for out-of-band + documentation fixes — **not** pushes to master. Three reasons, in order of + weight: (1) publishing from master would contradict target A — the public site + would describe unreleased features while the docs shipped inside the user's + install describe the release, and the two must not disagree; (2) this + repository's existing Pages deployment (`.github/workflows/gh-pages.yml`) + already uses `release: published`, so release-triggered is the established + convention here; (3) mirroring the product's release process is the documented + practice for *product* documentation, as distinct from a tool's own + development-tip docs, which is the model Vulcan follows. `workflow_dispatch` + covers the real cost of this choice — a typo fix that would otherwise wait for + a release. +- **Base path: `/docs/` (see §2.2.1), and it is NOT `/heimdall2/`.** There is no + `mitre.github.io/heimdall2/` site. This repository's Pages is bound to a custom + domain and is **already occupied**: `gh api repos/mitre/heimdall2/pages` reports + `status: built`, `cname: heimdall-lite.mitre.org`, `source: {branch: gh-pages, + path: /}`, `build_type: legacy` — it serves **Heimdall Lite**, deployed on every + published release, with an approved TLS certificate. Any documentation + deployment must therefore choose a hosting shape (subdirectory of the existing + site, a dedicated docs domain, a separate repository, or migrating this + repository's Pages to an `actions/deploy-pages` workflow publishing one + artifact) **and must not clobber Heimdall Lite** — `peaceiris/actions-gh-pages` + publishes to the branch root and removes existing files by default. The chosen + shape determines `base`; it is one value in the target table. **That hosting + decision is open** — see §2.2.1. + +**The wiki stays live until the published site exists.** Heimdall's documentation +is public today; an in-app-only site would remove access for anyone without a +running instance. §5.3's dependency chain already enforces this — Phase 6 +(decommission) depends on Phase 5, which depends on Phase 2. + +#### 2.2.1 Hosting shape — DECIDED 2026-08-11 (Aaron) + +**One Pages site, published by `actions/deploy-pages` from a single artifact: +Heimdall Lite at `/`, the documentation at `/docs/`.** Four shapes were weighed — +a subdirectory added to the existing branch deploy, a dedicated docs domain, a +separate documentation repository, and this one. + +Why this one: + +- It is GitHub's current mechanism. The repository is on `build_type: legacy` + (GitHub serves whatever sits on the `gh-pages` branch, force-pushed there by + `peaceiris/actions-gh-pages`). Migrating to `actions/upload-pages-artifact` + + `actions/deploy-pages` removes the orphan branch entirely and deploys with OIDC + into a `github-pages` environment. +- **Atomic.** One artifact carries both sites, so there is no `destination_dir` / + `keep_files` arrangement to get wrong — and the default behaviour of the branch + deploy is to REMOVE existing files, which is exactly how Heimdall Lite would be + destroyed by a careless documentation deploy. +- **The cadences already match.** Both Heimdall Lite and the documentation deploy + on `release: published` (§2.2 B), so a single deployment is coherent rather than + a compromise. Publishing from master would have forced them apart. +- Deployment history and rollback become visible in the Actions UI, and + environment protection rules apply. + +Costs and risks, stated plainly: + +- It changes how a **live public site** deploys. `heimdall-lite.mitre.org` serves + real users under an approved certificate; a botched migration takes it down. + The migration must be verified end to end before a release relies on it. +- It requires a repository **settings change** (Pages source → GitHub Actions), + which is an administrator action, not a code change. Aaron holds admin on the + repository and performs this step when the workflow is ready — so it is a + sequencing item, not a blocker. +- The custom domain moves from a `CNAME` file written into the published + directory to the Pages configuration itself; the existing "Write + Heimdall-Lite CNAME file" step in `gh-pages.yml` is removed with it. + +Two consequences worth carrying forward: + +- **The published base becomes `/docs/`** — the same value the in-app target uses. + Whether the two targets can then share a single build, or still warrant separate + builds to strip outbound chrome for the airgapped case, is an implementation + question for Phase 2/Phase 7 rather than an architectural one. +- **This does not settle the domain NAME.** The site remains + `heimdall-lite.mitre.org` — a hostname named for the browser-only viewer, while + the documentation mostly describes Heimdall Server. Rebinding the Pages custom + domain is a separate, later decision and is not required by this one. + +### 2.3 Proposed Structure and Content Migration + +All 24 wiki content pages map into the site; existing repo Markdown is symlinked or included, never duplicated. Migration is move-and-organize: content is **not rewritten** in the migration pass (except the `REGISTRATION_DISABLED` page, owned by ADR-004 Phase 9), and license/notice/attribution files move **verbatim**. + +| Site section | Content | Source | +|---|---|---| +| `getting-started/` | Installation, configuration, environment variables, troubleshooting | Wiki: Environment-Variables-Configuration, Troubleshooting, Docker-Bake; repo: `.env-example` narrative | +| `user-guide/` | Using Heimdall, groups/users, attestations, auth methods | Wiki: Group-and-User-Management, Manual-Attestations, Heimdall-Authentication-Methods | +| `deployment/` | Production installs, platform configs, releases | Wiki: Oracle-Linux-Production-Install, MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations, Heimdall-Heroku-Documentation, How-to-create-a-Heimdall2-release | +| `developers/` | Architecture, code style, components, processes, tips | Wiki: Heimdall-Architecture-Information, Developers-Code-Style, Heimdall-Frontend-Components, Heimdall-Class-Diagrams, Heimdall-Processes-Documentation, Heimdall-Development-Tips-&-Tricks, Heimdall-Interface-Connections; repo: `apps/backend/README.md`, `libs/*/README.md` | +| `converters/` | HDF converter docs | Wiki: HDF-Converter-Mappings, HDF-Converters-How-Tos, CCI-Converter | +| `api/` | API documentation | Wiki: Heimdall-API-Documentation (vitepress-openapi rendering of a machine-readable spec is an investigation item, not a commitment) | +| `security/` | Security control responses | Wiki: Heimdall-Server-Security-Control-Responses | +| `decisions/` | Published ADRs | Repo: `docs/adr-004-*`, `docs/adr-005-*` (this file), future ADRs | +| `about/` | Attributions, code of conduct, license | Wiki: Technology-Attributions (verbatim); repo: `CODE_OF_CONDUCT.md`, `LICENSE.md`, `README.md` (symlinked) | +| Landing (`index.md`) | Home + navigation | Wiki: Home, _Sidebar (becomes the sidebar config) | + +#### 2.3.1 Concrete file tree (reference layout for Phases 1, 3, 4) + +> **Amended 2026-08-11 (Phase 1 implementation, authorized by Aaron).** Two changes, +> both adopted from Vulcan *after* this ADR was written (Vulcan landed them on +> 2026-08-10; this ADR is dated 2026-07-10): +> +> 1. **Content lives under `docs/site/`, selected by `srcDir: 'site'`** — not flat +> under `docs/`. Publishing becomes structural: a tree outside `site/` cannot be +> published, so there is no exclude list to forget. This repository already keeps +> working documents beside the site (`docs/research/`, the ADRs themselves), which +> the flat layout would have tried to build into the public site. +> 2. **A build-target seam, `docs/.vitepress/target.mjs`**, carries `base`, `inApp` +> and `outboundChrome`. Rationale: the documentation must ship **with the +> application** so a disconnected/airgapped lab running the RPM install has it +> offline. `base` is baked into asset URLs at build time, so the published site +> and the in-app site are necessarily separate builds (both now at base +> `/docs/` per §2.2.1, so they differ by outbound chrome rather than path), and +> outbound chrome (GitHub edit link, social icons) is turned off for the in-app +> target because those links are dead without a network. +> +> The §2.3 section map below is unchanged and still governs nav/sidebar. How the +> NestJS application serves the in-app build is deliberately not settled here. + +Pages marked **NEW** are thin additive pages created during migration (an index, a checklist skeleton); they are not content rewrites and do not violate §4.3. + +``` +docs/ # the docs PROJECT (own package.json + yarn.lock) +├── .vitepress/ +│ ├── config.mjs # nav/sidebar, srcDir: site, local search, dead-link check on +│ ├── target.mjs # per-target base/inApp/outboundChrome (pages | local | app) +│ └── theme/ # minimal — SAF logo, theme color only +└── site/ # the PUBLISHED tree — only this builds + ├── public/ # migrated images, saf-logo.svg + ├── index.md # landing page (spec below) + ├── getting-started/ +│ ├── quick-start.md ← Home.md (docker-compose path, split out) +│ ├── installation.md ← Home.md + Docker-Bake.md +│ ├── configuration.md ← Environment-Variables-Configuration.md (overview half) +│ ├── environment-variables.md← Environment-Variables-Configuration.md — THE canonical env +│ │ reference; everything else links here, never duplicates +│ │ (ADR-004 Phase 9 target) +│ └── troubleshooting.md ← Troubleshooting.md +├── user-guide/ +│ ├── overview.md ← Home.md (usage half) +│ ├── groups-and-users.md ← Group-and-User-Management.md +│ ├── attestations.md ← Manual-Attestations.md +│ └── authentication.md ← Heimdall-Authentication-Methods.md — owns the ADR-004 +│ account_not_provisioned explanation; LocalLogin.vue's help +│ icon points here +├── deployment/ +│ ├── production-checklist.md # NEW — TLS-mandatory (Helmet HSTS), REGISTRATION_DISABLED +│ │ posture (ADR-004 §8), LOCAL_LOGIN_DISABLED ordering caveat, +│ │ JWT/API-key secrets +│ ├── oracle-linux.md ← Oracle-Linux-Production-Install.md +│ ├── lite-and-demo.md ← MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations.md +│ ├── heroku.md ← Heimdall-Heroku-Documentation.md (migrate with a +│ │ possibly-outdated banner; dropping content is the owner's +│ │ per-page call, not the migrator's) +│ └── releases.md ← How-to-create-a-Heimdall2-release.md +├── converters/ +│ ├── mappings.md ← HDF-Converter-Mappings.md +│ ├── how-tos.md ← HDF-Converters-How-Tos.md +│ └── cci-converter.md ← Control-Correlation-Identifier-(CCI)-Converter.md +├── developers/ +│ ├── architecture.md ← Heimdall-Architecture-Information.md +│ ├── frontend-components.md ← Heimdall-Frontend-Components.md +│ ├── class-diagrams.md ← Heimdall-Class-Diagrams.md +│ ├── processes.md ← Heimdall-Processes-Documentation.md +│ ├── interface-connections.md← Heimdall-Interface-Connections.md +│ ├── code-style.md ← Developers-Code-Style.md +│ ├── tips-and-tricks.md ← Heimdall-Development-Tips-&-Tricks.md +│ ├── backend.md ← apps/backend/README.md (included, not duplicated) +│ └── libraries.md ← libs/inspecjs + libs/hdf-converters READMEs +├── api/ +│ └── index.md ← Heimdall-API-Documentation.md (vitepress-openapi later, +│ only if a maintained machine-readable spec exists — §4.3) +├── security/ +│ └── control-responses.md ← Heimdall-Server-Security-Control-Responses.md +├── decisions/ +│ ├── index.md # NEW — ADR index, one-line summary each +│ ├── adr-004-external-auth-user-provisioning-policy.md (moved from docs/ root) +│ └── adr-005-vitepress-documentation-site.md +├── release-notes/ # NEW section — versioned upgrade/migration notes; the +│ └── index.md ADR-004 breaking-change note is its first durable entry +│ (GitLab upgrade-notes pattern; wiki has no equivalent) +└── about/ + ├── attributions.md ← Technology-Attributions.md (verbatim) + ├── code-of-conduct.md → symlink ../CODE_OF_CONDUCT.md + └── license.md → symlink ../LICENSE.md (verbatim) +``` + +#### 2.3.2 Landing page (`index.md`) + +VitePress `layout: home` hero + features: + +- **Hero:** name "Heimdall", text "Visualize and analyze your security results", tagline covering InSpec + the 30+ formats via hdf-converters, SAF logo. Actions: Quick Start → `/getting-started/quick-start`, Live Demo → the demo URL currently in `README.md` (taken from there, not invented), Environment Variables → the canonical reference. +- **Features (4):** View & Analyze (upload HDF, filter, drill into controls) · 30+ Converters · Deploy Anywhere (Docker, RPM, cloud, enterprise SSO/LDAP) · Compliance-Ready (NIST 800-53 views, attestations, exports). +- **Top nav:** Guide · Deploy · Converters · Developers · API · Decisions, plus GitHub link. + +#### 2.3.3 Site capabilities + +- **Local search** via VitePress's built-in provider (`themeConfig.search: {provider: 'local'}`) — zero dependencies, and the wiki's biggest missing feature. +- **`getting-started/environment-variables.md` is the single source of truth for configuration** — other pages link to it; duplicating variable descriptions elsewhere is a review-blocking error. +- **Known gap, deliberately not filled here:** the repo has no `CONTRIBUTING.md`. Docs sites conventionally link one from the footer; whether to create one — and its content — is a separate owner decision, out of this ADR's scope. + +### 2.4 Wiki Decommission + +Wikis cannot redirect, so each migrated wiki page is edited down to a one-line pointer to its new URL, and wiki editing is restricted to collaborators. Hardcoded wiki deep links in the product move to the docs site — verified inventory: `LocalLogin.vue` (external-authentication help icon), `apps/backend/.env-example` (header link), `README.md` (wiki references). + +--- + +## 3. Alternatives Considered + +### Option A: Keep the wiki (do nothing) + +**Pros:** zero work; contributors know where it is. +**Cons:** the motivating problem — docs can never be PR-reviewed or ship with code changes; ADR-004's required channel stays a manual out-of-band push. **Rejected.** + +### Option B: Docs-in-repo, plain Markdown only (no site generator) + +Move wiki pages into `docs/` and rely on GitHub's Markdown rendering. +**Pros:** PR-reviewable, zero toolchain, zero isolation concerns. +**Cons:** no navigation/search/landing page for deployers; ADRs and 24+ pages become a flat file listing; no versioned public URL to point the login page's help link at. **Rejected** — solves review but not publication. + +### Option C: Keep the wiki, sync from repo via GitHub Action + +Author docs in-repo, push to the wiki repo on merge. +**Pros:** PR review; wiki URLs keep working. +**Cons:** two sources of truth with drift risk; wiki remains the renderer (no nav/search/theme); sync action is bespoke infrastructure; direct wiki edits silently diverge. **Rejected** — more moving parts than publishing directly. + +### Option D: MkDocs (Material) + +**Pros:** mature, excellent search, used widely by MITRE SAF projects. +**Cons:** Python toolchain in a Node monorepo (new ecosystem for contributors and CI); no organizational reference implementation as close as Vulcan's. **Rejected** — viable, but VitePress keeps the toolchain Node-native and copies a working in-house pattern. + +### Option E: Docusaurus + +**Pros:** mature, React-based, versioned docs built in. +**Cons:** React toolchain in a Vue shop; heavier than needed; same isolation question with a larger surface. **Rejected.** + +**Why VitePress:** Node/Vue-native (matches the team), the isolation problem is already solved and proven in-house (`mitre/vulcan` — same Vue 2 app + Vue 3 docs split, config and deploy workflow read directly and reusable nearly verbatim), and it publishes ADRs as first-class pages (Vulcan's `decisions/` section). + +--- + +## 4. Consequences + +### 4.1 Positive + +- Documentation changes ship in the same reviewed PR as code (ADR-004 Phase 9's wiki row is superseded the moment this lands — the `REGISTRATION_DISABLED` page becomes an in-PR `getting-started/environment-variables.md` edit). +- ADRs get a published, linkable home (`decisions/`). +- The login page's help link points at a reviewed, versioned page instead of a wiki page anyone with write access can alter. +- Publishing is automatic; there is no manual copy step to forget at release time. + +### 4.2 Negative / Risks + +- One more toolchain to keep current (VitePress/Vue 3 in `docs/`), though Dependabot picks up `docs/package.json` automatically. +- Wiki URLs in the wild break unless the stub-pointer pass is done thoroughly. +- The isolation invariant depends on nobody "helpfully" adding `docs` to the workspaces globs or importing app code into docs — stated as a hard rule here and enforced by the scaffold card's acceptance criteria. +- VitePress 2 is in alpha (Vulcan runs `2.0.0-alpha.11` in production docs); pin the version, upgrade deliberately. + +### 4.3 Out of Scope + +- Custom domain (GitHub Pages project URL is sufficient to start) +- Rewriting/modernizing page content during migration (move-and-organize only; content rewrites are follow-on work per page) +- Versioned docs (per-release snapshots) +- vitepress-openapi API rendering (investigation item — depends on a maintained machine-readable API spec) + +--- + +## 5. Implementation Plan + +### 5.1 Quality Standards (inherited by every card) + +- **Isolation invariant:** root `package.json`, `lerna.json`, and workspace config are never modified. Every card's verification includes: root `yarn install` and app builds behave identically before/after. +- **Existing pattern:** Vulcan's `docs/.vitepress/config.mjs` and `docs.yml` are the reference — deviate only with a stated reason. +- **Verbatim rule for legal/attribution content:** `LICENSE.md`, `Technology-Attributions`, `CODE_OF_CONDUCT.md` move without any wording changes. +- **Dead links fail the build:** VitePress builds with dead-link checking on; every migration card's verification is `yarn build` inside `docs/`. +- **SHA-pinned actions** in the workflow, matching Vulcan. +- **No app imports in docs pages** — file reads and symlinks only. + +### 5.2 Shared Abstractions + +| Shared need | Used by | Built in | +|---|---|---| +| `docs/` scaffold (package.json, config.mjs, theme, index) | every content card | Phase 1 | +| Sidebar/nav structure (from §2.3 table) | every content card | Phase 1 | +| Deploy workflow + Pages setup | publication | Phase 2 | + +### 5.3 Phases + +Tracked as epic **`heimdall2-yvx`** on the heimdall2 beads board; each phase below is child card `heimdall2-yvx.` (e.g. Phase 3 = `heimdall2-yvx.3`), with dependencies mirroring the Depends on column. ADR-004's Phase 9 card (`heimdall2-4qg.9`) soft-references this epic: once the docs site is live, its wiki deliverables become docs-site page edits. + +**Board access:** the board is a shared Dolt database published at `refs/dolt/data` in this repository. Install `bd` from [gastownhall/beads](https://github.com/gastownhall/beads), then run `bd dolt pull` from a heimdall2 checkout with an existing beads clone, or `bd bootstrap` on a fresh machine. **Upgrade note (2026-07-10):** the board schema was migrated v49 → v54 — if you have a pre-existing beads clone, run `bd dolt pull` on your *current* bd binary **before** upgrading bd; if you upgraded first and bd refuses to start, `bd bootstrap` re-clones (push any local issues first). The team agent skills used to work these cards (card template, TDD gates, AC verification) live in [mitre/mitre-saf-skills](https://github.com/mitre/mitre-saf-skills). + +> **Phase list amended 2026-08-11.** A new **Phase 7** carries the in-app +> deployment (§2.2 target A), which had no phase of its own. Phase 2's hosting +> shape is an open decision (§2.2.1); phase ORDER is not settled here. No +> dependency changes: Phase 6 still waits on Phase 5, which still waits on +> Phase 2 — that is what keeps the public wiki alive until a public site +> replaces it. + +| Phase | Scope | Depends on | Estimate | +|---|---|---|---| +| 1 | Scaffold: `docs/` with own package.json/yarn.lock, VitePress config (srcDir `site`, target seam, cleanUrls, lastUpdated, dead-link check), minimal theme, landing page, section skeleton, `.gitignore`/`.dockerignore`/eslint-ignore entries. AC: root install/build byte-identical | — | sp:3 | +| 2 | Deploy: `docs.yml` workflow (`release: published` + `workflow_dispatch`, SHA-pinned, Node from `.nvmrc`, yarn cache on `docs/yarn.lock`), hosting shape per §2.2.1 chosen without clobbering Heimdall Lite, site live | 1 | sp:2 | +| 7 | In-app documentation for offline/airgapped installs: build the `app` target, serve it from NestJS at `/docs/` (static mount ordered ahead of the SPA catch-all), resolve the CSP conflict with VitePress's inline theme scripts, and ship the built output in the RPM and container images | 1 | sp:5 | +| 3 | Content migration: 24 wiki pages into the §2.3 structure, nav/sidebar wired, internal links rewritten, images moved into `docs/public/` | 1 | sp:5 | +| 4 | Repo Markdown + ADRs: symlink root files (README, CODE_OF_CONDUCT, LICENSE verbatim), publish `decisions/` with ADR-004/ADR-005, include `apps/backend` and `libs/*` READMEs | 3 | sp:2 | +| 5 | Product link updates: `LocalLogin.vue` help URL, `.env-example` header, `README.md` wiki references → docs-site URLs | 2, 3 | sp:1 | +| 6 | Wiki decommission: every migrated page reduced to a pointer stub, wiki editing restricted, final content-parity check against the wiki clone | 3, 4, 5 | sp:2 | + +--- + +## 6. References + +- `mitre/vulcan` `docs/.vitepress/config.mjs` and `.github/workflows/docs.yml` — reference implementation (read directly 2026-07-09) +- `mitre/heimdall2.wiki.git` — migration source, cloned and audited 2026-07-09 (26 files, 24 content pages) +- ADR-004 §3.4 / §6.2 / Phase 9 — the documentation channel this ADR upgrades +- [VitePress documentation](https://vitepress.dev/) +- Root `package.json` workspaces / `lerna.json` — the isolation constraint (verified this session) diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..6868d8d129 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,16 @@ +{ + "name": "heimdall2-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Heimdall documentation site (VitePress) — ADR-005", + "scripts": { + "dev": "vitepress dev .", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "vitepress": "2.0.0-alpha.19", + "vue": "^3.5.18" + } +} diff --git a/docs/site/about/index.md b/docs/site/about/index.md new file mode 100644 index 0000000000..4088fed706 --- /dev/null +++ b/docs/site/about/index.md @@ -0,0 +1,19 @@ +# About + +Project information: attributions, code of conduct and licensing. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Technology attributions | Wiki: `Technology-Attributions` (moved verbatim) | +| Code of conduct | Repo: `CODE_OF_CONDUCT.md` (symlinked, verbatim) | +| License | Repo: `LICENSE.md` (symlinked, verbatim) | + +Legal and attribution content moves without wording changes (ADR-005 §5.1). diff --git a/docs/site/api/index.md b/docs/site/api/index.md new file mode 100644 index 0000000000..a38719ebff --- /dev/null +++ b/docs/site/api/index.md @@ -0,0 +1,19 @@ +# API + +Programmatic access to Heimdall Server. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| API overview | Wiki: `Heimdall-API-Documentation` | + +Rendering a machine-readable OpenAPI specification here is an investigation +item, not a commitment — it depends on a maintained spec existing (ADR-005 +§4.3). diff --git a/docs/site/converters/index.md b/docs/site/converters/index.md new file mode 100644 index 0000000000..c2914bbe99 --- /dev/null +++ b/docs/site/converters/index.md @@ -0,0 +1,18 @@ +# Converters + +`hdf-converters` normalizes security results from many tools into the Heimdall +Data Format, and back out again. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Converter mappings | Wiki: `HDF-Converter-Mappings` | +| How-tos | Wiki: `HDF-Converters-How-Tos` | +| CCI converter | Wiki: `Control-Correlation-Identifier-(CCI)-Converter` | diff --git a/docs/site/decisions/index.md b/docs/site/decisions/index.md new file mode 100644 index 0000000000..cf69e1bf00 --- /dev/null +++ b/docs/site/decisions/index.md @@ -0,0 +1,20 @@ +# Decisions + +Architecture Decision Records — the reasoning behind significant technical +choices, kept with the code they describe. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — the ADRs themselves are published here +by a later phase. +::: + +## Records planned for this section + +| Record | Subject | +| --- | --- | +| ADR-004 | External-auth user provisioning policy | +| ADR-005 | This documentation site | +| ADR-006 | PBKDF2 password hashing via a FIPS 140-3 validated module | + +They live in `docs/` in the repository until the migration publishes them here. diff --git a/docs/site/deployment/index.md b/docs/site/deployment/index.md new file mode 100644 index 0000000000..b85f9f3bfe --- /dev/null +++ b/docs/site/deployment/index.md @@ -0,0 +1,26 @@ +# Deployment + +Running Heimdall in production: install methods, platform configuration and +release process. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Production checklist | New — TLS posture, registration policy, JWT/API-key secrets | +| Oracle Linux install | Wiki: `Oracle-Linux-Production-Install` | +| Lite and demo configurations | Wiki: `MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations` | +| Heroku | Wiki: `Heimdall-Heroku-Documentation` | +| Releases | Wiki: `How-to-create-a-Heimdall2-release` | + +## Known gaps + +These have no wiki predecessor and are new documentation work: **RPM install** +(see `packaging/rpm/INSTALL.md` in the repository), **Docker install**, and +**Kubernetes / Helm** deployment. diff --git a/docs/site/developers/index.md b/docs/site/developers/index.md new file mode 100644 index 0000000000..9b2097d34a --- /dev/null +++ b/docs/site/developers/index.md @@ -0,0 +1,26 @@ +# Developers + +Architecture, components and day-to-day development practice for contributors. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Architecture | Wiki: `Heimdall-Architecture-Information` | +| Frontend components | Wiki: `Heimdall-Frontend-Components` | +| Class diagrams | Wiki: `Heimdall-Class-Diagrams` | +| Processes | Wiki: `Heimdall-Processes-Documentation` | +| Interface connections | Wiki: `Heimdall-Interface-Connections` | +| Code style | Wiki: `Developers-Code-Style` | +| Tips and tricks | Wiki: `Heimdall-Development-Tips-&-Tricks` | +| Backend | Repo: `apps/backend/README.md` (included, not duplicated) | +| Libraries | Repo: `libs/inspecjs`, `libs/hdf-converters` READMEs | + +Until then, the repository README's *For Developers* section covers local setup +and the two run modes. diff --git a/docs/site/getting-started/index.md b/docs/site/getting-started/index.md new file mode 100644 index 0000000000..48786f84dc --- /dev/null +++ b/docs/site/getting-started/index.md @@ -0,0 +1,22 @@ +# Getting Started + +Installation, configuration and first steps for Heimdall. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Quick start | Wiki: `Home` (docker-compose path) | +| Installation | Wiki: `Home`, `Docker-Bake` | +| Configuration | Wiki: `Environment-Variables-Configuration` (overview) | +| Environment variables | Wiki: `Environment-Variables-Configuration` — the canonical reference every other page links to | +| Troubleshooting | Wiki: `Troubleshooting` | + +Until then, see the [README](https://github.com/mitre/heimdall2#readme) and +`apps/backend/.env-example` in the repository. diff --git a/docs/site/index.md b/docs/site/index.md new file mode 100644 index 0000000000..5899fe7961 --- /dev/null +++ b/docs/site/index.md @@ -0,0 +1,38 @@ +--- +layout: home + +hero: + name: Heimdall + text: Visualize and analyze your security results + tagline: >- + The MITRE SAF viewer for InSpec results and 30+ other security data formats, + normalized through hdf-converters into a single view. + actions: + - theme: brand + text: Get Started + link: /getting-started/ + - theme: alt + text: Live Demo + link: https://heimdall-demo.mitre.org/ + - theme: alt + text: Deploy + link: /deployment/ + +features: + - title: View & Analyze + details: >- + Upload HDF results, filter and sort controls, and drill into findings with + the detail you need for review and hot-wash. + - title: 30+ Converters + details: >- + hdf-converters normalizes results from scanners, cloud posture tools and + checklists into the Heimdall Data Format, in both directions. + - title: Deploy Anywhere + details: >- + Docker, RPM, and cloud deployments, with enterprise authentication — + LDAP, OIDC, Okta, GitHub, GitLab and Google. + - title: Compliance-Ready + details: >- + NIST 800-53 control views, manual attestations, and exports to checklist, + CAAT and XCCDF formats. +--- diff --git a/docs/site/release-notes/index.md b/docs/site/release-notes/index.md new file mode 100644 index 0000000000..0fb4d27926 --- /dev/null +++ b/docs/site/release-notes/index.md @@ -0,0 +1,13 @@ +# Release Notes + +Versioned upgrade and migration notes — what changes between releases, and what +an operator must do about it. + +::: info Section under construction +This section is new: the wiki has no equivalent. It exists so breaking changes +and upgrade steps have a durable home rather than living only in release +descriptions. +::: + +Until entries are written here, see the +[GitHub releases](https://github.com/mitre/heimdall2/releases). diff --git a/docs/site/security/index.md b/docs/site/security/index.md new file mode 100644 index 0000000000..4552709d43 --- /dev/null +++ b/docs/site/security/index.md @@ -0,0 +1,15 @@ +# Security + +How Heimdall addresses security controls, and how to report a vulnerability. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Security control responses | Wiki: `Heimdall-Server-Security-Control-Responses` | diff --git a/docs/site/user-guide/index.md b/docs/site/user-guide/index.md new file mode 100644 index 0000000000..1840db354a --- /dev/null +++ b/docs/site/user-guide/index.md @@ -0,0 +1,19 @@ +# User Guide + +Using Heimdall day to day: loading results, managing access, and attesting to +controls. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Overview | Wiki: `Home` (usage half) | +| Groups and users | Wiki: `Group-and-User-Management` | +| Attestations | Wiki: `Manual-Attestations` | +| Authentication methods | Wiki: `Heimdall-Authentication-Methods` | diff --git a/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 0000000000..ccc8223830 --- /dev/null +++ b/docs/yarn.lock @@ -0,0 +1,890 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/parser@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.8.tgz#9653716a2f10c677b98fbc63d4bfb000c302cf17" + integrity sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA== + dependencies: + "@babel/types" "^7.29.8" + +"@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@docsearch/css@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-4.7.0.tgz#d6d93c6ddf5e813a3ea09da719e150c222693a5c" + integrity sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw== + +"@docsearch/js@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/js/-/js-4.7.0.tgz#6294b040c7a0e461f61120f54b0dc770af3916bf" + integrity sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA== + +"@docsearch/sidepanel-js@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/sidepanel-js/-/sidepanel-js-4.7.0.tgz#d767ca71f72c4673db87229f64634786a255bb08" + integrity sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA== + +"@iconify-json/simple-icons@^1.2.92": + version "1.2.93" + resolved "https://registry.yarnpkg.com/@iconify-json/simple-icons/-/simple-icons-1.2.93.tgz#7c9105fe0d679a32cf846e0e72305899500db3c3" + integrity sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw== + dependencies: + "@iconify/types" "*" + +"@iconify/types@*": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@oxc-project/types@=0.143.0": + version "0.143.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.143.0.tgz#c3e4f3178b7b54e4dd194eac6d45258a60f0092b" + integrity sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA== + +"@rolldown/binding-android-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz#001b8b0b01844701efda1bb6bed84b681c4a488b" + integrity sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw== + +"@rolldown/binding-darwin-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz#5e87c602ed634a6fef092e2162e24fbfb881c4ec" + integrity sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA== + +"@rolldown/binding-darwin-x64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz#f32e0b286714bd03a421d693415d05d97d265b77" + integrity sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA== + +"@rolldown/binding-freebsd-x64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz#5f38ad5761b6b7b21b57a99566bb52634c60ab19" + integrity sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz#ab4dcd07f1bd88e8d659ae0c3bb9d2f290adb897" + integrity sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw== + +"@rolldown/binding-linux-arm64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz#d279b7016039a725fb66d82784b9841f42df83da" + integrity sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw== + +"@rolldown/binding-linux-arm64-musl@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz#d08bbc93d2742214548c5adf7df7788944e5a89a" + integrity sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q== + +"@rolldown/binding-linux-ppc64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz#6418e63745b3193f26ab3bb88744b3a4a1356d7c" + integrity sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w== + +"@rolldown/binding-linux-s390x-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz#77ec30d0704cf4eb1cb4a63f501c9852c6728cf4" + integrity sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg== + +"@rolldown/binding-linux-x64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz#3b9b6e0dd3e86c597f42858748ca25f1dfd58ed8" + integrity sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w== + +"@rolldown/binding-linux-x64-musl@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz#f78033c592c8bd2af48284a45f8e4baaa0befbf5" + integrity sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A== + +"@rolldown/binding-openharmony-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz#36e951f5a6fca922a5205e283d0a82b9f98199ca" + integrity sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug== + +"@rolldown/binding-win32-arm64-msvc@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz#c1e494ac47e13bd857fca0b3ad59c33580241f7e" + integrity sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw== + +"@rolldown/binding-win32-x64-msvc@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz#b0effffcd6872f8a021373eb437916b1b52283a4" + integrity sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg== + +"@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + +"@shikijs/core@4.4.3", "@shikijs/core@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-4.4.3.tgz#00a942fa45ad0e4146ac6dbbac32b8b704b42e3f" + integrity sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg== + dependencies: + "@shikijs/primitive" "4.4.3" + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + hast-util-to-html "^9.0.5" + +"@shikijs/engine-javascript@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz#42dbdc18ec2f86003624674839a8f090cdd7cb62" + integrity sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + oniguruma-to-es "^4.3.6" + +"@shikijs/engine-oniguruma@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz#a1754f9f42e0f35a55cda9a977599041a2ad5b07" + integrity sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + +"@shikijs/langs@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-4.4.3.tgz#113282396f119dbba8d3b5e86668258fa8df6e7b" + integrity sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A== + dependencies: + "@shikijs/types" "4.4.3" + +"@shikijs/primitive@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/primitive/-/primitive-4.4.3.tgz#86490cea63b3e2c56b8d9163046e010258844d81" + integrity sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +"@shikijs/themes@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-4.4.3.tgz#8310a78261f4cf742663e07e2028df046a02bd72" + integrity sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw== + dependencies: + "@shikijs/types" "4.4.3" + +"@shikijs/transformers@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/transformers/-/transformers-4.4.3.tgz#18869d95b0e2656fa7ae98350e09408c57585018" + integrity sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw== + dependencies: + "@shikijs/core" "4.4.3" + "@shikijs/types" "4.4.3" + +"@shikijs/types@4.4.3", "@shikijs/types@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-4.4.3.tgz#019aff19f0cbfb21642c59f6f8432ced74e27b45" + integrity sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +"@shikijs/vscode-textmate@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" + integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + +"@types/hast@^3.0.0", "@types/hast@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.5.tgz#48020de4c0e63492f4ca9db42068c108f68b7f8f" + integrity sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== + dependencies: + "@types/unist" "*" + +"@types/linkify-it@^5": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" + integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== + +"@types/markdown-it@^14.1.2": + version "14.1.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" + integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + dependencies: + "@types/linkify-it" "^5" + "@types/mdurl" "^2" + +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/mdurl@^2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" + integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== + +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + +"@ungap/structured-clone@^1.0.0": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.3.tgz#094041e1a4cb1987f038335421281ac8be390bcc" + integrity sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg== + +"@vitejs/plugin-vue@^6.0.8": + version "6.0.8" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz#1809d090b7c93b8f4ae83d3e7536655cbbb1c793" + integrity sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew== + dependencies: + "@rolldown/pluginutils" "^1.0.1" + +"@vue/compiler-core@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz#82a4012d8b420f5a62c1658a72d16f7d1edf11aa" + integrity sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/shared" "3.5.41" + entities "^7.0.1" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz#1029f75de09c665a0a92c6156463754192acb361" + integrity sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw== + dependencies: + "@vue/compiler-core" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/compiler-sfc@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz#f9c7c2170ad6ee4bf35c178b5df026485d8a63db" + integrity sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/compiler-core" "3.5.41" + "@vue/compiler-dom" "3.5.41" + "@vue/compiler-ssr" "3.5.41" + "@vue/shared" "3.5.41" + estree-walker "^2.0.2" + magic-string "^0.30.21" + postcss "^8.5.19" + source-map-js "^1.2.1" + +"@vue/compiler-ssr@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz#8e5f9f6a8d21b802fce7373807dd935ed4541083" + integrity sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A== + dependencies: + "@vue/compiler-dom" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/devtools-api@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz#9d95de2b908aa80b9957d737ddca51790e70c3b5" + integrity sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A== + dependencies: + "@vue/devtools-kit" "^8.2.1" + +"@vue/devtools-kit@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz#ad45babb12c51931d32d1b67ffaebc251f550ce9" + integrity sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ== + dependencies: + "@vue/devtools-shared" "^8.2.1" + birpc "^2.6.1" + hookable "^5.5.3" + perfect-debounce "^2.0.0" + +"@vue/devtools-shared@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz#26524e9e12fd205bcd5477cf3b5e9a17b876aeac" + integrity sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g== + +"@vue/reactivity@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.41.tgz#83be61b88b198f21c157d3aa6dcb843a25a11872" + integrity sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA== + dependencies: + "@vue/shared" "3.5.41" + +"@vue/runtime-core@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz#a3e023c9f21809c81f24813dacb7ffa18a2e4161" + integrity sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg== + dependencies: + "@vue/reactivity" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/runtime-dom@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz#428f7a0420402385fae17d413d25169f98f64205" + integrity sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw== + dependencies: + "@vue/reactivity" "3.5.41" + "@vue/runtime-core" "3.5.41" + "@vue/shared" "3.5.41" + csstype "^3.2.3" + +"@vue/server-renderer@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz#035a38c79182f154495238ea83acba24266ac200" + integrity sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ== + dependencies: + "@vue/compiler-ssr" "3.5.41" + "@vue/runtime-dom" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/shared@3.5.41", "@vue/shared@^3.5.40": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.41.tgz#ac476497f74495f7525087270849841756555cf2" + integrity sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA== + +"@vueuse/core@14.4.0", "@vueuse/core@^14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.4.0.tgz#a841da6b3c7d548bdeed5bf611e73f3de62d20fa" + integrity sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "14.4.0" + "@vueuse/shared" "14.4.0" + +"@vueuse/integrations@^14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-14.4.0.tgz#32b6854e3d27bfe2cfee966a997f709022a7e3be" + integrity sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w== + dependencies: + "@vueuse/core" "14.4.0" + "@vueuse/shared" "14.4.0" + +"@vueuse/metadata@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.4.0.tgz#a2508499b803bac14775a9c9b852b8738fc16f98" + integrity sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g== + +"@vueuse/shared@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.4.0.tgz#4e89813c7859d153d48c03d74bff78739f96723b" + integrity sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g== + +birpc@^2.6.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.9.0.tgz#b59550897e4cd96a223e2a6c1475b572236ed145" + integrity sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw== + +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +focus-trap@^8.2.2: + version "8.2.2" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-8.2.2.tgz#6e8a203f2228ca8b5eb95465433e69a5d5e48387" + integrity sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug== + dependencies: + tabbable "^6.5.0" + +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +hast-util-to-html@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +mark.js@8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +minisearch@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/minisearch/-/minisearch-7.2.0.tgz#3dc30e41e9464b3836553b6d969b656614f8f359" + integrity sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg== + +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +oniguruma-parser@^0.12.2: + version "0.12.2" + resolved "https://registry.yarnpkg.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz#e27ca446f7fcf0969662a3ab9b4f43176d62b139" + integrity sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw== + +oniguruma-to-es@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz#43e640280241b0d687a314e7a641d476407a1c4d" + integrity sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA== + dependencies: + oniguruma-parser "^0.12.2" + regex "^6.1.0" + regex-recursion "^6.0.2" + +perfect-debounce@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261" + integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.4, picomatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + +postcss@^8.5.19, postcss@^8.5.25: + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== + dependencies: + nanoid "^3.3.17" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +property-information@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.2.0.tgz#0809b34264e995c0bfcd3227028a1e35210af80a" + integrity sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg== + +regex-recursion@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-6.0.2.tgz#a0b1977a74c87f073377b938dbedfab2ea582b33" + integrity sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg== + dependencies: + regex-utilities "^2.3.0" + +regex-utilities@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280" + integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng== + +regex@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/regex/-/regex-6.1.0.tgz#d7ce98f8ee32da7497c13f6601fca2bc4a6a7803" + integrity sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg== + dependencies: + regex-utilities "^2.3.0" + +rolldown@~1.2.1: + version "1.2.3" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.3.tgz#103bdcbbd575d51265277b8b510f080827b6eb6f" + integrity sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A== + dependencies: + "@oxc-project/types" "=0.143.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.2.3" + "@rolldown/binding-darwin-arm64" "1.2.3" + "@rolldown/binding-darwin-x64" "1.2.3" + "@rolldown/binding-freebsd-x64" "1.2.3" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.3" + "@rolldown/binding-linux-arm64-gnu" "1.2.3" + "@rolldown/binding-linux-arm64-musl" "1.2.3" + "@rolldown/binding-linux-ppc64-gnu" "1.2.3" + "@rolldown/binding-linux-s390x-gnu" "1.2.3" + "@rolldown/binding-linux-x64-gnu" "1.2.3" + "@rolldown/binding-linux-x64-musl" "1.2.3" + "@rolldown/binding-openharmony-arm64" "1.2.3" + "@rolldown/binding-win32-arm64-msvc" "1.2.3" + "@rolldown/binding-win32-x64-msvc" "1.2.3" + +shiki@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-4.4.3.tgz#31fb41c5c82435779a0b5a9b92a3b0377b061e15" + integrity sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g== + dependencies: + "@shikijs/core" "4.4.3" + "@shikijs/engine-javascript" "4.4.3" + "@shikijs/engine-oniguruma" "4.4.3" + "@shikijs/langs" "4.4.3" + "@shikijs/themes" "4.4.3" + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +tabbable@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" + integrity sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA== + +tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +unist-util-is@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz#9a2a28b0aa76a15e0da70a08a5863a2f060e2468" + integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + +vite@^8.2.0: + version "8.2.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.1.tgz#6fc8d8bb843bd52353091fac978e194d4de5b31d" + integrity sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.25" + rolldown "~1.2.1" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vitepress@2.0.0-alpha.19: + version "2.0.0-alpha.19" + resolved "https://registry.yarnpkg.com/vitepress/-/vitepress-2.0.0-alpha.19.tgz#c5f1b1597e4199170e909f270849e25c2cadf033" + integrity sha512-WnBsb0Bwr43kXKyiis+lld/7ri3hnMbthS8N3hpFtjjwsdLO4IRmiAE08D7aud4q6oMDf9uwRowxzNqRFe/amw== + dependencies: + "@docsearch/css" "^4.7.0" + "@docsearch/js" "^4.7.0" + "@docsearch/sidepanel-js" "^4.7.0" + "@iconify-json/simple-icons" "^1.2.92" + "@shikijs/core" "^4.4.1" + "@shikijs/transformers" "^4.4.1" + "@shikijs/types" "^4.4.1" + "@types/markdown-it" "^14.1.2" + "@vitejs/plugin-vue" "^6.0.8" + "@vue/devtools-api" "^8.2.1" + "@vue/shared" "^3.5.40" + "@vueuse/core" "^14.4.0" + "@vueuse/integrations" "^14.4.0" + focus-trap "^8.2.2" + mark.js "8.11.1" + minisearch "^7.2.0" + shiki "^4.4.1" + vite "^8.2.0" + vue "^3.5.40" + +vue@^3.5.18, vue@^3.5.40: + version "3.5.41" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.41.tgz#8864bddfe59ce128a28c9bad219cd9248916af73" + integrity sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg== + dependencies: + "@vue/compiler-dom" "3.5.41" + "@vue/compiler-sfc" "3.5.41" + "@vue/runtime-dom" "3.5.41" + "@vue/server-renderer" "3.5.41" + "@vue/shared" "3.5.41" + +zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/eslint.config.mjs b/eslint.config.mjs index e088e8a20d..96c768bae7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,19 @@ import vue from 'eslint-plugin-vue'; export default defineConfig([ { - ignores: ['**/dist', '**/lib', '**/node_modules', 'libs/inspecjs/src/generated_parsers/**'], + // `docs` is the VitePress documentation site (ADR-005 §2.1): an isolated + // project with its own package.json, lockfile and toolchain, deliberately + // outside the workspaces globs. This config's typed linting cannot parse + // its .vitepress sources (they belong to no tsconfig project), and its + // markdown answers to the docs build — `yarn build` inside docs/, with + // dead-link checking — not to the application's markdown rules. + ignores: [ + '**/dist', + '**/lib', + '**/node_modules', + 'libs/inspecjs/src/generated_parsers/**', + 'docs/**', + ], name: 'global ignores', }, { From f12ff86e32d1d2e303def64e1d2ad958a6b6057a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 11 Aug 2026 20:26:14 -0400 Subject: [PATCH 047/197] docs: separate internal records from the published docs tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the documentation directory contract before any content is written: docs/site/ carries EXTERNAL USER-FACING documentation only, and internal project records live beside it and are never published. srcDir: 'site' makes that structural — a tree outside site/ cannot be built into the site, so there is no exclude list to forget. - Move both ADRs into docs/adrs/ (git mv; ADR-006 is a content-identical move) - Add docs/plans/ alongside the existing docs/research/ - Remove the site's decisions/ section, which published ADRs as site pages Amend ADR-005 so the document states that contract throughout rather than the superseded one it was written under: the §1.1 premise that ADRs are "invisible to deployers" (which argued for the decisions/ section), §1.2 requirements 3 and 4, the §2 decision statement, the §2.3 migration rule, the §2.3.1 file tree, the §2.3.2 landing-page nav, the §3 tool-choice rationale, the §4.1 consequence and the §4.3 out-of-scope list. Each correction quotes the wording it replaces so the history stays legible. Replace the six-phase §5.3 plan with the current 17-card set, generated from the board: content is rewritten section by section rather than moved, since a 56-document inventory found the wiki largely stale. Authored by: Aaron Lippold --- docs/.vitepress/config.mjs | 11 ++- .../adr-005-vitepress-documentation-site.md | 98 +++++++++++++------ ...adr-006-fips-validated-password-hashing.md | 0 docs/plans/.gitkeep | 0 docs/site/decisions/index.md | 20 ---- 5 files changed, 74 insertions(+), 55 deletions(-) rename docs/{ => adrs}/adr-005-vitepress-documentation-site.md (73%) rename docs/{ => adrs}/adr-006-fips-validated-password-hashing.md (100%) create mode 100644 docs/plans/.gitkeep delete mode 100644 docs/site/decisions/index.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 8f732198be..bfbe7cf775 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -36,13 +36,17 @@ export default defineConfig({ // migrates the 24 wiki pages into it. Section landing pages exist so the // structure is navigable — and so the dead-link check has something real // to check — while their content is explicitly Phase 3's. + // + // site/ carries EXTERNAL USER-FACING documentation only (Aaron, + // 2026-08-11). Internal project records — ADRs, plans, research — live + // beside the site under docs/ and are structurally unpublishable because + // srcDir points at site/. There is deliberately no "decisions" section. nav: [ {text: 'Guide', link: '/getting-started/'}, {text: 'Deploy', link: '/deployment/'}, {text: 'Converters', link: '/converters/'}, {text: 'Developers', link: '/developers/'}, - {text: 'API', link: '/api/'}, - {text: 'Decisions', link: '/decisions/'} + {text: 'API', link: '/api/'} ], sidebar: { @@ -90,9 +94,6 @@ export default defineConfig({ '/security/': [ {text: 'Security', items: [{text: 'Overview', link: '/security/'}]} ], - '/decisions/': [ - {text: 'Decisions', items: [{text: 'Overview', link: '/decisions/'}]} - ], '/release-notes/': [ { text: 'Release Notes', diff --git a/docs/adr-005-vitepress-documentation-site.md b/docs/adrs/adr-005-vitepress-documentation-site.md similarity index 73% rename from docs/adr-005-vitepress-documentation-site.md rename to docs/adrs/adr-005-vitepress-documentation-site.md index a490a6e3fe..7b639cfd0d 100644 --- a/docs/adr-005-vitepress-documentation-site.md +++ b/docs/adrs/adr-005-vitepress-documentation-site.md @@ -18,7 +18,7 @@ ADR-004 made this concrete: its breaking change to `REGISTRATION_DISABLED` names Additional forces: - The wiki is 26 pages (24 content pages + `_Sidebar`/`_Footer`) of plain Markdown — already portable. -- Repo-level docs (`README.md`, `apps/backend/README.md`, `libs/*/README.md`, `CODE_OF_CONDUCT.md`) and ADRs (`docs/adr-004`, this document) have no published home; ADRs in particular are invisible to deployers. +- Repo-level user-facing docs (`README.md`, `apps/backend/README.md`, `libs/*/README.md`, `CODE_OF_CONDUCT.md`) have no published home. **Corrected 2026-08-11 (Aaron):** this bullet originally added ADRs to that list and argued they were "invisible to deployers" — the premise that produced the `decisions/` site section. ADRs are internal project records, not deployer documentation; they are deliberately never published and live in `docs/adrs/` beside the site (§2.3). - `mitre/vulcan` solved this exact problem with VitePress; its setup was read directly this session (`docs/.vitepress/config.mjs`, `.github/workflows/docs.yml`) and serves as the reference implementation. - **Hard constraint:** Heimdall is a Yarn-workspaces monorepo (`workspaces: ["apps/*", "libs/*", "test"]`) with a Vue 2 frontend. A docs toolchain on Vue 3 must be invisible to the app build. Modifying the root `package.json` workspaces configuration is prohibited (a past `nohoist` change broke the entire frontend build). @@ -26,14 +26,14 @@ Additional forces: 1. Documentation changes are PR-reviewable and can ship in the same PR as code changes. 2. The Vue 3 docs toolchain and the Vue 2 app are mutually invisible — no shared dependency resolution, no root `package.json` changes. -3. Existing content (wiki pages, repo Markdown, ADRs) migrates rather than being rewritten. -4. Publishing is automatic on merge (no manual copy step to forget). +3. External user-facing content is written to current practice rather than moved verbatim. **Corrected 2026-08-11 (Aaron):** the original requirement read "existing content (wiki pages, repo Markdown, ADRs) migrates rather than being rewritten"; a full 56-document inventory found the wiki largely stale, so it is rewritten section by section (§2.3), and ADRs never enter the site at all. +4. Publishing is automated with no manual copy step (triggered by `release: published` + `workflow_dispatch` per §2.2.1 — corrected 2026-08-11 from "automatic on merge"). --- ## 2. Decision -Adopt **VitePress**, following the `mitre/vulcan` pattern: a self-contained `docs/` directory inside `mitre/heimdall2` with its own `package.json` and `docs/yarn.lock`, built and deployed to GitHub Pages by a dedicated workflow, publishing the migrated wiki content, repo Markdown, and ADRs. The docs site supersedes the wiki as the canonical documentation channel; the wiki is reduced to pointer stubs. +Adopt **VitePress**, following the `mitre/vulcan` pattern: a self-contained `docs/` directory inside `mitre/heimdall2` with its own `package.json` and `docs/yarn.lock`, built and deployed to GitHub Pages by a dedicated workflow, publishing **external user-facing documentation only** — the rewritten wiki content and the user-facing repo Markdown. Internal project records (ADRs, plans, research) stay in `docs/` beside the site and are never published (§2.3). The docs site supersedes the wiki as the canonical documentation channel; the wiki is reduced to pointer stubs. ### 2.1 Isolation Design (the load-bearing detail) @@ -150,7 +150,19 @@ Two consequences worth carrying forward: ### 2.3 Proposed Structure and Content Migration -All 24 wiki content pages map into the site; existing repo Markdown is symlinked or included, never duplicated. Migration is move-and-organize: content is **not rewritten** in the migration pass (except the `REGISTRATION_DISABLED` page, owned by ADR-004 Phase 9), and license/notice/attribution files move **verbatim**. +> **Amended 2026-08-11 (Aaron), governing rule for this whole section:** +> **`site/` carries EXTERNAL USER-FACING documentation only.** Internal project +> records — ADRs, plans, research notes — are never published: they live beside +> the site under `docs/`, where `srcDir: 'site'` makes them structurally +> unbuildable into it. The original `decisions/` row (published ADRs) is removed +> accordingly, and Phase 4 carries only user-facing repo files (README, +> CODE_OF_CONDUCT, LICENSE, attributions), not ADRs. +> +> The internal trees are `docs/adrs/`, `docs/plans/` and `docs/research/` — +> Vulcan's `docs/{decisions,plans,research,site}` layout, with `adrs/` in place +> of its `decisions/` per the owner. Nothing outside `docs/site/` is published. + +The 24 wiki content pages map into the site sections below; existing repo Markdown is symlinked or included, never duplicated. **Amended 2026-08-11 (Aaron): content is REWRITTEN, not moved** — the inventory found the wiki largely stale, so each section is authored fresh against the current product by its own card (§5.3), using the wiki as source material rather than as text to relocate. Two rules survive that change unaltered: license/notice/attribution files move **verbatim**, and the `REGISTRATION_DISABLED` content is owned by ADR-004 Phase 9. | Site section | Content | Source | |---|---|---| @@ -161,7 +173,6 @@ All 24 wiki content pages map into the site; existing repo Markdown is symlinked | `converters/` | HDF converter docs | Wiki: HDF-Converter-Mappings, HDF-Converters-How-Tos, CCI-Converter | | `api/` | API documentation | Wiki: Heimdall-API-Documentation (vitepress-openapi rendering of a machine-readable spec is an investigation item, not a commitment) | | `security/` | Security control responses | Wiki: Heimdall-Server-Security-Control-Responses | -| `decisions/` | Published ADRs | Repo: `docs/adr-004-*`, `docs/adr-005-*` (this file), future ADRs | | `about/` | Attributions, code of conduct, license | Wiki: Technology-Attributions (verbatim); repo: `CODE_OF_CONDUCT.md`, `LICENSE.md`, `README.md` (symlinked) | | Landing (`index.md`) | Home + navigation | Wiki: Home, _Sidebar (becomes the sidebar config) | @@ -196,6 +207,9 @@ docs/ # the docs PROJECT (own package.json + yarn.lock │ ├── config.mjs # nav/sidebar, srcDir: site, local search, dead-link check on │ ├── target.mjs # per-target base/inApp/outboundChrome (pages | local | app) │ └── theme/ # minimal — SAF logo, theme color only +├── adrs/ # INTERNAL — architecture decision records; NEVER published +├── plans/ # INTERNAL — implementation plans; NEVER published +├── research/ # INTERNAL — research notes; NEVER published └── site/ # the PUBLISHED tree — only this builds ├── public/ # migrated images, saf-logo.svg ├── index.md # landing page (spec below) @@ -243,10 +257,6 @@ docs/ # the docs PROJECT (own package.json + yarn.lock │ only if a maintained machine-readable spec exists — §4.3) ├── security/ │ └── control-responses.md ← Heimdall-Server-Security-Control-Responses.md -├── decisions/ -│ ├── index.md # NEW — ADR index, one-line summary each -│ ├── adr-004-external-auth-user-provisioning-policy.md (moved from docs/ root) -│ └── adr-005-vitepress-documentation-site.md ├── release-notes/ # NEW section — versioned upgrade/migration notes; the │ └── index.md ADR-004 breaking-change note is its first durable entry │ (GitLab upgrade-notes pattern; wiki has no equivalent) @@ -262,7 +272,7 @@ VitePress `layout: home` hero + features: - **Hero:** name "Heimdall", text "Visualize and analyze your security results", tagline covering InSpec + the 30+ formats via hdf-converters, SAF logo. Actions: Quick Start → `/getting-started/quick-start`, Live Demo → the demo URL currently in `README.md` (taken from there, not invented), Environment Variables → the canonical reference. - **Features (4):** View & Analyze (upload HDF, filter, drill into controls) · 30+ Converters · Deploy Anywhere (Docker, RPM, cloud, enterprise SSO/LDAP) · Compliance-Ready (NIST 800-53 views, attestations, exports). -- **Top nav:** Guide · Deploy · Converters · Developers · API · Decisions, plus GitHub link. +- **Top nav:** Guide · Deploy · Converters · Developers · API, plus GitHub link. (Corrected 2026-08-11: the `Decisions` entry was removed with the `decisions/` section — §2.3.) #### 2.3.3 Site capabilities @@ -287,7 +297,7 @@ Wikis cannot redirect, so each migrated wiki page is edited down to a one-line p Move wiki pages into `docs/` and rely on GitHub's Markdown rendering. **Pros:** PR-reviewable, zero toolchain, zero isolation concerns. -**Cons:** no navigation/search/landing page for deployers; ADRs and 24+ pages become a flat file listing; no versioned public URL to point the login page's help link at. **Rejected** — solves review but not publication. +**Cons:** no navigation/search/landing page for deployers; 24+ user-facing pages become a flat file listing; no versioned public URL to point the login page's help link at. **Rejected** — solves review but not publication. ### Option C: Keep the wiki, sync from repo via GitHub Action @@ -305,7 +315,7 @@ Author docs in-repo, push to the wiki repo on merge. **Pros:** mature, React-based, versioned docs built in. **Cons:** React toolchain in a Vue shop; heavier than needed; same isolation question with a larger surface. **Rejected.** -**Why VitePress:** Node/Vue-native (matches the team), the isolation problem is already solved and proven in-house (`mitre/vulcan` — same Vue 2 app + Vue 3 docs split, config and deploy workflow read directly and reusable nearly verbatim), and it publishes ADRs as first-class pages (Vulcan's `decisions/` section). +**Why VitePress:** Node/Vue-native (matches the team), the isolation problem is already solved and proven in-house (`mitre/vulcan` — same Vue 2 app + Vue 3 docs split, config and deploy workflow read directly and reusable nearly verbatim), and its `srcDir` makes the published tree structural, so internal records cannot leak into the site (§2.3). (Corrected 2026-08-11: this reason originally read "it publishes ADRs as first-class pages (Vulcan's `decisions/` section)" — the superseded premise.) --- @@ -314,7 +324,7 @@ Author docs in-repo, push to the wiki repo on merge. ### 4.1 Positive - Documentation changes ship in the same reviewed PR as code (ADR-004 Phase 9's wiki row is superseded the moment this lands — the `REGISTRATION_DISABLED` page becomes an in-PR `getting-started/environment-variables.md` edit). -- ADRs get a published, linkable home (`decisions/`). +- Internal records (ADRs, plans, research) sit beside the site in the same reviewed repo, so a decision and the documentation it changes ship in one PR — without exposing project internals to end users. **Corrected 2026-08-11 (Aaron):** this line originally claimed ADRs gain "a published, linkable home (`decisions/`)"; they are not published (§2.3). - The login page's help link points at a reviewed, versioned page instead of a wiki page anyone with write access can alter. - Publishing is automatic; there is no manual copy step to forget at release time. @@ -328,7 +338,8 @@ Author docs in-repo, push to the wiki repo on merge. ### 4.3 Out of Scope - Custom domain (GitHub Pages project URL is sufficient to start) -- Rewriting/modernizing page content during migration (move-and-organize only; content rewrites are follow-on work per page) +- ~~Rewriting/modernizing page content during migration (move-and-organize only)~~ — **reversed 2026-08-11 (Aaron):** rewriting is now the work itself. Each site section is authored fresh against the current product by its own card (§5.3), with the wiki as source material. +- Publishing ADRs, plans or research (§2.3 — internal records never enter `site/`) - Versioned docs (per-release snapshots) - vitepress-openapi API rendering (investigation item — depends on a maintained machine-readable API spec) @@ -355,26 +366,53 @@ Author docs in-repo, push to the wiki repo on merge. ### 5.3 Phases -Tracked as epic **`heimdall2-yvx`** on the heimdall2 beads board; each phase below is child card `heimdall2-yvx.` (e.g. Phase 3 = `heimdall2-yvx.3`), with dependencies mirroring the Depends on column. ADR-004's Phase 9 card (`heimdall2-4qg.9`) soft-references this epic: once the docs site is live, its wiki deliverables become docs-site page edits. +Tracked as epic **`heimdall2-yvx`** on the heimdall2 beads board; every row below is a child card (`heimdall2-yvx.`), and the Depends on column mirrors the board's own dependencies. ADR-004's Phase 9 card (`heimdall2-4qg.9`) soft-references this epic: once the docs site is live, its wiki deliverables become docs-site page edits. **Board access:** the board is a shared Dolt database published at `refs/dolt/data` in this repository. Install `bd` from [gastownhall/beads](https://github.com/gastownhall/beads), then run `bd dolt pull` from a heimdall2 checkout with an existing beads clone, or `bd bootstrap` on a fresh machine. **Upgrade note (2026-07-10):** the board schema was migrated v49 → v54 — if you have a pre-existing beads clone, run `bd dolt pull` on your *current* bd binary **before** upgrading bd; if you upgraded first and bd refuses to start, `bd bootstrap` re-clones (push any local issues first). The team agent skills used to work these cards (card template, TDD gates, AC verification) live in [mitre/mitre-saf-skills](https://github.com/mitre/mitre-saf-skills). -> **Phase list amended 2026-08-11.** A new **Phase 7** carries the in-app -> deployment (§2.2 target A), which had no phase of its own. Phase 2's hosting -> shape is an open decision (§2.2.1); phase ORDER is not settled here. No -> dependency changes: Phase 6 still waits on Phase 5, which still waits on -> Phase 2 — that is what keeps the public wiki alive until a public site -> replaces it. +> **Phase list REPLACED 2026-08-11 (Aaron).** The original six phases assumed a +> move-and-organize migration, so all 24 wiki pages sat on one card. After the +> 56-document inventory and the rewrite-not-move ruling (§2.3), the epic was +> re-planned from 6 cards to 17: one card per site section, plus the `docs/` +> reorganization that establishes the internal-vs-published contract, the +> canonical environment-variables reference every other page links to, the +> Kubernetes/Helm documentation that exists nowhere today, and in-app serving +> for airgapped installs. Card numbers are identifiers, not an order — read the +> Depends on column. What did NOT change: the wiki stays alive until a public +> site replaces it, so `yvx.6` runs last, behind `yvx.5` and `yvx.2`. + +Foundation: + +| Card | Scope | Depends on | Size | +|---|---|---|---| +| `yvx.1` ✅ | Scaffold: `docs/` with own package.json/yarn.lock, VitePress config (srcDir `site`, target seam, cleanUrls, lastUpdated, dead-link check), minimal theme, landing page, section skeleton, `.gitignore`/`.dockerignore`/eslint-ignore entries. AC: root install/build byte-identical | — | sp:3 | +| `yvx.7` | Reorganize `docs/` into internal (`adrs/`, `plans/`, `research/`) and published (`site/`) trees; amend this ADR to state that contract | — | sp:2 | +| `yvx.8` | The canonical `environment-variables.md` — heimdall2 has no environment-variables document at all today, and two competing partial references (a wiki page and the 489-line RPM man page) that will drift | 7 | sp:5 | + +Content — one card per site section, written fresh against the current product: + +| Card | Scope | Depends on | Size | +|---|---|---|---| +| `yvx.9` | `getting-started/` — quick start, install index, configuration, troubleshooting | 8 | sp:5 | +| `yvx.10` | `user-guide/` — how to actually use the Heimdall UI (compare, treemap, filters, exports, tags). The largest gap: documented nowhere today | 7 | sp:5 | +| `yvx.11` | `deployment/` — one page per install method, each linking to the method's own runbook rather than duplicating it, plus hardening, backup and upgrade | 8 | sp:5 | +| `yvx.12` | `deployment/kubernetes.md` — the `mitre/heimdall-helm` chart, undocumented everywhere today. Includes the probe caveat: the SPA catch-all returns 200 for any unmatched route, so a status-only `httpGet` probe reports false-healthy | 11 | sp:5 | +| `yvx.13` | `converters/` — supported formats, how-tos, CCI converter | 7 | sp:3 | +| `yvx.14` | `developers/` — architecture, setup, code style, release process | 7 | sp:3 | +| `yvx.15` | `api/`, `security/`, `about/` — the remaining sections | 8 | sp:3 | + +Delivery and decommission: -| Phase | Scope | Depends on | Estimate | +| Card | Scope | Depends on | Size | |---|---|---|---| -| 1 | Scaffold: `docs/` with own package.json/yarn.lock, VitePress config (srcDir `site`, target seam, cleanUrls, lastUpdated, dead-link check), minimal theme, landing page, section skeleton, `.gitignore`/`.dockerignore`/eslint-ignore entries. AC: root install/build byte-identical | — | sp:3 | -| 2 | Deploy: `docs.yml` workflow (`release: published` + `workflow_dispatch`, SHA-pinned, Node from `.nvmrc`, yarn cache on `docs/yarn.lock`), hosting shape per §2.2.1 chosen without clobbering Heimdall Lite, site live | 1 | sp:2 | -| 7 | In-app documentation for offline/airgapped installs: build the `app` target, serve it from NestJS at `/docs/` (static mount ordered ahead of the SPA catch-all), resolve the CSP conflict with VitePress's inline theme scripts, and ship the built output in the RPM and container images | 1 | sp:5 | -| 3 | Content migration: 24 wiki pages into the §2.3 structure, nav/sidebar wired, internal links rewritten, images moved into `docs/public/` | 1 | sp:5 | -| 4 | Repo Markdown + ADRs: symlink root files (README, CODE_OF_CONDUCT, LICENSE verbatim), publish `decisions/` with ADR-004/ADR-005, include `apps/backend` and `libs/*` READMEs | 3 | sp:2 | -| 5 | Product link updates: `LocalLogin.vue` help URL, `.env-example` header, `README.md` wiki references → docs-site URLs | 2, 3 | sp:1 | -| 6 | Wiki decommission: every migrated page reduced to a pointer stub, wiki editing restricted, final content-parity check against the wiki clone | 3, 4, 5 | sp:2 | +| `yvx.17` | In-app documentation for offline/airgapped installs: build the `app` target, serve it from NestJS at `/docs/` (static mount ordered ahead of the SPA catch-all), resolve the CSP conflict with VitePress's inline theme scripts, ship the output in the RPM and container images | — | sp:5 | +| `yvx.2` | Publication: one `actions/deploy-pages` artifact per §2.2.1 — Heimdall Lite at `/`, docs at `/docs/`, on `release: published` + `workflow_dispatch`, SHA-pinned, without clobbering Lite | 1 | sp:3 | +| `yvx.4` | User-facing repo Markdown symlinked into the site (README, CODE_OF_CONDUCT, LICENSE verbatim, attributions); the ADR half of this card was removed when §2.3 made internal records unpublishable | 3 | sp:2 | +| `yvx.5` | Product link updates: `LocalLogin.vue` help URL, `.env-example` header, `README.md` wiki references → docs-site URLs | 2, 3 | sp:1 | +| `yvx.6` | Wiki decommission: every page reduced to a pointer stub, editing restricted, final parity check against the wiki clone | 3, 4, 5 | sp:2 | +| `yvx.16` | Correct the FIPS posture statement — gated on the FIPS release actually shipping, because the current statement is TRUE for the released product | 11 | sp:1 | + +`yvx.3` (migrate all 24 wiki pages as one card) is **closed as superseded** by the rewrite ruling and the per-section cards above. --- diff --git a/docs/adr-006-fips-validated-password-hashing.md b/docs/adrs/adr-006-fips-validated-password-hashing.md similarity index 100% rename from docs/adr-006-fips-validated-password-hashing.md rename to docs/adrs/adr-006-fips-validated-password-hashing.md diff --git a/docs/plans/.gitkeep b/docs/plans/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/site/decisions/index.md b/docs/site/decisions/index.md deleted file mode 100644 index cf69e1bf00..0000000000 --- a/docs/site/decisions/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# Decisions - -Architecture Decision Records — the reasoning behind significant technical -choices, kept with the code they describe. - -::: info Section under construction -The Heimdall documentation is moving from the GitHub wiki into this site -(ADR-005). This section is scaffolding — the ADRs themselves are published here -by a later phase. -::: - -## Records planned for this section - -| Record | Subject | -| --- | --- | -| ADR-004 | External-auth user provisioning policy | -| ADR-005 | This documentation site | -| ADR-006 | PBKDF2 password hashing via a FIPS 140-3 validated module | - -They live in `docs/` in the repository until the migration publishes them here. From c2816ef0f65759fac97d145b31fb4b73264cab38 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 18:56:33 -0400 Subject: [PATCH 048/197] feat(docs): add canonical environment-variables reference Heimdall had three partial, disagreeing environment-variable references and no authoritative one. This adds the single source of truth, derived by reading the code that reads each variable rather than copying from any existing document. Covers every variable the application reads, each with whether it is required, its default verified against the source that supplies it, and its effect. Records the traps that have cost real time: the process environment overrides .env, .env is read from the working directory, PORT is read by both the backend and the frontend dev server, NODE_ENV selects the database name, and JWT_EXPIRE_TIME is not honored in the units it accepts. Reconciles the three existing sources. DATABASE_URL and the six password hashing variables are read by code but were documented nowhere. NGINX_HOST and LOG_FILE are read only by deployment tooling, not the application. PASSWORD_MIN_LENGTH, PASSWORD_REQUIRE_CLASSES and PASSWORD_MAX_CONSECUTIVE are described by the RPM man page but read by no code, and are marked as such rather than left to be discovered in production. .env-example gains the variables it was missing and now points at the reference instead of the wiki. Authored by: Aaron Lippold --- apps/backend/.env-example | 30 +- docs/.vitepress/config.mjs | 16 +- .../getting-started/environment-variables.md | 301 ++++++++++++++++++ docs/site/getting-started/index.md | 10 +- 4 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 docs/site/getting-started/environment-variables.md diff --git a/apps/backend/.env-example b/apps/backend/.env-example index c5ca9fb1d8..18a9e14d7a 100644 --- a/apps/backend/.env-example +++ b/apps/backend/.env-example @@ -1,4 +1,6 @@ -# For more information on any of these variables, see https://github.com/mitre/heimdall2/wiki/Environment-Variables-Configuration#github +# The canonical reference for every variable below — required/default/effect, and +# the traps — is docs/site/getting-started/environment-variables.md. This file is a +# starting template, not the reference; when the two disagree, the reference wins. # If a variable does not have a value assigned, remove the variable. (e.g if you aren't using a custom DATABASE_NAME, remove the DATABASE_NAME line.) @@ -22,8 +24,10 @@ JWT_SECRET= JWT_EXPIRE_TIME= API_KEY_SECRET= MAX_FILE_UPLOAD_SIZE= +WARNING_BANNER= ## Database +DATABASE_URL= DATABASE_HOST= DATABASE_PORT= DATABASE_USERNAME= @@ -35,12 +39,22 @@ DATABASE_SSL_KEY= DATABASE_SSL_CA= +## Password hashing (validated-module PBKDF2; out-of-range values throw at startup) +FIPS_MODE= +PASSWORD_HASH_ALGORITHM= +PASSWORD_HASH_ITERATIONS= +PASSWORD_MAX_LENGTH= +PASSWORD_KDF_CONCURRENCY= +PASSWORD_HASH_WRITE_ENABLED= + ## Reverse proxy +# Read by the setup scripts and the NGINX template, never by the application itself. NGINX_HOST= ## External interfaces SPLUNK_HOST_URL= TENABLE_HOST_URL= +FORCE_TENABLE_FRONTEND= # Authentication @@ -75,11 +89,18 @@ GITHUB_ENTERPRISE_INSTANCE_API_URL= GITLAB_CLIENTSECRET= +# GITLAB_SECRET is the legacy name for GITLAB_CLIENTSECRET and is still accepted. +# Set only one; GITLAB_CLIENTSECRET wins when both are present. GITLAB_BASEURL= OKTA_DOMAIN=".okta.com" OKTA_CLIENTID= OKTA_CLIENTSECRET= +OKTA_ISSUER_URL= +OKTA_AUTHORIZATION_URL= +OKTA_TOKEN_URL= +OKTA_USER_INFO_URL= +OKTA_USE_HTTPS_PROXY= ## Custom OIDC Service OIDC_NAME= @@ -90,3 +111,10 @@ OIDC_USER_INFO_URL= OIDC_CLIENT_SECRET= OIDC_EXTERNAL_GROUPS= +OIDC_USES_PKCE_S256= +OIDC_USES_PKCE_PLAIN= +OIDC_USES_VERIFIED_EMAIL= +OIDC_USE_HTTPS_PROXY= + +## Proxy +HTTPS_PROXY= diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index bfbe7cf775..245cee079f 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -53,7 +53,13 @@ export default defineConfig({ '/getting-started/': [ { text: 'Getting Started', - items: [{text: 'Overview', link: '/getting-started/'}] + items: [ + {text: 'Overview', link: '/getting-started/'}, + { + text: 'Environment Variables', + link: '/getting-started/environment-variables' + } + ] }, { text: 'User Guide', @@ -63,7 +69,13 @@ export default defineConfig({ '/user-guide/': [ { text: 'Getting Started', - items: [{text: 'Overview', link: '/getting-started/'}] + items: [ + {text: 'Overview', link: '/getting-started/'}, + { + text: 'Environment Variables', + link: '/getting-started/environment-variables' + } + ] }, { text: 'User Guide', diff --git a/docs/site/getting-started/environment-variables.md b/docs/site/getting-started/environment-variables.md new file mode 100644 index 0000000000..f325e0765b --- /dev/null +++ b/docs/site/getting-started/environment-variables.md @@ -0,0 +1,301 @@ +--- +title: Environment Variables +description: The canonical reference for every environment variable Heimdall reads, with defaults verified against the source that supplies them. +outline: [2, 3] +--- + +# Environment Variables + +This page is the single source of truth for Heimdall's configuration. Every +variable below was derived by reading the code that reads it — not copied from +another document. Other pages link here rather than restating variable +descriptions. + +::: tip Where these go +The variable names are identical across every deployment method; only the file +that holds them changes. + +- **Development** — `apps/backend/.env` (start from `apps/backend/.env-example`) +- **Docker Compose** — the `environment:` block, or a `.env` beside `docker-compose.yml` +- **RPM** — `/etc/heimdall-server/backend.env` +::: + +## How configuration is loaded + +Heimdall reads configuration in `apps/backend/config/app_config.ts`. Three +behaviors surprise people, so they are stated up front. + +**The process environment wins over the `.env` file.** `AppConfig.get()` is +`process.env[key] || envConfig[key]`. A variable exported in the shell, set in a +systemd unit, or injected by Kubernetes overrides the same key in `.env`. It is +not the other way around. + +**`.env` is read from the working directory.** The file is loaded with a +relative `fs.readFileSync('.env')`, so it is found relative to where the process +was started, not relative to the installed application. Starting the server from +a different directory silently loads no file — the application logs +`Unable to read configuration file .env!` and continues on the process +environment alone. + +**An empty value is not the same as a default.** Most reads use `||`, so an +empty string behaves like unset and the default applies. A few variables +validate instead and refuse to start; those are called out individually. + +## Known traps + +These have each cost someone real time. + +::: warning PORT is read by two different servers +`PORT` sets the backend's listen port (default `3000`). The frontend dev server +reads its own configuration from `apps/frontend/.env.development` and +deliberately reads nothing from `apps/backend/.env`. Setting `PORT` in the +backend `.env` to steer the frontend broke local development on 2026-08-10. +Leave `PORT` unset for local development and use `API_PROXY_TARGET` for the +frontend proxy. +::: + +::: warning NODE_ENV selects the database name +When `DATABASE_NAME` is unset, the database name is derived as +`heimdall-server-${NODE_ENV}`. Changing `NODE_ENV` therefore silently points +Heimdall at a different database. If **both** `DATABASE_NAME` and `NODE_ENV` are +unset the application throws at startup rather than guessing. +::: + +::: warning JWT_EXPIRE_TIME is not currently honored in the units it accepts +`JWT_EXPIRE_TIME` is converted to milliseconds and passed to `jsonwebtoken`'s +`expiresIn`, which interprets the number as **seconds**. Sessions therefore last +far longer than configured — the `60s` default yields roughly 16.6 hours, and +`1d` yields roughly 2.7 years. The value is separately clamped to a maximum of +two days before that conversion, so the clamp does not bound the resulting +session either. This is a known open defect; treat the configured value as +advisory until it is fixed. +::: + +## Core server + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `NODE_ENV` | Runtime mode: `development`, `production` or `test`. Also selects the database name when `DATABASE_NAME` is unset. | Yes | none | +| `PORT` | Port the backend listens on. | No | `3000` | +| `EXTERNAL_URL` | Public URL of the deployment, used to build OAuth callback URLs. Required for any external auth provider. | No | empty | +| `MAX_FILE_UPLOAD_SIZE` | Maximum evaluation upload size, in megabytes. | No | `50` | +| `WARNING_BANNER` | Text shown in the login banner. Empty means no banner. | No | empty | + +## Database + +`DATABASE_URL` is parsed at startup into the individual `DATABASE_*` components, +so it can be used instead of setting them separately. It does not appear in +`apps/backend/.env-example`, but the application does read it. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `DATABASE_URL` | Full connection string. When set, it populates `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_HOST`, `DATABASE_NAME` and `DATABASE_PORT`. | No | none | +| `DATABASE_HOST` | Database hostname. | No | `127.0.0.1` | +| `DATABASE_PORT` | Database port. | No | `5432` | +| `DATABASE_USERNAME` | Database user. | No | `postgres` | +| `DATABASE_PASSWORD` | Database password. | No | empty | +| `DATABASE_NAME` | Database name. When unset, derived as `heimdall-server-${NODE_ENV}`. | No | derived | +| `DATABASE_SSL` | Enable TLS to the database. Any value other than `false` enables it. | No | `false` | +| `DATABASE_SSL_INSECURE` | Set to `true` to skip database certificate verification. A security risk; intended for self-signed development certificates only. | No | `false` | +| `DATABASE_SSL_KEY` | Client key — either an absolute path to the file, or the key material itself (detected by a `-BEGIN` marker). Required when `DATABASE_SSL` is enabled with client certificates. | No | none | +| `DATABASE_SSL_CERT` | Client certificate — path or inline material, same detection. | No | none | +| `DATABASE_SSL_CA` | Certificate authority — path or inline material, same detection. | No | none | + +::: warning +When a `DATABASE_SSL_*` value is given as a path, the file must exist at startup. +A missing file raises `SSL Key file does not exist` (or the `Cert`/`CA` +equivalent) and the application does not start. +::: + +## Password hashing + +Heimdall derives password hashes with PBKDF2 so that hashing is performed by a +FIPS 140-3 validated module. These values are validated at startup and **throw** +on anything out of range — they are never silently clamped. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `FIPS_MODE` | `true` refuses to start unless the OpenSSL provider reports FIPS active. `false` disables the assertion. Any other value throws. When unset, no assertion runs and the application warns loudly at boot. | No | unset | +| `PASSWORD_HASH_ALGORITHM` | PBKDF2 digest: `sha256`, `sha384` or `sha512`. Any other value throws. | No | `sha512` | +| `PASSWORD_HASH_ITERATIONS` | PBKDF2 iteration count. Accepted range is `100000`–`10000000`; outside it, startup throws. | No | `600000` | +| `PASSWORD_MAX_LENGTH` | Maximum accepted password length when hashing. Accepted range is `1`–`128`. | No | `128` | +| `PASSWORD_KDF_CONCURRENCY` | Number of password derivations allowed to run concurrently. Minimum `1`. | No | `2` | +| `PASSWORD_HASH_WRITE_ENABLED` | `true` or `false`; any other value throws. Gates whether new credentials are written as PBKDF2. Set it `false` during a rolling deploy so older instances can still read newly written credentials, then enable it after cutover. | No | derived — see below | + +::: info How PASSWORD_HASH_WRITE_ENABLED behaves when unset +Leaving it unset is the normal case — the gate is then derived from the state of +the database, and an explicit value always overrides that derivation. + +- A durable marker exists, meaning PBKDF2 writes already began on this database — **enabled**. This is sticky across restarts. +- No marker and the `Users` table is empty, meaning a fresh install — **enabled**, because no older instance can exist. +- No marker and users already exist, meaning an upgrade — **disabled**, because a rolling window with older instances is possible. +::: + +::: danger PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode +With writes disabled, new credentials fall back to bcrypt — which generates the +hash outside the validated module. If FIPS mode is active, that combination +throws rather than producing a hash outside the boundary. Enable PBKDF2 writes +before enabling FIPS mode. +::: + +::: info Verification is never gated +Only the hashing path reads these values. Verification reads its parameters from +the stored hash, so credentials written under an earlier algorithm, iteration +count or length limit keep working after you change these settings. +::: + +## Authentication + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `JWT_SECRET` | Signing secret for session tokens. When unset, a value is generated at startup, which invalidates all sessions on every restart. | Yes in production | generated | +| `JWT_EXPIRE_TIME` | Token lifetime, clamped to a maximum of two days. See the trap above regarding units. | No | `60s` | +| `API_KEY_SECRET` | Signing secret for API keys. API keys are disabled entirely when this is unset. | No | none | +| `LOCAL_LOGIN_DISABLED` | `true` disables username/password login, leaving only external providers. | No | `false` | +| `REGISTRATION_DISABLED` | `true` prevents self-registration; only an administrator can create users. | No | `false` | +| `ONE_SESSION_PER_USER` | `true` limits each user to a single active session. | No | `false` | +| `ADMIN_EMAIL` | Email address of the seeded administrator account. | No | `admin@heimdall.local` | +| `ADMIN_PASSWORD` | Password for the seeded administrator. When unset, a random password is generated and printed once, during initial setup. | No | generated | +| `ADMIN_USES_EXTERNAL_AUTH` | `true` seeds the administrator as an external-auth user with no local password. | No | `false` | + +### LDAP + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `LDAP_ENABLED` | `true` enables LDAP authentication. | No | `false` | +| `LDAP_HOST` | LDAP server hostname. | Yes, for LDAP | none | +| `LDAP_PORT` | LDAP server port. | No | `389` | +| `LDAP_BINDDN` | Distinguished name used for lookups. | Yes, for LDAP | none | +| `LDAP_PASSWORD` | Password for the lookup account. | Yes, for LDAP | none | +| `LDAP_SEARCHBASE` | Search base, for example `OU=Users, DC=example, DC=local`. | Yes, for LDAP | none | +| `LDAP_SEARCHFILTER` | Search filter. Active Directory typically uses `sAMAccountName={{username}}`. | No | `(sAMAccountName={{username}})` | +| `LDAP_NAMEFIELD` | Attribute holding the user's full name. | No | `name` | +| `LDAP_MAILFIELD` | Attribute holding the user's email. | No | `mail` | +| `LDAP_SSL` | `true` connects with `ldaps://` instead of `ldap://`. | No | `false` | +| `LDAP_SSL_INSECURE` | `true` skips LDAP certificate verification. A security risk. | No | `false` | +| `LDAP_SSL_CA` | Certificate authority — path or inline material. | No | none | + +### OAuth and OIDC + +Setting a provider's `*_CLIENTID` is what enables that provider; leaving it unset +disables it. Every provider also needs `EXTERNAL_URL` set, because the callback +URL is built from it. + +#### GitHub + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GITHUB_CLIENTID` | GitHub application client ID. Enables the provider. | Yes, for GitHub | none | +| `GITHUB_CLIENTSECRET` | GitHub application client secret. | Yes, for GitHub | none | +| `GITHUB_ENTERPRISE_INSTANCE_BASE_URL` | Base URL for GitHub Enterprise. | No | `https://github.com/` | +| `GITHUB_ENTERPRISE_INSTANCE_API_URL` | API URL for GitHub Enterprise. | No | `https://api.github.com/` | + +#### GitLab + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GITLAB_CLIENTID` | GitLab application client ID. Enables the provider. | Yes, for GitLab | none | +| `GITLAB_CLIENTSECRET` | GitLab application client secret. | Yes, for GitLab | none | +| `GITLAB_SECRET` | Legacy name for the client secret, still accepted. | No | none | +| `GITLAB_BASEURL` | GitLab base URL, for self-managed instances. | No | `https://gitlab.com` | + +::: info Two names for the GitLab client secret +`GITLAB_CLIENTSECRET` is canonical — it matches the other providers. Earlier +releases read only `GITLAB_SECRET`, so that name remains supported and existing +deployments need no change. When both are set, `GITLAB_CLIENTSECRET` wins. +::: + +#### Google + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GOOGLE_CLIENTID` | Google application client ID, usually ending in `.apps.googleusercontent.com`. Enables the provider. | Yes, for Google | none | +| `GOOGLE_CLIENTSECRET` | Google application client secret. | Yes, for Google | none | + +#### Okta + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `OKTA_CLIENTID` | Okta application client ID. Enables the provider. | Yes, for Okta | none | +| `OKTA_CLIENTSECRET` | Okta application client secret. | Yes, for Okta | none | +| `OKTA_DOMAIN` | Okta domain, for example `example.okta.com`. The issuer and endpoint URLs below are derived from it when they are not set explicitly. | Yes, for Okta | none | +| `OKTA_ISSUER_URL` | Override the derived issuer URL. | No | derived from `OKTA_DOMAIN` | +| `OKTA_AUTHORIZATION_URL` | Override the derived authorization endpoint. | No | derived | +| `OKTA_TOKEN_URL` | Override the derived token endpoint. | No | derived | +| `OKTA_USER_INFO_URL` | Override the derived user-info endpoint. | No | derived | +| `OKTA_USE_HTTPS_PROXY` | `true` routes Okta requests through the proxy named by `HTTPS_PROXY`. | No | `false` | + +#### Generic OIDC + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `OIDC_CLIENTID` | OIDC client ID. Enables the provider. | Yes, for OIDC | none | +| `OIDC_CLIENT_SECRET` | OIDC client secret. Note the underscore — this name differs from the other providers. | Yes, for OIDC | none | +| `OIDC_NAME` | Label shown on the login button. | Yes, for OIDC | empty | +| `OIDC_ISSUER` | Issuer URL, for example `https://example.auth0.com`. | Yes, for OIDC | none | +| `OIDC_AUTHORIZATION_URL` | Authorization endpoint. | Yes, for OIDC | none | +| `OIDC_TOKEN_URL` | Token endpoint. | Yes, for OIDC | none | +| `OIDC_USER_INFO_URL` | User-info endpoint. | Yes, for OIDC | none | +| `OIDC_EXTERNAL_GROUPS` | `true` maps groups from the provider. Groups are never created automatically — users are only mapped into groups that already exist. | No | `false` | +| `OIDC_USES_PKCE_S256` | `true` uses PKCE with the `S256` challenge method. | No | `false` | +| `OIDC_USES_PKCE_PLAIN` | `true` uses PKCE with the `plain` challenge method. Ignored when `OIDC_USES_PKCE_S256` is set. | No | `false` | +| `OIDC_USES_VERIFIED_EMAIL` | Set to `false` to accept provider emails that are not marked verified. | No | `true` | +| `OIDC_USE_HTTPS_PROXY` | `true` routes OIDC requests through the proxy named by `HTTPS_PROXY`. | No | `false` | +| `HTTPS_PROXY` | Proxy URL used when `OIDC_USE_HTTPS_PROXY` or `OKTA_USE_HTTPS_PROXY` is enabled. | No | none | + +## External interfaces + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `SPLUNK_HOST_URL` | Splunk host URL, without a port. Enables the Splunk integration in the frontend. | No | empty | +| `TENABLE_HOST_URL` | Tenable.SC host URL, without a port. Enables the Tenable integration in the frontend. | No | empty | +| `FORCE_TENABLE_FRONTEND` | `true` forces the Tenable interface in the frontend. | No | `false` | + +## Classification banner + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `CLASSIFICATION_BANNER_TEXT` | Banner text, for example `CUI`. No banner is shown when this is empty. | No | empty | +| `CLASSIFICATION_BANNER_COLOR` | Banner background color. | No | `red` | +| `CLASSIFICATION_BANNER_TEXT_COLOR` | Banner text color. | No | `white` | + +## Deployment-method specific + +These are read by installation tooling or the runtime host, not by the +application itself. + +| Variable | Description | Where it applies | Default | +| --- | --- | --- | --- | +| `NGINX_HOST` | Templated into the bundled NGINX configuration as `server_name`. Read by the setup scripts, never by the application. | Docker Compose, dev setup scripts | `localhost` | +| `LOG_FILE` | When set, the launcher redirects stdout and stderr to this path. Unset means logging to journald. The directory must be writable by the `heimdall` user. | RPM only | unset (journald) | +| `NODE_EXTRA_CA_CERTS` | Path to additional trusted CAs. Read by the Node runtime itself, not by Heimdall. Needed behind a TLS-inspecting proxy. | Any | none | +| `API_PROXY_TARGET` | Backend URL the frontend dev server proxies to. Lives in `apps/frontend/.env.development`. Unset or empty means no proxy, and the frontend runs as standalone Heimdall Lite. | Development only | empty | + +## Documentation build + +These affect building this documentation site, not the application. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `HEIMDALL_DOCS_TARGET` | Build target: `pages`, `local` or `app`. An unrecognized value fails the build. | No | `local` | +| `HEIMDALL_DOCS_BASE` | Base path, honored by the `app` target only. | No | `/docs/` | + +## Not environment variables + +The frontend source refers to `PACKAGE_VERSION`, `DESCRIPTION`, `REPOSITORY`, +`LICENSE`, `CHANGELOG`, `BRANCH` and `ISSUES` through `process.env`. These are +**not** runtime environment variables — they are substituted at build time from +`package.json` by webpack's `DefinePlugin`. Setting them in the environment has +no effect; change `package.json` and rebuild instead. + +## Documented elsewhere but not yet implemented + +The RPM manual page describes three password-complexity variables that this +application does not currently read. They are listed here so the discrepancy is +explicit rather than discovered in production. + +| Variable | Status | +| --- | --- | +| `PASSWORD_MIN_LENGTH` | Not read by Heimdall. Setting it has no effect today. | +| `PASSWORD_REQUIRE_CLASSES` | Not read by Heimdall. Setting it has no effect today. | +| `PASSWORD_MAX_CONSECUTIVE` | Not read by Heimdall. Setting it has no effect today. | diff --git a/docs/site/getting-started/index.md b/docs/site/getting-started/index.md index 48786f84dc..017863067e 100644 --- a/docs/site/getting-started/index.md +++ b/docs/site/getting-started/index.md @@ -8,6 +8,12 @@ The Heimdall documentation is moving from the GitHub wiki into this site migration. ::: +## Available now + +- [Environment Variables](/getting-started/environment-variables) — the + canonical reference for every variable Heimdall reads. Other pages link here + instead of restating variable descriptions. + ## Pages planned for this section | Page | Source | @@ -15,8 +21,6 @@ migration. | Quick start | Wiki: `Home` (docker-compose path) | | Installation | Wiki: `Home`, `Docker-Bake` | | Configuration | Wiki: `Environment-Variables-Configuration` (overview) | -| Environment variables | Wiki: `Environment-Variables-Configuration` — the canonical reference every other page links to | | Troubleshooting | Wiki: `Troubleshooting` | -Until then, see the [README](https://github.com/mitre/heimdall2#readme) and -`apps/backend/.env-example` in the repository. +Until then, see the [README](https://github.com/mitre/heimdall2#readme). From 54dd1dfc5ccb41679785d2ac221caa5312cdb197 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 18:57:30 -0400 Subject: [PATCH 049/197] fix(auth): accept GITLAB_CLIENTSECRET for the GitLab client secret gitlab.strategy.ts read GITLAB_SECRET, but apps/backend/.env-example and the RPM man page both documented GITLAB_CLIENTSECRET. Anyone configuring GitLab OAuth from either document got clientSecret 'disabled' and a silent authentication failure. GitLab was the only provider with this mismatch: GitHub, Google and Okta all use _CLIENTSECRET and OIDC uses OIDC_CLIENT_SECRET, each matching its documentation. Resolution now lives on ConfigService as getGitlabClientSecret(), following the existing getExternalUrl/getSplunkHostUrl/getTenableHostUrl getter idiom. GITLAB_CLIENTSECRET is canonical and takes precedence when both names are present. GITLAB_SECRET remains supported so deployments configured against the code rather than the docs keep working, and an empty value counts as absent, matching AppConfig.get's own truthiness fallback. Adds gitlab.strategy.spec.ts to cover the wiring, not just the resolution. Verified by mutation: reverting the strategy to configService.get('GITLAB_SECRET') fails both named tests. Without that spec the mutation passes silently, because the ConfigService tests stay green while GitLab OAuth is broken. Also removes an unreachable `|| 'disabled'` on callbackURL. A template literal is always truthy, so the fallback could never fire. This matches the okta and oidc strategies, which never had it. test/constants/env-test.constant.ts is renamed to environment_test.constant.ts to satisfy the repository's filename rules. Authored by: Aaron Lippold --- .../backend/src/authn/gitlab.strategy.spec.ts | 56 +++++++++++ apps/backend/src/authn/gitlab.strategy.ts | 43 ++++---- .../backend/src/config/config.service.spec.ts | 98 +++++++++++------- apps/backend/src/config/config.service.ts | 99 +++++++++++-------- .../test/constants/env-test.constant.ts | 14 --- .../constants/environment_test.constant.ts | 30 ++++++ 6 files changed, 228 insertions(+), 112 deletions(-) create mode 100644 apps/backend/src/authn/gitlab.strategy.spec.ts delete mode 100644 apps/backend/test/constants/env-test.constant.ts create mode 100644 apps/backend/test/constants/environment_test.constant.ts diff --git a/apps/backend/src/authn/gitlab.strategy.spec.ts b/apps/backend/src/authn/gitlab.strategy.spec.ts new file mode 100644 index 0000000000..45806dfc40 --- /dev/null +++ b/apps/backend/src/authn/gitlab.strategy.spec.ts @@ -0,0 +1,56 @@ +import { Test } from '@nestjs/testing'; +import mock, { load, restore } from 'mock-fs'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + GITLAB_CANONICAL_SECRET_ENV, + GITLAB_LEGACY_SECRET_ENV, +} from '../../test/constants/environment_test.constant'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; +import { GitlabStrategy } from './gitlab.strategy'; + +// The ConfigService unit tests prove getGitlabClientSecret resolves both +// spellings. These prove the STRATEGY asks for it — without them, reverting +// gitlab.strategy.ts to configService.get('GITLAB_SECRET') would leave every +// resolution test green while GitLab OAuth stayed broken for anyone who +// configured GITLAB_CLIENTSECRET from the documentation. +async function buildStrategy(environmentFile: string): Promise<{ + resolveSpy: ReturnType; + strategy: GitlabStrategy; +}> { + mock({ '.env': environmentFile, node_modules: load('node_modules') }); + const configService = new ConfigService(); + const resolveSpy = vi.spyOn(configService, 'getGitlabClientSecret'); + const moduleReference = await Test.createTestingModule({ + providers: [ + GitlabStrategy, + { provide: ConfigService, useValue: configService }, + { provide: AuthnService, useValue: {} }, + ], + }).compile(); + return { resolveSpy, strategy: moduleReference.get(GitlabStrategy) }; +} + +describe('GitlabStrategy', () => { + beforeAll(() => { + console.log(); + }); + + afterAll(() => { + restore(); + }); + + it('should resolve its client secret through getGitlabClientSecret', async () => { + const { resolveSpy, strategy } = await buildStrategy( + GITLAB_CANONICAL_SECRET_ENV, + ); + expect(strategy).toBeInstanceOf(GitlabStrategy); + expect(resolveSpy).toHaveBeenCalledTimes(1); + expect(resolveSpy).toHaveReturnedWith('canonical-secret'); + }); + + it('should resolve the legacy GITLAB_SECRET through the same path', async () => { + const { resolveSpy } = await buildStrategy(GITLAB_LEGACY_SECRET_ENV); + expect(resolveSpy).toHaveReturnedWith('legacy-secret'); + }); +}); diff --git a/apps/backend/src/authn/gitlab.strategy.ts b/apps/backend/src/authn/gitlab.strategy.ts index 8fa58d74c6..a04891feac 100644 --- a/apps/backend/src/authn/gitlab.strategy.ts +++ b/apps/backend/src/authn/gitlab.strategy.ts @@ -1,49 +1,46 @@ -import {Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from 'passport-gitlab2'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-gitlab2'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface UserEmail { - value: string; -} - -interface GitlabProfile { - username: string; - emails: UserEmail[]; +type GitlabProfile = { displayName: string; -} + emails: UserEmail[]; + username: string; +}; + +type UserEmail = { value: string }; @Injectable() export class GitlabStrategy extends PassportStrategy(Strategy, 'gitlab') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ - clientID: configService.get('GITLAB_CLIENTID') || 'disabled', - clientSecret: configService.get('GITLAB_SECRET') || 'disabled', baseURL: configService.get('GITLAB_BASEURL'), - callbackURL: - `${configService.getExternalUrl()}/authn/gitlab/callback` || 'disabled' + callbackURL: `${configService.getExternalUrl()}/authn/gitlab/callback`, + clientID: configService.get('GITLAB_CLIENTID') || 'disabled', + clientSecret: configService.getGitlabClientSecret() || 'disabled', }); } async validate( accessToken: string, refreshToken: string, - profile: GitlabProfile + profile: GitlabProfile, ): Promise { const email = profile.emails[0].value; - const {firstName, lastName} = this.authnService.splitName( - profile.displayName + const { firstName, lastName } = this.authnService.splitName( + profile.displayName, ); return this.authnService.validateOrCreateUser( email, firstName, lastName, - 'gitlab' + 'gitlab', ); } } diff --git a/apps/backend/src/config/config.service.spec.ts b/apps/backend/src/config/config.service.spec.ts index 5f24415668..661438f190 100644 --- a/apps/backend/src/config/config.service.spec.ts +++ b/apps/backend/src/config/config.service.spec.ts @@ -1,28 +1,31 @@ import * as dotenv from 'dotenv'; -import mock from 'mock-fs'; -import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import mock, { file, load, restore } from 'mock-fs'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { DATABASE_URL_MOCK_ENV, ENV_MOCK_FILE, - SIMPLE_ENV_MOCK_FILE -} from '../../test/constants/env-test.constant'; -import {ConfigService} from './config.service'; + GITLAB_BOTH_SECRETS_ENV, + GITLAB_CANONICAL_SECRET_ENV, + GITLAB_EMPTY_CANONICAL_SECRET_ENV, + GITLAB_LEGACY_SECRET_ENV, + SIMPLE_ENV_MOCK_FILE, +} from '../../test/constants/environment_test.constant'; +import { ConfigService } from './config.service'; // If you run the test without --silent , you need to add console.log() before you mock out the file system in the beforeAll() or it'll throw an error (this is a documented bug which can be found at https://github.com/tschaub/mock-fs/issues/234). If you run the test with --silent (which we do by default), you don't need the log statement. describe('Config Service', () => { - beforeAll(async () => { - // eslint-disable-next-line no-console + beforeAll(() => { console.log(); // Used as an empty file system mock({ // No files created (.env file does not exist yet), but pull through node_modules so the testing framework can run - node_modules: mock.load('node_modules') + node_modules: load('node_modules'), }); }); afterAll(() => { // Restore the fs binding to the real file system - mock.restore(); + restore(); }); describe('Tests the get function when .env file does not exist', () => { @@ -36,10 +39,10 @@ describe('Config Service', () => { // Used to make sure logs are outputted new ConfigService(); expect(consoleSpy).toHaveBeenCalledWith( - 'Unable to read configuration file `.env`!' + 'Unable to read configuration file `.env`!', ); expect(consoleSpy).toHaveBeenCalledWith( - 'Falling back to environment or undefined values!' + 'Falling back to environment or undefined values!', ); }); }); @@ -47,9 +50,7 @@ describe('Config Service', () => { describe('Tests the get function when .env file does exist', () => { beforeAll(() => { // Mock .env file - mock({ - '.env': ENV_MOCK_FILE - }); + mock({ '.env': ENV_MOCK_FILE }); }); it('should return the correct database name', () => { @@ -60,7 +61,7 @@ describe('Config Service', () => { expect(configService.get('DATABASE_USERNAME')).toEqual('postgres'); expect(configService.get('DATABASE_PASSWORD')).toEqual('postgres'); expect(configService.get('DATABASE_NAME')).toEqual( - 'heimdallts_vitest_testing_service_db' + 'heimdallts_vitest_testing_service_db', ); expect(configService.get('JWT_SECRET')).toEqual('abc123'); expect(configService.get('NODE_ENV')).toEqual('test'); @@ -75,11 +76,9 @@ describe('Config Service', () => { describe('Tests the get function when environment file is sourced externally', () => { beforeAll(() => { // Mock .env file - mock({ - '.env-loaded-externally': SIMPLE_ENV_MOCK_FILE - }); - // eslint-disable-next-line @typescript-eslint/no-var-requires - dotenv.config({path: '.env-loaded-externally'}); + mock({ '.env-loaded-externally': SIMPLE_ENV_MOCK_FILE }); + + dotenv.config({ path: '.env-loaded-externally' }); }); it('should return the correct database port', () => { @@ -95,22 +94,20 @@ describe('Config Service', () => { describe('When using DATABASE_URL', () => { beforeAll(() => { - mock({ - '.env': DATABASE_URL_MOCK_ENV - }); + mock({ '.env': DATABASE_URL_MOCK_ENV }); }); it('should correctly parse DATABASE_URL into its components', () => { const configService = new ConfigService(); expect(configService.get('DATABASE_HOST')).toEqual( - 'ec2-00-000-11-123.compute-1.amazonaws.com' + 'ec2-00-000-11-123.compute-1.amazonaws.com', ); expect(configService.get('DATABASE_PORT')).toEqual('5432'); expect(configService.get('DATABASE_USERNAME')).toEqual( - 'abcdefghijk123456' + 'abcdefghijk123456', ); expect(configService.get('DATABASE_PASSWORD')).toEqual( - '000011112222333344455556666777778889999aaaabbbbccccddddeeeffff' + '000011112222333344455556666777778889999aaaabbbbccccddddeeeffff', ); expect(configService.get('DATABASE_NAME')).toEqual('database01'); }); @@ -120,20 +117,18 @@ describe('Config Service', () => { it('should throw an EACCES error', () => { expect.assertions(1); mock({ - '.env': mock.file({ + '.env': file({ content: 'DATABASE_NAME=heimdallts_vitest_testing_service_db', - mode: 0o000 // Set file system permissions to none - }) + mode: 0o000, // Set file system permissions to none + }), }); expect(() => new ConfigService()).toThrowError( - "EACCES, permission denied '.env'" + "EACCES, permission denied '.env'", ); }); it('should throw an error in the get function', () => { - mock({ - '.env': ENV_MOCK_FILE - }); + mock({ '.env': ENV_MOCK_FILE }); const configService = new ConfigService(); vi.spyOn(configService, 'get').mockImplementationOnce(() => { throw new Error('Test error'); @@ -149,4 +144,41 @@ describe('Config Service', () => { expect(configService.get('test')).toBe('value'); }); }); + + // GITLAB_CLIENTSECRET is canonical — it matches GITHUB_CLIENTSECRET / + // GOOGLE_CLIENTSECRET / OKTA_CLIENTSECRET and is the name .env-example and + // the RPM man page have always documented. GITLAB_SECRET is the legacy name + // gitlab.strategy.ts actually read, so both must resolve or every deployment + // configured from either source breaks. + describe('getGitlabClientSecret', () => { + it('should resolve the canonical GITLAB_CLIENTSECRET', () => { + mock({ '.env': GITLAB_CANONICAL_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('canonical-secret'); + }); + + it('should resolve the legacy GITLAB_SECRET when the canonical name is unset', () => { + mock({ '.env': GITLAB_LEGACY_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('legacy-secret'); + }); + + it('should prefer the canonical name when both are set', () => { + mock({ '.env': GITLAB_BOTH_SECRETS_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('canonical-secret'); + }); + + it('should treat an empty canonical value as unset and fall back to the legacy name', () => { + mock({ '.env': GITLAB_EMPTY_CANONICAL_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('legacy-secret'); + }); + + it('should return undefined when neither name is set', () => { + mock({ '.env': SIMPLE_ENV_MOCK_FILE }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toBe(undefined); + }); + }); }); diff --git a/apps/backend/src/config/config.service.ts b/apps/backend/src/config/config.service.ts index b56c39f48a..1e9d21782a 100644 --- a/apps/backend/src/config/config.service.ts +++ b/apps/backend/src/config/config.service.ts @@ -1,46 +1,34 @@ -import {SequelizeOptions} from 'sequelize-typescript'; +import type { SequelizeOptions } from 'sequelize-typescript'; import AppConfig from '../../config/app_config'; -import {StartupSettingsDto} from './dto/startup-settings.dto'; +import { StartupSettingsDto } from './dto/startup-settings.dto'; export class ConfigService { private readonly appConfig: AppConfig; - public defaultGithubBaseURL = 'https://github.com/'; - public defaultGithubAPIURL = 'https://api.github.com/'; - - constructor() { - this.appConfig = new AppConfig(); - } + public defaultGithubAPIURL = 'https://api.github.com/'; + public defaultGithubBaseURL = 'https://github.com/'; public sensitiveKeys = [ - /cookie/i, - /passw(or)?d/i, - /^pw$/, - /^pass$/i, - /secret/i, - /token/i, - /api[-._]?key/i, - /data/i + /cookie/iv, + /passw(?:or)?d/iv, + /^pw$/v, + /^pass$/iv, + /secret/iv, + /token/iv, + /api[\-._]?key/iv, + /data/iv, ]; - isRegistrationAllowed(): boolean { - return this.get('REGISTRATION_DISABLED')?.toLowerCase() !== 'true'; - } - - isLocalLoginAllowed(): boolean { - return this.get('LOCAL_LOGIN_DISABLED')?.toLowerCase() !== 'true'; - } - - isInProductionMode(): boolean { - return this.get('NODE_ENV')?.toLowerCase() === 'production'; + constructor() { + this.appConfig = new AppConfig(); } enabledOauthStrategies() { const enabledOauth: string[] = []; - supportedOauth.forEach((oauthStrategy) => { + for (const oauthStrategy of supportedOauth) { if (this.get(`${oauthStrategy.toUpperCase()}_CLIENTID`)) { enabledOauth.push(oauthStrategy); } - }); + } return enabledOauth; } @@ -55,43 +43,70 @@ export class ConfigService { this.get('CLASSIFICATION_BANNER_TEXT_COLOR') || 'white', enabledOAuth: this.enabledOauthStrategies(), externalUrl: this.getExternalUrl(), + forceTenableFrontend: + this.get('FORCE_TENABLE_FRONTEND')?.toLowerCase() === 'true', + ldap: (this.get('LDAP_ENABLED')?.toLocaleLowerCase() === 'true'), + localLoginEnabled: this.isLocalLoginAllowed(), oidcName: this.get('OIDC_NAME') || '', - ldap: this.get('LDAP_ENABLED')?.toLocaleLowerCase() === 'true' || false, registrationEnabled: this.isRegistrationAllowed(), - localLoginEnabled: this.isLocalLoginAllowed(), + splunkHostUrl: this.getSplunkHostUrl(), tenableHostUrl: this.getTenableHostUrl(), - forceTenableFrontend: - this.get('FORCE_TENABLE_FRONTEND')?.toLowerCase() === 'true', - splunkHostUrl: this.getSplunkHostUrl() }); } + get(key: string): string | undefined { + return this.appConfig.get(key); + } + + getDbConfig(): SequelizeOptions { + return this.appConfig.getDbConfig(); + } + getExternalUrl(): string { return this.appConfig.getExternalUrl(); } + /** + * GitLab's client secret accepts two names. GITLAB_CLIENTSECRET is canonical: + * it matches GITHUB_CLIENTSECRET / GOOGLE_CLIENTSECRET / OKTA_CLIENTSECRET, + * and it is the name apps/backend/.env-example and the RPM man page have + * always documented. GITLAB_SECRET is the legacy name this application + * actually read, so it stays supported — dropping it would break every + * deployment configured from the code rather than the docs. + * + * The canonical name wins when both are set. An empty value counts as unset, + * matching AppConfig.get's own truthiness fallback. + */ + getGitlabClientSecret(): string | undefined { + return this.get('GITLAB_CLIENTSECRET') || this.get('GITLAB_SECRET'); + } + getSplunkHostUrl(): string { return this.appConfig.getSplunkHostUrl(); } + getSSLConfig(): false | Record { + return this.appConfig.getSSLConfig(); + } + getTenableHostUrl(): string { return this.appConfig.getTenableHostUrl(); } - getDbConfig(): SequelizeOptions { - return this.appConfig.getDbConfig(); + isInProductionMode(): boolean { + return this.get('NODE_ENV')?.toLowerCase() === 'production'; } - getSSLConfig(): false | Record { - return this.appConfig.getSSLConfig(); + isLocalLoginAllowed(): boolean { + return this.get('LOCAL_LOGIN_DISABLED')?.toLowerCase() !== 'true'; } - set(key: string, value: string | undefined): void { - this.appConfig.set(key, value); + isRegistrationAllowed(): boolean { + return this.get('REGISTRATION_DISABLED')?.toLowerCase() !== 'true'; } - get(key: string): string | undefined { - return this.appConfig.get(key); + set(key: string, value: string | undefined): void { + this.appConfig.set(key, value); } } export const supportedOauth: string[] = [ @@ -99,5 +114,5 @@ export const supportedOauth: string[] = [ 'gitlab', 'google', 'okta', - 'oidc' + 'oidc', ]; diff --git a/apps/backend/test/constants/env-test.constant.ts b/apps/backend/test/constants/env-test.constant.ts deleted file mode 100644 index 0563fa1320..0000000000 --- a/apps/backend/test/constants/env-test.constant.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const ENV_MOCK_FILE = - 'PORT=8000\n' + - 'DATABASE_HOST=localhost\n' + - 'DATABASE_PORT=5432\n' + - 'DATABASE_USERNAME=postgres\n' + - 'DATABASE_PASSWORD=postgres\n' + - 'DATABASE_NAME=heimdallts_vitest_testing_service_db\n' + - 'JWT_SECRET=abc123\n' + - 'NODE_ENV=test\n'; - -export const SIMPLE_ENV_MOCK_FILE = 'PORT=8001\n'; - -export const DATABASE_URL_MOCK_ENV = - 'DATABASE_URL=postgres://abcdefghijk123456:000011112222333344455556666777778889999aaaabbbbccccddddeeeffff@ec2-00-000-11-123.compute-1.amazonaws.com:5432/database01'; diff --git a/apps/backend/test/constants/environment_test.constant.ts b/apps/backend/test/constants/environment_test.constant.ts new file mode 100644 index 0000000000..63b1acc0d0 --- /dev/null +++ b/apps/backend/test/constants/environment_test.constant.ts @@ -0,0 +1,30 @@ +export const ENV_MOCK_FILE + = 'PORT=8000\n' + + 'DATABASE_HOST=localhost\n' + + 'DATABASE_PORT=5432\n' + + 'DATABASE_USERNAME=postgres\n' + + 'DATABASE_PASSWORD=postgres\n' + + 'DATABASE_NAME=heimdallts_vitest_testing_service_db\n' + + 'JWT_SECRET=abc123\n' + + 'NODE_ENV=test\n'; + +export const SIMPLE_ENV_MOCK_FILE = 'PORT=8001\n'; + +export const DATABASE_URL_MOCK_ENV + = 'DATABASE_URL=postgres://abcdefghijk123456:000011112222333344455556666777778889999aaaabbbbccccddddeeeffff@ec2-00-000-11-123.compute-1.amazonaws.com:5432/database01'; + +// GitLab's client secret has two accepted spellings. GITLAB_CLIENTSECRET is +// canonical — it matches GITHUB_CLIENTSECRET / GOOGLE_CLIENTSECRET / +// OKTA_CLIENTSECRET and is what .env-example and the RPM man page have always +// documented. GITLAB_SECRET is the legacy name the strategy actually read, so +// deployments configured against the code rather than the docs keep working. +export const GITLAB_CANONICAL_SECRET_ENV + = 'GITLAB_CLIENTSECRET=canonical-secret\n'; + +export const GITLAB_LEGACY_SECRET_ENV = 'GITLAB_SECRET=legacy-secret\n'; + +export const GITLAB_BOTH_SECRETS_ENV + = 'GITLAB_CLIENTSECRET=canonical-secret\nGITLAB_SECRET=legacy-secret\n'; + +export const GITLAB_EMPTY_CANONICAL_SECRET_ENV + = 'GITLAB_CLIENTSECRET=\nGITLAB_SECRET=legacy-secret\n'; From 41a9422a2e5695d7dd651a3e85c2ea83678d3847 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 18:57:55 -0400 Subject: [PATCH 050/197] chore(lint): repair the lint configuration and adopt Prettier Repo-wide lint was silently broken and reported 58,124 problems. Almost none of it was code quality. This fixes the configuration; no source is changed by this commit. lint:ci could never fail. It ran `eslint --max-warnings 0 || true` with no path, so it linted `.`, crashed, and the `|| true` swallowed the non-zero exit. The crash is an upstream bug: @eslint/markdown 8.0.2 on ESLint 10.5.0 throws "Custom getLoc() method must be implemented in the subclass" when a GFM email autolink appears inside underscore emphasis. Minimal reproduction: `_a@b.com_` crashes, `*a@b.com*` does not. One tracked file trips it, so it is ignored with the reproduction recorded, and the `|| true` is removed. Import resolution was never configured for this monorepo. import-x and eslint-plugin-n resolved from the repo root and never saw apps/frontend/tsconfig.json, where `@/*` is defined, producing 744 errors for imports that resolve in every real build. Adds a createTypeScriptImportResolver over each package's tsconfig, and turns off n/no-missing-import for the frontend only, since eslint-plugin-n models Node resolution while the frontend is bundler-resolved. lodash is CommonJS with a dynamically built export object, so import-x/namespace reported 797 errors for members that demonstrably exist. Handled with the rule's documented ignore, scoped to lodash. Static data tables are excluded, extending the generated_parsers precedent already in this config. NiktoNistMappingData.ts alone produced 26,836 messages, of which 26,823 were "use single quotes" and "do not quote props" against a generated lookup table. Scoped to the *MappingData.ts suffix so the sibling *Mapping.ts logic stays linted. perfectionist is no longer extended. Every sort-* rule is a suggestion-type autofix that rewrites the AST, which ESLint's own guidance says must not be auto-applied when a fix can change behavior, and in this repo they did: sort-decorators reordered sequelize decorators until the backend suite stopped collecting, sort-classes landed in an unsatisfiable conflict with unicorn, and sort-imports relocates order-dependent CSS and prismjs imports. Formatting moves to Prettier, per ESLint's own recommendation since it deprecated formatting rules. eslint-config-prettier is placed last so the two tools stop competing; eslint-plugin-prettier is deliberately not used, as Prettier documents it as discouraged. .prettierignore excludes the 365 MB fixture corpus that tests compare against, the assets guarded by validate-generated, and the data tables. Scripts now separate safe formatting from behavior-changing fixes: `lint` applies only layout fixes, which cannot restructure the AST, while the AST-rewriting pass is explicitly named lint:fix:unsafe. Result: 58,124 problems to 5,754, with no source file modified. Authored by: Aaron Lippold --- .git-blame-ignore-revs | 13 +++++ .prettierignore | 33 +++++++++++ .prettierrc.json | 4 ++ eslint.config.mjs | 123 ++++++++++++++++++++++++++++++++++------- package.json | 10 +++- yarn.lock | 10 ++++ 6 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..041254e47f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,13 @@ +# Commits that changed formatting only, and should be skipped by `git blame`. +# +# Enable locally (once per clone): +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# GitHub honours this file automatically in its blame view. +# +# Add the full 40-character SHA of a pure-formatting commit below, one per line, +# with a comment naming it. Only add commits that changed NOTHING but formatting +# — if a commit mixes formatting with behavior, blame must not skip it. + +# (none yet — the initial `yarn format` adoption commit belongs here once the +# repo-wide Prettier reformat lands) diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..5936d5d5d2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,33 @@ +# Prettier owns formatting for SOURCE. Everything below is either build output, +# third-party/generated content, or fixture data that is compared byte-for-byte +# by tests — reformatting any of it changes meaning, not just appearance. + +# Build output and dependencies +**/dist +**/lib +**/node_modules +yarn.lock +package-lock.json + +# Test fixture corpora. 365 MB across 249 files; the mapper specs read these +# with readFileSync/JSON.parse and compare results against them, so reformatting +# would both take enormous time and risk changing what the tests assert. +libs/hdf-converters/sample_jsons/ + +# Generated assets guarded by the `validate-generated` script, which fails the +# build if their committed bytes change (tailwind style.css + the embedded +# strings derived from it). +libs/hdf-converters/data/ + +# Generated sources — same set the ESLint config ignores, kept in sync. +libs/inspecjs/src/generated_parsers/ +libs/hdf-converters/src/ckl-mapper/jsonixMapping.ts + +# Static data tables, not code: giant literal maps with no logic. Formatting +# them produces enormous diffs and zero benefit. +**/*MappingData.ts +apps/frontend/src/utilities/cci_util.ts + +# The documentation site is an isolated project (ADR-005 §2.1) with its own +# package.json and toolchain; it is not formatted by the application's tooling. +docs/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000000..84cc551fd2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 96c768bae7..203358ab93 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,11 +7,15 @@ import json from '@eslint/json'; import markdown from '@eslint/markdown'; import stylistic from '@stylistic/eslint-plugin'; import vitest from '@vitest/eslint-plugin'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import { + createTypeScriptImportResolver, + defaultExtensions, +} from 'eslint-import-resolver-typescript'; import { importX } from 'eslint-plugin-import-x'; import markdownLinks from 'eslint-plugin-markdown-links'; import markdownPreferences from 'eslint-plugin-markdown-preferences'; import n from 'eslint-plugin-n'; -import perfectionist from 'eslint-plugin-perfectionist'; import promise from 'eslint-plugin-promise'; import regexp from 'eslint-plugin-regexp'; import security from 'eslint-plugin-security'; @@ -38,6 +42,17 @@ export default defineConfig([ '**/node_modules', 'libs/inspecjs/src/generated_parsers/**', 'docs/**', + // Static DATA tables and generated sources — extending the + // generated_parsers precedent directly above. These hold no logic, so + // linting them yields no correctness signal (TypeScript still type-checks + // them); what it did yield was 36k formatting complaints that buried the + // real findings. NiktoNistMappingData.ts alone produced 26,836 messages — + // 17,882 "use single quotes" and 8,941 "don't quote props", i.e. exactly + // 3 per data row. Scoped to the *MappingData.ts suffix on purpose: the + // sibling *Mapping.ts / *MappingItem.ts files ARE logic and stay linted. + '**/*MappingData.ts', + 'libs/hdf-converters/src/ckl-mapper/jsonixMapping.ts', // "Generated by jsonix-schema-compiler" + 'apps/frontend/src/utilities/cci_util.ts', // 14k-line CCI_DESCRIPTIONS table, zero functions ], name: 'global ignores', }, @@ -65,7 +80,24 @@ export default defineConfig([ security.configs.recommended, importX.flatConfigs.recommended, importX.flatConfigs.typescript, - { ...perfectionist.configs['recommended-natural'], name: 'perfectionist/recommended-natural' }, + // perfectionist's preset is deliberately NOT extended. Its sort-* rules + // carry zero correctness value and every one is a `suggestion`-type + // autofix — the AST-rewriting class ESLint's own docs say must not be + // auto-applied when "a fix potentially changes functionality". In this + // repo they did exactly that: sort-decorators reordered sequelize + // decorators and the whole backend suite stopped collecting; + // sort-classes landed in an unsatisfiable conflict with + // unicorn/consistent-class-member-order; sort-objects reordering object + // literals contributed to a typing break in evaluations.service.ts. + // sort-objects is also unsound in principle — key order is semantic + // across a spread ({...defaults, mode} !== {mode, ...defaults}). + // The import-ordering rules were kept at first, then dropped too: + // sort-imports MOVES side-effect imports, which are order-dependent. + // Verified by dry run — it relocated `import '@mdi/font/...css'` behind + // the vuetify import in plugins/vuetify.ts, reordering the CSS cascade, + // and ControlRowDetails.vue has five prismjs imports where the core must + // load before its language components register onto it. No sort-* rule + // here can reach zero without rewriting order that carries meaning. { ...e18e.configs.modernization, name: 'e18e/modernization' }, { ...e18e.configs.performanceImprovements, name: 'e18e/performanceImprovements' }, cypress.configs.recommended, @@ -106,24 +138,6 @@ export default defineConfig([ '@typescript-eslint/prefer-nullish-coalescing': 'off', curly: 'error', 'n/no-missing-import': ['error', { tryExtensions: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.json'] }], - 'perfectionist/sort-imports': [ - 'error', - { - groups: [ - ['type-builtin', 'value-builtin'], - ['type-external', 'value-external'], - ['type-internal', 'value-internal'], - ['type-parent', 'value-parent'], - ['type-sibling', 'value-sibling'], - ['type-index', 'value-index'], - 'ts-equals-import', - 'unknown', - ], - newlinesBetween: 0, - type: 'natural', - useExperimentalDependencyDetection: true, - }, - ], 'prefer-object-has-own': 'error', 'unicorn/filename-case': ['error', { case: 'snakeCase' }], 'unicorn/no-null': 'off', @@ -131,6 +145,48 @@ export default defineConfig([ 'unicorn/prefer-node-protocol': 'off', 'unicorn/prevent-abbreviations': 'off', }, + // Without this, import-x/n resolve imports from the REPO ROOT and never see + // apps/frontend/tsconfig.json, where `@/*` -> `./src/*` is defined. Every + // `@/...` import in the frontend then reports as unresolved — ~744 errors + // for imports that resolve correctly in every real build. Listing each + // package's tsconfig lets the resolver pick the one closest to the file + // being linted (see the resolver's affinity sorting). + settings: { + // lodash is CommonJS and builds its export object dynamically, so + // import-x cannot statically enumerate its members. With 62 files doing + // `import * as _ from 'lodash'`, import-x/namespace reported 797 errors + // of the form "'get' not found in imported namespace '_'" — for + // functions that demonstrably exist (`typeof _.get === 'function'`). + // Every one was a false positive. This is the rule's documented escape + // hatch for modules whose exports can't be analyzed; it is scoped to + // lodash, so namespace checking stays active for every other module. + 'import-x/ignore': ['lodash'], + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ + extensions: [...defaultExtensions, '.vue'], + noWarnOnMultipleProjects: true, + project: [ + 'tsconfig.json', + 'apps/*/tsconfig.json', + 'libs/*/tsconfig.json', + 'test/tsconfig.json', + ], + }), + ], + }, + }, + { + // eslint-plugin-n models NODE.JS runtime resolution. apps/frontend is a + // webpack-bundled Vue app whose `@/*` imports are resolved by the BUNDLER + // via tsconfig paths — Node never resolves them, so the rule reports 326 + // failures for imports that are correct. `settings.n.tsconfigPath` does not + // teach it those aliases (verified: relative and absolute both still fail). + // import-x/no-unresolved covers the same ground and, with the resolver + // configured above, reports them correctly — so this is redundant here, + // not merely inconvenient. The backend IS a Node app and keeps the rule. + files: ['apps/frontend/**/*.{js,mjs,cjs,ts,mts,cts,vue}'], + name: 'n/frontend-bundler-resolution', + rules: { 'n/no-missing-import': 'off' }, }, { extends: [json.configs.recommended], @@ -154,10 +210,37 @@ export default defineConfig([ markdownPreferences.configs.standard, ], files: ['**/*.md'], + // UPSTREAM BUG WORKAROUND (2026-08-12). @eslint/markdown 8.0.2 on ESLint + // 10.5.0 throws "Custom getLoc() method must be implemented in the + // subclass" (from @eslint/plugin-kit 0.7.2) when a GFM email autolink sits + // inside UNDERSCORE emphasis. Minimal reproduction: + // _a@b.com_ -> crash + // *a@b.com* -> fine + // **a@b.com** -> fine + // a@b.com -> fine + // _no-at-sign_ -> fine + // The crash aborts the ENTIRE eslint run, not just the offending file, so a + // single line of markdown silently disabled linting for the whole repo — + // which is how ~56k violations accumulated unnoticed (`lint:ci` also had a + // `|| true` that hid the non-zero exit). + // Only one tracked file trips it: the RPM man page, at + // `**ADMIN_EMAIL**=_admin@heimdall.local_`. + // Remove this ignore once the upstream fix lands and @eslint/markdown is + // bumped; verify with the five-line reproduction above. + ignores: ['packaging/rpm/man/heimdall-server-backend.env.5.md'], language: 'markdown/gfm', name: 'markdown', plugins: { markdown }, }, + // MUST BE LAST. eslint-config-prettier only turns rules OFF — every + // formatting rule that would fight the formatter — so anything placed after + // it would switch those rules back on and reintroduce the conflict. + // Per Prettier's own guidance: formatting belongs to the formatter, code + // quality to the linter, and the two run as separate tools. + // eslint-plugin-prettier (Prettier AS an ESLint rule) is deliberately NOT + // used — Prettier documents it as discouraged: slower, and it reports + // formatting as lint errors, which is exactly the noise this removes. + eslintConfigPrettier, ]); // Should we retain this naming convention for any (i.e. common, hdf-converters projects) / all interfaces (soon to be mostly all types) // "@typescript-eslint/naming-convention": [ diff --git a/package.json b/package.json index 1c67da4ccd..88b6a588d7 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "frontend": "yarn workspace @mitre/heimdall-lite", "hdf-converters": "yarn workspace @mitre/hdf-converters", "inspecjs": "yarn workspace inspecjs", - "lint": "eslint --fix", - "lint:ci": "eslint --max-warnings 0 || true", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint --fix --fix-type layout", + "lint:check": "eslint", + "lint:ci": "eslint --max-warnings 0", + "lint:fix:unsafe": "eslint --fix", "pack:all": "lerna exec yarn pack --scope inspecjs --scope @mitre/heimdall-lite --scope @mitre/hdf-converters --parallel", "start": "yarn backend start", "start:built": "yarn build && yarn backend start", @@ -44,6 +48,7 @@ "@vitest/eslint-plugin": "^1.6.14", "dotenv-cli": "^11.0.0", "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-cypress": "^6.3.0", "eslint-plugin-import-x": "^4.16.2", @@ -57,6 +62,7 @@ "eslint-plugin-unicorn": "^68.0.0", "eslint-plugin-vue": "^10.8.0", "eslint-plugin-yml": "^3.3.1", + "prettier": "^3.9.6", "typescript-eslint": "^8.56.1", "vue-eslint-parser": "^10.4.0" }, diff --git a/yarn.lock b/yarn.lock index 2e8eea8884..b7859c4167 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8772,6 +8772,11 @@ eslint-compat-utils@^0.5.1: dependencies: semver "^7.5.4" +eslint-config-prettier@^10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + eslint-import-context@^0.1.8, eslint-import-context@^0.1.9: version "0.1.9" resolved "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz#967b0b2f0a90ef4b689125e088f790f0b7756dbe" @@ -15712,6 +15717,11 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +prettier@^3.9.6: + version "3.9.6" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6" + integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g== + pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" From 3bdd1f146f3fe9373bce9d25dfe11fcda8bc9dc8 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 18:58:38 -0400 Subject: [PATCH 051/197] style(backend): apply lint autofixes and repair what they broke Runs the ESLint autofixer across apps/backend, taking it from 3,801 problems to 612. Mostly formatting, but three changes are behavioral and called out because the autofixer introduced the breakage itself. tenable.controller.ts: the fixer rewrote `interface SessionData` to `type SessionData` inside `declare module 'express-session'`. Module augmentation works by declaration merging and only interfaces merge, so this declared a second, conflicting SessionData rather than extending the exported one. Result was TS2300 plus TS2339 on every session.tenable access. Restored to an interface with a scoped, documented disable, since no `type` form of this exists. evaluations.service.ts: the fixer folded the first push into the array literal, which changed the inferred element type from any[] to the shape of that first element and rejected the remaining heterogeneous criteria. Fixed with an explicit WhereOptions[] annotation. The function's return type was also wrong at HEAD, declaring WhereOptions while returning an array that both callers consume as the operand of Op.or/Op.and. ldap.strategy.ts: the fixer changed email[0] to email.at(0), which widens the type to string | undefined while validateOrCreateUser requires string. Index access is retained to preserve the exact runtime behavior. Closing the gap properly means deciding what happens when an LDAP user has no email address, which is an authentication behavior change rather than a lint fix. The sensitiveKeys redaction patterns gain unicode flags. Equivalence was verified mechanically across all 8 patterns and 43 sample keys with zero behavioral differences, rather than assumed. /^pw$/ deliberately keeps its case sensitivity; adding /i there would have widened redaction. Verified after: tsc --noEmit exit 0, and all 347 backend tests pass across 26 files. Note that this predates the Prettier adoption in the preceding commit, so these files carry @stylistic formatting. The repo-wide `yarn format` pass will supersede the formatting while leaving the fixes above intact. The parked evaluations.controller changes are deliberately excluded from this commit and remain uncommitted. Authored by: Aaron Lippold --- apps/backend/README.md | 7 +- apps/backend/config/app_config.ts | 150 +++--- apps/backend/src/apikeys/apikey.controller.ts | 108 ++-- apps/backend/src/apikeys/apikey.model.ts | 50 +- .../src/apikeys/apikey.service.spec.ts | 40 +- apps/backend/src/apikeys/apikey.service.ts | 117 ++--- apps/backend/src/apikeys/apikeys.module.ts | 38 +- apps/backend/src/apikeys/dto/apikey.dto.ts | 6 +- .../src/apikeys/dto/create-apikey.dto.ts | 12 +- .../src/apikeys/dto/delete-apikey.dto.ts | 4 +- .../src/apikeys/dto/update-apikey.dto.ts | 10 +- apps/backend/src/app.controller.ts | 8 +- apps/backend/src/app.service.ts | 51 +- apps/backend/src/authn/apikey.strategy.ts | 26 +- apps/backend/src/authn/authn.controller.ts | 231 +++++---- apps/backend/src/authn/authn.module.ts | 64 +-- apps/backend/src/authn/authn.service.ts | 68 +-- apps/backend/src/authn/github.strategy.ts | 81 ++- apps/backend/src/authn/google.strategy.ts | 51 +- apps/backend/src/authn/jwt.strategy.ts | 28 +- apps/backend/src/authn/ldap.strategy.ts | 67 +-- apps/backend/src/authn/local.strategy.ts | 14 +- apps/backend/src/authn/oidc.strategy.ts | 102 ++-- apps/backend/src/authn/okta.strategy.ts | 80 ++- apps/backend/src/authz/authz.module.ts | 6 +- apps/backend/src/authz/authz.service.ts | 4 +- .../src/casl/casl-ability.factory.spec.ts | 74 +-- apps/backend/src/casl/casl-ability.factory.ts | 120 ++--- .../backend/src/casl/casl-exception.filter.ts | 6 +- apps/backend/src/config/config.module.ts | 6 +- .../src/config/dto/startup-settings.dto.ts | 10 +- .../src/crypto/hash-write-gate.service.ts | 50 +- apps/backend/src/crypto/password.service.ts | 78 +-- apps/backend/src/database/database.module.ts | 85 ++-- .../src/database/database.service.spec.ts | 40 +- apps/backend/src/database/database.service.ts | 41 +- .../interfaces/delta-args.interface.ts | 4 +- .../database/interfaces/delta.interface.ts | 10 +- .../dto/create-evaluation-tag.dto.ts | 4 +- .../dto/delete-evaluation-tag.dto.ts | 4 +- .../evaluation-tags/dto/evaluation-tag.dto.ts | 10 +- .../evaluation-tags/evaluation-tag.model.ts | 30 +- .../evaluation-tags.controller.spec.ts | 153 +++--- .../evaluation-tags.controller.ts | 80 +-- .../evaluation-tags/evaluation-tags.module.ts | 28 +- .../evaluation-tags.service.spec.ts | 74 +-- .../evaluation-tags.service.ts | 105 ++-- .../evaluations/dto/create-evaluation.dto.ts | 22 +- .../src/evaluations/dto/evaluation.dto.ts | 60 +-- .../evaluations/dto/update-evaluation.dto.ts | 12 +- .../src/evaluations/evaluation.model.ts | 58 ++- .../src/evaluations/evaluations.module.ts | 36 +- .../evaluations/evaluations.service.spec.ts | 110 ++-- .../src/evaluations/evaluations.service.ts | 424 ++++++++-------- .../authentication-exception.filter.ts | 46 +- .../filters/unique-constraint-error.filter.ts | 27 +- .../group-evaluation.model.ts | 26 +- .../group-evaluations.module.ts | 10 +- .../src/group-users/group-user.model.ts | 32 +- .../src/group-users/group-users.module.ts | 10 +- .../src/groups/dto/add-user-to-group.dto.ts | 8 +- .../src/groups/dto/create-group.dto.ts | 12 +- .../src/groups/dto/evaluation-group.dto.ts | 4 +- apps/backend/src/groups/dto/group.dto.ts | 24 +- .../groups/dto/remove-user-from-group.dto.ts | 4 +- .../src/groups/dto/update-group-user.dto.ts | 8 +- apps/backend/src/groups/group.model.ts | 38 +- .../src/groups/groups.controller.spec.ts | 157 +++--- apps/backend/src/groups/groups.controller.ts | 211 ++++---- apps/backend/src/groups/groups.module.ts | 28 +- .../backend/src/groups/groups.service.spec.ts | 84 ++-- apps/backend/src/groups/groups.service.ts | 263 +++++----- .../src/guards/api-key-or-jwt-auth.guard.ts | 5 +- .../src/guards/api-keys-enabled.guard.ts | 11 +- .../guards/implicit-allow-jwt-auth.guard.ts | 8 +- apps/backend/src/guards/jwt-auth.guard.ts | 4 +- apps/backend/src/guards/local-auth.guard.ts | 4 +- apps/backend/src/guards/test.guard.ts | 8 +- .../create-evaluation-interceptor.ts | 28 +- .../src/interceptors/logging.interceptor.ts | 101 ++-- apps/backend/src/main.ts | 107 ++-- .../src/pipes/password-change.pipe.spec.ts | 44 +- .../backend/src/pipes/password-change.pipe.ts | 60 +-- .../pipes/password-complexity.pipe.spec.ts | 44 +- .../src/pipes/password-complexity.pipe.ts | 27 +- .../src/pipes/passwords-match.pipe.spec.ts | 22 +- .../backend/src/pipes/passwords-match.pipe.ts | 11 +- .../src/statistics/dto/statistics.dto.ts | 4 +- .../src/statistics/statistics.controller.ts | 22 +- .../src/statistics/statistics.module.ts | 42 +- .../src/statistics/statistics.service.ts | 20 +- .../backend/src/tenable/tenable.controller.ts | 247 ++++----- apps/backend/src/tenable/tenable.module.ts | 8 +- apps/backend/src/tenable/tenable.service.ts | 34 +- apps/backend/src/token/token.module.ts | 8 +- apps/backend/src/token/token.providers.ts | 20 +- apps/backend/src/users/dto/create-user.dto.ts | 28 +- apps/backend/src/users/dto/delete-user.dto.ts | 4 +- apps/backend/src/users/dto/slim-user.dto.ts | 20 +- apps/backend/src/users/dto/update-user.dto.ts | 28 +- apps/backend/src/users/dto/user.dto.ts | 18 +- apps/backend/src/users/user.model.ts | 64 +-- .../src/users/users.controller.spec.ts | 163 +++--- apps/backend/src/users/users.controller.ts | 148 +++--- apps/backend/src/users/users.module.ts | 22 +- apps/backend/src/users/users.service.spec.ts | 220 ++++---- apps/backend/src/users/users.service.ts | 227 ++++----- .../evaluation-tags-test.constant.ts | 26 +- .../constants/evaluations-test.constant.ts | 56 +-- .../test/constants/groups-test.constant.ts | 80 +-- .../test/constants/users-test.constant.ts | 476 +++++++++--------- apps/backend/test/tenable/README.md | 10 + apps/backend/vitest.config.ts | 16 +- 113 files changed, 3229 insertions(+), 3415 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index d677338a76..b61176ef40 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -3,8 +3,9 @@ Create the database by setting the appropriate environment variables found in `.env-example` in `.env` Run the following to create, migrate, and seed the database: -* `npx yarn sequelize db:create` -* `npx yarn sequelize db:migrate` -* `npx yarn sequelize db:seed` + +- `npx yarn sequelize db:create` +- `npx yarn sequelize db:migrate` +- `npx yarn sequelize db:seed` Run the application `npm run start` diff --git a/apps/backend/config/app_config.ts b/apps/backend/config/app_config.ts index 098a298e8d..6d6b097126 100644 --- a/apps/backend/config/app_config.ts +++ b/apps/backend/config/app_config.ts @@ -1,8 +1,8 @@ -import * as dotenv from 'dotenv'; import * as fs from 'fs'; +import * as dotenv from 'dotenv'; export default class AppConfig { - private envConfig: {[key: string]: string | undefined}; + private envConfig: Record; constructor() { console.log('Attempting to read configuration file `.env`!'); @@ -21,139 +21,121 @@ export default class AppConfig { } if (this.parseDatabaseUrl()) { console.log( - 'DATABASE_URL parsed into smaller components (i.e. DATABASE_USER)' + 'DATABASE_URL parsed into smaller components (i.e. DATABASE_USER)', ); } } - set(key: string, value: string | undefined): void { - this.envConfig[key] = value; - } - get(key: string): string | undefined { return process.env[key] || this.envConfig[key]; } - getExternalUrl(): string { - const external_url = this.get('EXTERNAL_URL'); - if (external_url === undefined) { - return ''; - } else { - return external_url; - } - } - - getSplunkHostUrl(): string { - const splunk_host_url = this.get('SPLUNK_HOST_URL'); - if (splunk_host_url !== undefined) { - return splunk_host_url; - } else { - return ''; - } - } - - getTenableHostUrl(): string { - const tenable_host_url = this.get('TENABLE_HOST_URL'); - if (tenable_host_url !== undefined) { - return tenable_host_url; - } else { - return ''; - } - } - getDatabaseName(): string { const databaseName = this.get('DATABASE_NAME'); const nodeEnvironment = this.get('NODE_ENV'); if (databaseName !== undefined) { return databaseName; - } else if (nodeEnvironment !== undefined) { - return `heimdall-server-${nodeEnvironment.toLowerCase()}`; - } else { + } + if (nodeEnvironment === undefined) { throw new TypeError( - 'NODE_ENV and DATABASE_NAME are undefined. Unable to set database or use the default based on environment.' + 'NODE_ENV and DATABASE_NAME are undefined. Unable to set database or use the default based on environment.', ); } + return `heimdall-server-${nodeEnvironment.toLowerCase()}`; + } + + getDbConfig() { + return { + database: this.getDatabaseName(), + dialect: 'postgres' as const, + dialectOptions: { ssl: this.getSSLConfig() }, + host: this.get('DATABASE_HOST') || '127.0.0.1', + password: this.get('DATABASE_PASSWORD') || '', + port: Number(this.get('DATABASE_PORT')) || 5432, + role: this.get('DATABASE_USERNAME') || 'postgres', + ssl: Boolean(this.get('DATABASE_SSL')), + user: this.get('DATABASE_USERNAME') || 'postgres', + username: this.get('DATABASE_USERNAME') || 'postgres', + }; + } + + getDefaultAdmin() { + return this.get('ADMIN_EMAIL') || 'admin@heimdall.local'; + } + + getExternalUrl(): string { + const external_url = this.get('EXTERNAL_URL'); + return external_url === undefined ? '' : external_url; + } + + getSplunkHostUrl(): string { + const splunk_host_url = this.get('SPLUNK_HOST_URL'); + return splunk_host_url === undefined ? '' : splunk_host_url; } getSSLConfig() { if ( - !this.get('DATABASE_SSL') || - this.get('DATABASE_SSL')?.toLowerCase() === 'false' + !this.get('DATABASE_SSL') + || this.get('DATABASE_SSL')?.toLowerCase() === 'false' ) { return false; } - let sslKey, sslCert, sslCA; + let sslCA, sslCert, sslKey; if (typeof this.get('DATABASE_SSL_KEY') === 'string') { - if (this.get('DATABASE_SSL_KEY')?.indexOf('-BEGIN') !== -1) { - sslKey = this.get('DATABASE_SSL_KEY'); - } else { + if (this.get('DATABASE_SSL_KEY')?.indexOf('-BEGIN') === -1) { // Verify file exists if (fs.statSync(this.get('DATABASE_SSL_KEY')!).isFile()) { sslKey = fs.readFileSync(this.get('DATABASE_SSL_KEY')!); } else { throw new Error('SSL Key file does not exist'); } + } else { + sslKey = this.get('DATABASE_SSL_KEY'); } } if (typeof this.get('DATABASE_SSL_CERT') === 'string') { - if (this.get('DATABASE_SSL_CERT')?.indexOf('-BEGIN') !== -1) { - sslCert = this.get('DATABASE_SSL_CERT'); - } else { + if (this.get('DATABASE_SSL_CERT')?.indexOf('-BEGIN') === -1) { // Verify file exists if (fs.statSync(this.get('DATABASE_SSL_CERT')!).isFile()) { sslCert = fs.readFileSync(this.get('DATABASE_SSL_CERT')!); } else { throw new Error('SSL Cert file does not exist'); } + } else { + sslCert = this.get('DATABASE_SSL_CERT'); } } if (typeof this.get('DATABASE_SSL_CA') === 'string') { - if (this.get('DATABASE_SSL_CA')?.indexOf('-BEGIN') !== -1) { - sslCA = this.get('DATABASE_SSL_CA'); - } else { + if (this.get('DATABASE_SSL_CA')?.indexOf('-BEGIN') === -1) { // Verify file exists if (fs.statSync(this.get('DATABASE_SSL_CA')!).isFile()) { sslCA = fs.readFileSync(this.get('DATABASE_SSL_CA')!); } else { throw new Error('SSL CA file does not exist'); } + } else { + sslCA = this.get('DATABASE_SSL_CA'); } } return { - rejectUnauthorized: - this.get('DATABASE_SSL_INSECURE') && - this.get('DATABASE_SSL_INSECURE')?.toLowerCase() !== 'true', - key: sslKey, + ca: sslCA, cert: sslCert, - ca: sslCA + key: sslKey, + rejectUnauthorized: + this.get('DATABASE_SSL_INSECURE') + && this.get('DATABASE_SSL_INSECURE')?.toLowerCase() !== 'true', }; } - getDefaultAdmin() { - return this.get('ADMIN_EMAIL') || 'admin@heimdall.local'; - } - - getDbConfig() { - return { - username: this.get('DATABASE_USERNAME') || 'postgres', - user: this.get('DATABASE_USERNAME') || 'postgres', - role: this.get('DATABASE_USERNAME') || 'postgres', - password: this.get('DATABASE_PASSWORD') || '', - database: this.getDatabaseName(), - host: this.get('DATABASE_HOST') || '127.0.0.1', - port: Number(this.get('DATABASE_PORT')) || 5432, - dialect: 'postgres' as const, - dialectOptions: { - ssl: this.getSSLConfig() - }, - ssl: Boolean(this.get('DATABASE_SSL')) || false - }; + getTenableHostUrl(): string { + const tenable_host_url = this.get('TENABLE_HOST_URL'); + return tenable_host_url === undefined ? '' : tenable_host_url; } parseDatabaseUrl() { @@ -161,9 +143,9 @@ export default class AppConfig { if (url === undefined) { return false; } else { - const pattern = - /^(?:([^:\/?#\s]+):\/{2})?(?:([^@\/?#\s]+)@)?([^\/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$/; - const matches = url.match(pattern); + const pattern + = /^(?:([^\s#/:?]+):\/{2})?(?:([^\s#/?@]+)@)?([^\s#/?]+)?(?:\/([^\s#?]*))?(?:\?([^\s#]+))?\S*$/; + const matches = pattern.exec(url); if (matches === null) { return false; @@ -171,25 +153,29 @@ export default class AppConfig { this.set( 'DATABASE_USERNAME', - matches[2] !== undefined ? matches[2].split(':')[0] : undefined + matches[2] === undefined ? undefined : matches[2].split(':', 1)[0], ); this.set( 'DATABASE_PASSWORD', - matches[2] !== undefined ? matches[2].split(':')[1] : undefined + matches[2] === undefined ? undefined : matches[2].split(':', 2)[1], ); this.set( 'DATABASE_HOST', - matches[3] !== undefined ? matches[3].split(/:(?=\d+$)/)[0] : undefined + matches[3] === undefined ? undefined : matches[3].split(/:(?=\d+$)/v, 1)[0], ); this.set( 'DATABASE_NAME', - matches[4] !== undefined ? matches[4].split('/')[0] : undefined + matches[4] === undefined ? undefined : matches[4].split('/', 1)[0], ); this.set( 'DATABASE_PORT', - matches[3] !== undefined ? matches[3].split(/:(?=\d+$)/)[1] : undefined + matches[3] === undefined ? undefined : matches[3].split(/:(?=\d+$)/v, 2)[1], ); return true; } } + + set(key: string, value: string | undefined): void { + this.envConfig[key] = value; + } } diff --git a/apps/backend/src/apikeys/apikey.controller.ts b/apps/backend/src/apikeys/apikey.controller.ts index 7811f814db..25d950d71e 100644 --- a/apps/backend/src/apikeys/apikey.controller.ts +++ b/apps/backend/src/apikeys/apikey.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { BadRequestException, Body, @@ -11,22 +11,22 @@ import { Query, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthnService} from '../authn/authn.service'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {GroupsService} from '../groups/groups.service'; -import {APIKeysEnabled} from '../guards/api-keys-enabled.guard'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {ApiKeyService} from './apikey.service'; -import {APIKeyDto} from './dto/apikey.dto'; -import {CreateApiKeyDto} from './dto/create-apikey.dto'; -import {DeleteAPIKeyDto} from './dto/delete-apikey.dto'; -import {UpdateAPIKeyDto} from './dto/update-apikey.dto'; +import { AuthnService } from '../authn/authn.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { GroupsService } from '../groups/groups.service'; +import { APIKeysEnabled } from '../guards/api-keys-enabled.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { ApiKeyService } from './apikey.service'; +import { APIKeyDto } from './dto/apikey.dto'; +import { CreateApiKeyDto } from './dto/create-apikey.dto'; +import { DeleteAPIKeyDto } from './dto/delete-apikey.dto'; +import { UpdateAPIKeyDto } from './dto/update-apikey.dto'; @UseInterceptors(LoggingInterceptor) @UseGuards(APIKeysEnabled) @@ -37,41 +37,15 @@ export class ApiKeyController { private readonly apiKeyService: ApiKeyService, private readonly authz: AuthzService, private readonly usersService: UsersService, - private readonly groupsService: GroupsService + private readonly groupsService: GroupsService, ) {} - @UseGuards(JwtAuthGuard) - @Get() - async findAPIKeys( - @Request() request: {user: User}, - @Query('userId') userId: string, - @Query('groupId') groupId: string - ): Promise { - const abac = this.authz.abac.createForUser(request.user); - - if (userId && groupId) { - throw new BadRequestException('Cannot specify both userId and groupId'); - } - - if (groupId) { - const group = await this.groupsService.findByPkBang(groupId); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); - return this.apiKeyService.findAllForGroup(group); - } else { - const user = userId - ? await this.usersService.findById(userId) - : request.user; - ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - return this.apiKeyService.findAllForUser(user); - } - } - @UseGuards(JwtAuthGuard) @Post() async createAPIKey( - @Request() request: {user: User}, - @Body() createApiKeyDto: CreateApiKeyDto - ): Promise<{id: string; apiKey: string}> { + @Request() request: { user: User }, + @Body() createApiKeyDto: CreateApiKeyDto, + ): Promise<{ apiKey: string; id: string }> { const abac = this.authz.abac.createForUser(request.user); let target; @@ -97,9 +71,9 @@ export class ApiKeyController { @UseGuards(JwtAuthGuard) @Delete(':id') async deleteAPIKey( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() deleteApiKeyDto: DeleteAPIKeyDto + @Body() deleteApiKeyDto: DeleteAPIKeyDto, ): Promise { const apiKeyToDelete = await this.apiKeyService.findById(id); const abac = this.authz.abac.createForUser(request.user); @@ -107,11 +81,11 @@ export class ApiKeyController { if (apiKeyToDelete.type === 'user') { ForbiddenError.from(abac).throwUnlessCan( Action.Update, - apiKeyToDelete.user + apiKeyToDelete.user, ); } else if (apiKeyToDelete.type === 'group') { const group = await this.groupsService.findByPkBang( - apiKeyToDelete.groupId + apiKeyToDelete.groupId, ); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); } else { @@ -124,24 +98,50 @@ export class ApiKeyController { return this.apiKeyService.remove(id); } + @UseGuards(JwtAuthGuard) + @Get() + async findAPIKeys( + @Request() request: { user: User }, + @Query('userId') userId: string, + @Query('groupId') groupId: string, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + + if (userId && groupId) { + throw new BadRequestException('Cannot specify both userId and groupId'); + } + + if (groupId) { + const group = await this.groupsService.findByPkBang(groupId); + ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); + return this.apiKeyService.findAllForGroup(group); + } else { + const user = userId + ? await this.usersService.findById(userId) + : request.user; + ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); + return this.apiKeyService.findAllForUser(user); + } + } + @UseGuards(JwtAuthGuard) @Put('/:id') async updateAPIKey( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateApiKeyDto: UpdateAPIKeyDto + @Body() updateApiKeyDto: UpdateAPIKeyDto, ): Promise { const apiKeyToUpdate = await this.apiKeyService.findById(id); const abac = this.authz.abac.createForUser(request.user); if (apiKeyToUpdate.type === 'group') { const group = await this.groupsService.findByPkBang( - apiKeyToUpdate.groupId + apiKeyToUpdate.groupId, ); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); } else if (apiKeyToUpdate.type === 'user') { ForbiddenError.from(abac).throwUnlessCan( Action.Update, - apiKeyToUpdate.user + apiKeyToUpdate.user, ); } else { throw new BadRequestException('Unknown API key type'); diff --git a/apps/backend/src/apikeys/apikey.model.ts b/apps/backend/src/apikeys/apikey.model.ts index 941cc73c83..1c0cd52438 100644 --- a/apps/backend/src/apikeys/apikey.model.ts +++ b/apps/backend/src/apikeys/apikey.model.ts @@ -9,53 +9,49 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class ApiKey extends Model { - @PrimaryKey - @AutoIncrement + @Column(DataType.STRING) + declare apiKey: string; + + @CreatedAt @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; + @Column(DataType.DATE) + declare createdAt: Date; - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; + @BelongsTo(() => Group, { constraints: false }) + declare group: Group; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @BelongsTo(() => User, { - constraints: false - }) - declare user: User; - - @BelongsTo(() => Group, { - constraints: false - }) - declare group: Group; + @PrimaryKey + @AutoIncrement + @AllowNull(false) + @Column(DataType.BIGINT) + declare id: string; @Column(DataType.STRING) declare name: string; - @Column(DataType.STRING) - declare apiKey: string; - @Column(DataType.STRING) declare type: string; - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; + + @BelongsTo(() => User, { constraints: false }) + declare user: User; + + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/apikeys/apikey.service.spec.ts b/apps/backend/src/apikeys/apikey.service.spec.ts index 33d5bd54f4..ad8b9502c4 100644 --- a/apps/backend/src/apikeys/apikey.service.spec.ts +++ b/apps/backend/src/apikeys/apikey.service.spec.ts @@ -1,5 +1,5 @@ -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; import { afterAll, afterEach, @@ -10,19 +10,19 @@ import { it, vi, } from 'vitest'; -import {ConfigService} from '../config/config.service'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; import { verifyPassword } from '../crypto/password'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {ApiKey} from './apikey.model'; -import {ApiKeyService} from './apikey.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { ApiKey } from './apikey.model'; +import { ApiKeyService } from './apikey.service'; // ADR-006 §7: narrow compare-and-swap writer for lazy API-key rehash. Same // shape as UsersService.updateEncryptedPassword, against the ApiKeys.apiKey @@ -49,11 +49,11 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { Group, GroupEvaluation, Evaluation, - EvaluationTag + EvaluationTag, ]), - CryptoModule + CryptoModule, ], - providers: [ApiKeyService, ConfigService, DatabaseService] + providers: [ApiKeyService, ConfigService, DatabaseService], }).compile(); apiKeyService = module.get(ApiKeyService); databaseService = module.get(DatabaseService); @@ -71,7 +71,7 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { const created = await ApiKey.create({ apiKey: ORIGINAL, name: 'cas-test', - type: 'user' + type: 'user', }); apiKeyId = created.id; }); @@ -80,7 +80,7 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { const affected = await apiKeyService.updateApiKeyHash( apiKeyId, 'a-stale-hash-that-does-not-match', - NEW + NEW, ); expect(affected).toBe(0); const reloaded = await ApiKey.findByPk(apiKeyId); @@ -91,7 +91,7 @@ describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { const affected = await apiKeyService.updateApiKeyHash( apiKeyId, ORIGINAL, - NEW + NEW, ); expect(affected).toBe(1); const reloaded = await ApiKey.findByPk(apiKeyId); @@ -205,7 +205,7 @@ describe('ApiKeyService.create (§4 site 7 — PBKDF2 hash of the JWT signature) currentPassword: 'unused-by-service-layer', name: 'awaited-key', }); - expect(saveSpy.mock.settledResults.map((entry) => entry.type)).toEqual([ + expect(saveSpy.mock.settledResults.map(entry => entry.type)).toEqual([ 'fulfilled', 'fulfilled', ]); diff --git a/apps/backend/src/apikeys/apikey.service.ts b/apps/backend/src/apikeys/apikey.service.ts index 690d0b2516..d8a9f75fff 100644 --- a/apps/backend/src/apikeys/apikey.service.ts +++ b/apps/backend/src/apikeys/apikey.service.ts @@ -1,14 +1,14 @@ -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; import jwt from 'jsonwebtoken'; -import {CreateApiKeyDto} from '../apikeys/dto/create-apikey.dto'; -import {ConfigService} from '../config/config.service'; +import { CreateApiKeyDto } from '../apikeys/dto/create-apikey.dto'; +import { ConfigService } from '../config/config.service'; import { PasswordService } from '../crypto/password.service'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {ApiKey} from './apikey.model'; -import {APIKeyDto} from './dto/apikey.dto'; -import {UpdateAPIKeyDto} from './dto/update-apikey.dto'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { ApiKey } from './apikey.model'; +import { APIKeyDto } from './dto/apikey.dto'; +import { UpdateAPIKeyDto } from './dto/update-apikey.dto'; @Injectable() export class ApiKeyService { @@ -16,7 +16,7 @@ export class ApiKeyService { @InjectModel(ApiKey) private readonly apiKeyModel: typeof ApiKey, private readonly configService: ConfigService, - private readonly passwordService: PasswordService + private readonly passwordService: PasswordService, ) {} async count(): Promise { @@ -24,30 +24,63 @@ export class ApiKeyService { } async create( - target: User | Group, - createApiKeyDto: CreateApiKeyDto - ): Promise<{id: string; name: string; apiKey: string}> { + target: Group | User, + createApiKeyDto: CreateApiKeyDto, + ): Promise<{ apiKey: string; id: string; name: string }> { const APIKeySecret = this.configService.get('API_KEY_SECRET') || ''; const newApiKey = new ApiKey({ - userId: target instanceof User ? target.id : undefined, groupId: target instanceof Group ? target.id : undefined, name: createApiKeyDto.name, - type: target instanceof User ? 'user' : 'group' + type: target instanceof User ? 'user' : 'group', + userId: target instanceof User ? target.id : undefined, }); await newApiKey.save(); const newJWT = jwt.sign( - {keyId: newApiKey.id, createdAt: new Date()}, - APIKeySecret + { createdAt: new Date(), keyId: newApiKey.id }, + APIKeySecret, ); // ADR-006 §4 site 7: PBKDF2 via the validated module, PHC output (§2). // Only the JWT signature is hashed — originally because of bcrypt's // 72-byte limit, kept because changing what is hashed invalidates every // existing key (§11/Scope). The save is awaited: create() must not // resolve before the hash is persisted (found defect fixed in e25.12). - const JWTSignature = newJWT.split('.')[2]; + const JWTSignature = newJWT.split('.', 3)[2]; newApiKey.apiKey = await this.passwordService.hash(JWTSignature); await newApiKey.save(); - return {id: newApiKey.id, name: newApiKey.name, apiKey: newJWT}; + return { apiKey: newJWT, id: newApiKey.id, name: newApiKey.name }; + } + + async findAllForGroup(group: Group): Promise { + const apiKeys = await this.apiKeyModel.findAll({ where: { groupId: group.id } }); + return apiKeys.map(key => new APIKeyDto(key)); + } + + async findAllForUser(user: User): Promise { + const apiKeys = await this.apiKeyModel.findAll({ where: { userId: user.id } }); + return apiKeys.map(key => new APIKeyDto(key)); + } + + async findById(id: string): Promise { + const apiKey = await this.apiKeyModel.findByPk(id, { include: [User, Group] }); + if (apiKey === null) { + throw new NotFoundException('API key with given id not found'); + } + return apiKey; + } + + async remove(id: string): Promise { + const apiKeyToDestroy = await this.findById(id); + await apiKeyToDestroy.destroy(); + return new APIKeyDto(apiKeyToDestroy); + } + + async update( + id: string, + updateAPIKeyDto: UpdateAPIKeyDto, + ): Promise { + const apiKey = await this.findById(id); + apiKey.name = updateAPIKeyDto.name; + return new APIKeyDto(await apiKey.save()); } /** @@ -62,52 +95,12 @@ export class ApiKeyService { async updateApiKeyHash( id: string, originalHash: string, - newHash: string + newHash: string, ): Promise { const [affected] = await this.apiKeyModel.update( - {apiKey: newHash}, - {where: {id, apiKey: originalHash}, fields: ['apiKey'], silent: true} + { apiKey: newHash }, + { fields: ['apiKey'], silent: true, where: { apiKey: originalHash, id } }, ); return affected; } - - async update( - id: string, - updateAPIKeyDto: UpdateAPIKeyDto - ): Promise { - const apiKey = await this.findById(id); - apiKey.name = updateAPIKeyDto.name; - return new APIKeyDto(await apiKey.save()); - } - - async remove(id: string): Promise { - const apiKeyToDestroy = await this.findById(id); - await apiKeyToDestroy.destroy(); - return new APIKeyDto(apiKeyToDestroy); - } - - async findById(id: string): Promise { - const apiKey = await this.apiKeyModel.findByPk(id, { - include: [User, Group] - }); - if (apiKey === null) { - throw new NotFoundException('API key with given id not found'); - } else { - return apiKey; - } - } - - async findAllForUser(user: User): Promise { - const apiKeys = await this.apiKeyModel.findAll({ - where: {userId: user.id} - }); - return apiKeys.map((key) => new APIKeyDto(key)); - } - - async findAllForGroup(group: Group): Promise { - const apiKeys = await this.apiKeyModel.findAll({ - where: {groupId: group.id} - }); - return apiKeys.map((key) => new APIKeyDto(key)); - } } diff --git a/apps/backend/src/apikeys/apikeys.module.ts b/apps/backend/src/apikeys/apikeys.module.ts index 0fb45a9d8b..56a801bbaf 100644 --- a/apps/backend/src/apikeys/apikeys.module.ts +++ b/apps/backend/src/apikeys/apikeys.module.ts @@ -1,36 +1,36 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {AuthnService} from '../authn/authn.service'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {CryptoModule} from '../crypto/crypto.module'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {TokenModule} from '../token/token.module'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {ApiKeyController} from './apikey.controller'; -import {ApiKey} from './apikey.model'; -import {ApiKeyService} from './apikey.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { AuthnService } from '../authn/authn.service'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { TokenModule } from '../token/token.module'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { ApiKeyController } from './apikey.controller'; +import { ApiKey } from './apikey.model'; +import { ApiKeyService } from './apikey.service'; @Module({ + controllers: [ApiKeyController], + exports: [SequelizeModule, ApiKeyService], imports: [ SequelizeModule.forFeature([ApiKey, User, Group]), AuthzModule, ConfigModule, CryptoModule, ApiKeyModule, - TokenModule + TokenModule, ], providers: [ ConfigService, AuthnService, UsersService, GroupsService, - ApiKeyService + ApiKeyService, ], - exports: [SequelizeModule, ApiKeyService], - controllers: [ApiKeyController] }) export class ApiKeyModule {} diff --git a/apps/backend/src/apikeys/dto/apikey.dto.ts b/apps/backend/src/apikeys/dto/apikey.dto.ts index 7ea89280ed..1f909fc221 100644 --- a/apps/backend/src/apikeys/dto/apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/apikey.dto.ts @@ -1,11 +1,11 @@ -import {IApiKey} from '@heimdall/common/interfaces'; -import {ApiKey} from '../apikey.model'; +import type { IApiKey } from '@heimdall/common/interfaces'; +import type { ApiKey } from '../apikey.model'; export class APIKeyDto implements IApiKey { + readonly createdAt!: Date; readonly id!: string; readonly name!: string; readonly type!: string; - readonly createdAt!: Date; readonly updatedAt!: Date; constructor(apiKey: ApiKey) { diff --git a/apps/backend/src/apikeys/dto/create-apikey.dto.ts b/apps/backend/src/apikeys/dto/create-apikey.dto.ts index 2aa4ebce5c..ada23bd736 100644 --- a/apps/backend/src/apikeys/dto/create-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/create-apikey.dto.ts @@ -1,10 +1,10 @@ -import {ICreateApiKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { ICreateApiKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class CreateApiKeyDto implements ICreateApiKey { @IsString() @IsOptional() - readonly userId?: string; + readonly currentPassword!: string; @IsString() @IsOptional() @@ -12,13 +12,13 @@ export class CreateApiKeyDto implements ICreateApiKey { @IsString() @IsOptional() - readonly userEmail?: string; + readonly name?: string; @IsString() @IsOptional() - readonly name?: string; + readonly userEmail?: string; @IsString() @IsOptional() - readonly currentPassword!: string; + readonly userId?: string; } diff --git a/apps/backend/src/apikeys/dto/delete-apikey.dto.ts b/apps/backend/src/apikeys/dto/delete-apikey.dto.ts index fed9bfa7dc..0a07725085 100644 --- a/apps/backend/src/apikeys/dto/delete-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/delete-apikey.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteApiKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { IDeleteApiKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class DeleteAPIKeyDto implements IDeleteApiKey { @IsString() diff --git a/apps/backend/src/apikeys/dto/update-apikey.dto.ts b/apps/backend/src/apikeys/dto/update-apikey.dto.ts index dc8f56632d..8215bef135 100644 --- a/apps/backend/src/apikeys/dto/update-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/update-apikey.dto.ts @@ -1,11 +1,11 @@ -import {IUpdateAPIKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { IUpdateAPIKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class UpdateAPIKeyDto implements IUpdateAPIKey { - @IsString() - readonly name!: string; - @IsString() @IsOptional() readonly currentPassword!: string; + + @IsString() + readonly name!: string; } diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index 89bf99a3fa..cb24d6c23d 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -1,7 +1,7 @@ -import {Controller, Get, UseInterceptors} from '@nestjs/common'; -import {ConfigService} from './config/config.service'; -import {StartupSettingsDto} from './config/dto/startup-settings.dto'; -import {LoggingInterceptor} from './interceptors/logging.interceptor'; +import { Controller, Get, UseInterceptors } from '@nestjs/common'; +import { ConfigService } from './config/config.service'; +import { StartupSettingsDto } from './config/dto/startup-settings.dto'; +import { LoggingInterceptor } from './interceptors/logging.interceptor'; @Controller() @UseInterceptors(LoggingInterceptor) diff --git a/apps/backend/src/app.service.ts b/apps/backend/src/app.service.ts index e40ee35e43..9e9143eaae 100644 --- a/apps/backend/src/app.service.ts +++ b/apps/backend/src/app.service.ts @@ -1,59 +1,54 @@ +import os from 'os'; import { BeforeApplicationShutdown, Injectable, OnApplicationBootstrap, - OnApplicationShutdown + OnApplicationShutdown, } from '@nestjs/common'; -import os from 'os'; import winston from 'winston'; @Injectable() export class AppService - implements - OnApplicationBootstrap, +implements BeforeApplicationShutdown, - OnApplicationShutdown -{ - private readonly line = '____________________________________________\n'; + OnApplicationBootstrap, + OnApplicationShutdown { private colors = winston.addColors({ + error: 'red', info: 'cyan', + verbose: 'blue', warn: 'yellow', - error: 'red', - verbose: 'blue' }); + private readonly line = '____________________________________________\n'; + public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.colorize({all: true}), - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.errors({stack: true}), + winston.format.colorize({ all: true }), + winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + winston.format.errors({ stack: true }), winston.format.align(), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (App Service): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (App Service): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); + beforeApplicationShutdown(signal: string): void { + this.logger.info({ message: `Received ${signal}, starting shutdown for PID ${process.pid}` }); + } + onApplicationBootstrap(): void { this.logger.info({ message: `Started Heimdall Enterprise Server on ${os.hostname()} (${os.platform()} ${os.release()}) with PID ${ process.pid - } and UID ${process.getuid?.()}` + } and UID ${process.getuid?.()}`, }); } - beforeApplicationShutdown(signal: string): void { - this.logger.info({ - message: `Received ${signal}, starting shutdown for PID ${process.pid}` - }); - } onApplicationShutdown(signal: string): void { - this.logger.info({ - message: `Finished shutdown for ${signal} for PID ${process.pid}` - }); + this.logger.info({ message: `Finished shutdown for ${signal} for PID ${process.pid}` }); } } diff --git a/apps/backend/src/authn/apikey.strategy.ts b/apps/backend/src/authn/apikey.strategy.ts index 75db4e9df6..70f121253e 100644 --- a/apps/backend/src/authn/apikey.strategy.ts +++ b/apps/backend/src/authn/apikey.strategy.ts @@ -1,31 +1,27 @@ -import {ForbiddenException, Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import HeaderAPIKeyStrategy from 'passport-headerapikey'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @Injectable() export class APIKeyStrategy extends PassportStrategy( HeaderAPIKeyStrategy, - 'apikey' + 'apikey', ) { constructor(private readonly authnService: AuthnService) { - super({header: 'Authorization', prefix: 'Api-Key '}, false); + super({ header: 'Authorization', prefix: 'Api-Key ' }, false); } async validate( apikey: string, done: ( - exception: null | ForbiddenException, - user: Promise | boolean - ) => unknown + exception: ForbiddenException | null, + user: boolean | Promise, + ) => unknown, ) { const auth = this.authnService.validateApiKey(apikey); - if (await auth) { - return done(null, auth); - } else { - return done(new ForbiddenException('Bad Api-Key'), auth); - } + return (await auth) ? done(null, auth) : done(new ForbiddenException('Bad Api-Key'), auth); } } diff --git a/apps/backend/src/authn/authn.controller.ts b/apps/backend/src/authn/authn.controller.ts index 20b75ec6a0..6f2d0861cb 100644 --- a/apps/backend/src/authn/authn.controller.ts +++ b/apps/backend/src/authn/authn.controller.ts @@ -6,17 +6,17 @@ import { Req, UseFilters, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; -import {Request} from 'express'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {AuthenticationExceptionFilter} from '../filters/authentication-exception.filter'; -import {LocalAuthGuard} from '../guards/local-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { ConfigService } from '../config/config.service'; +import { AuthenticationExceptionFilter } from '../filters/authentication-exception.filter'; +import { LocalAuthGuard } from '../guards/local-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @UseInterceptors(LoggingInterceptor) @Controller('authn') @@ -24,167 +24,160 @@ export class AuthnController { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), + winston.format.timestamp({ format: this.loggingTimeFormat }), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Controller): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (Authn Controller): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) {} + @Get('github/callback') + @UseGuards(AuthGuard('github')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGithubLogin(@Req() request: Request): Promise { + this.logger.debug('in the github login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + await this.setSessionCookies(request, session); + } + + @Get('gitlab/callback') + @UseGuards(AuthGuard('gitlab')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGitlabLogin(@Req() request: Request): Promise { + this.logger.debug('in the gitlab login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + await this.setSessionCookies(request, session); + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGoogle(@Req() request: Request): Promise { + this.logger.debug('in the google login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + await this.setSessionCookies(request, session); + } + + @Get('oidc_callback') + @UseGuards(AuthGuard('oidc')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromOIDC(@Req() request: Request): Promise { + this.logger.debug('in the oidc login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + await this.setSessionCookies(request, session); + } + + @Get('okta_callback') + @UseGuards(AuthGuard('okta')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromOkta(@Req() request: Request): Promise { + this.logger.debug('in the okta login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + await this.setSessionCookies(request, session); + } + @UseGuards(LocalAuthGuard) @Post('login') async login( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the local login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - if (!this.configService.isLocalLoginAllowed()) { - throw new ForbiddenException( - 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.' - ); - } else { - return this.authnService.login(req.user as User); + this.logger.debug(JSON.stringify(request.session, null, 2)); + if (this.configService.isLocalLoginAllowed()) { + return this.authnService.login(request.user as User); } - } - - @UseGuards(AuthGuard('ldap')) - @Post('login/ldap') - async loginToLDAP( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { - this.logger.debug('in the ldap login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); + throw new ForbiddenException( + 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.', + ); } @Get('github') @UseGuards(AuthGuard('github')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGithub( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the github login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('github/callback') - @UseGuards(AuthGuard('github')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGithubLogin(@Req() req: Request): Promise { - this.logger.debug('in the github login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('gitlab') @UseGuards(AuthGuard('gitlab')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGitlab( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the gitlab login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('gitlab/callback') - @UseGuards(AuthGuard('gitlab')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGitlabLogin(@Req() req: Request): Promise { - this.logger.debug('in the gitlab login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('google') @UseGuards(AuthGuard('google')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGoogle( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the google login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('google/callback') - @UseGuards(AuthGuard('google')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGoogle(@Req() req: Request): Promise { - this.logger.debug('in the google login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); - } - - @Get('okta') - @UseGuards(AuthGuard('okta')) - @UseFilters(new AuthenticationExceptionFilter()) - async loginToOkta( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { - this.logger.debug('in the okta login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } - @Get('okta_callback') - @UseGuards(AuthGuard('okta')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromOkta(@Req() req: Request): Promise { - this.logger.debug('in the okta login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + @UseGuards(AuthGuard('ldap')) + @Post('login/ldap') + async loginToLDAP( + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { + this.logger.debug('in the ldap login func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('oidc') @UseGuards(AuthGuard('oidc')) @UseFilters(new AuthenticationExceptionFilter()) async loginToOIDC( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the oidc login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } - @Get('oidc_callback') - @UseGuards(AuthGuard('oidc')) + @Get('okta') + @UseGuards(AuthGuard('okta')) @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromOIDC(@Req() req: Request): Promise { - this.logger.debug('in the oidc login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + async loginToOkta( + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { + this.logger.debug('in the okta login func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } async setSessionCookies( - req: Request, + request: Request, session: { - userID: string; accessToken: string; - } + userID: string; + }, ): Promise { - req.res?.cookie('userID', session.userID, { - secure: this.configService.isInProductionMode() - }); - req.res?.cookie('accessToken', session.accessToken, { - secure: this.configService.isInProductionMode() - }); - req.res?.redirect('/'); + request.res?.cookie('userID', session.userID, { secure: this.configService.isInProductionMode() }); + request.res?.cookie('accessToken', session.accessToken, { secure: this.configService.isInProductionMode() }); + request.res?.redirect('/'); } } diff --git a/apps/backend/src/authn/authn.module.ts b/apps/backend/src/authn/authn.module.ts index ec18cbc8a7..d18a76c19c 100644 --- a/apps/backend/src/authn/authn.module.ts +++ b/apps/backend/src/authn/authn.module.ts @@ -1,33 +1,34 @@ -import type {Agent} from 'http'; -import {Module} from '@nestjs/common'; -import {PassportModule} from '@nestjs/passport'; -import {AuthnController} from './authn.controller'; -import {ApiKeyModule} from '../apikeys/apikeys.module'; -import {ConfigModule} from '../config/config.module'; -import {CryptoModule} from '../crypto/crypto.module'; -import {GroupsModule} from '../groups/groups.module'; -import {TokenModule} from '../token/token.module'; -import {UsersModule} from '../users/users.module'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {AuthnService} from './authn.service'; -import {ConfigService} from '../config/config.service'; -import {GroupsService} from '../groups/groups.service'; -import {APIKeyStrategy} from './apikey.strategy'; -import {GithubStrategy} from './github.strategy'; -import {GitlabStrategy} from './gitlab.strategy'; -import {GoogleStrategy} from './google.strategy'; -import {JwtStrategy} from './jwt.strategy'; -import {LDAPStrategy} from './ldap.strategy'; -import {LocalStrategy} from './local.strategy'; -import {OidcStrategy} from './oidc.strategy'; -import {OktaStrategy} from './okta.strategy'; +import type { Agent } from 'http'; +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ApiKeyModule } from '../apikeys/apikeys.module'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { GroupsModule } from '../groups/groups.module'; +import { GroupsService } from '../groups/groups.service'; +import { TokenModule } from '../token/token.module'; +import { UsersModule } from '../users/users.module'; +import { APIKeyStrategy } from './apikey.strategy'; +import { AuthnController } from './authn.controller'; +import { AuthnService } from './authn.service'; +import { GithubStrategy } from './github.strategy'; +import { GitlabStrategy } from './gitlab.strategy'; +import { GoogleStrategy } from './google.strategy'; +import { JwtStrategy } from './jwt.strategy'; +import { LDAPStrategy } from './ldap.strategy'; +import { LocalStrategy } from './local.strategy'; +import { OidcStrategy } from './oidc.strategy'; +import { OktaStrategy } from './okta.strategy'; async function buildHttpsProxyAgent(proxyUrl: string): Promise { - const {HttpsProxyAgent} = await import('https-proxy-agent'); + const { HttpsProxyAgent } = await import('https-proxy-agent'); return new HttpsProxyAgent(proxyUrl); } @Module({ + controllers: [AuthnController], imports: [ ApiKeyModule, UsersModule, @@ -35,7 +36,7 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { TokenModule, ConfigModule, CryptoModule, - GroupsModule + GroupsModule, ], providers: [ AuthnService, @@ -48,11 +49,12 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { LDAPStrategy, ApiKeyService, { + inject: [AuthnService, ConfigService, GroupsService], provide: OidcStrategy, useFactory: async ( authn: AuthnService, config: ConfigService, - groups: GroupsService + groups: GroupsService, ) => new OidcStrategy( authn, @@ -60,11 +62,11 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { groups, config.get('OIDC_USE_HTTPS_PROXY') === 'true' ? await buildHttpsProxyAgent(config.get('HTTPS_PROXY') ?? '') - : undefined + : undefined, ), - inject: [AuthnService, ConfigService, GroupsService] }, { + inject: [AuthnService, ConfigService], provide: OktaStrategy, useFactory: async (authn: AuthnService, config: ConfigService) => new OktaStrategy( @@ -72,11 +74,9 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { config, config.get('OKTA_USE_HTTPS_PROXY') === 'true' ? await buildHttpsProxyAgent(config.get('HTTPS_PROXY') ?? '') - : undefined + : undefined, ), - inject: [AuthnService, ConfigService] - } + }, ], - controllers: [AuthnController] }) export class AuthnModule {} diff --git a/apps/backend/src/authn/authn.service.ts b/apps/backend/src/authn/authn.service.ts index f012137772..0529fe5dbc 100644 --- a/apps/backend/src/authn/authn.service.ts +++ b/apps/backend/src/authn/authn.service.ts @@ -26,12 +26,12 @@ import { UsersService } from '../users/users.service'; @Injectable() export class AuthnService { + private readonly line = '_______________________________________________\n'; // unicorn/consistent-class-member-order (privates-first) and // perfectionist/sort-classes (publics-first) are mutually exclusive on any // mixed class — documented floor class (password.service.ts carries the // same finding); perfectionist's order is kept. public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - private readonly line = '_______________________________________________\n'; public logger = createLogger({ format: format.combine( format.timestamp({ format: this.loggingTimeFormat }), @@ -51,6 +51,39 @@ export class AuthnService { private readonly passwordService: PasswordService, ) {} + /** + * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is + * saturated, and the ADR assigns the mapping to "the auth layer" — this is + * that layer for site 4. Left unmapped the error escapes as a 500 alongside + * everyone else's 401, which is itself an enumeration oracle: under + * saturation the absent-user dummy consumes a KDF slot while a legacy + * `bcryptjs.compare` consumes none, separating "no such account" from + * "account still on bcrypt". Returns null on overload (caller fails + * generically); anything else is a real bug and propagates. + * + * `subject` is a pre-formatted label for the server-side log only — e.g. + * `User` or `ApiKey` — so both credential paths share this + * mapping without the helper knowing which one called it. + */ + private async verifyOrGenericFailure( + arguments_: { hash: string; password: string }, + subject?: string, + ): Promise { + try { + return await this.passwordService.verify(arguments_); + } catch (error) { + if (error instanceof KdfOverloadedError) { + this.logger.info({ + message: `Credential verification rejected — KDF queue saturated${ + subject === undefined ? '' : ` for ${subject}` + }; returning the generic authentication failure.`, + }); + return null; + } + throw error; + } + } + async login(user: { email: string; forcePasswordChange: boolean | undefined; @@ -370,37 +403,4 @@ export class AuthnService { void this.usersService.updateLoginMetadata(user); return user; } - - /** - * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is - * saturated, and the ADR assigns the mapping to "the auth layer" — this is - * that layer for site 4. Left unmapped the error escapes as a 500 alongside - * everyone else's 401, which is itself an enumeration oracle: under - * saturation the absent-user dummy consumes a KDF slot while a legacy - * `bcryptjs.compare` consumes none, separating "no such account" from - * "account still on bcrypt". Returns null on overload (caller fails - * generically); anything else is a real bug and propagates. - * - * `subject` is a pre-formatted label for the server-side log only — e.g. - * `User` or `ApiKey` — so both credential paths share this - * mapping without the helper knowing which one called it. - */ - private async verifyOrGenericFailure( - arguments_: { hash: string; password: string }, - subject?: string, - ): Promise { - try { - return await this.passwordService.verify(arguments_); - } catch (error) { - if (error instanceof KdfOverloadedError) { - this.logger.info({ - message: `Credential verification rejected — KDF queue saturated${ - subject === undefined ? '' : ` for ${subject}` - }; returning the generic authentication failure.`, - }); - return null; - } - throw error; - } - } } diff --git a/apps/backend/src/authn/github.strategy.ts b/apps/backend/src/authn/github.strategy.ts index 3c76d890c3..034dd0f95c 100644 --- a/apps/backend/src/authn/github.strategy.ts +++ b/apps/backend/src/authn/github.strategy.ts @@ -1,78 +1,74 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import axios from 'axios'; -import {Strategy} from 'passport-github'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Strategy } from 'passport-github'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface GithubProfile { - name: string | null; - login: string; -} - -interface GithubEmail { +type GithubEmail = { email: string; verified: boolean; -} +}; + +type GithubProfile = { + login: string; + name: null | string; +}; @Injectable() export class GithubStrategy extends PassportStrategy(Strategy, 'github') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ - clientID: configService.get('GITHUB_CLIENTID') || 'disabled', - clientSecret: configService.get('GITHUB_CLIENTSECRET') || 'disabled', authorizationURL: ` ${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') || - configService.defaultGithubBaseURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') + || configService.defaultGithubBaseURL }login/oauth/authorize`, + clientID: configService.get('GITHUB_CLIENTID') || 'disabled', + clientSecret: configService.get('GITHUB_CLIENTSECRET') || 'disabled', + passReqToCallback: true, + scope: 'user:email', tokenURL: `${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') || - configService.defaultGithubBaseURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') + || configService.defaultGithubBaseURL }login/oauth/access_token`, userProfileURL: `${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - configService.defaultGithubAPIURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || configService.defaultGithubAPIURL }user`, - scope: 'user:email', - passReqToCallback: true }); } async validate( - req: Record, - accessToken: string + request: Record, + accessToken: string, ): Promise { // Get user's linked emails from Github const githubEmails = await axios .get( `${ - this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - this.configService.defaultGithubAPIURL + this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || this.configService.defaultGithubAPIURL }user/emails`, - { - headers: {Authorization: `token ${accessToken}`} - } + { headers: { Authorization: `token ${accessToken}` } }, ) - .then(({data}) => { + .then(({ data }) => { return data; }); // Get user's info const userInfoResponse = await axios .get( `${ - this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - this.configService.defaultGithubAPIURL + this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || this.configService.defaultGithubAPIURL }user`, - { - headers: {Authorization: `token ${accessToken}`} - } + { headers: { Authorization: `token ${accessToken}` } }, ) - .then(({data}) => { + .then(({ data }) => { return data; }); let firstName = userInfoResponse.login; @@ -91,12 +87,11 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') { primaryEmail.email, firstName, lastName, - 'github' - ); - } else { - throw new UnauthorizedException( - 'Please verify your email with Github before logging into Heimdall.' + 'github', ); } + throw new UnauthorizedException( + 'Please verify your email with Github before logging into Heimdall.', + ); } } diff --git a/apps/backend/src/authn/google.strategy.ts b/apps/backend/src/authn/google.strategy.ts index 5a71de37c1..e15d6fa168 100644 --- a/apps/backend/src/authn/google.strategy.ts +++ b/apps/backend/src/authn/google.strategy.ts @@ -1,60 +1,59 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {OAuth2Strategy} from 'passport-google-oauth'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { OAuth2Strategy } from 'passport-google-oauth'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface UserEmail { - value: string; - verified: boolean; -} - -interface GoogleProfile { +type GoogleProfile = { + emails: UserEmail[]; name: { familyName: string; givenName: string; }; - emails: UserEmail[]; -} +}; + +type UserEmail = { + value: string; + verified: boolean; +}; @Injectable() export class GoogleStrategy extends PassportStrategy(OAuth2Strategy, 'google') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ - clientID: configService.get('GOOGLE_CLIENTID') || 'disabled', - clientSecret: configService.get('GOOGLE_CLIENTSECRET') || 'disabled', callbackURL: `${configService.getExternalUrl()}/authn/google/callback` || 'disabled', - scope: ['email', 'profile'] + clientID: configService.get('GOOGLE_CLIENTID') || 'disabled', + clientSecret: configService.get('GOOGLE_CLIENTSECRET') || 'disabled', + scope: ['email', 'profile'], }); } async validate( accessToken: string, refreshToken: string, - profile: GoogleProfile + profile: GoogleProfile, ): Promise { - const {name, emails} = profile; + const { emails, name } = profile; const user = { email: emails[0], firstName: name.givenName, - lastName: name.familyName + lastName: name.familyName, }; if (user.email.verified) { return this.authnService.validateOrCreateUser( user.email.value, user.firstName, user.lastName, - 'google' - ); - } else { - throw new UnauthorizedException( - 'Please verify your email with Google before logging into Heimdall.' + 'google', ); } + throw new UnauthorizedException( + 'Please verify your email with Google before logging into Heimdall.', + ); } } diff --git a/apps/backend/src/authn/jwt.strategy.ts b/apps/backend/src/authn/jwt.strategy.ts index d59a268beb..c9faeb96b5 100644 --- a/apps/backend/src/authn/jwt.strategy.ts +++ b/apps/backend/src/authn/jwt.strategy.ts @@ -1,46 +1,44 @@ -import {IUser} from '@heimdall/common/interfaces'; -import {HttpException, Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; +import { IUser } from '@heimdall/common/interfaces'; +import { HttpException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import jwt from 'jsonwebtoken'; -import {ExtractJwt, Strategy} from 'passport-jwt'; -import {ConfigService} from '../config/config.service'; -import {UsersService} from '../users/users.service'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '../config/config.service'; +import { UsersService } from '../users/users.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private readonly configService: ConfigService, - private readonly usersService: UsersService + private readonly usersService: UsersService, ) { super({ + ignoreExpiration: false, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: async ( _request: Express.Request, jwtToken: string, - done: (exception: null | HttpException, secret?: string) => unknown + done: (exception: HttpException | null, secret?: string) => unknown, ) => { - const decodedToken = jwt.decode(jwtToken) as { - sub: string; - }; + const decodedToken = jwt.decode(jwtToken) as { sub: string }; try { const user = await usersService.findById(decodedToken.sub); done(null, configService.get('JWT_SECRET') + user.jwtSecret); } catch { done( new UnauthorizedException( - 'An exception occurred while validating your session' - ) + 'An exception occurred while validating your session', + ), ); } }, - ignoreExpiration: false }); } async validate(payload: { - sub: string; email: string; role: string; + sub: string; }): Promise { return this.usersService.findById(payload.sub); } diff --git a/apps/backend/src/authn/ldap.strategy.ts b/apps/backend/src/authn/ldap.strategy.ts index 67363cef08..9c2d711f81 100644 --- a/apps/backend/src/authn/ldap.strategy.ts +++ b/apps/backend/src/authn/ldap.strategy.ts @@ -1,86 +1,91 @@ -import {Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; import * as fs from 'fs'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import _ from 'lodash'; import Strategy from 'passport-ldapauth'; -import {ConfigService} from '../config/config.service'; -import {AuthnService} from './authn.service'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; @Injectable() export class LDAPStrategy extends PassportStrategy(Strategy, 'ldap') { static getSSLConfig(configService: ConfigService) { - const sslEnabled = - (configService.get('LDAP_SSL') ?? '').toLowerCase() === 'true'; - if (!sslEnabled) { + const isSslEnabled + = (configService.get('LDAP_SSL') ?? '').toLowerCase() === 'true'; + if (!isSslEnabled) { return false; } - let sslCA: string | Buffer | undefined = configService.get('LDAP_SSL_CA'); + let sslCA: Buffer | string | undefined = configService.get('LDAP_SSL_CA'); if (!sslCA) { throw new Error('SSL CA file or path to file not provided'); } - if (sslCA.indexOf('-BEGIN') === -1) { + if (!sslCA.includes('-BEGIN')) { if (fs.statSync(sslCA).isFile()) { sslCA = fs.readFileSync(sslCA); - if (sslCA.indexOf('-BEGIN') === -1) { + if (!sslCA.includes('-BEGIN')) { throw new Error('SSL CA file at given path was not a certificate'); } } else { throw new Error( - 'SSL CA file is neither a certificate nor is it a path to one' + 'SSL CA file is neither a certificate nor is it a path to one', ); } } - const sslInsecure = - (configService.get('LDAP_SSL_INSECURE') ?? '').toLowerCase() === 'true'; + const isSslInsecure + = (configService.get('LDAP_SSL_INSECURE') ?? '').toLowerCase() === 'true'; return { - rejectUnauthorized: !sslInsecure, - ca: sslCA + ca: sslCA, + rejectUnauthorized: !isSslInsecure, }; } constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { const sslConfig = LDAPStrategy.getSSLConfig(configService); super({ server: { - url: `${sslConfig ? 'ldaps' : 'ldap'}://${configService.get( - 'LDAP_HOST' - )}:${configService.get('LDAP_PORT') || '389'}`, - bindDN: configService.get('LDAP_BINDDN'), bindCredentials: configService.get('LDAP_PASSWORD'), + bindDN: configService.get('LDAP_BINDDN'), searchBase: configService.get('LDAP_SEARCHBASE') || 'disabled', searchFilter: - configService.get('LDAP_SEARCHFILTER') || - '(sAMAccountName={{username}})', + configService.get('LDAP_SEARCHFILTER') + || '(sAMAccountName={{username}})', + url: `${sslConfig ? 'ldaps' : 'ldap'}://${configService.get( + 'LDAP_HOST', + )}:${configService.get('LDAP_PORT') || '389'}`, ...(sslConfig && { tlsOptions: { + ca: sslConfig.ca, rejectUnauthorized: sslConfig.rejectUnauthorized, - ca: sslConfig.ca - } - }) - } + }, + }), + }, }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any async validate(user: unknown, done: any) { - const {firstName, lastName} = this.authnService.splitName( - _.get(user, this.configService.get('LDAP_NAMEFIELD') || 'name') + const { firstName, lastName } = this.authnService.splitName( + _.get(user, this.configService.get('LDAP_NAMEFIELD') || 'name'), ); const email: string = _.get( user, - this.configService.get('LDAP_MAILFIELD') || 'mail' + this.configService.get('LDAP_MAILFIELD') || 'mail', ); const validatedUser = this.authnService.validateOrCreateUser( + // eslint-disable-next-line unicorn/prefer-at -- `.at(0)` returns + // `string | undefined`, but validateOrCreateUser requires `string`. + // Index access keeps the exact runtime behavior this has always had. + // Closing the gap properly means deciding what should happen when an + // LDAP user has no email address — an authentication behavior change, + // not a lint fix. Tracked separately. Array.isArray(email) ? email[0] : email, firstName, lastName, - 'ldap' + 'ldap', ); return done(null, validatedUser); } diff --git a/apps/backend/src/authn/local.strategy.ts b/apps/backend/src/authn/local.strategy.ts index b777ba6f16..f6b6b7eb55 100644 --- a/apps/backend/src/authn/local.strategy.ts +++ b/apps/backend/src/authn/local.strategy.ts @@ -1,15 +1,13 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from 'passport-local'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private readonly authnService: AuthnService) { - super({ - usernameField: 'email' - }); + super({ usernameField: 'email' }); } async validate(email: string, password: string): Promise { diff --git a/apps/backend/src/authn/oidc.strategy.ts b/apps/backend/src/authn/oidc.strategy.ts index a56eaf1708..4c8fea6558 100644 --- a/apps/backend/src/authn/oidc.strategy.ts +++ b/apps/backend/src/authn/oidc.strategy.ts @@ -1,74 +1,72 @@ -import type {Agent} from 'http'; -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from '@govtechsg/passport-openidconnect'; +import type { Agent } from 'http'; +import { Strategy } from '@govtechsg/passport-openidconnect'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {GroupsService} from '../groups/groups.service'; -import {AuthnService} from './authn.service'; +import { ConfigService } from '../config/config.service'; +import { GroupsService } from '../groups/groups.service'; +import { AuthnService } from './authn.service'; -interface OIDCProfile { - id: string; - displayName: string; - name: {familyName: string; givenName: string}; - emails: [{value: string}]; - _raw: string; +type OIDCProfile = { _json: { - given_name: string; - family_name: string; email: string; email_verified: boolean; + family_name: string; + given_name: string; groups: string[]; }; -} + _raw: string; + displayName: string; + emails: [{ value: string }]; + id: string; + name: { familyName: string; givenName: string }; +}; @Injectable() -//eslint-disable-next-line @typescript-eslint/no-explicit-any -- Passport v11 changed their types and many 3rd party strategies are not compatible with the types despite actually still working just fine + export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), + winston.format.timestamp({ format: this.loggingTimeFormat }), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); constructor( private readonly authnService: AuthnService, private readonly configService: ConfigService, private readonly groupsService: GroupsService, - private readonly httpsAgent?: Agent + private readonly httpsAgent?: Agent, ) { super( { - issuer: configService.get('OIDC_ISSUER') || 'disabled', + agent: httpsAgent, authorizationURL: configService.get('OIDC_AUTHORIZATION_URL') || 'disabled', - tokenURL: configService.get('OIDC_TOKEN_URL') || 'disabled', - userInfoURL: configService.get('OIDC_USER_INFO_URL') || 'disabled', + callbackURL: `${configService.getExternalUrl()}/authn/oidc_callback`, clientID: configService.get('OIDC_CLIENTID') || 'disabled', clientSecret: configService.get('OIDC_CLIENT_SECRET') || 'disabled', - callbackURL: `${configService.getExternalUrl()}/authn/oidc_callback`, + issuer: configService.get('OIDC_ISSUER') || 'disabled', pkce: configService.get('OIDC_USES_PKCE_S256') === 'true' ? 'S256' - : configService.get('OIDC_USES_PKCE_PLAIN') === 'true' + : (configService.get('OIDC_USES_PKCE_PLAIN') === 'true' ? 'plain' - : undefined, - scope: ['openid', 'email', 'profile'], - skipUserProfile: false, + : undefined), proxy: configService.get('OIDC_USE_HTTPS_PROXY') === 'true' ? true : undefined, - agent: httpsAgent + scope: ['openid', 'email', 'profile'], + skipUserProfile: false, + tokenURL: configService.get('OIDC_TOKEN_URL') || 'disabled', + userInfoURL: configService.get('OIDC_USER_INFO_URL') || 'disabled', }, // using the 9-arity function so that we can access the underlying JSON response and extract the 'email_verified' attribute async ( @@ -79,9 +77,9 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken: string, _accessToken: string, _refreshToken: string, - _params: object, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + _parameters: object, + + done: any, ) => { return this.validate( _issuer, @@ -91,10 +89,10 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken, _accessToken, _refreshToken, - _params, - done + _parameters, + done, ); - } + }, ); } @@ -106,28 +104,28 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken: string, _accessToken: string, _refreshToken: string, - _params: object, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + _parameters: object, + + done: any, ) { this.logger.debug('in oidc strategy file'); this.logger.debug(JSON.stringify(uiProfile, null, 2)); const userData = uiProfile._json; - const {given_name, family_name, email, email_verified, groups} = userData; + const { email, email_verified, family_name, given_name, groups } = userData; if ( - this.configService.get('OIDC_USES_VERIFIED_EMAIL') === 'false' || - email_verified + this.configService.get('OIDC_USES_VERIFIED_EMAIL') === 'false' + || email_verified ) { const user = await this.authnService.validateOrCreateUser( email, given_name, family_name, - 'oidc' + 'oidc', ); if ( - this.configService.get('OIDC_EXTERNAL_GROUPS') === 'true' && - groups !== undefined + this.configService.get('OIDC_EXTERNAL_GROUPS') === 'true' + && groups !== undefined ) { await this.groupsService.syncUserGroups(user, groups); } @@ -136,8 +134,8 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { } return done( new UnauthorizedException( - 'Please verify your name and email with your identity provider before logging into Heimdall.' - ) + 'Please verify your name and email with your identity provider before logging into Heimdall.', + ), ); } } diff --git a/apps/backend/src/authn/okta.strategy.ts b/apps/backend/src/authn/okta.strategy.ts index 03ba745add..6cf931dd42 100644 --- a/apps/backend/src/authn/okta.strategy.ts +++ b/apps/backend/src/authn/okta.strategy.ts @@ -1,89 +1,87 @@ -import type {Agent} from 'http'; -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from '@govtechsg/passport-openidconnect'; +import type { Agent } from 'http'; +import { Strategy } from '@govtechsg/passport-openidconnect'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {AuthnService} from './authn.service'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; type Profile = { - provider: string; - id: string; displayName: string; + emails: { value: string }[]; + id: string; name: { familyName: string; givenName: string; middleName: string; }; - emails: {value: string}[]; + provider: string; }; @Injectable() -//eslint-disable-next-line @typescript-eslint/no-explicit-any -- Passport v11 changed their types and many 3rd party strategies are not compatible with the types despite actually still working just fine + export class OktaStrategy extends PassportStrategy(Strategy as any, 'okta') { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), + winston.format.timestamp({ format: this.loggingTimeFormat }), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); constructor( private readonly authnService: AuthnService, private readonly configService: ConfigService, - private readonly httpsAgent?: Agent + private readonly httpsAgent?: Agent, ) { super( { - issuer: - configService.get('OKTA_ISSUER_URL') || - `https://${configService.get('OKTA_DOMAIN')}` || - 'disabled', + agent: httpsAgent, authorizationURL: - configService.get('OKTA_AUTHORIZATION_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/authorize`, - tokenURL: - configService.get('OKTA_TOKEN_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/token`, - userInfoURL: - configService.get('OKTA_USER_INFO_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/userinfo`, + configService.get('OKTA_AUTHORIZATION_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/authorize`, + callbackURL: `${configService.getExternalUrl()}/authn/okta_callback`, clientID: configService.get('OKTA_CLIENTID') || 'disabled', clientSecret: configService.get('OKTA_CLIENTSECRET') || 'disabled', - callbackURL: `${configService.getExternalUrl()}/authn/okta_callback`, - scope: ['openid', 'email', 'profile'], - skipUserProfile: false, + issuer: + configService.get('OKTA_ISSUER_URL') + || `https://${configService.get('OKTA_DOMAIN')}` + || 'disabled', proxy: configService.get('OKTA_USE_HTTPS_PROXY') === 'true' ? true : undefined, - agent: httpsAgent + scope: ['openid', 'email', 'profile'], + skipUserProfile: false, + tokenURL: + configService.get('OKTA_TOKEN_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/token`, + userInfoURL: + configService.get('OKTA_USER_INFO_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/userinfo`, }, // Okta has no concept of a 'verified' email - the account has to have an email address associated with it - which is why we can use the 3-arity function since we don't need access to the underlying JSON response async ( _issuer: string, profile: Profile, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + + done: any, ) => { return this.validate(_issuer, profile, done); - } + }, ); } async validate( _issuer: string, profile: Profile, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + + done: any, ) { this.logger.debug('in okta strategy file'); this.logger.debug(JSON.stringify(profile, null, 2)); @@ -92,7 +90,7 @@ export class OktaStrategy extends PassportStrategy(Strategy as any, 'okta') { profile.emails[0].value, profile.name.givenName, profile.name.familyName, - 'okta' + 'okta', ); return done(null, user); } diff --git a/apps/backend/src/authz/authz.module.ts b/apps/backend/src/authz/authz.module.ts index 5bf19fe54a..0254de6a12 100644 --- a/apps/backend/src/authz/authz.module.ts +++ b/apps/backend/src/authz/authz.module.ts @@ -1,9 +1,9 @@ -import {Global, Module} from '@nestjs/common'; -import {AuthzService} from './authz.service'; +import { Global, Module } from '@nestjs/common'; +import { AuthzService } from './authz.service'; @Global() @Module({ + exports: [AuthzService], providers: [AuthzService], - exports: [AuthzService] }) export class AuthzModule {} diff --git a/apps/backend/src/authz/authz.service.ts b/apps/backend/src/authz/authz.service.ts index 50c83ba96e..518d8af274 100644 --- a/apps/backend/src/authz/authz.service.ts +++ b/apps/backend/src/authz/authz.service.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {CaslAbilityFactory} from '../casl/casl-ability.factory'; +import { Injectable } from '@nestjs/common'; +import { CaslAbilityFactory } from '../casl/casl-ability.factory'; @Injectable() export class AuthzService { diff --git a/apps/backend/src/casl/casl-ability.factory.spec.ts b/apps/backend/src/casl/casl-ability.factory.spec.ts index f7974445b1..bc50deed55 100644 --- a/apps/backend/src/casl/casl-ability.factory.spec.ts +++ b/apps/backend/src/casl/casl-ability.factory.spec.ts @@ -1,11 +1,11 @@ -import {MongoAbility} from '@casl/ability'; -import {beforeEach, describe, expect, it} from 'vitest'; +import type { MongoAbility } from '@casl/ability'; +import { beforeEach, describe, expect, it } from 'vitest'; import { ADMIN_WITH_ID, - TEST_USER_WITH_ID + TEST_USER_WITH_ID, } from '../../test/constants/users-test.constant'; -import {User} from '../users/user.model'; -import {Action, CaslAbilityFactory} from './casl-ability.factory'; +import { User } from '../users/user.model'; +import { Action, CaslAbilityFactory } from './casl-ability.factory'; describe('CaslAbilityFactory', () => { let abilityFactory: CaslAbilityFactory; @@ -22,20 +22,20 @@ describe('CaslAbilityFactory', () => { expect( userAbility.can( Action.Read, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( userAbility.can( Action.Update, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( userAbility.can( Action.Delete, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); @@ -43,26 +43,26 @@ describe('CaslAbilityFactory', () => { expect( userAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.SkipForcePasswordChange, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.UpdateRole, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); }); @@ -74,20 +74,20 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.Delete, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeFalsy(); }); @@ -95,14 +95,14 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.Update, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.Update, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeTruthy(); }); @@ -110,14 +110,14 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeFalsy(); }); @@ -125,8 +125,8 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.SkipForcePasswordChange, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); @@ -134,8 +134,8 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.UpdateRole, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); }); diff --git a/apps/backend/src/casl/casl-ability.factory.ts b/apps/backend/src/casl/casl-ability.factory.ts index 7dd0ddc9f8..35eee64886 100644 --- a/apps/backend/src/casl/casl-ability.factory.ts +++ b/apps/backend/src/casl/casl-ability.factory.ts @@ -3,59 +3,69 @@ import { createMongoAbility, ExtractSubjectType, InferSubjects, - MongoAbility + MongoAbility, } from '@casl/ability'; -import {Injectable} from '@nestjs/common'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; - -type AllTypes = typeof User | typeof Evaluation | typeof Group; - -type Subjects = InferSubjects | 'all'; -type PossibleAbilities = [Action, Subjects]; +import { Injectable } from '@nestjs/common'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; export enum Action { - Manage = 'manage', // manage is a special keyword in CASL which represents "any" action. + AddEvaluation = 'add-evaluation', Create = 'create', - Read = 'read', - Update = 'update', Delete = 'delete', + DeleteNoPassword = 'delete-no-password', + ForceRegistration = 'force-registration', + Manage = 'manage', // manage is a special keyword in CASL which represents "any" action. + Read = 'read', ReadAll = 'read-all', ReadSlim = 'read-slim', - DeleteNoPassword = 'delete-no-password', - UpdateNoPassword = 'update-no-password', + RemoveEvaluation = 'remove-evaluation', SkipForcePasswordChange = 'skip-force-password-change', + Update = 'update', + UpdateNoPassword = 'update-no-password', UpdateRole = 'update-role', - AddEvaluation = 'add-evaluation', - RemoveEvaluation = 'remove-evaluation', ViewStatistics = 'view-statistics', - ForceRegistration = 'force-registration' } -interface UserQuery extends User { - id: User['id']; - 'GroupUser.role': GroupUser['role']; - GroupUser: GroupUser; -} +export type AppAbility = MongoAbility; +type AllTypes = typeof Evaluation | typeof Group | typeof User; -interface GroupQuery extends Group { +type EvaluationQuery = Evaluation & { + 'groups.users': UserQuery[]; + 'groups.users.id': User['id']; +}; + +type GroupQuery = Group & { users: UserQuery[]; 'users.id': User['id']; -} +}; -interface EvaluationQuery extends Evaluation { - 'groups.users': UserQuery[]; - 'groups.users.id': User['id']; -} +type PossibleAbilities = [Action, Subjects]; -export type AppAbility = MongoAbility; +type Subjects = 'all' | InferSubjects; + +type UserQuery = User & { + GroupUser: GroupUser; + 'GroupUser.role': GroupUser['role']; + id: User['id']; +}; @Injectable() export class CaslAbilityFactory { + // This provides the ability to use the same codepath for validating + // user abilities and non-registered user abilities. Useful for the + // few anonymous endpoints we have. + createForAnonymous(): MongoAbility { + const { build, cannot } = new AbilityBuilder(createMongoAbility); + cannot(Action.Manage, 'all'); + + return build(); + } + createForUser(user: User): MongoAbility { - const {can, cannot, build} = new AbilityBuilder(createMongoAbility); + const { build, can, cannot } = new AbilityBuilder(createMongoAbility); if (user.role === 'admin') { // all is a special keyword in CASL that represents "any subject". // read-write access to everything @@ -63,65 +73,41 @@ export class CaslAbilityFactory { // Read statistics about this heimdall deployment can(Action.ViewStatistics, 'all'); // Force admins to supply their password when editing their own user. - cannot(Action.Manage, User, {id: user.id}); + cannot(Action.Manage, User, { id: user.id }); } can([Action.ReadSlim], User); - can([Action.Read, Action.Update, Action.Delete], User, {id: user.id}); + can([Action.Read, Action.Update, Action.Delete], User, { id: user.id }); can([Action.Create], Group); - can([Action.Read], Group, {public: true}); + can([Action.Read], Group, { public: true }); // Trying to compare the whole object here doesn't work since the // user object includes `GroupUser` and therefore the passed in user // is not equal to the user on the Group can( [Action.Read, Action.AddEvaluation, Action.RemoveEvaluation], Group, - { - 'users.id': user.id - } + { 'users.id': user.id }, ); - can([Action.Manage], Group, { - users: { - $elemMatch: {id: user.id, 'GroupUser.role': 'owner'} - } - }); + can([Action.Manage], Group, { users: { $elemMatch: { 'GroupUser.role': 'owner', id: user.id } } }); // This really isn't the best method to do this since // it requires every evaluation to have a join on Groups and then another join on Users can([Action.Create], Evaluation); - can(Action.Read, Evaluation, {public: true}); + can(Action.Read, Evaluation, { public: true }); - can([Action.Manage], Evaluation, { - userId: user.id - }); + can([Action.Manage], Evaluation, { userId: user.id }); - can([Action.Read], Evaluation, { - 'groups.users.id': user.id - }); + can([Action.Read], Evaluation, { 'groups.users.id': user.id }); - can([Action.Manage], Evaluation, { - 'groups.users': { - $elemMatch: {id: user.id, 'GroupUser.role': 'owner'} - } - }); + can([Action.Manage], Evaluation, { 'groups.users': { $elemMatch: { 'GroupUser.role': 'owner', id: user.id } } }); return build({ - detectSubjectType: (object) => - object.constructor as ExtractSubjectType + detectSubjectType: object => + object.constructor as ExtractSubjectType, }); } - - // This provides the ability to use the same codepath for validating - // user abilities and non-registered user abilities. Useful for the - // few anonymous endpoints we have. - createForAnonymous(): MongoAbility { - const {cannot, build} = new AbilityBuilder(createMongoAbility); - cannot(Action.Manage, 'all'); - - return build(); - } } diff --git a/apps/backend/src/casl/casl-exception.filter.ts b/apps/backend/src/casl/casl-exception.filter.ts index 2b2bc2ddd4..fcded9e12b 100644 --- a/apps/backend/src/casl/casl-exception.filter.ts +++ b/apps/backend/src/casl/casl-exception.filter.ts @@ -1,6 +1,6 @@ -import {ForbiddenError} from '@casl/ability'; -import {ArgumentsHost, Catch, ForbiddenException} from '@nestjs/common'; -import {BaseExceptionFilter} from '@nestjs/core'; +import { ForbiddenError } from '@casl/ability'; +import { ArgumentsHost, Catch, ForbiddenException } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; @Catch() export class CaslExceptionFilter extends BaseExceptionFilter { diff --git a/apps/backend/src/config/config.module.ts b/apps/backend/src/config/config.module.ts index 24c9feff7d..c9b90a74fa 100644 --- a/apps/backend/src/config/config.module.ts +++ b/apps/backend/src/config/config.module.ts @@ -1,8 +1,8 @@ -import {Module} from '@nestjs/common'; -import {ConfigService} from './config.service'; +import { Module } from '@nestjs/common'; +import { ConfigService } from './config.service'; @Module({ + exports: [ConfigService], providers: [ConfigService], - exports: [ConfigService] }) export class ConfigModule {} diff --git a/apps/backend/src/config/dto/startup-settings.dto.ts b/apps/backend/src/config/dto/startup-settings.dto.ts index 3af275b85b..93a8fc8c76 100644 --- a/apps/backend/src/config/dto/startup-settings.dto.ts +++ b/apps/backend/src/config/dto/startup-settings.dto.ts @@ -1,4 +1,4 @@ -import {IStartupSettings} from '@heimdall/common/interfaces'; +import type { IStartupSettings } from '@heimdall/common/interfaces'; export class StartupSettingsDto implements IStartupSettings { readonly apiKeysEnabled: boolean; @@ -8,13 +8,13 @@ export class StartupSettingsDto implements IStartupSettings { readonly classificationBannerTextColor: string; readonly enabledOAuth: string[]; readonly externalUrl: string; - readonly oidcName: string; + readonly forceTenableFrontend: boolean; readonly ldap: boolean; - readonly registrationEnabled: boolean; readonly localLoginEnabled: boolean; - readonly tenableHostUrl: string; - readonly forceTenableFrontend: boolean; + readonly oidcName: string; + readonly registrationEnabled: boolean; readonly splunkHostUrl: string; + readonly tenableHostUrl: string; constructor(settings: IStartupSettings) { this.apiKeysEnabled = settings.apiKeysEnabled; diff --git a/apps/backend/src/crypto/hash-write-gate.service.ts b/apps/backend/src/crypto/hash-write-gate.service.ts index 95de98bd71..5a75d904f2 100644 --- a/apps/backend/src/crypto/hash-write-gate.service.ts +++ b/apps/backend/src/crypto/hash-write-gate.service.ts @@ -25,14 +25,14 @@ export { SUPPORTED_HASH_MARKER_VERSION } from './hash-write-decision'; */ @Injectable() export class HashWriteGateService { + private derivation?: HashWriteDecision; + + private markerPlanted = false; public logger = createLogger({ format: format.printf(info => `[Hash Write Gate]: ${String(info.message)}`), transports: [new transports.Console()], }); - private derivation?: HashWriteDecision; - private markerPlanted = false; - constructor( @InjectModel(HashMigrationMarker) private readonly markerModel: typeof HashMigrationMarker, @@ -46,6 +46,28 @@ export class HashWriteGateService { ); } + private async derive(): Promise { + const explicitSetting = this.configService.get( + 'PASSWORD_HASH_WRITE_ENABLED', + ); + if (explicitSetting === 'true' || explicitSetting === 'false') { + // An explicit setting decides alone — no DB probes (the manual test + // constructions with unregistered model classes rely on this). + return deriveHashWriteState({ + explicitSetting, + markerPresent: false, + usersPresent: false, + }); + } + const hasMarker = (await this.markerModel.count()) > 0; + const hasUsers = (await this.userModel.count()) > 0; + return deriveHashWriteState({ + explicitSetting, + markerPresent: hasMarker, + usersPresent: hasUsers, + }); + } + /** * §12 mechanism 3 — the downgrade refusal, in the application because RPM * %pre cannot fire on the downgrades it targets (on downgrade the OLDER @@ -107,26 +129,4 @@ export class HashWriteGateService { } return this.derivation.enabled; } - - private async derive(): Promise { - const explicitSetting = this.configService.get( - 'PASSWORD_HASH_WRITE_ENABLED', - ); - if (explicitSetting === 'true' || explicitSetting === 'false') { - // An explicit setting decides alone — no DB probes (the manual test - // constructions with unregistered model classes rely on this). - return deriveHashWriteState({ - explicitSetting, - markerPresent: false, - usersPresent: false, - }); - } - const hasMarker = (await this.markerModel.count()) > 0; - const hasUsers = (await this.userModel.count()) > 0; - return deriveHashWriteState({ - explicitSetting, - markerPresent: hasMarker, - usersPresent: hasUsers, - }); - } } diff --git a/apps/backend/src/crypto/password.service.ts b/apps/backend/src/crypto/password.service.ts index a7a1753cee..47b91259f9 100644 --- a/apps/backend/src/crypto/password.service.ts +++ b/apps/backend/src/crypto/password.service.ts @@ -83,6 +83,45 @@ export class PasswordService { configureKdfLimiter({ concurrency }); } + private readAlgorithm(): PasswordHashAlgorithm { + const raw = this.configService.get('PASSWORD_HASH_ALGORITHM'); + if (raw === undefined || raw === '') { + return PasswordService.DEFAULT_ALGORITHM; + } + if (isAlgorithm(raw)) { + return raw; + } + throw new PasswordHashError( + `PASSWORD_HASH_ALGORITHM must be one of sha256, sha384, sha512 (got '${raw}')`, + ); + } + + private readIntInRange( + key: string, + fallback: number, + min: number, + max: number, + ): number { + const raw = this.configService.get(key); + if (raw === undefined || raw === '') { + return fallback; + } + // Decimal integers only — never parseInt/Number coercion (§6 step 4: + // parseInt('6e5') === 6, Number('0x10') === 16). + if (!DECIMAL_INTEGER.test(raw)) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + return value; + } + /** * Hash a password using the configured algorithm and iterations. Enforces * the configured PASSWORD_MAX_LENGTH on this (hash) path only; the pure @@ -142,43 +181,4 @@ export class PasswordService { writesEnabled(): Promise { return this.hashWriteGate.writesEnabled(); } - - private readAlgorithm(): PasswordHashAlgorithm { - const raw = this.configService.get('PASSWORD_HASH_ALGORITHM'); - if (raw === undefined || raw === '') { - return PasswordService.DEFAULT_ALGORITHM; - } - if (isAlgorithm(raw)) { - return raw; - } - throw new PasswordHashError( - `PASSWORD_HASH_ALGORITHM must be one of sha256, sha384, sha512 (got '${raw}')`, - ); - } - - private readIntInRange( - key: string, - fallback: number, - min: number, - max: number, - ): number { - const raw = this.configService.get(key); - if (raw === undefined || raw === '') { - return fallback; - } - // Decimal integers only — never parseInt/Number coercion (§6 step 4: - // parseInt('6e5') === 6, Number('0x10') === 16). - if (!DECIMAL_INTEGER.test(raw)) { - throw new PasswordHashError( - `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, - ); - } - const value = Number(raw); - if (!Number.isSafeInteger(value) || value < min || value > max) { - throw new PasswordHashError( - `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, - ); - } - return value; - } } diff --git a/apps/backend/src/database/database.module.ts b/apps/backend/src/database/database.module.ts index 507dc3829b..665db2b1cd 100644 --- a/apps/backend/src/database/database.module.ts +++ b/apps/backend/src/database/database.module.ts @@ -1,27 +1,23 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; import winston from 'winston'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {DatabaseService} from './database.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { DatabaseService } from './database.service'; const line = '________________________________________________\n'; const logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.colorize({ - all: true - }), - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.errors({stack: true}), + winston.format.colorize({ all: true }), + winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + winston.format.errors({ stack: true }), winston.format.align(), winston.format.printf( - (info) => - `${line}[${info.timestamp}] Query(${info.queryType}): ${info.message}` - ) - ) + info => + `${line}[${info.timestamp}] Query(${info.queryType}): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); const localConfigService = new ConfigService(); @@ -30,40 +26,36 @@ function getSynchronize(configService: ConfigService): boolean { const nodeEnvironment = configService.get('NODE_ENV'); if (nodeEnvironment === undefined) { throw new TypeError('NODE_ENV is not set and must be provided.'); - } else { - return nodeEnvironment === 'test' ? false : true; } -} - -function sanitize(fields: string[], values?: string[]): string[] { - return ( - values?.map((value, index) => { - if ( - localConfigService.sensitiveKeys.some((regex) => - regex.test(fields[index + 1]) - ) - ) { - return 'REDACTED'; - } else { - return value; - } - }) || [] - ); + return nodeEnvironment === 'test' ? false : true; } function logQuery( sql: string, - connection: {fields: string[]; bind: string[]; type: string} + connection: { bind: string[]; fields: string[]; type: string }, ) { logger.info({ message: `${sql} [${sanitize(connection.fields, connection.bind).join( - ', ' + ', ', )}]`, - queryType: connection.type + queryType: connection.type, }); } +function sanitize(fields: string[], values?: string[]): string[] { + return ( + values?.map((value, index) => { + return localConfigService.sensitiveKeys.some(regex => + regex.test(fields[index + 1]), + ) + ? 'REDACTED' + : value; + }) || [] + ); +} + @Module({ + exports: [DatabaseService], imports: [ SequelizeModule.forRootAsync({ imports: [ConfigModule], @@ -71,28 +63,27 @@ function logQuery( useFactory: (configService: ConfigService) => ({ ...configService.getDbConfig(), autoLoadModels: true, - synchronize: getSynchronize(configService), logging: (sql, connection) => { logQuery( sql, // Connection is incorrectly typed as a number connection as unknown as { - fields: string[]; bind: string[]; + fields: string[]; type: string; - } + }, ); }, pool: { + acquire: 30_000, + idle: 10_000, max: 5, min: 0, - acquire: 30000, - idle: 10000 - } - }) - }) + }, + synchronize: getSynchronize(configService), + }), + }), ], providers: [DatabaseService], - exports: [DatabaseService] }) export class DatabaseModule {} diff --git a/apps/backend/src/database/database.service.spec.ts b/apps/backend/src/database/database.service.spec.ts index 11e7a3c41a..b5541dcb5a 100644 --- a/apps/backend/src/database/database.service.spec.ts +++ b/apps/backend/src/database/database.service.spec.ts @@ -1,8 +1,8 @@ -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, describe, expect, it} from 'vitest'; -import {DatabaseModule} from './database.module'; -import {DatabaseService} from './database.service'; -import {DeltaArgs} from './interfaces/delta-args.interface'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DatabaseModule } from './database.module'; +import { DatabaseService } from './database.service'; +import type { DeltaArgs as DeltaArguments } from './interfaces/delta-args.interface'; describe('DatabaseService', () => { let databaseService: DatabaseService; @@ -10,7 +10,7 @@ describe('DatabaseService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [DatabaseModule], - providers: [DatabaseService] + providers: [DatabaseService], }).compile(); databaseService = module.get(DatabaseService); @@ -23,8 +23,8 @@ describe('DatabaseService', () => { describe('getDelta', () => { it('returns the correct value when no items are given', async () => { - const source: DeltaArgs[] = []; - const updated: DeltaArgs[] = []; + const source: DeltaArguments[] = []; + const updated: DeltaArguments[] = []; const delta = databaseService.getDelta(source, updated); expect(delta.added.length).toEqual(0); @@ -33,8 +33,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when an item is added', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}]; - const updated = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + const source = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -44,8 +44,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when an item is changed', async () => { - const source = [{id: 1, prop: 1}]; - const updated = [{id: 1, prop: 2}]; + const source = [{ id: 1, prop: 1 }]; + const updated = [{ id: 1, prop: 2 }]; const delta = databaseService.getDelta(source, updated); @@ -57,8 +57,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when an item is deleted', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated = [{id: 1}, {id: 2}, {id: 4}]; + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -68,8 +68,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when all items are added', async () => { - const source: DeltaArgs[] = []; - const updated: DeltaArgs[] = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + const source: DeltaArguments[] = []; + const updated: DeltaArguments[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -79,8 +79,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when all items are changed', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -90,8 +90,8 @@ describe('DatabaseService', () => { }); it('returns the correct value when all items are deleted', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated: DeltaArgs[] = []; + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated: DeltaArguments[] = []; const delta = databaseService.getDelta(source, updated); diff --git a/apps/backend/src/database/database.service.ts b/apps/backend/src/database/database.service.ts index e5a6ab41e6..f6eb2f5e92 100644 --- a/apps/backend/src/database/database.service.ts +++ b/apps/backend/src/database/database.service.ts @@ -1,52 +1,49 @@ -import {Injectable} from '@nestjs/common'; -import {Sequelize} from 'sequelize-typescript'; -import {DeltaArgs} from './interfaces/delta-args.interface'; -import {IDelta} from './interfaces/delta.interface'; +import { Injectable } from '@nestjs/common'; +import { Sequelize } from 'sequelize-typescript'; +import { DeltaArgs as DeltaArguments } from './interfaces/delta-args.interface'; +import { IDelta } from './interfaces/delta.interface'; @Injectable() export class DatabaseService { constructor(readonly sequelize: Sequelize) {} - async closeConnection(): Promise { - await this.sequelize.close(); + async cleanAll(): Promise { + await this.sequelize.truncate({ cascade: true, restartIdentity: true }); } - async cleanAll(): Promise { - await this.sequelize.truncate({cascade: true, restartIdentity: true}); + async closeConnection(): Promise { + await this.sequelize.close(); } - getDelta( - source: Array, - updated: Array + getDelta( + source: T[], + updated: T[], ): IDelta { if (source === undefined || updated === undefined) { return { added: [], changed: [], - deleted: [] + deleted: [], }; } const added = updated.filter( - (updatedItem) => - source.find((sourceItem) => sourceItem.id === updatedItem.id) === - undefined + updatedItem => + source.every(sourceItem => sourceItem.id !== updatedItem.id), ); const changed = updated.filter( - (updatedItem) => - source.find((sourceItem) => sourceItem.id === updatedItem.id) !== - undefined + updatedItem => + source.some(sourceItem => sourceItem.id === updatedItem.id), ); const deleted = source.filter( - (sourceItem) => - updated.find((updatedItem) => updatedItem.id === sourceItem.id) === - undefined + sourceItem => + updated.every(updatedItem => updatedItem.id !== sourceItem.id), ); return { added: added, changed: changed, - deleted: deleted + deleted: deleted, }; } } diff --git a/apps/backend/src/database/interfaces/delta-args.interface.ts b/apps/backend/src/database/interfaces/delta-args.interface.ts index 078b718373..692f20e1e9 100644 --- a/apps/backend/src/database/interfaces/delta-args.interface.ts +++ b/apps/backend/src/database/interfaces/delta-args.interface.ts @@ -1,3 +1 @@ -export interface DeltaArgs { - id: number; -} +export type DeltaArgs = { id: number }; diff --git a/apps/backend/src/database/interfaces/delta.interface.ts b/apps/backend/src/database/interfaces/delta.interface.ts index 020a0f92ba..590cae50e9 100644 --- a/apps/backend/src/database/interfaces/delta.interface.ts +++ b/apps/backend/src/database/interfaces/delta.interface.ts @@ -1,5 +1,5 @@ -export interface IDelta { - added: Array; - changed: Array; - deleted: Array; -} +export type IDelta = { + added: T[]; + changed: T[]; + deleted: T[]; +}; diff --git a/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts index e1a7cadaf3..3f8031db3f 100644 --- a/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts @@ -1,5 +1,5 @@ -import {ICreateEvaluationTag} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { ICreateEvaluationTag } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class CreateEvaluationTagDto implements ICreateEvaluationTag { @IsNotEmpty() diff --git a/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts index 98c9e1c234..bddd57e976 100644 --- a/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteEvaluationTag} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsNumberString, IsString, Min} from 'class-validator'; +import { IDeleteEvaluationTag } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsNumberString, IsString, Min } from 'class-validator'; export class DeleteEvaluationTagDto implements IDeleteEvaluationTag { @IsNotEmpty() diff --git a/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts index b10bf9a39c..673a4855f7 100644 --- a/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts @@ -1,12 +1,12 @@ -import {IEvaluationTag} from '@heimdall/common/interfaces'; -import {EvaluationTag} from '../evaluation-tag.model'; +import type { IEvaluationTag } from '@heimdall/common/interfaces'; +import type { EvaluationTag } from '../evaluation-tag.model'; export class EvaluationTagDto implements IEvaluationTag { - readonly id: string; - readonly value: string; - readonly evaluationId: string; readonly createdAt: Date; + readonly evaluationId: string; + readonly id: string; readonly updatedAt: Date; + readonly value: string; constructor(evaluationTag: EvaluationTag) { this.id = evaluationTag.id; diff --git a/apps/backend/src/evaluation-tags/evaluation-tag.model.ts b/apps/backend/src/evaluation-tags/evaluation-tag.model.ts index 5ba6f0fb86..67af37f501 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tag.model.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tag.model.ts @@ -7,12 +7,23 @@ import { ForeignKey, Model, PrimaryKey, - Table + Table, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; +import { Evaluation } from '../evaluations/evaluation.model'; @Table export class EvaluationTag extends Model { + @AllowNull(false) + @Column + declare createdAt: Date; + + @BelongsTo(() => Evaluation) + declare evaluation: Evaluation; + + @ForeignKey(() => Evaluation) + @Column(DataType.BIGINT) + declare evaluationId: string; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -21,20 +32,9 @@ export class EvaluationTag extends Model { @AllowNull(false) @Column - declare value: string; - - @AllowNull(false) - @Column - declare createdAt: Date; + declare updatedAt: Date; @AllowNull(false) @Column - declare updatedAt: Date; - - @ForeignKey(() => Evaluation) - @Column(DataType.BIGINT) - declare evaluationId: string; - - @BelongsTo(() => Evaluation) - declare evaluation: Evaluation; + declare value: string; } diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts index b190b7611b..6db6253340 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts @@ -1,30 +1,31 @@ -import {ForbiddenError} from '@casl/ability'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {CREATE_EVALUATION_TAG_DTO} from '../../test/constants/evaluation-tags-test.constant'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; -import {PRIVATE_GROUP} from '../../test/constants/groups-test.constant'; +import { ForbiddenError } from '@casl/ability'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { CREATE_EVALUATION_TAG_DTO } from '../../test/constants/evaluation-tags-test.constant'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; +import { PRIVATE_GROUP } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsController} from './evaluation-tags.controller'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsController } from './evaluation-tags.controller'; +import { EvaluationTagsService } from './evaluation-tags.service'; describe('EvaluationTagsController', () => { let evaluationTagsController: EvaluationTagsController; @@ -49,8 +50,8 @@ describe('EvaluationTagsController', () => { User, GroupEvaluation, Group, - GroupUser - ]) + GroupUser, + ]), ], providers: [ AuthzService, @@ -58,15 +59,15 @@ describe('EvaluationTagsController', () => { EvaluationTagsService, UsersService, EvaluationsService, - GroupsService - ] + GroupsService, + ], }).compile(); evaluationTagsController = module.get( - EvaluationTagsController + EvaluationTagsController, ); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); evaluationsService = module.get(EvaluationsService); usersService = module.get(UsersService); @@ -89,63 +90,57 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(1); expect(foundEvaluationTags[0].value).toEqual( - CREATE_EVALUATION_TAG_DTO.value + CREATE_EVALUATION_TAG_DTO.value, ); }); it('should return EvaluationTags a User has group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(1); expect(foundEvaluationTags[0].value).toEqual( - CREATE_EVALUATION_TAG_DTO.value + CREATE_EVALUATION_TAG_DTO.value, ); }); it('should not return EvaluationTags associated with an Evaluation a User not authorized to view', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(0); }); }); @@ -155,38 +150,38 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const foundTag = await evaluationTagsController.findById( evaluationTag.id, - {user: user} + { user: user }, ); expect(foundTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); }); it('should return an EvaluationTags a User has group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const foundEvaluationTag = await evaluationTagsController.findById( evaluationTag.id, - {user: user} + { user: user }, ); expect(foundEvaluationTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); @@ -196,20 +191,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); await expect( - evaluationTagsController.findById(evaluationTag.id, {user: user}) + evaluationTagsController.findById(evaluationTag.id, { user: user }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -219,24 +214,24 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} + { user: user }, ); expect(evaluationTag).toBeDefined(); }); it('should create EvaluationTags on an Evaluation a User has Group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); @@ -244,7 +239,7 @@ describe('EvaluationTagsController', () => { const evaluationTag = await evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} + { user: user }, ); expect(evaluationTag).toBeDefined(); @@ -254,20 +249,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await expect( evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} - ) + { user: user }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -277,38 +272,38 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const removedTag = await evaluationTagsController.remove( evaluationTag.id, - {user: user} + { user: user }, ); expect(removedTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); }); it('should remove EvaluationTags on an Evaluation a User has Group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const removedTag = await evaluationTagsController.remove( evaluationTag.id, - {user: user} + { user: user }, ); expect(removedTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); @@ -318,20 +313,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); await expect( - evaluationTagsController.remove(evaluationTag.id, {user: user}) + evaluationTagsController.remove(evaluationTag.id, { user: user }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts b/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts index 73f94f7234..27ccd6fa93 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -8,17 +8,17 @@ import { Post, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {CreateEvaluationTagDto} from './dto/create-evaluation-tag.dto'; -import {EvaluationTagDto} from './dto/evaluation-tag.dto'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { CreateEvaluationTagDto } from './dto/create-evaluation-tag.dto'; +import { EvaluationTagDto } from './dto/evaluation-tag.dto'; +import { EvaluationTagsService } from './evaluation-tags.service'; @Controller('evaluation-tags') @UseGuards(JwtAuthGuard) @@ -27,65 +27,65 @@ export class EvaluationTagsController { constructor( private readonly evaluationTagsService: EvaluationTagsService, private readonly evaluationsService: EvaluationsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get() - async index(@Request() request: {user: User}): Promise { + @Post(':evaluationId') + async create( + @Param('evaluationId') evaluationId: string, + @Body() createEvaluationTagDto: CreateEvaluationTagDto, + @Request() request: { user: User }, + ): Promise { const abac = this.authz.abac.createForUser(request.user); - let evaluationTags = await this.evaluationTagsService.findAll(); - evaluationTags = evaluationTags.filter((evaluationTag) => - abac.can(Action.Read, evaluationTag.evaluation) - ); - return evaluationTags.map( - (evaluationTag) => new EvaluationTagDto(evaluationTag) + const evaluation = await this.evaluationsService.findById(evaluationId); + // Use Action.Update here because any authenticated user can create an evaluation + // and we wouldn't want anyone to be able to add any tag to any evaluation. + ForbiddenError.from(abac).throwUnlessCan(Action.Update, evaluation); + + return new EvaluationTagDto( + await this.evaluationTagsService.create( + evaluationId, + createEvaluationTagDto, + ), ); } @Get(':id') async findById( @Param('id') id: string, - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); const evaluationTag = await this.evaluationTagsService.findById(id); ForbiddenError.from(abac).throwUnlessCan( Action.Read, - evaluationTag.evaluation + evaluationTag.evaluation, ); return new EvaluationTagDto(evaluationTag); } - @Post(':evaluationId') - async create( - @Param('evaluationId') evaluationId: string, - @Body() createEvaluationTagDto: CreateEvaluationTagDto, - @Request() request: {user: User} - ): Promise { + @Get() + async index(@Request() request: { user: User }): Promise { const abac = this.authz.abac.createForUser(request.user); - const evaluation = await this.evaluationsService.findById(evaluationId); - // Use Action.Update here because any authenticated user can create an evaluation - // and we wouldn't want anyone to be able to add any tag to any evaluation. - ForbiddenError.from(abac).throwUnlessCan(Action.Update, evaluation); - - return new EvaluationTagDto( - await this.evaluationTagsService.create( - evaluationId, - createEvaluationTagDto - ) + let evaluationTags = await this.evaluationTagsService.findAll(); + evaluationTags = evaluationTags.filter(evaluationTag => + abac.can(Action.Read, evaluationTag.evaluation), + ); + return evaluationTags.map( + evaluationTag => new EvaluationTagDto(evaluationTag), ); } @Delete(':id') async remove( @Param('id') id: string, - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); const evaluationTag = await this.evaluationTagsService.findById(id); ForbiddenError.from(abac).throwUnlessCan( Action.Delete, - evaluationTag.evaluation + evaluationTag.evaluation, ); return new EvaluationTagDto(await this.evaluationTagsService.remove(id)); } diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.module.ts b/apps/backend/src/evaluation-tags/evaluation-tags.module.ts index 6cad8fe0c0..9acc93e97b 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.module.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.module.ts @@ -1,23 +1,23 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ConfigModule} from '../config/config.module'; -import {DatabaseModule} from '../database/database.module'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsController} from './evaluation-tags.controller'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '../config/config.module'; +import { DatabaseModule } from '../database/database.module'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsController } from './evaluation-tags.controller'; +import { EvaluationTagsService } from './evaluation-tags.service'; @Module({ + controllers: [EvaluationTagsController], + exports: [EvaluationTagsService], imports: [ SequelizeModule.forFeature([Evaluation, Group, User, EvaluationTag]), ConfigModule, - DatabaseModule + DatabaseModule, ], providers: [EvaluationsService, EvaluationTagsService], - controllers: [EvaluationTagsController], - exports: [EvaluationTagsService] }) export class EvaluationTagsModule {} diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts b/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts index 32d7a680c8..66d0b93d08 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts @@ -1,29 +1,29 @@ -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { CREATE_EVALUATION_TAG_DTO, - CREATE_EVALUATION_TAG_DTO_MISSING_VALUE + CREATE_EVALUATION_TAG_DTO_MISSING_VALUE, } from '../../test/constants/evaluation-tags-test.constant'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - USERS_SERVICE_MOCK + USERS_SERVICE_MOCK, } from '../../test/constants/users-test.constant'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationDto} from '../evaluations/dto/evaluation.dto'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationDto } from '../evaluations/dto/evaluation.dto'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsService } from './evaluation-tags.service'; describe('EvaluationTagsService', () => { let evaluationTagsService: EvaluationTagsService; @@ -42,20 +42,20 @@ describe('EvaluationTagsService', () => { User, GroupEvaluation, Group, - GroupUser - ]) + GroupUser, + ]), ], providers: [ DatabaseService, EvaluationTagsService, EvaluationsService, - {provide: UsersService, useValue: USERS_SERVICE_MOCK}, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: UsersService, useValue: USERS_SERVICE_MOCK }, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); evaluationsService = module.get(EvaluationsService); databaseService = module.get(DatabaseService); @@ -74,8 +74,8 @@ describe('EvaluationTagsService', () => { await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id - }) + userId: user.id, + }), ); }); @@ -83,7 +83,7 @@ describe('EvaluationTagsService', () => { it('should create a valid EvaluationTag', async () => { const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); expect(evaluationTag.id).toBeDefined(); expect(evaluationTag.evaluationId).toEqual(evaluation.id); @@ -98,10 +98,10 @@ describe('EvaluationTagsService', () => { await expect( evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO_MISSING_VALUE - ) + CREATE_EVALUATION_TAG_DTO_MISSING_VALUE, + ), ).rejects.toThrow( - 'notNull Violation: EvaluationTag.value cannot be null' + 'notNull Violation: EvaluationTag.value cannot be null', ); }); }); @@ -116,14 +116,14 @@ describe('EvaluationTagsService', () => { // One existing tag await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toEqual(1); // Multiple existing tags await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toBeGreaterThan(1); @@ -134,15 +134,15 @@ describe('EvaluationTagsService', () => { it('should remove an existing tag', async () => { const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); expect(evaluationTag).toBeDefined(); const removedEvaluationTag = await evaluationTagsService.remove( - evaluationTag.id + evaluationTag.id, ); expect(removedEvaluationTag.value).toEqual(evaluationTag.value); const foundEvaluationTag = await EvaluationTag.findByPk( - evaluationTag.id + evaluationTag.id, ); expect(foundEvaluationTag).toEqual(null); }); diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.service.ts b/apps/backend/src/evaluation-tags/evaluation-tags.service.ts index 704c77ec53..e7a359461f 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.service.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.service.ts @@ -1,95 +1,94 @@ -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions} from 'sequelize'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {CreateEvaluationTagDto} from './dto/create-evaluation-tag.dto'; -import {EvaluationTag} from './evaluation-tag.model'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions } from 'sequelize'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { CreateEvaluationTagDto } from './dto/create-evaluation-tag.dto'; +import { EvaluationTag } from './evaluation-tag.model'; @Injectable() export class EvaluationTagsService { constructor( @InjectModel(EvaluationTag) - private readonly evaluationTagModel: typeof EvaluationTag + private readonly evaluationTagModel: typeof EvaluationTag, ) {} + async count(): Promise { + return this.evaluationTagModel.count(); + } + + async create( + evaluationId: string, + createEvaluationTagDto: CreateEvaluationTagDto, + ): Promise { + const evaluationTag = new EvaluationTag(); + evaluationTag.value = createEvaluationTagDto.value; + evaluationTag.evaluationId = evaluationId; + return evaluationTag.save(); + } + async findAll(): Promise { return this.evaluationTagModel.findAll({ include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); } - async count(): Promise { - return this.evaluationTagModel.count(); - } - async findById(id: string): Promise { return this.findByPkBang(id, { include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); } - async create( - evaluationId: string, - createEvaluationTagDto: CreateEvaluationTagDto + async findByPkBang( + identifier: Buffer | number | string | undefined, + options: Pick, ): Promise { - const evaluationTag = new EvaluationTag(); - evaluationTag.value = createEvaluationTagDto.value; - evaluationTag.evaluationId = evaluationId; - return evaluationTag.save(); + const evaluationTag = await this.evaluationTagModel.findByPk( + identifier, + options, + ); + if (evaluationTag === null) { + throw new NotFoundException('EvaluationTag with given id not found'); + } + return evaluationTag; } async remove(id: string): Promise { const evaluationTag = await this.findByPkBang(id, { include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); await evaluationTag.destroy(); return evaluationTag; } - - async findByPkBang( - identifier: string | number | Buffer | undefined, - options: Pick - ): Promise { - const evaluationTag = await this.evaluationTagModel.findByPk( - identifier, - options - ); - if (evaluationTag === null) { - throw new NotFoundException('EvaluationTag with given id not found'); - } else { - return evaluationTag; - } - } } diff --git a/apps/backend/src/evaluations/dto/create-evaluation.dto.ts b/apps/backend/src/evaluations/dto/create-evaluation.dto.ts index 74ae80909c..0b6c41791c 100644 --- a/apps/backend/src/evaluations/dto/create-evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/create-evaluation.dto.ts @@ -1,27 +1,27 @@ -import {ICreateEvaluation} from '@heimdall/common/interfaces'; +import { ICreateEvaluation } from '@heimdall/common/interfaces'; import { IsArray, IsBoolean, IsNotEmpty, IsOptional, - IsString + IsString, } from 'class-validator'; -import {CreateEvaluationTagDto} from '../../evaluation-tags/dto/create-evaluation-tag.dto'; +import { CreateEvaluationTagDto } from '../../evaluation-tags/dto/create-evaluation-tag.dto'; export class CreateEvaluationDto implements ICreateEvaluation { - @IsNotEmpty() - @IsString() - readonly filename!: string; - - @IsNotEmpty() - @IsBoolean() - readonly public!: boolean; - @IsOptional() @IsArray() readonly evaluationTags: CreateEvaluationTagDto[] | undefined; + @IsNotEmpty() + @IsString() + readonly filename!: string; + @IsOptional() @IsArray() readonly groups: string[] | undefined; + + @IsNotEmpty() + @IsBoolean() + readonly public!: boolean; } diff --git a/apps/backend/src/evaluations/dto/evaluation.dto.ts b/apps/backend/src/evaluations/dto/evaluation.dto.ts index 43933354f7..22616f6d5f 100644 --- a/apps/backend/src/evaluations/dto/evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/evaluation.dto.ts @@ -1,48 +1,47 @@ -import {IEvaluation} from '@heimdall/common/interfaces'; -import {EvaluationTagDto} from '../../evaluation-tags/dto/evaluation-tag.dto'; -import {GroupDto} from '../../groups/dto/group.dto'; -import {Group} from '../../groups/group.model'; -import {Evaluation} from '../evaluation.model'; +import type { IEvaluation } from '@heimdall/common/interfaces'; +import { EvaluationTagDto } from '../../evaluation-tags/dto/evaluation-tag.dto'; +import { GroupDto } from '../../groups/dto/group.dto'; +import type { Group } from '../../groups/group.model'; +import type { Evaluation } from '../evaluation.model'; + +export type IEvaluationResponse = { + evaluations: EvaluationDto[]; + totalCount: number; +}; export class EvaluationDto implements IEvaluation { - readonly id: string; - filename: string; + readonly createdAt: Date; readonly data?: Record; + readonly editable: boolean; readonly evaluationTags: EvaluationTagDto[]; - readonly groups: GroupDto[]; - readonly userId?: string; + filename: string; readonly groupId?: string; + readonly groups: GroupDto[]; + readonly id: string; readonly public: boolean; - readonly createdAt: Date; - readonly updatedAt: Date; - readonly editable: boolean; readonly shareURL?: string; + readonly updatedAt: Date; + readonly userId?: string; constructor( evaluation: Evaluation, editable = false, - shareURL: string | undefined = undefined + shareURL?: string, ) { this.id = evaluation.id; this.filename = evaluation.filename; this.data = evaluation.data; - if ( - evaluation.evaluationTags === null || - evaluation.evaluationTags === undefined - ) { - this.evaluationTags = []; - } else { - this.evaluationTags = evaluation.evaluationTags.map( - (tag) => new EvaluationTagDto(tag) + this.evaluationTags = evaluation.evaluationTags === null + || evaluation.evaluationTags === undefined + ? [] + : evaluation.evaluationTags.map( + tag => new EvaluationTagDto(tag), ); - } - if (evaluation.groups === null || evaluation.groups === undefined) { - this.groups = []; - } else { - this.groups = evaluation.groups.map( - (group) => new GroupDto(group as Group) + this.groups = evaluation.groups === null || evaluation.groups === undefined + ? [] + : evaluation.groups.map( + group => new GroupDto(group as Group), ); - } this.userId = evaluation.userId; this.groupId = evaluation.groupId; this.public = evaluation.public; @@ -52,8 +51,3 @@ export class EvaluationDto implements IEvaluation { this.shareURL = shareURL; } } - -export interface IEvaluationResponse { - evaluations: EvaluationDto[]; - totalCount: number; -} diff --git a/apps/backend/src/evaluations/dto/update-evaluation.dto.ts b/apps/backend/src/evaluations/dto/update-evaluation.dto.ts index 19d691e711..9edd795d01 100644 --- a/apps/backend/src/evaluations/dto/update-evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/update-evaluation.dto.ts @@ -1,15 +1,15 @@ -import {IUpdateEvaluation} from '@heimdall/common/interfaces'; -import {IsBoolean, IsObject, IsOptional, IsString} from 'class-validator'; +import { IUpdateEvaluation } from '@heimdall/common/interfaces'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; export class UpdateEvaluationDto implements IUpdateEvaluation { - @IsOptional() - @IsString() - readonly filename: string | undefined; - @IsOptional() @IsObject() readonly data: Record | undefined; + @IsOptional() + @IsString() + readonly filename: string | undefined; + @IsOptional() @IsBoolean() readonly public: boolean | undefined; diff --git a/apps/backend/src/evaluations/evaluation.model.ts b/apps/backend/src/evaluations/evaluation.model.ts index 80d2e4a1a8..e40616601a 100644 --- a/apps/backend/src/evaluations/evaluation.model.ts +++ b/apps/backend/src/evaluations/evaluation.model.ts @@ -12,60 +12,58 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class Evaluation extends Model { - @PrimaryKey - @AutoIncrement - @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; - + @CreatedAt @AllowNull(false) @Column - declare filename: string; + declare createdAt: Date; @AllowNull(false) @Column(DataType.JSON) declare data: Record; - @AllowNull(false) - @Default(false) - @Column(DataType.BOOLEAN) - declare public: boolean; + @HasMany(() => EvaluationTag) + declare evaluationTags: EvaluationTag[]; - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; + @AllowNull(false) + @Column + declare filename: string; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @BelongsTo(() => User, { - constraints: false - }) - declare user: User; + @BelongsToMany(() => Group, () => GroupEvaluation) + declare groups: (Group & { GroupEvaluation: GroupEvaluation })[]; - @CreatedAt + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column - declare createdAt: Date; + @Column(DataType.BIGINT) + declare id: string; + + @AllowNull(false) + @Default(false) + @Column(DataType.BOOLEAN) + declare public: boolean; @UpdatedAt @AllowNull(false) @Column declare updatedAt: Date; - @HasMany(() => EvaluationTag) - declare evaluationTags: EvaluationTag[]; + @BelongsTo(() => User, { constraints: false }) + declare user: User; - @BelongsToMany(() => Group, () => GroupEvaluation) - declare groups: Array; + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/evaluations/evaluations.module.ts b/apps/backend/src/evaluations/evaluations.module.ts index 8f0e2c3d29..234fd4dd37 100644 --- a/apps/backend/src/evaluations/evaluations.module.ts +++ b/apps/backend/src/evaluations/evaluations.module.ts @@ -1,19 +1,21 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ConfigModule} from '../config/config.module'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '../config/config.module'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Evaluation} from './evaluation.model'; -import {EvaluationsController} from './evaluations.controller'; -import {EvaluationsService} from './evaluations.service'; +import { DatabaseModule } from '../database/database.module'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Evaluation } from './evaluation.model'; +import { EvaluationsController } from './evaluations.controller'; +import { EvaluationsService } from './evaluations.service'; @Module({ + controllers: [EvaluationsController], + exports: [EvaluationsService], imports: [ SequelizeModule.forFeature([ Evaluation, @@ -21,14 +23,12 @@ import {EvaluationsService} from './evaluations.service'; User, Group, GroupUser, - GroupEvaluation + GroupEvaluation, ]), ConfigModule, CryptoModule, - DatabaseModule + DatabaseModule, ], providers: [EvaluationsService, UsersService, GroupsService], - controllers: [EvaluationsController], - exports: [EvaluationsService] }) export class EvaluationsModule {} diff --git a/apps/backend/src/evaluations/evaluations.service.spec.ts b/apps/backend/src/evaluations/evaluations.service.spec.ts index 1b2bb6f423..d364c3d134 100644 --- a/apps/backend/src/evaluations/evaluations.service.spec.ts +++ b/apps/backend/src/evaluations/evaluations.service.spec.ts @@ -1,33 +1,33 @@ -import {NotFoundException} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { NotFoundException } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { CREATE_EVALUATION_DTO_WITHOUT_FILENAME, CREATE_EVALUATION_DTO_WITHOUT_TAGS, EVALUATION_WITH_TAGS_1, UPDATE_EVALUATION, UPDATE_EVALUATION_DATA_ONLY, - UPDATE_EVALUATION_FILENAME_ONLY + UPDATE_EVALUATION_FILENAME_ONLY, } from '../../test/constants/evaluations-test.constant'; -import {GROUP_1} from '../../test/constants/groups-test.constant'; -import {CREATE_USER_DTO_TEST_OBJ} from '../../test/constants/users-test.constant'; -import {ConfigService} from '../config/config.service'; +import { GROUP_1 } from '../../test/constants/groups-test.constant'; +import { CREATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTagsModule} from '../evaluation-tags/evaluation-tags.module'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {UserDto} from '../users/dto/user.dto'; -import {UsersModule} from '../users/users.module'; -import {UsersService} from '../users/users.service'; -import {EvaluationDto} from './dto/evaluation.dto'; -import {Evaluation} from './evaluation.model'; -import {EvaluationsService} from './evaluations.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTagsModule } from '../evaluation-tags/evaluation-tags.module'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { UserDto } from '../users/dto/user.dto'; +import { UsersModule } from '../users/users.module'; +import { UsersService } from '../users/users.service'; +import { EvaluationDto } from './dto/evaluation.dto'; +import { Evaluation } from './evaluation.model'; +import { EvaluationsService } from './evaluations.service'; describe('EvaluationsService', () => { let evaluationsService: EvaluationsService; @@ -46,24 +46,24 @@ describe('EvaluationsService', () => { Evaluation, GroupUser, Group, - GroupEvaluation + GroupEvaluation, ]), EvaluationTagsModule, - UsersModule + UsersModule, ], providers: [ ConfigService, EvaluationsService, DatabaseService, UsersService, - GroupsService - ] + GroupsService, + ], }).compile(); databaseService = module.get(DatabaseService); evaluationsService = module.get(EvaluationsService); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); usersService = module.get(UsersService); groupsService = module.get(GroupsService); @@ -87,12 +87,12 @@ describe('EvaluationsService', () => { await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); evaluationsDtoArray = await evaluationsService.findAll(); expect(evaluationsDtoArray.length).toEqual(2); @@ -102,7 +102,7 @@ describe('EvaluationsService', () => { await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const evaluations = await evaluationsService.findAll(); @@ -115,7 +115,7 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); let evaluations = await evaluationsService.findAll(); @@ -140,18 +140,18 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const foundEvaluation = await evaluationsService.findById(evaluation.id); expect(new EvaluationDto(evaluation)).toEqual( - new EvaluationDto(foundEvaluation) + new EvaluationDto(foundEvaluation), ); }); it('should throw an error if an evaluation does not exist', async () => { expect.assertions(1); await expect(evaluationsService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); @@ -161,7 +161,7 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); expect(evaluation.id).toBeDefined(); expect(evaluation.updatedAt).toBeDefined(); @@ -174,12 +174,12 @@ describe('EvaluationsService', () => { if (EVALUATION_WITH_TAGS_1.evaluationTags === undefined) { throw new TypeError( - 'Evaluation fixture does not have any associated tags.' + 'Evaluation fixture does not have any associated tags.', ); } expect(evaluation.evaluationTags?.[0].value).toEqual( - EVALUATION_WITH_TAGS_1.evaluationTags[0].value + EVALUATION_WITH_TAGS_1.evaluationTags[0].value, ); }); @@ -187,14 +187,14 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...CREATE_EVALUATION_DTO_WITHOUT_TAGS, data: {}, - userId: user.id + userId: user.id, }); expect(evaluation.id).toBeDefined(); expect(evaluation.updatedAt).toBeDefined(); expect(evaluation.createdAt).toBeDefined(); expect(evaluation.data).toEqual({}); expect(evaluation.filename).toEqual( - CREATE_EVALUATION_DTO_WITHOUT_TAGS.filename + CREATE_EVALUATION_DTO_WITHOUT_TAGS.filename, ); expect(evaluation.evaluationTags).not.toBeDefined(); expect((await evaluationTagsService.findAll()).length).toBe(0); @@ -206,10 +206,10 @@ describe('EvaluationsService', () => { evaluationsService.create({ ...CREATE_EVALUATION_DTO_WITHOUT_FILENAME, data: {}, - userId: user.id - }) + userId: user.id, + }), ).rejects.toThrow( - 'notNull Violation: Evaluation.filename cannot be null' + 'notNull Violation: Evaluation.filename cannot be null', ); }); }); @@ -218,7 +218,7 @@ describe('EvaluationsService', () => { it('should throw an error if an evaluation does not exist', async () => { expect.assertions(1); await expect( - evaluationsService.update('-1', UPDATE_EVALUATION) + evaluationsService.update('-1', UPDATE_EVALUATION), ).rejects.toThrow(NotFoundException); }); @@ -226,11 +226,11 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION + UPDATE_EVALUATION, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); @@ -243,17 +243,17 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION_DATA_ONLY + UPDATE_EVALUATION_DATA_ONLY, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); expect(updatedEvaluation.updatedAt).not.toEqual(evaluation.updatedAt); expect(updatedEvaluation.evaluationTags.length).toEqual( - evaluation.evaluationTags.length + evaluation.evaluationTags.length, ); expect(updatedEvaluation.data).not.toEqual(evaluation.data); expect(updatedEvaluation.filename).toEqual(evaluation.filename); @@ -263,18 +263,18 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION_FILENAME_ONLY + UPDATE_EVALUATION_FILENAME_ONLY, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); expect(updatedEvaluation.updatedAt).not.toEqual(evaluation.updatedAt); expect(updatedEvaluation.evaluationTags.length).toEqual( - evaluation.evaluationTags.length + evaluation.evaluationTags.length, ); expect(updatedEvaluation.data).toEqual(evaluation.data); expect(updatedEvaluation.filename).not.toEqual(evaluation.filename); @@ -286,24 +286,24 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const removedEvaluation = await evaluationsService.remove(evaluation.id); const foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toEqual(0); expect(new EvaluationDto(removedEvaluation)).toEqual( - new EvaluationDto(evaluation) + new EvaluationDto(evaluation), ); await expect( - evaluationsService.findById(removedEvaluation.id) + evaluationsService.findById(removedEvaluation.id), ).rejects.toThrow(NotFoundException); }); it('should throw an error when the evaluation does not exist', async () => { expect.assertions(1); await expect(evaluationsService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); diff --git a/apps/backend/src/evaluations/evaluations.service.ts b/apps/backend/src/evaluations/evaluations.service.ts index 7102ed8080..0335a0facc 100644 --- a/apps/backend/src/evaluations/evaluations.service.ts +++ b/apps/backend/src/evaluations/evaluations.service.ts @@ -1,44 +1,45 @@ -import {IEvalPaginationParams} from '@heimdall/common/interfaces'; -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions, Op, WhereOptions, Sequelize} from 'sequelize'; -import {DatabaseService} from '../database/database.service'; -import {CreateEvaluationTagDto} from '../evaluation-tags/dto/create-evaluation-tag.dto'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {UpdateEvaluationDto} from './dto/update-evaluation.dto'; -import {Evaluation} from './evaluation.model'; - -interface EvaluationsResponse { - totalItems: number; +import { IEvalPaginationParams } from '@heimdall/common/interfaces'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions, Op, Sequelize, WhereOptions } from 'sequelize'; +import { DatabaseService } from '../database/database.service'; +import { CreateEvaluationTagDto } from '../evaluation-tags/dto/create-evaluation-tag.dto'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { UpdateEvaluationDto } from './dto/update-evaluation.dto'; +import { Evaluation } from './evaluation.model'; + +type EvaluationsResponse = { evaluations: Evaluation[]; -} + totalItems: number; +}; -interface WhereClauseParams { - searchFields: string[]; - operator: string; +type WhereClauseParameters = { + action: string; email: string; + operator: string; role: string; - action: string; -} + searchFields: string[]; +}; @Injectable() export class EvaluationsService { + /* + NOTE: Hack to overcome the inability to retrieve the desire + number of evaluation (see note 1 above). Pad the + requested number of records by an estimated number of + group members (20 per group). + */ + totalGroupMembers = 20; + constructor( @InjectModel(Evaluation) private readonly evaluationModel: typeof Evaluation, - private readonly databaseService: DatabaseService + private readonly databaseService: DatabaseService, ) {} - async findAll(): Promise { - return this.evaluationModel.findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}] - }); - } - /* - NOTES: These notes are about the getAllEvaluations() and the + NOTES: These notes are about the getAllEvaluations() and the getEvaluationsWithClause() methods 1: The sequelize model is using eager loading, at the SQL level, this is a @@ -58,7 +59,7 @@ export class EvaluationsService { 2: TypeScript is not able to infer OrderItem[]. - The 'order' option in sequelize is defined as type OrderItem like: + The 'order' option in sequelize is defined as type OrderItem like: string | fn | col | literal | [string | col | fn | literal, string] | [Model | { model: Model, as: string }, string, string] | [Model, Model, string, string] @@ -76,7 +77,7 @@ export class EvaluationsService { Using the findAll and calling specific queries to determine the total records. - 4: Using ORDER BY on top-level and nested columns, for that reason we need + 4: Using ORDER BY on top-level and nested columns, for that reason we need to reference nested columns by utilizing the '$nested.column$' syntax. For that reason the params.order array can have 2 or 3 indices as listed bellow. @@ -88,111 +89,183 @@ export class EvaluationsService { */ - /* - NOTE: Hack to overcome the inability to retrieve the desire - number of evaluation (see note 1 above). Pad the - requested number of records by an estimated number of - group members (20 per group). - */ - totalGroupMembers = 20; + async count(): Promise { + return this.evaluationModel.count(); + } + + async create(evaluation: { + data: unknown; + evaluationTags: CreateEvaluationTagDto[] | undefined; + filename: string; + groupId?: string; + public: boolean; + userId?: string; + }): Promise { + return Evaluation.create( + { ...evaluation }, + { include: [EvaluationTag] }, + ); + } + + async evaluationCount(userEmail: string, role: string): Promise { + return role === 'admin' + ? this.evaluationModel.count() + : this.evaluationModel.count({ + col: 'id', + distinct: true, + include: [User, { include: [User], model: Group }], + where: { + [Op.or]: [ + { public: { [Op.eq]: 'true' } }, + { '$user.email$': { [Op.like]: `${userEmail}` } }, + { + [Op.and]: { + '$groups->users.id$': { + [Op.eq]: Sequelize.literal( + `(SELECT id FROM "Users" WHERE "email" LIKE '${userEmail}')`, + ), + }, + }, + }, + ], + }, + }); + } + + async findAll(): Promise { + return this.evaluationModel.findAll({ + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + }); + } + + async findById(id: string): Promise { + return this.findByPkBang(id, { include: [EvaluationTag, User, Group, { include: [User], model: Group }] }); + } + + async findByPkBang( + identifier: Buffer | number | string | undefined, + options: Pick, + ): Promise { + const evaluation = await this.evaluationModel.findByPk( + identifier, + options, + ); + if (evaluation === null) { + throw new NotFoundException('Evaluation with given id not found'); + } + return evaluation; + } async getAllEvaluations( - params: IEvalPaginationParams, + parameters: IEvalPaginationParams, email: string, - role: string + role: string, ): Promise { const queryResponse: EvaluationsResponse = { + evaluations: [], totalItems: 0, - evaluations: [] }; const whereClause = this.getWhereClauseAll(role, email); await this.evaluationModel .findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}], - offset: params.offset, - limit: Number(params.limit) * this.totalGroupMembers, + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + limit: Number(parameters.limit) * this.totalGroupMembers, + offset: parameters.offset, order: - params.order.length === 2 - ? [[params.order[0], params.order[1]]] - : [[params.order[0], params.order[1], params.order[2]]], + parameters.order.length === 2 + ? [[parameters.order[0], parameters.order[1]]] + : [[parameters.order[0], parameters.order[1], parameters.order[2]]], subQuery: false, // enable where clause to reference attributes from the included models - where: whereClause + where: whereClause, }) .then(async (data) => { const totalItems = await this.evaluationCount(email, role); - const totalPages = Math.ceil(totalItems / params.limit); - const totalReturned = Number(params.offset) + Number(params.limit); + const totalPages = Math.ceil(totalItems / parameters.limit); + const totalReturned = Number(parameters.offset) + Number(parameters.limit); const onPage = Math.ceil( - totalReturned / 100 / (Number(params.limit) / 100) + totalReturned / 100 / (Number(parameters.limit) / 100), ); if (onPage == totalPages) { - const returnCnt = totalItems - Number(params.offset); + const returnCnt = totalItems - Number(parameters.offset); // Return from the back of the array queryResponse.evaluations = data.slice(-returnCnt); } else { - queryResponse.evaluations = data.slice(0, params.limit); + queryResponse.evaluations = data.slice(0, parameters.limit); } queryResponse.totalItems = totalItems; }); return queryResponse; } + async getEvaluationIdsForTagName(tagValue: string): Promise { + let evaluationIds: string[] = []; + await EvaluationTag.findAll({ + attributes: ['evaluationId'], + raw: true, + where: { value: { [Op.iRegexp]: tagValue } }, + }).then(async (evalIds) => { + evaluationIds = evalIds.map(evalIds => evalIds.evaluationId); + }); + return evaluationIds; + } + async getEvaluationsWithClause( - params: IEvalPaginationParams, + parameters: IEvalPaginationParams, email: string, - role: string + role: string, ): Promise { const queryResponse: EvaluationsResponse = { + evaluations: [], totalItems: 0, - evaluations: [] }; - const whereClauseParams: WhereClauseParams = { - searchFields: - params.searchFields === undefined ? [''] : params.searchFields, - operator: params.operator === undefined ? 'OR' : params.operator, + const whereClauseParameters: WhereClauseParameters = { + action: 'search', email: email, + operator: parameters.operator === undefined ? 'OR' : parameters.operator, role: role, - action: 'search' + searchFields: + parameters.searchFields === undefined ? [''] : parameters.searchFields, }; const whereClause = await this.getWhereClauseSearch( - whereClauseParams.searchFields, - whereClauseParams.operator, - whereClauseParams.email, - whereClauseParams.role, - whereClauseParams.action + whereClauseParameters.searchFields, + whereClauseParameters.operator, + whereClauseParameters.email, + whereClauseParameters.role, + whereClauseParameters.action, ); await this.evaluationModel .findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}], - offset: params.offset, - limit: Number(params.limit) * this.totalGroupMembers, + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + limit: Number(parameters.limit) * this.totalGroupMembers, + offset: parameters.offset, order: - params.order.length === 2 - ? [[params.order[0], params.order[1]]] - : [[params.order[0], params.order[1], params.order[2]]], + parameters.order.length === 2 + ? [[parameters.order[0], parameters.order[1]]] + : [[parameters.order[0], parameters.order[1], parameters.order[2]]], subQuery: false, - where: whereClause + where: whereClause, }) .then(async (data) => { - const totalItems = await this.searchItemsCount(whereClauseParams); + const totalItems = await this.searchItemsCount(whereClauseParameters); - const totalPages = Math.ceil(totalItems / params.limit); - const totalReturned = Number(params.offset) + Number(params.limit); + const totalPages = Math.ceil(totalItems / parameters.limit); + const totalReturned = Number(parameters.offset) + Number(parameters.limit); const onPage = Math.ceil( - totalReturned / 100 / (Number(params.limit) / 100) + totalReturned / 100 / (Number(parameters.limit) / 100), ); if (onPage === totalPages) { - const returnCnt = totalItems - Number(params.offset); + const returnCnt = totalItems - Number(parameters.offset); // Return from the back of the array queryResponse.evaluations = data.slice(-returnCnt); } else { - queryResponse.evaluations = data.slice(0, params.limit); + queryResponse.evaluations = data.slice(0, parameters.limit); } queryResponse.totalItems = totalItems; }); @@ -202,24 +275,28 @@ export class EvaluationsService { getWhereClauseAll(role: string, email: string): WhereOptions { const whereClause = this.getWhereClauseBaseCriteria(role, email); - return {[Op.or]: whereClause}; + return { [Op.or]: whereClause }; } - getWhereClauseBaseCriteria(role: string, email: string): WhereOptions { - const baseCriteria = []; - baseCriteria.push({public: {[Op.eq]: 'true'}}); + getWhereClauseBaseCriteria(role: string, email: string): WhereOptions[] { + // Explicitly typed: the criteria are heterogeneous (a `public` match, an + // `$user.email$` match, an [Op.and] group), so inference from the first + // element would lock the array to that one shape and reject the rest. + // Both callers consume the result as the operand of [Op.or]/[Op.and], + // so the array — not a single WhereOptions — is the honest return type. + const baseCriteria: WhereOptions[] = [{ public: { [Op.eq]: 'true' } }]; if (role === 'admin') { - baseCriteria.push({public: {[Op.eq]: 'false'}}); + baseCriteria.push({ public: { [Op.eq]: 'false' } }); } else { - baseCriteria.push({'$user.email$': {[Op.like]: `${email}`}}); + baseCriteria.push({ '$user.email$': { [Op.like]: `${email}` } }); baseCriteria.push({ [Op.and]: { '$groups->users.id$': { [Op.eq]: Sequelize.literal( - `(SELECT id FROM "Users" WHERE "email" LIKE '${email}')` - ) - } - } + `(SELECT id FROM "Users" WHERE "email" LIKE '${email}')`, + ), + }, + }, }); } return baseCriteria; @@ -230,175 +307,82 @@ export class EvaluationsService { operation: string, email: string, role: string, - action: string + action: string, ): Promise { const searchFields = []; const baseCriteria = this.getWhereClauseBaseCriteria(role, email); if (fields[0] !== '()') { - searchFields.push({filename: {[Op.iRegexp]: `${fields[0]}`}}); + searchFields.push({ filename: { [Op.iRegexp]: `${fields[0]}` } }); } if (fields[1] !== '()') { - searchFields.push({'$groups.name$': {[Op.iRegexp]: `${fields[1]}`}}); + searchFields.push({ '$groups.name$': { [Op.iRegexp]: `${fields[1]}` } }); } if (fields[2] !== '()') { if (action === 'count') { - searchFields.push({ - '$evaluationTags.value$': {[Op.iRegexp]: `${fields[2]}`} - }); + searchFields.push({ '$evaluationTags.value$': { [Op.iRegexp]: `${fields[2]}` } }); } else { const evaluationIds = await this.getEvaluationIdsForTagName(fields[2]); searchFields.push({ [Op.or]: [ - {id: {[Op.in]: evaluationIds}}, - {'$evaluationTags.value$': {[Op.iRegexp]: `${fields[2]}`}} - ] + { id: { [Op.in]: evaluationIds } }, + { '$evaluationTags.value$': { [Op.iRegexp]: `${fields[2]}` } }, + ], }); } } if (operation === 'AND') { // Expected outcome: an OR baseCriteria AND an AND searchFields - return {[Op.or]: baseCriteria, [Op.and]: searchFields}; - } else { - // Expected outcome: an OR baseCriteria AND an OR searchFields - return { - [Op.and]: [{[Op.or]: baseCriteria}, {[Op.and]: {[Op.or]: searchFields}}] - }; + return { [Op.and]: searchFields, [Op.or]: baseCriteria }; } + // Expected outcome: an OR baseCriteria AND an OR searchFields + return { [Op.and]: [{ [Op.or]: baseCriteria }, { [Op.and]: { [Op.or]: searchFields } }] }; } - async getEvaluationIdsForTagName(tagValue: string): Promise { - let evaluationIds: string[] = []; - await EvaluationTag.findAll({ - attributes: ['evaluationId'], - where: {value: {[Op.iRegexp]: tagValue}}, - raw: true - }).then(async (evalIds) => { - evaluationIds = evalIds.map((evalIds) => evalIds.evaluationId); - }); - return evaluationIds; + async groups(id: string): Promise { + return ( + await this.findByPkBang(id, { include: { include: [User], model: Group } }) + ).groups; } - async evaluationCount(userEmail: string, role: string): Promise { - if (role === 'admin') { - return this.evaluationModel.count(); - } else { - return this.evaluationModel.count({ - include: [User, {model: Group, include: [User]}], - where: { - [Op.or]: [ - {public: {[Op.eq]: 'true'}}, - {'$user.email$': {[Op.like]: `${userEmail}`}}, - { - [Op.and]: { - '$groups->users.id$': { - [Op.eq]: Sequelize.literal( - `(SELECT id FROM "Users" WHERE "email" LIKE '${userEmail}')` - ) - } - } - } - ] - }, - distinct: true, - col: 'id' - }); - } + async remove(id: string): Promise { + const evaluation = await this.findByPkBang(id, { include: [EvaluationTag] }); + await this.databaseService.sequelize.transaction(async (transaction) => { + if (evaluation.evaluationTags !== null) { + await evaluation.evaluationTags.map(async (evaluationTag) => { + await evaluationTag.destroy({ transaction }); + }); + } + return evaluation.destroy({ transaction }); + }); + return evaluation; } async searchItemsCount( - whereClauseParams: WhereClauseParams + whereClauseParameters: WhereClauseParameters, ): Promise { const whereClause = await this.getWhereClauseSearch( - whereClauseParams.searchFields, - whereClauseParams.operator, - whereClauseParams.email, - whereClauseParams.role, - 'count' + whereClauseParameters.searchFields, + whereClauseParameters.operator, + whereClauseParameters.email, + whereClauseParameters.role, + 'count', ); return this.evaluationModel.count({ - include: [EvaluationTag, User, {model: Group, include: [User]}], - where: whereClause, + col: 'id', distinct: true, - col: 'id' + include: [EvaluationTag, User, { include: [User], model: Group }], + where: whereClause, }); } - async count(): Promise { - return this.evaluationModel.count(); - } - - async create(evaluation: { - filename: string; - evaluationTags: CreateEvaluationTagDto[] | undefined; - public: boolean; - data: unknown; - userId?: string; - groupId?: string; - }): Promise { - return Evaluation.create( - { - ...evaluation - }, - { - include: [EvaluationTag] - } - ); - } - async update( id: string, - updateEvaluationDto: UpdateEvaluationDto + updateEvaluationDto: UpdateEvaluationDto, ): Promise { - const evaluation = await this.findByPkBang(id, { - include: [EvaluationTag] - }); + const evaluation = await this.findByPkBang(id, { include: [EvaluationTag] }); return evaluation.update(updateEvaluationDto); } - - async remove(id: string): Promise { - const evaluation = await this.findByPkBang(id, { - include: [EvaluationTag] - }); - await this.databaseService.sequelize.transaction(async (transaction) => { - if (evaluation.evaluationTags !== null) { - await Promise.all([ - evaluation.evaluationTags.map(async (evaluationTag) => { - await evaluationTag.destroy({transaction}); - }) - ]); - } - return evaluation.destroy({transaction}); - }); - return evaluation; - } - - async findById(id: string): Promise { - return this.findByPkBang(id, { - include: [EvaluationTag, User, Group, {model: Group, include: [User]}] - }); - } - - async groups(id: string): Promise { - return ( - await this.findByPkBang(id, {include: {model: Group, include: [User]}}) - ).groups; - } - - async findByPkBang( - identifier: string | number | Buffer | undefined, - options: Pick - ): Promise { - const evaluation = await this.evaluationModel.findByPk( - identifier, - options - ); - if (evaluation === null) { - throw new NotFoundException('Evaluation with given id not found'); - } else { - return evaluation; - } - } } diff --git a/apps/backend/src/filters/authentication-exception.filter.ts b/apps/backend/src/filters/authentication-exception.filter.ts index deaff398cf..2ca050caa6 100644 --- a/apps/backend/src/filters/authentication-exception.filter.ts +++ b/apps/backend/src/filters/authentication-exception.filter.ts @@ -1,46 +1,42 @@ -import {ArgumentsHost, Catch, ExceptionFilter} from '@nestjs/common'; +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; import _ from 'lodash'; import winston from 'winston'; -import {ConfigService} from '../config/config.service'; +import { ConfigService } from '../config/config.service'; @Catch(Error) export class AuthenticationExceptionFilter implements ExceptionFilter { - configService = new ConfigService(); - private readonly line = '_______________________________________________\n'; + + configService = new ConfigService(); public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), + winston.format.timestamp({ format: this.loggingTimeFormat }), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authentication Exception Filter): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (Authentication Exception Filter): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); catch(exception: Error, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const request = ctx.getRequest(); - const response = ctx.getResponse(); - const errInfo = { - message: exception.message, - stack: exception.stack, + const context_ = host.switchToHttp(); + const request = context_.getRequest(); + const response = context_.getResponse(); + const errorInfo = { authInfo: _.get(request, 'authInfo'), + headers: request.headers, + message: exception.message, query: request.query, - headers: request.headers + stack: exception.stack, }; this.logger.warn( - `Authentication Error\n${JSON.stringify(errInfo, null, 2)}` + `Authentication Error\n${JSON.stringify(errorInfo, null, 2)}`, ); - const authError = - `${_.has(request, 'authInfo.message') ? _.get(request, 'authInfo.message') : ''}\n${exception.message}`.trim(); - response.cookie('authenticationError', authError, { - secure: this.configService.isInProductionMode() - }); + const authError + = `${_.has(request, 'authInfo.message') ? _.get(request, 'authInfo.message') : ''}\n${exception.message}`.trim(); + response.cookie('authenticationError', authError, { secure: this.configService.isInProductionMode() }); response.redirect(302, '/'); } } diff --git a/apps/backend/src/filters/unique-constraint-error.filter.ts b/apps/backend/src/filters/unique-constraint-error.filter.ts index 5d22b4c05f..376eddc52a 100644 --- a/apps/backend/src/filters/unique-constraint-error.filter.ts +++ b/apps/backend/src/filters/unique-constraint-error.filter.ts @@ -2,31 +2,28 @@ import { ArgumentsHost, Catch, ExceptionFilter, - HttpStatus + HttpStatus, } from '@nestjs/common'; -import {Response} from 'express'; -import {UniqueConstraintError, ValidationErrorItem} from 'sequelize'; +import { Response } from 'express'; +import { UniqueConstraintError, ValidationErrorItem } from 'sequelize'; @Catch(UniqueConstraintError) export class UniqueConstraintErrorFilter implements ExceptionFilter { + buildMessage(errors: ValidationErrorItem[]): string[] { + const builtErrors: string[] = Array.from(errors, error => error.message); + return builtErrors; + } + catch(exception: UniqueConstraintError, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); + const context_ = host.switchToHttp(); + const response = context_.getResponse(); const status = HttpStatus.INTERNAL_SERVER_ERROR; const message = this.buildMessage(exception.errors); response.status(status).json({ - statusCode: status, error: 'Internal Server Error', - message: message - }); - } - - buildMessage(errors: ValidationErrorItem[]): string[] { - const builtErrors: string[] = []; - errors.forEach((error) => { - builtErrors.push(error.message); + message: message, + statusCode: status, }); - return builtErrors; } } diff --git a/apps/backend/src/group-evaluations/group-evaluation.model.ts b/apps/backend/src/group-evaluations/group-evaluation.model.ts index 13a0ea695c..7deb6d7267 100644 --- a/apps/backend/src/group-evaluations/group-evaluation.model.ts +++ b/apps/backend/src/group-evaluations/group-evaluation.model.ts @@ -8,31 +8,31 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {Group} from '../groups/group.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { Group } from '../groups/group.model'; @Table export class GroupEvaluation extends Model { - @PrimaryKey - @AutoIncrement + @CreatedAt @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @ForeignKey(() => Evaluation) @Column(DataType.BIGINT) - declare id: string; + declare evaluationId: string; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @ForeignKey(() => Evaluation) - @Column(DataType.BIGINT) - declare evaluationId: string; - - @CreatedAt + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; + @Column(DataType.BIGINT) + declare id: string; @UpdatedAt @AllowNull(false) diff --git a/apps/backend/src/group-evaluations/group-evaluations.module.ts b/apps/backend/src/group-evaluations/group-evaluations.module.ts index 87c9f4d339..4c1de1663e 100644 --- a/apps/backend/src/group-evaluations/group-evaluations.module.ts +++ b/apps/backend/src/group-evaluations/group-evaluations.module.ts @@ -1,8 +1,6 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {GroupEvaluation} from './group-evaluation.model'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { GroupEvaluation } from './group-evaluation.model'; -@Module({ - imports: [SequelizeModule.forFeature([GroupEvaluation])] -}) +@Module({ imports: [SequelizeModule.forFeature([GroupEvaluation])] }) export class GroupEvaluationsModule {} diff --git a/apps/backend/src/group-users/group-user.model.ts b/apps/backend/src/group-users/group-user.model.ts index b40ad84552..d9e55f599e 100644 --- a/apps/backend/src/group-users/group-user.model.ts +++ b/apps/backend/src/group-users/group-user.model.ts @@ -9,13 +9,22 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class GroupUser extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @ForeignKey(() => Group) + @Column(DataType.BIGINT) + declare groupId: string; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -27,21 +36,12 @@ export class GroupUser extends Model { @Column(DataType.STRING) declare role: string; - @ForeignKey(() => Group) - @Column(DataType.BIGINT) - declare groupId: string; - - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; + + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/group-users/group-users.module.ts b/apps/backend/src/group-users/group-users.module.ts index 7ac18b3314..f102ce30c2 100644 --- a/apps/backend/src/group-users/group-users.module.ts +++ b/apps/backend/src/group-users/group-users.module.ts @@ -1,8 +1,6 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {GroupUser} from './group-user.model'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { GroupUser } from './group-user.model'; -@Module({ - imports: [SequelizeModule.forFeature([GroupUser])] -}) +@Module({ imports: [SequelizeModule.forFeature([GroupUser])] }) export class GroupUsersModule {} diff --git a/apps/backend/src/groups/dto/add-user-to-group.dto.ts b/apps/backend/src/groups/dto/add-user-to-group.dto.ts index faed37c9e1..8e763721f2 100644 --- a/apps/backend/src/groups/dto/add-user-to-group.dto.ts +++ b/apps/backend/src/groups/dto/add-user-to-group.dto.ts @@ -1,12 +1,12 @@ -import {IAddUserToGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IAddUserToGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class AddUserToGroupDto implements IAddUserToGroup { @IsNotEmpty() @IsString() - readonly userId!: string; + readonly groupRole!: string; @IsNotEmpty() @IsString() - readonly groupRole!: string; + readonly userId!: string; } diff --git a/apps/backend/src/groups/dto/create-group.dto.ts b/apps/backend/src/groups/dto/create-group.dto.ts index 849913c2ad..b31c11abc3 100644 --- a/apps/backend/src/groups/dto/create-group.dto.ts +++ b/apps/backend/src/groups/dto/create-group.dto.ts @@ -1,7 +1,11 @@ -import {ICreateGroup} from '@heimdall/common/interfaces'; -import {IsBoolean, IsNotEmpty, IsOptional, IsString} from 'class-validator'; +import { ICreateGroup } from '@heimdall/common/interfaces'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateGroupDto implements ICreateGroup { + @IsOptional() + @IsString() + readonly desc!: string; + @IsNotEmpty() @IsString() readonly name!: string; @@ -9,8 +13,4 @@ export class CreateGroupDto implements ICreateGroup { @IsOptional() @IsBoolean() readonly public!: boolean; - - @IsOptional() - @IsString() - readonly desc!: string; } diff --git a/apps/backend/src/groups/dto/evaluation-group.dto.ts b/apps/backend/src/groups/dto/evaluation-group.dto.ts index 7e8eda23cd..573f9d433f 100644 --- a/apps/backend/src/groups/dto/evaluation-group.dto.ts +++ b/apps/backend/src/groups/dto/evaluation-group.dto.ts @@ -1,5 +1,5 @@ -import {IEvaluationGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IEvaluationGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class EvaluationGroupDto implements IEvaluationGroup { @IsNotEmpty() diff --git a/apps/backend/src/groups/dto/group.dto.ts b/apps/backend/src/groups/dto/group.dto.ts index cec4662bca..f129498f1d 100644 --- a/apps/backend/src/groups/dto/group.dto.ts +++ b/apps/backend/src/groups/dto/group.dto.ts @@ -1,29 +1,29 @@ -import {IGroup} from '@heimdall/common/interfaces'; -import {GroupUser} from '../../group-users/group-user.model'; -import {SlimUserDto} from '../../users/dto/slim-user.dto'; -import {Group} from '../group.model'; +import type { IGroup } from '@heimdall/common/interfaces'; +import type { GroupUser } from '../../group-users/group-user.model'; +import { SlimUserDto } from '../../users/dto/slim-user.dto'; +import type { Group } from '../group.model'; export class GroupDto implements IGroup { + readonly createdAt: Date; + readonly desc: string; readonly id: string; readonly name: string; readonly public: boolean; readonly role?: string; - readonly users: SlimUserDto[]; - readonly desc: string; - readonly createdAt: Date; readonly updatedAt: Date; + readonly users: SlimUserDto[]; - constructor(group: Group & {GroupUser?: GroupUser}, role?: string) { + constructor(group: Group & { GroupUser?: GroupUser }, role?: string) { this.id = group.id; this.name = group.name; this.role = role || group?.GroupUser?.role; this.public = group.public; - this.users = - group.users === undefined + this.users + = group.users === undefined ? [] : group.users.map((user) => { - return new SlimUserDto(user, user.GroupUser.role); - }); + return new SlimUserDto(user, user.GroupUser.role); + }); this.desc = group.desc; this.createdAt = group.createdAt; this.updatedAt = group.updatedAt; diff --git a/apps/backend/src/groups/dto/remove-user-from-group.dto.ts b/apps/backend/src/groups/dto/remove-user-from-group.dto.ts index 3894399cf3..6a2c833354 100644 --- a/apps/backend/src/groups/dto/remove-user-from-group.dto.ts +++ b/apps/backend/src/groups/dto/remove-user-from-group.dto.ts @@ -1,5 +1,5 @@ -import {IRemoveUserFromGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IRemoveUserFromGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class RemoveUserFromGroupDto implements IRemoveUserFromGroup { @IsNotEmpty() diff --git a/apps/backend/src/groups/dto/update-group-user.dto.ts b/apps/backend/src/groups/dto/update-group-user.dto.ts index 666cc58de9..79c317f2bd 100644 --- a/apps/backend/src/groups/dto/update-group-user.dto.ts +++ b/apps/backend/src/groups/dto/update-group-user.dto.ts @@ -1,12 +1,12 @@ -import {IUpdateGroupUser} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IUpdateGroupUser } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class UpdateGroupUserRoleDto implements IUpdateGroupUser { @IsNotEmpty() @IsString() - readonly userId!: string; + readonly groupRole!: string; @IsNotEmpty() @IsString() - readonly groupRole!: string; + readonly userId!: string; } diff --git a/apps/backend/src/groups/group.model.ts b/apps/backend/src/groups/group.model.ts index 3e17603943..a874b779fb 100644 --- a/apps/backend/src/groups/group.model.ts +++ b/apps/backend/src/groups/group.model.ts @@ -10,15 +10,28 @@ import { PrimaryKey, Table, Unique, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {User} from '../users/user.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { User } from '../users/user.model'; @Table export class Group extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @AllowNull(false) + @Default('') + @Column(DataType.TEXT) + declare desc: string; + + @BelongsToMany(() => Evaluation, () => GroupEvaluation) + declare evaluations: (Evaluation & { GroupEvaluation: GroupEvaluation })[]; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -35,24 +48,11 @@ export class Group extends Model { @Column(DataType.BOOLEAN) declare public: boolean; - @AllowNull(false) - @Default('') - @Column(DataType.TEXT) - declare desc: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; @BelongsToMany(() => User, () => GroupUser) - declare users: Array; - - @BelongsToMany(() => Evaluation, () => GroupEvaluation) - declare evaluations: Array; + declare users: (User & { GroupUser: GroupUser })[]; } diff --git a/apps/backend/src/groups/groups.controller.spec.ts b/apps/backend/src/groups/groups.controller.spec.ts index e681745f4b..aa5ef84ac9 100644 --- a/apps/backend/src/groups/groups.controller.spec.ts +++ b/apps/backend/src/groups/groups.controller.spec.ts @@ -1,33 +1,34 @@ -import {ForbiddenError} from '@casl/ability'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; +import { ForbiddenError } from '@casl/ability'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; import { GROUP_1, PRIVATE_GROUP, - UPDATE_GROUP + UPDATE_GROUP, } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {SlimUserDto} from '../users/dto/slim-user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Group} from './group.model'; -import {GroupsController} from './groups.controller'; -import {GroupsService} from './groups.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { SlimUserDto } from '../users/dto/slim-user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Group } from './group.model'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; describe('GroupsController', () => { let groupsController: GroupsController; @@ -52,16 +53,16 @@ describe('GroupsController', () => { GroupEvaluation, Evaluation, EvaluationTag, - User - ]) + User, + ]), ], providers: [ AuthzService, DatabaseService, GroupsService, UsersService, - EvaluationsService - ] + EvaluationsService, + ], }).compile(); groupsService = module.get(GroupsService); @@ -85,8 +86,8 @@ describe('GroupsController', () => { expect.assertions(3); const response = await groupsController.create( - {user: basicUser}, - PRIVATE_GROUP + { user: basicUser }, + PRIVATE_GROUP, ); const group = await groupsService.findByPkBang(response.id); expect(response.name).toEqual(PRIVATE_GROUP.name); @@ -106,7 +107,7 @@ describe('GroupsController', () => { it('findAll should only return public groups and groups the user is explicitly added to', async () => { expect.assertions(1); - const groups = await groupsController.findAll({user: basicUser}); + const groups = await groupsController.findAll({ user: basicUser }); expect(groups.length).toEqual(1); }); @@ -115,7 +116,7 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); - const groups = await groupsController.findAll({user: basicUser}); + const groups = await groupsController.findAll({ user: basicUser }); expect(groups.length).toEqual(2); }); @@ -123,9 +124,9 @@ describe('GroupsController', () => { expect.assertions(1); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); const publicGroups = (await groupsService.findAll()).filter( - (group) => group.public && group.id !== privateGroup.id + group => group.public && group.id !== privateGroup.id, ); - const groups = await groupsController.findForUser({user: basicUser}); + const groups = await groupsController.findForUser({ user: basicUser }); expect(groups.length).toEqual(1 + publicGroups.length); }); @@ -133,10 +134,10 @@ describe('GroupsController', () => { const otherUser = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await groupsService.addUserToGroup(privateGroup, otherUser, 'member'); - const groups = await groupsController.findForUser({user: basicUser}); + const groups = await groupsController.findForUser({ user: basicUser }); expect(groups[0].users).toContainEqual( - new SlimUserDto(otherUser, 'member') + new SlimUserDto(otherUser, 'member'), ); }); }); @@ -156,9 +157,9 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); const response = await groupsController.update( - {user: owner}, + { user: owner }, privateGroup.id, - UPDATE_GROUP + UPDATE_GROUP, ); expect(response.id).toEqual(privateGroup.id); expect(response.name).toEqual(UPDATE_GROUP.name); @@ -174,20 +175,20 @@ describe('GroupsController', () => { await expect( groupsController.update( - {user: basicUser}, + { user: basicUser }, privateGroup.id, - UPDATE_GROUP - ) + UPDATE_GROUP, + ), ).rejects.toBeInstanceOf(ForbiddenError); await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); await expect( groupsController.update( - {user: basicUser}, + { user: basicUser }, privateGroup.id, - UPDATE_GROUP - ) + UPDATE_GROUP, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -199,8 +200,8 @@ describe('GroupsController', () => { await groupsController.addUserToGroup( privateGroup.id, - {user: owner}, - {userId: basicUser.id, groupRole: 'member'} + { user: owner }, + { groupRole: 'member', userId: basicUser.id }, ); const groupMembers = await privateGroup.$get('users'); @@ -215,9 +216,9 @@ describe('GroupsController', () => { await expect( groupsController.addUserToGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id, groupRole: 'member'} - ) + { user: basicUser }, + { groupRole: 'member', userId: user.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -226,14 +227,14 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} + { user: basicUser }, + { id: evaluation.id }, ); const groupEvaluations = await privateGroup.$get('evaluations'); @@ -245,36 +246,36 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await expect( groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); it('should stop members from adding an evaluation they do not have access to', async () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await expect( groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -292,8 +293,8 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); const response = await groupsController.remove( - {user: owner}, - privateGroup.id + { user: owner }, + privateGroup.id, ); expect(response.id).toEqual(privateGroup.id); expect(response.name).toEqual(privateGroup.name); @@ -305,13 +306,13 @@ describe('GroupsController', () => { expect.assertions(2); await expect( - groupsController.remove({user: basicUser}, privateGroup.id) + groupsController.remove({ user: basicUser }, privateGroup.id), ).rejects.toBeInstanceOf(ForbiddenError); await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); await expect( - groupsController.remove({user: basicUser}, privateGroup.id) + groupsController.remove({ user: basicUser }, privateGroup.id), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -320,15 +321,15 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await groupsService.addEvaluationToGroup(privateGroup, evaluation); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); expect((await privateGroup.$get('evaluations')).length).toEqual(1); await groupsController.removeEvaluationFromGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} + { user: basicUser }, + { id: evaluation.id }, ); expect((await privateGroup.$get('evaluations')).length).toEqual(0); }); @@ -336,21 +337,21 @@ describe('GroupsController', () => { it('should prevent non-members from removing an evaluation', async () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await groupsService.addEvaluationToGroup(privateGroup, evaluation); await expect( groupsController.removeEvaluationFromGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -362,8 +363,8 @@ describe('GroupsController', () => { expect((await privateGroup.$get('users')).length).toEqual(2); await groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} + { user: basicUser }, + { userId: user.id }, ); expect((await privateGroup.$get('users')).length).toEqual(1); }); @@ -376,8 +377,8 @@ describe('GroupsController', () => { expect((await privateGroup.$get('users')).length).toEqual(2); await groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} + { user: basicUser }, + { userId: user.id }, ); expect((await privateGroup.$get('users')).length).toEqual(1); }); @@ -390,9 +391,9 @@ describe('GroupsController', () => { await expect( groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} - ) + { user: basicUser }, + { userId: user.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); diff --git a/apps/backend/src/groups/groups.controller.ts b/apps/backend/src/groups/groups.controller.ts index b070b40ee2..c5c4b35aa9 100644 --- a/apps/backend/src/groups/groups.controller.ts +++ b/apps/backend/src/groups/groups.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -9,23 +9,23 @@ import { Put, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupUser} from '../group-users/group-user.model'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {AddUserToGroupDto} from './dto/add-user-to-group.dto'; -import {CreateGroupDto} from './dto/create-group.dto'; -import {EvaluationGroupDto} from './dto/evaluation-group.dto'; -import {GroupDto} from './dto/group.dto'; -import {RemoveUserFromGroupDto} from './dto/remove-user-from-group.dto'; -import {UpdateGroupUserRoleDto} from './dto/update-group-user.dto'; -import {GroupsService} from './groups.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupUser } from '../group-users/group-user.model'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AddUserToGroupDto } from './dto/add-user-to-group.dto'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { EvaluationGroupDto } from './dto/evaluation-group.dto'; +import { GroupDto } from './dto/group.dto'; +import { RemoveUserFromGroupDto } from './dto/remove-user-from-group.dto'; +import { UpdateGroupUserRoleDto } from './dto/update-group-user.dto'; +import { GroupsService } from './groups.service'; @Controller('groups') @UseGuards(JwtAuthGuard) @@ -35,151 +35,161 @@ export class GroupsController { private readonly groupsService: GroupsService, private readonly usersService: UsersService, private readonly evaluationsService: EvaluationsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get() - async findAll(@Request() request: {user: User}): Promise { + @Post('/:id/evaluation') + async addEvaluationToGroup( + @Param('id') id: string, + @Request() request: { user: User }, + @Body() evaluationGroupDto: EvaluationGroupDto, + ): Promise { const abac = this.authz.abac.createForUser(request.user); - - let groups = await this.groupsService.findAll(); - groups = groups.filter((group) => abac.can(Action.Read, group)); - - return groups.map((group) => new GroupDto(group)); - } - - @Get('/my') - async findForUser(@Request() request: {user: User}): Promise { - const groups = await request.user.$get('groups', {include: [User]}); - const groupIds = groups.map((g) => g.id); - const publicGroups = (await this.groupsService.findAll()).filter( - (group) => group.public && !groupIds.includes(group.id) + const group = await this.groupsService.findByPkBang(id); + // Group Permissions + ForbiddenError.from(abac).throwUnlessCan(Action.AddEvaluation, group); + const evaluationToAdd = await this.evaluationsService.findById( + evaluationGroupDto.id, ); - return groups - .map((group) => new GroupDto(group)) - .concat(publicGroups.map((group) => new GroupDto(group))); - } - - @Post() - async create( - @Request() request: {user: User}, - @Body() createGroupDto: CreateGroupDto - ): Promise { - const group = await this.groupsService.create(createGroupDto); - await this.groupsService.addUserToGroup(group, request.user, 'owner'); - return new GroupDto(group, 'owner'); + // Evaluation Permissions + ForbiddenError.from(abac).throwUnlessCan(Action.Read, evaluationToAdd); + await this.groupsService.addEvaluationToGroup(group, evaluationToAdd); + return new GroupDto(group); } @Post('/:id/user') async addUserToGroup( @Param('id') id: string, - @Request() request: {user: User}, - @Body() addUserToGroupDto: AddUserToGroupDto + @Request() request: { user: User }, + @Body() addUserToGroupDto: AddUserToGroupDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); const userToAdd = await this.usersService.findById( - addUserToGroupDto.userId + addUserToGroupDto.userId, ); await this.groupsService.addUserToGroup( group, userToAdd, - addUserToGroupDto.groupRole + addUserToGroupDto.groupRole, ); return new GroupDto(group); } - @Delete('/:id/user') - async removeUserFromGroup( + @Post() + async create( + @Request() request: { user: User }, + @Body() createGroupDto: CreateGroupDto, + ): Promise { + const group = await this.groupsService.create(createGroupDto); + await this.groupsService.addUserToGroup(group, request.user, 'owner'); + return new GroupDto(group, 'owner'); + } + + @Get() + async findAll(@Request() request: { user: User }): Promise { + const abac = this.authz.abac.createForUser(request.user); + + let groups = await this.groupsService.findAll(); + groups = groups.filter(group => abac.can(Action.Read, group)); + + return groups.map(group => new GroupDto(group)); + } + + @Get(':id') + async findById( + @Request() request: { user: User }, @Param('id') id: string, - @Request() request: {user: User}, - @Body() removeUserFromGroupDto: RemoveUserFromGroupDto ): Promise { + const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); - if (request.user.role !== 'admin') { - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); - } - const userToRemove = await this.usersService.findById( - removeUserFromGroupDto.userId - ); - return new GroupDto( - await this.groupsService.removeUserFromGroup(group, userToRemove) + ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); + + return new GroupDto(group, 'owner'); + } + + @Get('/my') + async findForUser(@Request() request: { user: User }): Promise { + const groups = await request.user.$get('groups', { include: [User] }); + const groupIds = new Set(groups.map(g => g.id)); + const publicGroups = (await this.groupsService.findAll()).filter( + group => group.public && !groupIds.has(group.id), ); + return [...groups + .map(group => new GroupDto(group)), ...publicGroups.map(group => new GroupDto(group))]; } - @Post('/:id/evaluation') - async addEvaluationToGroup( + @Delete(':id') + async remove( + @Request() request: { user: User }, @Param('id') id: string, - @Request() request: {user: User}, - @Body() evaluationGroupDto: EvaluationGroupDto ): Promise { const abac = this.authz.abac.createForUser(request.user); - const group = await this.groupsService.findByPkBang(id); - // Group Permissions - ForbiddenError.from(abac).throwUnlessCan(Action.AddEvaluation, group); - const evaluationToAdd = await this.evaluationsService.findById( - evaluationGroupDto.id - ); - // Evaluation Permissions - ForbiddenError.from(abac).throwUnlessCan(Action.Read, evaluationToAdd); - await this.groupsService.addEvaluationToGroup(group, evaluationToAdd); - return new GroupDto(group); + const groupToDelete = await this.groupsService.findByPkBang(id); + ForbiddenError.from(abac).throwUnlessCan(Action.Delete, groupToDelete); + return new GroupDto(await this.groupsService.remove(groupToDelete)); } @Delete('/:id/evaluation') async removeEvaluationFromGroup( @Param('id') id: string, - @Request() request: {user: User}, - @Body() evaluationGroupDto: EvaluationGroupDto + @Request() request: { user: User }, + @Body() evaluationGroupDto: EvaluationGroupDto, ): Promise { // This must perform validation checks to ensure the user performing the action has permission to remove evaluations from a group. const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.RemoveEvaluation, group); const evaluationToRemove = await this.evaluationsService.findById( - evaluationGroupDto.id + evaluationGroupDto.id, ); return new GroupDto( await this.groupsService.removeEvaluationFromGroup( group, - evaluationToRemove - ) + evaluationToRemove, + ), ); } - @Get(':id') - async findById( - @Request() request: {user: User}, - @Param('id') id: string + @Delete('/:id/user') + async removeUserFromGroup( + @Param('id') id: string, + @Request() request: { user: User }, + @Body() removeUserFromGroupDto: RemoveUserFromGroupDto, ): Promise { - const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); - - return new GroupDto(group, 'owner'); + if (request.user.role !== 'admin') { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); + } + const userToRemove = await this.usersService.findById( + removeUserFromGroupDto.userId, + ); + return new GroupDto( + await this.groupsService.removeUserFromGroup(group, userToRemove), + ); } @Put(':id') async update( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateGroup: CreateGroupDto + @Body() updateGroup: CreateGroupDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const groupToUpdate = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Update, groupToUpdate); return new GroupDto( - await this.groupsService.update(groupToUpdate, updateGroup) + await this.groupsService.update(groupToUpdate, updateGroup), ); } @Put(':id/updateGroupUserRole') async updateGroupUserRole( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateGroupUser: UpdateGroupUserRoleDto + @Body() updateGroupUser: UpdateGroupUserRoleDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); @@ -187,15 +197,4 @@ export class GroupsController { return this.groupsService.updateGroupUserRole(group, updateGroupUser); } - - @Delete(':id') - async remove( - @Request() request: {user: User}, - @Param('id') id: string - ): Promise { - const abac = this.authz.abac.createForUser(request.user); - const groupToDelete = await this.groupsService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Delete, groupToDelete); - return new GroupDto(await this.groupsService.remove(groupToDelete)); - } } diff --git a/apps/backend/src/groups/groups.module.ts b/apps/backend/src/groups/groups.module.ts index 18f3269a7c..8e77dc1a42 100644 --- a/apps/backend/src/groups/groups.module.ts +++ b/apps/backend/src/groups/groups.module.ts @@ -1,16 +1,18 @@ -import {forwardRef, Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ApiKeyModule} from '../apikeys/apikeys.module'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; -import {EvaluationTagsModule} from '../evaluation-tags/evaluation-tags.module'; -import {EvaluationsModule} from '../evaluations/evaluations.module'; -import {UsersModule} from '../users/users.module'; -import {Group} from './group.model'; -import {GroupsController} from './groups.controller'; -import {GroupsService} from './groups.service'; +import { forwardRef, Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ApiKeyModule } from '../apikeys/apikeys.module'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; +import { EvaluationTagsModule } from '../evaluation-tags/evaluation-tags.module'; +import { EvaluationsModule } from '../evaluations/evaluations.module'; +import { UsersModule } from '../users/users.module'; +import { Group } from './group.model'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; @Module({ + controllers: [GroupsController], + exports: [GroupsService], imports: [ SequelizeModule.forFeature([Group]), ApiKeyModule, @@ -18,10 +20,8 @@ import {GroupsService} from './groups.service'; ConfigModule, forwardRef(() => UsersModule), EvaluationsModule, - EvaluationTagsModule + EvaluationTagsModule, ], providers: [GroupsService], - controllers: [GroupsController], - exports: [GroupsService] }) export class GroupsModule {} diff --git a/apps/backend/src/groups/groups.service.spec.ts b/apps/backend/src/groups/groups.service.spec.ts index ed6661d896..e9d56772ad 100644 --- a/apps/backend/src/groups/groups.service.spec.ts +++ b/apps/backend/src/groups/groups.service.spec.ts @@ -1,32 +1,32 @@ -import {ForbiddenException, NotFoundException} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { EVALUATION_1, - EVALUATION_WITH_TAGS_1 + EVALUATION_WITH_TAGS_1, } from '../../test/constants/evaluations-test.constant'; -import {GROUP_1} from '../../test/constants/groups-test.constant'; +import { GROUP_1 } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {ConfigService} from '../config/config.service'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTagDto} from '../evaluation-tags/dto/evaluation-tag.dto'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluationsModule} from '../group-evaluations/group-evaluations.module'; -import {GroupUser} from '../group-users/group-user.model'; -import {GroupUsersModule} from '../group-users/group-users.module'; -import {UserDto} from '../users/dto/user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Group} from './group.model'; -import {GroupsService} from './groups.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTagDto } from '../evaluation-tags/dto/evaluation-tag.dto'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluationsModule } from '../group-evaluations/group-evaluations.module'; +import { GroupUser } from '../group-users/group-user.model'; +import { GroupUsersModule } from '../group-users/group-users.module'; +import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Group } from './group.model'; +import { GroupsService } from './groups.service'; describe('GroupsService', () => { let groupsService: GroupsService; @@ -44,18 +44,18 @@ describe('GroupsService', () => { GroupUser, Evaluation, EvaluationTag, - User + User, ]), GroupEvaluationsModule, - GroupUsersModule + GroupUsersModule, ], providers: [ ConfigService, GroupsService, DatabaseService, UsersService, - EvaluationsService - ] + EvaluationsService, + ], }).compile(); groupsService = module.get(GroupsService); @@ -84,7 +84,7 @@ describe('GroupsService', () => { it('should throw a not found exception when the given id is not found', async () => { expect.assertions(1); await expect(groupsService.findByPkBang('0')).rejects.toBeInstanceOf( - NotFoundException + NotFoundException, ); }); @@ -92,7 +92,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const user = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, user, 'owner'); const foundGroup = await groupsService.findByPkBang(group.id); @@ -105,7 +105,7 @@ describe('GroupsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluation); const foundGroup = await groupsService.findByPkBang(group.id); @@ -118,7 +118,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const user = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, user, 'owner'); const groupUsers = await group.$get('users'); @@ -134,7 +134,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const groupOwner = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); const groupMember = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(group, groupOwner, 'owner'); @@ -152,11 +152,11 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const groupOwner = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, groupOwner, 'owner'); await expect( - groupsService.removeUserFromGroup(group, groupOwner) + groupsService.removeUserFromGroup(group, groupOwner), ).rejects.toBeInstanceOf(ForbiddenException); }); }); @@ -168,17 +168,15 @@ describe('GroupsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluation); - const groupEvaluations = await group.$get('evaluations', { - include: [{model: EvaluationTag}] - }); + const groupEvaluations = await group.$get('evaluations', { include: [{ model: EvaluationTag }] }); expect(groupEvaluations).toHaveLength(1); expect(groupEvaluations[0].filename).toEqual(evaluation.filename); expect(groupEvaluations[0].data).toEqual(evaluation.data); expect( - new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]) + new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]), ).toEqual(new EvaluationTagDto(evaluation.evaluationTags[0])); }); }); @@ -190,25 +188,23 @@ describe('GroupsService', () => { const evaluationOne = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTwo = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluationOne); await groupsService.addEvaluationToGroup(group, evaluationTwo); expect(await group.$get('evaluations')).toHaveLength(2); await groupsService.removeEvaluationFromGroup(group, evaluationOne); - const groupEvaluations = await group.$get('evaluations', { - include: [{model: EvaluationTag}] - }); + const groupEvaluations = await group.$get('evaluations', { include: [{ model: EvaluationTag }] }); expect(groupEvaluations).toHaveLength(1); expect(groupEvaluations[0].filename).toEqual(evaluationTwo.filename); expect(groupEvaluations[0].data).toEqual(evaluationTwo.data); expect( - new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]) + new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]), ).toEqual(new EvaluationTagDto(evaluationTwo.evaluationTags[0])); }); }); diff --git a/apps/backend/src/groups/groups.service.ts b/apps/backend/src/groups/groups.service.ts index cf314de079..8a7327fa3f 100644 --- a/apps/backend/src/groups/groups.service.ts +++ b/apps/backend/src/groups/groups.service.ts @@ -1,189 +1,140 @@ import { ForbiddenException, Injectable, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions, Op} from 'sequelize'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions, Op } from 'sequelize'; import winston from 'winston'; import AppConfig from '../../config/app_config'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {User} from '../users/user.model'; -import {CreateGroupDto} from './dto/create-group.dto'; -import {UpdateGroupUserRoleDto} from './dto/update-group-user.dto'; -import {Group} from './group.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { User } from '../users/user.model'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupUserRoleDto } from './dto/update-group-user.dto'; +import { Group } from './group.model'; @Injectable() export class GroupsService { private readonly line = '_______________________________________________\n'; public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), + winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Group Service): ${info.message}` - ) - ) + info => + `${this.line}[${[info.timestamp]}] (Group Service): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); + constructor( @InjectModel(Group) private readonly groupModel: typeof Group, @InjectModel(User) - private readonly userModel: typeof User + private readonly userModel: typeof User, ) {} - async findAll(): Promise { - return this.groupModel.findAll({include: 'users'}); - } - - async count(): Promise { - return this.groupModel.count(); + async addEvaluationToGroup( + group: Group, + evaluation: Evaluation, + ): Promise { + await group.$add('evaluation', evaluation, { through: { createdAt: new Date(), updatedAt: new Date() } }); } - async findOneBang(options?: FindOptions): Promise { - const group = await this.groupModel.findOne(options); - if (group === null) { - throw new NotFoundException('Group with given name not found'); - } else { - return group; - } + async addUserToGroup(group: Group, user: User, role: string): Promise { + await group.$add('user', user, { through: { createdAt: new Date(), role: role, updatedAt: new Date() } }); } - // This method is used to find groups by group name, - // primarily to sync user roles from an external provider - async findByName(name: string): Promise { - return this.findOneBang({ - where: { - name - } - }); + async count(): Promise { + return this.groupModel.count(); } - async findByPkBang(id: string): Promise { - // Users must be included for determining permissions on the group. - // Other assocations should be called by their ID separately and not eagerly loaded. - const group = await this.groupModel.findByPk(id, {include: 'users'}); - if (group === null) { - throw new NotFoundException('Group with given id not found'); - } else { - return group; + async create(createGroupDto: CreateGroupDto): Promise { + if ( + (await this.groupModel.findAll({ where: { name: createGroupDto.name } })) + .length > 0 + ) { + throw new ForbiddenException( + 'Duplicate key detected. The names of groups must be unique.', + ); } - } - - async findByIds(id: string[]): Promise { - return this.groupModel.findAll({ - where: {id: {[Op.in]: id}}, - include: 'users' - }); - } - async addUserToGroup(group: Group, user: User, role: string): Promise { - await group.$add('user', user, { - through: {role: role, createdAt: new Date(), updatedAt: new Date()} - }); + const group = new Group(createGroupDto as any); + return group.save(); } async ensureGroupHasOwner( group: Group, - user: User | GroupUser + user: GroupUser | User, ): Promise { const owners = (await group.$get('users')).filter( - (userOnGroup) => userOnGroup.GroupUser.role === 'owner' + userOnGroup => userOnGroup.GroupUser.role === 'owner', ); // If there are no more owners, set an admin to owner if ( - (owners.length < 2 && - owners.some( - (owner) => owner.id === ('userId' in user ? user.userId : user.id) - )) || - owners.length === 0 + (owners.length < 2 + && owners.some( + owner => owner.id === ('userId' in user ? user.userId : user.id), + )) + || owners.length === 0 ) { const appConfig = new AppConfig(); // If default admin is not found, use admin with lowest ID - const admin = - (await this.userModel.findOne({ - where: {role: 'admin', email: appConfig.getDefaultAdmin()} - })) || - (await this.userModel.findOne({ - where: {role: 'admin'}, - order: [['id', 'ASC']] - })); - if (admin !== null) { + const admin + = (await this.userModel.findOne({ where: { email: appConfig.getDefaultAdmin(), role: 'admin' } })) + || (await this.userModel.findOne({ + order: [['id', 'ASC']], + where: { role: 'admin' }, + })); + if (admin === null) { + // No admin found in system + throw new ForbiddenException('No admin to be promoted'); + } else { // If admin is in the group, promote it. If not, add as owner const adminId = admin.id; const adminInGroup = (await group.$get('users')).find( - (userOnGroup) => userOnGroup.id === adminId + userOnGroup => userOnGroup.id === adminId, ); adminInGroup - ? await adminInGroup.GroupUser.update({role: 'owner'}) + ? await adminInGroup.GroupUser.update({ role: 'owner' }) : await this.addUserToGroup(group, admin, 'owner'); - } else { - // No admin found in system - throw new ForbiddenException('No admin to be promoted'); } } } - async updateGroupUserRole( - group: Group, - updateGroupUser: UpdateGroupUserRoleDto - ): Promise { - const groupUser = await GroupUser.findOne({ - where: {groupId: group.id, userId: updateGroupUser.userId} - }); - if (groupUser) { - await this.ensureGroupHasOwner(group, groupUser); - } - return groupUser?.update({role: updateGroupUser.groupRole}); - } - - async removeUserFromGroup(group: Group, user: User): Promise { - await this.ensureGroupHasOwner(group, user); - return group.$remove('user', user); + async findAll(): Promise { + return this.groupModel.findAll({ include: 'users' }); } - async addEvaluationToGroup( - group: Group, - evaluation: Evaluation - ): Promise { - await group.$add('evaluation', evaluation, { - through: {createdAt: new Date(), updatedAt: new Date()} + async findByIds(id: string[]): Promise { + return this.groupModel.findAll({ + include: 'users', + where: { id: { [Op.in]: id } }, }); } - async removeEvaluationFromGroup( - group: Group, - evaluation: Evaluation - ): Promise { - return group.$remove('evaluation', evaluation); + // This method is used to find groups by group name, + // primarily to sync user roles from an external provider + async findByName(name: string): Promise { + return this.findOneBang({ where: { name } }); } - async create(createGroupDto: CreateGroupDto): Promise { - if ( - (await this.groupModel.findAll({where: {name: createGroupDto.name}})) - .length > 0 - ) { - throw new ForbiddenException( - 'Duplicate key detected. The names of groups must be unique.' - ); + async findByPkBang(id: string): Promise { + // Users must be included for determining permissions on the group. + // Other assocations should be called by their ID separately and not eagerly loaded. + const group = await this.groupModel.findByPk(id, { include: 'users' }); + if (group === null) { + throw new NotFoundException('Group with given id not found'); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const group = new Group(createGroupDto as any); - return group.save(); + return group; } - async update(groupToUpdate: Group, groupDto: CreateGroupDto): Promise { - if ( - (await this.groupModel.findAll({where: {name: groupDto.name}})).length > 1 - ) { - throw new ForbiddenException( - 'Duplicate key detected. The names of groups must be unique.' - ); + async findOneBang(options?: FindOptions): Promise { + const group = await this.groupModel.findOne(options); + if (group === null) { + throw new NotFoundException('Group with given name not found'); } - return groupToUpdate.update(groupDto); + return group; } async remove(groupToDelete: Group): Promise { @@ -192,22 +143,34 @@ export class GroupsService { return groupToDelete; } + async removeEvaluationFromGroup( + group: Group, + evaluation: Evaluation, + ): Promise { + return group.$remove('evaluation', evaluation); + } + + async removeUserFromGroup(group: Group, user: User): Promise { + await this.ensureGroupHasOwner(group, user); + return group.$remove('user', user); + } + // This method ensures that the passed in user is in all of the // passed in groups, as long as the group already exists. // It will additionally remove the user from any groups not in the list. // Called from oidc.strategy.ts, if OIDC_EXTERNAL_GROUPS is enabled async syncUserGroups(user: User, groups: string[]) { - const currentGroups = await user.$get('groups', {include: [User]}); + const currentGroups = await user.$get('groups', { include: [User] }); const groupsToLeave = currentGroups.filter( - (group) => !groups.includes(group.name) + group => !groups.includes(group.name), ); // Remove user from any groups that they should not be in for (const groupToLeave of groupsToLeave) { try { await this.removeUserFromGroup(groupToLeave, user); - } catch (err) { - this.logger.warn(`Failed to remove user from group: ${err}`); + } catch (error) { + this.logger.warn(`Failed to remove user from group: ${error}`); } } @@ -218,11 +181,11 @@ export class GroupsService { try { const existingGroup = await this.findByName(group); existingGroups.push(existingGroup); - } catch (err) { - if (err instanceof NotFoundException) { + } catch (error) { + if (error instanceof NotFoundException) { this.logger.info('External group does not exist locally, skipping..'); } else { - this.logger.warn(err); + this.logger.warn(error); } } } @@ -231,19 +194,41 @@ export class GroupsService { await Promise.all( existingGroups .filter( - (existingGroup) => - !currentGroups.some((group) => group.name === existingGroup.name) - ) - .map((existingGroup) => - this.addUserToGroup(existingGroup, user, 'member') + existingGroup => + currentGroups.every(group => group.name !== existingGroup.name), ) + .map(existingGroup => + this.addUserToGroup(existingGroup, user, 'member'), + ), ); // Ensure we didn't leave any dangling groups await Promise.all( groupsToLeave.map(async (group) => { await this.ensureGroupHasOwner(group, user); - }) + }), ); } + + async update(groupToUpdate: Group, groupDto: CreateGroupDto): Promise { + if ( + (await this.groupModel.findAll({ where: { name: groupDto.name } })).length > 1 + ) { + throw new ForbiddenException( + 'Duplicate key detected. The names of groups must be unique.', + ); + } + return groupToUpdate.update(groupDto); + } + + async updateGroupUserRole( + group: Group, + updateGroupUser: UpdateGroupUserRoleDto, + ): Promise { + const groupUser = await GroupUser.findOne({ where: { groupId: group.id, userId: updateGroupUser.userId } }); + if (groupUser) { + await this.ensureGroupHasOwner(group, groupUser); + } + return groupUser?.update({ role: updateGroupUser.groupRole }); + } } diff --git a/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts b/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts index 400f406256..5997443fd3 100644 --- a/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts +++ b/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class APIKeyOrJwtAuthGuard extends AuthGuard(['jwt', 'apikey']) {} diff --git a/apps/backend/src/guards/api-keys-enabled.guard.ts b/apps/backend/src/guards/api-keys-enabled.guard.ts index 8fdb76064e..a449c13505 100644 --- a/apps/backend/src/guards/api-keys-enabled.guard.ts +++ b/apps/backend/src/guards/api-keys-enabled.guard.ts @@ -1,6 +1,6 @@ -import {CanActivate, ExecutionContext, Injectable} from '@nestjs/common'; -import {Observable} from 'rxjs'; -import {ConfigService} from '../config/config.service'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../config/config.service'; @Injectable() export class APIKeysEnabled implements CanActivate { @@ -8,9 +8,10 @@ export class APIKeysEnabled implements CanActivate { constructor(configService: ConfigService) { this.configService = configService; } + canActivate( - _context: ExecutionContext - ): boolean | Promise | Observable { + _context: ExecutionContext, + ): boolean | Observable | Promise { return Boolean(this.configService.get('API_KEY_SECRET')); } } diff --git a/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts b/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts index 3ded7a2014..ee0aa72687 100644 --- a/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts +++ b/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts @@ -1,11 +1,11 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class ImplicitAllowJwtAuthGuard extends AuthGuard('jwt') { // All these are typed as any within passport - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - handleRequest(_err: any, user: any, _info: any): any { + + handleRequest(_error: any, user: any, _info: any): any { return user; } } diff --git a/apps/backend/src/guards/jwt-auth.guard.ts b/apps/backend/src/guards/jwt-auth.guard.ts index 5440f30669..2155290ede 100644 --- a/apps/backend/src/guards/jwt-auth.guard.ts +++ b/apps/backend/src/guards/jwt-auth.guard.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/backend/src/guards/local-auth.guard.ts b/apps/backend/src/guards/local-auth.guard.ts index 72c5876689..ccf962b679 100644 --- a/apps/backend/src/guards/local-auth.guard.ts +++ b/apps/backend/src/guards/local-auth.guard.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/apps/backend/src/guards/test.guard.ts b/apps/backend/src/guards/test.guard.ts index c4ccd446b2..7b7facf440 100644 --- a/apps/backend/src/guards/test.guard.ts +++ b/apps/backend/src/guards/test.guard.ts @@ -1,13 +1,13 @@ -import {CanActivate, Injectable} from '@nestjs/common'; +import { CanActivate, Injectable } from '@nestjs/common'; @Injectable() export class TestGuard implements CanActivate { async canActivate(): Promise { const environment = process.env.NODE_ENV; return ( - environment !== undefined && - ['development', 'test'].includes(environment) && - process.env.CYPRESS_TESTING === 'true' + environment !== undefined + && ['development', 'test'].includes(environment) + && process.env.CYPRESS_TESTING === 'true' ); } } diff --git a/apps/backend/src/interceptors/create-evaluation-interceptor.ts b/apps/backend/src/interceptors/create-evaluation-interceptor.ts index 9e638bf5ab..04cbc94fe3 100644 --- a/apps/backend/src/interceptors/create-evaluation-interceptor.ts +++ b/apps/backend/src/interceptors/create-evaluation-interceptor.ts @@ -1,13 +1,13 @@ -import {ICreateEvaluation} from '@heimdall/common/interfaces'; +import { ICreateEvaluation } from '@heimdall/common/interfaces'; import { CallHandler, ExecutionContext, Injectable, - NestInterceptor + NestInterceptor, } from '@nestjs/common'; -import {Observable} from 'rxjs'; -import {CreateEvaluationTagDto} from '../evaluation-tags/dto/create-evaluation-tag.dto'; -import {GroupsService} from '../groups/groups.service'; +import { Observable } from 'rxjs'; +import { CreateEvaluationTagDto } from '../evaluation-tags/dto/create-evaluation-tag.dto'; +import { GroupsService } from '../groups/groups.service'; @Injectable() export class CreateEvaluationInterceptor implements NestInterceptor { @@ -18,25 +18,21 @@ export class CreateEvaluationInterceptor implements NestInterceptor { public intercept( _context: ExecutionContext, - next: CallHandler + next: CallHandler, ): Observable { // changing request const request = _context.switchToHttp().getRequest(); if (request.body.public) { request.body.public = [true, 'true'].includes(request.body.public); } - if ( - request.body.evaluationTags !== undefined && - request.body.evaluationTags !== '' - ) { - request.body.evaluationTags = request.body.evaluationTags + request.body.evaluationTags = request.body.evaluationTags !== undefined + && request.body.evaluationTags !== '' + ? request.body.evaluationTags .split(',') .map( - (evaluationTag: string) => new CreateEvaluationTagDto(evaluationTag) - ); - } else { - request.body.evaluationTags = []; - } + (evaluationTag: string) => new CreateEvaluationTagDto(evaluationTag), + ) + : []; if (request.body.groups !== undefined) { request.body.groups = request.body.groups.split(','); } diff --git a/apps/backend/src/interceptors/logging.interceptor.ts b/apps/backend/src/interceptors/logging.interceptor.ts index 76f0326787..b8e5d5a3e3 100644 --- a/apps/backend/src/interceptors/logging.interceptor.ts +++ b/apps/backend/src/interceptors/logging.interceptor.ts @@ -2,96 +2,91 @@ import { CallHandler, ExecutionContext, Injectable, - NestInterceptor + NestInterceptor, } from '@nestjs/common'; -import {Request} from 'express'; +import { Request } from 'express'; import _ from 'lodash'; -import {Observable} from 'rxjs'; +import { Observable } from 'rxjs'; import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {SlimUserDto} from '../users/dto/slim-user.dto'; -import {UserDto} from '../users/dto/user.dto'; -import {User} from '../users/user.model'; +import { ConfigService } from '../config/config.service'; +import { SlimUserDto } from '../users/dto/slim-user.dto'; +import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.model'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly configService: ConfigService; private readonly line = '___________________________________________\n'; - constructor(configService: ConfigService) { - this.configService = configService; - } public logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), + winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), winston.format.printf( - (info) => + info => `${this.line}[${[info.timestamp]}] (Interceptor): ${info.ip} ${ info.referer - } ${info.userAgent} ${info.user} ${info.message}` - ) - ) + } ${info.userAgent} ${info.user} ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); + constructor(configService: ConfigService) { + this.configService = configService; + } + + getRealIP(request: Request): string | unknown { + const realIP = Object.keys(request.headers).find( + header => + header.toLowerCase() === 'x-forwarded-for' + || header.toLowerCase() === 'x-real-ip', + ); + return realIP ? `${request.headers[realIP]} -> ${request.ip}` : request.ip; + } + intercept(context: ExecutionContext, next: CallHandler): Observable { - const request: Request & {user?: User} = context + const request: Request & { user?: User } = context .switchToHttp() .getRequest(); const method = request.method; const endpoint = request.originalUrl; - const callingUser: User | undefined = request.user; + const callingUser: undefined | User = request.user; const calledMethod = context.getHandler().name; - const requestParams = JSON.stringify(this.redact(request.body)); - const referer = request.headers['referer']; + const requestParameters = JSON.stringify(this.redact(request.body)); + const referer = request.headers.referer; const userAgent = request.headers['user-agent']; this.logger.info({ ip: this.getRealIP(request), - user: this.userToString(callingUser), + message: `${_.startCase( + calledMethod, + )} (${method}) ${requestParameters} ${endpoint}`, referer: referer, + user: this.userToString(callingUser), userAgent: userAgent, - message: `${_.startCase( - calledMethod - )} (${method}) ${requestParams} ${endpoint}` }); return next.handle(); } - userToString(user?: User | UserDto | SlimUserDto): string { - if (user) { - return `User`; + redact(object?: Record): Record | undefined { + if (!_.isObject(object)) { + return undefined; } - return `User`; + return this.redactObject(structuredClone(object)); } - getRealIP(request: Request): string | unknown { - const realIP = Object.keys(request.headers).find( - (header) => - header.toLowerCase() === 'x-forwarded-for' || - header.toLowerCase() === 'x-real-ip' - ); - if (realIP) { - return `${request.headers[realIP]} -> ${request.ip}`; - } else { - return request.ip; + redactObject(object: Record): Record { + for (const key of Object.keys(object)) { + if (this.configService.sensitiveKeys.some(regex => regex.test(key))) { + object[key] = '[REDACTED]'; + } } + return object; } - redact(obj?: Record): Record | undefined { - if (!_.isObject(obj)) { - return undefined; + userToString(user?: SlimUserDto | User | UserDto): string { + if (user) { + return `User`; } - return this.redactObject(structuredClone(obj)); - } - - redactObject(obj: Record): Record { - Object.keys(obj).forEach((key) => { - if (this.configService.sensitiveKeys.some((regex) => regex.test(key))) { - obj[key] = '[REDACTED]'; - } - }); - return obj; + return 'User'; } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 31f79d032a..25f0b4f3f1 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,32 +1,30 @@ -import {ValidationPipe} from '@nestjs/common'; -import {NestFactory} from '@nestjs/core'; -import {NestExpressApplication} from '@nestjs/platform-express'; -import {json} from 'express'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import postgresSessionStore = require('connect-pg-simple'); +import { json } from 'express'; import rateLimit from 'express-rate-limit'; +import session = require('express-session'); import helmet from 'helmet'; import multer from 'multer'; -import winston from 'winston'; import passport = require('passport'); -import postgresSessionStore = require('connect-pg-simple'); -import session = require('express-session'); -import {AppModule} from './app.module'; -import {ConfigService} from './config/config.service'; +import winston from 'winston'; +import { AppModule } from './app.module'; +import { ConfigService } from './config/config.service'; import { assertFipsMode } from './crypto/fips'; import { HashWriteGateService } from './crypto/hash-write-gate.service'; -import {generateDefault} from './token/token.providers'; +import { generateDefault } from './token/token.providers'; const line = '_______________________________________________\n'; const loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; const logger = winston.createLogger({ - transports: [new winston.transports.Console()], format: winston.format.combine( - winston.format.timestamp({ - format: loggingTimeFormat - }), + winston.format.timestamp({ format: loggingTimeFormat }), winston.format.printf( - (info) => `${line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + info => `${line}[${[info.timestamp]}] (Authn Service): ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], }); async function bootstrap() { @@ -57,6 +55,14 @@ async function bootstrap() { // certificate as part of deployment. 'base-uri': ["'self'"], 'block-all-mixed-content': [], + // This is the only setting that is different from the defaults. + 'connect-src': [ + "'self'", + 'https://api.github.com', + 'https://sts.amazonaws.com', + configService.getTenableHostUrl(), + configService.getSplunkHostUrl(), + ].filter(Boolean), 'default-src': ["'self'"], 'font-src': ["'self'", 'https:', 'data:'], 'frame-ancestors': ["'self'"], @@ -65,45 +71,37 @@ async function bootstrap() { 'script-src': ["'self'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], - // This is the only setting that is different from the defaults. - 'connect-src': [ - "'self'", - 'https://api.github.com', - 'https://sts.amazonaws.com', - configService.getTenableHostUrl(), - configService.getSplunkHostUrl() - ].filter((source) => source) - } - }) + }, + }), ); - app.use(json({limit: '50mb'})); + app.use(json({ limit: '50mb' })); app.use(passport.initialize()); // Sessions was previously set to only be used for oauth callbacks // but now is used for Tenable authentication as well. if ( - configService.enabledOauthStrategies().length || - configService.getTenableHostUrl().length + configService.enabledOauthStrategies().length > 0 + || configService.getTenableHostUrl().length > 0 ) { app.use( session({ + cookie: { + maxAge: 60 * 60 * 1000, // 1 hour + secure: configService.isInProductionMode(), + }, + proxy: configService.isInProductionMode() ? true : undefined, + resave: false, + saveUninitialized: false, secret: generateDefault(), store: new (postgresSessionStore(session))({ conObject: { ...configService.getDbConfig(), /* The pg conObject takes mostly the same parameters as Sequelize, except the ssl options, those are equal to the dialectOptions passed to sequelize */ - ssl: configService.getSSLConfig() + ssl: configService.getSSLConfig(), }, - tableName: 'session' + tableName: 'session', }), - proxy: configService.isInProductionMode() ? true : undefined, - cookie: { - maxAge: 60 * 60 * 1000, // 1 hour - secure: configService.isInProductionMode() - }, - saveUninitialized: false, - resave: false - }) + }), ); if (configService.isInProductionMode()) { app.getHttpAdapter().getInstance().set('trust proxy', true); @@ -113,36 +111,35 @@ async function bootstrap() { app.use( '/authn/login', rateLimit({ - windowMs: 60 * 1000, max: 20, message: { - status: 429, + error: 'Ratelimited', message: 'Too Many Requests', - error: 'Ratelimited' - } - }) + status: 429, + }, + windowMs: 60 * 1000, + }), ); // Allow for file uploads up to 50 mb multer({ limits: { fieldSize: - parseInt(configService.get('MAX_FILE_UPLOAD_SIZE') || '50') * - 1024 * - 1024 - } + parseInt(configService.get('MAX_FILE_UPLOAD_SIZE') || '50') + * 1024 + * 1024, + }, }); app.useGlobalPipes( new ValidationPipe({ transform: true, - whitelist: true - }) + whitelist: true, + }), ); - //eslint-disable-next-line @typescript-eslint/no-explicit-any - app.use((req: any, res: any, next: any) => { - logger.debug('Url:', req.url); - logger.debug('Session:', JSON.stringify(req.session, null, 2)); + app.use((request: any, res: any, next: any) => { + logger.debug('Url:', request.url); + logger.debug('Session:', JSON.stringify(request.session, null, 2)); next(); }); diff --git a/apps/backend/src/pipes/password-change.pipe.spec.ts b/apps/backend/src/pipes/password-change.pipe.spec.ts index 6496def112..c48dd22ac6 100644 --- a/apps/backend/src/pipes/password-change.pipe.spec.ts +++ b/apps/backend/src/pipes/password-change.pipe.spec.ts @@ -1,12 +1,12 @@ -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UPDATE_USER_DTO_TEST_OBJ, UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, - UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD } from '../../test/constants/users-test.constant'; -import {PasswordChangePipe} from './password-change.pipe'; +import { PasswordChangePipe } from './password-change.pipe'; describe('PasswordChangePipe', () => { let passwordChangePipe: PasswordChangePipe; @@ -23,61 +23,61 @@ describe('PasswordChangePipe', () => { describe('classesChanged Helper Function', () => { it('should pass', () => { expect( - passwordChangePipe.classesChanged('Totally$Different199', 'Letmein123@') + passwordChangePipe.classesChanged('Totally$Different199', 'Letmein123@'), ).toBeTruthy(); }); it('should fail because both passwords have the same uppercase letter(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('abc$LghE17', 'LEtmein123') + passwordChangePipe.classesChanged('abc$LghE17', 'LEtmein123'), ).toBeFalsy(); }); it('should pass because both passwords have the same uppercase letter(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('abc$EghL17', 'LEtmein123') + passwordChangePipe.classesChanged('abc$EghL17', 'LEtmein123'), ).toBeTruthy(); }); it('should fail because both passwords have the same lowercase letter(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('ABCDe$PQRSt', 'LetMEIN123') + passwordChangePipe.classesChanged('ABCDe$PQRSt', 'LetMEIN123'), ).toBeFalsy(); }); it('should pass because both passwords have the same lowercase letter(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ABCDt$PQRSe', 'LetMEIN123') + passwordChangePipe.classesChanged('ABCDt$PQRSe', 'LetMEIN123'), ).toBeTruthy(); }); it('should fail because both passwords have the same number(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('ab0c$DEF7', '0ABCdef7') + passwordChangePipe.classesChanged('ab0c$DEF7', '0ABCdef7'), ).toBeFalsy(); }); it('should pass because both passwords have the same number(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ab7c$4DEF0', '0ABCdef7') + passwordChangePipe.classesChanged('ab7c$4DEF0', '0ABCdef7'), ).toBeTruthy(); }); it('should pass because both passwords have the same special character(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ab$c D1EF&', '&ABCdef7$') + passwordChangePipe.classesChanged('ab$c D1EF&', '&ABCdef7$'), ).toBeTruthy(); }); it('should fail because both passwords have the same special character(s) but in the same order', () => { expect( - passwordChangePipe.classesChanged('&abc D1EF$', '&ABCdef7$') + passwordChangePipe.classesChanged('&abc D1EF$', '&ABCdef7$'), ).toBeFalsy(); }); it('should fail because both passwords are the same', () => { expect( - passwordChangePipe.classesChanged('Letmein123$', 'Letmein123$') + passwordChangePipe.classesChanged('Letmein123$', 'Letmein123$'), ).toBeFalsy(); }); }); @@ -90,14 +90,14 @@ describe('PasswordChangePipe', () => { it('should return the same UpdateUserDto', () => { expect( passwordChangePipe.transform( - UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD - ) + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + ), ).toEqual(UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD); }); it('should return UpdateUserDto if password fields are null', () => { expect( - passwordChangePipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS) + passwordChangePipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); @@ -105,8 +105,8 @@ describe('PasswordChangePipe', () => { it('should should pass when the currentPassword is not provided and a valid new password is provided', () => { expect( passwordChangePipe.transform( - UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD - ) + UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, + ), ).toEqual(UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD); }); }); @@ -115,12 +115,12 @@ describe('PasswordChangePipe', () => { describe('Test Invalid Password Changes', () => { it('should throw a BadRequestException', () => { expect(() => - passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toThrowError(BadRequestException); expect(() => - passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toThrowError( - 'A minimum of four character classes must be changed when updating a password. A minimum of eight of the total number of characters must be changed when updating a password.' + 'A minimum of four character classes must be changed when updating a password. A minimum of eight of the total number of characters must be changed when updating a password.', ); }); }); diff --git a/apps/backend/src/pipes/password-change.pipe.ts b/apps/backend/src/pipes/password-change.pipe.ts index c3577b111a..1c777884e5 100644 --- a/apps/backend/src/pipes/password-change.pipe.ts +++ b/apps/backend/src/pipes/password-change.pipe.ts @@ -1,39 +1,14 @@ -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; import levenshtein from 'js-levenshtein'; @Injectable() export class PasswordChangePipe implements PipeTransform { - transform(value: { - currentPassword?: string; - password: string | undefined; - passwordConfirmation: string | undefined; - }): Record { - if ( - (!value.password && !value.passwordConfirmation) || - !value.currentPassword - ) { - return value; - } else if ( - typeof value.password == 'string' && - typeof value.currentPassword == 'string' && - levenshtein(value.password, value.currentPassword) > 8 && - this.classesChanged(value.password, value.currentPassword) - ) { - return value; - } else { - throw new BadRequestException( - 'A minimum of four character classes must be changed when updating a password.' + - ' A minimum of eight of the total number of characters must be changed when updating a password.' - ); - } - } - classesChanged(future: string, current: string): boolean { const validators = [ - RegExp('[a-z]', 'g'), - RegExp('[A-Z]', 'g'), - RegExp('[0-9]', 'g'), - RegExp(/[^\w\s]/, 'g') + new RegExp('[a-z]', 'gv'), + new RegExp('[A-Z]', 'gv'), + new RegExp(String.raw`\d`, 'g'), + new RegExp(/[^\s\w]/, 'g'), ]; for (const validator of validators) { @@ -45,4 +20,29 @@ export class PasswordChangePipe implements PipeTransform { } return true; } + + transform(value: { + currentPassword?: string; + password: string | undefined; + passwordConfirmation: string | undefined; + }): Record { + if ( + (!value.password && !value.passwordConfirmation) + || !value.currentPassword + ) { + return value; + } + if ( + typeof value.password == 'string' + && typeof value.currentPassword == 'string' + && levenshtein(value.password, value.currentPassword) > 8 + && this.classesChanged(value.password, value.currentPassword) + ) { + return value; + } + throw new BadRequestException( + 'A minimum of four character classes must be changed when updating a password.' + + ' A minimum of eight of the total number of characters must be changed when updating a password.', + ); + } } diff --git a/apps/backend/src/pipes/password-complexity.pipe.spec.ts b/apps/backend/src/pipes/password-complexity.pipe.spec.ts index 3c2bd9a785..1684bcc22d 100644 --- a/apps/backend/src/pipes/password-complexity.pipe.spec.ts +++ b/apps/backend/src/pipes/password-complexity.pipe.spec.ts @@ -1,16 +1,16 @@ -import {validators} from '@heimdall/password-complexity'; -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it} from 'vitest'; +import { validators } from '@heimdall/password-complexity'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CREATE_USER_DTO_TEST_OBJ, CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, UPDATE_USER_DTO_TEST_OBJ, UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, } from '../../test/constants/users-test.constant'; import { PasswordComplexityPipe, - validatePassword + validatePassword, } from './password-complexity.pipe'; describe('PasswordComplexityPipe', () => { @@ -31,7 +31,7 @@ describe('PasswordComplexityPipe', () => { }); it('should pass because the password has more than 15 characters', () => { expect(validatePassword('NotAShortPassword')).not.toContain( - validators[0].name + validators[0].name, ); }); }); @@ -39,31 +39,31 @@ describe('PasswordComplexityPipe', () => { describe('hasClasses', () => { it('should fail because the password does not contain a special character', () => { expect(validatePassword('Testpasswordwithoutspecialchar7')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain a number', () => { expect(validatePassword('Testpasswordwithoutanumber')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain an uppercase letter', () => { expect(validatePassword('testpasswordwithoutuppercase7$')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain a lowercase letter', () => { expect(validatePassword('TESTPASSWORDWITHOUTLOWERCASE7$')).toContain( - validators[1].name + validators[1].name, ); }); it('should pass because the password has all character classes and is at least 15 characters', () => { expect(validatePassword('Atestpassword7$')).not.toContain( - validators[1].name + validators[1].name, ); }); }); @@ -107,7 +107,7 @@ describe('PasswordComplexityPipe', () => { it('should pass because the password meets all the minimum requirements', () => { expect(validatePassword('aaaBBB111$$$')).not.toContain( - validators[2].name + validators[2].name, ); }); }); @@ -117,21 +117,21 @@ describe('PasswordComplexityPipe', () => { describe('Test Valid Password', () => { it('should return the same CreateUserDto', () => { expect( - passwordComplexityPipe.transform(CREATE_USER_DTO_TEST_OBJ) + passwordComplexityPipe.transform(CREATE_USER_DTO_TEST_OBJ), ).toEqual(CREATE_USER_DTO_TEST_OBJ); }); it('should return the same UpdateUserDto', () => { expect( - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toEqual(UPDATE_USER_DTO_TEST_OBJ); }); it('should return UpdateUserDto if password fields are null', () => { expect( passwordComplexityPipe.transform( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS - ) + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, + ), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); }); @@ -141,22 +141,22 @@ describe('PasswordComplexityPipe', () => { it('should throw a BadRequestException for CreateUserDto with missing password', () => { expect(() => passwordComplexityPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).toThrowError(BadRequestException); expect(() => passwordComplexityPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).toThrowError('Password must be of type string'); }); it('should throw a BadRequestException for UpdateUserDto with missing password', () => { expect(() => - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD), ).toThrowError(BadRequestException); expect(() => - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD), ).toThrowError('Password must be of type string'); }); }); diff --git a/apps/backend/src/pipes/password-complexity.pipe.ts b/apps/backend/src/pipes/password-complexity.pipe.ts index 8d010ae11c..f456f408ef 100644 --- a/apps/backend/src/pipes/password-complexity.pipe.ts +++ b/apps/backend/src/pipes/password-complexity.pipe.ts @@ -1,14 +1,12 @@ -import {validators} from '@heimdall/password-complexity'; -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { validators } from '@heimdall/password-complexity'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; export function validatePassword(password?: string): string[] { - if (typeof password !== 'string') { - return ['Password must be of type string']; - } else { - return validators - .filter((validator) => !validator.check(password)) - .map((validator) => validator.name); - } + return typeof password === 'string' + ? validators + .filter(validator => !validator.check(password)) + .map(validator => validator.name) + : ['Password must be of type string']; } @Injectable() @@ -21,14 +19,13 @@ export class PasswordComplexityPipe implements PipeTransform { return value; } if ( - typeof value.password === 'string' && - validatePassword(value.password).length === 0 + typeof value.password === 'string' + && validatePassword(value.password).length === 0 ) { return value; - } else { - throw new BadRequestException( - validatePassword(value.password).join(', ') - ); } + throw new BadRequestException( + validatePassword(value.password).join(', '), + ); } } diff --git a/apps/backend/src/pipes/passwords-match.pipe.spec.ts b/apps/backend/src/pipes/passwords-match.pipe.spec.ts index fd5f5f8766..a5ba70e5f6 100644 --- a/apps/backend/src/pipes/passwords-match.pipe.spec.ts +++ b/apps/backend/src/pipes/passwords-match.pipe.spec.ts @@ -1,12 +1,12 @@ -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it} from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CREATE_USER_DTO_TEST_OBJ, CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, UPDATE_USER_DTO_TEST_OBJ, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, } from '../../test/constants/users-test.constant'; -import {PasswordsMatchPipe} from './passwords-match.pipe'; +import { PasswordsMatchPipe } from './passwords-match.pipe'; describe('PasswordsMatchPipe', () => { let passwordsMatchPipe: PasswordsMatchPipe; @@ -23,19 +23,19 @@ describe('PasswordsMatchPipe', () => { describe('Test Matching Passwords', () => { it('should return the same CreateUserDto', () => { expect(passwordsMatchPipe.transform(CREATE_USER_DTO_TEST_OBJ)).toEqual( - CREATE_USER_DTO_TEST_OBJ + CREATE_USER_DTO_TEST_OBJ, ); }); it('should return the same UpdateUserDto', () => { expect(passwordsMatchPipe.transform(UPDATE_USER_DTO_TEST_OBJ)).toEqual( - UPDATE_USER_DTO_TEST_OBJ + UPDATE_USER_DTO_TEST_OBJ, ); }); it('should return UpdateUserDto if password fields are null', () => { expect( - passwordsMatchPipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS) + passwordsMatchPipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); }); @@ -45,13 +45,13 @@ describe('PasswordsMatchPipe', () => { it('should throw a Bad Request Exception', () => { expect(() => passwordsMatchPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS - ) + CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, + ), ).toThrowError(BadRequestException); expect(() => passwordsMatchPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS - ) + CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, + ), ).toThrowError('Passwords do not match'); }); }); diff --git a/apps/backend/src/pipes/passwords-match.pipe.ts b/apps/backend/src/pipes/passwords-match.pipe.ts index 68eced9982..4521cbfbbf 100644 --- a/apps/backend/src/pipes/passwords-match.pipe.ts +++ b/apps/backend/src/pipes/passwords-match.pipe.ts @@ -1,4 +1,4 @@ -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; @Injectable() export class PasswordsMatchPipe implements PipeTransform { @@ -8,16 +8,15 @@ export class PasswordsMatchPipe implements PipeTransform { passwordConfirmation: string | undefined; }): Record { if ( - value.currentPassword != null && - value.password == null && - value.passwordConfirmation == null + value.currentPassword != null + && value.password == null + && value.passwordConfirmation == null ) { return value; } if (value.password === value.passwordConfirmation) { return value; - } else { - throw new BadRequestException('Passwords do not match'); } + throw new BadRequestException('Passwords do not match'); } } diff --git a/apps/backend/src/statistics/dto/statistics.dto.ts b/apps/backend/src/statistics/dto/statistics.dto.ts index ea602fbfe7..6cc52bdcd3 100644 --- a/apps/backend/src/statistics/dto/statistics.dto.ts +++ b/apps/backend/src/statistics/dto/statistics.dto.ts @@ -1,11 +1,11 @@ -import {IStatistics} from '@heimdall/common/interfaces'; +import type { IStatistics } from '@heimdall/common/interfaces'; export class StatisticsDTO implements IStatistics { readonly apiKeyCount: number; - readonly userCount: number; readonly evaluationCount: number; readonly evaluationTagCount: number; readonly groupCount: number; + readonly userCount: number; constructor(statistics: StatisticsDTO) { this.apiKeyCount = statistics.apiKeyCount; diff --git a/apps/backend/src/statistics/statistics.controller.ts b/apps/backend/src/statistics/statistics.controller.ts index e5d67dfbbd..0b13edf547 100644 --- a/apps/backend/src/statistics/statistics.controller.ts +++ b/apps/backend/src/statistics/statistics.controller.ts @@ -1,31 +1,31 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Controller, Get, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {StatisticsDTO} from './dto/statistics.dto'; -import {StatisticsService} from './statistics.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { StatisticsDTO } from './dto/statistics.dto'; +import { StatisticsService } from './statistics.service'; @Controller('statistics') @UseInterceptors(LoggingInterceptor) export class StatisticsController { constructor( private readonly statisticsService: StatisticsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} @Get() @UseGuards(JwtAuthGuard) async getHeimdallStatistics( - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); ForbiddenError.from(abac).throwUnlessCan(Action.ViewStatistics, User); diff --git a/apps/backend/src/statistics/statistics.module.ts b/apps/backend/src/statistics/statistics.module.ts index 29c5a440fc..c83fe92213 100644 --- a/apps/backend/src/statistics/statistics.module.ts +++ b/apps/backend/src/statistics/statistics.module.ts @@ -1,33 +1,34 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ApiKey} from '../apikeys/apikey.model'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {StatisticsController} from './statistics.controller'; -import {StatisticsService} from './statistics.service'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { StatisticsController } from './statistics.controller'; +import { StatisticsService } from './statistics.service'; @Module({ + controllers: [StatisticsController], imports: [ SequelizeModule.forFeature([ ApiKey, Evaluation, EvaluationTag, User, - Group + Group, ]), ConfigModule, - CryptoModule + CryptoModule, ], providers: [ StatisticsService, @@ -37,8 +38,7 @@ import {StatisticsService} from './statistics.service'; EvaluationsService, EvaluationTagsService, UsersService, - GroupsService + GroupsService, ], - controllers: [StatisticsController] }) export class StatisticsModule {} diff --git a/apps/backend/src/statistics/statistics.service.ts b/apps/backend/src/statistics/statistics.service.ts index 1038aab98d..d4c42dbdd9 100644 --- a/apps/backend/src/statistics/statistics.service.ts +++ b/apps/backend/src/statistics/statistics.service.ts @@ -1,10 +1,10 @@ -import {Injectable} from '@nestjs/common'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupsService} from '../groups/groups.service'; -import {UsersService} from '../users/users.service'; -import {StatisticsDTO} from './dto/statistics.dto'; +import { Injectable } from '@nestjs/common'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupsService } from '../groups/groups.service'; +import { UsersService } from '../users/users.service'; +import { StatisticsDTO } from './dto/statistics.dto'; @Injectable() export class StatisticsService { @@ -13,16 +13,16 @@ export class StatisticsService { private readonly evaluationsService: EvaluationsService, private readonly evaluationTagsService: EvaluationTagsService, private readonly groupsService: GroupsService, - private readonly usersService: UsersService + private readonly usersService: UsersService, ) {} async getHeimdallStatistics(): Promise { return new StatisticsDTO({ apiKeyCount: await this.apiKeyService.count(), - userCount: await this.usersService.count(), evaluationCount: await this.evaluationsService.count(), evaluationTagCount: await this.evaluationTagsService.count(), - groupCount: await this.groupsService.count() + groupCount: await this.groupsService.count(), + userCount: await this.usersService.count(), }); } } diff --git a/apps/backend/src/tenable/tenable.controller.ts b/apps/backend/src/tenable/tenable.controller.ts index 153fd5df38..0b1ebc34a5 100644 --- a/apps/backend/src/tenable/tenable.controller.ts +++ b/apps/backend/src/tenable/tenable.controller.ts @@ -1,30 +1,37 @@ import { - Controller, - Req, - Res, - Post, + All, Body, + Controller, HttpException, HttpStatus, - All + Post, + Req, + Res, } from '@nestjs/common'; -import {TenableService} from './tenable.service'; import axios from 'axios'; -import {Request, Response} from 'express'; +import { Request, Response } from 'express'; +import { TenableService } from './tenable.service'; -// Extend express-session types to include 'tenable' +// Extend express-session types to include 'tenable'. +// This MUST stay an `interface`: module augmentation works by declaration +// merging, and only interfaces merge. Written as `type SessionData = {...}` it +// declares a second, conflicting SessionData instead of extending the one +// express-session exports — TS2300 duplicate identifier, and every +// `session.tenable` access then fails with TS2339. There is no `type` form of +// this fix, so the rule is disabled for this declaration only. declare module 'express-session' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface SessionData { tenable?: { - host_url: string; accesskey: string; + host_url: string; secretkey: string; }; } } -const TENABLE_CSP_NOT_SET = - "Cannot set properties of undefined (setting 'tenable')"; +const TENABLE_CSP_NOT_SET + = "Cannot set properties of undefined (setting 'tenable')"; // NestJS controller that handles Tenable authentication and proxying requests to Tenable // It allows users to log in with their Tenable credentials and then proxies all subsequent requests @@ -43,10 +50,10 @@ export class TenableController { * @throws {HttpException} If any credentials are missing or if authentication fails. */ async login( - @Req() req: Request, - @Body() body: {host_url: string; accesskey: string; secretkey: string} + @Req() request: Request, + @Body() body: { accesskey: string; host_url: string; secretkey: string }, ) { - const {host_url, accesskey, secretkey} = body; + const { accesskey, host_url, secretkey } = body; if (!host_url || !accesskey || !secretkey) { throw new HttpException('Missing credentials', HttpStatus.BAD_REQUEST); @@ -54,112 +61,116 @@ export class TenableController { try { // This helps prevent double slashes in the resulting URL if host_url ends with a slash. - const fullUrl = `${host_url.replace(/\/$/, '')}/rest/currentUser`; - const result = await axios.get(fullUrl, { - headers: { - 'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}` - } - }); + const fullUrl = `${host_url.replace(/\/$/v, '')}/rest/currentUser`; + const result = await axios.get(fullUrl, { headers: { 'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}` } }); // Assign the Tenable credentials to the session - req.session.tenable = {host_url, accesskey, secretkey}; + request.session.tenable = { accesskey, host_url, secretkey }; // Return the authenticated user data // Note: result.data is already a plain object, no need to convert it. - return {success: true, user: result.data}; // Return plain object - } catch (err) { - if (axios.isAxiosError(err)) { - if (err.message.includes(TENABLE_CSP_NOT_SET)) { + return { success: true, user: result.data }; // Return plain object + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.message.includes(TENABLE_CSP_NOT_SET)) { throw new HttpException( { - status: HttpStatus.NOT_FOUND, + code: 'ERR_NETWORK', // custom application error code (optional) message: 'Tenable CSP not set', - code: 'ERR_NETWORK' // custom application error code (optional) + status: HttpStatus.NOT_FOUND, }, - HttpStatus.NOT_FOUND + HttpStatus.NOT_FOUND, ); - } else if (err.response?.status === HttpStatus.UNAUTHORIZED) { + } + if (error.response?.status === HttpStatus.UNAUTHORIZED) { throw new HttpException( { - status: HttpStatus.UNAUTHORIZED, + code: 'INVALID_CREDENTIALS', // custom application error code (optional) message: 'Invalid Tenable credentials', - code: 'INVALID_CREDENTIALS' // custom application error code (optional) - }, - HttpStatus.UNAUTHORIZED - ); - } else if (err.code === 'ECONNREFUSED') { - throw new HttpException( - { - status: HttpStatus.BAD_GATEWAY, - message: 'Tenable server is unreachable', - code: 'SERVER_UNREACHABLE' // custom app code + status: HttpStatus.UNAUTHORIZED, }, - HttpStatus.BAD_GATEWAY + HttpStatus.UNAUTHORIZED, ); - } else if (err.code === 'ENOTFOUND') { - throw new HttpException( - { - status: HttpStatus.BAD_REQUEST, - message: + } + switch (error.code) { + case 'ECONNREFUSED': { + throw new HttpException( + { + code: 'SERVER_UNREACHABLE', // custom app code + message: 'Tenable server is unreachable', + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + case 'ENOTFOUND': { + throw new HttpException( + { + code: 'INVALID_HOST_URL', // custom app code + message: 'Unable to resolve Tenable host URL to an IP address (possible DNS resolution on the hosting platform).', - code: 'INVALID_HOST_URL' // custom app code - }, - HttpStatus.BAD_REQUEST - ); - } else if (err.code === 'ETIMEDOUT') { - throw new HttpException( - { - status: HttpStatus.REQUEST_TIMEOUT, - message: 'Tenable server took too long to respond', - code: 'CONNECTION_TIMEOUT' // custom application error code (optional) - }, - HttpStatus.REQUEST_TIMEOUT - ); - } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { - throw new HttpException( - { - status: HttpStatus.BAD_GATEWAY, - message: - 'SSL certificate verification failed while connecting to Tenable ' + - `(${host_url}). This may be due to an untrusted or incomplete TLS ` + - 'certificate chain.', - code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' - }, - HttpStatus.BAD_GATEWAY - ); - } else { - throw new HttpException( - { - status: err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, - message: - err.response?.data?.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' // Optional custom app code - }, - err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR - ); + status: HttpStatus.BAD_REQUEST, + }, + HttpStatus.BAD_REQUEST, + ); + } + case 'ETIMEDOUT': { + throw new HttpException( + { + code: 'CONNECTION_TIMEOUT', // custom application error code (optional) + message: 'Tenable server took too long to respond', + status: HttpStatus.REQUEST_TIMEOUT, + }, + HttpStatus.REQUEST_TIMEOUT, + ); + } + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': { + throw new HttpException( + { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + message: + 'SSL certificate verification failed while connecting to Tenable ' + + `(${host_url}). This may be due to an untrusted or incomplete TLS ` + + 'certificate chain.', + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + default: { + throw new HttpException( + { + code: 'TENABLE_PROXY_ERROR', // Optional custom app code + message: + error.response?.data?.message + || `Unexpected error connecting to Tenable ${host_url}`, + status: error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + }, + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } - } else if (err instanceof Error) { + } + if (error instanceof Error) { throw new HttpException( { - status: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'TENABLE_PROXY_ERROR', message: - err.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' - }, - HttpStatus.INTERNAL_SERVER_ERROR - ); - } else { - throw new HttpException( - { + error.message + || `Unexpected error connecting to Tenable ${host_url}`, status: HttpStatus.INTERNAL_SERVER_ERROR, - message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(err, null, 2)}`, - code: 'TENABLE_PROXY_ERROR' }, - HttpStatus.INTERNAL_SERVER_ERROR + HttpStatus.INTERNAL_SERVER_ERROR, ); } + throw new HttpException( + { + code: 'TENABLE_PROXY_ERROR', + message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(error, null, 2)}`, + status: HttpStatus.INTERNAL_SERVER_ERROR, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } @@ -175,60 +186,60 @@ export class TenableController { * @throws 404 if user session content is not available. * @throws 500 or the proxied error status if the proxy request fails. */ - async proxy(@Req() req: Request, @Res() res: Response) { + async proxy(@Req() request: Request, @Res() res: Response) { try { - const creds = req.session.tenable; + const creds = request.session.tenable; // If credentials are missing, user is not authenticated, send 401 Unauthorized. if (!creds) { - return res.status(401).json({error: 'Not authenticated with Tenable'}); + return res.status(401).json({ error: 'Not authenticated with Tenable' }); } // Forward the incoming request to the Tenable API using stored credentials. // Respond to the client with the status and data from Tenable's response or // handle any errors that occur during the proxy request. - const result = await this.tenableService.proxyRequest(req, creds); + const result = await this.tenableService.proxyRequest(request, creds); res.status(result.status).send(result.data); - } catch (err) { - const cspMsg = TENABLE_CSP_NOT_SET.replace('set', 'read').replace( + } catch (error) { + const cspMessage = TENABLE_CSP_NOT_SET.replace('set', 'read').replace( 'setting', - 'reading' + 'reading', ); - if (axios.isAxiosError(err)) { - if (err.message.includes(cspMsg)) { + if (axios.isAxiosError(error)) { + if (error.message.includes(cspMessage)) { throw new HttpException( { - status: HttpStatus.NOT_FOUND, + code: 'ERR_NETWORK', // custom application error code (optional) message: 'Tenable CSP not set', - code: 'ERR_NETWORK' // custom application error code (optional) + status: HttpStatus.NOT_FOUND, }, - HttpStatus.NOT_FOUND + HttpStatus.NOT_FOUND, ); } else { - const status = - err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; - const message = err.response?.data || 'Proxy error'; + const status + = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const message = error.response?.data || 'Proxy error'; res.status(status).send(message); } - } else if (err instanceof Error) { - if (err.message.includes(cspMsg)) { + } else if (error instanceof Error) { + if (error.message.includes(cspMessage)) { throw new HttpException( { - status: HttpStatus.NOT_FOUND, + code: 'ERR_NETWORK', message: 'Tenable CSP not set', - code: 'ERR_NETWORK' + status: HttpStatus.NOT_FOUND, }, - HttpStatus.NOT_FOUND + HttpStatus.NOT_FOUND, ); } else { const status = HttpStatus.INTERNAL_SERVER_ERROR; - const message = err.message || 'Proxy error'; + const message = error.message || 'Proxy error'; res.status(status).send(message); } } else { const status = HttpStatus.INTERNAL_SERVER_ERROR; - const message = `Proxy error: ${JSON.stringify(err, null, 2)}`; + const message = `Proxy error: ${JSON.stringify(error, null, 2)}`; res.status(status).send(message); } } diff --git a/apps/backend/src/tenable/tenable.module.ts b/apps/backend/src/tenable/tenable.module.ts index 528a3d6d8d..361cd1387b 100644 --- a/apps/backend/src/tenable/tenable.module.ts +++ b/apps/backend/src/tenable/tenable.module.ts @@ -1,6 +1,6 @@ -import {Module} from '@nestjs/common'; -import {TenableController} from './tenable.controller'; -import {TenableService} from './tenable.service'; +import { Module } from '@nestjs/common'; +import { TenableController } from './tenable.controller'; +import { TenableService } from './tenable.service'; // NestJS module definition for the Tenable proxy feature. // Registers the controller and service needed for routing Tenable requests. @@ -9,6 +9,6 @@ import {TenableService} from './tenable.service'; // Handles HTTP requests related to Tenable controllers: [TenableController], // Provides logic for proxying and interacting with Tenable API - providers: [TenableService] + providers: [TenableService], }) export class TenableModule {} diff --git a/apps/backend/src/tenable/tenable.service.ts b/apps/backend/src/tenable/tenable.service.ts index caeb4dc7d3..4fdfa060f3 100644 --- a/apps/backend/src/tenable/tenable.service.ts +++ b/apps/backend/src/tenable/tenable.service.ts @@ -1,39 +1,39 @@ -import {Injectable} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import axios from 'axios'; -import {Request} from 'express'; +import { Request } from 'express'; -interface TenableCredentials { - host_url: string; +type TenableCredentials = { accesskey: string; + host_url: string; secretkey: string; -} +}; // NestJS service that performs proxied requests to Tenable using credentials stored in the session @Injectable() export class TenableService { - async proxyRequest(req: Request, creds: TenableCredentials) { + async proxyRequest(request: Request, creds: TenableCredentials) { const axiosInstance = axios.create({ baseURL: creds.host_url, headers: { + 'Content-Type': request.get('content-type') || 'application/json', 'x-apikey': `accesskey=${creds.accesskey}; secretkey=${creds.secretkey}`, - 'Content-Type': req.get('content-type') || 'application/json' - } + }, }); - const method = req.method; - const url = req.originalUrl.replace('/api/tenable', ''); - const data = req.body; - const params = req.query; + const method = request.method; + const url = request.originalUrl.replace('/api/tenable', ''); + const data = request.body; + const parameters = request.query; return axiosInstance({ - method, - url, data, - params, + method, + params: parameters, responseType: - method === 'POST' && req.get('content-type')?.includes('zip') + method === 'POST' && request.get('content-type')?.includes('zip') ? 'arraybuffer' - : 'json' + : 'json', + url, }); } } diff --git a/apps/backend/src/token/token.module.ts b/apps/backend/src/token/token.module.ts index e246cf2631..b7b4fe2ec8 100644 --- a/apps/backend/src/token/token.module.ts +++ b/apps/backend/src/token/token.module.ts @@ -1,9 +1,9 @@ -import {Module} from '@nestjs/common'; -import {JwtModule} from '@nestjs/jwt'; -import {tokenProviders} from './token.providers'; +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { tokenProviders } from './token.providers'; @Module({ + exports: [JwtModule], imports: [...tokenProviders], - exports: [JwtModule] }) export class TokenModule {} diff --git a/apps/backend/src/token/token.providers.ts b/apps/backend/src/token/token.providers.ts index 7a79cefaa4..e628e51dcd 100644 --- a/apps/backend/src/token/token.providers.ts +++ b/apps/backend/src/token/token.providers.ts @@ -1,8 +1,8 @@ -import {JwtModule} from '@nestjs/jwt'; import * as crypto from 'crypto'; +import { JwtModule } from '@nestjs/jwt'; import ms from 'ms'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; export function generateDefault(): string { return crypto.randomBytes(64).toString('hex'); @@ -13,13 +13,11 @@ export function limitJWTTime(time: string, logLimit: boolean) { const maxDays = ms('2d'); // limit to two days if (timeMs > maxDays) { if (logLimit) { - // eslint-disable-next-line no-console console.log('JWT Expire time has been limited to two days maximum.'); } return maxDays; - } else { - return timeMs; } + return timeMs; } export const tokenProviders = [ @@ -31,9 +29,9 @@ export const tokenProviders = [ signOptions: { expiresIn: limitJWTTime( configService.get('JWT_EXPIRE_TIME') || '60s', - true - ) - } - }) - }) + true, + ), + }, + }), + }), ]; diff --git a/apps/backend/src/users/dto/create-user.dto.ts b/apps/backend/src/users/dto/create-user.dto.ts index 9a75a0d463..08df0c1111 100644 --- a/apps/backend/src/users/dto/create-user.dto.ts +++ b/apps/backend/src/users/dto/create-user.dto.ts @@ -1,18 +1,15 @@ -import {ICreateUser} from '@heimdall/common/interfaces'; -import {IsEmail, IsIn, IsNotEmpty, IsOptional, IsString} from 'class-validator'; +import { ICreateUser } from '@heimdall/common/interfaces'; +import { IsEmail, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateUserDto implements ICreateUser { - @IsEmail() - @IsNotEmpty() - readonly email!: string; - @IsNotEmpty() @IsString() - readonly password!: string; + @IsIn(['local', 'ldap', 'github', 'gitlab', 'google', 'okta', 'ldap']) + readonly creationMethod!: string; + @IsEmail() @IsNotEmpty() - @IsString() - readonly passwordConfirmation!: string; + readonly email!: string; @IsOptional() @IsString() @@ -26,17 +23,20 @@ export class CreateUserDto implements ICreateUser { @IsString() readonly organization: string | undefined; - @IsOptional() + @IsNotEmpty() @IsString() - readonly title: string | undefined; + readonly password!: string; + + @IsNotEmpty() + @IsString() + readonly passwordConfirmation!: string; @IsNotEmpty() @IsString() @IsIn(['user']) readonly role!: string; - @IsNotEmpty() + @IsOptional() @IsString() - @IsIn(['local', 'ldap', 'github', 'gitlab', 'google', 'okta', 'ldap']) - readonly creationMethod!: string; + readonly title: string | undefined; } diff --git a/apps/backend/src/users/dto/delete-user.dto.ts b/apps/backend/src/users/dto/delete-user.dto.ts index dfb5055008..00ccc2c82f 100644 --- a/apps/backend/src/users/dto/delete-user.dto.ts +++ b/apps/backend/src/users/dto/delete-user.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteUser} from '@heimdall/common/interfaces'; -import {IsOptional, IsString, MinLength} from 'class-validator'; +import { IDeleteUser } from '@heimdall/common/interfaces'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class DeleteUserDto implements IDeleteUser { @IsOptional() diff --git a/apps/backend/src/users/dto/slim-user.dto.ts b/apps/backend/src/users/dto/slim-user.dto.ts index 136d7b85c3..e4daf784b1 100644 --- a/apps/backend/src/users/dto/slim-user.dto.ts +++ b/apps/backend/src/users/dto/slim-user.dto.ts @@ -1,31 +1,31 @@ -import {ISlimUser} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; -import {User} from '../user.model'; +import { ISlimUser } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; +import { User } from '../user.model'; export class SlimUserDto implements ISlimUser { - @IsString() - readonly id: string; - @IsString() readonly email: string; @IsOptional() @IsString() - readonly title?: string; + readonly firstName?: string; @IsOptional() @IsString() readonly groupRole?: string; - @IsOptional() @IsString() - readonly firstName?: string; + readonly id: string; @IsOptional() @IsString() readonly lastName?: string; - constructor(user: User, groupRole: string | undefined = undefined) { + @IsOptional() + @IsString() + readonly title?: string; + + constructor(user: User, groupRole?: string) { this.id = user.id; this.email = user.email; this.title = user.title; diff --git a/apps/backend/src/users/dto/update-user.dto.ts b/apps/backend/src/users/dto/update-user.dto.ts index 00cbd5aad4..4e94c2cd14 100644 --- a/apps/backend/src/users/dto/update-user.dto.ts +++ b/apps/backend/src/users/dto/update-user.dto.ts @@ -1,7 +1,11 @@ -import {IUpdateUser} from '@heimdall/common/interfaces'; -import {IsBoolean, IsEmail, IsIn, IsOptional, IsString} from 'class-validator'; +import { IUpdateUser } from '@heimdall/common/interfaces'; +import { IsBoolean, IsEmail, IsIn, IsOptional, IsString } from 'class-validator'; export class UpdateUserDto implements IUpdateUser { + @IsOptional() + @IsString() + readonly currentPassword?: string; + @IsEmail() @IsOptional() readonly email: string | undefined; @@ -11,21 +15,16 @@ export class UpdateUserDto implements IUpdateUser { readonly firstName!: string | undefined; @IsOptional() - @IsString() - readonly lastName!: string | undefined; - - @IsOptional() - @IsString() - readonly organization!: string | undefined; + @IsBoolean() + readonly forcePasswordChange: boolean | undefined; @IsOptional() @IsString() - readonly title!: string | undefined; + readonly lastName!: string | undefined; @IsOptional() @IsString() - @IsIn(['user', 'admin']) - readonly role: string | undefined; + readonly organization!: string | undefined; @IsOptional() @IsString() @@ -36,10 +35,11 @@ export class UpdateUserDto implements IUpdateUser { readonly passwordConfirmation: string | undefined; @IsOptional() - @IsBoolean() - readonly forcePasswordChange: boolean | undefined; + @IsString() + @IsIn(['user', 'admin']) + readonly role: string | undefined; @IsOptional() @IsString() - readonly currentPassword?: string; + readonly title!: string | undefined; } diff --git a/apps/backend/src/users/dto/user.dto.ts b/apps/backend/src/users/dto/user.dto.ts index cb9247f7f3..7538f2c864 100644 --- a/apps/backend/src/users/dto/user.dto.ts +++ b/apps/backend/src/users/dto/user.dto.ts @@ -1,18 +1,18 @@ -import {IUser} from '@heimdall/common/interfaces'; -import {User} from '../user.model'; +import type { IUser } from '@heimdall/common/interfaces'; +import type { User } from '../user.model'; export class UserDto implements IUser { - id: string; + readonly createdAt: Date; + readonly creationMethod: string; readonly email: string; readonly firstName: string | undefined; + id: string; + readonly lastLogin: Date | undefined; readonly lastName: string | undefined; - readonly title: string | undefined; - readonly role: string; - readonly organization: string | undefined; readonly loginCount: number; - readonly lastLogin: Date | undefined; - readonly creationMethod: string; - readonly createdAt: Date; + readonly organization: string | undefined; + readonly role: string; + readonly title: string | undefined; readonly updatedAt: Date; constructor(user: User) { diff --git a/apps/backend/src/users/user.model.ts b/apps/backend/src/users/user.model.ts index 5a94eba76e..581e5f24f1 100644 --- a/apps/backend/src/users/user.model.ts +++ b/apps/backend/src/users/user.model.ts @@ -11,18 +11,21 @@ import { PrimaryKey, Table, Unique, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; @Table export class User extends Model { - @PrimaryKey - @AutoIncrement + @CreatedAt @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; + @Column(DataType.DATE) + declare createdAt: Date; + + @AllowNull(false) + @Column(DataType.STRING) + declare creationMethod: string; @Unique @IsEmail @@ -30,39 +33,48 @@ export class User extends Model { @Column(DataType.STRING) declare email: string; - @AllowNull(true) + @AllowNull(false) @Column(DataType.STRING) - declare firstName: string | undefined; + declare encryptedPassword: string; @AllowNull(true) @Column(DataType.STRING) - declare lastName: string | undefined; + declare firstName: string | undefined; @AllowNull(true) - @Column(DataType.STRING) - declare organization: string | undefined; + @Column(DataType.BOOLEAN) + declare forcePasswordChange: boolean | undefined; - @AllowNull(true) - @Column(DataType.STRING) - declare title: string | undefined; + @BelongsToMany(() => Group, () => GroupUser) + declare groups: (Group & { GroupUser: GroupUser })[]; + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column(DataType.STRING) - declare encryptedPassword: string; + @Column(DataType.BIGINT) + declare id: string; @AllowNull(true) - @Column(DataType.BOOLEAN) - declare forcePasswordChange: boolean | undefined; + @Column(DataType.STRING) + declare jwtSecret: string; @AllowNull(true) @Column(DataType.DATE) declare lastLogin: Date | undefined; + @AllowNull(true) + @Column(DataType.STRING) + declare lastName: string | undefined; + @AllowNull(false) @Default(0) @Column(DataType.BIGINT) declare loginCount: number; + @AllowNull(true) + @Column(DataType.STRING) + declare organization: string | undefined; + @AllowNull(true) @Column(DataType.DATE) declare passwordChangedAt: Date | undefined; @@ -72,24 +84,12 @@ export class User extends Model { @Column(DataType.STRING) declare role: string; - @AllowNull(false) - @Column(DataType.STRING) - declare creationMethod: string; - @AllowNull(true) @Column(DataType.STRING) - declare jwtSecret: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; + declare title: string | undefined; @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; - - @BelongsToMany(() => Group, () => GroupUser) - declare groups: Array; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 02aaebb2d8..b3674e7326 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -1,14 +1,15 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { BadRequestException, ForbiddenException, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {ValidationError} from 'sequelize'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { ValidationError } from 'sequelize'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_ADMIN_DTO, CREATE_USER_DTO_TEST_OBJ, @@ -20,24 +21,24 @@ import { DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, ID, UPDATE_USER_DTO_TEST_OBJ, - UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD + UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersController} from './users.controller'; -import {UsersService} from './users.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; // Test suite for the UsersController describe('UsersController Unit Tests', () => { @@ -63,15 +64,15 @@ describe('UsersController Unit Tests', () => { Group, GroupEvaluation, Evaluation, - EvaluationTag - ]) + EvaluationTag, + ]), ], providers: [ AuthzService, DatabaseService, UsersService, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); usersService = module.get(UsersService); @@ -99,7 +100,7 @@ describe('UsersController Unit Tests', () => { expect.assertions(1); expect( - await usersController.findUserById(basicUser.id, {user: basicUser}) + await usersController.findUserById(basicUser.id, { user: basicUser }), ).toEqual(new UserDto(await usersService.findById(basicUser.id))); }); @@ -107,9 +108,7 @@ describe('UsersController Unit Tests', () => { it('should test findById with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.findUserById(ID, {user: basicUser}); - }).rejects.toThrow(NotFoundException); + await expect(usersController.findUserById(ID, { user: basicUser })).rejects.toThrow(NotFoundException); }); }); @@ -118,11 +117,9 @@ describe('UsersController Unit Tests', () => { it('should list all users for an admin', async () => { expect.assertions(1); const serviceFoundUsers = (await usersService.adminFindAllUsers()).map( - (user) => new UserDto(user) + user => new UserDto(user), ); - const controllerFoundUsers = await usersController.adminFindAllUsers({ - user: adminUser - }); + const controllerFoundUsers = await usersController.adminFindAllUsers({ user: adminUser }); // In the case of admin, they should be equal becuase admin can see all expect(controllerFoundUsers).toEqual(serviceFoundUsers); }); @@ -135,10 +132,10 @@ describe('UsersController Unit Tests', () => { const createdUser = await usersController.create( CREATE_USER_DTO_TEST_OBJ_2, - {} + {}, ); expect(createdUser).toEqual( - new UserDto(await usersService.findById(createdUser.id)) + new UserDto(await usersService.findById(createdUser.id)), ); }); @@ -146,36 +143,30 @@ describe('UsersController Unit Tests', () => { it('should test the create function with missing email field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD, - {} - ); - }).rejects.toThrow(ValidationError); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD, + {}, + )).rejects.toThrow(ValidationError); }); // Tests the create function with dto that is missing password it('should test the create function with missing password field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, - {} - ); - }).rejects.toThrow(BadRequestException); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + {}, + )).rejects.toThrow(BadRequestException); }); // Tests the create function with dto that is missing passwordConfirmation it('should test the create function with missing password confirmation field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD, - {} - ); - }).rejects.toThrow(ValidationError); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD, + {}, + )).rejects.toThrow(ValidationError); }); }); @@ -186,7 +177,7 @@ describe('UsersController Unit Tests', () => { configService.set('REGISTRATION_DISABLED', 'true'); await expect( - usersController.create(CREATE_USER_DTO_TEST_OBJ_2, {}) + usersController.create(CREATE_USER_DTO_TEST_OBJ_2, {}), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -199,9 +190,9 @@ describe('UsersController Unit Tests', () => { expect( await usersController.update( basicUser.id, - {user: basicUser}, - UPDATE_USER_DTO_TEST_OBJ - ) + { user: basicUser }, + UPDATE_USER_DTO_TEST_OBJ, + ), ).toEqual(new UserDto(await usersService.findById(basicUser.id))); }); @@ -209,26 +200,22 @@ describe('UsersController Unit Tests', () => { it('should test update function with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.update( - ID, - {user: basicUser}, - UPDATE_USER_DTO_TEST_OBJ - ); - }).rejects.toThrow(NotFoundException); + await expect(usersController.update( + ID, + { user: basicUser }, + UPDATE_USER_DTO_TEST_OBJ, + )).rejects.toThrow(NotFoundException); }); // Tests the update function with dto that is missing currentPassword it('should test the update function with a dto that is missing currentPassword field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.update( - basicUser.id, - {user: basicUser}, - UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersController.update( + basicUser.id, + { user: basicUser }, + UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD, + )).rejects.toThrow(ForbiddenException); }); }); @@ -240,9 +227,9 @@ describe('UsersController Unit Tests', () => { expect( await usersController.remove( basicUser.id, - {user: basicUser}, - DELETE_USER_DTO_TEST_OBJ - ) + { user: basicUser }, + DELETE_USER_DTO_TEST_OBJ, + ), ).toEqual(new UserDto(basicUser)); }); @@ -250,26 +237,22 @@ describe('UsersController Unit Tests', () => { it('should test remove function with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.remove( - ID, - {user: adminUser}, - DELETE_USER_DTO_TEST_OBJ - ); - }).rejects.toThrow(NotFoundException); + await expect(usersController.remove( + ID, + { user: adminUser }, + DELETE_USER_DTO_TEST_OBJ, + )).rejects.toThrow(NotFoundException); }); // Tests the remove function with dto that is missing password it('should test remove function with a dto that is missing password field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.remove( - basicUser.id, - {user: basicUser}, - DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersController.remove( + basicUser.id, + { user: basicUser }, + DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, + )).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 906971830f..b72d18903d 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -12,26 +12,26 @@ import { UseFilters, UseGuards, UseInterceptors, - UsePipes + UsePipes, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {ConfigService} from '../config/config.service'; -import {UniqueConstraintErrorFilter} from '../filters/unique-constraint-error.filter'; -import {ImplicitAllowJwtAuthGuard} from '../guards/implicit-allow-jwt-auth.guard'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {TestGuard} from '../guards/test.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {PasswordChangePipe} from '../pipes/password-change.pipe'; -import {PasswordComplexityPipe} from '../pipes/password-complexity.pipe'; -import {PasswordsMatchPipe} from '../pipes/passwords-match.pipe'; -import {CreateUserDto} from './dto/create-user.dto'; -import {DeleteUserDto} from './dto/delete-user.dto'; -import {SlimUserDto} from './dto/slim-user.dto'; -import {UpdateUserDto} from './dto/update-user.dto'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersService} from './users.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { ConfigService } from '../config/config.service'; +import { UniqueConstraintErrorFilter } from '../filters/unique-constraint-error.filter'; +import { ImplicitAllowJwtAuthGuard } from '../guards/implicit-allow-jwt-auth.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { TestGuard } from '../guards/test.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { PasswordChangePipe } from '../pipes/password-change.pipe'; +import { PasswordComplexityPipe } from '../pipes/password-complexity.pipe'; +import { PasswordsMatchPipe } from '../pipes/passwords-match.pipe'; +import { CreateUserDto } from './dto/create-user.dto'; +import { DeleteUserDto } from './dto/delete-user.dto'; +import { SlimUserDto } from './dto/slim-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersService } from './users.service'; @UseInterceptors(LoggingInterceptor) @Controller('users') @@ -39,42 +39,25 @@ export class UsersController { constructor( private readonly usersService: UsersService, private readonly configService: ConfigService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get('/user-find-all') - @UseGuards(JwtAuthGuard) - async findAllUsers(@Request() request: {user: User}): Promise { - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.ReadSlim, User); - const users = await this.usersService.findAllUsers(); - return users.map((user) => new SlimUserDto(user)); - } - - @UseGuards(JwtAuthGuard) - @Get(':id') - async findUserById( - @Param('id') id: string, - @Request() request: {user: User} - ): Promise { - const user = await this.usersService.findById(id); - - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - - return new UserDto(user); - } - @Get() @UseGuards(JwtAuthGuard) async adminFindAllUsers( - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); ForbiddenError.from(abac).throwUnlessCan(Action.ReadAll, User); const users = await this.usersService.adminFindAllUsers(); - return users.map((user) => new UserDto(user)); + return users.map(user => new UserDto(user)); + } + + @UseGuards(TestGuard) + @Post('/clear') + async clear(): Promise { + User.truncate({ cascade: true }); } @Post() @@ -83,7 +66,7 @@ export class UsersController { @UseGuards(ImplicitAllowJwtAuthGuard) async create( @Body() createUserDto: CreateUserDto, - @Request() request: {user?: User} + @Request() request: { user?: User }, ): Promise { const abac = request.user ? this.authz.abac.createForUser(request.user) @@ -91,66 +74,83 @@ export class UsersController { // There should be no need to create users if user login is disabled if (!this.configService.isLocalLoginAllowed()) { throw new ForbiddenException( - 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.' + 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.', ); } // If registration is not allowed then validate the current user has the permission to bypass this check if (!this.configService.isRegistrationAllowed()) { ForbiddenError.from(abac) .setMessage( - 'User registration is disabled. Please ask your system administrator to create the account.' + 'User registration is disabled. Please ask your system administrator to create the account.', ) .throwUnlessCan(Action.ForceRegistration, User); } return new UserDto(await this.usersService.create(createUserDto)); } + @Get('/user-find-all') @UseGuards(JwtAuthGuard) - @Put(':id') - async update( + async findAllUsers(@Request() request: { user: User }): Promise { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.ReadSlim, User); + const users = await this.usersService.findAllUsers(); + return users.map(user => new SlimUserDto(user)); + } + + @UseGuards(JwtAuthGuard) + @Get(':id') + async findUserById( @Param('id') id: string, - @Request() request: {user: User}, - @Body( - new PasswordsMatchPipe(), - new PasswordChangePipe(), - new PasswordComplexityPipe() - ) - updateUserDto: UpdateUserDto + @Request() request: { user: User }, ): Promise { + const user = await this.usersService.findById(id); + const abac = this.authz.abac.createForUser(request.user); - const userToUpdate = await this.usersService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Update, userToUpdate); + ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - return new UserDto( - await this.usersService.update(userToUpdate, updateUserDto, abac) - ); + return new UserDto(user); + } + + @UseGuards(JwtAuthGuard) + @Post('/logout') + async logOut(@Request() request: { user: User }): Promise { + return this.usersService.updateUserSecret(request.user); } @UseGuards(JwtAuthGuard) @Delete(':id') async remove( @Param('id') id: string, - @Request() request: {user: User}, - @Body() deleteUserDto: DeleteUserDto + @Request() request: { user: User }, + @Body() deleteUserDto: DeleteUserDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const userToDelete = await this.usersService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Delete, userToDelete); return new UserDto( - await this.usersService.remove(userToDelete, deleteUserDto, abac) + await this.usersService.remove(userToDelete, deleteUserDto, abac), ); } @UseGuards(JwtAuthGuard) - @Post('/logout') - async logOut(@Request() request: {user: User}): Promise { - return this.usersService.updateUserSecret(request.user); - } + @Put(':id') + async update( + @Param('id') id: string, + @Request() request: { user: User }, + @Body( + new PasswordsMatchPipe(), + new PasswordChangePipe(), + new PasswordComplexityPipe(), + ) + updateUserDto: UpdateUserDto, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + const userToUpdate = await this.usersService.findByPkBang(id); + ForbiddenError.from(abac).throwUnlessCan(Action.Update, userToUpdate); - @UseGuards(TestGuard) - @Post('/clear') - async clear(): Promise { - User.truncate({cascade: true}); + return new UserDto( + await this.usersService.update(userToUpdate, updateUserDto, abac), + ); } } diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 687fee5cf3..77048d39b1 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -1,23 +1,23 @@ -import {forwardRef, Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; +import { forwardRef, Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; import { CryptoModule } from '../crypto/crypto.module'; -import {GroupsModule} from '../groups/groups.module'; -import {User} from './user.model'; -import {UsersController} from './users.controller'; -import {UsersService} from './users.service'; +import { GroupsModule } from '../groups/groups.module'; +import { User } from './user.model'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; @Module({ + controllers: [UsersController], + exports: [SequelizeModule, UsersService], imports: [ SequelizeModule.forFeature([User]), AuthzModule, ConfigModule, CryptoModule, - forwardRef(() => GroupsModule) + forwardRef(() => GroupsModule), ], providers: [UsersService], - controllers: [UsersController], - exports: [SequelizeModule, UsersService] }) export class UsersModule {} diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index f0c1c20275..9a2a0370a3 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -1,11 +1,11 @@ -import {Ability} from '@casl/ability'; +import type { Ability } from '@casl/ability'; import { BadRequestException, ForbiddenException, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; import { afterAll, beforeAll, @@ -13,9 +13,9 @@ import { describe, expect, it, - vi + vi, } from 'vitest'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_ADMIN_DTO, CREATE_SECOND_ADMIN_DTO, @@ -31,6 +31,7 @@ import { UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE, UPDATE_USER_DTO_TEST_OBJ, UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, UPDATE_USER_DTO_TEST_WITHOUT_EMAIL, UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, @@ -38,29 +39,28 @@ import { UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION, UPDATE_USER_DTO_TEST_WITHOUT_ROLE, UPDATE_USER_DTO_TEST_WITHOUT_TITLE, - UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - USER_ONE_DTO + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, + USER_ONE_DTO, } from '../../test/constants/users-test.constant'; -import {AuthzModule} from '../authz/authz.module'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigService} from '../config/config.service'; +import { AuthzModule } from '../authz/authz.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigService } from '../config/config.service'; import { CryptoModule } from '../crypto/crypto.module'; import type * as PasswordCrypto from '../crypto/password'; -import {hashPassword, verifyPassword} from '../crypto/password'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {SlimUserDto} from './dto/slim-user.dto'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersService} from './users.service'; +import { hashPassword, verifyPassword } from '../crypto/password'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { SlimUserDto } from './dto/slim-user.dto'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersService } from './users.service'; // Pass-through wrap so the FIPS-refuse test can steer ONE verifyPassword // result (real host FIPS state cannot be entered in CI — §10 it is host-level; @@ -68,7 +68,7 @@ import {UsersService} from './users.service'; // injected getFips). Every other call goes to the real implementation. vi.mock('../crypto/password', async (importOriginal) => { const actual = await importOriginal(); - return {...actual, verifyPassword: vi.fn(actual.verifyPassword)}; + return { ...actual, verifyPassword: vi.fn(actual.verifyPassword) }; }); // ADR-006 §2: exact prefix — algorithm AND iteration count pinned, never a @@ -80,8 +80,8 @@ describe('UsersService', () => { let authzService: AuthzService; let usersService: UsersService; let databaseService: DatabaseService; - const errorString = - 'User that was just created was not returned from the database. Create method may have failed silently.'; + const errorString + = 'User that was just created was not returned from the database. Create method may have failed silently.'; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -93,18 +93,18 @@ describe('UsersService', () => { Group, GroupEvaluation, Evaluation, - EvaluationTag + EvaluationTag, ]), AuthzModule, - CryptoModule + CryptoModule, ], providers: [ AuthzService, ConfigService, DatabaseService, UsersService, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); authzService = module.get(AuthzService); @@ -132,7 +132,7 @@ describe('UsersService', () => { expect(user.title).toEqual(USER_ONE_DTO.title); expect(user.organization).toEqual(USER_ONE_DTO.organization); expect(user.updatedAt.valueOf()).not.toBe( - USER_ONE_DTO.updatedAt.valueOf() + USER_ONE_DTO.updatedAt.valueOf(), ); expect(user.role).toEqual(USER_ONE_DTO.role); }); @@ -180,14 +180,14 @@ describe('UsersService', () => { it('should throw an error when missing the email field', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD), ).rejects.toThrow('notNull Violation: User.email cannot be null'); }); it('should throw an error when email field is invalid', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD), ).rejects.toThrow('Validation isEmail on email failed'); }); @@ -195,15 +195,15 @@ describe('UsersService', () => { expect.assertions(1); await expect( usersService.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).rejects.toThrow(BadRequestException); }); it('should throw an error when missing the role field', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE), ).rejects.toThrow('notNull Violation: User.role cannot be null'); }); }); @@ -214,7 +214,7 @@ describe('UsersService', () => { const userOne = await usersService.create(CREATE_USER_DTO_TEST_OBJ); const userTwo = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); const userDtoArray = (await usersService.adminFindAllUsers()).map( - (user) => new UserDto(user) + user => new UserDto(user), ); expect(userDtoArray).toContainEqual(new UserDto(userOne)); expect(userDtoArray).toContainEqual(new UserDto(userTwo)); @@ -227,7 +227,7 @@ describe('UsersService', () => { const userOne = await usersService.create(CREATE_USER_DTO_TEST_OBJ); const userTwo = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); const slimUserDtoArray = (await usersService.findAllUsers()).map( - (user) => new SlimUserDto(user) + user => new SlimUserDto(user), ); expect(slimUserDtoArray).toContainEqual(new SlimUserDto(userOne)); expect(slimUserDtoArray).toContainEqual(new SlimUserDto(userTwo)); @@ -252,7 +252,7 @@ describe('UsersService', () => { it('should throw an error if user does not exist', async () => { expect.assertions(1); await expect(usersService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); @@ -267,7 +267,7 @@ describe('UsersService', () => { expect(foundUser.lastName).toEqual(CREATE_USER_DTO_TEST_OBJ.lastName); expect(foundUser.title).toEqual(CREATE_USER_DTO_TEST_OBJ.title); expect(foundUser.organization).toEqual( - CREATE_USER_DTO_TEST_OBJ.organization + CREATE_USER_DTO_TEST_OBJ.organization, ); expect(foundUser.role).toEqual(CREATE_USER_DTO_TEST_OBJ.role); }); @@ -275,7 +275,7 @@ describe('UsersService', () => { it('should throw an error if user does not exist', async () => { expect.assertions(1); await expect( - usersService.findByEmail('doesnotexist@example.com') + usersService.findByEmail('doesnotexist@example.com'), ).rejects.toThrow(NotFoundException); }); }); @@ -295,9 +295,8 @@ describe('UsersService', () => { if (findUser === null || admin === null) { throw new TypeError(errorString); - } else { - user = findUser; } + user = findUser; userCreatedAt = user.updatedAt; abacPolicy = authzService.abac.createForUser(user); @@ -310,7 +309,7 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_OBJ, - abacPolicy + abacPolicy, ); expect(updatedUser.email).toEqual(UPDATE_USER_DTO_TEST_OBJ.email); @@ -318,28 +317,28 @@ describe('UsersService', () => { expect(updatedUser.lastName).toEqual(UPDATE_USER_DTO_TEST_OBJ.lastName); expect(updatedUser.title).toEqual(UPDATE_USER_DTO_TEST_OBJ.title); expect(updatedUser.organization).toEqual( - UPDATE_USER_DTO_TEST_OBJ.organization + UPDATE_USER_DTO_TEST_OBJ.organization, ); expect(updatedUser.role).toEqual(UPDATE_USER_DTO_TEST_OBJ.role); expect(updatedUser.email).not.toEqual(CREATE_USER_DTO_TEST_OBJ.email); expect(updatedUser.firstName).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.firstName + CREATE_USER_DTO_TEST_OBJ.firstName, ); expect(updatedUser.lastName).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.lastName + CREATE_USER_DTO_TEST_OBJ.lastName, ); expect(updatedUser.title).not.toEqual(CREATE_USER_DTO_TEST_OBJ.title); expect(updatedUser.organization).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.organization + CREATE_USER_DTO_TEST_OBJ.organization, ); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); // This will not change currently because there is only a 'user' role that can be updated via API. expect(updatedUser.role).toEqual(user.role); expect(user.forcePasswordChange).toEqual( - UPDATE_USER_DTO_TEST_OBJ.forcePasswordChange + UPDATE_USER_DTO_TEST_OBJ.forcePasswordChange, ); }); @@ -393,12 +392,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_EMAIL, - abacPolicy + abacPolicy, ); expect(updatedUser.email).toEqual(CREATE_USER_DTO_TEST_OBJ.email); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -408,12 +407,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME, - abacPolicy + abacPolicy, ); expect(updatedUser.firstName).toEqual(user.firstName); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -423,12 +422,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_LAST_NAME, - abacPolicy + abacPolicy, ); expect(updatedUser.lastName).toEqual(user.lastName); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -438,12 +437,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION, - abacPolicy + abacPolicy, ); expect(updatedUser.organization).toEqual(user.organization); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -453,12 +452,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_TITLE, - abacPolicy + abacPolicy, ); expect(updatedUser.title).toEqual(user.title); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -468,12 +467,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_ROLE, - abacPolicy + abacPolicy, ); expect(updatedUser.role).toEqual(user.role); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -483,35 +482,35 @@ describe('UsersService', () => { const updateUserDto = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, - abacPolicy + abacPolicy, ); const updateUser = await usersService.findByPkBang(updateUserDto.id); expect(updateUserDto.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); expect(updateUser.forcePasswordChange).toEqual(user.forcePasswordChange); }); it('should update a user without updating password', async () => { expect.assertions(8); - const {encryptedPassword} = user; + const { encryptedPassword } = user; await usersService.update( user, UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, - abacPolicy + abacPolicy, ); expect(user.email).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.email); expect(user.firstName).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.firstName + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.firstName, ); expect(user.lastName).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.lastName + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.lastName, ); expect(user.organization).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.organization + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.organization, ); expect(user.title).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.title); expect(user.role).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.role); @@ -521,18 +520,18 @@ describe('UsersService', () => { it('should update a user without matching password when admin', async () => { expect.assertions(2); - const {encryptedPassword} = user; + const { encryptedPassword } = user; const updateUser = await usersService.update( user, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - adminAbacPolicy + adminAbacPolicy, ); expect(user.encryptedPassword).not.toEqual(encryptedPassword); expect(updateUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -542,8 +541,8 @@ describe('UsersService', () => { usersService.update( user, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(ForbiddenException); }); @@ -553,8 +552,8 @@ describe('UsersService', () => { usersService.update( user, UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow('Validation error: Validation isEmail on email failed'); }); @@ -563,21 +562,21 @@ describe('UsersService', () => { await usersService.update( user, UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE, - abacPolicy + abacPolicy, ); await expect( usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(BadRequestException); }); describe('UpdateLoginMetadata', () => { it('should update user lastLogin and loginCount', async () => { expect.assertions(2); - const {lastLogin} = user; + const { lastLogin } = user; await usersService.updateLoginMetadata(user); @@ -601,10 +600,9 @@ describe('UsersService', () => { if (userResponse === null || adminResponse === null) { throw new TypeError(errorString); - } else { - user = userResponse; - adminUser = adminResponse; } + user = userResponse; + adminUser = adminResponse; abacPolicy = authzService.abac.createForUser(user); adminAbacPolicy = authzService.abac.createForUser(adminResponse); @@ -613,7 +611,7 @@ describe('UsersService', () => { it('should throw an error when password fields do not match', async () => { expect.assertions(1); await expect( - usersService.remove(user, DELETE_FAILURE_USER_DTO_TEST_OBJ, abacPolicy) + usersService.remove(user, DELETE_FAILURE_USER_DTO_TEST_OBJ, abacPolicy), ).rejects.toThrow(ForbiddenException); }); @@ -624,8 +622,8 @@ describe('UsersService', () => { usersService.remove( user, DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(ForbiddenException); }); @@ -637,17 +635,17 @@ describe('UsersService', () => { // fixture that ever loses its password fails this test loudly. await user.update({ encryptedPassword: await hashPassword( - DELETE_USER_DTO_TEST_OBJ.password ?? '' - ) + DELETE_USER_DTO_TEST_OBJ.password ?? '', + ), }); const removedUser = await usersService.remove( user, DELETE_USER_DTO_TEST_OBJ, - abacPolicy + abacPolicy, ); expect(removedUser.email).toEqual(user.email); await expect(usersService.findByEmail(user.email)).rejects.toThrow( - NotFoundException + NotFoundException, ); }); @@ -660,10 +658,10 @@ describe('UsersService', () => { vi.mocked(verifyPassword).mockResolvedValueOnce({ needsRehash: false, requiresReset: true, - valid: false + valid: false, }); await expect( - usersService.remove(user, DELETE_USER_DTO_TEST_OBJ, abacPolicy) + usersService.remove(user, DELETE_USER_DTO_TEST_OBJ, abacPolicy), ).rejects.toThrow(ForbiddenException); expect(verifyPassword).toHaveBeenCalled(); }); @@ -672,7 +670,7 @@ describe('UsersService', () => { const removedUser = await usersService.remove( user, DELETE_USER_DTO_TEST_OBJ, - abacPolicy + abacPolicy, ); expect.assertions(7); expect(removedUser.email).toEqual(user.email); @@ -682,7 +680,7 @@ describe('UsersService', () => { expect(removedUser.title).toEqual(user.title); expect(removedUser.role).toEqual(user.role); await expect(usersService.findByEmail(user.email)).rejects.toThrow( - NotFoundException + NotFoundException, ); }); @@ -690,7 +688,7 @@ describe('UsersService', () => { const removedUser = await usersService.remove( user, DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy + adminAbacPolicy, ); expect.assertions(7); expect(removedUser.email).toEqual(user.email); @@ -700,7 +698,7 @@ describe('UsersService', () => { expect(removedUser.title).toEqual(user.title); expect(removedUser.role).toEqual(user.role); await expect(usersService.findByEmail(user.email)).rejects.toThrow( - NotFoundException + NotFoundException, ); }); @@ -713,31 +711,27 @@ describe('UsersService', () => { await usersService.remove( adminUser, DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy + adminAbacPolicy, ); // Make sure the existing admin has been deleted - await expect(async () => { - await usersService.findById(adminUser.id); - }).rejects.toThrow(NotFoundException); + await expect(usersService.findById(adminUser.id)).rejects.toThrow(NotFoundException); }); // Admins should not be able to remove their account if they are the only administrator it('should test remove function with admin user that is the only admin', async () => { expect.assertions(1); - await expect(async () => { - await usersService.remove( - adminUser, - DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersService.remove( + adminUser, + DELETE_USER_DTO_TEST_OBJ, + adminAbacPolicy, + )).rejects.toThrow(ForbiddenException); }); // Admins should be able to remove other users without their password it('should test remove function with admin user and a dto that has no password', async () => { expect( - new UserDto(await usersService.remove(user, {}, adminAbacPolicy)) + new UserDto(await usersService.remove(user, {}, adminAbacPolicy)), ).toEqual(new UserDto(user)); }); }); @@ -761,7 +755,7 @@ describe('UsersService', () => { user = created; // Seed a known stored hash directly (bypassing hashing — this card is // persistence only). silent so the baseline updatedAt is stable. - await user.update({encryptedPassword: ORIGINAL}, {silent: true}); + await user.update({ encryptedPassword: ORIGINAL }, { silent: true }); }); it('returns 0 and writes nothing when the stored hash no longer matches originalHash', async () => { @@ -769,7 +763,7 @@ describe('UsersService', () => { const affected = await usersService.updateEncryptedPassword( user.id, 'a-stale-hash-that-does-not-match', - NEW + NEW, ); expect(affected).toBe(0); const reloaded = await User.findByPk(user.id); @@ -780,7 +774,7 @@ describe('UsersService', () => { const affected = await usersService.updateEncryptedPassword( user.id, ORIGINAL, - NEW + NEW, ); expect(affected).toBe(1); const reloaded = await User.findByPk(user.id); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 16d6b65fdd..bf21d1c94c 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,23 +1,23 @@ -import {Ability} from '@casl/ability'; +import { Ability } from '@casl/ability'; import { BadRequestException, ForbiddenException, Injectable, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions} from 'sequelize'; -import {v4} from 'uuid'; -import {AuthnService} from '../authn/authn.service'; -import {Action} from '../casl/casl-ability.factory'; -import {ConfigService} from '../config/config.service'; -import {verifyPassword} from '../crypto/password'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions } from 'sequelize'; +import { v4 } from 'uuid'; +import { AuthnService } from '../authn/authn.service'; +import { Action } from '../casl/casl-ability.factory'; +import { ConfigService } from '../config/config.service'; +import { verifyPassword } from '../crypto/password'; import { PasswordService } from '../crypto/password.service'; -import {GroupsService} from '../groups/groups.service'; -import {CreateUserDto} from './dto/create-user.dto'; -import {DeleteUserDto} from './dto/delete-user.dto'; -import {UpdateUserDto} from './dto/update-user.dto'; -import {User} from './user.model'; +import { GroupsService } from '../groups/groups.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { DeleteUserDto } from './dto/delete-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { User } from './user.model'; @Injectable() export class UsersService { @@ -26,35 +26,17 @@ export class UsersService { private readonly userModel: typeof User, private readonly configService: ConfigService, private readonly groupsService: GroupsService, - private readonly passwordService: PasswordService + private readonly passwordService: PasswordService, ) {} async adminFindAllUsers(): Promise { return this.userModel.findAll(); } - async findAllUsers(): Promise { - return this.userModel.findAll({ - attributes: ['id', 'email', 'title', 'firstName', 'lastName'] - }); - } - async count(): Promise { return this.userModel.count(); } - async findById(id: string): Promise { - return this.findByPkBang(id); - } - - async findByEmail(email: string): Promise { - return this.findOneBang({ - where: { - email - } - }); - } - async create(createUserDto: CreateUserDto): Promise { const user = new User(); user.email = createUserDto.email; @@ -68,7 +50,7 @@ export class UsersService { // ADR-006 §4 site 1: PBKDF2 via the validated module, PHC output (§2). // PasswordHashError (missing password, over-cap length) maps to 400. user.encryptedPassword = await this.passwordService.hash( - createUserDto.password + createUserDto.password, ); } catch { throw new BadRequestException(); @@ -76,28 +58,99 @@ export class UsersService { return user.save(); } + async findAllUsers(): Promise { + return this.userModel.findAll({ attributes: ['id', 'email', 'title', 'firstName', 'lastName'] }); + } + + async findByEmail(email: string): Promise { + return this.findOneBang({ where: { email } }); + } + + async findById(id: string): Promise { + return this.findByPkBang(id); + } + + async findByPkBang( + identifier: Buffer | number | string | undefined, + ): Promise { + const user = await this.userModel.findByPk(identifier); + if (user === null) { + throw new NotFoundException('User with given id not found'); + } + return user; + } + + async findOneBang(options: FindOptions | undefined): Promise { + const user = await this.userModel.findOne(options); + if (user === null) { + throw new NotFoundException('User with given id not found'); + } + return user; + } + + async remove( + userToDelete: User, + deleteUserDto: DeleteUserDto, + abac: Ability, + ): Promise { + if (abac.cannot(Action.DeleteNoPassword, userToDelete)) { + // Site 3 (ADR-006 §4): verify-only — consumes .valid alone, never + // rehashes. Handles PBKDF2 and legacy bcrypt; refuses bcrypt under + // FIPS like any failed verification. + const { valid } = await verifyPassword({ + hash: userToDelete.encryptedPassword, + password: deleteUserDto.password || '', + }); + if (!valid) { + throw new ForbiddenException( + 'Password was incorrect, could not delete account', + ); + } + } + + const adminCount = await this.userModel.count({ where: { role: 'admin' } }); + // Do not allow the administrator to destroy the only + // administrator account + if (userToDelete.role === 'admin' && adminCount < 2) { + throw new ForbiddenException( + 'Cannot destroy only administrator account, please promote another user to administrator first', + ); + } + // Clean up groups owned by user + await Promise.all( + (await this.groupsService.findAll()).map(async (group) => { + if (group.users.some(user => user.id === userToDelete.id)) { + await this.groupsService.ensureGroupHasOwner(group, userToDelete); + } + }), + ); + await userToDelete.destroy(); + return userToDelete; + } + async update( userToUpdate: User, updateUserDto: UpdateUserDto, - abac: Ability + abac: Ability, ): Promise { if (!abac.can('update-no-password', userToUpdate)) { await AuthnService.prototype.testPassword(updateUserDto, userToUpdate); } if ( - (updateUserDto.password === undefined || - updateUserDto.password === null) && - userToUpdate.forcePasswordChange && - !abac.can('skip-force-password-change', userToUpdate) + (updateUserDto.password === undefined + || updateUserDto.password === null) + && userToUpdate.forcePasswordChange + && !abac.can('skip-force-password-change', userToUpdate) ) { throw new BadRequestException('You must change your password'); - } else if (updateUserDto.password) { + } + if (updateUserDto.password) { try { // ADR-006 §4 site 2: PBKDF2 via the validated module, PHC output // (§2). Over-cap length (§6 approved range) maps to 400, matching // create(); bcryptjs silently truncated at 72 bytes instead. userToUpdate.encryptedPassword = await this.passwordService.hash( - updateUserDto.password + updateUserDto.password, ); } catch { throw new BadRequestException(); @@ -109,28 +162,17 @@ export class UsersService { userToUpdate.firstName = updateUserDto.firstName || userToUpdate.firstName; userToUpdate.lastName = updateUserDto.lastName || userToUpdate.lastName; userToUpdate.title = updateUserDto.title || userToUpdate.title; - userToUpdate.organization = - updateUserDto.organization || userToUpdate.organization; + userToUpdate.organization + = updateUserDto.organization || userToUpdate.organization; if (abac.can('update-role', userToUpdate)) { // Only admins can update roles userToUpdate.role = updateUserDto.role || userToUpdate.role; } - userToUpdate.forcePasswordChange = - updateUserDto.forcePasswordChange || userToUpdate.forcePasswordChange; + userToUpdate.forcePasswordChange + = updateUserDto.forcePasswordChange || userToUpdate.forcePasswordChange; return userToUpdate.save(); } - async updateLoginMetadata(user: User): Promise { - user.lastLogin = new Date(); - user.loginCount++; - await user.save(); - } - - async updateUserSecret(user: User): Promise { - user.jwtSecret = v4(); - await user.save(); - } - /** * ADR-006 §7: narrow compare-and-swap writer for lazy password rehash. * Rewrites encryptedPassword ONLY, and only while the stored value still @@ -145,76 +187,27 @@ export class UsersService { async updateEncryptedPassword( userId: string, originalHash: string, - newHash: string + newHash: string, ): Promise { const [affected] = await this.userModel.update( - {encryptedPassword: newHash}, + { encryptedPassword: newHash }, { - where: {id: userId, encryptedPassword: originalHash}, fields: ['encryptedPassword'], - silent: true - } + silent: true, + where: { encryptedPassword: originalHash, id: userId }, + }, ); return affected; } - async remove( - userToDelete: User, - deleteUserDto: DeleteUserDto, - abac: Ability - ): Promise { - if (abac.cannot(Action.DeleteNoPassword, userToDelete)) { - // Site 3 (ADR-006 §4): verify-only — consumes .valid alone, never - // rehashes. Handles PBKDF2 and legacy bcrypt; refuses bcrypt under - // FIPS like any failed verification. - const {valid} = await verifyPassword({ - hash: userToDelete.encryptedPassword, - password: deleteUserDto.password || '' - }); - if (!valid) { - throw new ForbiddenException( - 'Password was incorrect, could not delete account' - ); - } - } - - const adminCount = await this.userModel.count({where: {role: 'admin'}}); - // Do not allow the administrator to destroy the only - // administrator account - if (userToDelete.role === 'admin' && adminCount < 2) { - throw new ForbiddenException( - 'Cannot destroy only administrator account, please promote another user to administrator first' - ); - } - // Clean up groups owned by user - await Promise.all( - (await this.groupsService.findAll()).map(async (group) => { - if (group.users.some((user) => user.id === userToDelete.id)) { - await this.groupsService.ensureGroupHasOwner(group, userToDelete); - } - }) - ); - await userToDelete.destroy(); - return userToDelete; - } - - async findByPkBang( - identifier: string | number | Buffer | undefined - ): Promise { - const user = await this.userModel.findByPk(identifier); - if (user === null) { - throw new NotFoundException('User with given id not found'); - } else { - return user; - } + async updateLoginMetadata(user: User): Promise { + user.lastLogin = new Date(); + user.loginCount++; + await user.save(); } - async findOneBang(options: FindOptions | undefined): Promise { - const user = await this.userModel.findOne(options); - if (user === null) { - throw new NotFoundException('User with given id not found'); - } else { - return user; - } + async updateUserSecret(user: User): Promise { + user.jwtSecret = v4(); + await user.save(); } } diff --git a/apps/backend/test/constants/evaluation-tags-test.constant.ts b/apps/backend/test/constants/evaluation-tags-test.constant.ts index 9f4d6161fe..8f7008d5ed 100644 --- a/apps/backend/test/constants/evaluation-tags-test.constant.ts +++ b/apps/backend/test/constants/evaluation-tags-test.constant.ts @@ -1,34 +1,30 @@ -import {CreateEvaluationTagDto} from '../../src/evaluation-tags/dto/create-evaluation-tag.dto'; -import {EvaluationTagDto} from '../../src/evaluation-tags/dto/evaluation-tag.dto'; -import {EvaluationTag} from '../../src/evaluation-tags/evaluation-tag.model'; +import type { CreateEvaluationTagDto } from '../../src/evaluation-tags/dto/create-evaluation-tag.dto'; +import type { EvaluationTagDto } from '../../src/evaluation-tags/dto/evaluation-tag.dto'; +import type { EvaluationTag } from '../../src/evaluation-tags/evaluation-tag.model'; /* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-ignore export const EVALUATION_TAG_1: EvaluationTag = { + evaluationId: '1', value: 'value string', - evaluationId: '1' }; export const EVALUATION_TAG_DTO: EvaluationTagDto = { + createdAt: new Date(), + evaluationId: '1', id: '10001', + updatedAt: new Date(), value: 'value string', - evaluationId: '1', - createdAt: new Date(), - updatedAt: new Date() }; -export const CREATE_EVALUATION_TAG_DTO: CreateEvaluationTagDto = { - value: 'value string' -}; +export const CREATE_EVALUATION_TAG_DTO: CreateEvaluationTagDto = { value: 'value string' }; // @ts-ignore -export const CREATE_EVALUATION_TAG_DTO_MISSING_KEY: CreateEvaluationTagDto = { - value: 'value string' -}; +export const CREATE_EVALUATION_TAG_DTO_MISSING_KEY: CreateEvaluationTagDto = { value: 'value string' }; // @ts-ignore -export const CREATE_EVALUATION_TAG_DTO_MISSING_VALUE: CreateEvaluationTagDto = - {}; +export const CREATE_EVALUATION_TAG_DTO_MISSING_VALUE: CreateEvaluationTagDto + = {}; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/constants/evaluations-test.constant.ts b/apps/backend/test/constants/evaluations-test.constant.ts index 3327ccdfca..aa45c23d03 100644 --- a/apps/backend/test/constants/evaluations-test.constant.ts +++ b/apps/backend/test/constants/evaluations-test.constant.ts @@ -1,75 +1,63 @@ -import {CreateEvaluationDto} from '../../src/evaluations/dto/create-evaluation.dto'; -import {EvaluationDto} from '../../src/evaluations/dto/evaluation.dto'; -import {UpdateEvaluationDto} from '../../src/evaluations/dto/update-evaluation.dto'; -import {Evaluation} from '../../src/evaluations/evaluation.model'; -import {CREATE_EVALUATION_TAG_DTO} from './evaluation-tags-test.constant'; +import type { CreateEvaluationDto } from '../../src/evaluations/dto/create-evaluation.dto'; +import type { EvaluationDto } from '../../src/evaluations/dto/evaluation.dto'; +import type { UpdateEvaluationDto } from '../../src/evaluations/dto/update-evaluation.dto'; +import type { Evaluation } from '../../src/evaluations/evaluation.model'; +import { CREATE_EVALUATION_TAG_DTO } from './evaluation-tags-test.constant'; /* eslint-disable @typescript-eslint/ban-ts-comment */ const DEFAULT_FILE_NAME = 'example-result.json'; // @ts-ignore export const EVALUATION_1: CreateEvaluationDto = { + evaluationTags: [], filename: DEFAULT_FILE_NAME, - evaluationTags: [] }; // @ts-ignore export const EVALUATION_WITH_TAGS_1: CreateEvaluationDto = { + evaluationTags: [CREATE_EVALUATION_TAG_DTO], filename: DEFAULT_FILE_NAME, - evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore -export const CREATE_EVALUATION_DTO_WITHOUT_TAGS: CreateEvaluationDto = { - filename: DEFAULT_FILE_NAME -}; +export const CREATE_EVALUATION_DTO_WITHOUT_TAGS: CreateEvaluationDto = { filename: DEFAULT_FILE_NAME }; // @ts-ignore -export const CREATE_EVALUATION_DTO_WITHOUT_FILENAME: CreateEvaluationDto = { - evaluationTags: [CREATE_EVALUATION_TAG_DTO] -}; +export const CREATE_EVALUATION_DTO_WITHOUT_FILENAME: CreateEvaluationDto = { evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore export const CREATE_EVALUATION_DTO_WITHOUT_DATA: CreateEvaluationDto = { + evaluationTags: [CREATE_EVALUATION_TAG_DTO], filename: DEFAULT_FILE_NAME, - evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore export const UPDATE_EVALUATION: UpdateEvaluationDto = { - data: { - filename: DEFAULT_FILE_NAME - }, - filename: 'example-result-new.json' + data: { filename: DEFAULT_FILE_NAME }, + filename: 'example-result-new.json', }; // @ts-ignore -export const UPDATE_EVALUATION_FILENAME_ONLY: UpdateEvaluationDto = { - filename: 'example-result-new.json' -}; +export const UPDATE_EVALUATION_FILENAME_ONLY: UpdateEvaluationDto = { filename: 'example-result-new.json' }; // @ts-ignore -export const UPDATE_EVALUATION_DATA_ONLY: UpdateEvaluationDto = { - data: { - filename: DEFAULT_FILE_NAME - } -}; +export const UPDATE_EVALUATION_DATA_ONLY: UpdateEvaluationDto = { data: { filename: DEFAULT_FILE_NAME } }; // @ts-ignore export const EVALUATION_DTO: EvaluationDto = { - id: '9999', - filename: DEFAULT_FILE_NAME, - evaluationTags: [], createdAt: new Date(), - updatedAt: new Date() + evaluationTags: [], + filename: DEFAULT_FILE_NAME, + id: '9999', + updatedAt: new Date(), }; // @ts-ignore export const EVALUATION: Evaluation = { - id: '9999', - filename: DEFAULT_FILE_NAME, - evaluationTags: [], createdAt: new Date(), - updatedAt: new Date() + evaluationTags: [], + filename: DEFAULT_FILE_NAME, + id: '9999', + updatedAt: new Date(), }; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/constants/groups-test.constant.ts b/apps/backend/test/constants/groups-test.constant.ts index 9b7afec57f..e133471346 100644 --- a/apps/backend/test/constants/groups-test.constant.ts +++ b/apps/backend/test/constants/groups-test.constant.ts @@ -1,82 +1,82 @@ -import {Evaluation} from '../../src/evaluations/evaluation.model'; -import {GroupUser} from '../../src/group-users/group-user.model'; -import {CreateGroupDto} from '../../src/groups/dto/create-group.dto'; -import {UpdateGroupUserRoleDto} from '../../src/groups/dto/update-group-user.dto'; -import {Group} from '../../src/groups/group.model'; -import {User} from '../../src/users/user.model'; +import type { Evaluation } from '../../src/evaluations/evaluation.model'; +import type { GroupUser } from '../../src/group-users/group-user.model'; +import type { CreateGroupDto } from '../../src/groups/dto/create-group.dto'; +import type { UpdateGroupUserRoleDto } from '../../src/groups/dto/update-group-user.dto'; +import { Group } from '../../src/groups/group.model'; +import type { User } from '../../src/users/user.model'; export const GROUP_1 = { + desc: '', name: 'Heimdall Group', public: true, - desc: '' }; export const PRIVATE_GROUP = { + desc: 'Test description', name: 'Private Heimdall Group', public: false, - desc: 'Test description' }; export const UPDATE_GROUP: CreateGroupDto = { + desc: 'Updated test description', name: 'Updated Group', public: true, - desc: 'Updated test description' }; export const GROUPS_SERVICE_MOCK = { - async findAll(): Promise { - return []; - }, - async count(): Promise { - return 1; - }, - async findByPkBang(_id: string): Promise { - return new Group(); - }, - async findByIds(_id: string[]): Promise { - return []; + async addEvaluationToGroup( + _group: Group, + _evaluation: Evaluation, + ): Promise { + return; }, async addUserToGroup( _group: Group, _user: User, - _role: string + _role: string, ): Promise { return; }, - async updateGroupUserRole( - _group: Group, - _updateGroupUser: UpdateGroupUserRoleDto - ): Promise { - return undefined; + async count(): Promise { + return 1; }, - async removeUserFromGroup(group: Group, user: User): Promise { - return group.$remove('user', user); + async create(_createGroupDto: CreateGroupDto): Promise { + return new Group(); }, async ensureGroupHasOwner(): Promise { return; }, - async addEvaluationToGroup( - _group: Group, - _evaluation: Evaluation - ): Promise { - return; + async findAll(): Promise { + return []; + }, + async findByIds(_id: string[]): Promise { + return []; + }, + async findByPkBang(_id: string): Promise { + return new Group(); + }, + async remove(_groupToDelete: Group): Promise { + return new Group(); }, async removeEvaluationFromGroup( _group: Group, - _evaluation: Evaluation + _evaluation: Evaluation, ): Promise { return new Group(); }, - async create(_createGroupDto: CreateGroupDto): Promise { - return new Group(); + async removeUserFromGroup(group: Group, user: User): Promise { + return group.$remove('user', user); }, async update( _groupToUpdate: Group, - _groupDto: CreateGroupDto + _groupDto: CreateGroupDto, ): Promise { return new Group(); }, - async remove(_groupToDelete: Group): Promise { - return new Group(); - } + async updateGroupUserRole( + _group: Group, + _updateGroupUser: UpdateGroupUserRoleDto, + ): Promise { + return undefined; + }, }; diff --git a/apps/backend/test/constants/users-test.constant.ts b/apps/backend/test/constants/users-test.constant.ts index 35999256c9..91a8b68ce7 100644 --- a/apps/backend/test/constants/users-test.constant.ts +++ b/apps/backend/test/constants/users-test.constant.ts @@ -1,210 +1,210 @@ -import {MongoAbility} from '@casl/ability'; -import {FindOptions} from 'sequelize'; -import {CreateUserDto} from '../../src/users/dto/create-user.dto'; -import {DeleteUserDto} from '../../src/users/dto/delete-user.dto'; -import {UpdateUserDto} from '../../src/users/dto/update-user.dto'; -import {UserDto} from '../../src/users/dto/user.dto'; -import {User} from '../../src/users/user.model'; +import type { MongoAbility } from '@casl/ability'; +import type { FindOptions } from 'sequelize'; +import type { CreateUserDto } from '../../src/users/dto/create-user.dto'; +import type { DeleteUserDto } from '../../src/users/dto/delete-user.dto'; +import type { UpdateUserDto } from '../../src/users/dto/update-user.dto'; +import { UserDto } from '../../src/users/dto/user.dto'; +import { User } from '../../src/users/user.model'; /* eslint-disable @typescript-eslint/ban-ts-comment */ export const ID = '7'; -export const MINUTE_IN_MILLISECONDS = 60000; +export const MINUTE_IN_MILLISECONDS = 60_000; export const LOGIN_AUTHENTICATION = { email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP' + password: 'LETmeiN123$$$tP', }; export const LDAP_AUTHENTICATION = { + password: 'fry', username: 'fry', - password: 'fry' }; export const ADMIN_LOGIN_AUTHENTICATION = { email: 'admin@yahoo.com', - password: 'LETmeiN123$$$tP' + password: 'LETmeiN123$$$tP', }; export const BAD_LOGIN_AUTHENTICATION = { email: 'abc@yahoo.com', - password: 'Invalid_password' + password: 'Invalid_password', }; export const BAD_LDAP_AUTHENTICATION = { + password: 'zoiderg', username: 'fry', - password: 'zoiderg' }; export const SPLUNK_AUTHENTICATION = { - username: 'admin', + hostname: 'https://localhost:8089', password: 'Valid_password!', - hostname: 'https://localhost:8089' + username: 'admin', }; export const BAD_SPLUNK_AUTHENTICATION = { - username: 'admin', + hostname: 'https://localhost:8089', password: 'Invalid_password!', - hostname: 'https://localhost:8089' + username: 'admin', }; // @ts-ignore export const TEST_USER: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITH_ID: User = { ...TEST_USER, - id: '1' + id: '1', }; // @ts-ignore export const ADMIN: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'admin', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'admin', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const ADMIN_WITH_ID: User = { ...ADMIN, - id: '2' + id: '2', }; // @ts-ignore export const UPDATED_TEST_USER: User = { + createdAt: new Date(), email: 'updatedemail@yahoo.com', - firstName: 'Updated', - lastName: 'Name', - title: 'updated title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Updated Org', - loginCount: 0, + firstName: 'Updated', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Name', + loginCount: 0, + organization: 'Updated Org', + title: 'updated title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_EMAIL: User = { - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', + createdAt: new Date(), // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_FIRST_NAME: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_LAST_NAME: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_ORGANIZATION: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_TITLE: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITH_INVALID_ROLE: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'unknown', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'unknown', + updatedAt: new Date(), }; // @ts-ignore @@ -214,310 +214,310 @@ export const USER_ARRAY: User[] = [ // @ts-ignore TEST_USER_WITHOUT_FIRST_NAME, // @ts-ignore - UPDATED_TEST_USER + UPDATED_TEST_USER, ]; export const CREATE_USER_DTO_TEST_OBJ: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; export const CREATE_ADMIN_DTO: CreateUserDto = { + creationMethod: 'local', email: 'admin@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'Admin', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'admin', - creationMethod: 'local' + title: 'Admin', }; export const CREATE_SECOND_ADMIN_DTO: CreateUserDto = { ...CREATE_ADMIN_DTO, - email: 'admin2@yahoo.com' + email: 'admin2@yahoo.com', }; export const CREATE_USER_DTO_TEST_OBJ_2: CreateUserDto = { + creationMethod: 'local', email: 'def@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; -export const CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123%%%tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123%%%tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_FIRST_NAME: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_LAST_NAME: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ORGANIZATION: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ORGANIZATION: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_TITLE: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD: CreateUserDto = - { - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD: CreateUserDto + = { + creationMethod: 'local', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'NotAValidEmail', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', - creationMethod: 'local' + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_PASSWORD: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'InvalidPass1', - passwordConfirmation: 'InvalidPass1', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'InvalidPass1', + passwordConfirmation: 'InvalidPass1', role: 'user', - creationMethod: 'local' + title: 'fake title', }; export const UPDATE_USER_DTO_TEST_OBJ: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'updatedemail@yahoo.com', firstName: 'Updated', + forcePasswordChange: true, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'LETmeiN123$$$tP', passwordConfirmation: 'LETmeiN123$$$tP', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: true + role: 'user', + title: 'updated title', }; export const UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Updated', + forcePasswordChange: false, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: false + role: 'user', + title: 'updated title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_EMAIL: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'NotAValidEmail', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_LAST_NAME: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_TITLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'updated@example.com', firstName: 'Updated', lastName: 'Updated', organization: 'Updated', - title: 'Updated', role: 'user', - currentPassword: 'LETmeiN123$$$tP' + title: 'Updated', }; // @ts-ignore @@ -526,108 +526,104 @@ export const UPDATE_USER_DTO_WITH_NO_CURRENT_PASSWORD: UpdateUserDto = { firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - passwordConfirmation: 'ABCdefG456!@#pT' + passwordConfirmation: 'ABCdefG456!@#pT', + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD: UpdateUserDto = { ...UPDATE_USER_DTO_WITH_NO_CURRENT_PASSWORD, - currentPassword: 'invalid_password' + currentPassword: 'invalid_password', }; // @ts-ignore export const UPDATE_USER_DTO_WITH_ADMIN_ROLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', role: 'admin', - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore -export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD_CONFIRMATION: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD_CONFIRMATION: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_ROLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + title: 'fake title', }; // @ts-ignore -export const UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'changed@yahoo.com', - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore export const UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', forcePasswordChange: true, - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITH_NOT_COMPLEX_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', password: 'Invalidpass1', passwordConfirmation: 'Invalidpass1', - currentPassword: 'LETmeiN123$$$tP' + title: 'fake title', }; -export const UPDATE_USER_DTO_TEST_OBJ_WITH_MISSMATCHING_PASSWORDS: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_OBJ_WITH_MISSMATCHING_PASSWORDS: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'updatedemail@yahoo.com', firstName: 'Updated', + forcePasswordChange: false, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'defABCg789*(%Pt', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: false + role: 'user', + title: 'updated title', }; // @ts-ignore -export const UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD: UpdateUserDto = - { +export const UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD: UpdateUserDto + = { email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - passwordConfirmation: 'ABCdefG456!@#pT' + passwordConfirmation: 'ABCdefG456!@#pT', + role: 'user', + title: 'fake title', }; -export const DELETE_USER_DTO_TEST_OBJ: DeleteUserDto = { - password: 'LETmeiN123$$$tP' -}; +export const DELETE_USER_DTO_TEST_OBJ: DeleteUserDto = { password: 'LETmeiN123$$$tP' }; -export const DELETE_FAILURE_USER_DTO_TEST_OBJ: DeleteUserDto = { - password: 'Invalid_password' -}; +export const DELETE_FAILURE_USER_DTO_TEST_OBJ: DeleteUserDto = { password: 'Invalid_password' }; // @ts-ignore export const DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD: DeleteUserDto = {}; @@ -645,15 +641,15 @@ export const UPDATED_USER_DTO = new UserDto(USER_ARRAY[2]); export const USER_DTO_WITHOUT_EMAIL = new UserDto(TEST_USER_WITHOUT_EMAIL); export const USER_DTO_WITHOUT_FIRST_NAME = new UserDto( - TEST_USER_WITHOUT_FIRST_NAME + TEST_USER_WITHOUT_FIRST_NAME, ); export const USER_DTO_WITHOUT_LAST_NAME = new UserDto( - TEST_USER_WITHOUT_LAST_NAME + TEST_USER_WITHOUT_LAST_NAME, ); export const USER_DTO_WITHOUT_ORGANIZATION = new UserDto( - TEST_USER_WITHOUT_ORGANIZATION + TEST_USER_WITHOUT_ORGANIZATION, ); export const USER_DTO_WITHOUT_TITLE = new UserDto(TEST_USER_WITHOUT_TITLE); @@ -664,49 +660,49 @@ export const USERS_SERVICE_MOCK = { async adminFindAllUsers(): Promise { return []; }, - async findAllUsers(): Promise { - return []; - }, async count(): Promise { return 1; }, - async findById(_id: string): Promise { + async create(_createUserDto: CreateUserDto): Promise { return new User(); }, + async findAllUsers(): Promise { + return []; + }, async findByEmail(_email: string): Promise { return new User(); }, - async create(_createUserDto: CreateUserDto): Promise { + async findById(_id: string): Promise { return new User(); }, - async update( - _userToUpdate: User, - _updateUserDto: UpdateUserDto, - _abac: MongoAbility + async findByPkBang( + _identifier: Buffer | number | string | undefined, ): Promise { return new User(); }, - async updateLoginMetadata(_user: User): Promise { - return; - }, - async updateUserSecret(_user: User): Promise { - return; + async findOneBang(_options: FindOptions | undefined): Promise { + return new User(); }, async remove( _userToDelete: User, _deleteUserDto: DeleteUserDto, - _abac: MongoAbility + _abac: MongoAbility, ): Promise { return new User(); }, - async findByPkBang( - _identifier: string | number | Buffer | undefined + async update( + _userToUpdate: User, + _updateUserDto: UpdateUserDto, + _abac: MongoAbility, ): Promise { return new User(); }, - async findOneBang(_options: FindOptions | undefined): Promise { - return new User(); - } + async updateLoginMetadata(_user: User): Promise { + return; + }, + async updateUserSecret(_user: User): Promise { + return; + }, }; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/tenable/README.md b/apps/backend/test/tenable/README.md index 29a67884b3..eb8de3badf 100644 --- a/apps/backend/test/tenable/README.md +++ b/apps/backend/test/tenable/README.md @@ -20,8 +20,11 @@ This project simulates a subset of the Tenable.sc REST API using [Prism](https:/ ```bash > npm install -g @stoplight/prism-cli ``` + ### 2. Run the Mock Server + Navigate to the folder containing tenable-sc-mock.yaml and run this command: + ```bash > prism mock tenable-sc-mock.yaml ``` @@ -29,19 +32,26 @@ Navigate to the folder containing tenable-sc-mock.yaml and run this command: Server starts on: `http://localhost:4010` ### 3. Example Requests (using curl) + ✅ Get Current User + ```bash > curl -X GET http://localhost:4010/rest/currentUser -H "x-apikey: accesskey=abc123; secretkey=def456" Note: The `accesskey` and `secretkey` in the curl command can be any string. ``` + ✅ Get Scan Results + ```bash > curl -G http://localhost:4010/rest/scanResult --data-urlencode "fields=name,description" --data-urlencode "startTime=2024-01-01" --data-urlencode "endTime=2024-02-01" -H "x-apikey: accesskey=abc123; secretkey=def456" ``` + ✅ Download Scan Result (binary response) + ```bash > curl -X POST "http://localhost:4010/rest/scanResult/1234/download?downloadType=v2" \ -H "x-apikey: accesskey=abc123; secretkey=def456" \ --output result.zip ``` + Note: This will return mocked binary content (e.g. a placeholder). \ No newline at end of file diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 2106d99e46..994e30c159 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -1,7 +1,10 @@ import swc from 'unplugin-swc'; -import {defineConfig} from 'vitest/config'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ + plugins: [ + swc.vite({ module: { type: 'es6' } }), + ], test: { // ADR-006 §12: the write gate's no-env derivation probes live DB state // (marker row, Users count), which would make every suite's hashing @@ -9,13 +12,8 @@ export default defineConfig({ // explicitly enabled; hash-write-gate.service.spec.ts manipulates // process.env per case to exercise the derivation itself. env: { PASSWORD_HASH_WRITE_ENABLED: 'true' }, - hookTimeout: 20000, - testTimeout: 20000, - fileParallelism: false + fileParallelism: false, + hookTimeout: 20_000, + testTimeout: 20_000, }, - plugins: [ - swc.vite({ - module: {type: 'es6'}, - }), - ], }); From 83f0ea5ebbf75d662c26e0df75be3fdbb609454f Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 19:20:48 -0400 Subject: [PATCH 052/197] fix(docs): document CYPRESS_TESTING and UV_THREADPOOL_SIZE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by independent AC review, which failed the card. Both are genuine omissions from a page that bills itself as the single source of truth. CYPRESS_TESTING is read by apps/backend/src/guards/test.guard.ts and is the only guard on POST /users/clear, which runs User.truncate({cascade: true}). It takes effect when NODE_ENV is development or test — and development is the value this very page tells developers to use. One undocumented variable therefore stands between a development instance and an unauthenticated route that empties user accounts. Documented with a danger callout rather than a table row alone. UV_THREADPOOL_SIZE sizes the libuv pool that PBKDF2 hashing runs on. It is set to 8 by the Dockerfile, cmd.sh and the systemd unit, and cmd.sh writes it as ${UV_THREADPOOL_SIZE:-8}, so operators can override it. ADR-006 §11 states that 600000 iterations "is only safe if UV_THREADPOOL_SIZE is raised" — the page documented the iteration count while omitting the variable that makes it safe, though it already documented NODE_EXTRA_CA_CERTS, the same class of Node-runtime setting. Root cause of the miss: the previous derivation was hand-typed into a heredoc, so the mechanical comm compared the page against a list this session authored and could only ever confirm its own omissions. CYPRESS_TESTING was present in the raw grep output and dropped during that transcription. The derivation is now a script (scratchpad/derive-env.mjs) that extracts names from source by pattern — dot access, bracket access, ConfigService.get, envConfig, and the readIntInRange-style indirect reads that the first sweep also missed. It reports 91 names read by code, 91 documented, with only INVALID_VARIABLE outstanding, which is a fixture in config.service.spec. Also strengthens the GitLab wiring test, per the same review. The tests asserted that getGitlabClientSecret was called and what it returned, but not that the value reached passport. Verified by mutation: routing the resolved secret to a different option key, with the getter still called, previously kept both tests green and now fails both. Verified: 347 backend tests pass, tsc exit 0, docs build passes, and the new callout was read in both light and dark mode. Authored by: Aaron Lippold --- .../backend/src/authn/gitlab.strategy.spec.ts | 22 ++++++++++++++++--- .../getting-started/environment-variables.md | 13 +++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/authn/gitlab.strategy.spec.ts b/apps/backend/src/authn/gitlab.strategy.spec.ts index 45806dfc40..5c487cf071 100644 --- a/apps/backend/src/authn/gitlab.strategy.spec.ts +++ b/apps/backend/src/authn/gitlab.strategy.spec.ts @@ -14,6 +14,14 @@ import { GitlabStrategy } from './gitlab.strategy'; // gitlab.strategy.ts to configService.get('GITLAB_SECRET') would leave every // resolution test green while GitLab OAuth stayed broken for anyone who // configured GITLAB_CLIENTSECRET from the documentation. +// passport-oauth2 hands the credential to its node-oauth client, which stores it +// as _clientSecret. Reading it is the only way to prove the resolved value +// reached passport rather than merely being computed. passport-gitlab2 ships no +// type definitions, so this needs no type assertion to reach. +function clientSecretGivenToPassport(strategy: GitlabStrategy): unknown { + return strategy._oauth2?._clientSecret; +} + async function buildStrategy(environmentFile: string): Promise<{ resolveSpy: ReturnType; strategy: GitlabStrategy; @@ -40,17 +48,25 @@ describe('GitlabStrategy', () => { restore(); }); - it('should resolve its client secret through getGitlabClientSecret', async () => { + it('should pass the canonical secret to passport as clientSecret', async () => { const { resolveSpy, strategy } = await buildStrategy( GITLAB_CANONICAL_SECRET_ENV, ); expect(strategy).toBeInstanceOf(GitlabStrategy); expect(resolveSpy).toHaveBeenCalledTimes(1); expect(resolveSpy).toHaveReturnedWith('canonical-secret'); + // Asserting the RESOLVED VALUE ARRIVED, not merely that the getter ran. + // Without this, a mutation routing the secret to a different option key + // (clientID, say) leaves the spy assertions above green while GitLab OAuth + // is broken. passport-oauth2 keeps the credential on its OAuth2 client. + expect(clientSecretGivenToPassport(strategy)).toEqual('canonical-secret'); }); - it('should resolve the legacy GITLAB_SECRET through the same path', async () => { - const { resolveSpy } = await buildStrategy(GITLAB_LEGACY_SECRET_ENV); + it('should pass the legacy GITLAB_SECRET to passport as clientSecret', async () => { + const { resolveSpy, strategy } = await buildStrategy( + GITLAB_LEGACY_SECRET_ENV, + ); expect(resolveSpy).toHaveReturnedWith('legacy-secret'); + expect(clientSecretGivenToPassport(strategy)).toEqual('legacy-secret'); }); }); diff --git a/docs/site/getting-started/environment-variables.md b/docs/site/getting-started/environment-variables.md index f325e0765b..d50b40719c 100644 --- a/docs/site/getting-started/environment-variables.md +++ b/docs/site/getting-started/environment-variables.md @@ -266,11 +266,24 @@ application itself. | Variable | Description | Where it applies | Default | | --- | --- | --- | --- | +| `CYPRESS_TESTING` | `true` enables the end-to-end test support route. See the warning below before setting it. | Development and test only | unset | +| `UV_THREADPOOL_SIZE` | Size of libuv's thread pool, which is what PBKDF2 hashing runs on. Read by the Node runtime, not by Heimdall. Set to `8` by the Dockerfile, `cmd.sh` and the systemd unit. Raising `PASSWORD_HASH_ITERATIONS` without a matching thread pool starves concurrent logins. | Any | `8` where Heimdall's own launchers apply, otherwise Node's default of `4` | | `NGINX_HOST` | Templated into the bundled NGINX configuration as `server_name`. Read by the setup scripts, never by the application. | Docker Compose, dev setup scripts | `localhost` | | `LOG_FILE` | When set, the launcher redirects stdout and stderr to this path. Unset means logging to journald. The directory must be writable by the `heimdall` user. | RPM only | unset (journald) | | `NODE_EXTRA_CA_CERTS` | Path to additional trusted CAs. Read by the Node runtime itself, not by Heimdall. Needed behind a TLS-inspecting proxy. | Any | none | | `API_PROXY_TARGET` | Backend URL the frontend dev server proxies to. Lives in `apps/frontend/.env.development`. Unset or empty means no proxy, and the frontend runs as standalone Heimdall Lite. | Development only | empty | +::: danger CYPRESS_TESTING unlocks an endpoint that deletes every user +Setting `CYPRESS_TESTING=true` while `NODE_ENV` is `development` or `test` +enables `POST /users/clear`, which truncates the `Users` table. Both conditions +must hold, and `development` is the value used for ordinary local work — so the +one variable is what stands between a development instance and an unauthenticated +route that empties user accounts. + +Set it only for an end-to-end test run, and never in an environment holding data +you care about. It has no effect when `NODE_ENV=production`. +::: + ## Documentation build These affect building this documentation site, not the application. From 564374ab6fbcea06e8fc9e3e6b45071d360793c3 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 20:14:00 -0400 Subject: [PATCH 053/197] feat(docs): write the getting-started section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "Section under construction" placeholder with four pages, written against the current code rather than migrated from the wiki. quick-start covers running locally from source, which is how this repository is actually developed. It leads with `npx @mitre/heimdall-lite` for anyone who only wants to view results, then `yarn install` and `yarn start:dev`. Two facts it states that are easy to get wrong: the frontend dev server has no fixed port — vue-cli starts at 8080 and increments, so the page tells you to read the URL it prints rather than naming one — and the backend binds 3000 unless PORT is set. It also records that this documentation is a separate project outside the Yarn workspaces, so `yarn start:dev` does not serve it and there is no /docs route yet. installation is an index, not a duplicate. It splits Lite from Server, then tables the four server methods and links each to the deployment section rather than restating steps. configuration explains the model and defers every variable to the environment-variables reference: where variables live per install method, that the process environment beats the file, that the file is resolved against the working directory, that an empty value is not a default, and that the database name is derived from NODE_ENV with no fallback. troubleshooting is traced to source throughout. The two independent upload limits — MAX_FILE_UPLOAD_SIZE and a separate hard-coded 50mb JSON body cap — explain why raising the variable past 50 does not help. The CSP connect-src is assembled at startup from TENABLE_HOST_URL and SPLUNK_HOST_URL, so changing either without restarting leaves the browser blocking the connection. And upgrade-insecure-requests is deliberately absent (issue 787), so plain HTTP works and Heimdall's CSP is not the cause when something forces HTTPS. Corrects Node guidance while doing it: this codebase requires Node 22.18.0 or newer with .nvmrc pinned to 22, not the Node 18 the README and wiki still describe. No .bat scripts are referenced — none exist in the repository. Authored by: Aaron Lippold --- docs/.vitepress/config.mjs | 14 ++ docs/site/getting-started/configuration.md | 107 ++++++++++++ docs/site/getting-started/index.md | 50 ++++-- docs/site/getting-started/installation.md | 74 +++++++++ docs/site/getting-started/quick-start.md | 164 +++++++++++++++++++ docs/site/getting-started/troubleshooting.md | 136 +++++++++++++++ 6 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 docs/site/getting-started/configuration.md create mode 100644 docs/site/getting-started/installation.md create mode 100644 docs/site/getting-started/quick-start.md create mode 100644 docs/site/getting-started/troubleshooting.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 245cee079f..2341049053 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -55,9 +55,16 @@ export default defineConfig({ text: 'Getting Started', items: [ {text: 'Overview', link: '/getting-started/'}, + {text: 'Quick Start', link: '/getting-started/quick-start'}, + {text: 'Installation', link: '/getting-started/installation'}, + {text: 'Configuration', link: '/getting-started/configuration'}, { text: 'Environment Variables', link: '/getting-started/environment-variables' + }, + { + text: 'Troubleshooting', + link: '/getting-started/troubleshooting' } ] }, @@ -71,9 +78,16 @@ export default defineConfig({ text: 'Getting Started', items: [ {text: 'Overview', link: '/getting-started/'}, + {text: 'Quick Start', link: '/getting-started/quick-start'}, + {text: 'Installation', link: '/getting-started/installation'}, + {text: 'Configuration', link: '/getting-started/configuration'}, { text: 'Environment Variables', link: '/getting-started/environment-variables' + }, + { + text: 'Troubleshooting', + link: '/getting-started/troubleshooting' } ] }, diff --git a/docs/site/getting-started/configuration.md b/docs/site/getting-started/configuration.md new file mode 100644 index 0000000000..3d1105ba49 --- /dev/null +++ b/docs/site/getting-started/configuration.md @@ -0,0 +1,107 @@ +--- +title: Configuration +description: How Heimdall loads configuration, which file applies to which install method, and what takes precedence. +--- + +# Configuration + +Every Heimdall install is configured the same way — environment variables. What +changes between install methods is only the file those variables live in. + +This page covers the *model*: where configuration comes from and what wins. For +the variables themselves — names, defaults, effects — see the +[Environment Variables reference](/getting-started/environment-variables). That +page is the single source of truth and nothing here repeats it. + +## Where the variables live + +| Install method | File | +| --- | --- | +| Local development | `apps/backend/.env` (start from `apps/backend/.env-example`) | +| Docker Compose | `.env` beside `docker-compose.yml`, or the `environment:` block | +| RPM | `/etc/heimdall-server/backend.env` | +| Kubernetes | your chart's values, projected into the container environment | + +The variable names are identical across all four. A variable that works in +development works in production under the same name. + +## What takes precedence + +Two rules decide which value wins, and both surprise people. + +**The process environment beats the file.** Configuration is read as +`process.env[key] || envConfig[key]`. A variable exported in your shell, set in +a systemd unit, or injected by Kubernetes overrides the same key in the `.env` +file. It is not the other way around — editing the file will not fix a value +that is also set in the environment. + +**The file is read relative to the working directory.** It is loaded with a +relative path, so it is found relative to *where the process was started*, not +where the application is installed. Start the server from a different directory +and no file is loaded at all: the application logs +`Unable to read configuration file .env!` and continues on the process +environment alone. If configuration appears to be ignored entirely, check the +working directory before you check the file. + +A third, smaller rule: **an empty value is not a default.** Most variables are +read with `||`, so an empty string behaves like unset and the default applies. A +few validate instead and refuse to start; those are called out individually in +the reference. + +## The database name is derived + +There is no default database name. When `DATABASE_NAME` is unset, the name is +derived from `NODE_ENV`: + +``` +heimdall-server-${NODE_ENV} +``` + +So `NODE_ENV=development` uses `heimdall-server-development`, and changing +`NODE_ENV` silently points Heimdall at a different database. This is why the +database role needs `CREATEDB`. + +If **both** `DATABASE_NAME` and `NODE_ENV` are unset, the application throws at +startup rather than guessing. + +`DATABASE_URL` is an alternative to the individual settings — when set, it is +parsed into the username, password, host, name and port components at startup. + +## Secrets + +`JWT_SECRET` signs session tokens. When it is unset, a fresh random value is +generated at every start, which invalidates all sessions on every restart — +fine locally, wrong anywhere real. Generate one: + +```bash +openssl rand -hex 64 +``` + +`API_KEY_SECRET` works the same way and controls a feature: API keys are +disabled entirely when it is unset. + +```bash +openssl rand -hex 33 +``` + +::: warning Rotating a secret logs everyone out +Changing `JWT_SECRET` invalidates every existing session. Changing +`API_KEY_SECRET` invalidates every issued API key. Both are sometimes what you +want — neither should be a surprise. +::: + +## Changing configuration + +Configuration is read at startup, so a change takes effect on restart: + +| Method | Apply a change | +| --- | --- | +| Local development | restart `yarn start:dev` | +| Docker Compose | `docker compose up -d` (recreates the container) | +| RPM | `sudo systemctl restart heimdall-server` | +| Kubernetes | roll the deployment | + +## Next steps + +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — when configuration is right but something still fails diff --git a/docs/site/getting-started/index.md b/docs/site/getting-started/index.md index 017863067e..1b2d333374 100644 --- a/docs/site/getting-started/index.md +++ b/docs/site/getting-started/index.md @@ -1,26 +1,40 @@ +--- +title: Getting Started +description: Installation, configuration and first steps for Heimdall. +--- + # Getting Started -Installation, configuration and first steps for Heimdall. +Heimdall visualizes and analyzes security results in the Heimdall Data Format +(HDF). Start with whichever of these matches what you are trying to do. + +## I just want to look at some results + +```bash +npx @mitre/heimdall-lite +``` + +Heimdall Lite is the standalone viewer — no database, no server, nothing to +install. See [Installation](/getting-started/installation) for other ways to +run it. -::: info Section under construction -The Heimdall documentation is moving from the GitHub wiki into this site -(ADR-005). This section is scaffolding — its pages arrive with the content -migration. -::: +## I want to run the server -## Available now +Heimdall Server adds a backend and a PostgreSQL database, which is what gives +you accounts, saved evaluations, groups and the API. +[Installation](/getting-started/installation) indexes the supported methods — +Docker Compose, RPM, Kubernetes and source — and links to the full guide for +each. -- [Environment Variables](/getting-started/environment-variables) — the - canonical reference for every variable Heimdall reads. Other pages link here - instead of restating variable descriptions. +## I want to work on Heimdall itself -## Pages planned for this section +[Quick Start](/getting-started/quick-start) gets you running locally from source +in development mode, which rebuilds as you edit. -| Page | Source | -| --- | --- | -| Quick start | Wiki: `Home` (docker-compose path) | -| Installation | Wiki: `Home`, `Docker-Bake` | -| Configuration | Wiki: `Environment-Variables-Configuration` (overview) | -| Troubleshooting | Wiki: `Troubleshooting` | +## In this section -Until then, see the [README](https://github.com/mitre/heimdall2#readme). +- [Quick Start](/getting-started/quick-start) — run locally from source, with the versions and commands this repository actually uses +- [Installation](/getting-started/installation) — the supported ways to deploy, and how to choose +- [Configuration](/getting-started/configuration) — how configuration is loaded and what takes precedence +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — what the common failures actually mean diff --git a/docs/site/getting-started/installation.md b/docs/site/getting-started/installation.md new file mode 100644 index 0000000000..b464cbfefc --- /dev/null +++ b/docs/site/getting-started/installation.md @@ -0,0 +1,74 @@ +--- +title: Installation +description: Index of the supported ways to install Heimdall, with guidance on choosing one. +--- + +# Installation + +Heimdall ships in two shapes, and picking the right one first saves the most +time. + +**Heimdall Lite** is the standalone viewer. It is a static single-page +application — it loads HDF results in your browser, stores nothing, and needs no +database and no server. If your goal is to look at scan results, this is the +whole answer. + +**Heimdall Server** adds a backend and a PostgreSQL database, which is what +gives you user accounts, saved evaluations, groups, and the API. Choose it when +results need to persist or be shared. + +## Heimdall Lite + +No installation: + +```bash +npx @mitre/heimdall-lite +``` + +Install it locally if you use it often — subsequent `npx` runs then start much +faster: + +```bash +npm install -g @mitre/heimdall-lite +``` + +Or run it as a container: + +```bash +docker run -d -p 8080:80 mitre/heimdall-lite:release-latest +``` + +It is then at `http://localhost:8080`. Substitute the `latest` tag for +`release-latest` if you want the bleeding-edge build rather than the released +one. + +## Heimdall Server + +Every method below installs the same application; they differ in how it is +supervised, upgraded and secured. Each has its own page under Deployment. + +| Method | Choose it when | Guide | +| --- | --- | --- | +| Docker Compose | You want the fastest supported server install. Brings up the database and a TLS-terminating NGINX alongside Heimdall. | [Deployment](/deployment/) | +| RPM | You are deploying to RHEL or a derivative and want systemd supervision, a system user and standard file locations. | [Deployment](/deployment/) | +| Kubernetes / Helm | You already run Kubernetes and want Heimdall managed the same way as everything else. | [Deployment](/deployment/) | +| From source | You are developing Heimdall, or you need a build no release provides. | [Quick Start](/getting-started/quick-start) | + +The Deployment section covers each in full, along with hardening, backup and +upgrade. This page deliberately does not repeat those instructions — one set of +install steps, in one place. + +## Before you install + +Two things are worth settling before any method: + +**Configuration.** All methods read the same environment variables; only the +file holding them changes. Read [Configuration](/getting-started/configuration) +for the model, and the +[Environment Variables reference](/getting-started/environment-variables) for +the variables themselves. + +**TLS.** The Docker Compose path generates a self-signed certificate valid for +**seven days** so a fresh install works immediately. That is fine for a trial +and wrong for anything else — replace it with a real certificate before anyone +depends on the instance. diff --git a/docs/site/getting-started/quick-start.md b/docs/site/getting-started/quick-start.md new file mode 100644 index 0000000000..42b617b338 --- /dev/null +++ b/docs/site/getting-started/quick-start.md @@ -0,0 +1,164 @@ +--- +title: Quick Start +description: Run Heimdall locally from source in development mode, with the versions and commands this repository actually uses. +--- + +# Quick Start + +This page gets Heimdall running **locally from source** in development mode — +the mode that rebuilds as you edit. It is the path to use when you are working +on Heimdall itself. + +If you only want to *look at* HDF results and have no interest in running a +server, skip all of this: + +```bash +npx @mitre/heimdall-lite +``` + +That runs Heimdall Lite, the standalone viewer — no database, no build, no +clone. For production server installs, see [Installation](/getting-started/installation). + +## Prerequisites + +| Requirement | Version | Check | +| --- | --- | --- | +| Node.js | **22.18.0 or newer** | `node --version` | +| Yarn | 1.x (Classic) | `yarn --version` | +| PostgreSQL | any currently supported release | `psql --version` | +| Git | any | `git --version` | + +::: warning Node 18 is out of date +Older installation notes — including parts of the repository README and the +GitHub wiki — tell you to install Node 18. That is wrong for this codebase. +`package.json` declares `"engines": {"node": ">=22.18.0"}` and `.nvmrc` pins +major version 22. Install on Node 18 and the toolchain will fail. + +With `nvm` installed, the repository's own pin does this for you: + +```bash +nvm use +``` +::: + +Yarn Classic is what this repository uses — the lockfile is `yarn.lock` and the +workspaces are Yarn v1 workspaces. Do not substitute npm or pnpm. + +## 1. Clone and install + +```bash +git clone https://github.com/mitre/heimdall2 +cd heimdall2 +yarn install +``` + +`yarn install` bootstraps every workspace — backend, frontend, and the shared +libraries under `libs/`. + +## 2. Create the database + +Heimdall needs a PostgreSQL database and a role that can create databases. The +role must be able to create them because the backend derives separate database +names per environment. + +```bash +# as a superuser, e.g. `sudo -u postgres psql` +CREATE USER heimdall WITH ENCRYPTED PASSWORD 'your-password'; +ALTER USER heimdall CREATEDB; +``` + +You do not create the database itself by hand — the name is derived from +`NODE_ENV`, and the backend creates it on first run. See the note under +[Configuration](/getting-started/configuration#the-database-name-is-derived). + +## 3. Configure + +Copy the template and edit it: + +```bash +cp apps/backend/.env-example apps/backend/.env +``` + +At minimum set `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `JWT_SECRET` and +`NODE_ENV=development`. Generate a secret rather than inventing one: + +```bash +openssl rand -hex 64 +``` + +Every variable, its default and its effect is documented in the +[Environment Variables reference](/getting-started/environment-variables) — +that page is the single source of truth, and nothing here restates it. + +::: tip Leave PORT unset for local development +`PORT` is read by the backend, which already defaults to `3000`. The frontend +dev server owns its own port and proxy settings in +`apps/frontend/.env.development`. Setting `PORT` in the backend's `.env` to +steer the frontend does not work and breaks local development. +::: + +## 4. Run + +```bash +yarn start:dev +``` + +This runs every workspace's `start:dev` in parallel with streamed output, so +backend and frontend rebuild on change. Leave it running. + +You get **two** servers, not one: + +| Server | Port | Use it for | +| --- | --- | --- | +| Frontend dev server | **printed on startup** — see below | **Open this one.** Serves the UI with hot reload and proxies API calls to the backend. | +| Backend API | `3000` unless `PORT` is set | The NestJS API. Hit it directly when working on the API or reading its responses. | + +::: warning Do not assume the frontend port +The dev server does not have a fixed port. It tries `8080` and moves up — +`8081`, `8082`, and so on — until it finds a free one, so the number changes +between machines and between runs depending on what else is listening. Read the +URL it prints in the `yarn start:dev` output and use that. + +The backend is different: it binds `3000` unless you set `PORT`, so it is +predictable. Opening it in a browser gets you the API, not the interface. +::: + +The other root scripts you are likely to want: + +| Command | What it does | +| --- | --- | +| `yarn start:dev` | development mode, rebuilds on change — **use this while developing** | +| `yarn build` | production build of every workspace | +| `yarn start:built` | build, then start the server against the built output | +| `yarn start` | start the backend only, without building first | + +::: danger Do not use development mode to deploy +Development mode rebuilds on change and makes tradeoffs that are wrong for a +real deployment. To run Heimdall for actual use, follow +[Installation](/getting-started/installation). +::: + +## Running this documentation locally + +These docs are **not** part of the application and `yarn start:dev` does not +start them. The site is a separate project with its own `package.json` and +lockfile, deliberately outside the Yarn workspaces, so it never enters the app's +dependency graph. There is no `/docs` route on the running server. + +To work on the documentation, run it on its own: + +```bash +cd docs +yarn install +yarn dev +``` + +`yarn build` in the same directory produces the static site and fails the build +on dead internal links. + +## Next steps + +- [Configuration](/getting-started/configuration) — how config is loaded, and which file applies to which install method +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — what the common failures actually mean +- [Installation](/getting-started/installation) — the supported ways to deploy for real use diff --git a/docs/site/getting-started/troubleshooting.md b/docs/site/getting-started/troubleshooting.md new file mode 100644 index 0000000000..91bf1b9cfa --- /dev/null +++ b/docs/site/getting-started/troubleshooting.md @@ -0,0 +1,136 @@ +--- +title: Troubleshooting +description: What Heimdall's common failures actually mean, traced to the code that produces them. +--- + +# Troubleshooting + +Each symptom below is tied to the behavior in the code that causes it, so you +can confirm the diagnosis rather than guess at it. + +## Uploads fail on large files + +There are **two independent size limits**, and raising one does not raise the +other. + +`MAX_FILE_UPLOAD_SIZE` controls the evaluation upload limit in megabytes and +defaults to `50`. Separately, the JSON body parser is capped at a hard-coded +`50mb`. + +The consequence: raising `MAX_FILE_UPLOAD_SIZE` above 50 does **not** let you +post a larger JSON body — that request is rejected by the body parser before the +upload limit is ever consulted. Below 50 MB, `MAX_FILE_UPLOAD_SIZE` is the +effective limit and lowering it works as expected. + +If a large HDF file fails to upload, check which limit you are hitting: a +rejection from the body parser is a parser-level error, not a Heimdall +validation message. + +## Splunk or Tenable connections are blocked in the browser + +Symptom: the server is configured correctly, but the browser console shows the +request to your Splunk or Tenable host refused by Content Security Policy. + +Heimdall sends a CSP whose `connect-src` allows only `'self'`, +`https://api.github.com`, `https://sts.amazonaws.com`, and — added at startup — +the values of `TENABLE_HOST_URL` and `SPLUNK_HOST_URL`. + +The important part is **at startup**. Those hosts enter the policy when the +process boots. Setting or changing either variable without restarting leaves the +old policy in place, and the browser blocks the connection no matter how correct +the server-side configuration is. Restart after changing them. + +Empty values are filtered out, so an unset host simply is not in the policy. + +## Content is blocked over HTTPS + +The policy includes `block-all-mixed-content`. Any resource loaded over plain +HTTP by a page served over HTTPS is blocked by the browser. This usually shows +up behind a reverse proxy that terminates TLS while something upstream still +emits `http://` URLs. + +## Heimdall will not load inside an iframe + +`frame-ancestors` is `'self'`. Embedding Heimdall in a page on another origin is +refused by the browser. This is deliberate. + +## Running over plain HTTP + +This works, and is supported. Helmet's default CSP includes +`upgrade-insecure-requests`, which rewrites requests to HTTPS and breaks HTTP +deployments; Heimdall **deliberately removes that directive** for exactly this +reason. + +So if a plain-HTTP deployment is redirecting to HTTPS, Heimdall's CSP is not the +cause — look at your reverse proxy or a browser HSTS entry from a previous HTTPS +visit to the same host. + +## Database errors at startup + +**"NODE_ENV and DATABASE_NAME are undefined."** There is no fallback database +name. When `DATABASE_NAME` is unset the name is derived as +`heimdall-server-${NODE_ENV}`, so if both are unset the application refuses to +start rather than guess. Set `NODE_ENV`. + +**Connecting to the wrong database.** Because the name is derived from +`NODE_ENV`, changing `NODE_ENV` silently moves Heimdall to a different database. +Data that has "disappeared" after a configuration change is usually intact in +the database belonging to the previous `NODE_ENV`. + +**"SSL Key file does not exist"** (or the `Cert` / `CA` equivalent). The +`DATABASE_SSL_*` variables accept either a path or the certificate material +itself, distinguished by a `-BEGIN` marker. Given a path, the file must exist +at startup or the application will not start. Check the path is readable by the +user the service runs as — under the RPM that is the `heimdall` user, not you. + +## Configuration changes have no effect + +Two causes, in order of likelihood. + +**Something in the environment is overriding the file.** Configuration is read +as `process.env[key] || envConfig[key]`, so a shell export, a systemd +`Environment=` line, or an injected Kubernetes variable beats the file. Editing +the file cannot win. + +**The file was never read.** It is loaded on a relative path, so it is resolved +against the process's working directory. Started from elsewhere, no file is +loaded and the application logs `Unable to read configuration file .env!` before +continuing on the environment alone. That log line is the fastest way to confirm +this. + +See [Configuration](/getting-started/configuration) for the full precedence +model. + +## Requests are rejected as rate-limited + +Heimdall applies rate limiting and returns a `Ratelimited` error when a client +exceeds it. Behind a reverse proxy this can appear to affect everyone at once if +the proxy does not pass the real client address — every request then looks like +it comes from one IP. Ensure the proxy forwards the client address. + +## Where the logs are + +| Install method | Logs | +| --- | --- | +| Local development | the terminal running `yarn start:dev` | +| Docker Compose | `docker compose logs -f server` | +| RPM | `journalctl -u heimdall-server -f` by default; if `LOG_FILE` is set, the launcher redirects output to that path instead | +| Kubernetes | `kubectl logs` against the Heimdall pod | + +Under the RPM, `LOG_FILE` changes where output goes — unset means journald. If +it is set, the directory must be writable by the `heimdall` user. + +## The docs site is not served by the application + +`yarn start:dev` does not start this documentation, and there is no `/docs` +route on a running server. The site is a separate project with its own +`package.json`, outside the application's workspaces. Run it with +`cd docs && yarn dev`. Serving it from the application for offline and airgapped +installs is planned but not yet implemented. + +## Node version errors during install or build + +This codebase requires **Node 22.18.0 or newer** (`.nvmrc` pins major version +22). Older instructions — including parts of the repository README and the +GitHub wiki — say Node 18, which is out of date and will fail. Run `nvm use` in +the repository root to pick up the pinned version. From 81982a89c965f8c5dd885468390ff140199665b0 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 08:24:08 -0400 Subject: [PATCH 054/197] ci: report lint without blocking until the backlog is cleared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint configuration repair removed `|| true` from lint:ci, which was correct on its own terms — the script had been unable to fail at all, because a markdown parser crash was being swallowed along with every real violation. But removing it also armed a gate the codebase does not yet satisfy: lint:ci exits 1 with 5,754 problems (5,467 errors, 287 warnings), none of which this branch introduced. Arming the gate belongs with the cleanup that makes it pass, and that cleanup is a change of its own. Marking the step continue-on-error keeps everything the repair bought — linting runs, and every problem still prints and surfaces as a step annotation — without failing CI on branches that do not own the backlog. The exit code is now 1 rather than 2, so a configuration crash and a pile of violations are no longer indistinguishable. `yarn lint:ci` still exits non-zero locally, leaving the developer signal unchanged. Deleting the single continue-on-error line re-arms the gate. Authored by: Aaron Lippold --- .github/workflows/linter.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 169832e61a..11a4eb3164 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -35,3 +35,10 @@ jobs: - name: Run lint run: yarn run lint:ci + # Reports without blocking. The lint configuration was repaired + # separately, taking the repo from 58,124 problems to 5,754, but the + # remaining backlog is its own change and does not belong to whichever + # PR happens to run next. Problems still appear in the job output and + # as a step annotation. Delete this line — not the step — once + # `yarn lint:ci` exits 0. + continue-on-error: true From 3affb93b93d9ebfbfb585fe82fe9472fd9fa7b36 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:07:08 -0400 Subject: [PATCH 055/197] =?UTF-8?q?chore(lint):=20apply=20the=20full=20rul?= =?UTF-8?q?e=20triage=20=E2=80=94=20fix=20the=20config,=20not=20the=20coun?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of the 240 reported rules now has a deliberate disposition, decided rule-by-rule with measurements rather than fixed error-by-error. 58,124 -> 3,162, and every remaining problem is assigned to a work queue on the tracking card. Config bugs fixed (files the linter could not see at all): Sequelize migrations and seeders were fatally unparseable — plain CJS outside every tsconfig — and are now linted via disableTypeChecked; likewise the loose CJS scripts (Lite's npm-shipped server.js, vue.config.js, postcss config, test support servers). .vscode JSON is JSONC and is now parsed as such, per @eslint/json's own documented example. Migration and seeder filenames are identifiers recorded in SequelizeMeta/SequelizeData, so the case rule cannot apply to them: renaming one makes every deployment re-run it. Rules whose settings matched nothing: filename-case demanded snake_case in a repo with zero snake_case source files — now kebab (the rule's default and what NestJS generates), with per-package case unions taken from each package's measured convention so that no file is renamed. consistent-type-definitions now follows each package's measured majority (frontend, inspecjs and common are interface codebases; backend and hdf-converters are type) instead of forcing 113 conversions. Opinion rules dropped after review: name-replacements and consistent-boolean-name (vocabulary enforcement meaning mass renames of public-ish APIs), the style family whose suggestion-type fixers rewrite the AST (no-useless-else's fixer deletes continue statements), and the regexp unicode-flag pair (180 semantic changes to working legacy patterns, each needing fixture-level proof). Duplicate enforcement removed: e18e's modernization category re-checked what unicorn and typescript-eslint already own, reporting the same site 3-5 times. One generalist plugin, domain specialists, and rule-level curation for anything else — wholesale-extending an overlapping preset is the same mistake the perfectionist preset already demonstrated here. markdown formatting now belongs to Prettier alone; markdown-preferences keeps only its correctness rules. Scoped exceptions carry their reasons inline: __dirname in CommonJS compile targets, fixture paths in specs, the flat-config plugin.configs idiom in this file itself. Authored by: Aaron Lippold --- eslint.config.mjs | 258 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 252 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 203358ab93..1e52b7f0a9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,3 @@ -/* eslint-disable n/no-extraneous-import */ import e18e from '@e18e/eslint-plugin'; import comments from '@eslint-community/eslint-plugin-eslint-comments/configs'; @@ -26,8 +25,6 @@ import tseslint from 'typescript-eslint'; import cypress from 'eslint-plugin-cypress'; import vue from 'eslint-plugin-vue'; -/* eslint-enable n/no-extraneous-import */ - export default defineConfig([ { // `docs` is the VitePress documentation site (ADR-005 §2.1): an isolated @@ -53,6 +50,9 @@ export default defineConfig([ '**/*MappingData.ts', 'libs/hdf-converters/src/ckl-mapper/jsonixMapping.ts', // "Generated by jsonix-schema-compiler" 'apps/frontend/src/utilities/cci_util.ts', // 14k-line CCI_DESCRIPTIONS table, zero functions + 'libs/hdf-converters/schemas/**/jsonix-compiler-output/**', // same generator as jsonixMapping.ts + 'libs/hdf-converters/data/reverse-html-mapper/tw-elements.min.js', // vendored minified bundle + 'libs/hdf-converters/sample_jsons/**', // mapper fixture corpus — inputs under test, not code ], name: 'global ignores', }, @@ -76,7 +76,7 @@ export default defineConfig([ }, tseslint.configs.stylisticTypeChecked, comments.recommended, - yml.configs.standard.map((cfg) => ({...cfg, name: 'yml/standard'})), + yml.configs.standard.map((config) => ({...config, name: 'yml/standard'})), security.configs.recommended, importX.flatConfigs.recommended, importX.flatConfigs.typescript, @@ -98,7 +98,13 @@ export default defineConfig([ // and ControlRowDetails.vue has five prismjs imports where the core must // load before its language components register onto it. No sort-* rule // here can reach zero without rewriting order that carries meaning. - { ...e18e.configs.modernization, name: 'e18e/modernization' }, + // e18e is adopted for its UNIQUE value only (2026-08-13 triage, Aaron: + // one generalist plugin, specialists per domain, extras curated + // rule-by-rule). Its modernization category is NOT extended — all ten + // rules re-check what unicorn/typescript-eslint already own, so the + // same site reported 3-5x (prefer-includes: unicorn + ts + e18e). + // Wholesale-extending an overlapping preset was the perfectionist + // mistake repeated. Two perf-category duplicates are disabled below. { ...e18e.configs.performanceImprovements, name: 'e18e/performanceImprovements' }, cypress.configs.recommended, vue.configs['flat/vue2-recommended'], @@ -125,7 +131,13 @@ export default defineConfig([ '@stylistic/eol-last': 'error', '@stylistic/object-curly-newline': ['error', { multiline: true }], '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + // Per-package below (2026-08-13 triage, Aaron) — this global default + // covers packages not scoped there. Measured usage decides direction. '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + // e18e duplicates disabled — the specialist owns each check: + // regexp/prefer-regexp-test (regexp plugin) and unicorn/prefer-array-some. + 'e18e/prefer-regex-test': 'off', + 'e18e/prefer-array-some': 'off', '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-explicit-any': 'off', @@ -139,11 +151,40 @@ export default defineConfig([ curly: 'error', 'n/no-missing-import': ['error', { tryExtensions: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.json'] }], 'prefer-object-has-own': 'error', - 'unicorn/filename-case': ['error', { case: 'snakeCase' }], + // kebabCase is the rule's own default, what NestJS generates + // (schematics normalizeToKebabOrSnakeCase dasherizes camelCase), and + // what this repo already is: apps/backend/src alone has 56 kebab-case + // files and zero snake_case. The previous snakeCase setting matched + // nothing and produced 343 errors — hidden until `|| true` was removed + // from lint:ci. Packages with a different measured convention get + // scoped case unions below (Aaron, 2026-08-13 triage: zero renames). + 'unicorn/filename-case': ['error', { case: 'kebabCase' }], + // 2026-08-13 triage (Aaron): dropped as vocabulary opinion — 281 hits + // meant mass-renaming exported symbols and Vue props for zero behavior + // gain. + 'unicorn/name-replacements': 'off', + // 2026-08-13 triage (Aaron): same class as name-replacements — is/has + // prefix enforcement renames public-ish booleans (Vue props, mapper + // options) for zero behavior gain. + 'unicorn/consistent-boolean-name': 'off', 'unicorn/no-null': 'off', 'unicorn/no-process-exit': 'off', + // 2026-08-13 triage (Aaron): style family dropped — zero correctness + // value and every fixer is suggestion-type (AST-rewriting). + // no-useless-else's fixer DESTROYS continue statements (inspecjs stack + // overflow; docs/development/eslint-config-decisions.md hazard #6). + 'unicorn/no-for-each': 'off', + 'unicorn/no-useless-else': 'off', 'unicorn/prefer-node-protocol': 'off', + 'unicorn/prefer-ternary': 'off', 'unicorn/prevent-abbreviations': 'off', + 'unicorn/switch-case-braces': 'off', + // 2026-08-13 triage (Aaron): adding u/v flags to 180 working legacy + // regexes (144 in hdf-converters mappers) is 180 semantic changes each + // needing per-pattern equivalence proof against golden fixtures — cost + // far exceeds value. New code can adopt the flags freely. + 'regexp/require-unicode-regexp': 'off', + 'regexp/require-unicode-sets-regexp': 'off', }, // Without this, import-x/n resolve imports from the REPO ROOT and never see // apps/frontend/tsconfig.json, where `@/*` -> `./src/*` is defined. Every @@ -175,6 +216,181 @@ export default defineConfig([ ], }, }, + { + // Sequelize migrations are plain CJS scripts outside every tsconfig, so + // the type-aware projectService fatals on all ~31 of them ("was not found + // by the project service") and they were silently not linted at all. + // disableTypeChecked is typescript-eslint's documented answer for files + // outside the project: full linting minus the rules that need type info. + // Sequelize migrations and seeders are plain CJS scripts outside every + // tsconfig, so the type-aware projectService fatals on all of them ("was + // not found by the project service") and they were silently not linted at + // all. disableTypeChecked is typescript-eslint's documented answer for + // files outside the project: full linting minus the rules needing type + // info. + extends: [tseslint.configs.disableTypeChecked], + files: [ + 'apps/backend/migrations/**/*.js', + 'apps/backend/seeders/**/*.js', + ], + languageOptions: { parserOptions: { projectService: false } }, + name: 'ts/sequelize-scripts-untyped', + rules: { + // The standard sequelize signature is (queryInterface, Sequelize) and + // ~31 shipped migrations never use the second param. Editing historical + // migrations is churn on files nobody should touch (Aaron, 2026-08-13 + // triage) — ignore that one name here; real unused vars still flag. + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^Sequelize$' }], + // A migration's filename is an identifier stored in the SequelizeMeta + // table on every deployment (seeders likewise in SequelizeData) — + // renaming one causes it to re-run. The names are frozen data, not a + // style choice. + 'unicorn/filename-case': 'off', + }, + }, + { + // Same project-service fatal, different remedy: these loose CJS scripts + // (Lite's npm-shipped server, PostCSS config, test support servers, the + // FIPS bench spike) are real code and keep every rule — they just cannot + // have type-aware linting, belonging to no tsconfig project. + extends: [tseslint.configs.disableTypeChecked], + files: [ + 'apps/frontend/src/server.js', + 'packaging/**/*.js', + 'postcss.config.js', + 'test/support/**/*.js', + ], + languageOptions: { parserOptions: { projectService: false } }, + name: 'ts/loose-cjs-scripts-untyped', + }, + { + // The same loose CJS scripts, plus vue.config.js (CJS by webpack + // contract): __dirname/module.exports are correct here — same rationale + // as the backend/libs scope-off above. + files: [ + 'apps/frontend/src/server.js', + 'apps/frontend/vue.config.js', + 'packaging/**/*.js', + 'postcss.config.js', + 'test/support/**/*.js', + ], + name: 'unicorn/loose-cjs-prefer-module', + plugins: { unicorn }, + rules: { 'unicorn/prefer-module': 'off' }, + }, + { + // consistent-type-definitions per MEASURED package majority (2026-08-13 + // triage, Aaron — zero-churn philosophy, same as filenames): frontend + // 40:16, inspecjs 43:1 and common are interface codebases; backend (21:0 + // type) and hdf-converters (39:29 type) keep the global 'type'. The + // interface->type fixer is the one that broke declare-module before — + // never run it across module augmentations. + files: [ + 'apps/frontend/**/*.{ts,vue}', + 'libs/common/**/*.ts', + 'libs/inspecjs/**/*.ts', + ], + name: 'ts/interface-packages', + rules: { + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + }, + }, + { + // Mapper pipelines nest calls as a deliberate style; extracting named + // intermediates at 130 sites is churn, not clarity (2026-08-13 triage, + // Aaron). The handful of hits elsewhere get fixed by hand. + files: ['libs/hdf-converters/**'], + name: 'unicorn/hdf-nested-calls', + plugins: { unicorn }, + rules: { 'unicorn/max-nested-calls': 'off' }, + }, + { + // Filename-case unions per package, from MEASURED conventions (2026-08-13 + // triage, Aaron: zero renames). Each union is that package's existing + // styles — the rule still blocks any NEW style from appearing. Measured: + // frontend .vue 95/95 PascalCase (Vue's own style-guide convention); + // frontend .ts snake/flat/Pascal mix; hdf-converters src kebab-majority + // with PascalCase classes and 3 camel; hdf-converters test 33/35 + // snake_case (*_mapper.spec.ts); inspecjs flat/snake/kebab; test/ + // Pascal-majority. Backend stays strict kebab via the global rule. + files: ['apps/frontend/**/*.vue'], + name: 'unicorn/filename-vue', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true } }], + }, + }, + { + files: ['apps/frontend/**/*.ts'], + name: 'unicorn/filename-frontend-ts', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true, snakeCase: true } }], + }, + }, + { + files: ['libs/hdf-converters/src/**'], + name: 'unicorn/filename-hdf-src', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { camelCase: true, kebabCase: true, pascalCase: true } }], + }, + }, + { + files: ['libs/hdf-converters/test/**', 'libs/inspecjs/**'], + name: 'unicorn/filename-snake-test-dirs', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, snakeCase: true } }], + }, + }, + { + files: ['test/**'], + name: 'unicorn/filename-e2e', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true } }], + }, + }, + { + // import-x/no-named-as-default-member cautions that `plugin.configs` may + // not survive some CJS/ESM interop paths. In this file the pattern + // (`tseslint.configs...`, `yml.configs...`) is the flat-config idiom every + // plugin's own documentation uses, and the rule's suggested named imports + // would collide — six plugins here all export `configs`. Config file only; + // application code keeps the rule. + files: ['eslint.config.mjs'], + name: 'import-x/flat-config-idiom', + rules: { 'import-x/no-named-as-default-member': 'off' }, + }, + { + // security/detect-non-literal-fs-filename flags OWASP path traversal: + // fs calls whose path an attacker might influence. In spec files the + // paths are the test's own fixtures (tmpdir + literals) — there is no + // attacker input by construction, and lint:ci's --max-warnings 0 + // escalates the rule's deliberate warn severity into a hard failure. + // Production code keeps the warning. + files: ['**/*.spec.ts', '**/*.spec.js', 'test/**', '**/test/**'], + name: 'security/spec-fixture-paths', + rules: { 'security/detect-non-literal-fs-filename': 'off' }, + }, + { + // unicorn/prefer-module exists to drive ESM migration and forbids + // __dirname/__filename, which "are not available in JavaScript modules". + // These packages all extend the root tsconfig (module: nodenext) with no + // "type": "module", so they COMPILE TO COMMONJS — __dirname is correct + // there and import.meta is a syntax error. The rule is also auto-fixable: + // a --fix pass would rewrite __dirname to import.meta and break the build + // (same hazard class as sort-decorators; see + // docs/development/eslint-config-decisions.md). Scoped off until an ESM + // migration; the bundler-resolved frontend keeps it. + files: [ + 'apps/backend/**/*.{js,cjs,ts,cts}', + 'libs/**/*.{js,cjs,ts,cts}', + ], + name: 'unicorn/cjs-compile-target', + rules: { 'unicorn/prefer-module': 'off' }, + }, { // eslint-plugin-n models NODE.JS runtime resolution. apps/frontend is a // webpack-bundled Vue app whose `@/*` imports are resolved by the BUNDLER @@ -194,6 +410,16 @@ export default defineConfig([ ignores: ['package-lock.json', 'parse_testbed/**', 'schemas/**'], language: 'json/json', name: 'json', + }, + { + // VS Code config files are JSONC — comments are part of the format. The + // strict json language fatals on the first `//` ("Unexpected character + // '/'"), which silently un-linted .vscode/. This files/language pairing is + // @eslint/json's own documented example for exactly these files. + extends: [json.configs.recommended], + files: ['**/*.jsonc', '.vscode/*.json'], + language: 'json/jsonc', + name: 'jsonc', plugins: { json }, }, { @@ -231,6 +457,26 @@ export default defineConfig([ language: 'markdown/gfm', name: 'markdown', plugins: { markdown }, + // Formatting belongs to Prettier, which formats markdown too (Aaron, + // 2026-08-13 triage). These are markdown-preferences' pure-formatting + // rules — the exact analogue of the @stylistic rules eslint-config- + // prettier switches off for code, which it cannot do here because it does + // not know this plugin. They accounted for ~800 problems, 85% in + // packaging/. The plugin's correctness/content rules (prefer-fenced-code- + // blocks, prefer-autolinks, link checks) stay on. + rules: { + 'markdown-preferences/bullet-list-marker-style': 'off', + 'markdown-preferences/emphasis-delimiters-style': 'off', + 'markdown-preferences/hard-linebreak-style': 'off', + 'markdown-preferences/indent': 'off', + 'markdown-preferences/no-multi-spaces': 'off', + 'markdown-preferences/no-multiple-empty-lines': 'off', + 'markdown-preferences/no-trailing-spaces': 'off', + 'markdown-preferences/ordered-list-marker-sequence': 'off', + 'markdown-preferences/padding-line-between-blocks': 'off', + 'markdown-preferences/table-pipe-alignment': 'off', + 'markdown-preferences/table-pipe-spacing': 'off', + }, }, // MUST BE LAST. eslint-config-prettier only turns rules OFF — every // formatting rule that would fight the formatter — so anything placed after From 47777a9ef82db301c429d6e7717d183d3ea5fff6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:13:01 -0400 Subject: [PATCH 056/197] chore(lint): remove stale disable directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty disable comments no longer suppress anything. Twenty-seven became unused when the rules they silenced were turned off in the triage — removed mechanically with `eslint --fix --fix-type directive`, which can only touch directive comments, never code. Three referenced prettier/prettier, a rule from eslint-plugin-prettier, which this codebase does not use; since the plugin's removal they have produced "definition not found" errors instead of doing anything. One directive was drifted rather than stale: ldap.strategy.ts had its justified prefer-at disable separated from the line it guards by its own multi-line justification — disable-next-line only reaches one line down. The prose now sits above the directive and the directive above the code, so the suppression lands where the justification says it should. Comment-only change; no executable line is altered. Authored by: Aaron Lippold --- apps/backend/src/authn/ldap.strategy.ts | 12 ++++----- .../src/components/generic/ApexPieChart.vue | 2 +- .../global/upload_tabs/tenable/FileList.vue | 1 - apps/frontend/src/server.js | 4 +-- apps/frontend/src/store/color_hack.ts | 2 +- apps/frontend/src/store/report_intake.ts | 2 +- apps/frontend/src/utilities/export_util.ts | 3 +-- apps/frontend/src/utilities/tenable_util.ts | 25 +++++++++---------- apps/frontend/src/utilities/treemap_util.ts | 2 +- apps/frontend/tests/unit/Results.spec.ts | 8 +++--- .../tests/unit/parsing_and_counting.spec.ts | 2 +- apps/frontend/tests/util/testingUtils.ts | 2 +- test/support/server/json-server.js | 2 +- 13 files changed, 32 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/authn/ldap.strategy.ts b/apps/backend/src/authn/ldap.strategy.ts index 9c2d711f81..ff8bdab7a7 100644 --- a/apps/backend/src/authn/ldap.strategy.ts +++ b/apps/backend/src/authn/ldap.strategy.ts @@ -76,12 +76,12 @@ export class LDAPStrategy extends PassportStrategy(Strategy, 'ldap') { this.configService.get('LDAP_MAILFIELD') || 'mail', ); const validatedUser = this.authnService.validateOrCreateUser( - // eslint-disable-next-line unicorn/prefer-at -- `.at(0)` returns - // `string | undefined`, but validateOrCreateUser requires `string`. - // Index access keeps the exact runtime behavior this has always had. - // Closing the gap properly means deciding what should happen when an - // LDAP user has no email address — an authentication behavior change, - // not a lint fix. Tracked separately. + // `.at(0)` returns `string | undefined`, but validateOrCreateUser + // requires `string`. Index access keeps the exact runtime behavior this + // has always had. Closing the gap properly means deciding what should + // happen when an LDAP user has no email address — an authentication + // behavior change, not a lint fix. Tracked separately. + // eslint-disable-next-line unicorn/prefer-at Array.isArray(email) ? email[0] : email, firstName, lastName, diff --git a/apps/frontend/src/components/generic/ApexPieChart.vue b/apps/frontend/src/components/generic/ApexPieChart.vue index c0df6b687b..2e5a6fd127 100644 --- a/apps/frontend/src/components/generic/ApexPieChart.vue +++ b/apps/frontend/src/components/generic/ApexPieChart.vue @@ -36,7 +36,7 @@ type ApexTotalType = { label?: string; color?: string; // Formatter can take any parameter as defined by ApexCharts - // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter?(w: any): string; }; diff --git a/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue b/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue index 192979fba9..2b1e57961c 100644 --- a/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue +++ b/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue @@ -190,7 +190,6 @@ export default class FileList extends Vue { SnackbarModule.failure( `Scan ${execution.id} hasn't finished, wait until completed before loading for viewing.` ); - // eslint-disable-next-line prettier/prettier } else if (execution.status === 'Failed' || execution.status === 'Error') { SnackbarModule.failure( `Scan ${execution.id} has failed, please check the Tenable.sc for more details.` diff --git a/apps/frontend/src/server.js b/apps/frontend/src/server.js index e17a332d1a..81ac637f3a 100755 --- a/apps/frontend/src/server.js +++ b/apps/frontend/src/server.js @@ -16,13 +16,13 @@ let port = 8000; if (process.argv.length > 2) { port = Number.parseInt(process.argv[2]); if (Number.isNaN(port) || port < 1 || port >= 65536) { - // eslint-disable-next-line no-console + console.error(`Error: ${process.argv[2]} is not a valid port.`); return; } } -// eslint-disable-next-line no-console + console.log(`Serving Heimdall on port ${port}`); express() diff --git a/apps/frontend/src/store/color_hack.ts b/apps/frontend/src/store/color_hack.ts index a812d6001a..cbc9d0278c 100644 --- a/apps/frontend/src/store/color_hack.ts +++ b/apps/frontend/src/store/color_hack.ts @@ -94,7 +94,7 @@ export class ColorHack extends VuexModule { case 'Empty': return 'black'; default: - // eslint-disable-next-line no-console + console.warn(`No color defined for ${status}`); return 'rgb(187, 187, 187)'; } diff --git a/apps/frontend/src/store/report_intake.ts b/apps/frontend/src/store/report_intake.ts index 7e7f271064..644857bd23 100644 --- a/apps/frontend/src/store/report_intake.ts +++ b/apps/frontend/src/store/report_intake.ts @@ -372,7 +372,7 @@ export class InspecIntake extends VuexModule { InspecDataModule.addProfile(profileFile); FilteredDataModule.toggle_profile(profileFile.uniqueId); } else { - // eslint-disable-next-line no-console + console.error(result.errors); throw new Error( "Couldn't parse data. See developer's tools for more details." diff --git a/apps/frontend/src/utilities/export_util.ts b/apps/frontend/src/utilities/export_util.ts index eeb3aa63fe..913e209258 100644 --- a/apps/frontend/src/utilities/export_util.ts +++ b/apps/frontend/src/utilities/export_util.ts @@ -34,7 +34,7 @@ export async function saveSingleOrMultipleFiles( // Convert Blob to ArrayBuffer asynchronously binaryData = await file.data.arrayBuffer(); } else { - // eslint-disable-next-line no-console + console.warn(`Unsupported file type for ${file.filename}`); continue; } @@ -42,7 +42,6 @@ export async function saveSingleOrMultipleFiles( zip.file(file.filename, binaryData); } - // eslint-disable-next-line prettier/prettier const content = await zip.generateAsync({type: 'blob'}); saveAs(content, `exported_${filetype}s.zip`); } diff --git a/apps/frontend/src/utilities/tenable_util.ts b/apps/frontend/src/utilities/tenable_util.ts index a6a55381be..9fdeb64a54 100644 --- a/apps/frontend/src/utilities/tenable_util.ts +++ b/apps/frontend/src/utilities/tenable_util.ts @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import JSZip from 'jszip'; import axios, {AxiosInstance} from 'axios'; import {ServerModule} from '@/store/server'; @@ -62,14 +61,14 @@ export class TenableUtil { headers }); - // eslint-disable-next-line no-console + console.info( `Tenable Client initialized in ${this.isServer ? 'Server' : 'Lite'} mode` ); } async loginToTenable(): Promise { - // eslint-disable-next-line no-console + console.info(`Connecting to Tenable Client`); return new Promise((resolve, reject) => { setTimeout( () => reject(new Error(LOGIN_TIMEOUT_MSG)), LOGIN_TIMEOUT); @@ -78,7 +77,7 @@ export class TenableUtil { const url = this.isServer ? '/api/tenable/login' : '/rest/currentUser'; if (this.isServer) { // If running on the server, use the backend proxy endpoint - // eslint-disable-next-line no-console + console.info(`Using Server-Mode: ${url}`); this.axios_instance .post(url, { @@ -94,7 +93,7 @@ export class TenableUtil { } }) .catch((error) => { - // eslint-disable-next-line no-console + console.error( `Processing (Server-Mode) connection error -> ${error}` ); @@ -102,20 +101,20 @@ export class TenableUtil { }); } else { // If running in Lite mode, connect directly to Tenable - // eslint-disable-next-line no-console + console.info(`Using Lite-Mode`); this.axios_instance .get(url) .then((response) => { if (response.status === 200) { - // eslint-disable-next-line no-console + console.info('Processing (Lite-Mode) connected successfully'); resolve(true); } else { const msg = response.data?.message || 'Unexpected response structure from Tenable'; - // eslint-disable-next-line no-console + console.error( `Processing (Lite-Mode) connection failed: ${msg}` ); @@ -123,7 +122,7 @@ export class TenableUtil { } }) .catch((error) => { - // eslint-disable-next-line no-console + console.error(`Processing (Lite-Mode) connecting error: ${error}`); reject(this.getRejectConnectionMessage(error)); }); @@ -134,7 +133,7 @@ export class TenableUtil { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + getRejectConnectionMessage(error: any): string { let rejectMsg = ''; const DEFAULT_REJECT_MSG = @@ -253,7 +252,7 @@ export class TenableUtil { * name,description,scannedIPs,startTime,finishTime,status */ async getScans(startTime: number, endTime: number): Promise<[]> { - // eslint-disable-next-line no-console + console.info(`Getting scans from Tenable Client`); return new Promise((resolve, reject) => { setTimeout( () => reject(new Error(LOGIN_TIMEOUT_MSG)), LOGIN_TIMEOUT); @@ -287,7 +286,7 @@ export class TenableUtil { * For type "diagnostic", the file is a diagnostic database file. */ async getVulnerabilities(scanId: string): Promise { - // eslint-disable-next-line no-console + console.info(`Getting vulnerabilities from Tenable Client`); return new Promise((resolve, reject) => { setTimeout( () => reject(new Error(LOGIN_TIMEOUT_MSG)), LOGIN_TIMEOUT); @@ -332,7 +331,7 @@ export class TenableUtil { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + getRejectMessage(error: any): string { let rejectMsg = ''; if (error.code == 'ERR_BAD_REQUEST') { diff --git a/apps/frontend/src/utilities/treemap_util.ts b/apps/frontend/src/utilities/treemap_util.ts index 03bb472171..1da4550fd5 100644 --- a/apps/frontend/src/utilities/treemap_util.ts +++ b/apps/frontend/src/utilities/treemap_util.ts @@ -155,7 +155,7 @@ function populate_tree_map( parent.children.push(leaf); leaf.parent = parent; } else { - // eslint-disable-next-line no-console + console.warn( `Warning: unable to assign control ${leaf.nist_control.rawText} to valid treemap leaf` ); diff --git a/apps/frontend/tests/unit/Results.spec.ts b/apps/frontend/tests/unit/Results.spec.ts index dd38ae1c27..cac0bb4ea6 100644 --- a/apps/frontend/tests/unit/Results.spec.ts +++ b/apps/frontend/tests/unit/Results.spec.ts @@ -66,7 +66,7 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: Array; } ).items.length @@ -93,7 +93,7 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: Array; } ).items.length @@ -114,7 +114,7 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: Array; } ).items @@ -148,7 +148,7 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: Array; } ).items.length diff --git a/apps/frontend/tests/unit/parsing_and_counting.spec.ts b/apps/frontend/tests/unit/parsing_and_counting.spec.ts index 486d544aeb..1fc850ad23 100644 --- a/apps/frontend/tests/unit/parsing_and_counting.spec.ts +++ b/apps/frontend/tests/unit/parsing_and_counting.spec.ts @@ -35,7 +35,7 @@ describe('Parsing', async () => { // Get the corresponding count file const countFilename = `tests/hdf_data/counts/${file.filename}.info.counts`; const countFileContent = readFileSync(countFilename, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const counts: Record = JSON.parse(countFileContent); // Get the expected counts diff --git a/apps/frontend/tests/util/testingUtils.ts b/apps/frontend/tests/util/testingUtils.ts index f7988de8cd..58544c7e1d 100644 --- a/apps/frontend/tests/util/testingUtils.ts +++ b/apps/frontend/tests/util/testingUtils.ts @@ -73,7 +73,7 @@ export function expectedCount( // Get the corresponding count file const countFilename = `tests/hdf_data/counts/${file.filename}.info.counts`; const countFileContent = readFileSync(countFilename, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const counts: Record = JSON.parse(countFileContent); statuses['failed'] += counts.failed.total; diff --git a/test/support/server/json-server.js b/test/support/server/json-server.js index 46ba921fde..2435824f21 100644 --- a/test/support/server/json-server.js +++ b/test/support/server/json-server.js @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ + const jsonServer = require('json-server'); const server = jsonServer.create(); const middlewares = jsonServer.defaults(); From c7b1c75d662b4277abdbe72de50b5cf7432a5baf Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:22:29 -0400 Subject: [PATCH 057/197] style: apply layout-type autofixes across the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eslint --fix --fix-type layout` — the fix class that cannot restructure the AST, and the pass `yarn lint` is wired to. Fifty-seven source files: comment spacing (`//comment` -> `// comment`), blank lines between class members, and five regex character-class reorders from regexp/sort-character-class-elements, each verified set-equivalent by inspection ([,|;] = [,;|]; [a-zA-Z] = [A-Za-z]; [^A-Za-z0-9] = [^0-9A-Za-z]). Verified beyond inspection: inspecjs suite green, the password-complexity pipe spec green (23 tests, covering both reordered classes in that package's patterns), frontend vitest suite green (28), and hdf-converters 153/157 — the four failures are service-dependency tests (SonarQube mock on :3001, live Splunk) that fail identically without these changes; with the mock started the three SonarQube specs pass, and the Splunk spec's service container exists only in CI. Connection refusal at the socket is not a reachable failure mode for whitespace or set-equivalent patterns. Markdown deliberately excluded from this pass. Authored by: Aaron Lippold --- .../src/components/cards/ProfileData.vue | 2 +- .../cards/controltable/ControlRowDetails.vue | 6 +++--- .../src/components/generic/ApexLineChart.vue | 10 +++++----- .../src/components/global/ExportCSVModal.vue | 2 +- .../src/components/global/ExportJson.vue | 2 +- .../components/global/ExportXCCDFResults.vue | 2 +- .../global/sidebaritems/SidebarFileList.vue | 10 +++++----- .../global/upload_tabs/LoadFileList.vue | 12 +++++------ apps/frontend/src/main.ts | 2 +- apps/frontend/src/mixins/ServerMixin.ts | 2 +- apps/frontend/src/plugins/vuetify.ts | 8 ++++---- apps/frontend/src/store/data_store.ts | 2 +- apps/frontend/src/utilities/export_util.ts | 6 +++--- apps/frontend/src/views/Compare.vue | 2 +- apps/frontend/src/views/Results.vue | 4 ++-- apps/frontend/tests/unit/Compare.spec.ts | 6 +++--- .../src/anchore-grype-mapper.ts | 5 +++-- libs/hdf-converters/src/aws-config-mapper.ts | 2 +- libs/hdf-converters/src/base-converter.ts | 2 ++ libs/hdf-converters/src/burpsuite-mapper.ts | 1 + .../ckl-mapper/checklist-jsonix-converter.ts | 2 +- .../converters-from-hdf/asff/asff-types.ts | 2 +- .../asff/reverse-asff-mapper.ts | 8 ++++---- .../converters-from-hdf/asff/transformers.ts | 2 +- .../reverse-base-converter.ts | 13 ++++++------ libs/hdf-converters/src/conveyor-mapper.ts | 3 ++- .../src/cyclonedx-sbom-mapper.ts | 1 + libs/hdf-converters/src/dbprotect-mapper.ts | 1 + .../src/dependency-track-mapper.ts | 1 + libs/hdf-converters/src/fortify-mapper.ts | 3 ++- libs/hdf-converters/src/gosec-mapper.ts | 1 + libs/hdf-converters/src/jfrog-xray-mapper.ts | 1 + .../src/mappings/CweNistMapping.ts | 1 + .../src/mappings/NessusPluginsNistMapping.ts | 1 + .../src/mappings/ScoutsuiteNistMapping.ts | 1 + .../src/msft-secure-score-mapper.ts | 1 + libs/hdf-converters/src/nessus-mapper.ts | 1 + libs/hdf-converters/src/netsparker-mapper.ts | 1 + libs/hdf-converters/src/neuvector-mapper.ts | 1 + libs/hdf-converters/src/nikto-mapper.ts | 1 + libs/hdf-converters/src/sarif-mapper.ts | 1 + libs/hdf-converters/src/scoutsuite-mapper.ts | 1 + libs/hdf-converters/src/snyk-mapper.ts | 1 + libs/hdf-converters/src/sonarqube-mapper.ts | 2 +- libs/hdf-converters/src/trufflehog-mapper.ts | 1 + libs/hdf-converters/src/twistlock-mapper.ts | 1 + libs/hdf-converters/src/utils/global.ts | 2 +- libs/hdf-converters/src/veracode-mapper.ts | 1 + .../src/xccdf-results-mapper.ts | 1 + libs/hdf-converters/src/zap-mapper.ts | 1 + .../mappers/forward/conveyor_mapper.spec.ts | 20 +++++++++---------- .../reverse/asff_reverse_mapper.spec.ts | 4 ++-- libs/inspecjs/src/nist.ts | 4 ++-- libs/password-complexity/index.js | 4 ++-- test/support/pages/RegistrationPage.ts | 1 + test/support/verifiers/LoginPageVerifier.ts | 1 + 56 files changed, 105 insertions(+), 75 deletions(-) diff --git a/apps/frontend/src/components/cards/ProfileData.vue b/apps/frontend/src/components/cards/ProfileData.vue index 7b090f2d5e..09020ac9cc 100644 --- a/apps/frontend/src/components/cards/ProfileData.vue +++ b/apps/frontend/src/components/cards/ProfileData.vue @@ -131,7 +131,7 @@ export default class ProfileData extends Vue { return (result || this.file) as SourcedContextualizedProfile; } - //the single root tree item + // the single root tree item get root_tree(): TreeItem[] { const tree = new TreeItem(this.file_root_profile); tree.children = []; diff --git a/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue b/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue index 68b3767438..6c27da6c79 100644 --- a/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue +++ b/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue @@ -93,14 +93,14 @@ import ControlRowCol from '@/components/cards/controltable/ControlRowCol.vue'; import HtmlSanitizeMixin from '@/mixins/HtmlSanitizeMixin'; import {ContextualizedControl} from 'inspecjs'; import * as _ from 'lodash'; -//TODO: add line numbers +// TODO: add line numbers import 'prismjs'; import 'prismjs/components/prism-json'; import 'prismjs/components/prism-makefile.js'; import 'prismjs/components/prism-ruby.js'; import 'prismjs/themes/prism-tomorrow.css'; import Component, {mixins} from 'vue-class-component'; -//@ts-ignore +// @ts-ignore import Prism from 'vue-prism-component'; import {Prop, Watch} from 'vue-property-decorator'; @@ -290,7 +290,7 @@ export default class ControlRowDetails extends mixins(HtmlSanitizeMixin) { ); } - //for zebra background + // for zebra background zebra(ix: number): string { if (ix % 2 === 0) { return 'zebra-table'; diff --git a/apps/frontend/src/components/generic/ApexLineChart.vue b/apps/frontend/src/components/generic/ApexLineChart.vue index 84a1f6547a..2226284ae3 100644 --- a/apps/frontend/src/components/generic/ApexLineChart.vue +++ b/apps/frontend/src/components/generic/ApexLineChart.vue @@ -34,14 +34,14 @@ export interface SeriesItem { export default class ApexLineChart extends Vue { @Prop({required: true, type: Array}) readonly categories!: Category[]; @Prop({required: true, type: Array}) readonly series!: number[]; - @Prop({type: Number}) readonly upperRange!: number; //upper bound of y axis - @Prop({type: Boolean}) readonly sevChart!: boolean; //identifies chart as severity chart + @Prop({type: Number}) readonly upperRange!: number; // upper bound of y axis + @Prop({type: Boolean}) readonly sevChart!: boolean; // identifies chart as severity chart @Prop({type: String}) readonly title!: string; @Prop({type: String}) readonly yTitle!: string; @Prop({type: Number, default: undefined}) readonly tooltipMaxDisplayPrecision!: number | undefined; - //gives apex charts the severity colors + // gives apex charts the severity colors sevColors: string[] = ['#FFEB3B', '#FF9800', '#FF5722', '#F44336']; get label_colors(): string[] { @@ -52,7 +52,7 @@ export default class ApexLineChart extends Vue { return colors; } - //creates differing number of ticks based on number of controls + // creates differing number of ticks based on number of controls get y_axis_tick(): number { if (this.upperRange < 15) { return this.upperRange; @@ -82,7 +82,7 @@ export default class ApexLineChart extends Vue { toolbar: { show: false } - //background: '#000' + // background: '#000' }, colors: this.line_colors, dataLabels: { diff --git a/apps/frontend/src/components/global/ExportCSVModal.vue b/apps/frontend/src/components/global/ExportCSVModal.vue index 72fe06c187..7683995cd0 100644 --- a/apps/frontend/src/components/global/ExportCSVModal.vue +++ b/apps/frontend/src/components/global/ExportCSVModal.vue @@ -225,7 +225,7 @@ export default class ExportCSVModal extends Vue { case fieldNames[3]: result[fieldNames[3]] = control.data.title; break; - //Description + // Description case fieldNames[4]: result[fieldNames[4]] = control.data.desc; break; diff --git a/apps/frontend/src/components/global/ExportJson.vue b/apps/frontend/src/components/global/ExportJson.vue index 83b7f78138..05ff52b1fc 100644 --- a/apps/frontend/src/components/global/ExportJson.vue +++ b/apps/frontend/src/components/global/ExportJson.vue @@ -49,7 +49,7 @@ export default class ExportJSON extends Vue { return fileData; } - //exports .zip of jsons if multiple are selected, if one is selected it will export that .json file + // exports .zip of jsons if multiple are selected, if one is selected it will export that .json file export_json() { const files = this.populate_files(); saveSingleOrMultipleFiles(files, 'json'); diff --git a/apps/frontend/src/components/global/ExportXCCDFResults.vue b/apps/frontend/src/components/global/ExportXCCDFResults.vue index 67367e2e05..4f6264027e 100644 --- a/apps/frontend/src/components/global/ExportXCCDFResults.vue +++ b/apps/frontend/src/components/global/ExportXCCDFResults.vue @@ -34,7 +34,7 @@ export type FileData = { export default class ExportXCCDF extends Vue { @Prop({type: Object, required: true}) readonly filter!: Filter; @Prop({type: Boolean, required: true}) readonly isResultView!: boolean; - //exports .zip of XCCDFs if multiple are selected, if one is selected it will export that single file + // exports .zip of XCCDFs if multiple are selected, if one is selected it will export that single file exportXCCDF() { axios .get(`/static/export/xccdfTemplate.xml`) diff --git a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue index f5308bbec9..42dab6dac7 100644 --- a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue +++ b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue @@ -68,12 +68,12 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { } } - //checks if file is selected + // checks if file is selected get selected(): boolean { return FilteredDataModule.selected_file_ids.includes(this.file.uniqueId); } - //removes uploaded file from the currently observed files + // removes uploaded file from the currently observed files remove_file() { EvaluationModule.removeEvaluation(this.file.uniqueId); InspecDataModule.removeFile(this.file.uniqueId); @@ -83,7 +83,7 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { this.navigateWithNoErrors(`/${this.current_route}`); } - //saves file to database + // saves file to database save_file() { if (this.file?.database_id) { SnackbarModule.failure('This file is already in the database.'); @@ -92,7 +92,7 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { } } - //determines if the use can save the file + // determines if the use can save the file get disable_saving() { return typeof this.file?.database_id !== 'undefined' || this.saving; } @@ -150,7 +150,7 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { }); } - //gives different icons for a file if it is just a profile + // gives different icons for a file if it is just a profile get icon(): string { if (this.file.hasOwnProperty('profile')) { return 'mdi-note'; diff --git a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue b/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue index cedc607260..8b2a7e7485 100644 --- a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue +++ b/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue @@ -408,7 +408,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { return {offset, limit}; } - //-------------------------------------------------------------------- + // -------------------------------------------------------------------- // Called when the Search button is invoked (@click="executeSearch()") async executeSearch() { // Clearing the fields using the clearable icon sets the model to null @@ -474,7 +474,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.evaluationsCount = EvaluationModule.evaluationsCount; } - //------------------------------------------------------------------- + // ------------------------------------------------------------------- // Called when any of the sorted fields are invoked (@update:sort-by) async updateSortBy(sortField: string) { /* Hack: Implementing custom headers slots, the v-data-table sorting is @@ -526,7 +526,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { } } - //------------------------------------------------------------------------ + // ------------------------------------------------------------------------ // Called when page navigation arrows are invoked (@update:items-per-page) // or when the Rows per page is invoked (@update:page) and not in Page 1 // or when the page variable is programmatically set. @@ -552,13 +552,13 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.updatingPage = false; } - //---------------------------------------------------- + // ---------------------------------------------------- // Called when Rows per page is invoked (@update:page) // Note: If not on Page 1 the @update:items-per-page // is invoked first, hence the need for the flag async updateItemsPerPage(itemsCount: number) { // Updating the page reset to Page 1 - //this.page = 1; + // this.page = 1; if (this.updatingPage) { return; } else { @@ -661,7 +661,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { Number(this.activeItem.id) ); if (FilteredDataModule.selected_file_ids.includes(fileId)) { - //removes uploaded file from the currently observed files + // removes uploaded file from the currently observed files EvaluationModule.removeEvaluation(fileId); InspecDataModule.removeFile(fileId); // Remove any database files that may have been in the URL diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts index 92e2ae89eb..4e5d51698b 100644 --- a/apps/frontend/src/main.ts +++ b/apps/frontend/src/main.ts @@ -54,7 +54,7 @@ new Vue({ // The following line is a hot patch to add regex support, there are better // places to edit Prism variables, but could not locate them. Namely this is // the Prism library variables, and not the Prism component variables -//@ts-ignore +// @ts-ignore Prism.languages['rb'] = { 'token-name': { pattern: diff --git a/apps/frontend/src/mixins/ServerMixin.ts b/apps/frontend/src/mixins/ServerMixin.ts index 6060f0801f..242bff3699 100644 --- a/apps/frontend/src/mixins/ServerMixin.ts +++ b/apps/frontend/src/mixins/ServerMixin.ts @@ -3,7 +3,7 @@ import {Component, Vue} from 'vue-property-decorator'; @Component({}) export default class ServerMixin extends Vue { - //checks if heimdall is in server mode + // checks if heimdall is in server mode get serverMode() { return ServerModule.serverMode; } diff --git a/apps/frontend/src/plugins/vuetify.ts b/apps/frontend/src/plugins/vuetify.ts index baef3abb30..dc8da72883 100644 --- a/apps/frontend/src/plugins/vuetify.ts +++ b/apps/frontend/src/plugins/vuetify.ts @@ -38,8 +38,8 @@ const branding = { mitreSecondaryYellow: '#FFE23C', mitreSecondaryOrange: '#F7901E', mitreSecondaryRed: '#C6401D', - mitreSectionBackground: '#f3f2f2', //#eff3f5; - mitreSectionBorder: '#cfcfcf', //#b4bfae + mitreSectionBackground: '#f3f2f2', // #eff3f5; + mitreSectionBorder: '#cfcfcf', // #b4bfae mitreCardShadow: '#d6d6d6' }; @@ -84,7 +84,7 @@ const vuetify = new Vuetify({ }); export default vuetify; -/*** colors from new MII homepage ***/ +/** * colors from new MII homepage ***/ /* @highlightGrey: #999999; @cardLabelIcons: #aaaaaa; @@ -100,7 +100,7 @@ export default vuetify; @link-color: @fontLink; //#0015E8; @sectionBackground: #f3f2f2; //#eff3f5; @sectionBorder: #cfcfcf; //#b4bfae -/*outlook chart states*/ +/*outlook chart states */ /* @outlookBusy: #9698ce; @outlookOut: #a96ead; diff --git a/apps/frontend/src/store/data_store.ts b/apps/frontend/src/store/data_store.ts index 67fa0941e2..9fb94fcee9 100644 --- a/apps/frontend/src/store/data_store.ts +++ b/apps/frontend/src/store/data_store.ts @@ -19,7 +19,7 @@ import { } from 'vuex-module-decorators'; import {FilteredDataModule} from './data_filters'; -/** We make some new variant types of the Contextual types, to include their files*/ +/** We make some new variant types of the Contextual types, to include their files */ export function isFromProfileFile(p: SourcedContextualizedProfile) { return p.sourcedFrom === null; } diff --git a/apps/frontend/src/utilities/export_util.ts b/apps/frontend/src/utilities/export_util.ts index 913e209258..466445f22e 100644 --- a/apps/frontend/src/utilities/export_util.ts +++ b/apps/frontend/src/utilities/export_util.ts @@ -53,10 +53,10 @@ export function cleanUpFilename(filename: string): string { /** Converts a string to an array buffer */ export function s2ab(s: string) { - const buf = new ArrayBuffer(s.length); //convert s to arrayBuffer - const view = new Uint8Array(buf); //create uint8array as viewer + const buf = new ArrayBuffer(s.length); // convert s to arrayBuffer + const view = new Uint8Array(buf); // create uint8array as viewer for (let i = 0; i < s.length; i++) { - view[i] = s.charCodeAt(i) & 0xff; //convert to octet + view[i] = s.charCodeAt(i) & 0xff; // convert to octet } return buf; } diff --git a/apps/frontend/src/views/Compare.vue b/apps/frontend/src/views/Compare.vue index be86448cbf..35d2d35283 100644 --- a/apps/frontend/src/views/Compare.vue +++ b/apps/frontend/src/views/Compare.vue @@ -316,7 +316,7 @@ export default class Compare extends Vue { return new ComparisonContext(selectedData); } - /** Yields the control pairings that have changed*/ + /** Yields the control pairings that have changed */ get delta_sets(): [string, ControlSeries][] { return this.searched_sets.filter(([_id, series]) => { const controls = Object.values(series).map( diff --git a/apps/frontend/src/views/Results.vue b/apps/frontend/src/views/Results.vue index 639ad5d2d6..c55c9381f9 100644 --- a/apps/frontend/src/views/Results.vue +++ b/apps/frontend/src/views/Results.vue @@ -533,7 +533,7 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { return this.$router.currentRoute.path.replaceAll(/[^a-z]/giv, ''); } - //changes width of eval info if it is in server mode and needs more room for tags + // changes width of eval info if it is in server mode and needs more room for tags get info_width(): number { if (ServerModule.serverMode) { return 500; @@ -541,7 +541,7 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { return 300; } - //basically a v-model for the eval info cards when there is no slide group + // basically a v-model for the eval info cards when there is no slide group toggle_profile( file: SourcedContextualizedEvaluation | SourcedContextualizedProfile ) { diff --git a/apps/frontend/tests/unit/Compare.spec.ts b/apps/frontend/tests/unit/Compare.spec.ts index ed4ec75b90..e3bf869ab2 100644 --- a/apps/frontend/tests/unit/Compare.spec.ts +++ b/apps/frontend/tests/unit/Compare.spec.ts @@ -201,7 +201,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files', async () => { await loadSample('NGINX With Failing Tests'); await loadSample('NGINX Clean Sample'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 0], @@ -215,7 +215,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files with differing profiles', async () => { await loadSample('NGINX With Failing Tests'); await loadSample('Red Hat With Failing Tests'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 6], @@ -229,7 +229,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files with overlayed profiles', async () => { await loadSample('Three Layer RHEL7 Overlay Example'); await loadSample('Acme Overlay Example'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 8], diff --git a/libs/hdf-converters/src/anchore-grype-mapper.ts b/libs/hdf-converters/src/anchore-grype-mapper.ts index 4eedfaf21d..12b4e6ab87 100644 --- a/libs/hdf-converters/src/anchore-grype-mapper.ts +++ b/libs/hdf-converters/src/anchore-grype-mapper.ts @@ -24,7 +24,7 @@ function skipSeverityNegligibleOrUnknown(controls: unknown[]): unknown[] { // Filter to controls whose highest rating severity is either `negligible` or `unknown` .filter((control) => { const rating = _.get(control, 'tags.severity', '') as string; - //console.log(rating) + // console.log(rating) return rating === 'Negligible' || rating === 'Unknown'; }) // For every result contained by that control, set the status to skipped and request a manual review @@ -216,13 +216,14 @@ export class AnchoreGrypeMapper extends BaseConverter { passthrough: { transformer: (data: Record): Record => { return { - auxiliary_data: [{name: '', data: _.omit([])}], //Insert service name and mapped fields to be removed + auxiliary_data: [{name: '', data: _.omit([])}], // Insert service name and mapped fields to be removed ...(this.withRaw && {raw: data}) }; } } }; } + constructor(exportJson: string, withRaw = false) { const temp = JSON.parse(exportJson); super({wrapper: _.pick(temp, ['matches', 'ignoredMatches'])}); diff --git a/libs/hdf-converters/src/aws-config-mapper.ts b/libs/hdf-converters/src/aws-config-mapper.ts index 3eb84af0d5..e8a601c9c8 100644 --- a/libs/hdf-converters/src/aws-config-mapper.ts +++ b/libs/hdf-converters/src/aws-config-mapper.ts @@ -419,7 +419,7 @@ export class AwsConfigMapper { }, version: HeimdallToolsVersion, statistics: { - //aws_config_sdk_version: ConfigService., // How do i get the sdk version? + // aws_config_sdk_version: ConfigService., // How do i get the sdk version? duration: null }, profiles: [ diff --git a/libs/hdf-converters/src/base-converter.ts b/libs/hdf-converters/src/base-converter.ts index e45e101fbe..f679f54b01 100644 --- a/libs/hdf-converters/src/base-converter.ts +++ b/libs/hdf-converters/src/base-converter.ts @@ -188,6 +188,7 @@ export class BaseConverter> { Object.entries(obj).map(([k, v]) => [k, fn(v as ObjectEntryValue)]) ) as Record; } + convertInternal( file: Record, fields: T @@ -396,6 +397,7 @@ export class BaseConverter> { return _.get(file, pathArray[index]) ?? ''; } } + hasPath(file: Record, path: string | string[]): boolean { let pathArray; if (typeof path === 'string') { diff --git a/libs/hdf-converters/src/burpsuite-mapper.ts b/libs/hdf-converters/src/burpsuite-mapper.ts index 266a90f328..59e9cf15aa 100644 --- a/libs/hdf-converters/src/burpsuite-mapper.ts +++ b/libs/hdf-converters/src/burpsuite-mapper.ts @@ -168,6 +168,7 @@ export class BurpSuiteMapper extends BaseConverter { } } }; + constructor(burpsXml: string, withRaw = false) { super(parseXml(burpsXml)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index 98a93c5a9c..cf831f7402 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -498,7 +498,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< ).find((key) => key.toLowerCase() === attributeName.toLowerCase()); if (keyFoundInVulnattribute) { if (separateElementNames.includes(keyFoundInVulnattribute)) { - const dataStrings = data?.toString().split(/[,|;]/) ?? []; + const dataStrings = data?.toString().split(/[,;|]/) ?? []; for (const dataString of dataStrings) { stigdata.push({ vulnattribute: diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/asff-types.ts b/libs/hdf-converters/src/converters-from-hdf/asff/asff-types.ts index 540fa805e7..09ec8c54ed 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/asff-types.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/asff-types.ts @@ -1,4 +1,4 @@ -/////Interfaces for ExecJSON focused on ASFF +/// //Interfaces for ExecJSON focused on ASFF export interface IOptions { input: string; diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts index c13b0418a8..59acfcadf1 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts @@ -213,7 +213,7 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { return _.chunk( value, ATTRIBUTE_CHARACTER_LIMIT - - (type.length + attribute.length + 2) /*the slashes*/ + (type.length + attribute.length + 2) /* the slashes */ ).map((chunk) => `${type}/${attribute}/${chunk.join('')}`); }) .flat(); @@ -374,13 +374,13 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { return restrictedResults; } - //Convert from HDF to ASFF + // Convert from HDF to ASFF toAsff(): IFindingASFF[] { if (this.mappings() === undefined) { throw new Error('Mappings must be provided'); } else { - //Recursively transform the data into ASFF format - //Returns an array of the findings + // Recursively transform the data into ASFF format + // Returns an array of the findings let resList: IFindingASFF[] = this.controlsToSegments().map( (segment, index) => { this.index = index; diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts b/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts index 0d55b0ebc8..f31af2bb9f 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts @@ -17,7 +17,7 @@ import { SegmentedControl } from './reverse-asff-mapper'; -//FromHdfToAsff mapper transformers +// FromHdfToAsff mapper transformers type Counts = { Passed: number; PassedTests: number; diff --git a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts index 05d5a0ed8f..e060f9fffb 100644 --- a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts +++ b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts @@ -15,7 +15,7 @@ export interface ILookupPathFH { default?: any; } -//Base converter used to support conversions from HDF to Any Format +// Base converter used to support conversions from HDF to Any Format export class FromHdfBaseConverter { data: ExecJSON.Execution; mappings?: MappedTransform; @@ -30,7 +30,7 @@ export class FromHdfBaseConverter { this.mappings = mappings; } - //Called over and over to iterate through objects assigned to keys too + // Called over and over to iterate through objects assigned to keys too convertInternal(file: object, fields: T): MappedReform { const result = this.objectMap(fields as T[], (v) => this.evaluate(file, v as T & object & ILookupPathFH) @@ -48,7 +48,7 @@ export class FromHdfBaseConverter { ) as Record; } - //Used to get the data located at the paths + // Used to get the data located at the paths evaluate( file: object, v: T | Array @@ -97,7 +97,7 @@ export class FromHdfBaseConverter { const resultingData: Array = []; // Looks through parsed data file using the mapping setup in V if (v[0] && !v[0].path) { - const arrayTransformer = v[0].arrayTransformer; //does nothing since null + const arrayTransformer = v[0].arrayTransformer; // does nothing since null let output: Array = v.map( (element) => this.evaluate(file, element) as T ); @@ -110,7 +110,7 @@ export class FromHdfBaseConverter { const arrayTransformer = v[0].arrayTransformer; const transformer = v[0].transformer; if (this.hasPath(file, path)) { - const pathVal = this.handlePath(file, path); //Any matches in the path even if more than one, will grab an array of results + const pathVal = this.handlePath(file, path); // Any matches in the path even if more than one, will grab an array of results if (Array.isArray(pathVal)) { v = pathVal.map( (element: Record) => @@ -143,7 +143,7 @@ export class FromHdfBaseConverter { return uniqueResults; } - //Gets the value at the path using lodash and path stored in object + // Gets the value at the path using lodash and path stored in object handlePath(file: object, path: string): unknown { if (path.startsWith('$.')) { return _.get(this.data, path.slice(2)); @@ -151,6 +151,7 @@ export class FromHdfBaseConverter { return _.get(file, path); } } + hasPath(file: object, path: string): boolean { if (path.startsWith('$.')) { return _.has(this.data, path.slice(2)); diff --git a/libs/hdf-converters/src/conveyor-mapper.ts b/libs/hdf-converters/src/conveyor-mapper.ts index 1e8a99331e..c9a49efa51 100644 --- a/libs/hdf-converters/src/conveyor-mapper.ts +++ b/libs/hdf-converters/src/conveyor-mapper.ts @@ -46,7 +46,7 @@ function collateShaAndFilenames( const shaFilePairs: string[][] = []; for (const [sha, file] of Object.entries(currLevel)) { if (_.has(file, 'name')) { - //name always array of size 1 + // name always array of size 1 const name: string = _.get(file, 'name[0]') || ''; shaFilePairs.push([sha, name]); } @@ -241,6 +241,7 @@ export class ConveyorMapper extends BaseConverter { } ] }; + constructor( remappedConveyorResults: Record, data: Record, diff --git a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts index 62f77fb1ee..9a7a79da49 100644 --- a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts +++ b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts @@ -670,6 +670,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { } } }; + constructor(exportJson: DataStorage, withRaw = false) { super(exportJson, true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/dbprotect-mapper.ts b/libs/hdf-converters/src/dbprotect-mapper.ts index 9cd67a35ab..c0ae43f5b9 100644 --- a/libs/hdf-converters/src/dbprotect-mapper.ts +++ b/libs/hdf-converters/src/dbprotect-mapper.ts @@ -144,6 +144,7 @@ export class DBProtectMapper extends BaseConverter { } } }; + constructor(dbProtectXml: string, withRaw = false) { super(compileFindings(parseXml(dbProtectXml))); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/dependency-track-mapper.ts b/libs/hdf-converters/src/dependency-track-mapper.ts index b0414da003..0cb5ceb791 100644 --- a/libs/hdf-converters/src/dependency-track-mapper.ts +++ b/libs/hdf-converters/src/dependency-track-mapper.ts @@ -196,6 +196,7 @@ export class DependencyTrackMapper extends BaseConverter { } } }; + constructor(dtJson: string, withRaw = false) { super(JSON.parse(dtJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/fortify-mapper.ts b/libs/hdf-converters/src/fortify-mapper.ts index 37ad1b6e5c..b34791c584 100644 --- a/libs/hdf-converters/src/fortify-mapper.ts +++ b/libs/hdf-converters/src/fortify-mapper.ts @@ -39,7 +39,7 @@ function nistTag(rule: Record): string[] { if (tag === null || tag === undefined) { return DEFAULT_NIST_TAG; } else { - return _.get(tag, 'Title').match(/[a-zA-Z][a-zA-Z]-\d{1,2}/); + return _.get(tag, 'Title').match(/[A-Za-z][A-Za-z]-\d{1,2}/); } } return []; @@ -222,6 +222,7 @@ export class FortifyMapper extends BaseConverter { } } }; + constructor(fvdl: string, withRaw = false) { super( parseXml(fvdl, { diff --git a/libs/hdf-converters/src/gosec-mapper.ts b/libs/hdf-converters/src/gosec-mapper.ts index b358c0a809..f12eebda5b 100644 --- a/libs/hdf-converters/src/gosec-mapper.ts +++ b/libs/hdf-converters/src/gosec-mapper.ts @@ -135,6 +135,7 @@ export class GosecMapper extends BaseConverter { } } }; + constructor(gosecJson: string, withRaw = false) { super(JSON.parse(gosecJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/jfrog-xray-mapper.ts b/libs/hdf-converters/src/jfrog-xray-mapper.ts index 03c1cea40e..f91a3ce974 100644 --- a/libs/hdf-converters/src/jfrog-xray-mapper.ts +++ b/libs/hdf-converters/src/jfrog-xray-mapper.ts @@ -191,6 +191,7 @@ export class JfrogXrayMapper extends BaseConverter { } } }; + constructor(xrayJson: string, withRaw = false) { super(JSON.parse(xrayJson), true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/mappings/CweNistMapping.ts b/libs/hdf-converters/src/mappings/CweNistMapping.ts index ec43ffe8f7..792c6a0a04 100644 --- a/libs/hdf-converters/src/mappings/CweNistMapping.ts +++ b/libs/hdf-converters/src/mappings/CweNistMapping.ts @@ -21,6 +21,7 @@ export class CweNistMapping { }); } } + nistFilter(identifiers: string[] | string, defaultNist?: string[]): string[] { const DEFAULT_NIST_TAG = defaultNist; if (!Array.isArray(identifiers)) { diff --git a/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts b/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts index 5979f3dce0..4d1df56b5e 100644 --- a/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts +++ b/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts @@ -19,6 +19,7 @@ export class NessusPluginsNistMapping { }); } } + nistFilter(family: string, id: string, defaultNist: string[]): string[] { const DEFAULT_NIST_TAG = defaultNist; const matches: string[] = []; diff --git a/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts b/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts index 7bceecf61c..97889cbfcf 100644 --- a/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts +++ b/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts @@ -17,6 +17,7 @@ export class ScoutsuiteNistMapping { this.data = data.map((line) => new ScoutsuiteNistMappingItem(line)); } } + nistTag(rule: string): string[] { if (rule === '' || rule === undefined) { return DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; diff --git a/libs/hdf-converters/src/msft-secure-score-mapper.ts b/libs/hdf-converters/src/msft-secure-score-mapper.ts index f10faf3a3b..f441c503bd 100644 --- a/libs/hdf-converters/src/msft-secure-score-mapper.ts +++ b/libs/hdf-converters/src/msft-secure-score-mapper.ts @@ -342,6 +342,7 @@ export class MsftSecureScoreMapper extends BaseConverter { } } }; + constructor(secureScore_and_profiles_combined: string, withRaw = false) { const rawParams = JSON.parse(secureScore_and_profiles_combined); super(rawParams.secureScore.value[0]); diff --git a/libs/hdf-converters/src/nessus-mapper.ts b/libs/hdf-converters/src/nessus-mapper.ts index f7f84ab44f..23a6c6ac01 100644 --- a/libs/hdf-converters/src/nessus-mapper.ts +++ b/libs/hdf-converters/src/nessus-mapper.ts @@ -370,6 +370,7 @@ export class NessusMapper extends BaseConverter { } } }; + constructor(nessusJson: Record, withRaw = false) { super(nessusJson); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/netsparker-mapper.ts b/libs/hdf-converters/src/netsparker-mapper.ts index 970dab8728..7c9a4d0025 100644 --- a/libs/hdf-converters/src/netsparker-mapper.ts +++ b/libs/hdf-converters/src/netsparker-mapper.ts @@ -269,6 +269,7 @@ export class NetsparkerMapper extends BaseConverter { } }; } + constructor(netsparkerXml: string, withRaw = false) { super(parseXml(netsparkerXml)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/neuvector-mapper.ts b/libs/hdf-converters/src/neuvector-mapper.ts index 49b1b4c16f..a4c5187900 100644 --- a/libs/hdf-converters/src/neuvector-mapper.ts +++ b/libs/hdf-converters/src/neuvector-mapper.ts @@ -187,6 +187,7 @@ export class NeuVectorMapper extends BaseConverter { } } }; + constructor(exportJson: string, withRaw = false) { const rawParams = JSON.parse(exportJson); super(rawParams); diff --git a/libs/hdf-converters/src/nikto-mapper.ts b/libs/hdf-converters/src/nikto-mapper.ts index b36d43d7c8..1a19467ced 100644 --- a/libs/hdf-converters/src/nikto-mapper.ts +++ b/libs/hdf-converters/src/nikto-mapper.ts @@ -99,6 +99,7 @@ export class NiktoMapper extends BaseConverter { } } }; + constructor(niktoJson: string, withRaw = false) { super(JSON.parse(niktoJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/sarif-mapper.ts b/libs/hdf-converters/src/sarif-mapper.ts index c45188ae7e..b5b0a9b7ba 100644 --- a/libs/hdf-converters/src/sarif-mapper.ts +++ b/libs/hdf-converters/src/sarif-mapper.ts @@ -168,6 +168,7 @@ export class SarifMapper extends BaseConverter { } } }; + constructor(sarifJson: string, withRaw = false) { super(JSON.parse(sarifJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/scoutsuite-mapper.ts b/libs/hdf-converters/src/scoutsuite-mapper.ts index 46736b958a..d0eb74a872 100644 --- a/libs/hdf-converters/src/scoutsuite-mapper.ts +++ b/libs/hdf-converters/src/scoutsuite-mapper.ts @@ -291,6 +291,7 @@ export class ScoutsuiteMapper extends BaseConverter { } } }; + constructor(scoutsuiteJson: string, withRaw = false) { super(collapseServices(JSON.parse(scoutsuiteJson.split('\n', 2)[1]))); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/snyk-mapper.ts b/libs/hdf-converters/src/snyk-mapper.ts index 81e904db14..07bd342ed4 100644 --- a/libs/hdf-converters/src/snyk-mapper.ts +++ b/libs/hdf-converters/src/snyk-mapper.ts @@ -172,6 +172,7 @@ export class SnykMapper extends BaseConverter { } } }; + constructor(snykJson: Record) { super(snykJson); } diff --git a/libs/hdf-converters/src/sonarqube-mapper.ts b/libs/hdf-converters/src/sonarqube-mapper.ts index 115db60f94..3bd8585bef 100644 --- a/libs/hdf-converters/src/sonarqube-mapper.ts +++ b/libs/hdf-converters/src/sonarqube-mapper.ts @@ -374,7 +374,7 @@ function parseCweTags( if (rule.descriptionSections) { searchSpace += rule.descriptionSections.map((s) => s.content).join(''); } - const uniqueCwes = _.uniq(searchSpace.match(/CWE-\d\d\d?\d?\d?\d?\d/gi)); // CWE IDs are embedded inside of the HTML + const uniqueCwes = _.uniq(searchSpace.match(/cwe-\d\d\d?\d?\d?\d?\d/gi)); // CWE IDs are embedded inside of the HTML if (uniqueCwes.length) { return uniqueCwes; diff --git a/libs/hdf-converters/src/trufflehog-mapper.ts b/libs/hdf-converters/src/trufflehog-mapper.ts index 4871d96a58..506831d983 100644 --- a/libs/hdf-converters/src/trufflehog-mapper.ts +++ b/libs/hdf-converters/src/trufflehog-mapper.ts @@ -116,6 +116,7 @@ export class TrufflehogMapper extends BaseConverter { } } }; + constructor(trufflehogJson: Record, withRaw = false) { super(trufflehogJson, true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/twistlock-mapper.ts b/libs/hdf-converters/src/twistlock-mapper.ts index 7dd38f6e39..d0a8d80201 100644 --- a/libs/hdf-converters/src/twistlock-mapper.ts +++ b/libs/hdf-converters/src/twistlock-mapper.ts @@ -174,6 +174,7 @@ export class TwistlockMapper extends BaseConverter { } } }; + constructor(twistlockJson: Record, withRaw = false) { super(twistlockJson, true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/utils/global.ts b/libs/hdf-converters/src/utils/global.ts index 5e73800e4c..3d82a7c6de 100644 --- a/libs/hdf-converters/src/utils/global.ts +++ b/libs/hdf-converters/src/utils/global.ts @@ -24,7 +24,7 @@ export const DEFAULT_INFORMATION_SYSTEM_COMPONENT_MANAGEMENT_NIST_TAGS = [ ]; // The "Types" field of ASFF only supports a maximum of 2 slashes, and will get replaced with this text. Note that the default AWS CLI doesn't support UTF-8 encoding -export const FROM_ASFF_TYPES_SLASH_REPLACEMENT = /{{{SLASH}}}/gi; +export const FROM_ASFF_TYPES_SLASH_REPLACEMENT = /{{{slash}}}/gi; export function createWinstonLogger(mapperName: string, level = 'debug') { return createLogger({ diff --git a/libs/hdf-converters/src/veracode-mapper.ts b/libs/hdf-converters/src/veracode-mapper.ts index 43ae05d641..70d2ce13a2 100644 --- a/libs/hdf-converters/src/veracode-mapper.ts +++ b/libs/hdf-converters/src/veracode-mapper.ts @@ -514,6 +514,7 @@ export class VeracodeMapper extends BaseConverter { ] }; } + constructor(xml: string, withRaw = false) { // the default textNodeName that we're using ('text') clobbers any attributes that also are named 'text' of which there are many in this format // the attribute group names are necessary since there are many times that attributes and inner tags share the same name within a tag (ex. 'vulnerabilities' the attribute is a count whereas as an inner tag it is an array detailing the vulnerabilities) where it seems that the attribute clobbers the inner tag diff --git a/libs/hdf-converters/src/xccdf-results-mapper.ts b/libs/hdf-converters/src/xccdf-results-mapper.ts index 96b329b6b4..4aaf30eb10 100644 --- a/libs/hdf-converters/src/xccdf-results-mapper.ts +++ b/libs/hdf-converters/src/xccdf-results-mapper.ts @@ -630,6 +630,7 @@ export class XCCDFResultsMapper extends BaseConverter { } } }; + constructor(scapXml: string, withRaw = false) { super( parseXml(scapXml, { diff --git a/libs/hdf-converters/src/zap-mapper.ts b/libs/hdf-converters/src/zap-mapper.ts index 947cb894cd..3cd9fb8100 100644 --- a/libs/hdf-converters/src/zap-mapper.ts +++ b/libs/hdf-converters/src/zap-mapper.ts @@ -199,6 +199,7 @@ export class ZapMapper extends BaseConverter { } } }; + constructor(zapJson: string, name?: string, withRaw = false) { super( _.set( diff --git a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts index 0a2f3131bb..b9b2bcd4d2 100644 --- a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts @@ -11,26 +11,26 @@ describe('conveyor_mapper', () => { ) ); const mapped = mapper.toHdf(); - //fs.writeFileSync( + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-moldy-hdf.json', // JSON.stringify(mapped['Moldy'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-stigma-hdf.json', // JSON.stringify(mapped['Stigma'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-codequality-hdf.json', // JSON.stringify(mapped['CodeQuality'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-clamav-hdf.json', // JSON.stringify(mapped['Clamav'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-hdf.json', // JSON.stringify(mapped, null, 2) - //); + // ); expect(omitVersions(mapped['Moldy'])).toEqual( omitVersions( JSON.parse( diff --git a/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts index 7726453d1d..06b8df1804 100644 --- a/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts @@ -12,7 +12,7 @@ describe('ASFF Reverse Mapper', () => { ) ); - //The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool + // The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool const converted = new FromHdfToAsffMapper(inputData, { input: 'rhel7-results.json', awsAccountId: '12345678910', @@ -63,7 +63,7 @@ describe('ASFF Reverse Mapper', () => { ) ); - //The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool + // The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool const converted = new FromHdfToAsffMapper(inputData, { input: 'example-3-layer-overlay_03062022.json', awsAccountId: '12345678910', diff --git a/libs/inspecjs/src/nist.ts b/libs/inspecjs/src/nist.ts index 38c95a2576..0cb91f7f54 100644 --- a/libs/inspecjs/src/nist.ts +++ b/libs/inspecjs/src/nist.ts @@ -9,7 +9,7 @@ const NIST_FAMILY_RE = const NIST_CONTROL_RE = /^(A[CPRTU]|C[AMP]|D[IM]|I[APR]|M[AP]|P[ELMS]|RA|S[ACEI]|TR|U[LM])-(\d+)(.{0,60})$/; const SPEC_SPLITTER = /[\s\(\)\.]+/; // Includes all whitespace, periods, and parenthesis -const REV_RE = /^rev[\s_.]+(\d+)$/i; // Matches Rev_5 etc +const REV_RE = /^rev[\s._]+(\d+)$/i; // Matches Rev_5 etc type ParseNist = NistControl | NistRevision | null; export interface CanonizationConfig { @@ -346,7 +346,7 @@ function _generate_full_nist_hierarchy(): NistHierarchy { asNode = map[key]; asNode.control = asControl; } else { - //Make it fresh + // Make it fresh asNode = { control: asControl, children: [] diff --git a/libs/password-complexity/index.js b/libs/password-complexity/index.js index 2af5b5cdab..b5b8771a69 100644 --- a/libs/password-complexity/index.js +++ b/libs/password-complexity/index.js @@ -12,7 +12,7 @@ const validators = [ RegExp('[a-z]'), // Lowercase characters RegExp('[A-Z]'), // Uppercase characters RegExp('[0-9]'), // Numbers - RegExp(/[^A-Za-z0-9]/) // Special characters (Non Alphanumeric) + RegExp(/[^0-9A-Za-z]/) // Special characters (Non Alphanumeric) ]; return ( checks.filter((expr) => expr.test(password)).length === @@ -28,7 +28,7 @@ const validators = [ RegExp('[a-z]{4,}'), // 5 or more of lowercase characters in a row RegExp('[A-Z]{4,}'), // 5 or more of uppercase characters in a row RegExp('[0-9]{4,}'), // 5 or more numbers in a row - RegExp(/[^A-Za-z0-9]{4,}/) // 5 or more special characters in a row + RegExp(/[^0-9A-Za-z]{4,}/) // 5 or more special characters in a row ]; return checks.filter((expr) => expr.test(password)).length === 0; } diff --git a/test/support/pages/RegistrationPage.ts b/test/support/pages/RegistrationPage.ts index 4d37295801..3c5f073fc8 100644 --- a/test/support/pages/RegistrationPage.ts +++ b/test/support/pages/RegistrationPage.ts @@ -6,6 +6,7 @@ export default class RegistrationPage { cy.waitUntil(() => cy.get('#register').should('not.be.disabled')); cy.get('#register').click(); } + registerNoSubmit(user: CreateUserDto): void { cy.visit('/signup'); cy.get('input[name=firstName]').type(user.firstName); diff --git a/test/support/verifiers/LoginPageVerifier.ts b/test/support/verifiers/LoginPageVerifier.ts index 27b67643cd..75ee24883c 100644 --- a/test/support/verifiers/LoginPageVerifier.ts +++ b/test/support/verifiers/LoginPageVerifier.ts @@ -5,6 +5,7 @@ export default class LoginPageVerifier { cy.get('label[for=email_field]').should('contain', 'Email'); cy.get('label[for=password_field]').should('contain', 'Password'); } + ldapLoginFormPresent(): void { cy.get('form[name="login_form"]').should('exist'); cy.get('label[for=username_field]').should('contain', 'Username'); From aa99a3ee1e1f4f22018a83011171853159b7db36 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:26:31 -0400 Subject: [PATCH 058/197] chore(lint): never lint standards files; drop the link-breaking fixer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A layout fix pass rewrote LICENSE.md and CODE_OF_CONDUCT.md — converting the Apache license URL line into a fenced code block and inserting angle brackets into Contributor Covenant text. Those files are copies of external standards: matching upstream byte-for-byte is their requirement, so no markdown rule can legitimately fire on them and no fixer may ever rewrite them. They are now excluded from markdown linting entirely; the edited copies were restored from HEAD. markdown-preferences/prefer-autolinks is off everywhere: its fixer converted the relative links [SECURITY.md](SECURITY.md) and [manifest.yml.example](manifest.yml.example) into form — but angle-bracket autolinks require an absolute URI with a scheme, so the output is not a link at all; renderers treat it as a raw HTML tag. A fixer that produces invalid markdown from valid markdown cannot be trusted on any file. Authored by: Aaron Lippold --- eslint.config.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e52b7f0a9..4166d776d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -453,7 +453,17 @@ export default defineConfig([ // `**ADMIN_EMAIL**=_admin@heimdall.local_`. // Remove this ignore once the upstream fix lands and @eslint/markdown is // bumped; verify with the five-line reproduction above. - ignores: ['packaging/rpm/man/heimdall-server-backend.env.5.md'], + ignores: [ + 'packaging/rpm/man/heimdall-server-backend.env.5.md', + // Copies of external standards (MITRE SAF license text, Contributor + // Covenant). Matching upstream BYTE-FOR-BYTE is their requirement, so + // no markdown rule can legitimately fire on them — and fixers must + // never rewrite them. A layout `--fix` pass converted LICENSE.md's + // Apache URL line to a fenced block on 2026-08-13 before this ignore + // existed; the files were restored from HEAD. + '**/LICENSE.md', + 'CODE_OF_CONDUCT.md', + ], language: 'markdown/gfm', name: 'markdown', plugins: { markdown }, @@ -476,6 +486,12 @@ export default defineConfig([ 'markdown-preferences/padding-line-between-blocks': 'off', 'markdown-preferences/table-pipe-alignment': 'off', 'markdown-preferences/table-pipe-spacing': 'off', + // Its fixer converts RELATIVE links — [SECURITY.md](SECURITY.md) became + // on 2026-08-13 — but angle-bracket autolinks require an + // absolute URI with a scheme, so the output is not a link at all; + // renderers treat it as a raw HTML tag. A fixer that produces invalid + // markdown from valid markdown cannot be trusted on any file. + 'markdown-preferences/prefer-autolinks': 'off', }, }, // MUST BE LAST. eslint-config-prettier only turns rules OFF — every From 84f61238797941f72538f9eb814cd34907a6c48c Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:28:51 -0400 Subject: [PATCH 059/197] style: use dot notation for literal string property access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @typescript-eslint/dot-notation, applied surgically — only this rule's fixes, extracted from the JSON report by range. Sixty-two conversions of obj['Literal'] to obj.Literal across twenty files (hdf-converters, inspecjs, frontend). Distinct from security/detect-object-injection's concern, which is COMPUTED keys; these are all string literals the compiler verifies as properties. Verified: hdf-converters 156/157 with the SonarQube mock running (the 157th is the Splunk spec, whose service container exists only in CI), inspecjs suite green, frontend suite green, tsc clean for hdf-converters and the frontend. Authored by: Aaron Lippold --- .../cards/controltable/ControlRowHeader.vue | 2 +- apps/frontend/src/main.ts | 2 +- apps/frontend/src/store/server.ts | 2 +- apps/frontend/src/store/severity_counts.ts | 10 ++++----- apps/frontend/tests/util/testingUtils.ts | 10 ++++----- .../ckl-mapper/checklist-jsonix-converter.ts | 22 +++++++++---------- .../src/ckl-mapper/checklist-mapper.ts | 2 +- .../asff/reverse-asff-mapper.ts | 4 +--- .../caat/reverse-caat-mapper.ts | 4 ++-- .../splunk/reverse-splunk-mapper.ts | 8 +++---- libs/hdf-converters/src/ionchannel-mapper.ts | 4 ++-- .../mappings/NessusPluginsNistMappingItem.ts | 10 ++++----- .../src/mappings/NiktoNistMappingItem.ts | 2 +- .../src/mappings/OwaspNistMappingItem.ts | 2 +- .../src/mappings/ScoutsuiteNistMappingItem.ts | 4 ++-- libs/hdf-converters/src/prisma-mapper.ts | 10 ++++----- libs/hdf-converters/src/splunk-mapper.ts | 8 +++---- libs/hdf-converters/src/utils/attestations.ts | 2 +- .../mappers/forward/conveyor_mapper.spec.ts | 6 ++--- .../src/compat_impl/compat_inspec_1_0.ts | 10 ++++----- 20 files changed, 61 insertions(+), 63 deletions(-) diff --git a/apps/frontend/src/components/cards/controltable/ControlRowHeader.vue b/apps/frontend/src/components/cards/controltable/ControlRowHeader.vue index 72c4aa6a40..3356633f61 100644 --- a/apps/frontend/src/components/cards/controltable/ControlRowHeader.vue +++ b/apps/frontend/src/components/cards/controltable/ControlRowHeader.vue @@ -265,7 +265,7 @@ export default class ControlRowHeader extends mixins(HtmlSanitizeMixin) { } showLegacy(control: ContextualizedControl) { - let legacyTag = control.data.tags['legacy']; + let legacyTag = control.data.tags.legacy; if (!legacyTag) { return ''; } diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts index 4e5d51698b..90480edd8a 100644 --- a/apps/frontend/src/main.ts +++ b/apps/frontend/src/main.ts @@ -55,7 +55,7 @@ new Vue({ // places to edit Prism variables, but could not locate them. Namely this is // the Prism library variables, and not the Prism component variables // @ts-ignore -Prism.languages['rb'] = { +Prism.languages.rb = { 'token-name': { pattern: /(?["'])(?:\k|(?:(?![^\\]\k)[\s\S])*[^\\]\k)/gv diff --git a/apps/frontend/src/store/server.ts b/apps/frontend/src/store/server.ts index dc88d396a1..2b30b431bd 100644 --- a/apps/frontend/src/store/server.ts +++ b/apps/frontend/src/store/server.ts @@ -93,7 +93,7 @@ class Server extends VuexModule implements IServerState { SET_TOKEN(newToken: string) { this.token = newToken; localToken.set(newToken); - axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; + axios.defaults.headers.common.Authorization = `Bearer ${newToken}`; } @Mutation diff --git a/apps/frontend/src/store/severity_counts.ts b/apps/frontend/src/store/severity_counts.ts index 2377ceb846..937cff1114 100644 --- a/apps/frontend/src/store/severity_counts.ts +++ b/apps/frontend/src/store/severity_counts.ts @@ -72,23 +72,23 @@ export class SeverityCount extends VuexModule { } get none(): (filter: Filter) => number { - return (filter) => this.hash(filter)['none']; + return (filter) => this.hash(filter).none; } get low(): (filter: Filter) => number { - return (filter) => this.hash(filter)['low']; + return (filter) => this.hash(filter).low; } get medium(): (filter: Filter) => number { - return (filter) => this.hash(filter)['medium']; + return (filter) => this.hash(filter).medium; } get high(): (filter: Filter) => number { - return (filter) => this.hash(filter)['high']; + return (filter) => this.hash(filter).high; } get critical(): (filter: Filter) => number { - return (filter) => this.hash(filter)['critical']; + return (filter) => this.hash(filter).critical; } } diff --git a/apps/frontend/tests/util/testingUtils.ts b/apps/frontend/tests/util/testingUtils.ts index 58544c7e1d..7a63fabab9 100644 --- a/apps/frontend/tests/util/testingUtils.ts +++ b/apps/frontend/tests/util/testingUtils.ts @@ -76,11 +76,11 @@ export function expectedCount( const counts: Record = JSON.parse(countFileContent); - statuses['failed'] += counts.failed.total; - statuses['passed'] += counts.passed.total; - statuses['notReviewed'] += counts.skipped.total; - statuses['notApplicable'] += counts.no_impact.total; - statuses['profileError'] += counts.error.total; + statuses.failed += counts.failed.total; + statuses.passed += counts.passed.total; + statuses.notReviewed += counts.skipped.total; + statuses.notApplicable += counts.no_impact.total; + statuses.profileError += counts.error.total; }); return statuses[status]; diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index cf831f7402..a958a90681 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -669,10 +669,10 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< // if severity or severity override don't fit into low, medium, high // denote them in the control specific data if (severityTag === 'none' || severityTag === 'critical') - hdfSpecificData['severity'] = severityTag; + hdfSpecificData.severity = severityTag; if (severityOverrideTag === 'none' || severityOverrideTag === 'critical') - hdfSpecificData['severityoverride'] = severityOverrideTag; + hdfSpecificData.severityoverride = severityOverrideTag; // if impact does not align with what would be computed from the checklist // store it in the hdfSpecificData @@ -685,19 +685,19 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< impact >= 0.9) && impact !== 0.0 ) { - hdfSpecificData['impact'] = control.impact; + hdfSpecificData.impact = control.impact; } // if there is no severity tag, severity is aligned to impact // this must be represented in hdfSpecificData when impact needs to // map to severity none or critical if (severityTag === null) { - if (impact < 0.1) hdfSpecificData['severity'] = 'none'; - else if (impact >= 0.9) hdfSpecificData['severity'] = 'critical'; + if (impact < 0.1) hdfSpecificData.severity = 'none'; + else if (impact >= 0.9) hdfSpecificData.severity = 'critical'; } if (control.code?.startsWith('control')) { - hdfSpecificData['code'] = control.code; + hdfSpecificData.code = control.code; } const hdfDataExist = Object.keys(hdfSpecificData).length !== 0; @@ -721,19 +721,19 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< addHdfProfileSpecificData(profile: ExecJSON.Profile): string { const hdfSpecificData: Record = {}; if (profile.attributes.length) { - hdfSpecificData['attributes'] = profile.attributes; + hdfSpecificData.attributes = profile.attributes; } if (profile.copyright) { - hdfSpecificData['copyright'] = profile.copyright; + hdfSpecificData.copyright = profile.copyright; } if (profile.copyright_email) { - hdfSpecificData['copyright_email'] = profile.copyright_email; + hdfSpecificData.copyright_email = profile.copyright_email; } if (profile.maintainer) { - hdfSpecificData['maintainer'] = profile.maintainer; + hdfSpecificData.maintainer = profile.maintainer; } if (profile.version) { - hdfSpecificData['version'] = profile.version; + hdfSpecificData.version = profile.version; } const hdfDataExist = Object.keys(hdfSpecificData).length !== 0; diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts index 5f4202dcfa..d570c53dbe 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts @@ -502,7 +502,7 @@ export class ChecklistMapper extends BaseConverter { // not follow above naming conventions const severityOverride = findSeverityOverride(input); if (severityOverride) { - fullTags['severityoverride'] = severityOverride; + fullTags.severityoverride = severityOverride; } return fullTags; } diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts index 59acfcadf1..82e5a0d969 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts @@ -384,9 +384,7 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { let resList: IFindingASFF[] = this.controlsToSegments().map( (segment, index) => { this.index = index; - return this.convertInternal(segment, this.mappings())[ - 'Findings' - ][0] as IFindingASFF; + return this.convertInternal(segment, this.mappings()).Findings[0] as IFindingASFF; } ); resList.push(createProfileInfoFinding(this.data, this.ioptions)); diff --git a/libs/hdf-converters/src/converters-from-hdf/caat/reverse-caat-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/caat/reverse-caat-mapper.ts index 6b2a4cd89f..910dcf3585 100644 --- a/libs/hdf-converters/src/converters-from-hdf/caat/reverse-caat-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/caat/reverse-caat-mapper.ts @@ -182,7 +182,7 @@ export class FromHDFToCAATMapper { row['Finding Description'] = FromHDFToCAATMapper.fix(hdf.wraps.title); row['Weakness Description'] = this.newCaveat(hdf); row['Control Weakness Type'] = 'Security'; - row['Source'] = 'Self-Assessment'; + row.Source = 'Self-Assessment'; row['Test Method'] = 'Test'; row['Test Objective'] = FromHDFToCAATMapper.fix( hdf.descriptions.check ?? hdf.wraps.tags.check @@ -194,7 +194,7 @@ export class FromHDFToCAATMapper { row['Recommended Corrective Action(s)'] = FromHDFToCAATMapper.fix( hdf.descriptions.fix ?? hdf.wraps.tags.fix ); - row['Impact'] = this.newImpact(hdf); + row.Impact = this.newImpact(hdf); return row; }) ); diff --git a/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts index aedbe0dcbc..034b879648 100644 --- a/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts @@ -213,7 +213,7 @@ export function createControlMapping( const descObjects: Record = {}; if (Array.isArray(data)) { for (const item of data) { - descObjects[item['label']] = item['data']; + descObjects[item.label] = item.data; } } return descObjects; @@ -335,8 +335,8 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { splunkData: SplunkData ): Promise { const hostname = generateHostname(config); - this.axiosInstance.defaults.params['sourcetype'] = MAPPER_NAME; - this.axiosInstance.defaults.params['index'] = targetIndex.name; + this.axiosInstance.defaults.params.sourcetype = MAPPER_NAME; + this.axiosInstance.defaults.params.index = targetIndex.name; try { // Upload execution event @@ -397,7 +397,7 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { // Attempt to authenticate using given credentials const authResponse = await checkSplunkCredentials(config); - this.axiosInstance.defaults.headers.common['Authorization'] = + this.axiosInstance.defaults.headers.common.Authorization = `Bearer ${authResponse}`; // Request all available indexes diff --git a/libs/hdf-converters/src/ionchannel-mapper.ts b/libs/hdf-converters/src/ionchannel-mapper.ts index 8157f50751..52f134ebea 100644 --- a/libs/hdf-converters/src/ionchannel-mapper.ts +++ b/libs/hdf-converters/src/ionchannel-mapper.ts @@ -126,9 +126,9 @@ export class IonChannelAPIMapper { this.analysisId = analysisId; this.apiClient = axios.create(); - this.apiClient.defaults.headers.common['Authorization'] = + this.apiClient.defaults.headers.common.Authorization = `Bearer ${this.apiKey}`; - this.apiClient.defaults.headers.common['Accept'] = + this.apiClient.defaults.headers.common.Accept = 'application/json, text/plain, */*'; } diff --git a/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts b/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts index 1ecca5c66c..194d2a2de2 100644 --- a/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts @@ -6,18 +6,18 @@ export class NessusPluginsNistMappingItem { nistId: string; constructor(values: INESSUSJSONID) { - if (values['pluginFamily'] === undefined) { + if (values.pluginFamily === undefined) { throw new Error( 'Nessus Plugins Nist Mapping Data must contain a plugin family.' ); } else { - this.pluginFamily = values['pluginFamily']; + this.pluginFamily = values.pluginFamily; } // Could be a string "*" or a number - if (typeof values['pluginID'] === 'string') { - this.pluginId = values['pluginID']; + if (typeof values.pluginID === 'string') { + this.pluginId = values.pluginID; } else { - this.pluginId = values['pluginID'].toString(); + this.pluginId = values.pluginID.toString(); } if (values['NIST-ID'] === undefined) { this.nistId = ''; diff --git a/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts b/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts index 9f6294b994..e4c1a90911 100644 --- a/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts @@ -24,6 +24,6 @@ export class NiktoNistMappingItem { } else { this.nistId = values['NIST-ID']; } - this.osvdb = values['OSVDB']; + this.osvdb = values.OSVDB; } } diff --git a/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts b/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts index 6fb0486fdc..0c3ca12628 100644 --- a/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts @@ -23,7 +23,7 @@ export class OwaspNistMappingItem { } else { this.nistId = values['NIST-ID']; } - this.rev = values['Rev']; + this.rev = values.Rev; if (values['NIST Name'] === undefined) { throw new Error('OWASP Nist Mapping Data must contain a nist name.'); } else { diff --git a/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts b/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts index 5f2f238d4d..a90a1f1d49 100644 --- a/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts @@ -5,10 +5,10 @@ export class ScoutsuiteNistMappingItem { nistId: string; constructor(values: ISCOUTSUITEJSONID) { - if (values['RULE'] === undefined) { + if (values.RULE === undefined) { throw new Error('Scoutsuite Nist Mapping Data must contain a rule.'); } else { - this.rule = values['RULE']; + this.rule = values.RULE; } if (values['NIST-ID'] === undefined) { this.nistId = ''; diff --git a/libs/hdf-converters/src/prisma-mapper.ts b/libs/hdf-converters/src/prisma-mapper.ts index 69c165d65b..e409376585 100644 --- a/libs/hdf-converters/src/prisma-mapper.ts +++ b/libs/hdf-converters/src/prisma-mapper.ts @@ -121,8 +121,8 @@ export class PrismaControlMapper extends BaseConverter { transformer: (obj: PrismaControl) => { let result = ''; if (obj.Type === 'image') { - if (obj['Packages'] !== '') { - result += `Version check of package: ${obj['Packages']}`; + if (obj.Packages !== '') { + result += `Version check of package: ${obj.Packages}`; } } else if (obj.Type === 'linux') { if (obj.Distro !== '') { @@ -174,9 +174,9 @@ export class PrismaMapper { const executions: ExecJSON.Execution[] = []; const hostnameToControls: Record = {}; this.data.forEach((record: PrismaControl) => { - hostnameToControls[record['Hostname']] = - hostnameToControls[record['Hostname']] || []; - hostnameToControls[record['Hostname']].push(record); + hostnameToControls[record.Hostname] = + hostnameToControls[record.Hostname] || []; + hostnameToControls[record.Hostname].push(record); }); Object.entries(hostnameToControls).forEach(([hostname, controls]) => { const converted = new PrismaControlMapper(controls).toHdf(); diff --git a/libs/hdf-converters/src/splunk-mapper.ts b/libs/hdf-converters/src/splunk-mapper.ts index 9c3c9001f5..993385e50f 100644 --- a/libs/hdf-converters/src/splunk-mapper.ts +++ b/libs/hdf-converters/src/splunk-mapper.ts @@ -100,11 +100,11 @@ function consolidateFilePayloads( // In the end we wish to produce a single evaluation EventPayload which in fact contains all data for the guid // Group by subtype const subtypes = groupBy(filePayloads, (event) => event.meta.subtype); - const execEvents = (subtypes['header'] || + const execEvents = (subtypes.header || []) as Partial[]; - const profileEvents = (subtypes['profile'] || + const profileEvents = (subtypes.profile || []) as unknown as (ExecJSON.Profile & GenericPayloadWithMetaData)[]; - const controlEvents = (subtypes['control'] || + const controlEvents = (subtypes.control || []) as unknown as (ExecJSON.Control & GenericPayloadWithMetaData)[]; logger.debug(`Have ${execEvents.length} execution events`); @@ -351,7 +351,7 @@ export class SplunkMapper { // Request session key for Axios instance const authToken = await checkSplunkCredentials(this.config); - this.axiosInstance.defaults.headers.common['Authorization'] = + this.axiosInstance.defaults.headers.common.Authorization = `Bearer ${authToken}`; // Create new search job from given query diff --git a/libs/hdf-converters/src/utils/attestations.ts b/libs/hdf-converters/src/utils/attestations.ts index 6e633a440d..6797e05af3 100644 --- a/libs/hdf-converters/src/utils/attestations.ts +++ b/libs/hdf-converters/src/utils/attestations.ts @@ -174,7 +174,7 @@ export async function parseXLSXAttestations( const workbook = XLSX.read(attestationXLSX, { cellDates: true }); - const sheet = workbook.Sheets['attestations']; + const sheet = workbook.Sheets.attestations; const data: Record[] = XLSX.utils.sheet_to_json(sheet); const attestations: Attestation[] = data.map((attestation) => { diff --git a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts index b9b2bcd4d2..d1637ba33f 100644 --- a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts @@ -31,7 +31,7 @@ describe('conveyor_mapper', () => { // 'sample_jsons/conveyor_mapper/conveyor-hdf.json', // JSON.stringify(mapped, null, 2) // ); - expect(omitVersions(mapped['Moldy'])).toEqual( + expect(omitVersions(mapped.Moldy)).toEqual( omitVersions( JSON.parse( fs.readFileSync( @@ -43,7 +43,7 @@ describe('conveyor_mapper', () => { ) ) ); - expect(omitVersions(mapped['Stigma'])).toEqual( + expect(omitVersions(mapped.Stigma)).toEqual( omitVersions( JSON.parse( fs.readFileSync( @@ -55,7 +55,7 @@ describe('conveyor_mapper', () => { ) ) ); - expect(omitVersions(mapped['Clamav'])).toEqual( + expect(omitVersions(mapped.Clamav)).toEqual( omitVersions( JSON.parse( fs.readFileSync( diff --git a/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts b/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts index 129866c8aa..052b08a9f9 100644 --- a/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts +++ b/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts @@ -109,7 +109,7 @@ abstract class HDFControl10 implements HDFControl { private static compute_raw_nist_tags( raw: ResultControl_1_0 | ProfileControl_1_0 ): string[] | string { - const fetched: string[] | string | undefined | null = raw.tags['nist']; + const fetched: string[] | string | undefined | null = raw.tags.nist; if (!fetched) { return ['UM-1']; } else { @@ -167,12 +167,12 @@ abstract class HDFControl10 implements HDFControl { raw: ResultControl_1_0 | ProfileControl_1_0 ): Severity { // use severity override tag if it exists - if (severities.includes(raw.tags['severityoverride']?.toLowerCase())) - return raw.tags['severityoverride']; + if (severities.includes(raw.tags.severityoverride?.toLowerCase())) + return raw.tags.severityoverride; // use severity tag if it exists - if (severities.includes(raw.tags['severity']?.toLowerCase())) - return raw.tags['severity']; + if (severities.includes(raw.tags.severity?.toLowerCase())) + return raw.tags.severity; // otherwise, compute severity with impact return convertImpactToSeverity(raw.impact); From 6bcdb4cdb8e746c1d717bdc907949b10a4b37280 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:33:22 -0400 Subject: [PATCH 060/197] style: use bracket array syntax consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @typescript-eslint/array-type, applied surgically from the JSON report — 45 conversions of Array to T[] (parenthesized for union elements) across 17 files. Pure type-syntax equivalence; no runtime representation exists to change. Verified: tsc clean (hdf-converters, frontend non-vue), inspecjs suite green, frontend suite green, hdf-converters 156/157 with the SonarQube mock up — the 157th is the Splunk service spec, CI-only as before. Authored by: Aaron Lippold --- .../src/components/global/ExportNist.vue | 2 +- .../global/upload_tabs/FileReader.vue | 2 +- .../global/upload_tabs/aws/S3Reader.vue | 2 +- apps/frontend/src/store/evaluations.ts | 2 +- apps/frontend/src/utilities/helper_util.ts | 4 ++-- apps/frontend/src/utilities/treemap_util.ts | 4 ++-- apps/frontend/tests/unit/Results.spec.ts | 8 +++---- .../evaluation/evaluation.interface.ts | 4 ++-- libs/hdf-converters/src/base-converter.ts | 22 +++++++++---------- libs/hdf-converters/src/checkov-mapper.ts | 2 +- .../reverse-any-base-converter.ts | 2 +- .../reverse-base-converter.ts | 14 ++++++------ .../src/dependency-track-mapper.ts | 2 +- libs/hdf-converters/src/splunk-mapper.ts | 6 ++--- libs/hdf-converters/src/zap-mapper.ts | 2 +- libs/hdf-converters/types/cyclonedx.d.ts | 8 +++---- libs/inspecjs/src/raw_nist.ts | 4 ++-- 17 files changed, 45 insertions(+), 45 deletions(-) diff --git a/apps/frontend/src/components/global/ExportNist.vue b/apps/frontend/src/components/global/ExportNist.vue index 480877f896..ec1d475530 100644 --- a/apps/frontend/src/components/global/ExportNist.vue +++ b/apps/frontend/src/components/global/ExportNist.vue @@ -120,7 +120,7 @@ export default class ExportNIST extends Vue { export_nist() { // Get files we plan on exporting - const files: Array = [ + const files: (FileID | undefined)[] = [ undefined, ...FilteredDataModule.selected_file_ids ]; diff --git a/apps/frontend/src/components/global/upload_tabs/FileReader.vue b/apps/frontend/src/components/global/upload_tabs/FileReader.vue index b9a0df8e1a..1cc1eab45c 100644 --- a/apps/frontend/src/components/global/upload_tabs/FileReader.vue +++ b/apps/frontend/src/components/global/upload_tabs/FileReader.vue @@ -147,7 +147,7 @@ interface VueFileAgentRecord { */ @Component export default class FileReader extends mixins(ServerMixin) { - fileRecords: Array = []; + fileRecords: VueFileAgentRecord[] = []; loading = false; percent = 0; isActiveDialog = false; diff --git a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue b/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue index e92f4f4a94..e1f4df218e 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue +++ b/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue @@ -248,7 +248,7 @@ export default class S3Reader extends Vue { } /** Callback on got files */ - gotFiles(files: Array) { + gotFiles(files: FileID[]) { this.$emit('got-files', files); } } diff --git a/apps/frontend/src/store/evaluations.ts b/apps/frontend/src/store/evaluations.ts index cc3c7a8bd8..2d10d51590 100644 --- a/apps/frontend/src/store/evaluations.ts +++ b/apps/frontend/src/store/evaluations.ts @@ -39,7 +39,7 @@ export class Evaluation extends VuexModule { page: number = 1; offset: number = 0; limit: number = 10; - order: Array = ['createdAt', 'DESC']; + order: string[] = ['createdAt', 'DESC']; loading = true; get evaluationForFile(): Function { diff --git a/apps/frontend/src/utilities/helper_util.ts b/apps/frontend/src/utilities/helper_util.ts index 9a48b64416..75f29a0252 100644 --- a/apps/frontend/src/utilities/helper_util.ts +++ b/apps/frontend/src/utilities/helper_util.ts @@ -11,8 +11,8 @@ import * as _ from 'lodash'; * if a is lexicographically after b, return > 1 */ export function compareArrays( - a: Array, - b: Array, + a: T[], + b: T[], comparator: (a: T, b: T) => number ) { // Compare element-wise diff --git a/apps/frontend/src/utilities/treemap_util.ts b/apps/frontend/src/utilities/treemap_util.ts index 1da4550fd5..029fe30d18 100644 --- a/apps/frontend/src/utilities/treemap_util.ts +++ b/apps/frontend/src/utilities/treemap_util.ts @@ -52,7 +52,7 @@ export type D3TreemapNode = HierarchyNode; * @param controls The controls to build into a nist node map */ function controls_to_nist_node_data( - contextualizedControls: Readonly, + contextualizedControls: readonly ContextualizedControl[], colors: ColorHack ): TreemapNodeLeaf[] { return contextualizedControls.flatMap((cc) => { @@ -226,7 +226,7 @@ function node_data_to_tree_map( /** Does all the steps */ export function build_nist_tree_map( - data: Readonly, + data: readonly ContextualizedControl[], colors: ColorHack ): D3TreemapNode { const leaves = controls_to_nist_node_data(data, colors); diff --git a/apps/frontend/tests/unit/Results.spec.ts b/apps/frontend/tests/unit/Results.spec.ts index cac0bb4ea6..3fe4962f77 100644 --- a/apps/frontend/tests/unit/Results.spec.ts +++ b/apps/frontend/tests/unit/Results.spec.ts @@ -67,7 +67,7 @@ describe('Datatable', () => { ( controlTableWrapper.vm as Vue & { - items: Array; + items: any[]; } ).items.length ).toBe(expected); @@ -94,7 +94,7 @@ describe('Datatable', () => { ( controlTableWrapper.vm as Vue & { - items: Array; + items: any[]; } ).items.length ).toBe(expected); @@ -115,7 +115,7 @@ describe('Datatable', () => { ( controlTableWrapper.vm as Vue & { - items: Array; + items: any[]; } ).items .map((item: ListElt) => item.control.data.id) @@ -149,7 +149,7 @@ describe('Datatable', () => { ( controlTableWrapper.vm as Vue & { - items: Array; + items: any[]; } ).items.length ).toBe(3); // the file loaded includes 3 controls with severity override tags diff --git a/libs/common/interfaces/evaluation/evaluation.interface.ts b/libs/common/interfaces/evaluation/evaluation.interface.ts index 46a2023b8f..a58ab96054 100644 --- a/libs/common/interfaces/evaluation/evaluation.interface.ts +++ b/libs/common/interfaces/evaluation/evaluation.interface.ts @@ -23,8 +23,8 @@ export interface IEvaluationResponse { export interface IEvalPaginationParams { offset: number; limit: number; - order: Array; + order: string[]; useClause?: boolean; operator?: string; - searchFields?: Array; + searchFields?: string[]; } diff --git a/libs/hdf-converters/src/base-converter.ts b/libs/hdf-converters/src/base-converter.ts index f679f54b01..3711276ce3 100644 --- a/libs/hdf-converters/src/base-converter.ts +++ b/libs/hdf-converters/src/base-converter.ts @@ -16,7 +16,7 @@ export interface ILookupPath { export type ObjectEntryValue = {[K in keyof T]: readonly [K, T[K]]}[keyof T]; /* eslint-disable @typescript-eslint/ban-types */ export type MappedTransform = { - [K in keyof T]: Exclude extends Array + [K in keyof T]: Exclude extends any[] ? MappedTransform : T[K] extends Function ? T[K] @@ -25,7 +25,7 @@ export type MappedTransform = { : T[K] | U; }; export type MappedReform = { - [K in keyof T]: Exclude extends Array + [K in keyof T]: Exclude extends any[] ? MappedReform : T[K] extends object ? MappedReform @@ -99,10 +99,10 @@ export function impactMapping( // eslint-disable-next-line @typescript-eslint/ban-types function collapseDuplicates( - array: Array, + array: T[], key: string, collapseResults: boolean -): Array { +): T[] { const seen = new Map(); const newArray: T[] = []; let counter = 0; @@ -180,7 +180,7 @@ export class BaseConverter> { } } - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { @@ -213,8 +213,8 @@ export class BaseConverter> { evaluate( file: Record, - v: T | Array - ): T | Array | MappedReform { + v: T | T[] + ): T | T[] | MappedReform { if (v === undefined) { return v; } @@ -294,12 +294,12 @@ export class BaseConverter> { handleArray( file: Record, - v: Array - ): Array { + v: (T & ILookupPath)[] + ): T[] { if (v.length === 0) { return []; } - const resultingData: Array = []; + const resultingData: T[] = []; for (const lookupPath of v) { if (lookupPath.path === undefined) { const arrayTransformer = lookupPath.arrayTransformer?.bind(this); @@ -308,7 +308,7 @@ export class BaseConverter> { ? (_.omit(element, ['arrayTransformer']) as T & ILookupPath) : element; }); - let output: Array = []; + let output: T[] = []; output.push(this.evaluate(file, lookupPath) as T); if (arrayTransformer !== undefined) { if (Array.isArray(arrayTransformer)) { diff --git a/libs/hdf-converters/src/checkov-mapper.ts b/libs/hdf-converters/src/checkov-mapper.ts index af7bfa47c7..b7365bfb25 100644 --- a/libs/hdf-converters/src/checkov-mapper.ts +++ b/libs/hdf-converters/src/checkov-mapper.ts @@ -23,7 +23,7 @@ type CheckovCheck = { file_path: string; file_line_range: number[]; resource: string; - code_block: Array<[number, string]>; + code_block: [number, string][]; check_class: string; file_abs_path: string; repo_file_path: string; diff --git a/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts b/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts index 82695cce70..b23e61ab87 100644 --- a/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts +++ b/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts @@ -17,7 +17,7 @@ export class FromAnyBaseConverter extends FromHdfBaseConverter { } // Preforms fn() on all entries inside the passed obj - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { diff --git a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts index e060f9fffb..19eb570d69 100644 --- a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts +++ b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts @@ -39,7 +39,7 @@ export class FromHdfBaseConverter { } // Preforms fn() on all entries inside the passed obj - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { @@ -51,8 +51,8 @@ export class FromHdfBaseConverter { // Used to get the data located at the paths evaluate( file: object, - v: T | Array - ): T | Array | MappedReform { + v: T | T[] + ): T | T[] | MappedReform { const transformer = _.get(v, 'transformer') as any; if (Array.isArray(v)) { return this.handleArray(file, v); @@ -92,13 +92,13 @@ export class FromHdfBaseConverter { handleArray( file: object, - v: Array - ): Array { - const resultingData: Array = []; + v: (T & ILookupPathFH)[] + ): T[] { + const resultingData: T[] = []; // Looks through parsed data file using the mapping setup in V if (v[0] && !v[0].path) { const arrayTransformer = v[0].arrayTransformer; // does nothing since null - let output: Array = v.map( + let output: T[] = v.map( (element) => this.evaluate(file, element) as T ); if (arrayTransformer) { diff --git a/libs/hdf-converters/src/dependency-track-mapper.ts b/libs/hdf-converters/src/dependency-track-mapper.ts index 0cb5ceb791..a545854186 100644 --- a/libs/hdf-converters/src/dependency-track-mapper.ts +++ b/libs/hdf-converters/src/dependency-track-mapper.ts @@ -115,7 +115,7 @@ export class DependencyTrackMapper extends BaseConverter { vulnerabilitySubtitle: {path: 'vulnerability.subtitle'}, vulnerabilityAliases: { path: 'vulnerability.aliases', - transformer: (aliases: Array>): string => + transformer: (aliases: Record[]): string => JSON.stringify(aliases, null, 2) }, vulnerabilityCvssV2BaseScore: { diff --git a/libs/hdf-converters/src/splunk-mapper.ts b/libs/hdf-converters/src/splunk-mapper.ts index 993385e50f..18f9c0f557 100644 --- a/libs/hdf-converters/src/splunk-mapper.ts +++ b/libs/hdf-converters/src/splunk-mapper.ts @@ -36,10 +36,10 @@ let logger = createWinstonLogger('Splunk2HDF'); // Groups items by using the provided key function export function groupBy( - items: Array, + items: T[], keyGetter: (v: T) => string -): Hash> { - const result: Hash> = {}; +): Hash { + const result: Hash = {}; for (const i of items) { // Get the items key const key = keyGetter(i); diff --git a/libs/hdf-converters/src/zap-mapper.ts b/libs/hdf-converters/src/zap-mapper.ts index 3cd9fb8100..2418b30594 100644 --- a/libs/hdf-converters/src/zap-mapper.ts +++ b/libs/hdf-converters/src/zap-mapper.ts @@ -17,7 +17,7 @@ const CWE_NIST_MAPPING = new CweNistMapping(); let parseHtml: (input: unknown) => string; -function filterSite(input: Array, name?: string) { +function filterSite(input: T[], name?: string) { // Choose passed site if provided if (name) { return input.find( diff --git a/libs/hdf-converters/types/cyclonedx.d.ts b/libs/hdf-converters/types/cyclonedx.d.ts index 89ea7b60a9..17c5f0ae85 100644 --- a/libs/hdf-converters/types/cyclonedx.d.ts +++ b/libs/hdf-converters/types/cyclonedx.d.ts @@ -3824,7 +3824,7 @@ export type PurpleInputType = { /** * Inputs that have the form of parameters with names and values. */ - environmentVars?: Array; + environmentVars?: (LightweightNameValuePairObject | string)[]; /** * Inputs that have the form of parameters with names and values. */ @@ -3909,7 +3909,7 @@ export type PurpleOutputType = { /** * Outputs that have the form of environment variables. */ - environmentVars?: Array; + environmentVars?: (LightweightNameValuePairObject | string)[]; properties?: LightweightNameValuePairObject[]; /** * A reference to an independent resource generated as output by the task. @@ -8001,7 +8001,7 @@ export type FluffyInputType = { /** * Inputs that have the form of parameters with names and values. */ - environmentVars?: Array; + environmentVars?: (LightweightNameValuePairClass | string)[]; /** * Inputs that have the form of parameters with names and values. */ @@ -8095,7 +8095,7 @@ export type FluffyOutputType = { /** * Outputs that have the form of environment variables. */ - environmentVars?: Array; + environmentVars?: (LightweightNameValuePairClass | string)[]; /** * Provides the ability to document properties in a name-value store. This provides * flexibility to include data not officially supported in the standard without having to diff --git a/libs/inspecjs/src/raw_nist.ts b/libs/inspecjs/src/raw_nist.ts index 18c69b9126..20297b0ab4 100644 --- a/libs/inspecjs/src/raw_nist.ts +++ b/libs/inspecjs/src/raw_nist.ts @@ -1,5 +1,5 @@ /** does what is says on the tin */ -export const ALL_NIST_FAMILIES: Readonly = [ +export const ALL_NIST_FAMILIES: readonly string[] = [ 'AC', 'AP', 'AR', @@ -29,7 +29,7 @@ export const ALL_NIST_FAMILIES: Readonly = [ 'UM' // We added this - it is not official ]; -export const ALL_NIST_CONTROL_NUMBERS: Readonly = [ +export const ALL_NIST_CONTROL_NUMBERS: readonly string[] = [ 'UM-1', // We added this - it is not official 'AC-1', 'AC-10', From d0d1e5d7d0b5e7038f39a5a6f1f6309b3cec5425 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:39:04 -0400 Subject: [PATCH 061/197] style: remove type assertions the compiler proves redundant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @typescript-eslint/no-unnecessary-type-assertion, applied surgically — 40 removals across 16 files where the asserted type is already the inferred type. One site was a genuine rule-versus-compiler disagreement rather than a redundant assertion: checklist-jsonix-converter reached the untyped passthrough field through _.get plus an as-unknown-as double cast, which the rule flagged but whose removal broke compilation. Replaced with a single assertion widening hdf to carry an optional passthrough — the compiler requires it, so the rule accepts it, and the double cast is gone. Verified: tsc clean (hdf-converters, frontend non-vue), inspecjs green, frontend green, hdf-converters 156/157 with the SonarQube mock (the Splunk service spec remains CI-only). Authored by: Aaron Lippold --- .../src/components/cards/ProfileInfo.vue | 6 ++-- .../src/components/global/ExportCaat.vue | 2 +- apps/frontend/src/utilities/export_util.ts | 2 +- apps/frontend/src/utilities/treemap_util.ts | 2 +- apps/frontend/src/views/Compare.vue | 2 +- .../src/anchore-grype-mapper.ts | 2 +- .../src/asff-mapper/asff-mapper.ts | 12 ++++---- libs/hdf-converters/src/base-converter.ts | 2 +- .../ckl-mapper/checklist-jsonix-converter.ts | 29 +++++++++---------- .../asff/reverse-asff-mapper.ts | 2 +- .../html/reverse-html-mapper.ts | 4 +-- libs/hdf-converters/src/conveyor-mapper.ts | 2 +- .../src/cyclonedx-sbom-mapper.ts | 4 +-- libs/hdf-converters/src/nessus-mapper.ts | 14 ++++----- libs/hdf-converters/src/veracode-mapper.ts | 2 +- .../src/xccdf-results-mapper.ts | 10 +++---- 16 files changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/frontend/src/components/cards/ProfileInfo.vue b/apps/frontend/src/components/cards/ProfileInfo.vue index 71faec9f23..739f87b31a 100644 --- a/apps/frontend/src/components/cards/ProfileInfo.vue +++ b/apps/frontend/src/components/cards/ProfileInfo.vue @@ -102,9 +102,7 @@ export default class ProfileInfo extends Vue { ]; get from_file(): string | undefined { - return _.get(this.profile, 'sourcedFrom.from_file.filename') as unknown as - | string - | undefined; + return _.get(this.profile, 'sourcedFrom.from_file.filename'); } get version(): string | undefined { @@ -112,7 +110,7 @@ export default class ProfileInfo extends Vue { } get sha256_hash(): string | undefined { - return _.get(this.profile, 'data.sha256') as unknown as string | undefined; + return _.get(this.profile, 'data.sha256'); } get maintainer(): string | undefined { diff --git a/apps/frontend/src/components/global/ExportCaat.vue b/apps/frontend/src/components/global/ExportCaat.vue index 151a8d3bf8..af7b1dc8b6 100644 --- a/apps/frontend/src/components/global/ExportCaat.vue +++ b/apps/frontend/src/components/global/ExportCaat.vue @@ -36,7 +36,7 @@ export default class ExportCaat extends Vue { exportCaat() { const inputData = this.filter.fromFile.map((fileId: string) => { const file = ( - InspecDataModule.allEvaluationFiles as EvaluationFile[] + InspecDataModule.allEvaluationFiles ).find((f) => f.uniqueId === fileId); const data = file?.evaluation ?? ''; const filename = file?.filename || fileId; diff --git a/apps/frontend/src/utilities/export_util.ts b/apps/frontend/src/utilities/export_util.ts index 466445f22e..43b46822c0 100644 --- a/apps/frontend/src/utilities/export_util.ts +++ b/apps/frontend/src/utilities/export_util.ts @@ -17,7 +17,7 @@ export async function saveSingleOrMultipleFiles( d.byteOffset, d.byteOffset + d.byteLength ) as ArrayBuffer) - : (d as BlobPart); + : (d); const blob = new Blob([part]); saveAs(blob, cleanUpFilename(`${files[0]?.filename}`)); } else { diff --git a/apps/frontend/src/utilities/treemap_util.ts b/apps/frontend/src/utilities/treemap_util.ts index 029fe30d18..b7b18ea5f0 100644 --- a/apps/frontend/src/utilities/treemap_util.ts +++ b/apps/frontend/src/utilities/treemap_util.ts @@ -218,7 +218,7 @@ function node_data_to_tree_map( return 1; } } else if (root.parent !== null) { - return 1.0 / root.parent!.children.length; + return 1.0 / root.parent.children.length; } return 0; }); diff --git a/apps/frontend/src/views/Compare.vue b/apps/frontend/src/views/Compare.vue index 35d2d35283..d747d2f9d2 100644 --- a/apps/frontend/src/views/Compare.vue +++ b/apps/frontend/src/views/Compare.vue @@ -416,7 +416,7 @@ export default class Compare extends Vue { ) { if (typeof aPassthroughField === 'string') { return (aPassthroughField as string).localeCompare( - bPassthroughField as string + bPassthroughField ); } else if (typeof aPassthroughField === 'number') { return aPassthroughField - Number(bPassthroughField); diff --git a/libs/hdf-converters/src/anchore-grype-mapper.ts b/libs/hdf-converters/src/anchore-grype-mapper.ts index 12b4e6ab87..bd98f432a1 100644 --- a/libs/hdf-converters/src/anchore-grype-mapper.ts +++ b/libs/hdf-converters/src/anchore-grype-mapper.ts @@ -112,7 +112,7 @@ export class AnchoreGrypeMapper extends BaseConverter { return ( vuln_urls.concat( ...relatedVulnerabilitiesUrls - ) as unknown as Record[] + ) ).map((element) => ({url: element})); } } as unknown as ExecJSON.Reference[], diff --git a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts index 1c4992cc03..df4af05b22 100644 --- a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts +++ b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts @@ -249,7 +249,7 @@ function handleIdGroup( JSON.stringify({Findings: findings}, null, 2) ), results: group.map((d) => d.results).flat() - } as ExecJSON.Control; + }; } // consolidate the array of controls which were generated 1:1 with findings in order to have subfindings/results @@ -446,7 +446,7 @@ export class ASFFMapper extends BaseConverter { finding, 'findingTags', {} - ) as Record, + ), cci: { transformer: (finding: Record): string[] => { const tags = externalProductHandler( @@ -548,7 +548,7 @@ export class ASFFMapper extends BaseConverter { finding, 'subfindingsStatus', defaultFunc - ) as ExecJSON.ControlResultStatus; + ); } }, code_desc: { @@ -686,7 +686,7 @@ export class ASFFMapper extends BaseConverter { this, 'mapping', this.defaultMappings - ) as MappedTransform; + ); } constructor( @@ -760,7 +760,7 @@ export class ASFFResults { wrapped, 'preprocessingASFF', wrapped - ) as Record, + ), externalProductHandler( this, whichSpecialCase( @@ -778,7 +778,7 @@ export class ASFFResults { undefined, 'meta', this.meta - ) as Record + ) ).toHdf(); }); } diff --git a/libs/hdf-converters/src/base-converter.ts b/libs/hdf-converters/src/base-converter.ts index 3711276ce3..f8ad8a4ad7 100644 --- a/libs/hdf-converters/src/base-converter.ts +++ b/libs/hdf-converters/src/base-converter.ts @@ -278,7 +278,7 @@ export class BaseConverter> { } if (hasTransformer) { - return transformer(hasPath ? pathV : (file as T | T[])) as + return transformer(hasPath ? pathV : (file)) as | T | T[] | MappedReform; diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index a958a90681..ec6e3f6f4a 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -283,11 +283,11 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< hostip: _.get(jsonixData, 'value.asset.hostip') as unknown as string, hostmac: _.get(jsonixData, 'value.asset.hostmac') as unknown as string, hostfqdn: _.get(jsonixData, 'value.asset.hostfqdn') as unknown as string, - marking: _.get(jsonixData, 'value.asset.marking') as unknown as string, + marking: _.get(jsonixData, 'value.asset.marking'), targetcomment: _.get( jsonixData, 'value.asset.targetcomment' - ) as unknown as string, + ), techarea: _.get( jsonixData, 'value.asset.techarea' @@ -297,9 +297,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< 'value.asset.targetkey' ) as unknown as string, webordatabase: [true, 'true'].includes( - _.get(jsonixData, 'value.asset.webordatabase', false) as - | string - | boolean + _.get(jsonixData, 'value.asset.webordatabase', false) ), webdbsite: _.get( jsonixData, @@ -320,7 +318,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< const stigInfo: Sidata[] = _.get( stig, 'stiginfo.sidata' - ) as unknown as Sidata[]; + ); const header: StigHeader = { version: this.getValueFromAttributeName(stigInfo, 'version'), classification: this.getValueFromAttributeName( @@ -405,7 +403,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< documentable: this.getValueFromAttributeName( stigdata, 'Documentable' - ) as unknown as string, + ), mitigations: this.getValueFromAttributeName( stigdata, 'Mitigations' @@ -515,7 +513,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< Vulnattribute[ keyFoundInVulnattribute as keyof typeof Vulnattribute ], - attributedata: data as string + attributedata: data }); } } @@ -846,10 +844,13 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< */ hdfToIntermediateObject(hdf: ExecJSON.Execution): ChecklistObject { const stigs: ChecklistStig[] = []; - const metadata: ChecklistMetadata | undefined = _.get( - hdf, - 'passthrough.metadata' - ) as unknown as ChecklistMetadata | undefined; + // `passthrough` exists on no ExecJSON type — the old code reached it with + // _.get plus an as-unknown-as double cast. One assertion widening hdf to + // carry an optional passthrough is the honest minimum: required by the + // compiler, so the no-unnecessary-type-assertion rule accepts it too. + const metadata: ChecklistMetadata | undefined = ( + hdf as { passthrough?: { metadata?: ChecklistMetadata } } + ).passthrough?.metadata; for (const profile of hdf.profiles) { // if profile is overlay or parent profile, skip if (profile.depends?.length) { @@ -913,9 +914,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< webdbinstance: _.get(hdf, 'passthrough.metadata.webdbinstance', ''), webdbsite: _.get(hdf, 'passthrough.metadata.webdbsite', ''), webordatabase: [true, 'true'].includes( - _.get(hdf, 'passthrough.metadata.webordatabase', false) as - | string - | boolean + _.get(hdf, 'passthrough.metadata.webordatabase', false) ) }, stigs: stigs diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts index 82e5a0d969..d826b6b73e 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts @@ -384,7 +384,7 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { let resList: IFindingASFF[] = this.controlsToSegments().map( (segment, index) => { this.index = index; - return this.convertInternal(segment, this.mappings()).Findings[0] as IFindingASFF; + return this.convertInternal(segment, this.mappings()).Findings[0]; } ); resList.push(createProfileInfoFinding(this.data, this.ioptions)); diff --git a/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts index f6015e49bb..5cfb7acece 100644 --- a/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts @@ -193,8 +193,8 @@ export class FromHDFToHTMLMapper { // Set file profile data this.outputData.files.push({ filename: file.fileName, - toolVersion: _.get(file.data, 'data.version') as unknown as string, - platform: _.get(file.data, 'data.platform.name') as unknown as string, + toolVersion: _.get(file.data, 'data.version'), + platform: _.get(file.data, 'data.platform.name'), duration: _.get( file.data, 'data.statistics.duration' diff --git a/libs/hdf-converters/src/conveyor-mapper.ts b/libs/hdf-converters/src/conveyor-mapper.ts index c9a49efa51..e313834ad0 100644 --- a/libs/hdf-converters/src/conveyor-mapper.ts +++ b/libs/hdf-converters/src/conveyor-mapper.ts @@ -145,7 +145,7 @@ function preprocessObject( _.get(result, 'result.sections') as Record[], (section) => createDescription( - section as Record, + section, _.get(result, 'result.score') as number, _.get(result, 'response.milestones.service_started') as string, _.get(result, 'response.service_name') as string, diff --git a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts index 9a7a79da49..d80949bbaa 100644 --- a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts +++ b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts @@ -241,7 +241,7 @@ export class CycloneDXSBOMResults { if (!data.components[index].affectingVulnerabilities) { data.components[index].affectingVulnerabilities = []; } - (data.components[index].affectingVulnerabilities as string[]).push( + (data.components[index].affectingVulnerabilities).push( _.get(vulnerability, 'bom-ref') as unknown as string ); } @@ -256,7 +256,7 @@ export class CycloneDXSBOMResults { ...(_.cloneDeep(data.raw.vulnerabilities) as | CycloneDXBillOfMaterialsStandardVulnerability[] | CycloneDXSoftwareBillOfMaterialsStandardVulnerability[]) - ] as unknown as IntermediaryVulnerability[]; + ]; for (const vulnerability of data.vulnerabilities) { vulnerability.affectedComponents = vulnerability.affects?.map((id) => { diff --git a/libs/hdf-converters/src/nessus-mapper.ts b/libs/hdf-converters/src/nessus-mapper.ts index 23a6c6ac01..1512dd11a7 100644 --- a/libs/hdf-converters/src/nessus-mapper.ts +++ b/libs/hdf-converters/src/nessus-mapper.ts @@ -49,7 +49,7 @@ function getVersion(): string { function getId(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'Vuln-ID' )[0]; } else { @@ -58,7 +58,7 @@ function getId(item: unknown): string { } function getTitle(item: unknown): string { if (_.has(item, COMPLIANCE_CHECK_NAME)) { - return _.get(item, COMPLIANCE_CHECK_NAME) as unknown as string; + return _.get(item, COMPLIANCE_CHECK_NAME); } else { return _.get(item, 'pluginName') as unknown as string; } @@ -94,7 +94,7 @@ function parseRef(input: string, key: string): string[] { function getImpact(item: unknown): number { if (_.has(item, COMPLIANCE_PATH)) { return impactMapping(IMPACT_MAPPING)( - parseRef(_.get(item, COMPLIANCE_PATH) as unknown as string, 'CAT').join( + parseRef(_.get(item, COMPLIANCE_PATH), 'CAT').join( '' ) ); @@ -121,14 +121,14 @@ function getFix(item: unknown): string { function getNist(item: unknown): string[] { if (_.has(item, COMPLIANCE_PATH)) { - return cciNistTag(_.get(item, COMPLIANCE_PATH) as unknown as string); + return cciNistTag(_.get(item, COMPLIANCE_PATH)); } else { return pluginNistTag(item); } } function getCci(item: unknown): string[] { if (_.has(item, COMPLIANCE_PATH)) { - return parseRef(_.get(item, COMPLIANCE_PATH) as unknown as string, 'CCI'); + return parseRef(_.get(item, COMPLIANCE_PATH), 'CCI'); } else { return []; } @@ -136,7 +136,7 @@ function getCci(item: unknown): string[] { function getRid(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'Rule-ID' ).join(','); } else { @@ -146,7 +146,7 @@ function getRid(item: unknown): string { function getStig(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'STIG-ID' ).join(','); } else { diff --git a/libs/hdf-converters/src/veracode-mapper.ts b/libs/hdf-converters/src/veracode-mapper.ts index 70d2ce13a2..b27f855e17 100644 --- a/libs/hdf-converters/src/veracode-mapper.ts +++ b/libs/hdf-converters/src/veracode-mapper.ts @@ -257,7 +257,7 @@ function componentListCreate(input: unknown): Record[] { if (!Array.isArray(component)) { component = [component]; } - for (const value of component as Record[]) { + for (const value of component) { if (_.get(value, '@_.vulnerabilities') !== '0') { componentList.push(value); } diff --git a/libs/hdf-converters/src/xccdf-results-mapper.ts b/libs/hdf-converters/src/xccdf-results-mapper.ts index 4aaf30eb10..4f9bbd70c6 100644 --- a/libs/hdf-converters/src/xccdf-results-mapper.ts +++ b/libs/hdf-converters/src/xccdf-results-mapper.ts @@ -349,7 +349,7 @@ export class XCCDFResultsMapper extends BaseConverter { parseXml(description), 'VulnDiscussion', description - ) as string + ) ) }, group_id: {path: 'group.id'}, @@ -362,7 +362,7 @@ export class XCCDFResultsMapper extends BaseConverter { parseXml(description), 'GroupDescription', description - ) as string + ) ) }, rule_id: {path: 'id'}, @@ -401,7 +401,7 @@ export class XCCDFResultsMapper extends BaseConverter { parseXml(description), 'ProfileDescription', description - ) as string + ) ) }, title: {path: ['title.text', 'title']} @@ -495,7 +495,7 @@ export class XCCDFResultsMapper extends BaseConverter { parseXml(description), 'ProfileDescription', description - ) as string + ) ) }, descriptions: [ @@ -584,7 +584,7 @@ export class XCCDFResultsMapper extends BaseConverter { parseXml(description), 'VulnDiscussion', description - ) as string + ) ) }, start_time: { From 238b861a7c972a0bcc5f4fb11304ce98f48756b7 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:41:23 -0400 Subject: [PATCH 062/197] style: use Record for index signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @typescript-eslint/consistent-indexed-object-style, applied surgically — 29 conversions of {[key: string]: T} index signatures to Record across 21 files. Type-space equivalence; nothing exists at runtime. Verified: tsc clean, inspecjs green, frontend green, hdf-converters 156/157 (Splunk service spec CI-only, unchanged). Authored by: Aaron Lippold --- apps/frontend/src/components/global/ExportCSVModal.vue | 6 ++---- apps/frontend/src/store/color_hack.ts | 2 +- apps/frontend/src/store/search.ts | 6 ++---- apps/frontend/src/store/severity_counts.ts | 2 +- apps/frontend/src/store/status_counts.ts | 4 +--- apps/frontend/src/utilities/delta_util.ts | 4 ++-- apps/frontend/src/utilities/helper_util.ts | 6 ++---- apps/frontend/src/utilities/nist_util.ts | 2 +- apps/frontend/src/utilities/treemap_util.ts | 6 +++--- apps/frontend/tests/util/fs.ts | 2 +- libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts | 2 +- .../src/converters-from-hdf/asff/transformers.ts | 2 +- .../src/converters-from-hdf/html/html-types.ts | 4 +--- .../src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts | 2 +- libs/hdf-converters/src/splunk-mapper.ts | 6 +++--- libs/hdf-converters/src/utils/global.ts | 4 +--- libs/hdf-converters/types/reverseMappedXCCDF.d.ts | 2 +- libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts | 2 +- libs/inspecjs/src/compat_wrappers.ts | 2 +- libs/inspecjs/src/nist.ts | 2 +- libs/inspecjs/test/status_counts.ts | 4 ++-- 21 files changed, 30 insertions(+), 42 deletions(-) diff --git a/apps/frontend/src/components/global/ExportCSVModal.vue b/apps/frontend/src/components/global/ExportCSVModal.vue index 7683995cd0..d6c40053f3 100644 --- a/apps/frontend/src/components/global/ExportCSVModal.vue +++ b/apps/frontend/src/components/global/ExportCSVModal.vue @@ -88,9 +88,7 @@ const fieldNames = [ 'Waiver Data' ]; -type ControlSetRow = { - [key: string]: unknown; -}; +type ControlSetRow = Record; type File = { filename: string; @@ -132,7 +130,7 @@ export default class ExportCSVModal extends Vue { descriptionsToString( descriptions?: | ExecJSON.ControlDescription[] - | {[key: string]: string} + | Record | null ): string { let result = ''; diff --git a/apps/frontend/src/store/color_hack.ts b/apps/frontend/src/store/color_hack.ts index cbc9d0278c..feafe828cc 100644 --- a/apps/frontend/src/store/color_hack.ts +++ b/apps/frontend/src/store/color_hack.ts @@ -44,7 +44,7 @@ export class ColorHack extends VuexModule { */ get lookupColor(): (colorName: string) => string { // Establish a cache - const localCache: {[key: string]: string} = {}; + const localCache: Record = {}; // Establish to vue that we vary on any changes to the theme // let _depends: any = this. diff --git a/apps/frontend/src/store/search.ts b/apps/frontend/src/store/search.ts index 1fb9ed0ea1..845d690a1a 100644 --- a/apps/frontend/src/store/search.ts +++ b/apps/frontend/src/store/search.ts @@ -22,12 +22,10 @@ export interface ISearchState { severityFilter: Severity[]; } -export interface SearchQuery { - [key: string]: { +export type SearchQuery = Record; export const statusTypes = [ 'Not Applicable', diff --git a/apps/frontend/src/store/severity_counts.ts b/apps/frontend/src/store/severity_counts.ts index 937cff1114..f8c0fd6844 100644 --- a/apps/frontend/src/store/severity_counts.ts +++ b/apps/frontend/src/store/severity_counts.ts @@ -14,7 +14,7 @@ import {LRUCache} from 'lru-cache'; import {getModule, Module, VuexModule} from 'vuex-module-decorators'; // The hash that we will generally be working with herein -type SeverityHash = {[key in Severity]: number}; +type SeverityHash = Record; // Helper function for counting a status in a list of controls function count_severities(data: FilteredData, filter: Filter): SeverityHash { diff --git a/apps/frontend/src/store/status_counts.ts b/apps/frontend/src/store/status_counts.ts index 18cf9fd08f..8afe1c5813 100644 --- a/apps/frontend/src/store/status_counts.ts +++ b/apps/frontend/src/store/status_counts.ts @@ -14,9 +14,7 @@ import {LRUCache} from 'lru-cache'; import {getModule, Module, VuexModule} from 'vuex-module-decorators'; // The hash that we will generally be working with herein -export type ControlStatusHash = { - [key in ControlStatus | 'Waived']: number; -}; +export type ControlStatusHash = Record; export type StatusHash = ControlStatusHash & { PassedTests: number; // from passed controls FailedTests: number; diff --git a/apps/frontend/src/utilities/delta_util.ts b/apps/frontend/src/utilities/delta_util.ts index 3e21fa5601..56c28ae4d1 100644 --- a/apps/frontend/src/utilities/delta_util.ts +++ b/apps/frontend/src/utilities/delta_util.ts @@ -208,10 +208,10 @@ function extract_top_level_controls( } /** An object of contextualized controls with the same V-ID */ -export type ControlSeries = {[key: string]: ContextualizedControl}; +export type ControlSeries = Record; /** Matches ControlID keys to Arrays of Controls */ -export type ControlSeriesLookup = {[key: string]: ControlSeries}; +export type ControlSeriesLookup = Record; /** Helps manage comparing change(s) between one or more profile executions */ export class ComparisonContext { diff --git a/apps/frontend/src/utilities/helper_util.ts b/apps/frontend/src/utilities/helper_util.ts index 75f29a0252..a0193f7b46 100644 --- a/apps/frontend/src/utilities/helper_util.ts +++ b/apps/frontend/src/utilities/helper_util.ts @@ -88,9 +88,7 @@ export class LocalStorageVal { /** Get description from Array of descriptions or Key/String pair */ export function getDescription( descriptions: - | { - [key: string]: string; - } + | Record | ExecJSON.ControlDescription[], key: string ): string | undefined { @@ -108,7 +106,7 @@ export function getDescription( } /** A useful shorthand */ -export type Hash = {[key: string]: T}; +export type Hash = Record; /** Converts a simple, single level json dict into uri params */ export function toURIParams(params: Hash) { diff --git a/apps/frontend/src/utilities/nist_util.ts b/apps/frontend/src/utilities/nist_util.ts index 6c3e81cae1..97a5653e4b 100644 --- a/apps/frontend/src/utilities/nist_util.ts +++ b/apps/frontend/src/utilities/nist_util.ts @@ -6,7 +6,7 @@ export const nistCanonConfig = { add_periods: false, add_parens: false }; -export const NIST_DESCRIPTIONS: {[tag: string]: string} = { +export const NIST_DESCRIPTIONS: Record = { 'AC-01': 'ACCESS CONTROL POLICY AND PROCEDURES', 'AC-01 a': 'Develops, documents, and disseminates to [Assignment: organization-defined personnel or roles]:', diff --git a/apps/frontend/src/utilities/treemap_util.ts b/apps/frontend/src/utilities/treemap_util.ts index b7b18ea5f0..c2971605b3 100644 --- a/apps/frontend/src/utilities/treemap_util.ts +++ b/apps/frontend/src/utilities/treemap_util.ts @@ -81,7 +81,7 @@ function controls_to_nist_node_data( function recursive_nist_map( parent: TreemapNodeParent | null, node: Readonly, - controlLookup: {[key: string]: TreemapNodeParent}, + controlLookup: Record, maxDepth: number ): TreemapNodeParent { // Init child list @@ -142,7 +142,7 @@ function lookup_key_for(x: NistControl, maxDepth: number): string { /** Populates a treemap using the given lookup table */ function populate_tree_map( - lookup: {[key: string]: TreemapNodeParent}, + lookup: Record, leaves: TreemapNodeLeaf[], maxDepth: number ) { @@ -169,7 +169,7 @@ function populate_tree_map( */ function build_populated_nist_map(data: TreemapNodeLeaf[]): TreemapNodeParent { // Build our scaffold - const lookup: {[key: string]: TreemapNodeParent} = {}; + const lookup: Record = {}; const rootChildren: TreemapNodeParent[] = []; const root: TreemapNodeParent = { key: 'tree_root', diff --git a/apps/frontend/tests/util/fs.ts b/apps/frontend/tests/util/fs.ts index bd5d91942e..2207b2d891 100644 --- a/apps/frontend/tests/util/fs.ts +++ b/apps/frontend/tests/util/fs.ts @@ -31,7 +31,7 @@ export function read_files(dirName: string): FileResult[] { }); } -export type FileHash = {[key: string]: FileResult}; +export type FileHash = Record; export function populate_hash(results: FileResult[]) { const hash: FileHash = {}; results.forEach((f) => { diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts index d570c53dbe..a584579a79 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts @@ -287,7 +287,7 @@ function getAttributes(input: unknown[]) { function getHdfSpecificDataAttribute( attribute: string, input: string -): {[key: string]: any}[] | string | undefined { +): Record[] | string | undefined { const data = parseJson(input); if (!data.ok) return undefined; const hdfSpecificData = _.get(data.value, 'hdfSpecificData'); diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts b/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts index f31af2bb9f..61a6b7a151 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/transformers.ts @@ -52,7 +52,7 @@ export function getRunTime(hdf: ExecJSON.Execution): string { function filter_overlays( controls: ContextualizedControl[] ): ContextualizedControl[] { - const idHash: {[key: string]: ContextualizedControl} = {}; + const idHash: Record = {}; controls.forEach((c) => { const id = c.hdf.wraps.id; const old: ContextualizedControl | undefined = idHash[id]; diff --git a/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts b/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts index 3ff7bc8440..bc5e9537e7 100644 --- a/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts +++ b/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts @@ -70,9 +70,7 @@ export interface IResultSet { } // All used icons; lvl 1 -export interface IIcons { - [key: string]: string; -} +export type IIcons = Record; // Top level interface; lvl 0 export interface IOutputData { diff --git a/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts index 39307f61b5..e07c5531b0 100644 --- a/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts @@ -15,7 +15,7 @@ const TESTING_DATE_OVERRIDE = '1970-01-01'; const TESTING_DATETIME_OVERRIDE = '2022-05-06T21:46:47.939Z'; function arrayifyObjectDescriptions( - descriptions?: {[key: string]: any} | ExecJSON.ControlDescription[] | null + descriptions?: Record | ExecJSON.ControlDescription[] | null ): ExecJSON.ControlDescription[] { if (!descriptions) { return []; diff --git a/libs/hdf-converters/src/splunk-mapper.ts b/libs/hdf-converters/src/splunk-mapper.ts index 18f9c0f557..773194e5b8 100644 --- a/libs/hdf-converters/src/splunk-mapper.ts +++ b/libs/hdf-converters/src/splunk-mapper.ts @@ -11,7 +11,7 @@ import { handleSplunkErrorResponse } from './utils/splunk-tools'; -export type Hash = {[key: string]: T}; +export type Hash = Record; export type SplunkConfigNoIndex = Omit; @@ -79,7 +79,7 @@ export function consolidatePayloads( export function replaceKeyValueDescriptions( controls: (ExecJSON.Control & GenericPayloadWithMetaData & { - descriptions?: {[key: string]: string} | ExecJSON.ControlDescription[]; + descriptions?: Record | ExecJSON.ControlDescription[]; })[] ) { return controls.map((control) => { @@ -140,7 +140,7 @@ function consolidateFilePayloads( corrControls as unknown as (ExecJSON.Control & GenericPayloadWithMetaData & { descriptions?: - | {[key: string]: string} + | Record | ExecJSON.ControlDescription[]; })[] ) diff --git a/libs/hdf-converters/src/utils/global.ts b/libs/hdf-converters/src/utils/global.ts index 3d82a7c6de..0361c1bd2b 100644 --- a/libs/hdf-converters/src/utils/global.ts +++ b/libs/hdf-converters/src/utils/global.ts @@ -44,9 +44,7 @@ export function createWinstonLogger(mapperName: string, level = 'debug') { /** Get description from Array of descriptions or Key/Value pairs */ export function getDescription( descriptions: - | { - [key: string]: string; - } + | Record | ExecJSON.ControlDescription[], key: string ): string | undefined { diff --git a/libs/hdf-converters/types/reverseMappedXCCDF.d.ts b/libs/hdf-converters/types/reverseMappedXCCDF.d.ts index b4e84d8157..65a412d239 100644 --- a/libs/hdf-converters/types/reverseMappedXCCDF.d.ts +++ b/libs/hdf-converters/types/reverseMappedXCCDF.d.ts @@ -15,7 +15,7 @@ export type Benchmark = { endTime: string; hasAttributes: boolean; // Any as defined by InSpec Inputs, matching InSpecJS - attributes: {[key: string]: any}[]; + attributes: Record[]; results: TestResult[]; }; }; diff --git a/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts b/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts index 052b08a9f9..b005480764 100644 --- a/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts +++ b/libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts @@ -35,7 +35,7 @@ abstract class HDFControl10 implements HDFControl { readonly waived: boolean; readonly attested: boolean; readonly attestationStatus?: 'passed' | 'failed'; - readonly descriptions: {[key: string]: string} = {}; + readonly descriptions: Record = {}; readonly isProfile: boolean; // We use this as a reference diff --git a/libs/inspecjs/src/compat_wrappers.ts b/libs/inspecjs/src/compat_wrappers.ts index bf358c64df..bce7f61974 100644 --- a/libs/inspecjs/src/compat_wrappers.ts +++ b/libs/inspecjs/src/compat_wrappers.ts @@ -173,7 +173,7 @@ export interface HDFControl { isProfile: boolean; /** Maps string labels to description items. */ - descriptions: {[key: string]: string}; + descriptions: Record; /** Returns whether this control was waived. */ waived: boolean; diff --git a/libs/inspecjs/src/nist.ts b/libs/inspecjs/src/nist.ts index 0cb91f7f54..c0351967ae 100644 --- a/libs/inspecjs/src/nist.ts +++ b/libs/inspecjs/src/nist.ts @@ -325,7 +325,7 @@ function _generate_full_nist_hierarchy(): NistHierarchy { }); // Init our map, which maps _key_for of controls to their corresponding hierarchy nodes - const map: {[key: string]: NistHierarchyNode} = {}; + const map: Record = {}; // Add roots to the map roots.forEach((r) => { diff --git a/libs/inspecjs/test/status_counts.ts b/libs/inspecjs/test/status_counts.ts index 9fde43c1f5..6f4f1c9037 100644 --- a/libs/inspecjs/test/status_counts.ts +++ b/libs/inspecjs/test/status_counts.ts @@ -2,7 +2,7 @@ import {ControlStatus, HDFControl, hdfWrapControl} from '../src'; import {ExecJSON} from '../src/versions/v_1_0'; -type Counts = {[key in ControlStatus]: number}; +type Counts = Record; export const statusCounts = { /** Instantiates a counts objects with all keys set to 0 */ @@ -28,7 +28,7 @@ export const statusCounts = { /** Trivial overlay filter that just takes the version of the control that has results from amongst all identical ids */ filter_overlays: (controls: HDFControl[]): HDFControl[] => { - const idHash: {[key: string]: HDFControl} = {}; + const idHash: Record = {}; controls.forEach((c) => { const id = c.wraps.id; const old: HDFControl | undefined = idHash[id]; From 1ea920e15b6d93faf1a41fb212a632c33f0577ec Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:43:32 -0400 Subject: [PATCH 063/197] style: put constructor generics on the constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @typescript-eslint/consistent-generic-constructors, applied surgically — 24 sites across 23 files move type arguments from the annotation to the constructor call (const m: Map = new Map() becomes const m = new Map()). Inference-order equivalence only. Verified: tsc clean, inspecjs green, frontend green, hdf-converters 156/157 (Splunk service spec CI-only, unchanged). Authored by: Aaron Lippold --- apps/frontend/src/store/data_filters.ts | 4 ++-- apps/frontend/src/store/severity_counts.ts | 2 +- apps/frontend/src/store/status_counts.ts | 2 +- libs/hdf-converters/src/anchore-grype-mapper.ts | 2 +- libs/hdf-converters/src/asff-mapper/asff-mapper.ts | 6 +++--- libs/hdf-converters/src/burpsuite-mapper.ts | 2 +- libs/hdf-converters/src/checkov-mapper.ts | 2 +- .../src/ckl-mapper/checklist-jsonix-converter.ts | 2 +- .../src/converters-from-hdf/asff/reverse-asff-mapper.ts | 2 +- libs/hdf-converters/src/cyclonedx-sbom-mapper.ts | 2 +- libs/hdf-converters/src/dbprotect-mapper.ts | 2 +- libs/hdf-converters/src/dependency-track-mapper.ts | 2 +- libs/hdf-converters/src/gosec-mapper.ts | 2 +- libs/hdf-converters/src/jfrog-xray-mapper.ts | 2 +- libs/hdf-converters/src/nessus-mapper.ts | 2 +- libs/hdf-converters/src/netsparker-mapper.ts | 2 +- libs/hdf-converters/src/sarif-mapper.ts | 2 +- libs/hdf-converters/src/scoutsuite-mapper.ts | 2 +- libs/hdf-converters/src/snyk-mapper.ts | 2 +- libs/hdf-converters/src/sonarqube-mapper.ts | 2 +- libs/hdf-converters/src/twistlock-mapper.ts | 2 +- libs/hdf-converters/src/veracode-mapper.ts | 2 +- libs/hdf-converters/src/xccdf-results-mapper.ts | 2 +- 23 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/store/data_filters.ts b/apps/frontend/src/store/data_filters.ts index dfb8948339..783780065a 100644 --- a/apps/frontend/src/store/data_filters.ts +++ b/apps/frontend/src/store/data_filters.ts @@ -304,8 +304,8 @@ export class FilteredData extends VuexModule { */ get controls(): (filter: Filter) => readonly ContextualizedControl[] { /** Cache by filter */ - const localCache: LRUCache = - new LRUCache({max: MAX_CACHE_ENTRIES}); + const localCache = + new LRUCache({max: MAX_CACHE_ENTRIES}); return (filter: Filter) => { // Generate a hash for cache purposes. diff --git a/apps/frontend/src/store/severity_counts.ts b/apps/frontend/src/store/severity_counts.ts index f8c0fd6844..89307b018c 100644 --- a/apps/frontend/src/store/severity_counts.ts +++ b/apps/frontend/src/store/severity_counts.ts @@ -54,7 +54,7 @@ export class SeverityCount extends VuexModule { /** Generates a hash mapping each status -> a count of its members */ get hash(): (filter: Filter) => SeverityHash { // Establish our cache and dependency - const cache: LRUCache = new LRUCache({max: 30}); + const cache = new LRUCache({max: 30}); return (filter: Filter) => { const id = filter_cache_key(filter); diff --git a/apps/frontend/src/store/status_counts.ts b/apps/frontend/src/store/status_counts.ts index 8afe1c5813..1eaf1ca558 100644 --- a/apps/frontend/src/store/status_counts.ts +++ b/apps/frontend/src/store/status_counts.ts @@ -90,7 +90,7 @@ export class StatusCount extends VuexModule { /** Generates a hash mapping each status -> a count of its members */ get hash(): (filter: Filter) => StatusHash { // Establish our cache and dependency - const cache: LRUCache = new LRUCache({max: 30}); + const cache = new LRUCache({max: 30}); return (filter: Filter) => { const id = filter_cache_key(filter); diff --git a/libs/hdf-converters/src/anchore-grype-mapper.ts b/libs/hdf-converters/src/anchore-grype-mapper.ts index bd98f432a1..fdabccef08 100644 --- a/libs/hdf-converters/src/anchore-grype-mapper.ts +++ b/libs/hdf-converters/src/anchore-grype-mapper.ts @@ -8,7 +8,7 @@ import { MappedTransform } from './base-converter'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], diff --git a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts index df4af05b22..2edc6e4a01 100644 --- a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts +++ b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts @@ -20,7 +20,7 @@ import {getProwler} from './case-prowler'; import {getSecurityHub} from './case-security-hub'; import {getTrivy} from './case-trivy'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['CRITICAL', 0.9], ['HIGH', 0.7], ['MEDIUM', 0.5], @@ -107,11 +107,11 @@ function whichSpecialCase(finding: Record): SpecialCasing { } } -const SPECIAL_CASE_MAPPING: Map< +const SPECIAL_CASE_MAPPING = new Map< SpecialCasing, // eslint-disable-next-line @typescript-eslint/ban-types Record -> = new Map([ +>([ [SpecialCasing.CMSInSpec, getCMSInSpec()], [SpecialCasing.FirewallManager, getFirewallManager()], [SpecialCasing.GuardDuty, getGuardDuty()], diff --git a/libs/hdf-converters/src/burpsuite-mapper.ts b/libs/hdf-converters/src/burpsuite-mapper.ts index 59e9cf15aa..a25f0c5846 100644 --- a/libs/hdf-converters/src/burpsuite-mapper.ts +++ b/libs/hdf-converters/src/burpsuite-mapper.ts @@ -16,7 +16,7 @@ import { } from './utils/global'; // Constant -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], diff --git a/libs/hdf-converters/src/checkov-mapper.ts b/libs/hdf-converters/src/checkov-mapper.ts index b7365bfb25..63395fa6e7 100644 --- a/libs/hdf-converters/src/checkov-mapper.ts +++ b/libs/hdf-converters/src/checkov-mapper.ts @@ -84,7 +84,7 @@ type CheckovReport = { // Severity is only populated when passing in an API key via --bc-api-key, otherwise it is null // Default to medium - treat null/unknown risk as moderate until a formal risk assessment is performed. const MEDIUM_SEVERITY = 0.6; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 1], ['high', 0.8], ['important', 0.8], diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index ec6e3f6f4a..0173a7cc5b 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -94,7 +94,7 @@ enum StatusMapping { Not_Reviewed = 'Not Reviewed' } -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts index d826b6b73e..020358fd04 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts @@ -136,7 +136,7 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { ioptions: IOptions; index?: number; - impactMapping: Map = new Map([ + impactMapping = new Map([ [0.9, 'CRITICAL'], [0.7, 'HIGH'], [0.5, 'MEDIUM'], diff --git a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts index d80949bbaa..546050a090 100644 --- a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts +++ b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts @@ -52,7 +52,7 @@ type DataStorage = { const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 1.0], ['high', 0.7], ['medium', 0.5], diff --git a/libs/hdf-converters/src/dbprotect-mapper.ts b/libs/hdf-converters/src/dbprotect-mapper.ts index c0ae43f5b9..89f4f69eee 100644 --- a/libs/hdf-converters/src/dbprotect-mapper.ts +++ b/libs/hdf-converters/src/dbprotect-mapper.ts @@ -13,7 +13,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], diff --git a/libs/hdf-converters/src/dependency-track-mapper.ts b/libs/hdf-converters/src/dependency-track-mapper.ts index a545854186..e67dedaf6f 100644 --- a/libs/hdf-converters/src/dependency-track-mapper.ts +++ b/libs/hdf-converters/src/dependency-track-mapper.ts @@ -18,7 +18,7 @@ interface ICweEntry { name: string; } -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], diff --git a/libs/hdf-converters/src/gosec-mapper.ts b/libs/hdf-converters/src/gosec-mapper.ts index f12eebda5b..1e013dd4f8 100644 --- a/libs/hdf-converters/src/gosec-mapper.ts +++ b/libs/hdf-converters/src/gosec-mapper.ts @@ -11,7 +11,7 @@ import {CweNistMapping} from './mappings/CweNistMapping'; const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] diff --git a/libs/hdf-converters/src/jfrog-xray-mapper.ts b/libs/hdf-converters/src/jfrog-xray-mapper.ts index f91a3ce974..852fade408 100644 --- a/libs/hdf-converters/src/jfrog-xray-mapper.ts +++ b/libs/hdf-converters/src/jfrog-xray-mapper.ts @@ -15,7 +15,7 @@ import { } from './utils/global'; // Constants -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] diff --git a/libs/hdf-converters/src/nessus-mapper.ts b/libs/hdf-converters/src/nessus-mapper.ts index 1512dd11a7..76fdd821ef 100644 --- a/libs/hdf-converters/src/nessus-mapper.ts +++ b/libs/hdf-converters/src/nessus-mapper.ts @@ -13,7 +13,7 @@ import {CciNistMapping} from './mappings/CciNistMapping'; import {NessusPluginsNistMapping} from './mappings/NessusPluginsNistMapping'; // Constants -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['4', 0.9], ['3', 0.7], ['i', 0.7], diff --git a/libs/hdf-converters/src/netsparker-mapper.ts b/libs/hdf-converters/src/netsparker-mapper.ts index 7c9a4d0025..97c60af2a1 100644 --- a/libs/hdf-converters/src/netsparker-mapper.ts +++ b/libs/hdf-converters/src/netsparker-mapper.ts @@ -16,7 +16,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 1.0], ['high', 0.7], ['medium', 0.5], diff --git a/libs/hdf-converters/src/sarif-mapper.ts b/libs/hdf-converters/src/sarif-mapper.ts index b5b0a9b7ba..317d541f85 100644 --- a/libs/hdf-converters/src/sarif-mapper.ts +++ b/libs/hdf-converters/src/sarif-mapper.ts @@ -8,7 +8,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['error', 0.7], ['warning', 0.5], ['note', 0.3] diff --git a/libs/hdf-converters/src/scoutsuite-mapper.ts b/libs/hdf-converters/src/scoutsuite-mapper.ts index d0eb74a872..a1ed742606 100644 --- a/libs/hdf-converters/src/scoutsuite-mapper.ts +++ b/libs/hdf-converters/src/scoutsuite-mapper.ts @@ -19,7 +19,7 @@ const INSPEC_INPUTS_MAPPING = { boolean: 'Boolean', any: 'Any' }; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['danger', 0.7], ['warning', 0.5] ]); diff --git a/libs/hdf-converters/src/snyk-mapper.ts b/libs/hdf-converters/src/snyk-mapper.ts index 07bd342ed4..3edc1fe1b9 100644 --- a/libs/hdf-converters/src/snyk-mapper.ts +++ b/libs/hdf-converters/src/snyk-mapper.ts @@ -13,7 +13,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] diff --git a/libs/hdf-converters/src/sonarqube-mapper.ts b/libs/hdf-converters/src/sonarqube-mapper.ts index 3bd8585bef..aaf8989c6c 100644 --- a/libs/hdf-converters/src/sonarqube-mapper.ts +++ b/libs/hdf-converters/src/sonarqube-mapper.ts @@ -321,7 +321,7 @@ type Data = { }; // https://docs.sonarsource.com/sonarqube-server/latest/user-guide/rules/overview/#how-severities-are-assigned -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['blocker', 1.0], ['critical', 0.7], ['major', 0.5], diff --git a/libs/hdf-converters/src/twistlock-mapper.ts b/libs/hdf-converters/src/twistlock-mapper.ts index d0a8d80201..cd5ab45bbf 100644 --- a/libs/hdf-converters/src/twistlock-mapper.ts +++ b/libs/hdf-converters/src/twistlock-mapper.ts @@ -12,7 +12,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['important', 0.9], ['high', 0.7], diff --git a/libs/hdf-converters/src/veracode-mapper.ts b/libs/hdf-converters/src/veracode-mapper.ts index b27f855e17..4381b1133d 100644 --- a/libs/hdf-converters/src/veracode-mapper.ts +++ b/libs/hdf-converters/src/veracode-mapper.ts @@ -14,7 +14,7 @@ const SEVERITY = 'detailedreport.severity'; const FILE_PATH_VALUE = 'file_paths.file_path.@_.value'; const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['5', 0.9], ['4', 0.7], ['3', 0.5], diff --git a/libs/hdf-converters/src/xccdf-results-mapper.ts b/libs/hdf-converters/src/xccdf-results-mapper.ts index 4f9bbd70c6..e1ba0693a7 100644 --- a/libs/hdf-converters/src/xccdf-results-mapper.ts +++ b/libs/hdf-converters/src/xccdf-results-mapper.ts @@ -15,7 +15,7 @@ import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], From de3cbbc13dd9038719222d30c70a997dcb768e81 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:45:37 -0400 Subject: [PATCH 064/197] style: drop zero fractions from number literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unicorn/no-zero-fractions, applied surgically — 22 literals (1.0 -> 1, 0.0 -> 0) across 16 files. JavaScript has one number type, so the tokens are the same value; JSON.stringify emits them identically, which the golden-fixture mapper suites confirm. Verified: tsc clean, inspecjs green, frontend green, hdf-converters 156/157 (Splunk service spec CI-only, unchanged). Authored by: Aaron Lippold --- apps/frontend/src/components/cards/treemap/Cell.vue | 2 +- apps/frontend/src/utilities/color_util.ts | 2 +- apps/frontend/src/utilities/treemap_util.ts | 2 +- libs/hdf-converters/src/anchore-grype-mapper.ts | 2 +- libs/hdf-converters/src/asff-mapper/asff-mapper.ts | 4 ++-- libs/hdf-converters/src/asff-mapper/case-security-hub.ts | 2 +- .../src/ckl-mapper/checklist-jsonix-converter.ts | 4 ++-- libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts | 2 +- .../src/converters-from-hdf/asff/reverse-asff-mapper.ts | 2 +- libs/hdf-converters/src/cyclonedx-sbom-mapper.ts | 4 ++-- libs/hdf-converters/src/ionchannel-mapper.ts | 2 +- libs/hdf-converters/src/msft-secure-score-mapper.ts | 2 +- libs/hdf-converters/src/nessus-mapper.ts | 2 +- libs/hdf-converters/src/netsparker-mapper.ts | 6 +++--- libs/hdf-converters/src/sonarqube-mapper.ts | 4 ++-- libs/hdf-converters/src/veracode-mapper.ts | 2 +- 16 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/components/cards/treemap/Cell.vue b/apps/frontend/src/components/cards/treemap/Cell.vue index 1f1e3d8344..c9ef89c81a 100644 --- a/apps/frontend/src/components/cards/treemap/Cell.vue +++ b/apps/frontend/src/components/cards/treemap/Cell.vue @@ -67,7 +67,7 @@ export default class Cell extends Vue { @Prop({type: Number, default: 0}) readonly depth!: number; @Prop({type: Object, default: 0}) readonly scales!: XYScale; - scale = 1.0; + scale = 1; /** Are we a control? Use treemap util type checker */ get is_control(): boolean { diff --git a/apps/frontend/src/utilities/color_util.ts b/apps/frontend/src/utilities/color_util.ts index db0aebdd27..39b2eb4724 100644 --- a/apps/frontend/src/utilities/color_util.ts +++ b/apps/frontend/src/utilities/color_util.ts @@ -130,7 +130,7 @@ export function shift(baseColor: string, amount: number): string { const BASE_SPREAD = 0.5; export function gen_variants( baseColor: string, - spread = 1.0 + spread = 1 ): VuetifyParsedThemeItem { // Re-scale spread = spread * BASE_SPREAD; diff --git a/apps/frontend/src/utilities/treemap_util.ts b/apps/frontend/src/utilities/treemap_util.ts index c2971605b3..e4bd2591f4 100644 --- a/apps/frontend/src/utilities/treemap_util.ts +++ b/apps/frontend/src/utilities/treemap_util.ts @@ -218,7 +218,7 @@ function node_data_to_tree_map( return 1; } } else if (root.parent !== null) { - return 1.0 / root.parent.children.length; + return 1 / root.parent.children.length; } return 0; }); diff --git a/libs/hdf-converters/src/anchore-grype-mapper.ts b/libs/hdf-converters/src/anchore-grype-mapper.ts index fdabccef08..120d4b1277 100644 --- a/libs/hdf-converters/src/anchore-grype-mapper.ts +++ b/libs/hdf-converters/src/anchore-grype-mapper.ts @@ -13,7 +13,7 @@ const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['negligible', 0.0], + ['negligible', 0], ['unknown', 0.5] ]); diff --git a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts index 2edc6e4a01..4358a1ebf2 100644 --- a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts +++ b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts @@ -25,7 +25,7 @@ const IMPACT_MAPPING = new Map([ ['HIGH', 0.7], ['MEDIUM', 0.5], ['LOW', 0.3], - ['INFORMATIONAL', 0.0] + ['INFORMATIONAL', 0] ]); const SEVERITY_LABEL = 'Severity.Label'; @@ -422,7 +422,7 @@ export class ASFFMapper extends BaseConverter { (_.get(finding, SEVERITY_LABEL) as string | undefined) ? (_.get(finding, SEVERITY_LABEL) as string) : (_.get(finding, 'Severity.Normalized') as number) / - 100.0; + 100; impact = externalProductHandler( this, whichSpecialCase(finding), diff --git a/libs/hdf-converters/src/asff-mapper/case-security-hub.ts b/libs/hdf-converters/src/asff-mapper/case-security-hub.ts index f4c47d9433..1bbc299530 100644 --- a/libs/hdf-converters/src/asff-mapper/case-security-hub.ts +++ b/libs/hdf-converters/src/asff-mapper/case-security-hub.ts @@ -73,7 +73,7 @@ function findingImpact( // severity is required, but must include either 'label' or 'normalized' internally with 'label' being preferred. other values can be in here too such as the original severity rating. impact = _.get(finding, 'Severity.Label') || - (_.get(finding, 'Severity.Normalized') as unknown as number) / 100.0; + (_.get(finding, 'Severity.Normalized') as unknown as number) / 100; // securityhub asff file does not contain accurate severity information by setting things that shouldn't be informational to informational: when additional context, i.e. standards, is not provided, set informational to medium. if (typeof impact === 'string' && impact === 'INFORMATIONAL') { impact = 'MEDIUM'; diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index 0173a7cc5b..fa605a39a9 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -99,7 +99,7 @@ const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['none', 0.0] + ['none', 0] ]); export enum Severity { @@ -681,7 +681,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< ((computedImpact !== undefined && computedImpact !== impact) || impact < 0.1 || impact >= 0.9) && - impact !== 0.0 + impact !== 0 ) { hdfSpecificData.impact = control.impact; } diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts index a584579a79..019685adf8 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts @@ -126,7 +126,7 @@ function computeSeverity(vuln: ChecklistVuln): string { * @returns impact - number */ function transformImpact(vuln: ChecklistVuln): number { - if (vuln.status === 'Not Applicable') return 0.0; + if (vuln.status === 'Not Applicable') return 0; const severity = computeSeverity(vuln); let impact: number = ImpactMapping[severity as keyof typeof ImpactMapping]; const hdfExistingData = parseJson(vuln.thirdPartyTools); diff --git a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts index 020358fd04..fe72299809 100644 --- a/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/asff/reverse-asff-mapper.ts @@ -141,7 +141,7 @@ export class FromHdfToAsffMapper extends FromHdfBaseConverter { [0.7, 'HIGH'], [0.5, 'MEDIUM'], [0.3, 'LOW'], - [0.0, 'INFORMATIONAL'] + [0, 'INFORMATIONAL'] ]); constructor(hdfObj: ExecJSON.Execution, options: IOptions | undefined) { diff --git a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts index 546050a090..0df4031103 100644 --- a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts +++ b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts @@ -53,12 +53,12 @@ type DataStorage = { const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; const IMPACT_MAPPING = new Map([ - ['critical', 1.0], + ['critical', 1], ['high', 0.7], ['medium', 0.5], ['low', 0.3], ['info', 0.5], - ['none', 0.0], + ['none', 0], ['unknown', 0.5] ]); diff --git a/libs/hdf-converters/src/ionchannel-mapper.ts b/libs/hdf-converters/src/ionchannel-mapper.ts index 52f134ebea..f1c1fe9169 100644 --- a/libs/hdf-converters/src/ionchannel-mapper.ts +++ b/libs/hdf-converters/src/ionchannel-mapper.ts @@ -321,7 +321,7 @@ export class IonChannelMapper extends BaseConverter { } }, desc: '', - impact: 0.0, + impact: 0, code: { transformer: (dependency: Dependency) => JSON.stringify(dependency, null, 2) diff --git a/libs/hdf-converters/src/msft-secure-score-mapper.ts b/libs/hdf-converters/src/msft-secure-score-mapper.ts index f441c503bd..589ab1dbe8 100644 --- a/libs/hdf-converters/src/msft-secure-score-mapper.ts +++ b/libs/hdf-converters/src/msft-secure-score-mapper.ts @@ -131,7 +131,7 @@ export class MsftSecureScoreMapper extends BaseConverter { } const highMaxScore = Math.max(...knownMaxScores); - return highMaxScore / 10.0; + return highMaxScore / 10; } }, refs: [], diff --git a/libs/hdf-converters/src/nessus-mapper.ts b/libs/hdf-converters/src/nessus-mapper.ts index 76fdd821ef..7ffa96b186 100644 --- a/libs/hdf-converters/src/nessus-mapper.ts +++ b/libs/hdf-converters/src/nessus-mapper.ts @@ -21,7 +21,7 @@ const IMPACT_MAPPING = new Map([ ['ii', 0.5], ['1', 0.3], ['iii', 0.3], - ['0', 0.0] + ['0', 0] ]); const COMPLIANCE_PATH = 'compliance-reference'; const COMPLIANCE_CHECK_NAME = 'compliance-check-name'; diff --git a/libs/hdf-converters/src/netsparker-mapper.ts b/libs/hdf-converters/src/netsparker-mapper.ts index 97c60af2a1..7a87f92a6d 100644 --- a/libs/hdf-converters/src/netsparker-mapper.ts +++ b/libs/hdf-converters/src/netsparker-mapper.ts @@ -17,12 +17,12 @@ import { } from './utils/global'; const IMPACT_MAPPING = new Map([ - ['critical', 1.0], + ['critical', 1], ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['best_practice', 0.0], - ['information', 0.0] + ['best_practice', 0], + ['information', 0] ]); const CWE_NIST_MAPPING = new CweNistMapping(); diff --git a/libs/hdf-converters/src/sonarqube-mapper.ts b/libs/hdf-converters/src/sonarqube-mapper.ts index aaf8989c6c..4349b614bc 100644 --- a/libs/hdf-converters/src/sonarqube-mapper.ts +++ b/libs/hdf-converters/src/sonarqube-mapper.ts @@ -322,11 +322,11 @@ type Data = { // https://docs.sonarsource.com/sonarqube-server/latest/user-guide/rules/overview/#how-severities-are-assigned const IMPACT_MAPPING = new Map([ - ['blocker', 1.0], + ['blocker', 1], ['critical', 0.7], ['major', 0.5], ['minor', 0.3], - ['info', 0.0] + ['info', 0] ]); const CWE_NIST_MAPPING = new CweNistMapping(); diff --git a/libs/hdf-converters/src/veracode-mapper.ts b/libs/hdf-converters/src/veracode-mapper.ts index 4381b1133d..3cdf8d27c5 100644 --- a/libs/hdf-converters/src/veracode-mapper.ts +++ b/libs/hdf-converters/src/veracode-mapper.ts @@ -20,7 +20,7 @@ const IMPACT_MAPPING = new Map([ ['3', 0.5], ['2', 0.3], ['1', 0.1], - ['0', 0.0] + ['0', 0] ]); function impactMapping(severity: number | string): number { From bbe0b2d455e777dd10a4491b83270a3272fd4973 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:48:47 -0400 Subject: [PATCH 065/197] style: name every catch binding `error` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unicorn/catch-error-name, applied surgically — 30 catch bindings renamed from e/err/ex to error across 16 files, references included. Local-scope renames only; the fixer skips any binding the scope already uses. Verified: tsc clean, inspecjs green, frontend green, hdf-converters 156/157 (Splunk service spec CI-only), node --check on the one plain-CJS script touched. Authored by: Aaron Lippold --- .../src/components/cards/ComplianceChart.vue | 2 +- .../src/components/generic/CopyButton.vue | 4 ++-- .../components/global/groups/GroupModal.vue | 4 ++-- .../global/upload_tabs/FileReader.vue | 4 ++-- .../global/upload_tabs/aws/S3Reader.vue | 16 +++++++-------- .../global/upload_tabs/splunk/FileList.vue | 4 ++-- .../global/upload_tabs/tenable/FileList.vue | 4 ++-- apps/frontend/src/store/evaluations.ts | 10 +++++----- apps/frontend/src/utilities/tenable_util.ts | 16 +++++++-------- apps/frontend/src/views/Base.vue | 4 ++-- libs/hdf-converters/src/sonarqube-mapper.ts | 20 +++++++++---------- libs/hdf-converters/src/trufflehog-mapper.ts | 2 +- libs/hdf-converters/src/utils/parseJson.ts | 8 ++++---- libs/inspecjs/src/fileparse.ts | 12 +++++------ packaging/test-infra/fips-ec2/spike/bench.js | 4 ++-- 15 files changed, 57 insertions(+), 57 deletions(-) diff --git a/apps/frontend/src/components/cards/ComplianceChart.vue b/apps/frontend/src/components/cards/ComplianceChart.vue index b82d675e73..a61d2f4b5d 100644 --- a/apps/frontend/src/components/cards/ComplianceChart.vue +++ b/apps/frontend/src/components/cards/ComplianceChart.vue @@ -60,7 +60,7 @@ export default class ComplianceChart extends Vue { const val = calculateCompliance(this.filter); if (isNaN(val) || typeof val !== 'number') return []; return [val]; - } catch (e) { + } catch (error) { return []; } } diff --git a/apps/frontend/src/components/generic/CopyButton.vue b/apps/frontend/src/components/generic/CopyButton.vue index 5c1a5d2f24..b46f3a1956 100644 --- a/apps/frontend/src/components/generic/CopyButton.vue +++ b/apps/frontend/src/components/generic/CopyButton.vue @@ -40,8 +40,8 @@ export default class CopyButton extends Vue { this.unsecuredCopyToClipboard(this.text); } SnackbarModule.notify('Text copied to your clipboard'); - } catch (e) { - SnackbarModule.failure(`Failed to copy to your clipboard: ${e}`); + } catch (error) { + SnackbarModule.failure(`Failed to copy to your clipboard: ${error}`); } } diff --git a/apps/frontend/src/components/global/groups/GroupModal.vue b/apps/frontend/src/components/global/groups/GroupModal.vue index 9170c5872b..0640324423 100644 --- a/apps/frontend/src/components/global/groups/GroupModal.vue +++ b/apps/frontend/src/components/global/groups/GroupModal.vue @@ -214,8 +214,8 @@ export default class GroupModal extends Vue { // Not calling this would result in reactivity delays on the frontend await GroupsModule.FetchGroupData(); SnackbarModule.notify(`Group Successfully Saved`); - } catch (err) { - SnackbarModule.failure(`Failed to Save Group: ${err}`); + } catch (error) { + SnackbarModule.failure(`Failed to Save Group: ${error}`); } } diff --git a/apps/frontend/src/components/global/upload_tabs/FileReader.vue b/apps/frontend/src/components/global/upload_tabs/FileReader.vue index 1cc1eab45c..5686053f41 100644 --- a/apps/frontend/src/components/global/upload_tabs/FileReader.vue +++ b/apps/frontend/src/components/global/upload_tabs/FileReader.vue @@ -169,8 +169,8 @@ export default class FileReader extends mixins(ServerMixin) { const fileId = await InspecIntakeModule.loadFile({file}); this.percent = Math.floor((index++ / totalFiles) * 100); return fileId; - } catch (err) { - SnackbarModule.failure(String(err)); + } catch (error) { + SnackbarModule.failure(String(error)); document.body.style.cursor = 'default'; } }) diff --git a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue b/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue index e1f4df218e..8fb8365649 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue +++ b/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue @@ -132,8 +132,8 @@ export default class S3Reader extends Vue { }, // Failure of initial get session token: want to set error normally - (failure) => { - this.handleError(failure); + (error) => { + this.handleError(error); } ); } @@ -154,8 +154,8 @@ export default class S3Reader extends Vue { }, // Failure of initial get session token: want to set error normally - (failure) => { - this.handleError(failure); + (error) => { + this.handleError(error); } ); } @@ -196,8 +196,8 @@ export default class S3Reader extends Vue { this.assumedRole = success; this.step = 3; }, - (failure) => { - this.handleError(failure); + (error) => { + this.handleError(error); } ); } @@ -227,8 +227,8 @@ export default class S3Reader extends Vue { }) ); this.files = response.Contents || []; - } catch (err) { - this.handleError(err); + } catch (error) { + this.handleError(error); } } diff --git a/apps/frontend/src/components/global/upload_tabs/splunk/FileList.vue b/apps/frontend/src/components/global/upload_tabs/splunk/FileList.vue index 9bc93076f1..4ebc0141c1 100644 --- a/apps/frontend/src/components/global/upload_tabs/splunk/FileList.vue +++ b/apps/frontend/src/components/global/upload_tabs/splunk/FileList.vue @@ -153,8 +153,8 @@ export default class FileList extends Vue { return InspecIntakeModule.loadText({ text: JSON.stringify(hdf), filename: _.get(hdf, 'meta.filename') as unknown as string - }).catch((err) => { - SnackbarModule.failure(String(err)); + }).catch((error) => { + SnackbarModule.failure(String(error)); }); } else { SnackbarModule.failure('Attempted to load an undefined execution'); diff --git a/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue b/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue index 2b1e57961c..8ab24ce6a5 100644 --- a/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue +++ b/apps/frontend/src/components/global/upload_tabs/tenable/FileList.vue @@ -239,8 +239,8 @@ export default class FileList extends Vue { }; // .loadFile evaluates to data if file is not provided return await InspecIntakeModule.loadFile(textFile); - } catch (err) { - SnackbarModule.failure(String(err)); + } catch (error) { + SnackbarModule.failure(String(error)); } } } else { diff --git a/apps/frontend/src/store/evaluations.ts b/apps/frontend/src/store/evaluations.ts index 2d10d51590..1c3b8192a8 100644 --- a/apps/frontend/src/store/evaluations.ts +++ b/apps/frontend/src/store/evaluations.ts @@ -48,7 +48,7 @@ export class Evaluation extends VuexModule { return this.allEvaluations.find((e) => { return e.id === file.database_id?.toString(); }); - } catch (err) { + } catch (error) { return false; } }; @@ -110,8 +110,8 @@ export class Evaluation extends VuexModule { tags: [] // Tags are not yet implemented, so for now the value is passed in empty }) .then((fileId) => loadedIds.push(fileId)) - .catch((err) => { - SnackbarModule.failure(err); + .catch((error) => { + SnackbarModule.failure(error); }); } else if (evaluation.data) { const inputFile: FileLoadOptions = { @@ -132,8 +132,8 @@ export class Evaluation extends VuexModule { SnackbarModule.failure(`Empty File: ${evaluation.filename}`); } }) - .catch((err) => { - SnackbarModule.failure(err); + .catch((error) => { + SnackbarModule.failure(error); }) ) ); diff --git a/apps/frontend/src/utilities/tenable_util.ts b/apps/frontend/src/utilities/tenable_util.ts index 9fdeb64a54..2a30800386 100644 --- a/apps/frontend/src/utilities/tenable_util.ts +++ b/apps/frontend/src/utilities/tenable_util.ts @@ -127,8 +127,8 @@ export class TenableUtil { reject(this.getRejectConnectionMessage(error)); }); } - } catch (e) { - reject(`Unknown error: ${e}`); + } catch (error) { + reject(`Unknown error: ${error}`); } }); } @@ -271,8 +271,8 @@ export class TenableUtil { .catch((error) => { reject(this.getRejectMessage(error)); }); - } catch (e) { - reject(e); + } catch (error) { + reject(error); } }); } @@ -318,15 +318,15 @@ export class TenableUtil { const text = await firstFile.async('text'); resolve(text); - } catch (unzipErr) { - reject(unzipErr); + } catch (error) { + reject(error); } }) .catch((error) => { reject(this.getRejectMessage(error)); }); - } catch (e) { - reject(e); + } catch (error) { + reject(error); } }); } diff --git a/apps/frontend/src/views/Base.vue b/apps/frontend/src/views/Base.vue index 46d859e11f..4aa879f454 100644 --- a/apps/frontend/src/views/Base.vue +++ b/apps/frontend/src/views/Base.vue @@ -97,8 +97,8 @@ export default class Base extends Vue { const droppedFiles = event.dataTransfer?.files; if (droppedFiles) { [...droppedFiles].forEach(async (file) => { - return InspecIntakeModule.loadFile({file}).catch((err) => { - SnackbarModule.failure(String(err)); + return InspecIntakeModule.loadFile({file}).catch((error) => { + SnackbarModule.failure(String(error)); }); }); } diff --git a/libs/hdf-converters/src/sonarqube-mapper.ts b/libs/hdf-converters/src/sonarqube-mapper.ts index 4349b614bc..227a4cb508 100644 --- a/libs/hdf-converters/src/sonarqube-mapper.ts +++ b/libs/hdf-converters/src/sonarqube-mapper.ts @@ -941,7 +941,7 @@ export class SonarqubeResults { `Raw param data: ${JSON.stringify(statusParam)}` ); } - } catch (e) { + } catch (error) { // Step 2: Fallback to hardcoded full status list if discovery fails allStatuses = isLegacy ? ['OPEN', 'REOPENED', 'CONFIRMED', 'RESOLVED', 'CLOSED'] @@ -956,7 +956,7 @@ export class SonarqubeResults { logger.warn( `Could not discover statuses from server, using fallback: ${allStatuses.join(',')}` ); - logger.debug(inspect(e, {depth: 3})); + logger.debug(inspect(error, {depth: 3})); } // Step 3: Determine which deny-list to use @@ -1102,8 +1102,8 @@ export class SonarqubeResults { data.paging.pageIndex * data.paging.pageSize <= data.paging.total; page += 1; }) - .catch((e) => { - this.logAxiosError(e); + .catch((error) => { + this.logAxiosError(error); throw new Error('Failed at retrieving Sonarqube issues'); }); if (page * PAGE_SIZE > UPPER_LIMIT) { @@ -1141,8 +1141,8 @@ export class SonarqubeResults { data.paging.pageIndex * data.paging.pageSize <= data.paging.total; page += 1; }) - .catch((e) => { - this.logAxiosError(e); + .catch((error) => { + this.logAxiosError(error); throw new Error('Failed at retrieving the list of components'); }); if (page * PAGE_SIZE > UPPER_LIMIT) { @@ -1212,8 +1212,8 @@ export class SonarqubeResults { responseType: 'text' }) .then(({data}) => data) - .catch((e) => { - this.logAxiosError(e); + .catch((error) => { + this.logAxiosError(error); return Promise.reject( new Error( `Failed at getting Sonarqube code snippet for ${component}` @@ -1310,8 +1310,8 @@ export class SonarqubeResults { } }) .then(({data}) => data) - .catch((e) => { - this.logAxiosError(e); + .catch((error) => { + this.logAxiosError(error); return Promise.reject( new Error(`Failed at getting Sonarqube rule: ${rule}`) ); diff --git a/libs/hdf-converters/src/trufflehog-mapper.ts b/libs/hdf-converters/src/trufflehog-mapper.ts index 506831d983..22b0b88de3 100644 --- a/libs/hdf-converters/src/trufflehog-mapper.ts +++ b/libs/hdf-converters/src/trufflehog-mapper.ts @@ -10,7 +10,7 @@ export class TrufflehogResults { let parsedData = {}; try { parsedData = JSON.parse(trufflehogJson.trim()); - } catch (e) { + } catch (error) { parsedData = trufflehogJson .trim() .split('\n') diff --git a/libs/hdf-converters/src/utils/parseJson.ts b/libs/hdf-converters/src/utils/parseJson.ts index e23856bab1..3bb1b18a76 100644 --- a/libs/hdf-converters/src/utils/parseJson.ts +++ b/libs/hdf-converters/src/utils/parseJson.ts @@ -11,11 +11,11 @@ export type JSONValue = export function parseJson(str: string): Result { try { return {ok: true, value: JSON.parse(str)}; - } catch (e) { - if (e instanceof Error) { - return {ok: false, error: e}; + } catch (error) { + if (error instanceof Error) { + return {ok: false, error: error}; } else { - return {ok: false, error: new Error(String(e))}; + return {ok: false, error: new Error(String(error))}; } } } diff --git a/libs/inspecjs/src/fileparse.ts b/libs/inspecjs/src/fileparse.ts index bbaa44d61b..b93b549064 100644 --- a/libs/inspecjs/src/fileparse.ts +++ b/libs/inspecjs/src/fileparse.ts @@ -41,24 +41,24 @@ export function convertFile( try { result['1_0_ExecJson'] = EXEC_JSON_1_0.Convert.toExecJSON(jsonText); return result; - } catch (e) { - errors['1_0_ExecJson'] = e; + } catch (error) { + errors['1_0_ExecJson'] = error; } try { result['1_0_ExecJsonMin'] = EXEC_JSON_MIN_1_0.Convert.toExecJsonmin(jsonText); return result; - } catch (e) { - errors['1_0_ExecJsonMin'] = e; + } catch (error) { + errors['1_0_ExecJsonMin'] = error; } try { result['1_0_ProfileJson'] = PROFILE_JSON_1_0.Convert.toProfileJSON(jsonText); return result; - } catch (e) { - errors['1_0_ProfileJson'] = e; + } catch (error) { + errors['1_0_ProfileJson'] = error; } if (keepErrors) { diff --git a/packaging/test-infra/fips-ec2/spike/bench.js b/packaging/test-infra/fips-ec2/spike/bench.js index 8efe598d66..ea0aab7fa3 100644 --- a/packaging/test-infra/fips-ec2/spike/bench.js +++ b/packaging/test-infra/fips-ec2/spike/bench.js @@ -91,7 +91,7 @@ const steal = () => { stop = true; console.log(`fs.readFile ms — baseline p50=${pct(base, 50).toFixed(2)} p95=${pct(base, 95).toFixed(2)} | under sustained 8-KDF load p50=${pct(loaded, 50).toFixed(2)} p95=${pct(loaded, 95).toFixed(2)}`); -})().catch((e) => { - console.error('BENCH FAILED:', e.message); +})().catch((error) => { + console.error('BENCH FAILED:', error.message); process.exit(1); }); From f7a5ada178db32a91c238c31aa0149da8fd0320b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 13 Aug 2026 10:52:02 -0400 Subject: [PATCH 066/197] style: make length checks explicit comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unicorn/explicit-length-check, applied surgically — 54 sites across 29 files: `.length` truthiness and `!== 0` become `> 0`, `!x.length` becomes `=== 0`. For the number type these are the same branch, spelled so the reader need not recall that 0 is falsy. Verified: tsc clean, inspecjs green, frontend green, hdf-converters 156/157 (Splunk service spec CI-only, unchanged). Authored by: Aaron Lippold --- .../src/components/cards/EvaluationInfo.vue | 2 +- apps/frontend/src/components/cards/ProfileInfo.vue | 2 +- .../frontend/src/components/cards/treemap/Cell.vue | 2 +- .../src/components/cards/treemap/Treemap.vue | 4 ++-- .../src/components/global/ExportCKLModal.vue | 4 ++-- .../src/components/global/ExportCSVModal.vue | 2 +- .../src/components/global/login/LocalLogin.vue | 2 +- .../global/upload_tabs/DatabaseReader.vue | 2 +- .../components/global/upload_tabs/LoadFileList.vue | 8 ++++---- .../components/global/upload_tabs/SampleList.vue | 2 +- .../components/global/upload_tabs/aws/FileList.vue | 2 +- apps/frontend/src/store/search.ts | 2 +- apps/frontend/src/utilities/delta_util.ts | 2 +- apps/frontend/src/utilities/treemap_util.ts | 2 +- apps/frontend/src/views/Compare.vue | 2 +- apps/frontend/src/views/Login.vue | 2 +- apps/frontend/src/views/Results.vue | 14 +++++++------- libs/hdf-converters/src/asff-mapper/asff-mapper.ts | 4 ++-- libs/hdf-converters/src/aws-config-mapper.ts | 4 ++-- libs/hdf-converters/src/base-converter.ts | 2 +- .../src/ckl-mapper/checklist-jsonix-converter.ts | 6 +++--- .../src/converters-from-hdf/asff/transformers.ts | 2 +- .../splunk/reverse-splunk-mapper.ts | 4 ++-- libs/hdf-converters/src/cyclonedx-sbom-mapper.ts | 2 +- libs/hdf-converters/src/netsparker-mapper.ts | 2 +- libs/hdf-converters/src/sonarqube-mapper.ts | 12 ++++++------ libs/inspecjs/src/compat_impl/compat_inspec_1_0.ts | 2 +- libs/inspecjs/src/context.ts | 6 +++--- libs/inspecjs/src/nist.ts | 4 ++-- 29 files changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/frontend/src/components/cards/EvaluationInfo.vue b/apps/frontend/src/components/cards/EvaluationInfo.vue index 24cbb34a01..f3bc29c706 100644 --- a/apps/frontend/src/components/cards/EvaluationInfo.vue +++ b/apps/frontend/src/components/cards/EvaluationInfo.vue @@ -15,7 +15,7 @@
    Groups: diff --git a/apps/frontend/src/components/cards/ProfileInfo.vue b/apps/frontend/src/components/cards/ProfileInfo.vue index 739f87b31a..c5a0ec0d45 100644 --- a/apps/frontend/src/components/cards/ProfileInfo.vue +++ b/apps/frontend/src/components/cards/ProfileInfo.vue @@ -55,7 +55,7 @@
    Inputs for {{ profile.data.title }}
    -
    +