@exortek/apikey 1.1.0 → 1.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @exortek/apikey
2
2
 
3
+ ## 1.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - b9e0647: Publish only the package-root README, CHANGELOG and LICENSE.
8
+
9
+ The `files` list matched those names at any depth rather than just the root, so a nested document was published
10
+ alongside them — `@exortek/oauth2` shipped its `examples/README.md`. The entries are now anchored to the package root.
11
+
12
+ - 0a94f13: Smaller bundles — the internal argument-guard helpers are now tree-shakeable.
13
+
14
+ Each package bundles the guard helpers it uses. They were previously built as one object holding all fourteen, which a
15
+ bundler cannot take apart, so every package shipped all of them regardless of how many it called. They are now
16
+ individually importable, and each package pulls in only what it uses.
17
+
18
+ No API change: the errors, codes and messages raised by argument validation are identical. Published bundles shrink by
19
+ roughly 7-18% depending on the package.
20
+
21
+ ## 1.1.1
22
+
23
+ ### Patch Changes
24
+
25
+ - e53ae64: Fix polynomial-ReDoS (quadratic regex backtracking) surfaced by CodeQL, plus small code-quality fixes.
26
+ Bundled `@exortek/shared` utilities (base32, base64, duration) are inlined into the packages listed above, so each
27
+ ships the fix.
28
+
29
+ - Replace `/=+$/` trailing-padding strips (base32 / base64 decode, OTP provisioning URI) with linear scans —
30
+ `"=".repeat(n) + "x"` was O(n²).
31
+ - Rework the duration parser regex so surrounding whitespace can no longer cause O(n²) matching (trim first, single
32
+ interior `\s*`).
33
+ - Strip trailing spaces/dots in filename sanitisation with a linear scan — upload filenames are attacker-controlled.
34
+ - Cap email length before the regex in magic-link.
35
+ - Thread the JWT decode label into error messages (removes dead arguments and gives failures which segment broke).
36
+ - Drop a duplicate character in a UA browser-detection regex character class.
37
+
38
+ DoS-hardening and hygiene only — no API or behavioural change for valid input.
39
+
3
40
  ## 1.1.0
4
41
 
5
42
  ### Minor Changes
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  > Stripe-style prefixed API keys for Node.js 22+ — 3-segment tokens (`sk_live_<id>_<secret>`), HMAC-SHA256 storage hash with optional pepper rotation, scope allowlists, memory + Redis stores, Express + Fastify middleware. Zero non-`@exortek/*` runtime dependencies.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@exortek/apikey.svg?color=cb3837)](https://www.npmjs.com/package/@exortek/apikey)
6
- [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
6
+ [![tests](https://github.com/ExorTek/auth/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
7
7
  [![node](https://img.shields.io/node/v/@exortek/apikey.svg?color=339933)](https://nodejs.org)
8
8
  [![install size](https://packagephobia.com/badge?p=@exortek/apikey)](https://packagephobia.com/result?p=@exortek/apikey)
9
9
  [![types](https://img.shields.io/badge/types-included-3178C6)](./dist/index.d.ts)
10
- [![license](https://img.shields.io/npm/l/@exortek/apikey.svg?color=blue)](./LICENSE)
10
+ [![license](https://img.shields.io/npm/l/@exortek/apikey.svg?color=blue)](https://github.com/ExorTek/auth/blob/master/LICENSE)
11
11
 
12
12
  📖 **Docs:** [**auth.memet.dev/apikey**](https://auth.memet.dev/apikey)
13
13
 
@@ -325,4 +325,4 @@ Expected verify failures do NOT throw — they surface as `{ valid: false, reaso
325
325
 
326
326
  ## License
327
327
 
328
- MIT © ExorTek — see [LICENSE](./LICENSE).
328
+ MIT © ExorTek — see [LICENSE](https://github.com/ExorTek/auth/blob/master/LICENSE).
package/dist/index.cjs CHANGED
@@ -65,7 +65,11 @@ const UNIT_MS = Object.freeze({
65
65
  years: 31_536_000_000,
66
66
  });
67
67
 
68
- const DURATION_RE = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i;
68
+ // Note: no leading/trailing `\s*` — the caller trims first. Two `\s*` runs
69
+ // around the optional unit (`\s*([a-z]+)?\s*`) made this O(n^2) on hostile
70
+ // input like `"1" + " ".repeat(n) + "!"`; trimming plus a single interior
71
+ // `\s*` keeps matching linear.
72
+ const DURATION_RE = /^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/i;
69
73
 
70
74
  const SUPPORTED_UNITS = 'ms, s, m, h, d, w, y (and long/plural forms)';
71
75
 
@@ -95,7 +99,7 @@ function parseDuration(input) {
95
99
  throw new TypeError(`parseDuration: expected string or number; got ${typeof input}`);
96
100
  }
97
101
 
98
- const match = DURATION_RE.exec(input);
102
+ const match = DURATION_RE.exec(input.trim());
99
103
  if (!match) {
100
104
  throw new Error(
101
105
  `parseDuration: could not parse ${JSON.stringify(input)}. Examples: 900 (ms), '15m', '2h', '7d', '500ms', '30s'.`,
@@ -347,20 +351,33 @@ class ApiKeyError extends BaseError {
347
351
  * `@exortek/shared/validate`: **schema** for whole options objects,
348
352
  * **asserts** for one-liner argument guards at the call site.
349
353
  *
350
- * The consumer surface is {@link defineGuards} (or the raw
351
- * {@link bindAsserts} for full control). Each package binds the assert
352
- * set to its own typed error class once (in `internal/guards.js`), so
353
- * every argument failure throws that package's class —
354
- * `err instanceof CryptoError` holds and users see at a glance which
355
- * package raised the error. The bound `parse` bridges
356
- * `@exortek/shared/validate` schemas to the same error class.
354
+ * Every guard is exported as a `make…` **factory** taking the binding
355
+ * package's `wrap` and returning the guard itself. A package binds the
356
+ * guards it calls once, in its `internal/guards.js`, so every argument
357
+ * failure throws that package's error class — `err instanceof
358
+ * CryptoError` holds, and users see at a glance which package raised it.
359
+ *
360
+ * // packages/<pkg>/src/internal/guards.js
361
+ * import { makeAssertString, makeInvalidArgument } from '@exortek/shared/asserts';
362
+ * import { CryptoError, ErrorCode } from '../errors.js';
363
+ *
364
+ * const wrap = (message, extra) => new CryptoError(ErrorCode.INVALID_ARGUMENT, message, extra);
365
+ *
366
+ * export const assertString = makeAssertString(wrap);
367
+ * export const invalidArgument = makeInvalidArgument(wrap);
368
+ *
369
+ * Importing each factory by name is what makes the module
370
+ * tree-shakeable. An earlier version handed back one object carrying
371
+ * every guard, so all of them shipped in every package's bundle no
372
+ * matter how few were used — `assertBoolean` reached fifteen published
373
+ * packages without a single call site. Import what you call.
357
374
  *
358
375
  * **Path naming convention** (used as the `name` argument):
359
376
  *
360
377
  * `<publicFunction>[.options|.config][.<field>]`
361
378
  *
362
- * - `assertString(name, 'createUser.name')` — top-level arg
363
- * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
379
+ * - `assertString(name, 'createUser.name')` — top-level arg
380
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
364
381
  * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
365
382
  *
366
383
  * Keep the path short: the emitted error is `"<name> must be <desc>"`,
@@ -369,301 +386,34 @@ class ApiKeyError extends BaseError {
369
386
  * encryptSymmetric()"), not for restating the field.
370
387
  */
371
388
 
372
- /**
373
- * @typedef {{ cause?: unknown }} WrapExtra
374
- * Extra options forwarded to the wrap function, so `invalidArgument`
375
- * sites that catch an underlying error can preserve the `cause` chain
376
- * without falling back to a raw `new PackageError(...)`.
377
- *
378
- * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
379
- * Constructs (never throws) the binding package's error, e.g.
380
- * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
381
- *
382
- * @typedef {{ hint?: string }} AssertOptions
383
- * `hint` is appended to the message after an em-dash — use it for
384
- * actionable guidance ("pass the exact bytes returned by …").
385
- *
386
- * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
387
- */
388
389
 
389
390
  /**
390
- * Build the failure message. `description` completes the sentence
391
- * `"<name> must be <description>"`; `hint` (optional) follows an
392
- * em-dash.
391
+ * Construct (not throw) the bound error with a free-form message — for
392
+ * `throw invalidArgument('…')` sites that don't fit the `X must be Y`
393
+ * shape (canonicalisation errors, cross-field constraint failures). Pass
394
+ * `{ cause }` when the failure was triggered by an underlying throw whose
395
+ * chain matters (e.g. `JSON.stringify` on a bigint).
393
396
  *
394
- * @param {string} name
395
- * @param {string} description
396
- * @param {string} [hint]
397
- * @returns {string}
397
+ * @type {(wrap: WrapFn) => (msg: string, extra?: WrapExtra) => Error}
398
398
  */
399
- function message(name, description, hint) {
400
- return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
401
- }
402
-
403
- const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
399
+ const makeInvalidArgument = wrap => (msg, extra) => wrap(msg, extra);
404
400
 
405
401
  /**
406
- * Bind the assert set to a package's error class. Call once per
407
- * package (conventionally in `internal/guards.js`) and import the
408
- * bound object everywhere:
409
- *
410
- * import { bindAsserts } from '@exortek/shared/asserts';
411
- * import { CryptoError, ErrorCode } from '../errors.js';
412
- * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
413
- *
414
- * // call sites:
415
- * g.assertPositiveInt(options.iterations, 'options.iterations');
416
- * const cfg = g.parse(OptionsSchema, options, 'options');
417
- *
418
- * @param {WrapFn} wrap
402
+ * Argument guards bound to `ApiKeyError` — the package-wide binding of
403
+ * `@exortek/shared/asserts`. Import guards from here, never from the
404
+ * shared module directly.
419
405
  */
420
- function bindAsserts(wrap) {
421
- /**
422
- * @param {string} name
423
- * @param {string} description
424
- * @param {AssertOptions} [opts]
425
- * @returns {never}
426
- */
427
- function fail(name, description, opts) {
428
- throw wrap(message(name, description, opts?.hint));
429
- }
430
406
 
431
- /**
432
- * Assert that `value` is a plain object (not `null`, not an array,
433
- * not a primitive).
434
- * @param {unknown} value
435
- * @param {string} name
436
- * @param {AssertOptions} [opts]
437
- */
438
- function assertObject(value, name, opts) {
439
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
440
- fail(name, 'an object', opts);
441
- }
442
- }
443
-
444
- return {
445
- /**
446
- * Construct (not throw) the bound error with a free-form message —
447
- * for `throw g.invalidArgument('…')` sites that don't fit the
448
- * `X must be Y` shape (canonicalisation errors, cross-field
449
- * constraint failures). Pass `{ cause }` when the argument-shape
450
- * failure was triggered by an underlying throw whose chain matters
451
- * (e.g. `JSON.stringify` on a bigint).
452
- *
453
- * @param {string} msg
454
- * @param {WrapExtra} [extra]
455
- * @returns {Error}
456
- */
457
- invalidArgument(msg, extra) {
458
- return wrap(msg, extra);
459
- },
460
-
461
- /**
462
- * Validate `input` against a `@exortek/shared/validate` schema;
463
- * failures throw the bound error carrying every collected message.
464
- * The bridge that keeps schema validation on the package's own
465
- * error surface.
466
- *
467
- * @param {ParseableSchema} schema
468
- * @param {unknown} input
469
- * @param {string} [path='options']
470
- * @returns {unknown} the parsed (possibly defaulted) value
471
- */
472
- parse(schema, input, path = 'options') {
473
- const r = schema.safeParse(input, path);
474
- if (!r.ok) {
475
- throw wrap(r.errors.join('; '));
476
- }
477
- return r.value;
478
- },
479
-
480
- /**
481
- * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
482
- * @param {unknown} value
483
- * @param {string} name Argument name to include in the error message.
484
- * @param {AssertOptions} [opts]
485
- */
486
- assertNonNegativeInt(value, name, opts) {
487
- if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
488
- fail(name, 'a non-negative safe integer', opts);
489
- }
490
- },
491
-
492
- /**
493
- * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
494
- * @param {unknown} value
495
- * @param {string} name
496
- * @param {AssertOptions} [opts]
497
- */
498
- assertPositiveInt(value, name, opts) {
499
- if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
500
- fail(name, 'a positive integer', opts);
501
- }
502
- },
503
-
504
- /**
505
- * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
506
- * Used for Unix millisecond timestamps in UUID v7 / ULID.
507
- * @param {unknown} value
508
- * @param {string} name
509
- * @param {AssertOptions} [opts]
510
- */
511
- assertUint48(value, name, opts) {
512
- if (
513
- !Number.isSafeInteger(value) ||
514
- /** @type {number} */ (value) < 0 ||
515
- /** @type {number} */ (value) > 0xffffffffffff
516
- ) {
517
- fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
518
- }
519
- },
520
-
521
- /**
522
- * Assert that `value` is a string (may be empty).
523
- * @param {unknown} value
524
- * @param {string} name
525
- * @param {AssertOptions} [opts]
526
- */
527
- assertString(value, name, opts) {
528
- if (typeof value !== 'string') {
529
- fail(name, 'a string', opts);
530
- }
531
- },
532
-
533
- /**
534
- * Assert that `value` is a non-empty string.
535
- * @param {unknown} value
536
- * @param {string} name
537
- * @param {AssertOptions} [opts]
538
- */
539
- assertNonEmptyString(value, name, opts) {
540
- if (typeof value !== 'string' || value.length === 0) {
541
- fail(name, 'a non-empty string', opts);
542
- }
543
- },
544
-
545
- /**
546
- * Assert that `value` is a boolean.
547
- * @param {unknown} value
548
- * @param {string} name
549
- * @param {AssertOptions} [opts]
550
- */
551
- assertBoolean(value, name, opts) {
552
- if (typeof value !== 'boolean') {
553
- fail(name, 'a boolean', opts);
554
- }
555
- },
556
-
557
- /**
558
- * Assert that `value` is a function.
559
- * @param {unknown} value
560
- * @param {string} name
561
- * @param {AssertOptions} [opts]
562
- */
563
- assertFunction(value, name, opts) {
564
- if (typeof value !== 'function') {
565
- fail(name, 'a function', opts);
566
- }
567
- },
568
-
569
- assertObject,
570
-
571
- /**
572
- * Assert that `value` is either `undefined` or a plain object —
573
- * the "optional options object" pattern.
574
- * @param {unknown} value
575
- * @param {string} name
576
- * @param {AssertOptions} [opts]
577
- */
578
- assertOptionalObject(value, name, opts) {
579
- if (value === undefined) {
580
- return;
581
- }
582
- assertObject(value, name, opts);
583
- },
584
-
585
- /**
586
- * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
587
- * strings are NOT accepted. For already-encoded material where a
588
- * string would be ambiguous (ciphertext, signatures, raw key bytes).
589
- * @param {unknown} value
590
- * @param {string} name
591
- * @param {AssertOptions} [opts]
592
- */
593
- assertBytes(value, name, opts) {
594
- if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
595
- fail(name, 'a Buffer or Uint8Array', opts);
596
- }
597
- },
598
-
599
- /**
600
- * Assert that `value` is either a string or a byte buffer (`Buffer`
601
- * or `Uint8Array`).
602
- * @param {unknown} value
603
- * @param {string} name
604
- * @param {AssertOptions} [opts]
605
- */
606
- assertBytesOrString(value, name, opts) {
607
- if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
608
- fail(name, 'a string or Buffer', opts);
609
- }
610
- },
611
-
612
- /**
613
- * Assert that `encoding` is one of the accepted output/input
614
- * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
615
- * no sense (verifying a string signature, decoding a token payload).
616
- *
617
- * @param {unknown} encoding
618
- * @param {string} name
619
- * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
620
- */
621
- assertEncoding(encoding, name, opts) {
622
- const allowBuffer = opts?.allowBuffer !== false;
623
- const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
624
- if (!valid) {
625
- const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
626
- fail(name, list, opts);
627
- }
628
- },
629
- };
630
- }
631
407
 
632
408
  /**
633
- * One-line sugar over {@link bindAsserts} — the overwhelmingly common
634
- * pattern where a package binds every argument-shape failure to its
635
- * own error class + `INVALID_ARGUMENT` code. Emits the standard
636
- * `new ErrorClass(code, message, { cause })` shape, so the underlying
637
- * `BaseError` `{ cause, status, details }` options object is respected.
638
- *
639
- * // packages/<pkg>/src/internal/guards.js
640
- * import { defineGuards } from '@exortek/shared/asserts';
641
- * import { PasswordError, ErrorCode } from '../errors.js';
409
+ * Bind every guard this package calls to ApiKeyError, so an argument-shape
410
+ * failure anywhere carries one class and one code to branch on.
642
411
  *
643
- * export const { assertString, invalidArgument, parse } =
644
- * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
645
- *
646
- * Reach for the raw {@link bindAsserts} when you need a different
647
- * mapping (an alt code, a decorated message) — it stays available.
648
- *
649
- * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
650
- * @param {string} code
651
- */
652
- function defineGuards(ErrorClass, code) {
653
- return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
654
- }
655
-
656
- /**
657
- * Argument guards bound to `ApiKeyError` — the package-wide binding of
658
- * `@exortek/shared/asserts`. Import guards from here, never from the
659
- * shared module directly.
412
+ * @type {import('@exortek/shared/asserts').WrapFn}
660
413
  */
414
+ const wrap = (message, extra) => new ApiKeyError(ErrorCode.INVALID_ARGUMENT, message, extra);
661
415
 
662
-
663
- const { invalidArgument} = defineGuards(
664
- ApiKeyError,
665
- ErrorCode.INVALID_ARGUMENT,
666
- );
416
+ const invalidArgument = makeInvalidArgument(wrap);
667
417
 
668
418
  /**
669
419
  * Scope match helpers.