@c9up/ream 0.2.2 → 0.2.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/container/Container.d.ts +18 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +29 -0
- package/dist/container/Container.js.map +1 -1
- package/dist/env/Env.d.ts +11 -0
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +33 -17
- package/dist/env/Env.js.map +1 -1
- package/dist/env/Secret.d.ts +17 -0
- package/dist/env/Secret.d.ts.map +1 -1
- package/dist/env/Secret.js +23 -0
- package/dist/env/Secret.js.map +1 -1
- package/dist/errors/aggregate.d.ts +3 -0
- package/dist/errors/aggregate.d.ts.map +1 -1
- package/dist/errors/aggregate.js +3 -0
- package/dist/errors/aggregate.js.map +1 -1
- package/dist/events/Emitter.d.ts +18 -1
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +25 -6
- package/dist/events/Emitter.js.map +1 -1
- package/dist/helpers/MessageBuilder.d.ts +29 -0
- package/dist/helpers/MessageBuilder.d.ts.map +1 -0
- package/dist/helpers/MessageBuilder.js +63 -0
- package/dist/helpers/MessageBuilder.js.map +1 -0
- package/dist/helpers/VerificationToken.d.ts +56 -0
- package/dist/helpers/VerificationToken.d.ts.map +1 -0
- package/dist/helpers/VerificationToken.js +70 -0
- package/dist/helpers/VerificationToken.js.map +1 -0
- package/dist/helpers/defineStaticProperty.d.ts +15 -0
- package/dist/helpers/defineStaticProperty.d.ts.map +1 -0
- package/dist/helpers/defineStaticProperty.js +47 -0
- package/dist/helpers/defineStaticProperty.js.map +1 -0
- package/dist/helpers/duration.d.ts +20 -0
- package/dist/helpers/duration.d.ts.map +1 -1
- package/dist/helpers/duration.js +71 -0
- package/dist/helpers/duration.js.map +1 -1
- package/dist/helpers/fs.d.ts +39 -0
- package/dist/helpers/fs.d.ts.map +1 -0
- package/dist/helpers/fs.js +70 -0
- package/dist/helpers/fs.js.map +1 -0
- package/dist/helpers/index.d.ts +8 -0
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +7 -0
- package/dist/helpers/index.js.map +1 -1
- package/dist/helpers/safeTiming.d.ts +14 -0
- package/dist/helpers/safeTiming.d.ts.map +1 -0
- package/dist/helpers/safeTiming.js +42 -0
- package/dist/helpers/safeTiming.js.map +1 -0
- package/dist/helpers/string.d.ts +85 -0
- package/dist/helpers/string.d.ts.map +1 -1
- package/dist/helpers/string.js +127 -1
- package/dist/helpers/string.js.map +1 -1
- package/dist/http/HttpContext.d.ts +14 -1
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +16 -0
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +34 -9
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +5 -17
- package/dist/http/Request.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts +54 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +72 -3
- package/dist/router/Router.js.map +1 -1
- package/dist/session/ReadOnlyValuesStore.d.ts +11 -0
- package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -1
- package/dist/session/ReadOnlyValuesStore.js +17 -0
- package/dist/session/ReadOnlyValuesStore.js.map +1 -1
- package/dist/session/Session.d.ts +11 -3
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +15 -6
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.js +2 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/errors.d.ts +25 -0
- package/dist/session/errors.d.ts.map +1 -0
- package/dist/session/errors.js +32 -0
- package/dist/session/errors.js.map +1 -0
- package/dist/types/authenticators.d.ts +24 -0
- package/dist/types/authenticators.d.ts.map +1 -0
- package/dist/types/authenticators.js +2 -0
- package/dist/types/authenticators.js.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +9 -0
- package/dist/types/index.js.map +1 -0
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +7 -3
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/container/Container.ts +31 -0
- package/src/env/Env.ts +34 -15
- package/src/env/Secret.ts +26 -0
- package/src/errors/aggregate.ts +3 -0
- package/src/events/Emitter.ts +30 -5
- package/src/helpers/MessageBuilder.ts +69 -0
- package/src/helpers/VerificationToken.ts +93 -0
- package/src/helpers/defineStaticProperty.ts +51 -0
- package/src/helpers/duration.ts +75 -0
- package/src/helpers/fs.ts +104 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/safeTiming.ts +45 -0
- package/src/helpers/string.ts +145 -1
- package/src/http/HttpContext.ts +14 -1
- package/src/http/RedirectBuilder.ts +38 -8
- package/src/http/Request.ts +5 -16
- package/src/index.ts +11 -2
- package/src/router/Router.ts +86 -3
- package/src/session/ReadOnlyValuesStore.ts +20 -0
- package/src/session/Session.ts +16 -11
- package/src/session/SessionMiddleware.ts +2 -1
- package/src/session/errors.ts +39 -0
- package/src/types/authenticators.ts +24 -0
- package/src/types/index.ts +10 -0
package/src/events/Emitter.ts
CHANGED
|
@@ -590,17 +590,42 @@ 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
|
+
*/
|
|
597
|
+
constructor(..._args: unknown[]) {}
|
|
598
|
+
|
|
593
599
|
static eventName?: string
|
|
594
|
-
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The emitter events dispatch through. Public, as in AdonisJS: a test swaps
|
|
603
|
+
* it to capture dispatches without booting a provider.
|
|
604
|
+
*/
|
|
605
|
+
static emitter: Emitter | undefined
|
|
595
606
|
|
|
596
607
|
/** @internal Wire the emitter (called by EventsProvider). */
|
|
597
608
|
static useEmitter(emitter: Emitter): void {
|
|
598
|
-
BaseEvent.
|
|
609
|
+
BaseEvent.emitter = emitter
|
|
599
610
|
}
|
|
600
611
|
|
|
601
612
|
/** @internal Ownership-guarded reset — only clears if `emitter` is still the active one. */
|
|
602
613
|
static resetEmitter(emitter: Emitter): void {
|
|
603
|
-
if (BaseEvent.
|
|
614
|
+
if (BaseEvent.emitter === emitter) BaseEvent.emitter = undefined
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Construct and dispatch in one call — `OrderShipped.dispatch(order)`.
|
|
619
|
+
*
|
|
620
|
+
* The AdonisJS idiom, and the reason `emit()` alone is not enough: an event
|
|
621
|
+
* is almost always built only to be emitted, and naming the class twice to
|
|
622
|
+
* do it is what the static removes.
|
|
623
|
+
*/
|
|
624
|
+
static async dispatch<T extends typeof BaseEvent>(
|
|
625
|
+
this: T,
|
|
626
|
+
...args: ConstructorParameters<T>
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
await new this(...args).emit()
|
|
604
629
|
}
|
|
605
630
|
|
|
606
631
|
/**
|
|
@@ -608,9 +633,9 @@ export class BaseEvent {
|
|
|
608
633
|
* No-op if no emitter is wired (test/standalone mode).
|
|
609
634
|
*/
|
|
610
635
|
async emit(): Promise<void> {
|
|
611
|
-
if (!BaseEvent.
|
|
636
|
+
if (!BaseEvent.emitter) return
|
|
612
637
|
try {
|
|
613
|
-
await BaseEvent.
|
|
638
|
+
await BaseEvent.emitter.dispatchEvent(this)
|
|
614
639
|
} catch (err) {
|
|
615
640
|
process.stderr.write(`[events] dispatch error for ${this.constructor.name}: ${err}\n`)
|
|
616
641
|
}
|
|
@@ -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
|
+
}
|
package/src/helpers/duration.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/helpers/string.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
2
2
|
import bytes from './bytes.js'
|
|
3
|
+
import { milliseconds, seconds } from './duration.js'
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Internal helpers
|
|
@@ -79,6 +80,135 @@ export function titleCase(s: string): string {
|
|
|
79
80
|
.join(' ')
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Space-separated lowercase words.
|
|
85
|
+
* @example noCase('userName') // 'user name'
|
|
86
|
+
*/
|
|
87
|
+
export function noCase(s: string): string {
|
|
88
|
+
return splitWords(s).join(' ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Dot-separated lowercase words.
|
|
93
|
+
* @example dotCase('userName') // 'user.name'
|
|
94
|
+
*/
|
|
95
|
+
export function dotCase(s: string): string {
|
|
96
|
+
return splitWords(s).join('.')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every word capitalised, space separated.
|
|
101
|
+
* @example capitalCase('user_name') // 'User Name'
|
|
102
|
+
*/
|
|
103
|
+
export function capitalCase(s: string): string {
|
|
104
|
+
return titleCase(s)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* First word capitalised, the rest lowercase.
|
|
109
|
+
* @example sentenceCase('userName') // 'User name'
|
|
110
|
+
*/
|
|
111
|
+
export function sentenceCase(s: string): string {
|
|
112
|
+
const words = splitWords(s)
|
|
113
|
+
if (words.length === 0) return ''
|
|
114
|
+
const [first, ...rest] = words
|
|
115
|
+
return [(first ?? '').charAt(0).toUpperCase() + (first ?? '').slice(1), ...rest].join(' ')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Join values the way a sentence does.
|
|
120
|
+
* @example sentence(['a', 'b', 'c']) // 'a, b, and c'
|
|
121
|
+
*/
|
|
122
|
+
export function sentence(
|
|
123
|
+
values: readonly unknown[],
|
|
124
|
+
options: { separator?: string; pairSeparator?: string; lastSeparator?: string } = {},
|
|
125
|
+
): string {
|
|
126
|
+
const parts = values.map((value) => String(value))
|
|
127
|
+
if (parts.length === 0) return ''
|
|
128
|
+
if (parts.length === 1) return parts[0] ?? ''
|
|
129
|
+
const separator = options.separator ?? ', '
|
|
130
|
+
const pairSeparator = options.pairSeparator ?? ' and '
|
|
131
|
+
const lastSeparator = options.lastSeparator ?? ', and '
|
|
132
|
+
if (parts.length === 2) return parts.join(pairSeparator)
|
|
133
|
+
return parts.slice(0, -1).join(separator) + lastSeparator + parts[parts.length - 1]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wrap text at `width`, breaking on spaces.
|
|
138
|
+
*
|
|
139
|
+
* A word longer than the width is left whole rather than cut: breaking an
|
|
140
|
+
* identifier or a URL mid-token is worse than an over-long line.
|
|
141
|
+
*/
|
|
142
|
+
export function wordWrap(
|
|
143
|
+
value: string,
|
|
144
|
+
options: { width: number; indent?: string; newLine?: string },
|
|
145
|
+
): string {
|
|
146
|
+
const width = Math.max(1, Math.floor(options.width))
|
|
147
|
+
const indent = options.indent ?? ''
|
|
148
|
+
const newLine = options.newLine ?? '\n'
|
|
149
|
+
const lines: string[] = []
|
|
150
|
+
let line = ''
|
|
151
|
+
for (const word of value.split(/\s+/).filter(Boolean)) {
|
|
152
|
+
if (line.length === 0) {
|
|
153
|
+
line = word
|
|
154
|
+
} else if (line.length + 1 + word.length <= width) {
|
|
155
|
+
line += ` ${word}`
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(line)
|
|
158
|
+
line = word
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (line.length > 0) lines.push(line)
|
|
162
|
+
return lines.map((entry, index) => (index === 0 ? entry : indent + entry)).join(newLine)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pad every column to the same width.
|
|
167
|
+
* @example justify(['a', 'bb'], { width: 4 }) // ['a ', 'bb ']
|
|
168
|
+
*/
|
|
169
|
+
export function justify(
|
|
170
|
+
columns: readonly string[],
|
|
171
|
+
options: { width: number; align?: 'left' | 'right'; indent?: string },
|
|
172
|
+
): string[] {
|
|
173
|
+
const width = Math.max(0, Math.floor(options.width))
|
|
174
|
+
const indent = options.indent ?? ''
|
|
175
|
+
return columns.map((column) => {
|
|
176
|
+
const padded =
|
|
177
|
+
options.align === 'right' ? column.padStart(width, ' ') : column.padEnd(width, ' ')
|
|
178
|
+
return indent + padded
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A RFC 4122 v4 UUID. */
|
|
183
|
+
export function uuid(): string {
|
|
184
|
+
return randomUUID()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `ordinal` under the name AdonisJS also ships it as. */
|
|
188
|
+
export function ordinalize(value: number): string {
|
|
189
|
+
return ordinal(value)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `sentence` under the name AdonisJS also ships it as. */
|
|
193
|
+
export function toSentence(
|
|
194
|
+
values: readonly unknown[],
|
|
195
|
+
options?: { separator?: string; pairSeparator?: string; lastSeparator?: string },
|
|
196
|
+
): string {
|
|
197
|
+
return sentence(values, options)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `escapeHTML` under the name @poppinss/string ships it as. */
|
|
201
|
+
export function htmlEscape(value: string): string {
|
|
202
|
+
return escapeHTML(value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Pluralise `word`, honouring a count: `pluralize('item', 1)` stays singular.
|
|
207
|
+
*/
|
|
208
|
+
export function pluralize(word: string, count?: number): string {
|
|
209
|
+
return count === 1 ? singular(word) : plural(word)
|
|
210
|
+
}
|
|
211
|
+
|
|
82
212
|
/**
|
|
83
213
|
* Converts a snake_case/camelCase string to a human-readable form.
|
|
84
214
|
* @example humanize('user_name') // 'User name'
|
|
@@ -466,6 +596,20 @@ const string = {
|
|
|
466
596
|
prettyHrTime,
|
|
467
597
|
toUnixSlash,
|
|
468
598
|
interpolate,
|
|
599
|
+
noCase,
|
|
600
|
+
dotCase,
|
|
601
|
+
capitalCase,
|
|
602
|
+
sentenceCase,
|
|
603
|
+
sentence,
|
|
604
|
+
toSentence,
|
|
605
|
+
wordWrap,
|
|
606
|
+
justify,
|
|
607
|
+
uuid,
|
|
608
|
+
ordinalize,
|
|
609
|
+
htmlEscape,
|
|
610
|
+
pluralize,
|
|
611
|
+
seconds,
|
|
612
|
+
milliseconds,
|
|
469
613
|
}
|
|
470
614
|
|
|
471
615
|
export default string
|