@c9up/ream 0.2.8 → 0.2.10
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/README.md +1 -0
- package/dist/Application.d.ts +26 -6
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +40 -12
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Ignitor.d.ts +13 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +149 -26
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +8 -0
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +46 -1
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/commands/Migrate.d.ts +9 -0
- package/dist/commands/Migrate.d.ts.map +1 -0
- package/dist/commands/Migrate.js +16 -0
- package/dist/commands/Migrate.js.map +1 -0
- package/dist/commands/MigrateBase.d.ts +27 -0
- package/dist/commands/MigrateBase.d.ts.map +1 -0
- package/dist/commands/MigrateBase.js +77 -0
- package/dist/commands/MigrateBase.js.map +1 -0
- package/dist/commands/MigrateRollback.d.ts +9 -0
- package/dist/commands/MigrateRollback.d.ts.map +1 -0
- package/dist/commands/MigrateRollback.js +16 -0
- package/dist/commands/MigrateRollback.js.map +1 -0
- package/dist/commands/MigrateStatus.d.ts +9 -0
- package/dist/commands/MigrateStatus.d.ts.map +1 -0
- package/dist/commands/MigrateStatus.js +19 -0
- package/dist/commands/MigrateStatus.js.map +1 -0
- package/dist/commands/ScheduleList.d.ts +18 -0
- package/dist/commands/ScheduleList.d.ts.map +1 -0
- package/dist/commands/ScheduleList.js +55 -0
- package/dist/commands/ScheduleList.js.map +1 -0
- package/dist/commands/ScheduleRun.d.ts +19 -0
- package/dist/commands/ScheduleRun.d.ts.map +1 -0
- package/dist/commands/ScheduleRun.js +61 -0
- package/dist/commands/ScheduleRun.js.map +1 -0
- package/dist/commands/index.d.ts +15 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +63 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/scheduler_resolver.d.ts +12 -0
- package/dist/commands/scheduler_resolver.d.ts.map +1 -0
- package/dist/commands/scheduler_resolver.js +19 -0
- package/dist/commands/scheduler_resolver.js.map +1 -0
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +2 -1
- package/dist/env/Env.js.map +1 -1
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +5 -1
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/nodeEnv.d.ts +24 -0
- package/dist/env/nodeEnv.d.ts.map +1 -0
- package/dist/env/nodeEnv.js +40 -0
- package/dist/env/nodeEnv.js.map +1 -0
- package/dist/errors/ReamError.d.ts +6 -1
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +8 -2
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +2 -1
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +18 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +71 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +11 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +46 -17
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +14 -5
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -1
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts +14 -0
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +62 -14
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/config.d.ts +29 -0
- package/dist/scheduler/config.d.ts.map +1 -0
- package/dist/scheduler/config.js +14 -0
- package/dist/scheduler/config.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +3 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/locks/LockBackend.d.ts +13 -3
- package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/LockBackend.js +20 -4
- package/dist/scheduler/locks/LockBackend.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
- package/dist/scheduler/locks/RedisLockBackend.js +83 -0
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
- package/dist/scheduler/locks/locks.d.ts +39 -0
- package/dist/scheduler/locks/locks.d.ts.map +1 -0
- package/dist/scheduler/locks/locks.js +42 -0
- package/dist/scheduler/locks/locks.js.map +1 -0
- package/dist/scheduler/locks/quasar.d.ts +17 -0
- package/dist/scheduler/locks/quasar.d.ts.map +1 -0
- package/dist/scheduler/locks/quasar.js +51 -0
- package/dist/scheduler/locks/quasar.js.map +1 -0
- package/dist/security/CookieSigner.d.ts +16 -13
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +33 -14
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/services/encryption.d.ts +12 -2
- package/dist/services/encryption.d.ts.map +1 -1
- package/dist/services/encryption.js +11 -1
- package/dist/services/encryption.js.map +1 -1
- package/dist/session/Session.d.ts +8 -7
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +1 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +1 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +1 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +9 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +42 -15
- package/src/ErrorBoundary.ts +3 -1
- package/src/Ignitor.ts +160 -30
- package/src/bodyparser/MultipartFile.ts +48 -1
- package/src/commands/Migrate.ts +18 -0
- package/src/commands/MigrateBase.ts +81 -0
- package/src/commands/MigrateRollback.ts +18 -0
- package/src/commands/MigrateStatus.ts +22 -0
- package/src/commands/ScheduleList.ts +66 -0
- package/src/commands/ScheduleRun.ts +65 -0
- package/src/commands/index.ts +73 -0
- package/src/commands/scheduler_resolver.ts +23 -0
- package/src/env/Env.ts +2 -1
- package/src/env/loadEnvFiles.ts +5 -1
- package/src/env/nodeEnv.ts +38 -0
- package/src/errors/ReamError.ts +9 -2
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +2 -1
- package/src/http/Request.ts +73 -8
- package/src/http/Response.ts +58 -19
- package/src/index.ts +8 -1
- package/src/router/Router.ts +14 -5
- package/src/rpc/RpcRouter.ts +2 -1
- package/src/scheduler/ScheduleProvider.ts +65 -15
- package/src/scheduler/config.ts +34 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/locks/LockBackend.ts +26 -3
- package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
- package/src/scheduler/locks/RedisLockBackend.ts +114 -0
- package/src/scheduler/locks/locks.ts +49 -0
- package/src/scheduler/locks/quasar.ts +67 -0
- package/src/security/CookieSigner.ts +36 -14
- package/src/services/encryption.ts +12 -2
- package/src/session/Session.ts +8 -7
- package/src/session/SessionMiddleware.ts +4 -1
- package/src/testing/ApiResponse.ts +1 -2
- package/src/testing/RequestBuilder.ts +1 -2
- package/src/utils/Macroable.ts +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis lock backend — the one a second replica needs.
|
|
3
|
+
*
|
|
4
|
+
* Without a shared lock, every instance of an application runs every
|
|
5
|
+
* scheduled task on every tick: a nightly invoice run goes out N times,
|
|
6
|
+
* a reminder email arrives N times, and nothing in the logs says so.
|
|
7
|
+
* `MemoryLockBackend` does not close that gap — each process keeps its
|
|
8
|
+
* own map — and the scheduler locks nothing at all by default.
|
|
9
|
+
*
|
|
10
|
+
* No import of a Redis package. The client is taken structurally — `set`
|
|
11
|
+
* and `eval` — so this works against a `@c9up/quasar` connection without
|
|
12
|
+
* ream depending on quasar, which is an optional peer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { assertValidTtl, type LockBackend } from './LockBackend.js'
|
|
17
|
+
|
|
18
|
+
/** The commands this issues. Any client answering them will do. */
|
|
19
|
+
export interface LockRedisClient {
|
|
20
|
+
/** `SET key value PX <ttl> NX` — answers null when the key already exists. */
|
|
21
|
+
set(key: string, value: string, ...args: unknown[]): Promise<unknown>
|
|
22
|
+
/** `EVAL script numKeys ...` — used for the compare-and-delete release. */
|
|
23
|
+
eval(script: string, numKeys: number, ...args: unknown[]): Promise<unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How the backend gets its client: the client itself, or something that
|
|
28
|
+
* answers with one.
|
|
29
|
+
*
|
|
30
|
+
* The resolver form is what a config file needs. `config/scheduler.ts` is
|
|
31
|
+
* read before the application boots, so the connection does not exist yet
|
|
32
|
+
* and cannot be awaited there — a function defers the lookup to the first
|
|
33
|
+
* task that fires.
|
|
34
|
+
*/
|
|
35
|
+
export type LockRedisResolver = LockRedisClient | (() => LockRedisClient | Promise<LockRedisClient>)
|
|
36
|
+
|
|
37
|
+
export interface RedisLockBackendOptions {
|
|
38
|
+
/** Key prefix. Default `"ream:schedule:lock:"`. */
|
|
39
|
+
prefix?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Release only a lease we still hold.
|
|
44
|
+
*
|
|
45
|
+
* A plain `DEL` is the classic way to break this: a task that outlives its
|
|
46
|
+
* TTL loses the lock, another instance acquires it and starts running, and
|
|
47
|
+
* the first one then deletes THAT instance's lease on its way out — leaving
|
|
48
|
+
* the name free while a task is still running under it. Comparing the token
|
|
49
|
+
* before deleting makes the release a no-op once the lease is no longer ours.
|
|
50
|
+
*/
|
|
51
|
+
const RELEASE = `
|
|
52
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
53
|
+
return redis.call('del', KEYS[1])
|
|
54
|
+
end
|
|
55
|
+
return 0
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
export class RedisLockBackend implements LockBackend {
|
|
59
|
+
readonly #source: LockRedisResolver
|
|
60
|
+
readonly #prefix: string
|
|
61
|
+
#resolved: Promise<LockRedisClient> | undefined
|
|
62
|
+
/** Our token per held name, so a release can prove the lease is ours. */
|
|
63
|
+
readonly #tokens = new Map<string, string>()
|
|
64
|
+
|
|
65
|
+
constructor(client: LockRedisResolver, options: RedisLockBackendOptions = {}) {
|
|
66
|
+
this.#source = client
|
|
67
|
+
this.#prefix = options.prefix ?? 'ream:schedule:lock:'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The client, resolved once and kept. */
|
|
71
|
+
#client(): Promise<LockRedisClient> {
|
|
72
|
+
if (!this.#resolved) {
|
|
73
|
+
this.#resolved = Promise.resolve(
|
|
74
|
+
typeof this.#source === 'function' ? this.#source() : this.#source,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return this.#resolved
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#key(name: string): string {
|
|
81
|
+
return `${this.#prefix}${name}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `SET key token PX ttl NX` — one round trip, atomic on the server, which
|
|
86
|
+
* is what makes the decision the same for every instance asking at once.
|
|
87
|
+
*
|
|
88
|
+
* The TTL is what recovers the lock after a crash: an instance that dies
|
|
89
|
+
* holding it never releases, and the lease simply expires.
|
|
90
|
+
*/
|
|
91
|
+
async acquire(name: string, ttlMs: number): Promise<boolean> {
|
|
92
|
+
assertValidTtl(ttlMs)
|
|
93
|
+
const client = await this.#client()
|
|
94
|
+
const token = randomUUID()
|
|
95
|
+
// Redis takes an integer number of milliseconds; a fractional TTL is a
|
|
96
|
+
// protocol error, not a rounding detail.
|
|
97
|
+
const result = await client.set(this.#key(name), token, 'PX', Math.ceil(ttlMs), 'NX')
|
|
98
|
+
if (result === null || result === undefined) return false
|
|
99
|
+
this.#tokens.set(name, token)
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Idempotent, as the contract requires: releasing a name this instance
|
|
105
|
+
* never acquired — or one whose lease has since expired — does nothing.
|
|
106
|
+
*/
|
|
107
|
+
async release(name: string): Promise<void> {
|
|
108
|
+
const token = this.#tokens.get(name)
|
|
109
|
+
if (token === undefined) return
|
|
110
|
+
this.#tokens.delete(name)
|
|
111
|
+
const client = await this.#client()
|
|
112
|
+
await client.eval(RELEASE, 1, this.#key(name), token)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lock backends a scheduler config names.
|
|
3
|
+
*
|
|
4
|
+
* // config/scheduler.ts
|
|
5
|
+
* import { defineConfig, locks } from '@c9up/ream'
|
|
6
|
+
*
|
|
7
|
+
* export default defineConfig({
|
|
8
|
+
* lock: locks.redis({ connection: 'main' }),
|
|
9
|
+
* })
|
|
10
|
+
*
|
|
11
|
+
* Without a lock the scheduler runs every task on every instance, which is
|
|
12
|
+
* correct while there is one, and duplicates every run once there are two.
|
|
13
|
+
*
|
|
14
|
+
* Factories are lazy: the backend is built when the scheduler is, so naming
|
|
15
|
+
* a Redis lock in a config that runs single-instance costs nothing until it
|
|
16
|
+
* is selected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { LockBackend } from './LockBackend.js'
|
|
20
|
+
import { MemoryLockBackend } from './MemoryLockBackend.js'
|
|
21
|
+
import { quasarConnection } from './quasar.js'
|
|
22
|
+
import { type LockRedisResolver, RedisLockBackend } from './RedisLockBackend.js'
|
|
23
|
+
|
|
24
|
+
/** A lock backend, built on first use. */
|
|
25
|
+
export type LockBackendFactory = () => LockBackend
|
|
26
|
+
|
|
27
|
+
export const locks = {
|
|
28
|
+
/**
|
|
29
|
+
* In this process's memory. Two processes each keep their own, so this
|
|
30
|
+
* bounds nothing across replicas — it is for a single-process deployment
|
|
31
|
+
* and for tests.
|
|
32
|
+
*/
|
|
33
|
+
memory(): LockBackendFactory {
|
|
34
|
+
return () => new MemoryLockBackend()
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Redis. `connection` takes a client, a function answering one, or the
|
|
39
|
+
* NAME of a `@c9up/quasar` connection — the last of which is resolved at
|
|
40
|
+
* runtime without ream importing quasar, which stays an optional peer.
|
|
41
|
+
*/
|
|
42
|
+
redis(options: { connection: LockRedisResolver | string; prefix?: string }): LockBackendFactory {
|
|
43
|
+
const client: LockRedisResolver =
|
|
44
|
+
typeof options.connection === 'string'
|
|
45
|
+
? quasarConnection(options.connection)
|
|
46
|
+
: options.connection
|
|
47
|
+
return () => new RedisLockBackend(client, { prefix: options.prefix })
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a Redis connection by name, from `@c9up/quasar`.
|
|
3
|
+
*
|
|
4
|
+
* Ream does not depend on quasar: it is an optional peer, and this module
|
|
5
|
+
* never imports it statically. The specifier is built at runtime so the
|
|
6
|
+
* TypeScript build stays free of it too — a hard type import would make
|
|
7
|
+
* ream unbuildable for anyone scheduling on a single instance.
|
|
8
|
+
*
|
|
9
|
+
* Same bridge the session store uses, for the same reason.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LockRedisClient } from './RedisLockBackend.js'
|
|
13
|
+
|
|
14
|
+
/** The slice of quasar's manager this needs: a connection, by name. */
|
|
15
|
+
interface ConnectionSource {
|
|
16
|
+
connection(name?: string): unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isConnectionSource(value: unknown): value is ConnectionSource {
|
|
20
|
+
return (
|
|
21
|
+
typeof value === 'object' &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
typeof Reflect.get(value, 'connection') === 'function'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The commands the backend issues, checked before use: a connection missing
|
|
29
|
+
* one would fail on the first task that fires, far from the cause.
|
|
30
|
+
*/
|
|
31
|
+
function isLockRedisClient(value: unknown): value is LockRedisClient {
|
|
32
|
+
if (typeof value !== 'object' || value === null) return false
|
|
33
|
+
return ['set', 'eval'].every((name) => typeof Reflect.get(value, name) === 'function')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A resolver for `locks.redis({ connection })` — quasar is loaded when the
|
|
38
|
+
* first task fires, not while the config file is read.
|
|
39
|
+
*/
|
|
40
|
+
export function quasarConnection(name?: string): () => Promise<LockRedisClient> {
|
|
41
|
+
return async () => {
|
|
42
|
+
const specifier = '@c9up/quasar/services/main'
|
|
43
|
+
let loaded: unknown
|
|
44
|
+
try {
|
|
45
|
+
loaded = await import(/* @vite-ignore */ specifier)
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Ream: the scheduler lock asks for the '${name ?? 'default'}' quasar connection, but @c9up/quasar is not installed.\n` +
|
|
49
|
+
' pnpm add @c9up/quasar',
|
|
50
|
+
{ cause },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manager = isConnectionSource(loaded) ? loaded : Reflect.get(Object(loaded), 'default')
|
|
55
|
+
if (!isConnectionSource(manager)) {
|
|
56
|
+
throw new Error('Ream: @c9up/quasar/services/main did not expose a connection() manager')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const connection = manager.connection(name)
|
|
60
|
+
if (!isLockRedisClient(connection)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Ream: quasar connection '${name ?? 'default'}' does not carry the commands a scheduler lock needs`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return connection
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
* Cookie signing and encryption.
|
|
3
3
|
* Uses Rust NAPI via crypto facade when available.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* The cipher is `aes-256-gcm`, where authentication is part of the cipher
|
|
6
|
+
* rather than a second primitive bolted on: encrypt-then-MAC assembled by hand
|
|
7
|
+
* is a well-known source of subtle breaks.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* That is upstream's own current choice, not a divergence from it — v7 ships
|
|
10
|
+
* AES-256-GCM, ChaCha20-Poly1305 and AES-SIV as first-class drivers, and its
|
|
11
|
+
* documented example configures `aes256gcm` as the default. The CBC + HMAC
|
|
12
|
+
* construction lives on there under the name `legacy`, described in its own
|
|
13
|
+
* source as maintaining compatibility with the OLD v6 format.
|
|
12
14
|
*
|
|
13
|
-
* The consequence, stated plainly: a cookie encrypted by
|
|
14
|
-
* be decrypted here
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The consequence, stated plainly: a cookie encrypted by a v6-era application
|
|
16
|
+
* cannot be decrypted here — those users are signed out once, at deploy. The
|
|
17
|
+
* same is true of upstream v7 unless the legacy driver is configured. Should
|
|
18
|
+
* that migration path ever be wanted, the answer is the one taken there: an
|
|
19
|
+
* additional, explicitly named legacy reader — never a downgrade of the
|
|
20
|
+
* default.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
|
|
@@ -29,15 +32,29 @@ export class E_MISSING_APP_KEY extends Error {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
/** Raised when the APP_KEY is too short
|
|
35
|
+
/** Raised when the APP_KEY is too short, or is a value everyone already has. */
|
|
33
36
|
export class E_INSECURE_APP_KEY extends Error {
|
|
34
37
|
readonly code = 'E_INSECURE_APP_KEY' as const
|
|
35
|
-
constructor() {
|
|
36
|
-
super(
|
|
38
|
+
constructor(reason = 'it must be at least 16 characters') {
|
|
39
|
+
super(`APP_KEY is not usable — ${reason}.`)
|
|
37
40
|
this.name = 'E_INSECURE_APP_KEY'
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Keys that appear in scaffolding, documentation and tutorials.
|
|
46
|
+
*
|
|
47
|
+
* A key anyone can read is not a secret: with it, cookies, sessions, CSRF
|
|
48
|
+
* tokens and signed URLs can all be forged. Refusing them costs a developer
|
|
49
|
+
* one `ream generate:key` and costs an attacker everything.
|
|
50
|
+
*/
|
|
51
|
+
const PUBLICLY_KNOWN_KEYS = new Set([
|
|
52
|
+
'change-me-to-a-unique-32+-byte-secret!!',
|
|
53
|
+
'change-me',
|
|
54
|
+
'your-app-key-here',
|
|
55
|
+
'secret',
|
|
56
|
+
])
|
|
57
|
+
|
|
41
58
|
/** The envelope carried inside an encrypted or signed value. */
|
|
42
59
|
interface Envelope {
|
|
43
60
|
/**
|
|
@@ -61,6 +78,11 @@ export class CookieSigner {
|
|
|
61
78
|
// configuration error, not something to discover at decrypt time.
|
|
62
79
|
if (typeof secret !== 'string' || secret.length === 0) throw new E_MISSING_APP_KEY()
|
|
63
80
|
if (secret.length < 16) throw new E_INSECURE_APP_KEY()
|
|
81
|
+
if (PUBLICLY_KNOWN_KEYS.has(secret.trim().toLowerCase())) {
|
|
82
|
+
throw new E_INSECURE_APP_KEY(
|
|
83
|
+
'it is a placeholder from the scaffolding, which everyone can read. Run `ream generate:key`',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
64
86
|
this.#secret = secret
|
|
65
87
|
// Derive a 32-byte key for AES-256-GCM via HKDF-like derivation
|
|
66
88
|
this.#keyBuffer = Buffer.from(createHmac('sha256', secret).update('cookie-key').digest())
|
|
@@ -14,8 +14,18 @@ import { createServiceProxy } from './createServiceProxy.js'
|
|
|
14
14
|
|
|
15
15
|
let instance: CookieSigner | undefined
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* @internal Set the signer, or clear it with `undefined`.
|
|
19
|
+
*
|
|
20
|
+
* Called by the Ignitor for EVERY boot, key or no key. Leaving a previous
|
|
21
|
+
* application's signer in place when this one has none is the dangerous
|
|
22
|
+
* reading: `import encryption from '.../services/encryption'` would answer
|
|
23
|
+
* with a key this application never configured, and sign its cookies with it.
|
|
24
|
+
* Last boot owns the locator — the same rule as `setApp` / `setRouter` /
|
|
25
|
+
* `setServer` beside it. A process running two applications at once should
|
|
26
|
+
* resolve `'encryption'` from its own container, which is per-application.
|
|
27
|
+
*/
|
|
28
|
+
export function setEncryption(signer: CookieSigner | undefined): void {
|
|
19
29
|
instance = signer
|
|
20
30
|
}
|
|
21
31
|
|
package/src/session/Session.ts
CHANGED
|
@@ -78,16 +78,17 @@ const INTENDED_URL_KEY = '__intended_url'
|
|
|
78
78
|
|
|
79
79
|
export interface SessionConfig {
|
|
80
80
|
/**
|
|
81
|
-
* Which store to use
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Which store to use — a key of {@link stores}, or a driver name on its own.
|
|
82
|
+
* Read from the environment in the generated config, so a deployment picks
|
|
83
|
+
* its backend without editing a file.
|
|
84
84
|
*/
|
|
85
|
-
driver?: string
|
|
86
|
-
/** AdonisJS spelling of {@link driver}. */
|
|
87
85
|
store?: string
|
|
86
|
+
/** Ream's older spelling of {@link store}. Both are accepted; `store` wins. */
|
|
87
|
+
driver?: string
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
* The selected entry supplies the
|
|
89
|
+
* The stores this application can use, by name:
|
|
90
|
+
* `{ store: 'redis', stores: { redis: … } }`. The selected entry supplies the
|
|
91
|
+
* driver name and its options.
|
|
91
92
|
*/
|
|
92
93
|
stores?: Record<string, { driver: string } & Record<string, unknown>>
|
|
93
94
|
/** `file` driver only — the directory session files are written to. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
9
10
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
10
11
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
11
12
|
import { CookieDriver } from './drivers/CookieDriver.js'
|
|
@@ -104,7 +105,9 @@ export default class SessionMiddleware {
|
|
|
104
105
|
const cookieName = this.#config.cookieName
|
|
105
106
|
const maxAge = this.#config.maxAge
|
|
106
107
|
const rolling = this.#config.rolling
|
|
107
|
-
|
|
108
|
+
// Normalised: `NODE_ENV=prod` reaching here as "not production" ships
|
|
109
|
+
// the session cookie without Secure, over plain HTTP.
|
|
110
|
+
const isProduction = currentNodeEnv() === 'production'
|
|
108
111
|
|
|
109
112
|
// Read the session cookie — pre-parsed by the Rust HyperServer.
|
|
110
113
|
//
|
|
@@ -270,7 +270,7 @@ const responseGetters = new Map<string, (this: ApiResponse, res: ApiResponse) =>
|
|
|
270
270
|
* `headers()`, `text()` — and `body()` returns the content-type-parsed body,
|
|
271
271
|
* exactly like helix. `json()` is kept as an additive convenience.
|
|
272
272
|
*/
|
|
273
|
-
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the
|
|
273
|
+
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the StatusAssertions interface below types the status shortcuts attached to the prototype from STATUS_ASSERTIONS — every member is implemented, so the merge is safe.
|
|
274
274
|
export class ApiResponse {
|
|
275
275
|
readonly #raw: TestResponse
|
|
276
276
|
readonly #method: string
|
|
@@ -659,7 +659,6 @@ export type StatusAssertions<T> = {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
// Merge the generated status-shortcut methods into the ApiResponse type.
|
|
662
|
-
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached to the prototype from STATUS_ASSERTIONS — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
|
|
663
662
|
export interface ApiResponse extends StatusAssertions<ApiResponse> {}
|
|
664
663
|
|
|
665
664
|
/** Apply registered macros/getters onto a freshly built response. */
|
|
@@ -174,7 +174,7 @@ const requestSerializers = new Map<
|
|
|
174
174
|
(data: unknown) => { body: Buffer; contentType: string }
|
|
175
175
|
>()
|
|
176
176
|
|
|
177
|
-
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the
|
|
177
|
+
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the StatusAssertions interface below types the status shortcuts attached from STATUS_ASSERTIONS at load — every member is implemented, so the merge is safe.
|
|
178
178
|
export class RequestBuilder {
|
|
179
179
|
#sender: HttpSender
|
|
180
180
|
#method: HttpMethod
|
|
@@ -761,7 +761,6 @@ export class RequestBuilder {
|
|
|
761
761
|
}
|
|
762
762
|
|
|
763
763
|
/** The status-shortcut assertions (lazy) are typed onto the builder. */
|
|
764
|
-
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached from STATUS_ASSERTIONS at load — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
|
|
765
764
|
export interface RequestBuilder extends StatusAssertions<RequestBuilder> {}
|
|
766
765
|
|
|
767
766
|
// Attach the lazy status-shortcut assertions from the shared map so the builder
|
package/src/utils/Macroable.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type MacroFn = (...args: unknown[]) => unknown
|
|
|
27
27
|
/** A getter implementation — declare `this` in your callback to type the receiver. */
|
|
28
28
|
export type GetterFn = () => unknown
|
|
29
29
|
|
|
30
|
+
// biome-ignore lint/complexity/noStaticOnlyClass: eight classes extend this one to inherit macro()/getter(); it is a base class, not a namespace.
|
|
30
31
|
export class Macroable {
|
|
31
32
|
/**
|
|
32
33
|
* Attach a method to every instance of THIS class (AdonisJS `macro`).
|