# Capability Spec Delta: password-strength (enterprise-password-strength) **Package**: `@suaveplan/password-strength` **Status**: PROPOSED — first delta; becomes canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Dictionary pattern matcher with l33t and reverse-string lookup The package SHALL export `dictionaryMatch(password, dictionaries)` returning every substring of `password` that exists in any supplied dictionary, with one match record per (substring, dictionary, l33t-substitution-set, reversed-flag) tuple. The matcher SHALL enumerate every l33t substitution combination from the configured `l33tTable` (default per Wheeler §3.1) and SHALL also test the reversed string. Every match SHALL carry `pattern: "dictionary"`, the matched substring, the matched dictionary entry, the rank, and the dictionary name. The matcher SHALL NOT mutate the supplied `dictionaries` argument. #### Scenario: matches a plain dictionary token - **WHEN** `dictionaryMatch("password", { passwords: new Map([["password", 1]]) })` runs - **THEN** the result contains exactly one match with `i=0`, `j=7`, `token="password"`, `matchedWord="password"`, `rank=1`, `l33t=false`, `reversed=false` #### Scenario: matches a l33t-substituted token - **WHEN** `dictionaryMatch("p4ssw0rd", { passwords: new Map([["password", 1]]) })` runs with the default l33t table - **THEN** the result contains a match with `l33t=true`, `sub: { "4": "a", "0": "o" }`, `matchedWord="password"` #### Scenario: matches a reversed token - **WHEN** `dictionaryMatch("drowssap", { passwords: new Map([["password", 1]]) })` runs - **THEN** the result contains a match with `reversed=true`, `matchedWord="password"`, `i=0`, `j=7` #### Scenario: emits one match per (l33t, reversed) tuple - **WHEN** the same substring matches under both l33t-true and reversed-true - **THEN** two distinct match records are emitted (the cover algorithm picks the lower-guesses one) **Verified by:** `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a plain dictionary token"` AND `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a l33t-substituted token"` AND `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a reversed token"` AND `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"emits one match per l33t-reversed tuple"` --- ### Requirement: Sequence pattern matcher The package SHALL export `sequenceMatch(password)` returning every substring of length ≥ 2 that is an alphabetical (`abcd`, `zyx`) or numeric (`1234`, `87`) sequence in either direction. Every match SHALL carry `pattern: "sequence"`, `sequenceName: "lower" | "upper" | "digits"`, `sequenceSpace: 26 | 26 | 10`, and `ascending: boolean`. #### Scenario: matches an ascending alphabetical run - **WHEN** `sequenceMatch("abcd")` runs - **THEN** the result contains one match with `i=0`, `j=3`, `ascending=true`, `sequenceName="lower"` #### Scenario: matches a descending numeric run - **WHEN** `sequenceMatch("9876")` runs - **THEN** the result contains one match with `ascending=false`, `sequenceName="digits"`, `sequenceSpace=10` #### Scenario: rejects length-1 substrings - **WHEN** `sequenceMatch("a")` runs - **THEN** the result is an empty array **Verified by:** `packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches ascending alphabetical run"` AND `packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches descending numeric run"` AND `packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"rejects length-1 substrings"` --- ### Requirement: Repeat pattern matcher The package SHALL export `repeatMatch(password, dictionaries)` returning every substring composed of a repeating chunk of length ≥ 1 (e.g. `aaaa`, `abcabc`, `babababa`). Every match SHALL carry `pattern: "repeat"`, `baseToken` (the repeating chunk), `repeatCount`, and `baseGuesses` computed by recursively running `mostGuessableMatchSequence` on the `baseToken` with the supplied dictionaries. #### Scenario: matches a single-character repeat - **WHEN** `repeatMatch("aaaa", emptyDictionaries)` runs - **THEN** the result contains a match with `baseToken="a"`, `repeatCount=4` #### Scenario: matches a multi-character repeat - **WHEN** `repeatMatch("abcabc", emptyDictionaries)` runs - **THEN** the result contains a match with `baseToken="abc"`, `repeatCount=2` #### Scenario: recursively scores baseToken via mostGuessableMatchSequence - **WHEN** `repeatMatch("passwordpassword", { passwords: new Map([["password", 1]]) })` runs - **THEN** the resulting match's `baseGuesses` equals `mostGuessableMatchSequence("password", […]).guesses` **Verified by:** `packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches single-character repeat"` AND `packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches multi-character repeat"` AND `packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"recursively scores baseToken"` --- ### Requirement: Date pattern matcher The package SHALL export `dateMatch(password, opts?: { referenceYear?: number })` returning every substring that parses as a date with `1900 ≤ year ≤ referenceYear + 50` (default `referenceYear` is current year). Both separator (`/`, `-`, `.`, ` `) and no-separator (`mmddyyyy`, `yyyymmdd`, `mmyy`, `yyyy`) forms SHALL be recognised. Every match SHALL carry `pattern: "date"`, `year`, `month`, `day`, `separator`. When a substring admits multiple date parses, the matcher SHALL emit the parse whose `year` is closest to `referenceYear`. #### Scenario: matches a separator date - **WHEN** `dateMatch("11/22/1985")` runs with `referenceYear=2026` - **THEN** the result contains a match with `year=1985`, `month=11`, `day=22`, `separator="/"` #### Scenario: matches a no-separator date - **WHEN** `dateMatch("11221985")` runs with `referenceYear=2026` - **THEN** the result contains a match with `year=1985`, `month=11`, `day=22`, `separator=""` #### Scenario: rejects out-of-range year - **WHEN** `dateMatch("11/22/1850")` runs with `referenceYear=2026` - **THEN** the result does NOT contain a date match #### Scenario: prefers year closest to reference - **WHEN** `dateMatch("010199")` runs with `referenceYear=2026` (parseable as 1999 or 2099) - **THEN** the resulting match has `year=2099` (closest to `referenceYear + 50` window) **Verified by:** `packages/crypto/password-strength/src/matchers/date/date.test.ts::"matches separator date"` AND `packages/crypto/password-strength/src/matchers/date/date.test.ts::"matches no-separator date"` AND `packages/crypto/password-strength/src/matchers/date/date.test.ts::"rejects out-of-range year"` AND `packages/crypto/password-strength/src/matchers/date/date.test.ts::"prefers year closest to reference"` --- ### Requirement: Keyboard adjacency pattern matcher The package SHALL export `keyboardMatch(password, opts?: { graphs?: KeyboardGraphMap })` returning every substring that traces a walk on a keyboard adjacency graph. QWERTY graph SHALL be the default. DVORAK and AZERTY graphs SHALL be exported under subpaths `./graphs/dvorak` and `./graphs/azerty` respectively and SHALL be runtime-pluggable via the `graphs` option. Every match SHALL carry `pattern: "spatial"`, `graph` (graph name), `turns` (count of direction changes), and `shiftedCount` (count of shifted-key presses on the walk). The matcher SHALL throw `PWSTRENGTH.GRAPH_INVALID` when invoked with a graph containing self-adjacency or non-symmetric edges. #### Scenario: matches a QWERTY straight-line walk - **WHEN** `keyboardMatch("qwerty")` runs with the default QWERTY graph - **THEN** the result contains a match with `graph="qwerty"`, `turns=0`, `shiftedCount=0`, `i=0`, `j=5` #### Scenario: counts turns on direction change - **WHEN** `keyboardMatch("qazwsx")` runs - **THEN** the resulting match's `turns` is ≥ 1 #### Scenario: counts shifted keys - **WHEN** `keyboardMatch("QWERTY")` runs - **THEN** the resulting match's `shiftedCount` equals `6` #### Scenario: rejects malformed graph - **WHEN** `keyboardMatch("qwerty", { graphs: { broken: { q: ["q"] } } })` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.GRAPH_INVALID"` **Verified by:** `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"matches QWERTY straight-line walk"` AND `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts turns on direction change"` AND `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts shifted keys"` AND `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"rejects malformed graph"` --- ### Requirement: Regex pattern matcher (recent-year + registry-pluggable) The package SHALL export `regexMatch(password, opts?: { regexes?: Record })` returning every substring matching a registered regex. The default registry SHALL include `recent_year` matching `19xx` / `20xx`. Callers MAY register additional regexes via `opts.regexes`. Every match SHALL carry `pattern: "regex"`, `regexName`, and `regexMatch: RegExpMatchArray`. #### Scenario: matches a recent year - **WHEN** `regexMatch("login1985")` runs - **THEN** the result contains a match with `regexName="recent_year"`, `token="1985"` #### Scenario: matches a custom registered regex - **WHEN** `regexMatch("ABCDEF", { regexes: { hex: /[0-9A-F]+/g } })` runs - **THEN** the result contains a match with `regexName="hex"` **Verified by:** `packages/crypto/password-strength/src/matchers/regex/regex.test.ts::"matches recent year"` AND `packages/crypto/password-strength/src/matchers/regex/regex.test.ts::"matches custom registered regex"` --- ### Requirement: `mostGuessableMatchSequence` dynamic-programming cover The package SHALL export `mostGuessableMatchSequence(password, matches): MatchSequence` returning `{ password, guesses, guessesLog10, sequence }` where `sequence` is the lowest-`Π guesses(match) × k!` cover of `password` (factorial penalty for `k` matches per Wheeler §4). When two covers tie on product-times-factorial, the cover with fewer matches SHALL win. The DP SHALL be `O(n^2)` over `n = password.length`. Empty input SHALL return `{ guesses: 1, guessesLog10: 0, sequence: [] }`. #### Scenario: empty input - **WHEN** `mostGuessableMatchSequence("", [])` runs - **THEN** the result equals `{ password: "", guesses: 1, guessesLog10: 0, sequence: [] }` #### Scenario: single-match cover - **WHEN** the input has exactly one match covering the entire password - **THEN** the resulting `sequence` contains exactly that match and `guesses` equals `match.guesses × 1!` #### Scenario: prefers fewer-match cover on tie - **WHEN** two covers have identical `Π guesses × k!` - **THEN** the cover with fewer matches is returned #### Scenario: factorial penalty - **WHEN** a 2-match cover has guess product `1000` - **THEN** the resulting `guesses` equals `1000 × 2! = 2000` **Verified by:** `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"empty input returns 1 guess"` AND `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"single-match cover"` AND `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"prefers fewer-match cover on tie"` AND `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"applies factorial penalty"` --- ### Requirement: `estimateGuesses` per pattern type The package SHALL export `estimateGuesses(match, password): number` implementing the Wheeler §4 closed-form formulas: dictionary (`rank × uppercaseVariations × l33tVariations × (reversed ? 2 : 1)`), sequence (`base × length × (ascending ? 1 : 2)` where `base ∈ {4, 26, 26, 10}` for one-char / lower / upper / digits), repeat (`baseGuesses × repeatCount`), date (`365 × |year - referenceYear| × (separator ? 4 : 1)`), keyboard (`Σ_{i,j,t,s} (i_choose_j) × possible_starts × paths` per Wheeler §4.5), regex (year-distance heuristic for `recent_year`). #### Scenario: dictionary closed form - **GIVEN** a match `{ pattern: "dictionary", rank: 100, l33t: false, reversed: false, token: "password" }` - **WHEN** `estimateGuesses(match, "password")` runs - **THEN** the result equals `100` (no uppercase, no l33t, no reverse multipliers) #### Scenario: dictionary with reverse and l33t multipliers - **GIVEN** a match `{ pattern: "dictionary", rank: 100, l33t: true, sub: { "4": "a" }, reversed: true, token: "p4ssword" }` - **WHEN** `estimateGuesses(match, "p4ssword")` runs - **THEN** the result is `≥ 100 × 2 × l33tVariations(sub)` (reverse and l33t multipliers applied) #### Scenario: agrees with Wheeler corpus to ≤1 bit log10 - **WHEN** every vector in the vendored Wheeler test corpus is fed through `estimateGuesses` - **THEN** every result agrees to `|Δ log10(guesses)| ≤ 1` **Verified by:** `packages/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary closed form"` AND `packages/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary reverse and l33t multipliers"` AND `packages/crypto/password-strength/src/conformance.test.ts::"Wheeler corpus agrees to <=1 bit log10"` --- ### Requirement: `crackTimes` four-scenario calculator The package SHALL export `crackTimes(guesses, opts?: CrackTimeOptions): CrackTimes` returning a record `{ online_throttling_100_per_hour, online_no_throttling_10_per_second, offline_slow_hashing_1e4_per_second, offline_fast_hashing_1e10_per_second }` where each value is `guesses / rate × (opts.adjustments?. ?? 1)`. All four scenarios SHALL always be present in the output. Adjustments map values SHALL be positive numbers; Zod boundary SHALL reject zero or negative. #### Scenario: four scenarios at boundary guesses - **WHEN** `crackTimes(1e10)` runs - **THEN** the result has `online_throttling_100_per_hour ≈ 1e10 / (100/3600)` AND `offline_fast_hashing_1e10_per_second = 1` #### Scenario: applies adjustments multiplier - **WHEN** `crackTimes(1e10, { adjustments: { online_throttled: 0.5 } })` runs - **THEN** the resulting `online_throttling_100_per_hour` is half the unadjusted value #### Scenario: rejects non-positive adjustment - **WHEN** `crackTimes(1e10, { adjustments: { online_throttled: 0 } })` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INVALID_OPTIONS"` **Verified by:** `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"four scenarios at boundary guesses"` AND `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"applies adjustments multiplier"` AND `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"rejects non-positive adjustment"` --- ### Requirement: Score buckets `0` through `4` The package SHALL export `score(guesses): 0 | 1 | 2 | 3 | 4` per Wheeler §5: `0` when `guesses < 1e3`, `1` when `< 1e6`, `2` when `< 1e8`, `3` when `< 1e10`, `4` when `≥ 1e10`. The output type SHALL be the literal union `0 | 1 | 2 | 3 | 4` (verified at type level). #### Scenario: bucket boundary 1e3 - **WHEN** `score(999)` and `score(1000)` are called - **THEN** the results are `0` and `1` #### Scenario: bucket boundary 1e6 - **WHEN** `score(999999)` and `score(1000000)` are called - **THEN** the results are `1` and `2` #### Scenario: bucket boundary 1e8 - **WHEN** `score(99999999)` and `score(100000000)` are called - **THEN** the results are `2` and `3` #### Scenario: bucket boundary 1e10 - **WHEN** `score(9999999999)` and `score(10000000000)` are called - **THEN** the results are `3` and `4` #### Scenario: type-level literal union - **WHEN** `expectTypeOf(score(0)).toEqualTypeOf<0 | 1 | 2 | 3 | 4>()` runs - **THEN** the type-level check passes **Verified by:** `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e3"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e6"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e8"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e10"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"score literal union type"` --- ### Requirement: `assess` full-pipeline public API The package SHALL export `assess(password: string, opts?: AssessOptions): StrengthResult` returning `{ password, guesses, guessesLog10, score, crackTimes, sequence, feedback, calcTimeMs }`. The pipeline SHALL: (1) Zod-validate `opts`; (2) reject input with `length > 200` via `PWSTRENGTH.INPUT_TOO_LONG`; (3) prepend `opts.userInputs` (if any) to the dictionary set as a synthetic `user_inputs` ranked dictionary; (4) run all six pattern matchers; (5) call `mostGuessableMatchSequence`; (6) call `crackTimes`; (7) call `score`; (8) call `feedback`; (9) record OTel histogram and counter; (10) return result. The DoS-guard boundary SHALL be exclusive of 200 (199 accepted, 200 rejected). #### Scenario: full pipeline returns canonical shape - **WHEN** `assess("Password1!")` runs - **THEN** the result is shaped per `StrengthResultSchema` and `score` is `≤ 1` #### Scenario: 199-character input is accepted - **WHEN** `assess("a".repeat(199))` runs - **THEN** the call returns a `StrengthResult` (no throw) #### Scenario: 200-character input is rejected - **WHEN** `assess("a".repeat(200))` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"` #### Scenario: userInputs surface as ranked matches - **WHEN** `assess("hello-acme-corp", { userInputs: ["acme corp"] })` runs - **THEN** the resulting `sequence` contains a match with `dictionaryName="user_inputs"` #### Scenario: custom dictionaries take precedence - **WHEN** `assess("trustno1", { dictionaries: loadDictionaries({ passwords: new Map([["trustno1", 1]]) }) })` runs - **THEN** the resulting `score` is `0` **Verified by:** `packages/crypto/password-strength/src/assess/assess.test.ts::"full pipeline returns canonical shape"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"userInputs surface as ranked matches"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"custom dictionaries take precedence"` --- ### Requirement: `feedback` keyed-string generator with i18n resolver The package SHALL export `feedback(result: StrengthResult, opts?: FeedbackOptions): { warning: string; suggestions: string[] }` returning a warning + 0..N suggestions. Strings SHALL be obtained by calling `opts.resolver(key, values)` when supplied; the package SHALL fall back to a built-in English message map when `opts.resolver` is undefined or returns `undefined` for a key. The warning SHALL be keyed off the longest-cover pattern in `result.sequence` (e.g. `"feedback.warning.dictionary.top10"` for `result.sequence[0]` being a top-10 password). When the resolver throws, the package SHALL throw `PWSTRENGTH.RESOLVER_FAILED` with `cause` preserved. #### Scenario: dispatches to resolver - **GIVEN** a result whose longest match is a top-10 password - **AND** `opts.resolver` returns `"RESOLVED"` for the corresponding key - **WHEN** `feedback(result, opts)` runs - **THEN** the resulting `warning` equals `"RESOLVED"` #### Scenario: falls back to English when resolver is undefined - **GIVEN** a result whose longest match is a top-10 password - **WHEN** `feedback(result)` runs (no resolver) - **THEN** the resulting `warning` is the built-in English string for that key #### Scenario: resolver throw surfaces RESOLVER_FAILED - **WHEN** `opts.resolver` throws an `Error("boom")` - **THEN** `feedback(result, opts)` throws `PasswordStrengthError` with code `"PWSTRENGTH.RESOLVER_FAILED"` and `cause.message === "boom"` **Verified by:** `packages/crypto/password-strength/src/feedback/feedback.test.ts::"dispatches to resolver"` AND `packages/crypto/password-strength/src/feedback/feedback.test.ts::"falls back to English when resolver undefined"` AND `packages/crypto/password-strength/src/feedback/feedback.test.ts::"resolver throw surfaces RESOLVER_FAILED"` --- ### Requirement: `loadDictionaries` runtime swap (no global mutation) The package SHALL export `loadDictionaries(dicts: PartialDictionaryMap): DictionarySet` returning a fresh `DictionarySet` value with the supplied dictionaries merged over the bundled defaults. The bundled default dictionaries SHALL NOT be mutated. The function SHALL throw `PWSTRENGTH.DICTIONARY_INVALID` when any entry has a non-positive `rank` or non-string token. #### Scenario: returns a fresh DictionarySet - **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 1]]) })` is called twice - **THEN** the two returned values are distinct object references #### Scenario: does not mutate bundled defaults - **GIVEN** the default English dictionary set is captured before the call - **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 1]]) })` runs - **THEN** the captured default dictionary set is byte-equal to its post-call value #### Scenario: rejects non-positive rank - **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 0]]) })` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.DICTIONARY_INVALID"` **Verified by:** `packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"returns a fresh DictionarySet"` AND `packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"does not mutate bundled defaults"` AND `packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"rejects non-positive rank"` --- ### Requirement: `userInputs` synthetic dictionary The package SHALL accept `opts.userInputs: string[]` on `assess`. Every supplied input SHALL contribute to a synthetic `user_inputs` ranked dictionary (rank assigned by input order). Matches against `user_inputs` SHALL surface in `feedback` with the personal-info warning key (`feedback.warning.personalInfo`). #### Scenario: userInputs become a ranked dictionary - **WHEN** `assess("alice@example.com-pwd", { userInputs: ["alice@example.com"] })` runs - **THEN** `result.sequence` contains a match with `dictionaryName="user_inputs"` and `matchedWord="alice@example.com"` #### Scenario: feedback surfaces personalInfo key - **WHEN** the longest-cover match in `result.sequence` has `dictionaryName="user_inputs"` - **THEN** `feedback(result)` returns a warning equal to the resolver's value for `"feedback.warning.personalInfo"` (or the English fallback) **Verified by:** `packages/crypto/password-strength/src/assess/assess.test.ts::"userInputs become a ranked dictionary"` AND `packages/crypto/password-strength/src/feedback/feedback.test.ts::"surfaces personalInfo warning key"` --- ### Requirement: Multilingual dictionary subpaths The package SHALL ship Wikipedia top-30 000 dictionaries for `en` (default), `de`, `fr`, `es`, `zh-Hans`, `ru` under subpath exports `./dicts/`. Each subpath SHALL export a `DictionarySet`-shaped value usable directly with `assess(pwd, { dictionaries: })`. #### Scenario: every locale subpath resolves - **WHEN** every subpath in `./dicts/{en,de,fr,es,zh-Hans,ru}` is imported - **THEN** every import resolves with no `Module not found` errors AND every imported value satisfies `DictionarySetSchema` #### Scenario: locale dictionary plugs into assess - **WHEN** `assess("passwort", { dictionaries: deDictionaries })` runs (where `passwort` is rank 1 in the `de` bundle) - **THEN** `result.score` is `0` **Verified by:** `packages/crypto/password-strength/src/dicts/dicts.test.ts::"every locale subpath resolves"` AND `packages/crypto/password-strength/src/dicts/dicts.test.ts::"locale dictionary plugs into assess"` --- ### Requirement: Online-attack adjustment The package SHALL accept `opts.adjustments` on `crackTimes` allowing site operators to multiply each scenario's effective rate by a positive number (e.g. `{ online_throttled: 0.5 }` to model a stricter rate-limit policy halving the attacker's online throughput). #### Scenario: halves online_throttled time - **WHEN** `crackTimes(1e6, { adjustments: { online_throttled: 0.5 } })` runs - **THEN** the resulting `online_throttling_100_per_hour` equals half the unadjusted value #### Scenario: every scenario is independently adjustable - **WHEN** `crackTimes(1e6, { adjustments: { offline_fast: 2 } })` runs - **THEN** only `offline_fast_hashing_1e10_per_second` is affected; the other three scenarios match the unadjusted values **Verified by:** `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"halves online_throttled time"` AND `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"adjusts each scenario independently"` --- ### Requirement: `assessReactive` streaming wrapper The package SHALL export `assessReactive(input$: AsyncIterable, opts?: AssessReactiveOptions): AsyncIterable` debouncing input emissions by `opts.debounceMs` (default 150) and cancelling any in-flight `assess` when a newer input arrives. Cancelled assessments SHALL NOT yield a result. The wrapper SHALL honour `opts.signal: AbortSignal`; aborted iteration SHALL complete cleanly with no throw and no further yields. #### Scenario: debounces rapid inputs - **WHEN** an input stream emits `["p", "pa", "pas", "pass"]` within `< debounceMs` - **THEN** the output stream yields exactly one result for `"pass"` #### Scenario: cancels in-flight on newer input - **WHEN** an input emits `"first"`, `assess` for `"first"` is in flight, and `"second"` is emitted - **THEN** the output stream yields a result only for `"second"` (no result for `"first"`) #### Scenario: AbortSignal completes cleanly - **GIVEN** `opts.signal` is aborted mid-stream - **WHEN** the consumer awaits the next iteration - **THEN** the iterable completes (`done: true`) with no thrown error **Verified by:** `packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"debounces rapid inputs"` AND `packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"cancels in-flight on newer input"` AND `packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"AbortSignal completes cleanly"` --- ### Requirement: `INPUT_TOO_LONG` DoS guard The package SHALL reject any input string with `length > 200` from `assess` and `assessReactive` by throwing `PWSTRENGTH.INPUT_TOO_LONG`. The boundary is exclusive: 199 accepted, 200 rejected. #### Scenario: 199-character input accepted - **WHEN** `assess("a".repeat(199))` runs - **THEN** the call returns a `StrengthResult` #### Scenario: 200-character input rejected - **WHEN** `assess("a".repeat(200))` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"` #### Scenario: assessReactive enforces the same bound - **WHEN** an input stream emits `"a".repeat(200)` - **THEN** the consumer receives a thrown `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"` **Verified by:** `packages/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"rejects 200-character input"` --- ### Requirement: Error code registry The package SHALL declare an error registry via `defineCodes("PWSTRENGTH", […])` containing exactly: `INPUT_TOO_LONG`, `INVALID_OPTIONS`, `DICTIONARY_INVALID`, `GRAPH_INVALID`, `RESOLVER_FAILED`, `ABORTED`. Every code SHALL be thrown by at least one production path; `ABORTED` MAY be surfaced as the iteration-completion reason on `assessReactive` rather than as a thrown error, but SHALL be referenced from production code (per `openspec/AGENTS.md` §2c "wired in, not deleted"). #### Scenario: every code thrown by ≥1 production path - **WHEN** the test suite drives each of the five throw-paths and the one signal-cancellation path - **THEN** every code in the registry is observed at least once **Verified by:** `packages/crypto/password-strength/src/errors/error-codes.test.ts::"every code is thrown by a production path"` AND `packages/crypto/password-strength/src/errors/error-codes.test.ts::"ABORTED is surfaced from assessReactive cancellation"` --- ### Requirement: Zod boundary schemas The package SHALL export Zod schemas at every public boundary: `AssessOptionsSchema`, `FeedbackOptionsSchema`, `AssessReactiveOptionsSchema`, `CrackTimeOptionsSchema`, `MatchSchema` (discriminated union), `StrengthResultSchema`, `DictionarySetSchema`. Every schema SHALL throw `PWSTRENGTH.INVALID_OPTIONS` (wrapping `ZodError`) when validation fails. Public functions SHALL Zod-parse their options before any other work. #### Scenario: rejects malformed AssessOptions - **WHEN** `assess("pwd", { userInputs: 42 } as never)` runs - **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INVALID_OPTIONS"` #### Scenario: MatchSchema discriminates over pattern types - **WHEN** `MatchSchema.parse({ pattern: "dictionary", i: 0, j: 7, token: "password", matchedWord: "password", rank: 1, dictionaryName: "passwords", l33t: false, reversed: false })` runs - **THEN** the parse succeeds #### Scenario: rejects unknown pattern type - **WHEN** `MatchSchema.parse({ pattern: "unknown" } as never)` runs - **THEN** the parse throws `ZodError` **Verified by:** `packages/crypto/password-strength/src/schemas/schemas.test.ts::"rejects malformed AssessOptions"` AND `packages/crypto/password-strength/src/schemas/schemas.test.ts::"MatchSchema discriminates over pattern types"` AND `packages/crypto/password-strength/src/schemas/schemas.test.ts::"MatchSchema rejects unknown pattern type"` --- ### Requirement: OTel observability The package SHALL emit the following instruments via `@opentelemetry/api`: `pwstrength.assess_duration_ms{score}` histogram (recorded once per `assess` call), `pwstrength.score_distribution_total{score}` counter (incremented once per `assess` call), `pwstrength.errors_total{code}` counter (incremented once per thrown `PWSTRENGTH.*` code), `pwstrength.matchers_run_total{matcher}` counter (incremented once per match found, by matcher name). #### Scenario: assess emits duration histogram and score counter - **GIVEN** an in-memory `MeterProvider` registered as the global meter provider - **WHEN** `assess("Password1!")` runs - **THEN** the recorded data contains exactly one `pwstrength.assess_duration_ms` data point with `score` matching the result AND one `pwstrength.score_distribution_total` increment with the same `score` #### Scenario: throws emit errors_total counter - **WHEN** `assess("a".repeat(200))` throws - **THEN** the recorded data contains a `pwstrength.errors_total{code="PWSTRENGTH.INPUT_TOO_LONG"}` increment #### Scenario: matcher runs emit matchers_run_total counter - **WHEN** `assess("abcd1234")` runs - **THEN** the recorded data contains `pwstrength.matchers_run_total` increments with `matcher="sequence"` and `matcher="sequence"` (one per matched substring) **Verified by:** `packages/crypto/password-strength/src/observability/metrics.test.ts::"assess emits duration histogram and score counter"` AND `packages/crypto/password-strength/src/observability/metrics.test.ts::"throws emit errors_total counter"` AND `packages/crypto/password-strength/src/observability/metrics.test.ts::"matcher runs emit matchers_run_total counter"` --- ### Requirement: Property tests — matcher commutativity and score-bucket monotonicity The package SHALL include fast-check property suites pinning: (1) **matcher commutativity** — running matchers in any permutation produces identical `mostGuessableMatchSequence` output; (2) **score monotonicity** — `g1 ≤ g2 → score(g1) ≤ score(g2)`; (3) **`crackTimes` monotonicity** — every scenario value is monotonic in `guesses`; (4) **idempotence** — `assess(p)` called twice on the same input returns deeply-equal results; (5) **DoS-guard boundary** — `length ≤ 199` accepts, `length ≥ 200` rejects, with no exception leaks. #### Scenario: matcher commutativity - **WHEN** fast-check generates a password and runs the matcher set in randomised order - **THEN** the resulting `MatchSequence` (sorted) is byte-equal across permutations #### Scenario: score-bucket monotonicity - **WHEN** fast-check generates `(g1, g2)` with `g1 ≤ g2` - **THEN** `score(g1) ≤ score(g2)` holds #### Scenario: idempotence - **WHEN** fast-check generates a password ≤199 chars - **THEN** `assess(p)` called twice returns deeply-equal results (excluding `calcTimeMs`) #### Scenario: DoS-guard boundary holds across all inputs - **WHEN** fast-check generates strings of every length in `[0, 250]` - **THEN** `length < 200` always returns; `length ≥ 200` always throws `PWSTRENGTH.INPUT_TOO_LONG` **Verified by:** `packages/crypto/password-strength/src/properties.test.ts::"matcher commutativity"` AND `packages/crypto/password-strength/src/properties.test.ts::"score-bucket monotonicity"` AND `packages/crypto/password-strength/src/properties.test.ts::"crackTimes monotonicity"` AND `packages/crypto/password-strength/src/properties.test.ts::"assess idempotence"` AND `packages/crypto/password-strength/src/properties.test.ts::"DoS-guard boundary"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can tree-shake the surface they need: - `@suaveplan/password-strength` — root barrel - `@suaveplan/password-strength/assess` — `assess`, `assessReactive`, types - `@suaveplan/password-strength/feedback` — `feedback`, types - `@suaveplan/password-strength/scoring` — `mostGuessableMatchSequence`, `estimateGuesses`, `crackTimes`, `score` - `@suaveplan/password-strength/matchers/{dictionary,sequence,repeat,date,keyboard,regex}` - `@suaveplan/password-strength/dicts/{en,de,fr,es,zh-Hans,ru}` - `@suaveplan/password-strength/graphs/{qwerty,dvorak,azerty}` - `@suaveplan/password-strength/errors` - `@suaveplan/password-strength/schemas` #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/crypto/password-strength/src/index.test.ts::"every documented subpath export resolves"`