hanzio 2.1.0 → 2.2.0

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.
@@ -256,7 +256,8 @@ function createApiClientImpl<
256
256
  responseSizeMb: result.validatedData.responseSizeBytes / (1024 * 1024),
257
257
  responseTimeMs: elapsed(),
258
258
  httpStatus: result.httpStatus,
259
- retryCount: result.retryCount
259
+ retryCount: result.retryCount,
260
+ headers: result.validatedData.headers
260
261
  }
261
262
  }
262
263
 
@@ -265,9 +266,14 @@ function createApiClientImpl<
265
266
  httpStatus: number,
266
267
  attempt: number
267
268
  ) => {
268
- if (!apiConfig.checkResponse) return
269
+ // An endpoint's own check (or `false`) replaces the client's.
270
+ const checkResponse =
271
+ endpoint.checkResponse === undefined
272
+ ? apiConfig.checkResponse
273
+ : endpoint.checkResponse
274
+ if (!checkResponse) return
269
275
  try {
270
- await apiConfig.checkResponse(data, {
276
+ await checkResponse(data, {
271
277
  endpoint: endpointName,
272
278
  method: endpoint.method,
273
279
  url: fullUrl,
@@ -1,11 +1,12 @@
1
1
  import type { z } from 'zod'
2
- import type { ApiEndpoint } from './types'
2
+ import type { ApiEndpoint, CheckResponseContext } from './types'
3
3
 
4
4
  /**
5
5
  * Defines an endpoint with full inference, as an alternative to
6
6
  * `satisfies ApiEndpoint`: `resFormatter` must return what `resSchema`
7
- * accepts, and a literal `path` types its `:params` (making `reqParams`
8
- * required when there's no `reqParamsSchema`).
7
+ * accepts, `checkResponse` receives the parsed `resSchema` output, and a
8
+ * literal `path` types its `:params` (making `reqParams` required when
9
+ * there's no `reqParamsSchema`).
9
10
  *
10
11
  * @example
11
12
  * export const usersGet = defineEndpoint({
@@ -14,12 +15,25 @@ import type { ApiEndpoint } from './types'
14
15
  * resSchema: User
15
16
  * })
16
17
  */
17
- export function defineEndpoint<const TEndpoint extends ApiEndpoint>(
18
+ // `TRes` is inferred from `resSchema` before the callbacks are typed, and the
19
+ // constraint omits `checkResponse` so its untyped `ApiEndpoint` signature
20
+ // doesn't override the one below.
21
+ export function defineEndpoint<
22
+ const TEndpoint extends Omit<ApiEndpoint, 'checkResponse'>,
23
+ TRes extends z.ZodType = TEndpoint['resSchema']
24
+ >(
18
25
  endpoint: TEndpoint & {
26
+ resSchema: TRes
19
27
  resFormatter?: (
20
28
  data: unknown,
21
29
  headers: Record<string, string>
22
- ) => z.input<TEndpoint['resSchema']>
30
+ ) => z.input<TRes>
31
+ checkResponse?:
32
+ | ((
33
+ data: z.output<TRes>,
34
+ context: CheckResponseContext
35
+ ) => void | Promise<void>)
36
+ | false
23
37
  }
24
38
  ): TEndpoint {
25
39
  return endpoint
@@ -303,7 +303,11 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
303
303
  >,
304
304
  response: Response,
305
305
  ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
306
- ): Promise<{ data: z.infer<T>; responseSizeBytes: number }> {
306
+ ): Promise<{
307
+ data: z.infer<T>
308
+ responseSizeBytes: number
309
+ headers: Record<string, string>
310
+ }> {
307
311
  const responseHeaders = getHeadersAsObject(response.headers)
308
312
  const text = await response.text()
309
313
  const responseSizeBytes = new TextEncoder().encode(text).length
@@ -327,7 +331,7 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
327
331
  : responseData
328
332
 
329
333
  const data = await validateResponse(endpoint.resSchema, transformedData, ctx)
330
- return { data, responseSizeBytes }
334
+ return { data, responseSizeBytes, headers: responseHeaders }
331
335
  }
332
336
 
333
337
  export function defaultShouldRetry(ctx: RetryContext): boolean {
@@ -35,6 +35,17 @@ export interface ApiEndpoint<
35
35
  ) => z.input<TResponse>
36
36
  reqDefaultQueryParams?: QueryParams
37
37
  doNotEncodeQueryParams?: boolean
38
+ /**
39
+ * Replaces the client's `checkResponse` for this endpoint; `false` skips it.
40
+ * `defineEndpoint` types `data` as the parsed `resSchema` output.
41
+ */
42
+ checkResponse?:
43
+ | ((
44
+ // biome-ignore lint/suspicious/noExplicitAny: typed per endpoint by defineEndpoint
45
+ data: any,
46
+ context: CheckResponseContext
47
+ ) => void | Promise<void>)
48
+ | false
38
49
  }
39
50
 
40
51
  export interface ApiWrapperResponse<T> {
@@ -44,6 +55,8 @@ export interface ApiWrapperResponse<T> {
44
55
  responseTimeMs: number
45
56
  httpStatus: number
46
57
  retryCount: number
58
+ /** Response headers of the final attempt, keyed by lowercase name. */
59
+ headers: Record<string, string>
47
60
  }
48
61
 
49
62
  export type RetryContext = {
@@ -0,0 +1,144 @@
1
+ ---
2
+ description: Web Crypto helpers - base64/hex encoding, constant-time compare, random tokens, SHA-256, HMAC and webhook signatures, AES-GCM sealing.
3
+ ---
4
+
5
+ # hanzio/crypto
6
+
7
+ Built only on `globalThis.crypto` (Web Crypto), so it runs in browsers, Workers,
8
+ Bun and Node ≥ 19. On Node 18, `globalThis.crypto` may be missing; fix that with
9
+ `globalThis.crypto ??= (await import('node:crypto')).webcrypto`. Nothing here
10
+ imports `node:crypto`. Hashing and HMAC are async because Web Crypto is; keep
11
+ synchronous Node hashing (`createHash`) in apps that need it.
12
+
13
+ ```ts
14
+ import {
15
+ createSealer,
16
+ randomToken,
17
+ safeEqual,
18
+ sha256Hex,
19
+ verifyHmacSignature,
20
+ verifySvixSignature
21
+ } from 'hanzio/crypto'
22
+ ```
23
+
24
+ ## Encoding
25
+
26
+ | Function | Notes |
27
+ | --- | --- |
28
+ | `encodeBase64(bytes)` / `decodeBase64(value)` | standard alphabet, padded output; decoding accepts missing padding |
29
+ | `encodeBase64Url(bytes)` / `decodeBase64Url(value)` | URL-safe, unpadded output; decoding accepts padding |
30
+ | `bytesToHex(bytes)` / `hexToBytes(value)` | lowercase output; decoding accepts either case |
31
+ | `utf8Encode(string)` / `utf8Decode(bytes)` | invalid UTF-8 decodes to U+FFFD |
32
+
33
+ `bytes` may be a `Uint8Array` or an `ArrayBuffer`. Decoders throw a `TypeError` on
34
+ invalid input, and the message never contains the input.
35
+
36
+ ## Compare and random
37
+
38
+ ```ts
39
+ safeEqual(providedToken, expectedToken) // string | Uint8Array, strings compared as UTF-8
40
+ randomToken() // 32 random bytes as base64url (43 chars)
41
+ randomToken(16) // 22 chars
42
+ randomBytes(12) // Uint8Array
43
+ ```
44
+
45
+ `safeEqual` always loops over the longer input and never exits early, so timing
46
+ can reveal the **lengths** of the inputs but not where they differ. That is fine
47
+ for comparing against fixed-length secrets or digests. If the length itself is
48
+ sensitive, hash both sides first (`safeEqual(await sha256(a), await sha256(b))`).
49
+
50
+ ## Hashing and HMAC
51
+
52
+ ```ts
53
+ await sha256('abc') // Uint8Array
54
+ await sha256Hex('abc') // 'ba7816bf…'
55
+
56
+ await hmacSign(secret, body) // hex HMAC-SHA-256
57
+ await hmacSign(secret, body, { algorithm: 'SHA-512', encoding: 'base64url' })
58
+
59
+ // Meta / GitHub style: `x-hub-signature-256: sha256=<hex>`
60
+ await verifyHmacSignature(appSecret, rawBody, request.headers.get('x-hub-signature-256') ?? '', {
61
+ prefix: 'sha256='
62
+ })
63
+ ```
64
+
65
+ | Option | Default | Values |
66
+ | --- | --- | --- |
67
+ | `algorithm` | `'SHA-256'` | `'SHA-256' \| 'SHA-384' \| 'SHA-512'` |
68
+ | `encoding` | `'hex'` | `'hex' \| 'base64' \| 'base64url'` |
69
+ | `prefix` (verify only) | none | stripped before decoding; if it is missing, the result is `false` |
70
+
71
+ `verifyHmacSignature` decodes the signature and checks it with
72
+ `crypto.subtle.verify`, which compares in constant time. A malformed, truncated
73
+ or wrongly prefixed signature returns `false`. Configuration errors throw: an
74
+ empty secret, or an unknown algorithm or encoding.
75
+
76
+ ## Svix / Standard Webhooks
77
+
78
+ Resend, Clerk and other Svix-based providers sign
79
+ `${id}.${timestamp}.${body}` with HMAC-SHA-256, using the base64 key inside the
80
+ `whsec_` secret.
81
+
82
+ ```ts
83
+ const ok = await verifySvixSignature({
84
+ secret: env.RESEND_WEBHOOK_SECRET, // 'whsec_…'
85
+ payload: rawBody, // the exact raw body, not re-serialized JSON
86
+ headers: request.headers, // Headers or a plain record
87
+ toleranceSec: 300, // default
88
+ now: Date.now() // default, in milliseconds
89
+ })
90
+ ```
91
+
92
+ - Reads the `svix-id`, `svix-timestamp` and `svix-signature` headers. If they are
93
+ missing, it reads `webhook-id`, `webhook-timestamp` and `webhook-signature`.
94
+ Header lookup ignores case, also for plain records.
95
+ - The signature header is a space-separated list. Every `v1,<base64>` entry is
96
+ checked, so verification keeps working while the provider rotates the secret.
97
+ - Returns `false` for missing headers, a non-integer timestamp, a timestamp more
98
+ than `toleranceSec` from `now` in either direction, or no matching signature.
99
+ An invalid secret throws a `TypeError`.
100
+
101
+ ## Sealing (AES-256-GCM)
102
+
103
+ ```ts
104
+ const sealer = createSealer({
105
+ secret: env.APP_SECRET,
106
+ info: 'myapp.oauth-tokens.v1' // use a different value for each purpose
107
+ })
108
+
109
+ const sealed = await sealer.sealJson({ accessToken }) // 'enc:v1:…'
110
+ const tokens = await sealer.openJson<{ accessToken: string }>(sealed)
111
+
112
+ await sealer.seal('plain text')
113
+ await sealer.open(sealed)
114
+ ```
115
+
116
+ - The key is derived from `secret` with HKDF-SHA-256 (`salt`, default empty;
117
+ `info`, default `'hanzio.sealer.aes-256-gcm.v1'`). Derivation runs once per
118
+ sealer, and the resulting `CryptoKey` cannot be exported.
119
+ - Each value gets a random 96-bit IV.
120
+ - The envelope is `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`.
121
+ - `open` throws a `SealerError` with one of these codes:
122
+ - `'malformed'`
123
+ - `'unsupported_version'`, for `enc:vN:` values other than v1
124
+ - `'decrypt_failed'`, for a wrong key, salt or info, or tampered data
125
+
126
+ Error messages never contain the secret or the plaintext.
127
+ - `sealJson` stores `JSON.stringify(value)`. It throws for `undefined` and for
128
+ functions.
129
+
130
+ ### Migrating lullu `secret-json.ts`
131
+
132
+ The envelope and key derivation match `apps/backend/src/lib/crypto/secret-json.ts`,
133
+ so values already stored keep opening and don't need to be re-encrypted:
134
+
135
+ ```ts
136
+ const sealer = createSealer({
137
+ secret: getSecret('APP_SECRET'),
138
+ salt: 'lullu.secret-json.salt.v1',
139
+ info: 'lullu.secret-json.aes-256-gcm.v1'
140
+ })
141
+ ```
142
+
143
+ lullu's `openSecretJson` returns non-sealed values unchanged. `open` throws on
144
+ them instead, so keep the `value.startsWith('enc:')` check in the app.
@@ -0,0 +1,120 @@
1
+ # hanzio/format
2
+
3
+ Locale-aware formatting on top of `Intl`, with no dependencies. Every function
4
+ takes the locale (a BCP-47 tag such as `'en-US'` or `'da-DK'`) explicitly, and
5
+ every function that reads the clock accepts a `now` for tests.
6
+
7
+ ```ts
8
+ import { formatRelativeTime, formatBytes } from 'hanzio/format'
9
+ ```
10
+
11
+ ## Cached formatters
12
+
13
+ Creating an `Intl` formatter is slow; these return one shared instance per
14
+ (locale, options), keyed on a stable JSON of the options (key order does not
15
+ matter). Each cache holds the 100 most recently used formatters.
16
+
17
+ ```ts
18
+ getNumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(5) // '€5.00'
19
+ getDateTimeFormat('en-GB', { dateStyle: 'medium' }).format(date) // '28 Sep 2026'
20
+ getRelativeTimeFormat('en-US', { numeric: 'auto' }).format(-1, 'day') // 'yesterday'
21
+ getListFormat('en-US', { type: 'disjunction' }).format(['a', 'b']) // 'a or b'
22
+ getPluralRules('en-US').select(1) // 'one'
23
+ ```
24
+
25
+ ## Numbers
26
+
27
+ ```ts
28
+ formatNumber(1234567.891, 'da-DK') // '1.234.567,891'
29
+ formatPercent(0.25, 'en-US') // '25%' (takes a fraction, not 25)
30
+ formatCurrency(12.5, 'USD', 'en-US') // '$12.50' (whole units, not cents)
31
+ formatCurrency(2500, 'DKK', 'da-DK', { maximumFractionDigits: 0 }) // '2.500 kr.'
32
+ formatCompactNumber(1234, 'en-US') // '1.2K'
33
+ ```
34
+
35
+ ### `formatBytes(bytes, { locale?, decimals?, binary? })`
36
+
37
+ Unit labels (B, KB, MB, GB, TB, PB) are not localized; the number is. 1 KB is
38
+ 1024 B by default (`binary: false` for 1000). Whole numbers up to KB, one
39
+ decimal from MB up, unless `decimals` is set. `locale` defaults to `'en-US'`.
40
+
41
+ ```ts
42
+ formatBytes(3.45 * 1024 * 1024) // '3.5 MB'
43
+ ```
44
+
45
+ ## Durations
46
+
47
+ ```ts
48
+ splitDuration(93_784_005) // { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }
49
+ formatClockDuration(3_909_000) // '1:05:09' ('5:09' under an hour)
50
+ ```
51
+
52
+ ### `formatDuration(ms, locale, { style?, maxUnits? })`
53
+
54
+ Truncates, never rounds up. Shows up to `maxUnits` (default 2) adjacent units
55
+ starting at the largest non-zero one, so 1 day 0 h 5 min reads `'1 day'`.
56
+ `style` is `'short'` by default.
57
+
58
+ ```ts
59
+ formatDuration(3_930_000, 'en-US') // '1 hr, 5 min'
60
+ formatDuration(3_930_000, 'en-US', { style: 'narrow' }) // '1h 5m'
61
+ formatDuration(3_930_000, 'en-US', { style: 'long' }) // '1 hour, 5 minutes'
62
+ ```
63
+
64
+ ## Relative time
65
+
66
+ ### `formatRelativeTime(date, locale, { now?, numeric?, style?, withSuffix? })`
67
+
68
+ Picks seconds, minutes, hours, days (< 30), months (30-day) or years.
69
+ `numeric` defaults to `'auto'` ("yesterday"). `withSuffix: false` returns the
70
+ bare amount ("5 minutes") for use next to a label that already says when.
71
+ Unparseable input returns `''`.
72
+
73
+ ```ts
74
+ formatRelativeTime('2026-09-28T11:00:00Z', 'en-US', { now: new Date('2026-09-28T12:00:00Z') }) // '1 hour ago'
75
+ ```
76
+
77
+ ### `formatWait(value, locale, { now? })`
78
+
79
+ Compact "how long has this waited" for a status pill: floors, never below
80
+ `1 min`, a future timestamp counts as no waiting. A `number` is a duration in
81
+ milliseconds; a `Date` or string is a start time measured against `now`.
82
+
83
+ ```ts
84
+ formatWait(14 * 60_000 + 50_000, 'en-US') // '14 min'
85
+ ```
86
+
87
+ ## Dates and lists
88
+
89
+ ### `formatDateRange(start, end, locale, options?)`
90
+
91
+ Uses `Intl.DateTimeFormat#formatRange`, which collapses the shared parts. When
92
+ `options` has no date/time fields, it uses day + short month + year (and still
93
+ applies `timeZone` and similar options).
94
+
95
+ ```ts
96
+ formatDateRange('2026-08-24', '2026-08-30', 'en-GB', { timeZone: 'UTC' }) // '24–30 Aug 2026'
97
+ ```
98
+
99
+ ### `formatList(items, locale, options?)`
100
+
101
+ ```ts
102
+ formatList(['a', 'b', 'c'], 'en-US') // 'a, b, and c'
103
+ ```
104
+
105
+ ## Counts
106
+
107
+ ```ts
108
+ formatCappedCount(150) // '99+' (a negative number or NaN gives '0')
109
+ formatCappedCount(12, 9) // '9+'
110
+ pluralize(1234, { one: '{count} item', other: '{count} items' }, 'en-US') // '1,234 items'
111
+ ```
112
+
113
+ `pluralize` picks the form with `Intl.PluralRules` and falls back to `other`.
114
+
115
+ ## Runtime support
116
+
117
+ Works in browsers, Node, Bun, Workers and React Native. On Hermes, polyfill
118
+ `Intl.RelativeTimeFormat` and `Intl.PluralRules` if your Hermes build does not
119
+ have them. `formatList`, `formatDuration` and `formatDateRange` fall back to
120
+ plain separators when `Intl.ListFormat` or `formatRange` is missing.
@@ -0,0 +1,65 @@
1
+ ---
2
+ description: Typed `{name}` placeholders, interpolation and a small typed translator.
3
+ ---
4
+
5
+ # hanzio/i18n
6
+
7
+ A typed helper for `{name}` message templates, not an i18n framework: no
8
+ plurals, ICU, locale negotiation or escaping.
9
+
10
+ ```ts
11
+ import { createTranslator, extractPlaceholders, interpolate } from 'hanzio/i18n'
12
+
13
+ interpolate('Hi {name}, you have {count} messages', { name: 'Ada', count: 3 })
14
+ // 'Hi Ada, you have 3 messages'
15
+
16
+ interpolate('Hi {name}') // type error: params required
17
+ interpolate('Hi {name}', { nam: 'Ada' }) // type error: misspelled
18
+
19
+ extractPlaceholders('{name} has {count} items, {name}') // ['name', 'count']
20
+ ```
21
+
22
+ ## Placeholders
23
+
24
+ A placeholder is `{` + word characters (`[A-Za-z0-9_]`) + `}`. Anything else is
25
+ plain text: `{ name }`, `{}`, `{count, plural, ...}`. The types and the runtime
26
+ use the same rule.
27
+
28
+ - A placeholder without a param (or with `null`/`undefined`) stays as `{name}`.
29
+ - Only own properties of `params` are used, so `{toString}` is never filled from
30
+ `Object.prototype`.
31
+ - Values are `string | number`, converted with `String()`.
32
+
33
+ Types:
34
+
35
+ | Type | `'Hi {name}, {count}'` |
36
+ | ------------------------ | -------------------------------------------- |
37
+ | `PlaceholderNames<S>` | `'name' \| 'count'` |
38
+ | `PlaceholderParams<S>` | `{ name: string \| number; count: ... }` |
39
+ | `PlaceholderArgs<S>` | `[params: PlaceholderParams<S>]`, `[]` if none |
40
+
41
+ For a non-literal `string` template, names are `string` and params are optional.
42
+
43
+ ## Translator
44
+
45
+ ```ts
46
+ const messages = {
47
+ common: { save: 'Save' },
48
+ greeting: 'Hi {name}'
49
+ } as const
50
+
51
+ const t = createTranslator(messages)
52
+
53
+ t('common.save') // 'Save'
54
+ t('greeting', { name: 'Ada' }) // 'Hi Ada'
55
+ t('greeting') // type error: params required
56
+ t('common.save', { name: 'Ada' }) // type error: no placeholders
57
+ t('common.missing') // type error: unknown key
58
+ ```
59
+
60
+ Keys are the dot paths of the string leaves (`MessageKeys<T>`). Pass the
61
+ messages inline or `as const` so the templates stay literal; with plain
62
+ `Record<string, string>` messages (for example JSON imports) keys are `string`
63
+ and params are optional. A key that is missing at runtime returns the key.
64
+
65
+ `flattenMessages(messages)` gives the flat map: `{ 'common.save': 'Save', greeting: 'Hi {name}' }`.
@@ -0,0 +1,70 @@
1
+ ---
2
+ description: JWT sign/verify/decode using built-in Web Crypto HMAC (HS256/HS384/HS512).
3
+ ---
4
+
5
+ # hanzio/jwt
6
+
7
+ ```ts
8
+ import { jwtDecode, jwtSign, jwtVerify, jwtVerifyResult } from 'hanzio/jwt'
9
+
10
+ const token = await jwtSign({ sub: 'user-1' }, secret, {
11
+ expiresIn: 60 * 60, // seconds
12
+ issuer: 'api',
13
+ audience: 'web'
14
+ })
15
+
16
+ // Payload or null
17
+ const payload = await jwtVerify<{ sub: string }>(token, secret)
18
+
19
+ // Result with a reason
20
+ const result = await jwtVerifyResult(token, secret, {
21
+ issuer: 'api',
22
+ audience: 'web',
23
+ leewaySec: 30
24
+ })
25
+ if (!result.ok) console.log(result.error.code) // 'expired', 'invalid_signature', …
26
+
27
+ // Unverified: for reading claims client-side, never for auth
28
+ const decoded = jwtDecode(token) // { header, payload } | null
29
+ ```
30
+
31
+ ## Signing
32
+
33
+ `jwtSign(payload, secret, options?)`
34
+
35
+ | Option | Claim | Notes |
36
+ | ------------ | ----- | ----------------------------------- |
37
+ | `expiresIn` | `exp` | seconds from now |
38
+ | `notBefore` | `nbf` | seconds from now |
39
+ | `issuer` | `iss` | |
40
+ | `audience` | `aud` | `string \| string[]` |
41
+ | `algorithm` | — | `'HS256'` (default), `'HS384'`, `'HS512'` |
42
+
43
+ - `iat` is always set to the current time.
44
+ - Options win: a claim set through an option replaces the same claim in `payload`.
45
+ Payload claims are kept when the option is omitted.
46
+ - Non-finite `expiresIn`/`notBefore` throw a `TypeError`.
47
+ - The header is `{ alg, typ: 'JWT' }`.
48
+
49
+ ## Verifying
50
+
51
+ `jwtVerifyResult(token, secret, options?)` returns
52
+ `Result<Payload, JwtVerifyError>` (see `hanzio` promise helpers). `error.code` is one of
53
+ `'malformed' | 'unsupported_alg' | 'invalid_signature' | 'expired' | 'not_yet_valid' | 'invalid_issuer' | 'invalid_audience'`.
54
+
55
+ | Option | Default | Notes |
56
+ | ------------ | ----------------------- | ------------------------------------------ |
57
+ | `leewaySec` | `0` | clock skew tolerance for `exp` / `nbf` |
58
+ | `issuer` | — | required `iss` |
59
+ | `audience` | — | must equal `aud` or be one of its entries |
60
+ | `algorithms` | `['HS256','HS384','HS512']` | the header `alg` must be in this list |
61
+
62
+ - The signature is checked before any claim, so unsigned claims never decide the error code.
63
+ - `alg: 'none'` and anything outside `algorithms` is rejected as `unsupported_alg`.
64
+ - Configuration errors (no Web Crypto, empty secret) throw. `jwtVerify` swallows them
65
+ and returns `null`, like any other failure.
66
+
67
+ Imported `CryptoKey`s are cached per secret, algorithm and usage (up to 100 entries).
68
+
69
+ The base64url and UTF-8 helpers this module uses are public in
70
+ [`hanzio/crypto`](../crypto/README.md) (`encodeBase64Url`, `decodeBase64Url`, …).