@c9up/ream 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +20 -18
  2. package/dist/Application.d.ts +26 -6
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +40 -12
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +2 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/Ignitor.d.ts +13 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +98 -23
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/MultipartFile.d.ts +8 -0
  14. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  15. package/dist/bodyparser/MultipartFile.js +46 -1
  16. package/dist/bodyparser/MultipartFile.js.map +1 -1
  17. package/dist/env/Env.d.ts.map +1 -1
  18. package/dist/env/Env.js +2 -1
  19. package/dist/env/Env.js.map +1 -1
  20. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  21. package/dist/env/loadEnvFiles.js +5 -1
  22. package/dist/env/loadEnvFiles.js.map +1 -1
  23. package/dist/env/nodeEnv.d.ts +24 -0
  24. package/dist/env/nodeEnv.d.ts.map +1 -0
  25. package/dist/env/nodeEnv.js +40 -0
  26. package/dist/env/nodeEnv.js.map +1 -0
  27. package/dist/errors/ReamError.d.ts +6 -1
  28. package/dist/errors/ReamError.d.ts.map +1 -1
  29. package/dist/errors/ReamError.js +8 -2
  30. package/dist/errors/ReamError.js.map +1 -1
  31. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  32. package/dist/graphql/GraphQLEngine.js +3 -2
  33. package/dist/graphql/GraphQLEngine.js.map +1 -1
  34. package/dist/http/Exception.d.ts.map +1 -1
  35. package/dist/http/Exception.js +2 -1
  36. package/dist/http/Exception.js.map +1 -1
  37. package/dist/http/Request.d.ts +18 -3
  38. package/dist/http/Request.d.ts.map +1 -1
  39. package/dist/http/Request.js +71 -8
  40. package/dist/http/Request.js.map +1 -1
  41. package/dist/http/Response.d.ts +11 -4
  42. package/dist/http/Response.d.ts.map +1 -1
  43. package/dist/http/Response.js +46 -17
  44. package/dist/http/Response.js.map +1 -1
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/router/Router.d.ts.map +1 -1
  50. package/dist/router/Router.js +14 -5
  51. package/dist/router/Router.js.map +1 -1
  52. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  53. package/dist/rpc/RpcRouter.js +2 -1
  54. package/dist/rpc/RpcRouter.js.map +1 -1
  55. package/dist/scheduler/ScheduleProvider.d.ts +14 -0
  56. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  57. package/dist/scheduler/ScheduleProvider.js +62 -14
  58. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  59. package/dist/scheduler/config.d.ts +29 -0
  60. package/dist/scheduler/config.d.ts.map +1 -0
  61. package/dist/scheduler/config.js +14 -0
  62. package/dist/scheduler/config.js.map +1 -0
  63. package/dist/scheduler/index.d.ts +4 -0
  64. package/dist/scheduler/index.d.ts.map +1 -1
  65. package/dist/scheduler/index.js +3 -0
  66. package/dist/scheduler/index.js.map +1 -1
  67. package/dist/scheduler/locks/LockBackend.d.ts +13 -3
  68. package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
  69. package/dist/scheduler/locks/LockBackend.js +20 -4
  70. package/dist/scheduler/locks/LockBackend.js.map +1 -1
  71. package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
  72. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  73. package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
  74. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  75. package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
  76. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
  77. package/dist/scheduler/locks/RedisLockBackend.js +83 -0
  78. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
  79. package/dist/scheduler/locks/locks.d.ts +39 -0
  80. package/dist/scheduler/locks/locks.d.ts.map +1 -0
  81. package/dist/scheduler/locks/locks.js +42 -0
  82. package/dist/scheduler/locks/locks.js.map +1 -0
  83. package/dist/scheduler/locks/quasar.d.ts +17 -0
  84. package/dist/scheduler/locks/quasar.d.ts.map +1 -0
  85. package/dist/scheduler/locks/quasar.js +51 -0
  86. package/dist/scheduler/locks/quasar.js.map +1 -0
  87. package/dist/security/CookieSigner.d.ts +16 -13
  88. package/dist/security/CookieSigner.d.ts.map +1 -1
  89. package/dist/security/CookieSigner.js +33 -14
  90. package/dist/security/CookieSigner.js.map +1 -1
  91. package/dist/services/encryption.d.ts +12 -2
  92. package/dist/services/encryption.d.ts.map +1 -1
  93. package/dist/services/encryption.js +11 -1
  94. package/dist/services/encryption.js.map +1 -1
  95. package/dist/session/Session.d.ts +8 -7
  96. package/dist/session/Session.d.ts.map +1 -1
  97. package/dist/session/Session.js.map +1 -1
  98. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  99. package/dist/session/SessionMiddleware.js +4 -1
  100. package/dist/session/SessionMiddleware.js.map +1 -1
  101. package/dist/testing/ApiResponse.d.ts.map +1 -1
  102. package/dist/testing/ApiResponse.js +1 -1
  103. package/dist/testing/ApiResponse.js.map +1 -1
  104. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  105. package/dist/testing/RequestBuilder.js +1 -1
  106. package/dist/testing/RequestBuilder.js.map +1 -1
  107. package/dist/utils/Macroable.d.ts.map +1 -1
  108. package/dist/utils/Macroable.js +1 -0
  109. package/dist/utils/Macroable.js.map +1 -1
  110. package/events.win32-x64-msvc.node +0 -0
  111. package/index.win32-x64-msvc.node +0 -0
  112. package/package.json +5 -1
  113. package/scheduler.win32-x64-msvc.node +0 -0
  114. package/src/Application.ts +42 -15
  115. package/src/ErrorBoundary.ts +3 -1
  116. package/src/Ignitor.ts +109 -27
  117. package/src/bodyparser/MultipartFile.ts +48 -1
  118. package/src/env/Env.ts +2 -1
  119. package/src/env/loadEnvFiles.ts +5 -1
  120. package/src/env/nodeEnv.ts +38 -0
  121. package/src/errors/ReamError.ts +9 -2
  122. package/src/graphql/GraphQLEngine.ts +3 -2
  123. package/src/http/Exception.ts +2 -1
  124. package/src/http/Request.ts +73 -8
  125. package/src/http/Response.ts +58 -19
  126. package/src/index.ts +8 -1
  127. package/src/router/Router.ts +14 -5
  128. package/src/rpc/RpcRouter.ts +2 -1
  129. package/src/scheduler/ScheduleProvider.ts +65 -15
  130. package/src/scheduler/config.ts +34 -0
  131. package/src/scheduler/index.ts +8 -0
  132. package/src/scheduler/locks/LockBackend.ts +26 -3
  133. package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
  134. package/src/scheduler/locks/RedisLockBackend.ts +114 -0
  135. package/src/scheduler/locks/locks.ts +49 -0
  136. package/src/scheduler/locks/quasar.ts +67 -0
  137. package/src/security/CookieSigner.ts +36 -14
  138. package/src/services/encryption.ts +12 -2
  139. package/src/session/Session.ts +8 -7
  140. package/src/session/SessionMiddleware.ts +4 -1
  141. package/src/testing/ApiResponse.ts +1 -2
  142. package/src/testing/RequestBuilder.ts +1 -2
  143. package/src/utils/Macroable.ts +1 -0
@@ -24,8 +24,9 @@ import { getServiceRegistry } from '../decorators/Service.js'
24
24
  import { ReamError } from '../errors/ReamError.js'
25
25
  import type { AppContext } from '../Provider.js'
26
26
  import { Provider } from '../Provider.js'
27
+ import type { SchedulerConfig } from './config.js'
27
28
  import { getScheduleMetadata } from './Schedule.js'
28
- import { Scheduler } from './Scheduler.js'
29
+ import { Scheduler, type SchedulerOptions } from './Scheduler.js'
29
30
 
30
31
  export interface ScheduleProviderOptions {
31
32
  /**
@@ -40,12 +41,14 @@ let activeProvider: ScheduleProvider | undefined
40
41
 
41
42
  export class ScheduleProvider extends Provider {
42
43
  readonly scheduler: Scheduler
43
- #booted = false
44
+ /** Task names already registered, so a second pass adds only what is new. */
45
+ readonly #known = new Set<string>()
44
46
  #registered = false
45
47
 
46
48
  constructor(app: AppContext, options: ScheduleProviderOptions = {}) {
47
49
  super(app)
48
- this.scheduler = options.scheduler ?? new Scheduler()
50
+ this.scheduler =
51
+ options.scheduler ?? new Scheduler(schedulerOptionsFrom(app.config.get('scheduler')))
49
52
  }
50
53
 
51
54
  /**
@@ -82,8 +85,44 @@ export class ScheduleProvider extends Provider {
82
85
  this.#registered = true
83
86
  }
84
87
 
88
+ /**
89
+ * Discovery runs at START, not at boot, and that is the whole point.
90
+ *
91
+ * `app/modules/**` is auto-loaded during the start phase — after every
92
+ * provider has booted. A `@Service()` carrying `@Schedule` there, which is
93
+ * where one naturally lives, was therefore registered into the service
94
+ * registry AFTER this had already read it. The task was never registered,
95
+ * and nothing said so: no error, no warning, the application started
96
+ * normally and the task simply never fired.
97
+ *
98
+ * Reading the registry once the modules are in place is what fixes it. A
99
+ * service declared anywhere earlier — a provider, a preload — is in the
100
+ * registry by then too, so nothing is lost by waiting.
101
+ */
85
102
  override async boot(): Promise<void> {
86
- if (this.#booted) return
103
+ // Anything already in the registry — declared by a provider, or imported
104
+ // by one. The rest is caught at start().
105
+ this.#discover()
106
+ }
107
+
108
+ override async start(): Promise<void> {
109
+ this.#discover()
110
+ if (activeProvider !== undefined && activeProvider !== this) {
111
+ // A previous app's scheduler is still running — stop it before this one
112
+ // starts, otherwise every @Schedule task fires twice per tick.
113
+ activeProvider.scheduler.stop()
114
+ }
115
+ activeProvider = this
116
+ this.scheduler.start()
117
+ }
118
+
119
+ /**
120
+ * Walk the service registry and register every `@Schedule` it declares.
121
+ *
122
+ * Idempotent: a second call registers nothing twice, so an application that
123
+ * restarts its Ignitor does not end up firing everything in duplicate.
124
+ */
125
+ #discover(): void {
87
126
  const registry = getServiceRegistry()
88
127
  const registered: string[] = []
89
128
 
@@ -118,6 +157,8 @@ export class ScheduleProvider extends Provider {
118
157
  }
119
158
 
120
159
  const taskName = `${target.name}.${methodName}`
160
+ // Registered on an earlier pass: skip rather than fire it twice.
161
+ if (this.#known.has(taskName)) continue
121
162
  try {
122
163
  this.scheduler.register(taskName, cronExpr, async () => {
123
164
  // Resolve from the container on every fire so transient
@@ -133,6 +174,7 @@ export class ScheduleProvider extends Provider {
133
174
  await (method as (...args: unknown[]) => unknown).call(instance)
134
175
  })
135
176
  registered.push(taskName)
177
+ this.#known.add(taskName)
136
178
  } catch (cause) {
137
179
  const inner = cause as ReamError & { message?: string; hint?: string }
138
180
  throw new ReamError(
@@ -146,7 +188,6 @@ export class ScheduleProvider extends Provider {
146
188
  }
147
189
  }
148
190
  }
149
- this.#booted = true
150
191
  } catch (err) {
151
192
  // Roll back any partial registrations so a retry does not hit
152
193
  // DUPLICATE_TASK and the scheduler is never half-initialized.
@@ -162,16 +203,6 @@ export class ScheduleProvider extends Provider {
162
203
  }
163
204
  }
164
205
 
165
- override async start(): Promise<void> {
166
- if (activeProvider !== undefined && activeProvider !== this) {
167
- // A previous app's scheduler is still running — stop it before this one starts,
168
- // otherwise every @Schedule task fires twice per tick.
169
- activeProvider.scheduler.stop()
170
- }
171
- activeProvider = this
172
- this.scheduler.start()
173
- }
174
-
175
206
  override async shutdown(): Promise<void> {
176
207
  if (activeProvider === this) activeProvider = undefined
177
208
  this.scheduler.stop()
@@ -180,3 +211,22 @@ export class ScheduleProvider extends Provider {
180
211
 
181
212
  // Default export so reamrc's provider loader can `() => import('@c9up/ream/<feature>/provider')` (resolves to { default }), matching events/rpc. Named export above stays.
182
213
  export default ScheduleProvider
214
+
215
+ /**
216
+ * Turn `config/scheduler.ts` into the options the `Scheduler` takes.
217
+ *
218
+ * The lock is built here rather than in the config file because that file is
219
+ * read before the application boots — a Redis connection named in it does not
220
+ * exist yet, so the helpers hand back a factory and this calls it.
221
+ */
222
+ function schedulerOptionsFrom(config: SchedulerConfig | undefined): SchedulerOptions {
223
+ if (config === undefined) return {}
224
+ const options: SchedulerOptions = {}
225
+ if (config.lock !== undefined) {
226
+ options.lockBackend = typeof config.lock === 'function' ? config.lock() : config.lock
227
+ }
228
+ if (config.defaultLockTtlMs !== undefined) {
229
+ options.defaultLockTtlMs = config.defaultLockTtlMs
230
+ }
231
+ return options
232
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Author-time config for `config/scheduler.ts`.
3
+ *
4
+ * import { defineConfig, locks } from '@c9up/ream/scheduler/config'
5
+ *
6
+ * export default defineConfig({
7
+ * lock: locks.redis({ connection: 'main' }),
8
+ * })
9
+ */
10
+
11
+ import type { LockBackend } from './locks/LockBackend.js'
12
+ import type { LockBackendFactory } from './locks/locks.js'
13
+
14
+ export interface SchedulerConfig {
15
+ /**
16
+ * The lock consulted before a task fires, so that only one instance runs
17
+ * it. Takes a backend, or a factory answering one — which is what the
18
+ * `locks.*` helpers return, so a config file can name a Redis lock before
19
+ * the connection exists.
20
+ *
21
+ * Left out, the scheduler locks nothing: every instance runs every task.
22
+ * Correct on one instance, and a duplicate run per replica past that.
23
+ */
24
+ lock?: LockBackend | LockBackendFactory
25
+ /** How long a lease is held before it expires on its own. Default 60 000. */
26
+ defaultLockTtlMs?: number
27
+ }
28
+
29
+ export function defineConfig(config: SchedulerConfig): SchedulerConfig {
30
+ return config
31
+ }
32
+
33
+ export type { LockBackend } from './locks/LockBackend.js'
34
+ export { type LockBackendFactory, locks } from './locks/locks.js'
@@ -1,5 +1,13 @@
1
+ export type { SchedulerConfig } from './config.js'
2
+ export { defineConfig as defineSchedulerConfig } from './config.js'
1
3
  export type { LockBackend } from './locks/LockBackend.js'
4
+ export { type LockBackendFactory, locks } from './locks/locks.js'
2
5
  export { MemoryLockBackend } from './locks/MemoryLockBackend.js'
6
+ export {
7
+ type LockRedisClient,
8
+ type LockRedisResolver,
9
+ RedisLockBackend,
10
+ } from './locks/RedisLockBackend.js'
3
11
  export type { ErrorReporter } from './observability/ErrorReporter.js'
4
12
  export type {
5
13
  ScheduleEvent,
@@ -3,13 +3,18 @@
3
3
  *
4
4
  * The scheduler consults a `LockBackend` immediately before firing a
5
5
  * registered task so that in multi-instance deployments only one
6
- * instance runs a given task on each tick. Redis, database
7
- * advisory-lock, and other backends live in user-land — this module
8
- * ships only the interface and a single-process `MemoryLockBackend`.
6
+ * instance runs a given task on each tick.
7
+ *
8
+ * Two backends ship with it: `MemoryLockBackend`, correct while the
9
+ * application runs in one process, and `RedisLockBackend`, which is
10
+ * what a second replica needs — see `locks` for the factories a
11
+ * config file names.
9
12
  *
10
13
  * @implements Story 28.3
11
14
  */
12
15
 
16
+ import { ReamError } from '../../errors/ReamError.js'
17
+
13
18
  export interface LockBackend {
14
19
  /**
15
20
  * Attempt to acquire an exclusive lock on `name` for up to `ttlMs`
@@ -36,3 +41,21 @@ export interface LockBackend {
36
41
  */
37
42
  release(name: string): Promise<void>
38
43
  }
44
+
45
+ /**
46
+ * Reject a TTL that cannot bound anything. A zero or negative lease
47
+ * expires the instant it is taken, so every instance would acquire the
48
+ * same lock and run the same task — the exact outcome locking exists to
49
+ * prevent, arriving silently.
50
+ */
51
+ export function assertValidTtl(ttlMs: number): void {
52
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
53
+ throw new ReamError(
54
+ 'SCHEDULE_INVALID_LOCK_TTL',
55
+ `Lock TTL must be a finite positive number, got ${ttlMs}`,
56
+ {
57
+ hint: 'Use a millisecond value greater than zero (typical range 1_000 - 600_000).',
58
+ },
59
+ )
60
+ }
61
+ }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Default single-process lock backend backed by a `Map`.
3
3
  *
4
- * Correct for apps that run in a single Node.js process. For
5
- * horizontally-scaled deployments, implement `LockBackend` against a
6
- * shared store (Redis `SET NX PX`, Postgres advisory locks, etc.) —
7
- * those drivers live in user-land per the project's "agnostic per
8
- * package" rule.
4
+ * Correct for apps that run in a single Node.js process, and only
5
+ * those: two processes each keep their own map, so both acquire the
6
+ * same name and both run the task. Reach for `RedisLockBackend` the
7
+ * moment a second replica exists.
9
8
  *
10
9
  * Expiry is evaluated **lazily** at `acquire` time — no background
11
10
  * timer, no `setInterval`. This preserves Story 28.1's
@@ -20,23 +19,10 @@
20
19
  * @implements Story 28.3
21
20
  */
22
21
 
23
- import { ReamError } from '../../errors/ReamError.js'
24
- import type { LockBackend } from './LockBackend.js'
22
+ import { assertValidTtl, type LockBackend } from './LockBackend.js'
25
23
 
26
24
  const SWEEP_THRESHOLD = 256
27
25
 
28
- function assertValidTtl(ttlMs: number): void {
29
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
30
- throw new ReamError(
31
- 'SCHEDULE_INVALID_LOCK_TTL',
32
- `Lock TTL must be a finite positive number, got ${ttlMs}`,
33
- {
34
- hint: 'Use a millisecond value greater than zero (typical range 1_000 – 600_000).',
35
- },
36
- )
37
- }
38
- }
39
-
40
26
  export class MemoryLockBackend implements LockBackend {
41
27
  #locks = new Map<string, number>()
42
28
 
@@ -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
- * NAMED DEVIATION — the wire format is NOT AdonisJS's.
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
- * AdonisJS encrypts with `aes-256-cbc` and appends its own HMAC
8
- * (`base64url(payload).base64url(iv).hmac`). Ream uses `aes-256-gcm`, where
9
- * authentication is part of the cipher rather than a second primitive bolted on
10
- * — encrypt-then-MAC assembled by hand is a well-known source of subtle breaks,
11
- * and there is no reason to reproduce it.
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 an AdonisJS app cannot
14
- * be decrypted here. Migrating an app invalidates the encrypted cookies already
15
- * in users' browsers — they are signed out once, at deploy. Everything the app
16
- * CALLS (`encrypt` / `decrypt` / `sign` / `unsign`, with purpose and expiry)
17
- * behaves the same.
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 to be worth having. */
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('APP_KEY is too short — it must be at least 16 characters.')
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
- /** @internal Set the signer (called by Ignitor once APP_KEY is validated). */
18
- export function setEncryption(signer: CookieSigner): void {
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
 
@@ -78,16 +78,17 @@ const INTENDED_URL_KEY = '__intended_url'
78
78
 
79
79
  export interface SessionConfig {
80
80
  /**
81
- * Which store to use. AdonisJS names this key `store`; `driver` is ream's
82
- * older spelling and both are accepted, so a migrated `config/session.ts`
83
- * runs with its imports rewritten and nothing else.
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
- * Named stores, AdonisJS-style: `{ store: 'redis', stores: { redis: … } }`.
90
- * The selected entry supplies the driver name and its options.
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
- const isProduction = process.env.NODE_ENV === 'production'
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
  //