@cloudyventures/baseh 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/codec.d.ts CHANGED
@@ -19,9 +19,52 @@ export interface ValidateResult {
19
19
  canonicalCode?: string;
20
20
  reason?: BasehErrorCode;
21
21
  }
22
+ /**
23
+ * Spec 12.5. Live as-you-type feedback for a code entry field. `typing`
24
+ * carries the normalized typed symbols with separators inserted as far as
25
+ * the groups go, plus a progress fraction; `invalid` carries the
26
+ * BasehErrorCode from validate; `valid` is only ever reported for a
27
+ * complete code.
28
+ */
29
+ export type InspectResult = {
30
+ state: "empty";
31
+ } | {
32
+ state: "typing";
33
+ typed: string;
34
+ progress: number;
35
+ } | {
36
+ state: "bad-char";
37
+ } | {
38
+ state: "too-long";
39
+ } | {
40
+ state: "invalid";
41
+ reason: BasehErrorCode;
42
+ } | {
43
+ state: "valid";
44
+ id: bigint;
45
+ canonicalCode: string;
46
+ };
22
47
  /** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
23
48
  export declare function normalize(input: string, profile: PreparedProfile, acceptSpaces?: boolean): string;
24
49
  export declare function formatRaw(raw: string, profile: PreparedProfile): string;
50
+ /**
51
+ * Spec 19.5. Balanced grouping: the split is a pure function of the total
52
+ * length — `g = max(2, ceil(L / 5))` groups differing in size by at most
53
+ * one, larger groups to the left. There is no configurable pattern in
54
+ * expandable mode (`grouping` must be empty, section 2.2).
55
+ */
56
+ export declare function expandableGrouping(length: number): number[];
57
+ /**
58
+ * Spec 19.1/22.3. First id of generation L: the sum of each generation's
59
+ * capacity A^(k - effectiveK(k)) for k from minLength through L-1. The
60
+ * effective checksum length is per-generation (spec 22), so the sum is not
61
+ * a single geometric series when the short checksum is on.
62
+ */
63
+ export declare function generationBase(profile: PreparedProfile, length: number): bigint;
64
+ /** Spec 19.1/22.3. Ids held by generation L: A^(L - effectiveK(L)). */
65
+ export declare function generationCapacity(profile: PreparedProfile, length: number): bigint;
66
+ /** Smallest generation whose range holds id, per spec 19.6. */
67
+ export declare function generationForId(profile: PreparedProfile, id: bigint): number;
25
68
  /** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
26
69
  export declare function generateCandidates(body: string, confusionMap: Record<string, string[]>, maxEdits?: number): string[];
27
70
  export declare class Baseh {
@@ -29,10 +72,23 @@ export declare class Baseh {
29
72
  private readonly bodyIndex;
30
73
  constructor(profile: BasehProfile);
31
74
  capacity(): bigint;
32
- /** Spec 8. */
75
+ private permKey;
76
+ private checkBlocked;
77
+ /** Spec 8 (fixed mode). */
78
+ private encodeFixed;
79
+ /** Spec 19.6. */
80
+ private encodeExpandable;
81
+ /** Spec 8/19.6. */
33
82
  encode(id: bigint | number): string;
34
- /** Spec 9. */
83
+ /** Spec 9/19.7. */
35
84
  decode(input: string, options?: DecodeOptions): DecodeResult;
36
85
  /** Spec 12.4. Never throws on user input. */
37
86
  validate(input: string, options?: DecodeOptions): ValidateResult;
87
+ /**
88
+ * Spec 12.5. Live as-you-type inspection. Gates on the typed length before
89
+ * validating, so spec 3.4 re-padding can never paint an incomplete fixed-mode
90
+ * code `valid` (or `invalid`): a short fixed input is `typing`, never
91
+ * checked. Never throws on user input.
92
+ */
93
+ inspect(input: string): InspectResult;
38
94
  }
package/dist/codec.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { BasehError } from "./errors.js";
2
- import { decodeBaseN, encodeBaseN, alphabetIndex } from "./basen.js";
2
+ import { decodeBaseN, encodeBaseN, alphabetIndex, powBigInt } from "./basen.js";
3
3
  import { calculateChecksum } from "./checksum.js";
4
4
  import { inversePermute, permute } from "./feistel.js";
5
- import { prepareProfile } from "./profile.js";
5
+ import { prepareProfile, effectiveChecksumLength } from "./profile.js";
6
6
  /** Built-in spoken-confusion candidate maps. Spec 3.3; pairs apply to body symbols only. */
7
7
  export const CONFUSION_MAPS = {
8
8
  light: { B: ["D"], D: ["B"], P: ["T"], T: ["P"] },
@@ -13,10 +13,12 @@ export const CONFUSION_MAPS = {
13
13
  }
14
14
  };
15
15
  const ASCII_WS = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
16
+ const INSPECT_WS = /[\t\n\v\f\r ]/;
16
17
  const MAX_CANDIDATES = 64;
17
18
  /** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
18
19
  export function normalize(input, profile, acceptSpaces = false) {
19
20
  let s = input.replace(ASCII_WS, "");
21
+ const hadSeparator = profile.separator.length > 0 && s.includes(profile.separator);
20
22
  if (profile.separator.length > 0) {
21
23
  s = s.split(profile.separator).join("");
22
24
  }
@@ -26,13 +28,33 @@ export function normalize(input, profile, acceptSpaces = false) {
26
28
  if (!profile.caseSensitive) {
27
29
  s = s.toUpperCase();
28
30
  }
29
- s = [...s].map((ch) => profile.aliasesNorm[ch] ?? ch).join("");
30
31
  const allowed = new Set([...profile.bodyAlphabetNorm, ...profile.checksumAlphabetNorm]);
32
+ // Spec 3.2: an alias never maps two distinct canonical symbols into one
33
+ // value, so a symbol that is already canonical stays as-is and only
34
+ // non-canonical symbols are aliased. (In fixed tiers alias sources are
35
+ // never canonical, so this changes nothing there.)
36
+ s = [...s].map((ch) => (allowed.has(ch) ? ch : (profile.aliasesNorm[ch] ?? ch))).join("");
31
37
  for (const ch of s) {
32
38
  if (!allowed.has(ch)) {
33
39
  throw new BasehError("INVALID_CHARACTER", `Symbol ${JSON.stringify(ch)} is not accepted`);
34
40
  }
35
41
  }
42
+ if (profile.mode === "expandable") {
43
+ // Spec 19.2/19.7: no left-padding and no stripped-zero leniency. Input
44
+ // shorter than minLength or longer than 32 fails INVALID_LENGTH, and a
45
+ // separator below separatorMinLength is rejected (spec 19.5: the decoder
46
+ // expects no separators there).
47
+ if (s.length < profile.minLength) {
48
+ throw new BasehError("INVALID_LENGTH", `Expected at least ${profile.minLength} symbols, got ${s.length}`);
49
+ }
50
+ if (s.length > 32) {
51
+ throw new BasehError("INVALID_LENGTH", `Expected at most 32 symbols, got ${s.length}`);
52
+ }
53
+ if (hadSeparator && s.length < profile.separatorMinLength) {
54
+ throw new BasehError("INVALID_CHARACTER", `Separators do not appear below ${profile.separatorMinLength} symbols`);
55
+ }
56
+ return s;
57
+ }
36
58
  const expected = profile.bodyLength + profile.checksumLength;
37
59
  // Spec 3.4: a code that lost leading zero body symbols is re-padded with
38
60
  // the body zero symbol. The checksum symbols always remain, so the split
@@ -47,16 +69,76 @@ export function normalize(input, profile, acceptSpaces = false) {
47
69
  }
48
70
  return s;
49
71
  }
50
- export function formatRaw(raw, profile) {
51
- if (profile.separator.length === 0)
72
+ function formatWith(raw, sizes, separator) {
73
+ if (separator.length === 0)
52
74
  return raw;
53
75
  const parts = [];
54
76
  let o = 0;
55
- for (const size of profile.grouping) {
77
+ for (const size of sizes) {
56
78
  parts.push(raw.slice(o, o + size));
57
79
  o += size;
58
80
  }
59
- return parts.join(profile.separator);
81
+ return parts.join(separator);
82
+ }
83
+ export function formatRaw(raw, profile) {
84
+ if (profile.mode === "expandable") {
85
+ if (raw.length < profile.separatorMinLength)
86
+ return raw;
87
+ return formatWith(raw, expandableGrouping(raw.length), profile.separator);
88
+ }
89
+ return formatWith(raw, profile.grouping, profile.separator);
90
+ }
91
+ /**
92
+ * Spec 19.5. Balanced grouping: the split is a pure function of the total
93
+ * length — `g = max(2, ceil(L / 5))` groups differing in size by at most
94
+ * one, larger groups to the left. There is no configurable pattern in
95
+ * expandable mode (`grouping` must be empty, section 2.2).
96
+ */
97
+ export function expandableGrouping(length) {
98
+ const g = Math.max(2, Math.ceil(length / 5));
99
+ const base = Math.floor(length / g);
100
+ if (base < 1)
101
+ return [length];
102
+ const rem = length % g;
103
+ return [
104
+ ...Array(rem).fill(base + 1),
105
+ ...Array(g - rem).fill(base)
106
+ ];
107
+ }
108
+ /**
109
+ * Spec 19.1/22.3. First id of generation L: the sum of each generation's
110
+ * capacity A^(k - effectiveK(k)) for k from minLength through L-1. The
111
+ * effective checksum length is per-generation (spec 22), so the sum is not
112
+ * a single geometric series when the short checksum is on.
113
+ */
114
+ export function generationBase(profile, length) {
115
+ let base = 0n;
116
+ for (let l = profile.minLength; l < length; l += 1) {
117
+ base += generationCapacity(profile, l);
118
+ }
119
+ return base;
120
+ }
121
+ /** Spec 19.1/22.3. Ids held by generation L: A^(L - effectiveK(L)). */
122
+ export function generationCapacity(profile, length) {
123
+ return powBigInt(BigInt(profile.bodyAlphabetNorm.length), length - effectiveChecksumLength(profile, length));
124
+ }
125
+ /** Smallest generation whose range holds id, per spec 19.6. */
126
+ export function generationForId(profile, id) {
127
+ let l = profile.minLength;
128
+ let base = 0n;
129
+ let cap = generationCapacity(profile, l);
130
+ while (id >= base + cap) {
131
+ // Codes cap at 32 symbols, so generation 33 is the hard ceiling.
132
+ // Throwing from inside keeps an adversarial huge id from spinning the
133
+ // loop (and its bigint multiplications) without bound.
134
+ if (l >= 32) {
135
+ throw new BasehError("OUT_OF_RANGE", "ID requires a code longer than 32 symbols");
136
+ }
137
+ base += cap;
138
+ l += 1;
139
+ cap = generationCapacity(profile, l);
140
+ }
141
+ return l;
60
142
  }
61
143
  /** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
62
144
  export function generateCandidates(body, confusionMap, maxEdits = 1) {
@@ -85,25 +167,20 @@ export class Baseh {
85
167
  this.bodyIndex = alphabetIndex(this.profile.bodyAlphabetNorm);
86
168
  }
87
169
  capacity() {
170
+ // Spec 12.3: fixed mode only. Expandable profiles have no single
171
+ // capacity; use the per-generation formulas of spec 19.1.
172
+ if (this.profile.mode !== "fixed") {
173
+ throw new BasehError("INVALID_PROFILE", "capacity() is only defined for fixed-mode profiles", false);
174
+ }
88
175
  return this.profile.capacity;
89
176
  }
90
- /** Spec 8. */
91
- encode(id) {
92
- let value = BigInt(id);
93
- if (value < 0n || value >= this.profile.capacity) {
94
- throw new BasehError("OUT_OF_RANGE", `ID ${value} is outside the profile capacity`);
95
- }
177
+ permKey(length) {
96
178
  const perm = this.profile.permutation;
97
- if (perm.enabled) {
98
- value = permute(value, this.profile.capacity, {
99
- profileId: this.profile.profileId,
100
- keyBytes: perm.keyBytes,
101
- rounds: perm.rounds
102
- });
103
- }
104
- const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, this.profile.bodyLength);
105
- const checksum = calculateChecksum(this.profile, body);
106
- const raw = body + checksum;
179
+ if (!perm.enabled)
180
+ throw new BasehError("INVALID_PROFILE", "permutation is disabled", false);
181
+ return { profileId: this.profile.profileId, keyBytes: perm.keyBytes, rounds: perm.rounds, ...(length === undefined ? {} : { length }) };
182
+ }
183
+ checkBlocked(raw) {
107
184
  // Spec 18.2: case-insensitive substring scan over the raw code.
108
185
  if (this.profile.blocklist.length > 0) {
109
186
  const upper = raw.toUpperCase();
@@ -113,18 +190,76 @@ export class Baseh {
113
190
  }
114
191
  }
115
192
  }
193
+ // Spec 21.2: a run of the same symbol at or above maxRepetition blocks
194
+ // the code. Runs are measured on the raw string, so a separator never
195
+ // breaks a run.
196
+ const max = this.profile.maxRepetition;
197
+ if (max > 0 && new RegExp(`(.)\\1{${max - 1},}`).test(raw)) {
198
+ throw new BasehError("BLOCKED_CODE", "The generated reference repeats a symbol beyond the profile limit", false);
199
+ }
200
+ }
201
+ /** Spec 8 (fixed mode). */
202
+ encodeFixed(id) {
203
+ let value = id;
204
+ if (value < 0n || value >= this.profile.capacity) {
205
+ throw new BasehError("OUT_OF_RANGE", `ID ${value} is outside the profile capacity`);
206
+ }
207
+ const perm = this.profile.permutation;
208
+ if (perm.enabled) {
209
+ value = permute(value, this.profile.capacity, this.permKey());
210
+ }
211
+ const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, this.profile.bodyLength);
212
+ const raw = body + calculateChecksum(this.profile, body, this.profile.checksumLength, this.bodyIndex);
213
+ this.checkBlocked(raw);
116
214
  return formatRaw(raw, this.profile);
117
215
  }
118
- /** Spec 9. */
216
+ /** Spec 19.6. */
217
+ encodeExpandable(id) {
218
+ if (id < 0n) {
219
+ throw new BasehError("OUT_OF_RANGE", `ID ${id} is negative`);
220
+ }
221
+ // generationForId throws OUT_OF_RANGE for ids beyond the 32-symbol ceiling.
222
+ const l = generationForId(this.profile, id);
223
+ let value = id - generationBase(this.profile, l);
224
+ const domain = generationCapacity(this.profile, l);
225
+ const perm = this.profile.permutation;
226
+ if (perm.enabled) {
227
+ value = permute(value, domain, this.permKey(l));
228
+ }
229
+ const k = effectiveChecksumLength(this.profile, l);
230
+ const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, l - k);
231
+ const raw = body + calculateChecksum(this.profile, body, k, this.bodyIndex);
232
+ this.checkBlocked(raw);
233
+ return formatRaw(raw, this.profile);
234
+ }
235
+ /** Spec 8/19.6. */
236
+ encode(id) {
237
+ const value = BigInt(id);
238
+ return this.profile.mode === "expandable" ? this.encodeExpandable(value) : this.encodeFixed(value);
239
+ }
240
+ /** Spec 9/19.7. */
119
241
  decode(input, options = {}) {
242
+ // Spec API is maxCorrections?: 0 | 1; anything else is a caller bug and
243
+ // is rejected at the boundary instead of silently coerced.
244
+ if (options.maxCorrections !== undefined && options.maxCorrections !== 0 && options.maxCorrections !== 1) {
245
+ throw new BasehError("INVALID_PROFILE", "maxCorrections must be 0 or 1", false);
246
+ }
120
247
  const raw = normalize(input, this.profile, options.acceptSpaces === true);
121
- let body = raw.slice(0, this.profile.bodyLength);
122
- const suppliedChecksum = raw.slice(this.profile.bodyLength);
248
+ // Spec 22: the generation is selected by the presented total length, so
249
+ // the effective checksum length is a deterministic function of it.
250
+ const effectiveK = this.profile.mode === "expandable"
251
+ ? effectiveChecksumLength(this.profile, raw.length)
252
+ : this.profile.checksumLength;
253
+ const bodyLength = this.profile.mode === "expandable"
254
+ ? raw.length - effectiveK
255
+ : this.profile.bodyLength;
256
+ let body = raw.slice(0, bodyLength);
257
+ const suppliedChecksum = raw.slice(bodyLength);
123
258
  // Spec 3.1 validates union membership before the split. There is no
124
259
  // per-region membership check: a checksum-region symbol outside the
125
260
  // checksum alphabet simply fails as INVALID_CHECKSUM, and a body symbol
126
261
  // outside the body alphabet fails later in decodeBaseN as INVALID_CHARACTER.
127
- if (calculateChecksum(this.profile, body) !== suppliedChecksum) {
262
+ if (calculateChecksum(this.profile, body, effectiveK, this.bodyIndex) !== suppliedChecksum) {
128
263
  if (!options.tryCorrection || (options.maxCorrections ?? 1) === 0) {
129
264
  throw new BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
130
265
  }
@@ -145,7 +280,7 @@ export class Baseh {
145
280
  }
146
281
  const valid = new Set();
147
282
  for (const candidate of generateCandidates(body, map, options.maxCorrections ?? 1)) {
148
- if (calculateChecksum(this.profile, candidate) === suppliedChecksum) {
283
+ if (calculateChecksum(this.profile, candidate, effectiveK, this.bodyIndex) === suppliedChecksum) {
149
284
  valid.add(candidate);
150
285
  }
151
286
  }
@@ -159,12 +294,17 @@ export class Baseh {
159
294
  }
160
295
  let value = decodeBaseN(body, this.profile.bodyAlphabetNorm, this.bodyIndex);
161
296
  const perm = this.profile.permutation;
162
- if (perm.enabled) {
163
- value = inversePermute(value, this.profile.capacity, {
164
- profileId: this.profile.profileId,
165
- keyBytes: perm.keyBytes,
166
- rounds: perm.rounds
167
- });
297
+ if (this.profile.mode === "expandable") {
298
+ // Spec 19.7: the offset is de-permuted within the generation's own
299
+ // domain, then the generation base is added back.
300
+ const l = raw.length;
301
+ if (perm.enabled) {
302
+ value = inversePermute(value, generationCapacity(this.profile, l), this.permKey(l));
303
+ }
304
+ value = generationBase(this.profile, l) + value;
305
+ }
306
+ else if (perm.enabled) {
307
+ value = inversePermute(value, this.profile.capacity, this.permKey());
168
308
  }
169
309
  const canonicalCode = this.encode(value);
170
310
  const canonicalRaw = canonicalCode.split(this.profile.separator).join("");
@@ -182,4 +322,76 @@ export class Baseh {
182
322
  throw err;
183
323
  }
184
324
  }
325
+ /**
326
+ * Spec 12.5. Live as-you-type inspection. Gates on the typed length before
327
+ * validating, so spec 3.4 re-padding can never paint an incomplete fixed-mode
328
+ * code `valid` (or `invalid`): a short fixed input is `typing`, never
329
+ * checked. Never throws on user input.
330
+ */
331
+ inspect(input) {
332
+ const p = this.profile;
333
+ // Remove every occurrence of the separator string, then drop ASCII
334
+ // whitespace anywhere (a paste can carry either inside the code).
335
+ const noSep = p.separator.length > 0 ? input.split(p.separator).join("") : input;
336
+ const cleaned = [...noSep].filter((ch) => !INSPECT_WS.test(ch));
337
+ const typedCount = cleaned.length;
338
+ if (typedCount === 0)
339
+ return { state: "empty" };
340
+ const fixed = p.mode === "fixed";
341
+ const expected = fixed ? p.bodyLength + p.checksumLength : 32;
342
+ if (typedCount > expected)
343
+ return { state: "too-long" };
344
+ // Spec 3.1 steps 4-6, without the length checks: case normalization,
345
+ // aliases, then union membership. A symbol outside both alphabets is
346
+ // bad-char; a symbol valid only in the other region (say a checksum-only
347
+ // symbol typed into the body) passes here and fails later under validate.
348
+ const allowed = new Set([...p.bodyAlphabetNorm, ...p.checksumAlphabetNorm]);
349
+ let s = cleaned.join("");
350
+ if (!p.caseSensitive)
351
+ s = s.toUpperCase();
352
+ const raw = [...s].map((ch) => (allowed.has(ch) ? ch : (p.aliasesNorm[ch] ?? ch))).join("");
353
+ if ([...raw].some((ch) => !allowed.has(ch)))
354
+ return { state: "bad-char" };
355
+ // Fixed mode: complete means exactly bodyLength + checksumLength symbols.
356
+ // Expandable mode: every length from minLength through 32 is a complete
357
+ // code (the length selects the generation), so typing is only below
358
+ // minLength.
359
+ const complete = fixed ? typedCount === expected : typedCount >= p.minLength;
360
+ if (!complete) {
361
+ return {
362
+ state: "typing",
363
+ typed: formatPartial(raw, p),
364
+ progress: typedCount / (fixed ? expected : p.minLength)
365
+ };
366
+ }
367
+ const result = this.validate(raw);
368
+ if (!result.valid)
369
+ return { state: "invalid", reason: result.reason ?? "INVALID_CHECKSUM" };
370
+ const decoded = this.decode(raw);
371
+ return { state: "valid", id: decoded.id, canonicalCode: decoded.canonicalCode };
372
+ }
373
+ }
374
+ /**
375
+ * Spec 12.5. Separators inserted into a partially typed code, as far as the
376
+ * groups go. Fixed mode walks the configured grouping; expandable mode uses
377
+ * the balanced grouping rule of spec 19.5 for the typed length, bare below
378
+ * separatorMinLength.
379
+ */
380
+ function formatPartial(raw, profile) {
381
+ if (profile.separator.length === 0)
382
+ return raw;
383
+ if (profile.mode === "expandable") {
384
+ if (raw.length < profile.separatorMinLength)
385
+ return raw;
386
+ return formatWith(raw, expandableGrouping(raw.length), profile.separator);
387
+ }
388
+ const parts = [];
389
+ let offset = 0;
390
+ for (const size of profile.grouping) {
391
+ if (offset >= raw.length)
392
+ break;
393
+ parts.push(raw.slice(offset, offset + size));
394
+ offset += size;
395
+ }
396
+ return parts.join(profile.separator);
185
397
  }
@@ -0,0 +1,9 @@
1
+ import { type DecodeOptions, type DecodeResult, type InspectResult, type ValidateResult } from "./codec.js";
2
+ /** Encode an id with the default expandable v1 profile. */
3
+ export declare function encode(id: bigint | number): string;
4
+ /** Decode a code with the default expandable v1 profile. Throws BasehError like the instance API. */
5
+ export declare function decode(input: string, options?: DecodeOptions): DecodeResult;
6
+ /** Validate a code with the default expandable v1 profile. Never throws on user input. */
7
+ export declare function validate(input: string, options?: DecodeOptions): ValidateResult;
8
+ /** Live as-you-type inspection with the default expandable v1 profile. Never throws on user input. */
9
+ export declare function inspect(input: string): InspectResult;
package/dist/facade.js ADDED
@@ -0,0 +1,30 @@
1
+ import { Baseh } from "./codec.js";
2
+ import { basehExpandableV1 } from "./profiles.js";
3
+ /**
4
+ * Zero-config facade over the shipped expandable v1 default profile. Most
5
+ * callers never need to touch a profile object: `encode(id)` and
6
+ * `decode(code)` here behave exactly like the same methods on a
7
+ * `new Baseh(basehExpandableV1())` instance, sharing one lazily constructed
8
+ * instance for the process.
9
+ */
10
+ let shared;
11
+ function sharedInstance() {
12
+ shared ??= new Baseh(basehExpandableV1());
13
+ return shared;
14
+ }
15
+ /** Encode an id with the default expandable v1 profile. */
16
+ export function encode(id) {
17
+ return sharedInstance().encode(id);
18
+ }
19
+ /** Decode a code with the default expandable v1 profile. Throws BasehError like the instance API. */
20
+ export function decode(input, options = {}) {
21
+ return sharedInstance().decode(input, options);
22
+ }
23
+ /** Validate a code with the default expandable v1 profile. Never throws on user input. */
24
+ export function validate(input, options = {}) {
25
+ return sharedInstance().validate(input, options);
26
+ }
27
+ /** Live as-you-type inspection with the default expandable v1 profile. Never throws on user input. */
28
+ export function inspect(input) {
29
+ return sharedInstance().inspect(input);
30
+ }
package/dist/feistel.d.ts CHANGED
@@ -2,6 +2,12 @@ interface FeistelKey {
2
2
  profileId: string;
3
3
  keyBytes: Uint8Array;
4
4
  rounds: number;
5
+ /**
6
+ * Expandable mode only (spec 7.3/19.4): the total code length L of the
7
+ * generation, mixed into the round message. Absent in fixed mode, where
8
+ * the message stays byte-for-byte unchanged.
9
+ */
10
+ length?: number;
5
11
  }
6
12
  /** Spec 7.3 forward permutation with cycle walking. */
7
13
  export declare function permute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
package/dist/feistel.js CHANGED
@@ -24,10 +24,11 @@ function toBe(value, byteCount) {
24
24
  }
25
25
  return out;
26
26
  }
27
- function roundMessage(profileId, round, right, wr) {
27
+ function roundMessage(profileId, round, right, wr, length) {
28
28
  const pidBytes = new TextEncoder().encode(profileId);
29
+ const lenBytes = length === undefined ? new Uint8Array(0) : new TextEncoder().encode(String(length));
29
30
  const rightBytes = toBe(right, Math.ceil(wr / 8));
30
- const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + 1 + rightBytes.length);
31
+ const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + lenBytes.length + (length === undefined ? 0 : 1) + 1 + rightBytes.length);
31
32
  let o = 0;
32
33
  msg.set(TAG, o);
33
34
  o += TAG.length;
@@ -37,6 +38,12 @@ function roundMessage(profileId, round, right, wr) {
37
38
  o += pidBytes.length;
38
39
  msg[o] = 0;
39
40
  o += 1;
41
+ if (length !== undefined) {
42
+ msg.set(lenBytes, o);
43
+ o += lenBytes.length;
44
+ msg[o] = 0;
45
+ o += 1;
46
+ }
40
47
  msg[o] = round;
41
48
  o += 1;
42
49
  msg.set(rightBytes, o);
@@ -48,7 +55,7 @@ function runRounds(h, key, w0, w1) {
48
55
  const even = i % 2 === 0;
49
56
  const wr = even ? w1 : w0;
50
57
  const wl = even ? w0 : w1;
51
- const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr));
58
+ const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr, key.length));
52
59
  const f = lowBits(digest, wl);
53
60
  const newLeft = right;
54
61
  const newRight = left ^ f;
@@ -63,7 +70,7 @@ function runInverse(h, key, w0, w1) {
63
70
  const even = i % 2 === 0;
64
71
  const wr = even ? w1 : w0;
65
72
  const wl = even ? w0 : w1;
66
- const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr));
73
+ const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr, key.length));
67
74
  const f = lowBits(digest, wl);
68
75
  const prevRight = left;
69
76
  const prevLeft = right ^ f;
package/dist/index.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  export { BasehError } from "./errors.js";
2
2
  export type { BasehErrorCode } from "./errors.js";
3
3
  export type { BasehProfile, BasehPermutation, PreparedProfile } from "./profile.js";
4
- export { prepareProfile } from "./profile.js";
4
+ export { prepareProfile, effectiveChecksumLength } from "./profile.js";
5
5
  export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
6
- export type { DecodeOptions, DecodeResult, ValidateResult, ConfusionProfileName } from "./codec.js";
6
+ export type { DecodeOptions, DecodeResult, ValidateResult, InspectResult, ConfusionProfileName } from "./codec.js";
7
7
  export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
8
8
  export { calculateChecksum, checksumValue } from "./checksum.js";
9
9
  export { permute, inversePermute } from "./feistel.js";
10
- export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, type FrozenKeyOptions } from "./profiles.js";
11
- export { toCode, fromCode } from "./zero.js";
10
+ export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, basehExpandableV1, basehExpandablePV1, FROZEN_KEY_BYTES, type FrozenKeyOptions } from "./profiles.js";
11
+ export { generationBase, generationCapacity, generationForId, expandableGrouping } from "./codec.js";
12
+ export { encode, decode, validate, inspect } from "./facade.js";
12
13
  export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
13
14
  export type { BasehProfanity, BasehProfanityMode } from "./blocklist.js";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export { BasehError } from "./errors.js";
2
- export { prepareProfile } from "./profile.js";
2
+ export { prepareProfile, effectiveChecksumLength } from "./profile.js";
3
3
  export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
4
4
  export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
5
5
  export { calculateChecksum, checksumValue } from "./checksum.js";
6
6
  export { permute, inversePermute } from "./feistel.js";
7
- export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1 } from "./profiles.js";
8
- export { toCode, fromCode } from "./zero.js";
7
+ export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, basehExpandableV1, basehExpandablePV1, FROZEN_KEY_BYTES } from "./profiles.js";
8
+ export { generationBase, generationCapacity, generationForId, expandableGrouping } from "./codec.js";
9
+ export { encode, decode, validate, inspect } from "./facade.js";
9
10
  export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
package/dist/profile.d.ts CHANGED
@@ -10,28 +10,79 @@ export type BasehPermutation = {
10
10
  };
11
11
  export interface BasehProfile {
12
12
  profileId: string;
13
+ /**
14
+ * Spec 2.1/19.9. "fixed" keeps the classic constant-width behaviour;
15
+ * "expandable" gives variable-length codes driven by id magnitude
16
+ * (spec 19). Profiles that predate the mode field are fixed: the shared
17
+ * frozen vectors pin their byte-for-byte behaviour, so a missing mode is
18
+ * prepared as "fixed".
19
+ */
20
+ mode?: "fixed" | "expandable";
13
21
  bodyAlphabet: string;
14
- bodyLength: number;
22
+ /** Fixed mode only; ignored in expandable mode. */
23
+ bodyLength?: number;
24
+ /** Expandable mode only; default 4. Must exceed checksumLength. */
25
+ minLength?: number;
15
26
  checksumAlphabet: string;
16
27
  checksumLength: number;
28
+ /**
29
+ * Spec 22. Expandable mode only. The checksum width used by generations at
30
+ * or below `shortChecksumUntil`; may be 0 (a zero-checksum window: those
31
+ * generations carry no checksum symbols and no typo detection). Without a
32
+ * window (`shortChecksumUntil` absent or 0) this must be absent or 0.
33
+ */
34
+ shortChecksumLength?: number;
35
+ /**
36
+ * Spec 22. The last generation (total length) that uses the short
37
+ * checksum; 0 or absent turns the feature off (the codebase convention,
38
+ * like maxRepetition). When set it must be an integer from `minLength`
39
+ * through 8.
40
+ */
41
+ shortChecksumUntil?: number;
17
42
  caseSensitive: boolean;
18
43
  separator: string;
44
+ /** Expandable mode only; default 0 (separator always applies). */
45
+ separatorMinLength?: number;
19
46
  grouping: number[];
20
47
  aliases: Record<string, string>;
21
48
  permutation: BasehPermutation;
22
49
  /** Spec 18. Defaults to mode "none". */
23
50
  profanity?: BasehProfanity;
51
+ /**
52
+ * Spec 21. Maximum allowed run of the same symbol in a raw code. 0 (the
53
+ * default) disables the filter; otherwise it must be an integer of at
54
+ * least 3. A value above the code length is a legal no-op.
55
+ */
56
+ maxRepetition?: number;
24
57
  }
25
58
  /** Case-prepared derived data, computed once at construction. */
26
59
  export interface PreparedProfile extends BasehProfile {
60
+ readonly mode: "fixed" | "expandable";
61
+ readonly minLength: number;
62
+ readonly separatorMinLength: number;
27
63
  readonly bodyAlphabetNorm: string;
28
64
  readonly checksumAlphabetNorm: string;
29
65
  readonly aliasesNorm: Record<string, string>;
30
66
  readonly checksumModulus: bigint;
67
+ /** Fixed-mode capacity A^bodyLength. Meaningless in expandable mode. */
31
68
  readonly capacity: bigint;
32
69
  /** Spec 18. Empty unless the profile uses mode "blocklist". */
33
70
  readonly blocklist: string[];
71
+ /** Spec 21. 0 disables the repetition filter. */
72
+ readonly maxRepetition: number;
73
+ /** Spec 22. 0 disables the short checksum. */
74
+ readonly shortChecksumLength: number;
75
+ /** Spec 22. Last short-checksum generation; 0 when the feature is off. */
76
+ readonly shortChecksumUntil: number;
34
77
  }
78
+ /**
79
+ * Spec 22. The checksum length that applies to a generation of the given
80
+ * total length: `shortChecksumLength` at or below `shortChecksumUntil`,
81
+ * `checksumLength` above it (and always in fixed mode). The feature is on
82
+ * exactly when `shortChecksumUntil` is non-zero; a `shortChecksumLength` of
83
+ * 0 then means the window's generations carry no checksum symbols at all.
84
+ */
85
+ export declare function effectiveChecksumLength(profile: PreparedProfile, length: number): number;
35
86
  /**
36
87
  * Validates a profile per spec section 2.2 and returns it with derived,
37
88
  * pre-computed values. Throws BasehError INVALID_PROFILE on any violation.