@c9up/ream 0.2.1 → 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/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +15 -1
- package/dist/Ignitor.js.map +1 -1
- 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 +27 -0
- package/dist/errors/aggregate.d.ts.map +1 -0
- package/dist/errors/aggregate.js +27 -0
- package/dist/errors/aggregate.js.map +1 -0
- 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/events/services/main.d.ts.map +1 -1
- package/dist/events/services/main.js +3 -10
- package/dist/events/services/main.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/Exception.d.ts +76 -6
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +178 -85
- package/dist/http/Exception.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 +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- 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 +73 -4
- package/dist/router/Router.js.map +1 -1
- package/dist/services/app.d.ts.map +1 -1
- package/dist/services/app.js +3 -21
- package/dist/services/app.js.map +1 -1
- package/dist/services/console.d.ts.map +1 -1
- package/dist/services/console.js +3 -19
- package/dist/services/console.js.map +1 -1
- package/dist/services/createServiceProxy.d.ts +19 -0
- package/dist/services/createServiceProxy.d.ts.map +1 -0
- package/dist/services/createServiceProxy.js +44 -0
- package/dist/services/createServiceProxy.js.map +1 -0
- package/dist/services/router.d.ts.map +1 -1
- package/dist/services/router.js +3 -12
- package/dist/services/router.js.map +1 -1
- package/dist/services/server.d.ts.map +1 -1
- package/dist/services/server.js +3 -22
- package/dist/services/server.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/container.d.ts +64 -0
- package/dist/types/container.d.ts.map +1 -0
- package/dist/types/container.js +23 -0
- package/dist/types/container.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 +13 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +15 -1
- 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 +36 -0
- package/src/events/Emitter.ts +30 -5
- package/src/events/services/main.ts +6 -12
- 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/Exception.ts +225 -99
- 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 +24 -2
- package/src/router/Router.ts +87 -4
- package/src/services/app.ts +6 -23
- package/src/services/console.ts +6 -21
- package/src/services/createServiceProxy.ts +47 -0
- package/src/services/router.ts +6 -14
- package/src/services/server.ts +6 -24
- 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/container.ts +66 -0
- package/src/types/index.ts +10 -0
package/src/env/Secret.ts
CHANGED
|
@@ -34,8 +34,34 @@ export class Secret<T> {
|
|
|
34
34
|
return this.#keyword
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Redacted placeholder for implicit coercion.
|
|
39
|
+
*
|
|
40
|
+
* `valueOf` is what `+secret` and `secret == 'x'` reach for — without it,
|
|
41
|
+
* they fall back to the raw value and the secret leaks through arithmetic
|
|
42
|
+
* and loose comparison.
|
|
43
|
+
*/
|
|
44
|
+
valueOf(): string {
|
|
45
|
+
return this.#keyword
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Redacted placeholder for locale-aware string conversion. */
|
|
49
|
+
toLocaleString(): string {
|
|
50
|
+
return this.#keyword
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
/** Release the underlying value — the one deliberate way to read the secret. */
|
|
38
54
|
release(): T {
|
|
39
55
|
return this.#value
|
|
40
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transform the wrapped value, keeping it wrapped.
|
|
60
|
+
*
|
|
61
|
+
* The point is that the intermediate never escapes: `secret.map(v => v.trim())`
|
|
62
|
+
* hands back another Secret rather than a bare string that could be logged.
|
|
63
|
+
*/
|
|
64
|
+
map<R>(transform: (value: T) => R): Secret<R> {
|
|
65
|
+
return new Secret(transform(this.#value), this.#keyword)
|
|
66
|
+
}
|
|
41
67
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's exceptions, in one bag.
|
|
3
|
+
*
|
|
4
|
+
* AdonisJS exposes `import { errors } from '@adonisjs/core'` (and one bag per
|
|
5
|
+
* module) so an app can branch on `error instanceof errors.E_ROUTE_NOT_FOUND`
|
|
6
|
+
* without importing each class by name. The flat named exports stay — this is
|
|
7
|
+
* the shape a migrating app already writes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EnvValidationException } from '../env/Env.js'
|
|
11
|
+
import {
|
|
12
|
+
E_FORBIDDEN,
|
|
13
|
+
E_HTTP_EXCEPTION,
|
|
14
|
+
E_HTTP_REQUEST_ABORTED,
|
|
15
|
+
E_ROUTE_NOT_FOUND,
|
|
16
|
+
E_ROW_NOT_FOUND,
|
|
17
|
+
E_UNAUTHORIZED,
|
|
18
|
+
E_VALIDATION_ERROR,
|
|
19
|
+
} from '../http/Exception.js'
|
|
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'
|
|
22
|
+
|
|
23
|
+
export const errors = {
|
|
24
|
+
E_FORBIDDEN,
|
|
25
|
+
E_HTTP_EXCEPTION,
|
|
26
|
+
E_HTTP_REQUEST_ABORTED,
|
|
27
|
+
E_INSECURE_APP_KEY,
|
|
28
|
+
E_INVALID_ENV_VARIABLES: EnvValidationException,
|
|
29
|
+
E_MISSING_APP_KEY,
|
|
30
|
+
E_ROUTE_NOT_FOUND,
|
|
31
|
+
E_ROW_NOT_FOUND,
|
|
32
|
+
E_SESSION_NOT_READY,
|
|
33
|
+
E_SESSION_TAGGING_NOT_SUPPORTED,
|
|
34
|
+
E_UNAUTHORIZED,
|
|
35
|
+
E_VALIDATION_ERROR,
|
|
36
|
+
} as const
|
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
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Populated by `EventsProvider.boot()`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { createServiceProxy } from '../../services/createServiceProxy.js'
|
|
12
13
|
import type { Emitter } from '../Emitter.js'
|
|
13
14
|
|
|
14
15
|
let instance: Emitter | undefined
|
|
@@ -23,17 +24,10 @@ export function getEmitter(): Emitter | undefined {
|
|
|
23
24
|
return instance
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const emitter: Emitter =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'Check that `@c9up/ream/events/provider` is listed in your reamrc.ts providers.',
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
const value = Reflect.get(instance, prop, instance)
|
|
35
|
-
return typeof value === 'function' ? value.bind(instance) : value
|
|
36
|
-
},
|
|
37
|
-
})
|
|
27
|
+
const emitter: Emitter = createServiceProxy<Emitter>(
|
|
28
|
+
() => instance,
|
|
29
|
+
'[ream:events] Emitter singleton accessed before EventsProvider.boot() ran. ' +
|
|
30
|
+
'Check that `@c9up/ream/events/provider` is listed in your reamrc.ts providers.',
|
|
31
|
+
)
|
|
38
32
|
|
|
39
33
|
export default emitter
|
|
@@ -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
|
+
}
|