hanzio 3.0.0 → 3.1.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.
Files changed (112) hide show
  1. package/README.md +114 -3
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-mq2qgc20.js +116 -0
  23. package/dist/index-najbyzpr.js +99 -0
  24. package/dist/index-nfvq3zax.js +475 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +14 -4
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +533 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod'
2
+ import type { EnvironmentVariables } from './environment'
3
+ import { SecretLoadError } from './errors'
4
+
5
+ const identityFileSchema = z.object({
6
+ clientId: z.string().min(1),
7
+ clientSecret: z.string().min(1),
8
+ siteUrl: z.string().url()
9
+ })
10
+
11
+ /**
12
+ * Machine identity (Universal Auth) credentials kept in a private file for every
13
+ * process on this machine, for example by agentgate, which syncs one identity to
14
+ * all of a user's machines. `INFISICAL_UNIVERSAL_AUTH_FILE` overrides the default
15
+ * `~/.infisical/universal-auth.json`. Only credentials for `siteUrl`'s instance are
16
+ * returned, so a secret is never sent to another Infisical instance.
17
+ */
18
+ export async function readInfisicalIdentityFile(
19
+ env: EnvironmentVariables | undefined,
20
+ siteUrl: string
21
+ ): Promise<{ clientId: string; clientSecret: string } | undefined> {
22
+ // Runtimes without a filesystem (browsers, workers) have no identity file.
23
+ const modules = await Promise.all([
24
+ import('node:fs/promises'),
25
+ import('node:os'),
26
+ import('node:path')
27
+ ]).catch(() => undefined)
28
+
29
+ if (!modules) {
30
+ return undefined
31
+ }
32
+
33
+ const [fs, os, path] = modules
34
+ const file =
35
+ env?.INFISICAL_UNIVERSAL_AUTH_FILE ||
36
+ path.join(os.homedir(), '.infisical', 'universal-auth.json')
37
+ let text: string
38
+
39
+ try {
40
+ text = await fs.readFile(file, 'utf8')
41
+ } catch (error) {
42
+ if ((error as { code?: string }).code === 'ENOENT') {
43
+ return undefined
44
+ }
45
+
46
+ throw new SecretLoadError(
47
+ 'CONFIGURATION',
48
+ `The Infisical identity file ${file} could not be read.`
49
+ )
50
+ }
51
+
52
+ let identity: z.infer<typeof identityFileSchema>
53
+
54
+ try {
55
+ identity = identityFileSchema.parse(JSON.parse(text))
56
+ } catch {
57
+ // Never echo the file: it holds a client secret.
58
+ throw new SecretLoadError(
59
+ 'CONFIGURATION',
60
+ `The Infisical identity file ${file} is not valid; it needs clientId, clientSecret and siteUrl.`
61
+ )
62
+ }
63
+
64
+ if (new URL(identity.siteUrl).origin !== new URL(siteUrl).origin) {
65
+ return undefined
66
+ }
67
+
68
+ return { clientId: identity.clientId, clientSecret: identity.clientSecret }
69
+ }
@@ -5,6 +5,7 @@ import { getProcessEnvironment } from './environment'
5
5
  import { SecretLoadError } from './errors'
6
6
  import { readInfisicalCliToken } from './infisical-cli'
7
7
  import { validateInfisicalScope, type InfisicalScope } from './infisical-config'
8
+ import { readInfisicalIdentityFile } from './infisical-identity-file'
8
9
  import { parseInfisicalSessionToken } from './infisical-session'
9
10
 
10
11
  export const DEFAULT_INFISICAL_SITE_URL = 'https://eu.infisical.com'
@@ -74,14 +75,26 @@ export async function loadInfisicalSecrets<K extends string>(
74
75
  validateInfisicalScope(options)
75
76
 
76
77
  const env = getProcessEnvironment()
77
- const clientId = env?.[options.clientIdEnvKey]
78
- const clientSecret = env?.[options.clientSecretEnvKey]
79
78
  const mode = options.auth ?? 'auto'
80
79
 
81
80
  if (!['auto', 'http', 'cli'].includes(mode)) {
82
81
  throw new SecretLoadError('CONFIGURATION', 'Invalid Infisical auth mode.')
83
82
  }
84
83
 
84
+ // Environment credentials win; with neither set, a machine identity file
85
+ // stands in for them (see readInfisicalIdentityFile).
86
+ const fromEnv = Boolean(
87
+ env?.[options.clientIdEnvKey] || env?.[options.clientSecretEnvKey]
88
+ )
89
+ const file =
90
+ mode === 'cli' || fromEnv
91
+ ? undefined
92
+ : await readInfisicalIdentityFile(env, options.siteUrl)
93
+ const clientId = fromEnv ? env?.[options.clientIdEnvKey] : file?.clientId
94
+ const clientSecret = fromEnv
95
+ ? env?.[options.clientSecretEnvKey]
96
+ : file?.clientSecret
97
+
85
98
  // Explicit modes take precedence. Auto mode uses HTTP only when both
86
99
  // credentials exist; a failed HTTP request must not switch accounts.
87
100
  const useHttp =
@@ -95,7 +108,9 @@ export async function loadInfisicalSecrets<K extends string>(
95
108
  )
96
109
  }
97
110
 
98
- options.reportSource?.('Infisical HTTP')
111
+ options.reportSource?.(
112
+ file ? 'Infisical HTTP (identity file)' : 'Infisical HTTP'
113
+ )
99
114
 
100
115
  return fetchInfisicalSecrets({ ...options, clientId, clientSecret })
101
116
  }
@@ -322,3 +322,62 @@ function safeDecodeURIComponent(value: string): string {
322
322
  return value
323
323
  }
324
324
  }
325
+
326
+ /** Escapes `& < > " '` for HTML or XML text and attribute values. */
327
+ export function escapeHtml(text: string): string {
328
+ return text
329
+ .replace(/&/g, '&amp;')
330
+ .replace(/</g, '&lt;')
331
+ .replace(/>/g, '&gt;')
332
+ .replace(/"/g, '&quot;')
333
+ .replace(/'/g, '&#39;')
334
+ }
335
+
336
+ /**
337
+ * Pictographs plus the parts that build an emoji sequence: regional indicators
338
+ * (flags), skin tones, the keycap combiner, the emoji variation selector and
339
+ * the zero-width joiner. Digits, `#` and `*` also carry the `Emoji` property
340
+ * but are ordinary text, so they are deliberately not matched.
341
+ */
342
+ const EMOJI_PATTERN =
343
+ /[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}\u{1F3FB}-\u{1F3FF}\u{20E3}\u{FE0F}\u{200D}]/gu
344
+
345
+ /**
346
+ * Removes emoji and tidies the spacing they leave, so `'Hi there 👋, welcome'`
347
+ * becomes `'Hi there, welcome'`. Use it for text that is spoken (TTS drops
348
+ * emoji from the audio but not from the transcript). Text without emoji is
349
+ * returned unchanged.
350
+ */
351
+ export function stripEmoji(text: string): string {
352
+ const stripped = text.replace(EMOJI_PATTERN, '')
353
+ if (stripped === text) return text
354
+ return stripped
355
+ .replace(/ {2,}/g, ' ')
356
+ .replace(/ +([,.!?;:])/g, '$1')
357
+ .trim()
358
+ }
359
+
360
+ /**
361
+ * Lowercased extension of a file name or path, without the dot. `undefined`
362
+ * when there is none; like `path.extname`, a leading dot (`.env`) is not one.
363
+ */
364
+ export function fileExtension(filename: string): string | undefined {
365
+ const name = filename.slice(
366
+ Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\')) + 1
367
+ )
368
+ const dot = name.lastIndexOf('.')
369
+ if (dot <= 0 || dot === name.length - 1) return undefined
370
+ return name.slice(dot + 1).toLowerCase()
371
+ }
372
+
373
+ /**
374
+ * Normalizes a value the way a JSON round trip does: `undefined` properties
375
+ * are dropped, `Date`s become ISO strings and `NaN` becomes `null`. Top-level
376
+ * `undefined` becomes `null`. Use it before storing loosely typed data in a
377
+ * JSON column. Throws on `bigint` and circular references.
378
+ */
379
+ export function toJsonValue(value: unknown): JsonValue {
380
+ if (value === undefined) return null
381
+ const text = JSON.stringify(value)
382
+ return text === undefined ? null : (JSON.parse(text) as JsonValue)
383
+ }
@@ -0,0 +1,119 @@
1
+ ---
2
+ description: bun:test helpers - restorable module mocks, a deep Prisma mock, a minimal window shim, and a race contract for API clients that share logins.
3
+ ---
4
+
5
+ # hanzio/testing
6
+
7
+ For `bun:test`. The module can be imported outside Bun; `bun:test` is only
8
+ loaded when a helper that needs it runs.
9
+
10
+ ```ts
11
+ import {
12
+ connectionAuthContract,
13
+ createPrismaMock,
14
+ installWindowShim,
15
+ ModuleMocker,
16
+ mockCallArg,
17
+ noopLogger
18
+ } from 'hanzio/testing'
19
+ ```
20
+
21
+ ## Restorable module mocks
22
+
23
+ Bun's `mock.module` replaces a module for the whole test run, and
24
+ `mock.restore()` does not undo it, so a stub in one file leaks into every file
25
+ that runs after it. `ModuleMocker` spreads the real exports under your
26
+ overrides and `clear()` puts the originals back.
27
+
28
+ ```ts
29
+ const moduleMocker = new ModuleMocker()
30
+ await moduleMocker.mock(path.resolve(import.meta.dir, '../lib/posthog.ts'), () => ({
31
+ captureException: mock()
32
+ }))
33
+ afterAll(() => moduleMocker.clear())
34
+ ```
35
+
36
+ Bun resolves a relative specifier from the file that registers the mock, which
37
+ would be hanzio's. Pass absolute paths or package names, or give the
38
+ constructor a resolver for your own shorthand:
39
+
40
+ ```ts
41
+ const moduleMocker = new ModuleMocker((specifier) =>
42
+ specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
43
+ )
44
+ ```
45
+
46
+ ## Prisma
47
+
48
+ `createPrismaMock<Client>(mock)` builds a client where every model method is a
49
+ Bun mock created on first access. Register it in place of the module that
50
+ exports your client, spreading that module's other exports so they keep
51
+ working for every file in the run:
52
+
53
+ ```ts
54
+ import { mock } from 'bun:test'
55
+ import * as prismaClientExports from '@prisma/client'
56
+ import type { prisma } from '../lib/prisma'
57
+
58
+ export const prismaMock = createPrismaMock<typeof prisma>(mock)
59
+ mock.module(path.resolve(import.meta.dir, '../lib/prisma/index.ts'), () => ({
60
+ ...prismaClientExports,
61
+ prisma: prismaMock
62
+ }))
63
+
64
+ beforeEach(() => prismaMock.$reset())
65
+ prismaMock.user.findUnique.mockResolvedValue({ id: 'u1' })
66
+ ```
67
+
68
+ Model and method names are checked against the client type; arguments and
69
+ return values are not.
70
+
71
+ ## Window shim
72
+
73
+ `installWindowShim(href?)` installs a minimal `window` (`location`,
74
+ `localStorage`, no-op event listeners) for code that only probes for one, and
75
+ returns a reset for `afterEach`. Tests may assign a partial `window`; the shim
76
+ keeps the `location`, `localStorage` and listeners from it, because libraries
77
+ such as axios and PostHog read them at import time. It deliberately installs no
78
+ `document`, which Vue and PrismJS would treat as a full DOM.
79
+
80
+ ```ts
81
+ // test-setup.ts, preloaded through bunfig.toml
82
+ afterEach(installWindowShim())
83
+ ```
84
+
85
+ `createTestStorage()` is the in-memory `Storage` it uses.
86
+
87
+ ## Connection auth contract
88
+
89
+ `connectionAuthContract` registers the race tests every API client built on
90
+ `ConnectionAuthCache` ([`hanzio/api-wrapper/connection-auth`](../api-wrapper/README.md#sharing-a-login-across-clients))
91
+ must pass, run through the real client and transport: shared refreshes,
92
+ rotated credentials, late 401s, aborted waiters and failed fresh logins.
93
+
94
+ ```ts
95
+ await connectionAuthContract({
96
+ reset: () => authCache.clear(),
97
+ create: (fetch, variant) => new ShopApi(credentialsFor(variant), fetch),
98
+ identityChanges: ['apiKey', 'secret'],
99
+ isLogin: (url) => url.endsWith('/oauth/token'),
100
+ loginResponse: (token) => Response.json({ access_token: token, expires_in: 3600 }),
101
+ readResponse: () => Response.json({ items: [] }),
102
+ tokenHeader: 'authorization'
103
+ })
104
+ ```
105
+
106
+ `create(fetch, variant)` builds a client. `variant` is one of
107
+ `identityChanges` (must force a new login), `'settings'` (must reuse it),
108
+ `'connection'` (another connection) or `'secret'` (a rotated secret). `read()`
109
+ makes one request and, on a 401, calls `reject` and retries once; `login()`
110
+ forces a fresh login.
111
+
112
+ ## Small helpers
113
+
114
+ ```ts
115
+ const input = mockCallArg<CreateUserInput>(createUser) // call 0, argument 0
116
+ mockCallArg<boolean>(createUser, 1, 1) // call 1, argument 1
117
+
118
+ runJob({ logger: noopLogger }) // trace…fatal discard; bind/child return itself
119
+ ```
@@ -0,0 +1,303 @@
1
+ // Bun types `clone()` as the undici Response; it is the same runtime object.
2
+ const cloneResponse = (response: Response) => response.clone() as Response
3
+
4
+ export type ConnectionAuthContractInput = {
5
+ reset: () => void
6
+ create: (
7
+ fetchImpl: typeof fetch,
8
+ variant?: string
9
+ ) => {
10
+ read: () => Promise<unknown>
11
+ login: (options?: { signal?: AbortSignal }) => Promise<string>
12
+ }
13
+ identityChanges: string[]
14
+ isLogin: (url: string) => boolean
15
+ loginResponse: (token: string) => Response
16
+ readResponse: () => Response
17
+ tokenHeader: string
18
+ }
19
+
20
+ /**
21
+ * Registers the races every client built on `ConnectionAuthCache`
22
+ * (`hanzio/api-wrapper/connection-auth`) must survive, run through the real
23
+ * client and its transport: shared refreshes, rotated credentials, late 401s,
24
+ * aborted waiters and failed fresh logins. Await it at the top level of a
25
+ * `bun:test` file.
26
+ *
27
+ * - `create(fetch, variant)` builds a client on the given fetch. `variant` is
28
+ * one of `identityChanges` (a credential change that must force a new login),
29
+ * `'settings'` (a non-auth setting that must reuse the login), `'connection'`
30
+ * (another connection) or `'secret'` (a rotated secret).
31
+ * - `read()` makes one authenticated request; on a 401 it calls `reject` and
32
+ * retries once. `login()` forces a fresh login and resolves its token.
33
+ * - `reset()` clears the cache between tests.
34
+ * - `tokenHeader` is where requests carry the token; `'authorization'` means
35
+ * `Bearer <token>`.
36
+ *
37
+ * @example
38
+ * await connectionAuthContract({
39
+ * reset: () => authCache.clear(),
40
+ * create: (fetch, variant) => new ShopApi(credentialsFor(variant), fetch),
41
+ * identityChanges: ['apiKey', 'secret'],
42
+ * isLogin: (url) => url.endsWith('/oauth/token'),
43
+ * loginResponse: (token) => Response.json({ access_token: token, expires_in: 3600 }),
44
+ * readResponse: () => Response.json({ items: [] }),
45
+ * tokenHeader: 'authorization'
46
+ * })
47
+ */
48
+ export async function connectionAuthContract(
49
+ input: ConnectionAuthContractInput
50
+ ): Promise<void> {
51
+ const { beforeEach, describe, expect, test } = await import('bun:test')
52
+ describe('shared authentication contract', () => {
53
+ beforeEach(input.reset)
54
+
55
+ function provider(
56
+ respond: (
57
+ login: boolean,
58
+ init: RequestInit
59
+ ) => Promise<Response> | Response
60
+ ) {
61
+ let exchanges = 0
62
+ const usedTokens: (string | null)[] = []
63
+ const fetchImpl = (async (url, init) => {
64
+ const login = input.isLogin(String(url))
65
+ if (login) exchanges += 1
66
+ else usedTokens.push(new Headers(init?.headers).get(input.tokenHeader))
67
+ return respond(login, init ?? {})
68
+ }) as typeof fetch
69
+
70
+ return { fetchImpl, usedTokens, exchanges: () => exchanges }
71
+ }
72
+
73
+ test.each(
74
+ input.identityChanges
75
+ )('%s changes authentication identity', async (variant) => {
76
+ const fake = provider((login) =>
77
+ login
78
+ ? input.loginResponse(`token-${fake.exchanges()}`)
79
+ : input.readResponse()
80
+ )
81
+ await input.create(fake.fetchImpl).read()
82
+ await input.create(fake.fetchImpl, variant).read()
83
+
84
+ expect(fake.exchanges()).toBe(2)
85
+ expect(fake.usedTokens[1]).not.toBe(fake.usedTokens[0])
86
+ })
87
+
88
+ test('non-auth settings reuse completed authentication across instances', async () => {
89
+ const fake = provider((login) =>
90
+ login ? input.loginResponse('same') : input.readResponse()
91
+ )
92
+ await input.create(fake.fetchImpl).read()
93
+ await input.create(fake.fetchImpl, 'settings').read()
94
+
95
+ expect(fake.exchanges()).toBe(1)
96
+ })
97
+
98
+ test('unrelated connections proceed while another login is blocked', async () => {
99
+ const pending = Promise.withResolvers<Response>()
100
+ const fake = provider((login) => {
101
+ if (!login) return input.readResponse()
102
+ return fake.exchanges() === 1
103
+ ? pending.promise
104
+ : input.loginResponse('independent')
105
+ })
106
+ const blocked = input.create(fake.fetchImpl).read()
107
+ await input.create(fake.fetchImpl, 'connection').read()
108
+ expect(fake.exchanges()).toBe(2)
109
+
110
+ pending.resolve(input.loginResponse('blocked'))
111
+ await blocked
112
+ })
113
+
114
+ test('failed shared refresh is evicted and a later attempt recovers', async () => {
115
+ const pending = Promise.withResolvers<Response>()
116
+ const fake = provider((login) =>
117
+ login ? pending.promise.then(cloneResponse) : input.readResponse()
118
+ )
119
+ const results = Promise.allSettled(
120
+ Array.from({ length: 3 }, () => input.create(fake.fetchImpl).read())
121
+ )
122
+ pending.reject(new Error('provider unavailable'))
123
+ expect((await results).map((result) => result.status)).toEqual([
124
+ 'rejected',
125
+ 'rejected',
126
+ 'rejected'
127
+ ])
128
+ expect(fake.exchanges()).toBe(1)
129
+
130
+ const recovered = provider((login) =>
131
+ login ? input.loginResponse('recovered') : input.readResponse()
132
+ )
133
+ await input.create(recovered.fetchImpl).read()
134
+ expect(recovered.exchanges()).toBe(1)
135
+ })
136
+
137
+ test('simultaneous 401s share one refresh, and a delayed 401 cannot evict it', async () => {
138
+ const rejected = Array.from({ length: 3 }, () =>
139
+ Promise.withResolvers<Response>()
140
+ )
141
+ const requestsStarted = Promise.withResolvers<void>()
142
+ const refresh = Promise.withResolvers<Response>()
143
+ let oldRequests = 0
144
+ const fake = provider((login, init) => {
145
+ if (login)
146
+ return fake.exchanges() === 1
147
+ ? input.loginResponse('A')
148
+ : refresh.promise.then(cloneResponse)
149
+ const header = new Headers(init.headers).get(input.tokenHeader)
150
+ if (
151
+ header === (input.tokenHeader === 'authorization' ? 'Bearer A' : 'A')
152
+ ) {
153
+ const response = rejected[oldRequests++]!
154
+ if (oldRequests === 3) requestsStarted.resolve()
155
+ return response.promise
156
+ }
157
+ return input.readResponse()
158
+ })
159
+ await input.create(fake.fetchImpl).login()
160
+ const reads = Array.from({ length: 3 }, () =>
161
+ input.create(fake.fetchImpl).read()
162
+ )
163
+ await requestsStarted.promise
164
+
165
+ rejected[0]!.resolve(new Response('', { status: 401 }))
166
+ rejected[1]!.resolve(new Response('', { status: 401 }))
167
+ refresh.resolve(input.loginResponse('B'))
168
+ await Promise.all(reads.slice(0, 2))
169
+ rejected[2]!.resolve(new Response('', { status: 401 }))
170
+ await reads[2]
171
+
172
+ expect(fake.exchanges()).toBe(2)
173
+ expect(fake.usedTokens).toHaveLength(6)
174
+ expect(fake.usedTokens.slice(3)).toEqual(
175
+ Array(3).fill(input.tokenHeader === 'authorization' ? 'Bearer B' : 'B')
176
+ )
177
+ })
178
+
179
+ test('a delayed 401 cannot evict a new generation with identical token bytes', async () => {
180
+ const rejected = Promise.withResolvers<Response>()
181
+ const started = Promise.withResolvers<void>()
182
+ let reads = 0
183
+ const fake = provider((login) => {
184
+ if (login) return input.loginResponse('same')
185
+ reads += 1
186
+ if (reads === 1) {
187
+ started.resolve()
188
+ return rejected.promise
189
+ }
190
+ return input.readResponse()
191
+ })
192
+ await input.create(fake.fetchImpl).login()
193
+ const oldRead = input.create(fake.fetchImpl).read()
194
+ await started.promise
195
+ await input.create(fake.fetchImpl).login()
196
+ rejected.resolve(new Response('', { status: 401 }))
197
+ await oldRead
198
+
199
+ expect(fake.exchanges()).toBe(2)
200
+ expect(reads).toBe(2)
201
+ })
202
+
203
+ test('failed fresh authentication cannot fall back to the previous completed token', async () => {
204
+ const fake = provider((login) => {
205
+ if (!login) return input.readResponse()
206
+ if (fake.exchanges() === 2) throw new Error('credentials rejected')
207
+ return input.loginResponse('valid')
208
+ })
209
+ await input.create(fake.fetchImpl).read()
210
+ await expect(input.create(fake.fetchImpl).login()).rejects.toThrow(
211
+ 'credentials rejected'
212
+ )
213
+ await input.create(fake.fetchImpl).read()
214
+
215
+ expect(fake.exchanges()).toBe(3)
216
+ })
217
+
218
+ test('a late old login cannot replace rotated authentication', async () => {
219
+ const pending = Promise.withResolvers<Response>()
220
+ const fake = provider((login) => {
221
+ if (!login) return input.readResponse()
222
+ return fake.exchanges() === 1
223
+ ? pending.promise
224
+ : input.loginResponse('new')
225
+ })
226
+ const old = input.create(fake.fetchImpl).read()
227
+ await input.create(fake.fetchImpl, 'secret').read()
228
+ pending.resolve(input.loginResponse('old'))
229
+ await old
230
+ await input.create(fake.fetchImpl, 'secret').read()
231
+
232
+ expect(fake.exchanges()).toBe(2)
233
+ expect(fake.usedTokens.at(-1)).toBe(
234
+ input.tokenHeader === 'authorization' ? 'Bearer new' : 'new'
235
+ )
236
+ })
237
+
238
+ test('fresh logins bypass completed tokens and coalesce identical pending checks', async () => {
239
+ const pending = Promise.withResolvers<Response>()
240
+ const fake = provider((login) => {
241
+ if (!login) return input.readResponse()
242
+ return fake.exchanges() === 1
243
+ ? input.loginResponse('warm')
244
+ : pending.promise.then(cloneResponse)
245
+ })
246
+ await input.create(fake.fetchImpl).read()
247
+ const checks = Array.from({ length: 3 }, () =>
248
+ input.create(fake.fetchImpl).login()
249
+ )
250
+ expect(fake.exchanges()).toBe(2)
251
+ pending.resolve(input.loginResponse('fresh'))
252
+ expect(await Promise.all(checks)).toEqual(['fresh', 'fresh', 'fresh'])
253
+ })
254
+
255
+ test('aborting the initiating waiter leaves the shared refresh running', async () => {
256
+ const pending = Promise.withResolvers<Response>()
257
+ const fake = provider((login, init) => {
258
+ if (!login) return input.readResponse()
259
+ expect(init.signal).toBeUndefined()
260
+ return pending.promise
261
+ })
262
+ const controller = new AbortController()
263
+ const cancelled = input
264
+ .create(fake.fetchImpl)
265
+ .login({ signal: controller.signal })
266
+ const survivor = input.create(fake.fetchImpl).read()
267
+ controller.abort(new Error('wait cancelled'))
268
+ await expect(cancelled).rejects.toThrow('wait cancelled')
269
+
270
+ pending.resolve(input.loginResponse('survived'))
271
+ await survivor
272
+ await input.create(fake.fetchImpl).read()
273
+ expect(fake.exchanges()).toBe(1)
274
+ })
275
+
276
+ test('a pre-aborted waiter starts no exchange', async () => {
277
+ const fake = provider(() => {
278
+ throw new Error('unexpected fetch')
279
+ })
280
+ const controller = new AbortController()
281
+ controller.abort(new Error('already cancelled'))
282
+
283
+ await expect(
284
+ input.create(fake.fetchImpl).login({ signal: controller.signal })
285
+ ).rejects.toThrow('already cancelled')
286
+ expect(fake.exchanges()).toBe(0)
287
+ })
288
+
289
+ test('a second 401 is surfaced without expanding the retry budget', async () => {
290
+ const fake = provider((login) =>
291
+ login
292
+ ? input.loginResponse('rejected')
293
+ : new Response('', { status: 401 })
294
+ )
295
+ await expect(input.create(fake.fetchImpl).read()).rejects.toMatchObject({
296
+ status: 401
297
+ })
298
+
299
+ expect(fake.exchanges()).toBe(2)
300
+ expect(fake.usedTokens).toHaveLength(2)
301
+ })
302
+ })
303
+ }
@@ -0,0 +1,41 @@
1
+ type LogMethod = (...args: unknown[]) => void
2
+
3
+ export type NoopLogger = {
4
+ trace: LogMethod
5
+ debug: LogMethod
6
+ info: LogMethod
7
+ warn: LogMethod
8
+ error: LogMethod
9
+ fatal: LogMethod
10
+ bind: (...args: unknown[]) => NoopLogger
11
+ child: (...args: unknown[]) => NoopLogger
12
+ }
13
+
14
+ const noop: LogMethod = () => {}
15
+
16
+ /** A logger that discards everything; `bind` and `child` return itself. */
17
+ export const noopLogger: NoopLogger = {
18
+ trace: noop,
19
+ debug: noop,
20
+ info: noop,
21
+ warn: noop,
22
+ error: noop,
23
+ fatal: noop,
24
+ bind: () => noopLogger,
25
+ child: () => noopLogger
26
+ }
27
+
28
+ /**
29
+ * Reads one argument from a mock's call history as `T`. Bun types
30
+ * `mock.calls` as `never[]` for loosely typed mocks.
31
+ *
32
+ * @example
33
+ * const input = mockCallArg<CreateUserInput>(createUser) // first call, first argument
34
+ */
35
+ export function mockCallArg<T>(
36
+ fn: { mock: { calls: unknown[][] } },
37
+ callIndex = 0,
38
+ argIndex = 0
39
+ ): T {
40
+ return fn.mock.calls[callIndex]?.[argIndex] as T
41
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ type ConnectionAuthContractInput,
3
+ connectionAuthContract
4
+ } from './connection-auth-contract'
5
+ export { mockCallArg, type NoopLogger, noopLogger } from './helpers'
6
+ export { ModuleMocker } from './module-mocker'
7
+ export {
8
+ createPrismaMock,
9
+ type DeepMockedPrisma,
10
+ type PrismaMethodMock
11
+ } from './prisma'
12
+ export { createTestStorage, installWindowShim } from './window'