@exortek/apikey 1.1.1 → 1.1.3

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/stores.cjs CHANGED
@@ -124,11 +124,42 @@ function assertRedisClient(client, methods, wrap) {
124
124
  * can share a single detection path instead of copy-pasting the same
125
125
  * three-way dispatch.
126
126
  *
127
- * Internal to `@exortek/shared` — consumed by `incr-store.js` and
128
- * `record-store.js`, not re-exported to consuming packages.
127
+ * Consumed by `incr-store.js` and `record-store.js` here, and imported
128
+ * directly by the `oauth2`, `opaque` and `session` stores — so treat this
129
+ * module's surface as shared, not private to `@exortek/shared`.
129
130
  */
130
131
 
131
132
 
133
+ /**
134
+ * Which calling convention a client expects for the commands whose
135
+ * signatures differ between drivers — Lua (`eval`) and `SET` with a TTL.
136
+ *
137
+ * Most helpers here can dispatch on a method name (`mget` vs `mGet`), but
138
+ * these two share a name and differ only in how arguments are passed, so
139
+ * they need an explicit answer.
140
+ *
141
+ * A real `ioredis` instance reports `constructor.name === 'EventEmitter'`
142
+ * — it extends EventEmitter and no class name survives to the instance —
143
+ * so a constructor-name probe alone never matches it. Detect on the API
144
+ * surface instead (`scanStream` / the `status` string), neither of which
145
+ * node-redis has, and keep the name check as a fallback for wrapped
146
+ * clients. Anything unrecognised is treated as node-redis, whose
147
+ * options-object form is also what `@upstash/redis` accepts.
148
+ *
149
+ * @param {any} client
150
+ * @returns {'ioredis' | 'node-redis'}
151
+ */
152
+ function detectDialect(client) {
153
+ if (client && (isFunction(client.scanStream) || isString(client.status))) {
154
+ return 'ioredis';
155
+ }
156
+ const name = (client && client.constructor && client.constructor.name) || '';
157
+ if (name === 'Redis' || name === 'Cluster') {
158
+ return 'ioredis';
159
+ }
160
+ return 'node-redis';
161
+ }
162
+
132
163
  /**
133
164
  * @param {object} client A Redis-compatible client instance.
134
165
  * @returns {{
@@ -142,7 +173,11 @@ function assertRedisClient(client, methods, wrap) {
142
173
  * }}
143
174
  */
144
175
  function createRedisHelpers(client) {
176
+ const dialect = detectDialect(client);
177
+
145
178
  return {
179
+ dialect,
180
+
146
181
  async mget(keys) {
147
182
  if (keys.length === 0) {
148
183
  return [];
@@ -188,9 +223,13 @@ function createRedisHelpers(client) {
188
223
 
189
224
  async setWithTTL(key, value, ttlMs) {
190
225
  const px = Math.max(1, Math.ceil(ttlMs));
191
- try {
226
+ // Must dispatch on the dialect, not on a try/catch: node-redis accepts
227
+ // the ioredis positional form without complaint and simply stores the
228
+ // key with no expiry, so a catch-based fallback never fires and the TTL
229
+ // is silently dropped.
230
+ if (dialect === 'ioredis') {
192
231
  await client.set(key, value, 'PX', px);
193
- } catch {
232
+ } else {
194
233
  await client.set(key, value, { PX: px });
195
234
  }
196
235
  },
@@ -590,20 +629,33 @@ function memoryStore() {
590
629
  * `@exortek/shared/validate`: **schema** for whole options objects,
591
630
  * **asserts** for one-liner argument guards at the call site.
592
631
  *
593
- * The consumer surface is {@link defineGuards} (or the raw
594
- * {@link bindAsserts} for full control). Each package binds the assert
595
- * set to its own typed error class once (in `internal/guards.js`), so
596
- * every argument failure throws that package's class —
597
- * `err instanceof CryptoError` holds and users see at a glance which
598
- * package raised the error. The bound `parse` bridges
599
- * `@exortek/shared/validate` schemas to the same error class.
632
+ * Every guard is exported as a `make…` **factory** taking the binding
633
+ * package's `wrap` and returning the guard itself. A package binds the
634
+ * guards it calls once, in its `internal/guards.js`, so every argument
635
+ * failure throws that package's error class — `err instanceof
636
+ * CryptoError` holds, and users see at a glance which package raised it.
637
+ *
638
+ * // packages/<pkg>/src/internal/guards.js
639
+ * import { makeAssertString, makeInvalidArgument } from '@exortek/shared/asserts';
640
+ * import { CryptoError, ErrorCode } from '../errors.js';
641
+ *
642
+ * const wrap = (message, extra) => new CryptoError(ErrorCode.INVALID_ARGUMENT, message, extra);
643
+ *
644
+ * export const assertString = makeAssertString(wrap);
645
+ * export const invalidArgument = makeInvalidArgument(wrap);
646
+ *
647
+ * Importing each factory by name is what makes the module
648
+ * tree-shakeable. An earlier version handed back one object carrying
649
+ * every guard, so all of them shipped in every package's bundle no
650
+ * matter how few were used — `assertBoolean` reached fifteen published
651
+ * packages without a single call site. Import what you call.
600
652
  *
601
653
  * **Path naming convention** (used as the `name` argument):
602
654
  *
603
655
  * `<publicFunction>[.options|.config][.<field>]`
604
656
  *
605
- * - `assertString(name, 'createUser.name')` — top-level arg
606
- * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
657
+ * - `assertString(name, 'createUser.name')` — top-level arg
658
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
607
659
  * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
608
660
  *
609
661
  * Keep the path short: the emitted error is `"<name> must be <desc>"`,
@@ -612,289 +664,17 @@ function memoryStore() {
612
664
  * encryptSymmetric()"), not for restating the field.
613
665
  */
614
666
 
615
- /**
616
- * @typedef {{ cause?: unknown }} WrapExtra
617
- * Extra options forwarded to the wrap function, so `invalidArgument`
618
- * sites that catch an underlying error can preserve the `cause` chain
619
- * without falling back to a raw `new PackageError(...)`.
620
- *
621
- * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
622
- * Constructs (never throws) the binding package's error, e.g.
623
- * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
624
- *
625
- * @typedef {{ hint?: string }} AssertOptions
626
- * `hint` is appended to the message after an em-dash — use it for
627
- * actionable guidance ("pass the exact bytes returned by …").
628
- *
629
- * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
630
- */
631
-
632
- /**
633
- * Build the failure message. `description` completes the sentence
634
- * `"<name> must be <description>"`; `hint` (optional) follows an
635
- * em-dash.
636
- *
637
- * @param {string} name
638
- * @param {string} description
639
- * @param {string} [hint]
640
- * @returns {string}
641
- */
642
- function message(name, description, hint) {
643
- return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
644
- }
645
-
646
- const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
647
-
648
- /**
649
- * Bind the assert set to a package's error class. Call once per
650
- * package (conventionally in `internal/guards.js`) and import the
651
- * bound object everywhere:
652
- *
653
- * import { bindAsserts } from '@exortek/shared/asserts';
654
- * import { CryptoError, ErrorCode } from '../errors.js';
655
- * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
656
- *
657
- * // call sites:
658
- * g.assertPositiveInt(options.iterations, 'options.iterations');
659
- * const cfg = g.parse(OptionsSchema, options, 'options');
660
- *
661
- * @param {WrapFn} wrap
662
- */
663
- function bindAsserts(wrap) {
664
- /**
665
- * @param {string} name
666
- * @param {string} description
667
- * @param {AssertOptions} [opts]
668
- * @returns {never}
669
- */
670
- function fail(name, description, opts) {
671
- throw wrap(message(name, description, opts?.hint));
672
- }
673
-
674
- /**
675
- * Assert that `value` is a plain object (not `null`, not an array,
676
- * not a primitive).
677
- * @param {unknown} value
678
- * @param {string} name
679
- * @param {AssertOptions} [opts]
680
- */
681
- function assertObject(value, name, opts) {
682
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
683
- fail(name, 'an object', opts);
684
- }
685
- }
686
-
687
- return {
688
- /**
689
- * Construct (not throw) the bound error with a free-form message —
690
- * for `throw g.invalidArgument('…')` sites that don't fit the
691
- * `X must be Y` shape (canonicalisation errors, cross-field
692
- * constraint failures). Pass `{ cause }` when the argument-shape
693
- * failure was triggered by an underlying throw whose chain matters
694
- * (e.g. `JSON.stringify` on a bigint).
695
- *
696
- * @param {string} msg
697
- * @param {WrapExtra} [extra]
698
- * @returns {Error}
699
- */
700
- invalidArgument(msg, extra) {
701
- return wrap(msg, extra);
702
- },
703
-
704
- /**
705
- * Validate `input` against a `@exortek/shared/validate` schema;
706
- * failures throw the bound error carrying every collected message.
707
- * The bridge that keeps schema validation on the package's own
708
- * error surface.
709
- *
710
- * @param {ParseableSchema} schema
711
- * @param {unknown} input
712
- * @param {string} [path='options']
713
- * @returns {unknown} the parsed (possibly defaulted) value
714
- */
715
- parse(schema, input, path = 'options') {
716
- const r = schema.safeParse(input, path);
717
- if (!r.ok) {
718
- throw wrap(r.errors.join('; '));
719
- }
720
- return r.value;
721
- },
722
-
723
- /**
724
- * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
725
- * @param {unknown} value
726
- * @param {string} name Argument name to include in the error message.
727
- * @param {AssertOptions} [opts]
728
- */
729
- assertNonNegativeInt(value, name, opts) {
730
- if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
731
- fail(name, 'a non-negative safe integer', opts);
732
- }
733
- },
734
-
735
- /**
736
- * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
737
- * @param {unknown} value
738
- * @param {string} name
739
- * @param {AssertOptions} [opts]
740
- */
741
- assertPositiveInt(value, name, opts) {
742
- if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
743
- fail(name, 'a positive integer', opts);
744
- }
745
- },
746
-
747
- /**
748
- * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
749
- * Used for Unix millisecond timestamps in UUID v7 / ULID.
750
- * @param {unknown} value
751
- * @param {string} name
752
- * @param {AssertOptions} [opts]
753
- */
754
- assertUint48(value, name, opts) {
755
- if (
756
- !Number.isSafeInteger(value) ||
757
- /** @type {number} */ (value) < 0 ||
758
- /** @type {number} */ (value) > 0xffffffffffff
759
- ) {
760
- fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
761
- }
762
- },
763
-
764
- /**
765
- * Assert that `value` is a string (may be empty).
766
- * @param {unknown} value
767
- * @param {string} name
768
- * @param {AssertOptions} [opts]
769
- */
770
- assertString(value, name, opts) {
771
- if (typeof value !== 'string') {
772
- fail(name, 'a string', opts);
773
- }
774
- },
775
-
776
- /**
777
- * Assert that `value` is a non-empty string.
778
- * @param {unknown} value
779
- * @param {string} name
780
- * @param {AssertOptions} [opts]
781
- */
782
- assertNonEmptyString(value, name, opts) {
783
- if (typeof value !== 'string' || value.length === 0) {
784
- fail(name, 'a non-empty string', opts);
785
- }
786
- },
787
-
788
- /**
789
- * Assert that `value` is a boolean.
790
- * @param {unknown} value
791
- * @param {string} name
792
- * @param {AssertOptions} [opts]
793
- */
794
- assertBoolean(value, name, opts) {
795
- if (typeof value !== 'boolean') {
796
- fail(name, 'a boolean', opts);
797
- }
798
- },
799
-
800
- /**
801
- * Assert that `value` is a function.
802
- * @param {unknown} value
803
- * @param {string} name
804
- * @param {AssertOptions} [opts]
805
- */
806
- assertFunction(value, name, opts) {
807
- if (typeof value !== 'function') {
808
- fail(name, 'a function', opts);
809
- }
810
- },
811
-
812
- assertObject,
813
-
814
- /**
815
- * Assert that `value` is either `undefined` or a plain object —
816
- * the "optional options object" pattern.
817
- * @param {unknown} value
818
- * @param {string} name
819
- * @param {AssertOptions} [opts]
820
- */
821
- assertOptionalObject(value, name, opts) {
822
- if (value === undefined) {
823
- return;
824
- }
825
- assertObject(value, name, opts);
826
- },
827
-
828
- /**
829
- * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
830
- * strings are NOT accepted. For already-encoded material where a
831
- * string would be ambiguous (ciphertext, signatures, raw key bytes).
832
- * @param {unknown} value
833
- * @param {string} name
834
- * @param {AssertOptions} [opts]
835
- */
836
- assertBytes(value, name, opts) {
837
- if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
838
- fail(name, 'a Buffer or Uint8Array', opts);
839
- }
840
- },
841
-
842
- /**
843
- * Assert that `value` is either a string or a byte buffer (`Buffer`
844
- * or `Uint8Array`).
845
- * @param {unknown} value
846
- * @param {string} name
847
- * @param {AssertOptions} [opts]
848
- */
849
- assertBytesOrString(value, name, opts) {
850
- if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
851
- fail(name, 'a string or Buffer', opts);
852
- }
853
- },
854
-
855
- /**
856
- * Assert that `encoding` is one of the accepted output/input
857
- * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
858
- * no sense (verifying a string signature, decoding a token payload).
859
- *
860
- * @param {unknown} encoding
861
- * @param {string} name
862
- * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
863
- */
864
- assertEncoding(encoding, name, opts) {
865
- const allowBuffer = opts?.allowBuffer !== false;
866
- const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
867
- if (!valid) {
868
- const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
869
- fail(name, list, opts);
870
- }
871
- },
872
- };
873
- }
874
667
 
875
668
  /**
876
- * One-line sugar over {@link bindAsserts} — the overwhelmingly common
877
- * pattern where a package binds every argument-shape failure to its
878
- * own error class + `INVALID_ARGUMENT` code. Emits the standard
879
- * `new ErrorClass(code, message, { cause })` shape, so the underlying
880
- * `BaseError` `{ cause, status, details }` options object is respected.
881
- *
882
- * // packages/<pkg>/src/internal/guards.js
883
- * import { defineGuards } from '@exortek/shared/asserts';
884
- * import { PasswordError, ErrorCode } from '../errors.js';
885
- *
886
- * export const { assertString, invalidArgument, parse } =
887
- * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
669
+ * Construct (not throw) the bound error with a free-form message — for
670
+ * `throw invalidArgument('…')` sites that don't fit the `X must be Y`
671
+ * shape (canonicalisation errors, cross-field constraint failures). Pass
672
+ * `{ cause }` when the failure was triggered by an underlying throw whose
673
+ * chain matters (e.g. `JSON.stringify` on a bigint).
888
674
  *
889
- * Reach for the raw {@link bindAsserts} when you need a different
890
- * mapping (an alt code, a decorated message) — it stays available.
891
- *
892
- * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
893
- * @param {string} code
675
+ * @type {(wrap: WrapFn) => (msg: string, extra?: WrapExtra) => Error}
894
676
  */
895
- function defineGuards(ErrorClass, code) {
896
- return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
897
- }
677
+ const makeInvalidArgument = wrap => (msg, extra) => wrap(msg, extra);
898
678
 
899
679
  /**
900
680
  * Shared base error class — the single error structure behind every
@@ -1005,10 +785,15 @@ class ApiKeyError extends BaseError {
1005
785
  */
1006
786
 
1007
787
 
1008
- const { invalidArgument} = defineGuards(
1009
- ApiKeyError,
1010
- ErrorCode.INVALID_ARGUMENT,
1011
- );
788
+ /**
789
+ * Bind every guard this package calls to ApiKeyError, so an argument-shape
790
+ * failure anywhere carries one class and one code to branch on.
791
+ *
792
+ * @type {import('@exortek/shared/asserts').WrapFn}
793
+ */
794
+ const wrap = (message, extra) => new ApiKeyError(ErrorCode.INVALID_ARGUMENT, message, extra);
795
+
796
+ const invalidArgument = makeInvalidArgument(wrap);
1012
797
 
1013
798
  /**
1014
799
  * Redis-backed store for API keys.
@@ -1112,39 +897,101 @@ function redisStore(client, options = {}) {
1112
897
  }
1113
898
 
1114
899
  /**
1115
- * Wrap a user-supplied store into the `ApiKeyStore` interface.
1116
- * Validates that the required methods exist so a misconfiguration
1117
- * surfaces at construction time, not on the first `createApiKey` /
1118
- * `verifyApiKey` call. Sync implementations are wrapped transparently
1119
- * — return a plain value or a Promise, either works.
900
+ * Family factory for the `customStore(impl)` wrapper every store-shipping
901
+ * package exposes.
1120
902
  *
1121
- * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
903
+ * Each package used to hand-roll the same three steps: assert `impl` is an
904
+ * object, assert its required methods are functions, then `Promise.resolve()`
905
+ * -wrap every call so a sync implementation is transparently promoted to the
906
+ * async store interface. Only the method-name arrays and the typed error
907
+ * differ. This collapses all of that into one binding per package.
1122
908
  *
1123
- * @param {Partial<import('../index.js').ApiKeyStore>} impl
1124
- * @returns {import('../index.js').ApiKeyStore}
909
+ * Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
1125
910
  */
1126
911
 
1127
- const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
1128
912
 
1129
- function customStore(impl) {
913
+ /**
914
+ * Assert `impl` is an object exposing every method in `required` as a function.
915
+ * Throws the caller's typed error (via `wrap`) on the first failure. Used on
916
+ * its own by stores that keep a caller's implementation verbatim (jwt / paseto
917
+ * token registries), and internally by {@link createCustomStoreValidator}.
918
+ *
919
+ * @param {unknown} impl
920
+ * @param {readonly string[]} required
921
+ * @param {(message: string) => Error} wrap
922
+ * The package's error factory; its returned error is thrown. A `wrap` that
923
+ * throws directly also works (the outer `throw` is then unreachable).
924
+ * @returns {void}
925
+ */
926
+ function assertStoreShape(impl, required, wrap) {
1130
927
  if (!isObject(impl)) {
1131
- throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
928
+ throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
1132
929
  }
1133
- for (const name of REQUIRED) {
1134
- if (!isFunction(impl[name])) {
1135
- throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
930
+ for (const name of required) {
931
+ if (!isFunction(/** @type {any} */ (impl)[name])) {
932
+ throw wrap(`customStore: impl.${name} is required and must be a function`);
1136
933
  }
1137
934
  }
1138
- return {
1139
- put: record => Promise.resolve(impl.put(record)),
1140
- getById: id => Promise.resolve(impl.getById(id)),
1141
- update: (id, patch) => Promise.resolve(impl.update(id, patch)),
1142
- revoke: (id, reason) => Promise.resolve(impl.revoke(id, reason)),
1143
- revokeAllForUser: (userId, reason) => Promise.resolve(impl.revokeAllForUser(userId, reason)),
1144
- listByUser: userId => Promise.resolve(impl.listByUser(userId)),
935
+ }
936
+
937
+ /**
938
+ * @param {object} spec
939
+ * @param {readonly string[]} spec.required
940
+ * Methods that must exist on `impl`; missing ones fail at construction time.
941
+ * @param {readonly string[]} [spec.optional]
942
+ * Methods wrapped through only when present (feature-gated store extras).
943
+ * @param {(message: string) => Error} spec.wrap
944
+ * The package's error factory (its `invalidArgument`) — called with a
945
+ * diagnostic message; its returned typed error is thrown so
946
+ * `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
947
+ * works (the outer `throw` is then unreachable).
948
+ * @param {Record<string, (result: any) => any>} [spec.coerce]
949
+ * Optional per-method post-processor applied to the resolved result — e.g.
950
+ * opaque coerces `set` to `undefined` so a native `Map.set` return value
951
+ * can't leak out as `Promise<Map>`.
952
+ * @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
953
+ */
954
+ function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
955
+ return function customStore(impl) {
956
+ assertStoreShape(impl, required, wrap);
957
+
958
+ /** @type {Record<string, (...args: any[]) => Promise<any>>} */
959
+ const store = {};
960
+ const bind = name => {
961
+ const post = coerce[name];
962
+ store[name] = post
963
+ ? (...args) => Promise.resolve(impl[name](...args)).then(post)
964
+ : (...args) => Promise.resolve(impl[name](...args));
965
+ };
966
+
967
+ for (const name of required) {
968
+ bind(name);
969
+ }
970
+ for (const name of optional) {
971
+ if (isFunction(impl[name])) {
972
+ bind(name);
973
+ }
974
+ }
975
+ return store;
1145
976
  };
1146
977
  }
1147
978
 
979
+ /**
980
+ * Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
981
+ * required methods at construction time (not on first use) and promotes sync
982
+ * implementations to the async interface.
983
+ *
984
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
985
+ *
986
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
987
+ * @returns {import('../index.js').ApiKeyStore}
988
+ */
989
+
990
+ const customStore = createCustomStoreValidator({
991
+ required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
992
+ wrap: invalidArgument,
993
+ });
994
+
1148
995
  exports.customStore = customStore;
1149
996
  exports.memoryStore = memoryStore;
1150
997
  exports.redisStore = redisStore;