34 KiB
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. (Seeopenspec/AGENTS.mdSection 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/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a plain dictionary token" AND packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a l33t-substituted token" AND packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a reversed token" AND packages/vault/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/vault/password-strength/src/matchers/sequence/sequence.test.ts::"matches ascending alphabetical run" AND packages/vault/password-strength/src/matchers/sequence/sequence.test.ts::"matches descending numeric run" AND packages/vault/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
baseGuessesequalsmostGuessableMatchSequence("password", […]).guesses
Verified by: packages/vault/password-strength/src/matchers/repeat/repeat.test.ts::"matches single-character repeat" AND packages/vault/password-strength/src/matchers/repeat/repeat.test.ts::"matches multi-character repeat" AND packages/vault/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 withreferenceYear=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 withreferenceYear=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 withreferenceYear=2026 - THEN the result does NOT contain a date match
Scenario: prefers year closest to reference
- WHEN
dateMatch("010199")runs withreferenceYear=2026(parseable as 1999 or 2099) - THEN the resulting match has
year=2099(closest toreferenceYear + 50window)
Verified by: packages/vault/password-strength/src/matchers/date/date.test.ts::"matches separator date" AND packages/vault/password-strength/src/matchers/date/date.test.ts::"matches no-separator date" AND packages/vault/password-strength/src/matchers/date/date.test.ts::"rejects out-of-range year" AND packages/vault/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
turnsis ≥ 1
Scenario: counts shifted keys
- WHEN
keyboardMatch("QWERTY")runs - THEN the resulting match's
shiftedCountequals6
Scenario: rejects malformed graph
- WHEN
keyboardMatch("qwerty", { graphs: { broken: { q: ["q"] } } })runs - THEN the call throws
PasswordStrengthErrorwith code"PWSTRENGTH.GRAPH_INVALID"
Verified by: packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"matches QWERTY straight-line walk" AND packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts turns on direction change" AND packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts shifted keys" AND packages/vault/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<string, RegExp> }) 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/vault/password-strength/src/matchers/regex/regex.test.ts::"matches recent year" AND packages/vault/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
sequencecontains exactly that match andguessesequalsmatch.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
guessesequals1000 × 2! = 2000
Verified by: packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"empty input returns 1 guess" AND packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"single-match cover" AND packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"prefers fewer-match cover on tie" AND packages/vault/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/vault/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary closed form" AND packages/vault/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary reverse and l33t multipliers" AND packages/vault/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?.<scenario> ?? 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)ANDoffline_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_houris half the unadjusted value
Scenario: rejects non-positive adjustment
- WHEN
crackTimes(1e10, { adjustments: { online_throttled: 0 } })runs - THEN the call throws
PasswordStrengthErrorwith code"PWSTRENGTH.INVALID_OPTIONS"
Verified by: packages/vault/password-strength/src/scoring/crack-times.test.ts::"four scenarios at boundary guesses" AND packages/vault/password-strength/src/scoring/crack-times.test.ts::"applies adjustments multiplier" AND packages/vault/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)andscore(1000)are called - THEN the results are
0and1
Scenario: bucket boundary 1e6
- WHEN
score(999999)andscore(1000000)are called - THEN the results are
1and2
Scenario: bucket boundary 1e8
- WHEN
score(99999999)andscore(100000000)are called - THEN the results are
2and3
Scenario: bucket boundary 1e10
- WHEN
score(9999999999)andscore(10000000000)are called - THEN the results are
3and4
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/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e3" AND packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e6" AND packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e8" AND packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e10" AND packages/vault/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
StrengthResultSchemaandscoreis≤ 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
PasswordStrengthErrorwith code"PWSTRENGTH.INPUT_TOO_LONG"
Scenario: userInputs surface as ranked matches
- WHEN
assess("hello-acme-corp", { userInputs: ["acme corp"] })runs - THEN the resulting
sequencecontains a match withdictionaryName="user_inputs"
Scenario: custom dictionaries take precedence
- WHEN
assess("trustno1", { dictionaries: loadDictionaries({ passwords: new Map([["trustno1", 1]]) }) })runs - THEN the resulting
scoreis0
Verified by: packages/vault/password-strength/src/assess/assess.test.ts::"full pipeline returns canonical shape" AND packages/vault/password-strength/src/assess/assess.test.ts::"accepts 199-character input" AND packages/vault/password-strength/src/assess/assess.test.ts::"rejects 200-character input" AND packages/vault/password-strength/src/assess/assess.test.ts::"userInputs surface as ranked matches" AND packages/vault/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.resolverreturns"RESOLVED"for the corresponding key - WHEN
feedback(result, opts)runs - THEN the resulting
warningequals"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
warningis the built-in English string for that key
Scenario: resolver throw surfaces RESOLVER_FAILED
- WHEN
opts.resolverthrows anError("boom") - THEN
feedback(result, opts)throwsPasswordStrengthErrorwith code"PWSTRENGTH.RESOLVER_FAILED"andcause.message === "boom"
Verified by: packages/vault/password-strength/src/feedback/feedback.test.ts::"dispatches to resolver" AND packages/vault/password-strength/src/feedback/feedback.test.ts::"falls back to English when resolver undefined" AND packages/vault/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
PasswordStrengthErrorwith code"PWSTRENGTH.DICTIONARY_INVALID"
Verified by: packages/vault/password-strength/src/dicts/load-dictionaries.test.ts::"returns a fresh DictionarySet" AND packages/vault/password-strength/src/dicts/load-dictionaries.test.ts::"does not mutate bundled defaults" AND packages/vault/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.sequencecontains a match withdictionaryName="user_inputs"andmatchedWord="alice@example.com"
Scenario: feedback surfaces personalInfo key
- WHEN the longest-cover match in
result.sequencehasdictionaryName="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/vault/password-strength/src/assess/assess.test.ts::"userInputs become a ranked dictionary" AND packages/vault/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/<locale>. Each subpath SHALL export a DictionarySet-shaped value usable directly with assess(pwd, { dictionaries: <imported> }).
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 founderrors AND every imported value satisfiesDictionarySetSchema
Scenario: locale dictionary plugs into assess
- WHEN
assess("passwort", { dictionaries: deDictionaries })runs (wherepasswortis rank 1 in thedebundle) - THEN
result.scoreis0
Verified by: packages/vault/password-strength/src/dicts/dicts.test.ts::"every locale subpath resolves" AND packages/vault/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_hourequals 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_secondis affected; the other three scenarios match the unadjusted values
Verified by: packages/vault/password-strength/src/scoring/crack-times.test.ts::"halves online_throttled time" AND packages/vault/password-strength/src/scoring/crack-times.test.ts::"adjusts each scenario independently"
Requirement: assessReactive streaming wrapper
The package SHALL export assessReactive(input$: AsyncIterable<string>, opts?: AssessReactiveOptions): AsyncIterable<StrengthResult> 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",assessfor"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.signalis aborted mid-stream - WHEN the consumer awaits the next iteration
- THEN the iterable completes (
done: true) with no thrown error
Verified by: packages/vault/password-strength/src/assess/assess-reactive.test.ts::"debounces rapid inputs" AND packages/vault/password-strength/src/assess/assess-reactive.test.ts::"cancels in-flight on newer input" AND packages/vault/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
PasswordStrengthErrorwith 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
PasswordStrengthErrorwith code"PWSTRENGTH.INPUT_TOO_LONG"
Verified by: packages/vault/password-strength/src/assess/assess.test.ts::"accepts 199-character input" AND packages/vault/password-strength/src/assess/assess.test.ts::"rejects 200-character input" AND packages/vault/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/vault/password-strength/src/errors/error-codes.test.ts::"every code is thrown by a production path" AND packages/vault/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
PasswordStrengthErrorwith 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/vault/password-strength/src/schemas/schemas.test.ts::"rejects malformed AssessOptions" AND packages/vault/password-strength/src/schemas/schemas.test.ts::"MatchSchema discriminates over pattern types" AND packages/vault/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
MeterProviderregistered as the global meter provider - WHEN
assess("Password1!")runs - THEN the recorded data contains exactly one
pwstrength.assess_duration_msdata point withscorematching the result AND onepwstrength.score_distribution_totalincrement with the samescore
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_totalincrements withmatcher="sequence"andmatcher="sequence"(one per matched substring)
Verified by: packages/vault/password-strength/src/observability/metrics.test.ts::"assess emits duration histogram and score counter" AND packages/vault/password-strength/src/observability/metrics.test.ts::"throws emit errors_total counter" AND packages/vault/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)withg1 ≤ 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 (excludingcalcTimeMs)
Scenario: DoS-guard boundary holds across all inputs
- WHEN fast-check generates strings of every length in
[0, 250] - THEN
length < 200always returns;length ≥ 200always throwsPWSTRENGTH.INPUT_TOO_LONG
Verified by: packages/vault/password-strength/src/properties.test.ts::"matcher commutativity" AND packages/vault/password-strength/src/properties.test.ts::"score-bucket monotonicity" AND packages/vault/password-strength/src/properties.test.ts::"crackTimes monotonicity" AND packages/vault/password-strength/src/properties.test.ts::"assess idempotence" AND packages/vault/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 founderrors
Verified by: packages/vault/password-strength/src/index.test.ts::"every documented subpath export resolves"