@c9up/ream 0.2.2 → 0.2.4

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 (122) hide show
  1. package/dist/container/Container.d.ts +18 -0
  2. package/dist/container/Container.d.ts.map +1 -1
  3. package/dist/container/Container.js +29 -0
  4. package/dist/container/Container.js.map +1 -1
  5. package/dist/env/Env.d.ts +11 -0
  6. package/dist/env/Env.d.ts.map +1 -1
  7. package/dist/env/Env.js +33 -17
  8. package/dist/env/Env.js.map +1 -1
  9. package/dist/env/Secret.d.ts +17 -0
  10. package/dist/env/Secret.d.ts.map +1 -1
  11. package/dist/env/Secret.js +23 -0
  12. package/dist/env/Secret.js.map +1 -1
  13. package/dist/errors/aggregate.d.ts +3 -0
  14. package/dist/errors/aggregate.d.ts.map +1 -1
  15. package/dist/errors/aggregate.js +3 -0
  16. package/dist/errors/aggregate.js.map +1 -1
  17. package/dist/events/Emitter.d.ts +20 -1
  18. package/dist/events/Emitter.d.ts.map +1 -1
  19. package/dist/events/Emitter.js +28 -6
  20. package/dist/events/Emitter.js.map +1 -1
  21. package/dist/helpers/MessageBuilder.d.ts +29 -0
  22. package/dist/helpers/MessageBuilder.d.ts.map +1 -0
  23. package/dist/helpers/MessageBuilder.js +63 -0
  24. package/dist/helpers/MessageBuilder.js.map +1 -0
  25. package/dist/helpers/VerificationToken.d.ts +56 -0
  26. package/dist/helpers/VerificationToken.d.ts.map +1 -0
  27. package/dist/helpers/VerificationToken.js +70 -0
  28. package/dist/helpers/VerificationToken.js.map +1 -0
  29. package/dist/helpers/defineStaticProperty.d.ts +15 -0
  30. package/dist/helpers/defineStaticProperty.d.ts.map +1 -0
  31. package/dist/helpers/defineStaticProperty.js +47 -0
  32. package/dist/helpers/defineStaticProperty.js.map +1 -0
  33. package/dist/helpers/duration.d.ts +20 -0
  34. package/dist/helpers/duration.d.ts.map +1 -1
  35. package/dist/helpers/duration.js +71 -0
  36. package/dist/helpers/duration.js.map +1 -1
  37. package/dist/helpers/fs.d.ts +39 -0
  38. package/dist/helpers/fs.d.ts.map +1 -0
  39. package/dist/helpers/fs.js +70 -0
  40. package/dist/helpers/fs.js.map +1 -0
  41. package/dist/helpers/index.d.ts +8 -0
  42. package/dist/helpers/index.d.ts.map +1 -1
  43. package/dist/helpers/index.js +7 -0
  44. package/dist/helpers/index.js.map +1 -1
  45. package/dist/helpers/safeTiming.d.ts +14 -0
  46. package/dist/helpers/safeTiming.d.ts.map +1 -0
  47. package/dist/helpers/safeTiming.js +42 -0
  48. package/dist/helpers/safeTiming.js.map +1 -0
  49. package/dist/helpers/string.d.ts +85 -0
  50. package/dist/helpers/string.d.ts.map +1 -1
  51. package/dist/helpers/string.js +127 -1
  52. package/dist/helpers/string.js.map +1 -1
  53. package/dist/http/HttpContext.d.ts +14 -1
  54. package/dist/http/HttpContext.d.ts.map +1 -1
  55. package/dist/http/HttpContext.js.map +1 -1
  56. package/dist/http/RedirectBuilder.d.ts +16 -0
  57. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  58. package/dist/http/RedirectBuilder.js +34 -9
  59. package/dist/http/RedirectBuilder.js.map +1 -1
  60. package/dist/http/Request.d.ts.map +1 -1
  61. package/dist/http/Request.js +5 -30
  62. package/dist/http/Request.js.map +1 -1
  63. package/dist/index.d.ts +3 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/router/Router.d.ts +54 -0
  68. package/dist/router/Router.d.ts.map +1 -1
  69. package/dist/router/Router.js +72 -3
  70. package/dist/router/Router.js.map +1 -1
  71. package/dist/session/ReadOnlyValuesStore.d.ts +11 -0
  72. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -1
  73. package/dist/session/ReadOnlyValuesStore.js +17 -0
  74. package/dist/session/ReadOnlyValuesStore.js.map +1 -1
  75. package/dist/session/Session.d.ts +11 -3
  76. package/dist/session/Session.d.ts.map +1 -1
  77. package/dist/session/Session.js +15 -6
  78. package/dist/session/Session.js.map +1 -1
  79. package/dist/session/SessionMiddleware.js +2 -1
  80. package/dist/session/SessionMiddleware.js.map +1 -1
  81. package/dist/session/errors.d.ts +25 -0
  82. package/dist/session/errors.d.ts.map +1 -0
  83. package/dist/session/errors.js +32 -0
  84. package/dist/session/errors.js.map +1 -0
  85. package/dist/types/{container.d.ts → index.d.ts} +47 -18
  86. package/dist/types/index.d.ts.map +1 -0
  87. package/dist/types/index.js +14 -0
  88. package/dist/types/index.js.map +1 -0
  89. package/events.darwin-arm64.node +0 -0
  90. package/events.darwin-x64.node +0 -0
  91. package/events.linux-arm64-gnu.node +0 -0
  92. package/events.linux-x64-gnu.node +0 -0
  93. package/events.win32-x64-msvc.node +0 -0
  94. package/index.win32-x64-msvc.node +0 -0
  95. package/package.json +7 -3
  96. package/scheduler.win32-x64-msvc.node +0 -0
  97. package/src/container/Container.ts +31 -0
  98. package/src/env/Env.ts +34 -15
  99. package/src/env/Secret.ts +26 -0
  100. package/src/errors/aggregate.ts +3 -0
  101. package/src/events/Emitter.ts +33 -5
  102. package/src/helpers/MessageBuilder.ts +69 -0
  103. package/src/helpers/VerificationToken.ts +93 -0
  104. package/src/helpers/defineStaticProperty.ts +51 -0
  105. package/src/helpers/duration.ts +75 -0
  106. package/src/helpers/fs.ts +104 -0
  107. package/src/helpers/index.ts +8 -0
  108. package/src/helpers/safeTiming.ts +45 -0
  109. package/src/helpers/string.ts +145 -1
  110. package/src/http/HttpContext.ts +14 -1
  111. package/src/http/RedirectBuilder.ts +38 -8
  112. package/src/http/Request.ts +5 -29
  113. package/src/index.ts +11 -2
  114. package/src/router/Router.ts +86 -3
  115. package/src/session/ReadOnlyValuesStore.ts +20 -0
  116. package/src/session/Session.ts +16 -11
  117. package/src/session/SessionMiddleware.ts +2 -1
  118. package/src/session/errors.ts +39 -0
  119. package/src/types/{container.ts → index.ts} +48 -17
  120. package/dist/types/container.d.ts.map +0 -1
  121. package/dist/types/container.js +0 -23
  122. package/dist/types/container.js.map +0 -1
@@ -18,6 +18,7 @@ import {
18
18
  E_VALIDATION_ERROR,
19
19
  } from '../http/Exception.js'
20
20
  import { E_INSECURE_APP_KEY, E_MISSING_APP_KEY } from '../security/CookieSigner.js'
21
+ import { E_SESSION_NOT_READY, E_SESSION_TAGGING_NOT_SUPPORTED } from '../session/errors.js'
21
22
 
22
23
  export const errors = {
23
24
  E_FORBIDDEN,
@@ -28,6 +29,8 @@ export const errors = {
28
29
  E_MISSING_APP_KEY,
29
30
  E_ROUTE_NOT_FOUND,
30
31
  E_ROW_NOT_FOUND,
32
+ E_SESSION_NOT_READY,
33
+ E_SESSION_TAGGING_NOT_SUPPORTED,
31
34
  E_UNAUTHORIZED,
32
35
  E_VALIDATION_ERROR,
33
36
  } as const
@@ -590,17 +590,45 @@ export class Emitter {
590
590
  * emitter.on(TaskDeclared, LogTaskEvent)
591
591
  */
592
592
  export class BaseEvent {
593
+ /**
594
+ * Subclasses declare their own payload parameters; this signature only has
595
+ * to be wide enough for `dispatch` to forward them (AdonisJS does the same).
596
+ * It widens `ConstructorParameters<typeof BaseEvent>` from `[]` to a rest
597
+ * parameter, which is what lets `dispatch` call `new this(...args)`.
598
+ */
599
+ // biome-ignore lint/complexity/noUselessConstructor: it widens ConstructorParameters for dispatch — removing it fails to compile
600
+ constructor(..._args: unknown[]) {}
601
+
593
602
  static eventName?: string
594
- private static _emitter: Emitter | undefined
603
+
604
+ /**
605
+ * The emitter events dispatch through. Public, as in AdonisJS: a test swaps
606
+ * it to capture dispatches without booting a provider.
607
+ */
608
+ static emitter: Emitter | undefined
595
609
 
596
610
  /** @internal Wire the emitter (called by EventsProvider). */
597
611
  static useEmitter(emitter: Emitter): void {
598
- BaseEvent._emitter = emitter
612
+ BaseEvent.emitter = emitter
599
613
  }
600
614
 
601
615
  /** @internal Ownership-guarded reset — only clears if `emitter` is still the active one. */
602
616
  static resetEmitter(emitter: Emitter): void {
603
- if (BaseEvent._emitter === emitter) BaseEvent._emitter = undefined
617
+ if (BaseEvent.emitter === emitter) BaseEvent.emitter = undefined
618
+ }
619
+
620
+ /**
621
+ * Construct and dispatch in one call — `OrderShipped.dispatch(order)`.
622
+ *
623
+ * The AdonisJS idiom, and the reason `emit()` alone is not enough: an event
624
+ * is almost always built only to be emitted, and naming the class twice to
625
+ * do it is what the static removes.
626
+ */
627
+ static async dispatch<T extends typeof BaseEvent>(
628
+ this: T,
629
+ ...args: ConstructorParameters<T>
630
+ ): Promise<void> {
631
+ await new this(...args).emit()
604
632
  }
605
633
 
606
634
  /**
@@ -608,9 +636,9 @@ export class BaseEvent {
608
636
  * No-op if no emitter is wired (test/standalone mode).
609
637
  */
610
638
  async emit(): Promise<void> {
611
- if (!BaseEvent._emitter) return
639
+ if (!BaseEvent.emitter) return
612
640
  try {
613
- await BaseEvent._emitter.dispatchEvent(this)
641
+ await BaseEvent.emitter.dispatchEvent(this)
614
642
  } catch (err) {
615
643
  process.stderr.write(`[events] dispatch error for ${this.constructor.name}: ${err}\n`)
616
644
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Wrap a value with an expiry and a purpose before it is signed or encrypted.
3
+ *
4
+ * The signature on a token proves it was issued by this application. It does
5
+ * NOT prove the token is still valid, nor that it was issued for the job it is
6
+ * being presented for — a signed password-reset token would otherwise pass as a
7
+ * signed email-verification token. Those two claims travel inside the payload,
8
+ * which is why they are added before signing rather than checked after.
9
+ *
10
+ * Ported from `@poppinss/utils`; `CookieSigner` and `SignedUrl` sign what this
11
+ * produces.
12
+ */
13
+
14
+ import { milliseconds } from './duration.js'
15
+
16
+ /** The envelope `build` emits and `verify` reads back. */
17
+ interface Envelope {
18
+ message: unknown
19
+ purpose?: string
20
+ expiryDate?: string
21
+ }
22
+
23
+ export class MessageBuilder {
24
+ /**
25
+ * Serialise `message`, optionally stamped with an expiry and a purpose.
26
+ *
27
+ * @param expiresIn - a duration (`'2 hours'`) or a number of milliseconds.
28
+ */
29
+ build(message: unknown, expiresIn?: string | number, purpose?: string): string {
30
+ const envelope: Envelope = { message }
31
+ if (purpose !== undefined) envelope.purpose = purpose
32
+ if (expiresIn !== undefined) {
33
+ envelope.expiryDate = new Date(Date.now() + milliseconds.parse(expiresIn)).toISOString()
34
+ }
35
+ return JSON.stringify(envelope)
36
+ }
37
+
38
+ /**
39
+ * Read an envelope back, or `null` when it is malformed, expired, or was
40
+ * issued for a different purpose.
41
+ *
42
+ * Never throws: every failure is the same `null`, so a caller cannot
43
+ * accidentally tell "tampered" from "expired" and leak which it was.
44
+ */
45
+ verify<T = unknown>(payload: string, purpose?: string): T | null {
46
+ let parsed: unknown
47
+ try {
48
+ parsed = JSON.parse(payload)
49
+ } catch {
50
+ return null
51
+ }
52
+ if (parsed === null || typeof parsed !== 'object') return null
53
+ if (!('message' in parsed) || parsed.message === undefined) return null
54
+
55
+ const envelopePurpose = 'purpose' in parsed ? parsed.purpose : undefined
56
+ if (envelopePurpose !== purpose) return null
57
+
58
+ if ('expiryDate' in parsed && typeof parsed.expiryDate === 'string') {
59
+ const expiresAt = new Date(parsed.expiryDate)
60
+ // An unparseable date counts as expired: a token whose expiry cannot be
61
+ // read is a token whose expiry cannot be enforced.
62
+ if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) return null
63
+ }
64
+
65
+ // The caller declares the shape it put in — the same contract as
66
+ // `session.get<T>()`, over a payload that is untyped by nature.
67
+ return parsed.message as T
68
+ }
69
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The base class for opaque, single-use tokens — password resets, email
3
+ * verification, magic links.
4
+ *
5
+ * The shape it enforces is the point. The token handed to the user is
6
+ * `<identifier>.<secret>`, and only the SHA-256 of the secret is stored. A
7
+ * database leak therefore yields hashes, not usable tokens, and the identifier
8
+ * travels alongside so verification is one indexed lookup rather than a scan.
9
+ *
10
+ * Comparison goes through a constant-time equality: comparing hashes with `===`
11
+ * leaks their common prefix through timing, which is enough to forge one.
12
+ *
13
+ * Ported from `@adonisjs/core/helpers` — subclass it and add the persistence.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto'
17
+ import { Secret } from '../env/Secret.js'
18
+ import { base64 } from './base64.js'
19
+ import { seconds } from './duration.js'
20
+ import { safeEqual } from './safeEqual.js'
21
+ import { random } from './string.js'
22
+
23
+ export abstract class VerificationToken {
24
+ /** Row identifier, as stored. */
25
+ declare identifier: string | number | bigint
26
+ /** Who the token belongs to. */
27
+ declare tokenableId: string | number | bigint
28
+ /** SHA-256 of the secret — the only half that is persisted. */
29
+ declare hash: string
30
+ /** When the token stops being accepted. */
31
+ declare expiresAt: Date
32
+ /** The full token, present only on the instance that just created it. */
33
+ value?: Secret<string>
34
+
35
+ /**
36
+ * Split a presented token back into its identifier and secret.
37
+ *
38
+ * Returns `null` for anything malformed rather than throwing: the input is
39
+ * attacker-controlled, and every rejection should look the same.
40
+ */
41
+ static decode(value: string): { identifier: string; secret: Secret<string> } | null {
42
+ if (typeof value !== 'string' || value.length === 0) return null
43
+ const [identifier, ...rest] = value.split('.')
44
+ if (!identifier || rest.length === 0) return null
45
+ const decodedIdentifier = base64.urlDecode(identifier)
46
+ const decodedSecret = base64.urlDecode(rest.join('.'))
47
+ if (!decodedIdentifier || !decodedSecret) return null
48
+ return { identifier: decodedIdentifier, secret: new Secret(decodedSecret) }
49
+ }
50
+
51
+ /** A fresh secret and its hash. */
52
+ static seed(size: number): { secret: Secret<string>; hash: string } {
53
+ const secret = new Secret(random(size))
54
+ return {
55
+ secret,
56
+ hash: createHash('sha256').update(secret.release()).digest('hex'),
57
+ }
58
+ }
59
+
60
+ /** A seed plus the owner and an expiry, ready to be persisted. */
61
+ static createTransientToken(
62
+ userId: string | number | bigint,
63
+ size: number,
64
+ expiresIn: string | number,
65
+ ): {
66
+ userId: string | number | bigint
67
+ expiresAt: Date
68
+ secret: Secret<string>
69
+ hash: string
70
+ } {
71
+ const expiresAt = new Date()
72
+ expiresAt.setSeconds(expiresAt.getSeconds() + seconds.parse(expiresIn))
73
+ return { userId, expiresAt, ...VerificationToken.seed(size) }
74
+ }
75
+
76
+ /** Build the public `<identifier>.<secret>` value, after the row has an id. */
77
+ protected computeValue(secret: Secret<string>): void {
78
+ this.value = new Secret(
79
+ `${base64.urlEncode(String(this.identifier))}.${base64.urlEncode(secret.release())}`,
80
+ )
81
+ }
82
+
83
+ /** Whether the expiry has passed. */
84
+ isExpired(): boolean {
85
+ return this.expiresAt < new Date()
86
+ }
87
+
88
+ /** Whether `secret` hashes to the stored hash, compared in constant time. */
89
+ verify(secret: Secret<string>): boolean {
90
+ const candidate = createHash('sha256').update(secret.release()).digest('hex')
91
+ return safeEqual(this.hash, candidate)
92
+ }
93
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Give a subclass its own copy of an inherited static property.
3
+ *
4
+ * A static declared on a base class is SHARED: every subclass mutating
5
+ * `Model.booted` or `Model.$columns` writes into the same object. This walks
6
+ * that back by defining an own property on the subclass, seeded either from a
7
+ * fresh value or from a copy of what it inherited.
8
+ *
9
+ * Ported from `@poppinss/utils` — the mechanism ORM-style base classes rely on.
10
+ */
11
+ export function defineStaticProperty<T>(
12
+ target: object,
13
+ propertyName: string,
14
+ options: { initialValue: T; strategy: 'inherit' | 'define' | ((value: T) => T) },
15
+ ): void {
16
+ if (Object.hasOwn(target, propertyName)) return
17
+
18
+ const inherited: unknown = Reflect.get(target, propertyName)
19
+ const { strategy, initialValue } = options
20
+
21
+ const value =
22
+ strategy === 'define' || inherited === undefined
23
+ ? initialValue
24
+ : typeof strategy === 'function'
25
+ ? strategy(inherited as T)
26
+ : structuredCloneOrCopy(inherited)
27
+
28
+ Object.defineProperty(target, propertyName, {
29
+ value,
30
+ configurable: true,
31
+ enumerable: true,
32
+ writable: true,
33
+ })
34
+ }
35
+
36
+ /**
37
+ * A deep copy where one is possible, a shallow one otherwise.
38
+ *
39
+ * `structuredClone` throws on functions and class instances, which a static
40
+ * bag can legitimately hold — falling back keeps the inherit strategy usable
41
+ * instead of failing at class-definition time.
42
+ */
43
+ function structuredCloneOrCopy(value: unknown): unknown {
44
+ try {
45
+ return structuredClone(value)
46
+ } catch {
47
+ if (Array.isArray(value)) return [...value]
48
+ if (value !== null && typeof value === 'object') return { ...value }
49
+ return value
50
+ }
51
+ }
@@ -69,3 +69,78 @@ export function durationToSeconds(value: number | string, label: string): number
69
69
  }
70
70
  return Math.floor(milliseconds / SECOND)
71
71
  }
72
+
73
+ /**
74
+ * Format a millisecond duration the way `@poppinss/string` does.
75
+ *
76
+ * Short form by default (`'2h'`), long form on request (`'2 hours'`).
77
+ */
78
+ function formatMs(value: number, long: boolean): string {
79
+ const table: [number, string][] = long
80
+ ? [
81
+ [YEAR, 'year'],
82
+ [DAY, 'day'],
83
+ [HOUR, 'hour'],
84
+ [MINUTE, 'minute'],
85
+ [SECOND, 'second'],
86
+ [1, 'millisecond'],
87
+ ]
88
+ : [
89
+ [DAY, 'd'],
90
+ [HOUR, 'h'],
91
+ [MINUTE, 'm'],
92
+ [SECOND, 's'],
93
+ [1, 'ms'],
94
+ ]
95
+ const magnitude = Math.abs(value)
96
+ for (const [scale, label] of table) {
97
+ if (magnitude >= scale) {
98
+ const count = Math.round(value / scale)
99
+ return long ? `${count} ${label}${Math.abs(count) === 1 ? '' : 's'}` : `${count}${label}`
100
+ }
101
+ }
102
+ return long ? `${value} milliseconds` : `${value}ms`
103
+ }
104
+
105
+ /** The duration that could not be read, as one message shape for both objects. */
106
+ function parseOrThrow(duration: string | number, unitLabel: string): number {
107
+ if (typeof duration === 'number') return duration
108
+ const parsed = parseDurationMs(duration)
109
+ if (parsed === undefined) {
110
+ throw new Error(
111
+ `Cannot read "${duration}" as ${unitLabel}. Use a number, or a duration such as '30m', '2 hours' or '7 days'.`,
112
+ )
113
+ }
114
+ return parsed
115
+ }
116
+
117
+ /**
118
+ * `string.milliseconds` — parse and format durations in milliseconds.
119
+ *
120
+ * A number passes through untouched, matching `@poppinss/string`.
121
+ */
122
+ export const milliseconds = {
123
+ parse(duration: string | number): number {
124
+ return parseOrThrow(duration, 'milliseconds')
125
+ },
126
+ format(value: number, long = false): string {
127
+ return formatMs(value, long)
128
+ },
129
+ }
130
+
131
+ /**
132
+ * `string.seconds` — the same, in seconds.
133
+ *
134
+ * `parse` keeps the AdonisJS quirk its own `durationToSeconds` documents: a
135
+ * NUMBER is already seconds and passes through, while a unit-less STRING is
136
+ * milliseconds and gets scaled down.
137
+ */
138
+ export const seconds = {
139
+ parse(duration: string | number): number {
140
+ if (typeof duration === 'number') return duration
141
+ return parseOrThrow(duration, 'seconds') / SECOND
142
+ },
143
+ format(value: number, long = false): string {
144
+ return formatMs(value * SECOND, long)
145
+ },
146
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Recursive directory reads, as `fsReadAll` / `fsImportAll` in AdonisJS.
3
+ *
4
+ * What the framework itself uses to discover config files, preloads and
5
+ * commands, and what an app reaches for to load a directory of modules.
6
+ */
7
+
8
+ import type { Dirent } from 'node:fs'
9
+ import { readdir } from 'node:fs/promises'
10
+ import { join, relative, sep } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ export interface ReadAllFilesOptions {
14
+ /** Keep only files this returns true for. Defaults to `.js`/`.ts` modules. */
15
+ filter?: (filePath: string, index: number) => boolean
16
+ /** Sort the collected paths. Defaults to a stable alphabetical order. */
17
+ sort?: (current: string, next: string) => number
18
+ /** Ignore directories whose name this returns true for. */
19
+ ignoreMissingRoot?: boolean
20
+ /** Return absolute paths instead of paths relative to the root. */
21
+ absolute?: boolean
22
+ /** Also descend into subdirectories. Defaults to true. */
23
+ recursive?: boolean
24
+ }
25
+
26
+ const DEFAULT_FILTER = (filePath: string): boolean =>
27
+ /\.(js|ts|mjs|cjs|mts|cts)$/.test(filePath) && !/\.d\.ts$/.test(filePath)
28
+
29
+ /**
30
+ * Every file under `location`, relative to it and alphabetically sorted.
31
+ *
32
+ * A missing root throws unless `ignoreMissingRoot` is set — an app that
33
+ * declares no `config/` directory is legitimate, a typo in the path is not,
34
+ * and only the caller can tell them apart.
35
+ */
36
+ export async function fsReadAll(
37
+ location: string | URL,
38
+ options: ReadAllFilesOptions = {},
39
+ ): Promise<string[]> {
40
+ const root = location instanceof URL ? location.pathname : location
41
+ const filter = options.filter ?? DEFAULT_FILTER
42
+ const recursive = options.recursive ?? true
43
+
44
+ let entries: Dirent[]
45
+ try {
46
+ entries = await readdir(root, { withFileTypes: true, recursive })
47
+ } catch (error) {
48
+ if (options.ignoreMissingRoot && isMissing(error)) return []
49
+ throw error
50
+ }
51
+
52
+ const files: string[] = []
53
+ for (const entry of entries) {
54
+ if (!entry.isFile()) continue
55
+ // `parentPath` is where recursive readdir puts the containing directory.
56
+ const absolute = join(entry.parentPath ?? root, entry.name)
57
+ const value = options.absolute ? absolute : relative(root, absolute).split(sep).join('/')
58
+ files.push(value)
59
+ }
60
+
61
+ const kept = files.filter((file, index) => filter(file, index))
62
+ return options.sort ? kept.sort(options.sort) : kept.sort()
63
+ }
64
+
65
+ export interface ImportAllFilesOptions extends ReadAllFilesOptions {
66
+ /** Turn a file path into the key it lands under. Defaults to the path minus its extension. */
67
+ transformKeys?: (key: string) => string
68
+ }
69
+
70
+ /**
71
+ * Import every file under `location`, keyed by its path minus the extension.
72
+ *
73
+ * A module with a default export contributes that export; one without
74
+ * contributes its named exports as a plain object — the same rule the config
75
+ * loader follows, and for the same reason.
76
+ */
77
+ export async function fsImportAll(
78
+ location: string | URL,
79
+ options: ImportAllFilesOptions = {},
80
+ ): Promise<Record<string, unknown>> {
81
+ const root = location instanceof URL ? location.pathname : location
82
+ const files = await fsReadAll(root, options)
83
+ const collected: Record<string, unknown> = {}
84
+
85
+ for (const file of files) {
86
+ const key = (options.transformKeys ?? stripExtension)(file)
87
+ const module: Record<string, unknown> = await import(pathToFileURL(join(root, file)).href)
88
+ collected[key] = 'default' in module ? module.default : { ...module }
89
+ }
90
+ return collected
91
+ }
92
+
93
+ function stripExtension(file: string): string {
94
+ return file.replace(/\.(js|ts|mjs|cjs|mts|cts)$/, '')
95
+ }
96
+
97
+ function isMissing(error: unknown): boolean {
98
+ return (
99
+ error !== null &&
100
+ typeof error === 'object' &&
101
+ 'code' in error &&
102
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
103
+ )
104
+ }
@@ -1,6 +1,14 @@
1
+ export { Secret } from '../env/Secret.js'
1
2
  export { base64 } from './base64.js'
3
+ export { defineStaticProperty } from './defineStaticProperty.js'
4
+ export { milliseconds, seconds } from './duration.js'
5
+ export type { ImportAllFilesOptions, ReadAllFilesOptions } from './fs.js'
6
+ export { fsImportAll, fsReadAll } from './fs.js'
2
7
  export type * from './is.js'
3
8
  export { default as is } from './is.js'
9
+ export { MessageBuilder } from './MessageBuilder.js'
4
10
  export { safeEqual } from './safeEqual.js'
11
+ export { safeTiming } from './safeTiming.js'
5
12
  export { default as string } from './string.js'
6
13
  export type * from './types.js'
14
+ export { VerificationToken } from './VerificationToken.js'
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Run `callback`, never returning before `minimumMs` has elapsed.
3
+ *
4
+ * A login that answers in 3ms for an unknown address and 80ms for a known one
5
+ * tells an attacker which addresses exist, whatever the response body says.
6
+ * Holding every path to the same floor removes that signal.
7
+ *
8
+ * The callback can call `returnEarly()` to opt out — on a path where the timing
9
+ * carries nothing, waiting is pure latency.
10
+ */
11
+ export async function safeTiming<T>(
12
+ minimumMs: number,
13
+ callback: (timing: { returnEarly(): void }) => Promise<T>,
14
+ ): Promise<T> {
15
+ const startedAt = performance.now()
16
+ let returnEarly = false
17
+
18
+ let result: T | undefined
19
+ let caught: unknown
20
+ let threw = false
21
+ try {
22
+ result = await callback({
23
+ returnEarly() {
24
+ returnEarly = true
25
+ },
26
+ })
27
+ } catch (error) {
28
+ caught = error
29
+ threw = true
30
+ }
31
+
32
+ // The floor applies to the failing path too: an error that comes back faster
33
+ // than a success is the same leak in the other direction.
34
+ if (!returnEarly) {
35
+ const remaining = minimumMs - (performance.now() - startedAt)
36
+ if (remaining > 0) {
37
+ await new Promise((resolve) => setTimeout(resolve, remaining))
38
+ }
39
+ }
40
+
41
+ if (threw) throw caught
42
+ // `result` is assigned whenever the callback returned, and we rethrew above
43
+ // when it did not.
44
+ return result as T
45
+ }