odac 1.4.1 → 1.4.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/.agent/rules/memory.md +5 -0
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/odac.js +3 -2
- package/client/odac.js +124 -28
- package/docs/ai/skills/backend/database.md +19 -0
- package/docs/ai/skills/backend/forms.md +107 -13
- package/docs/ai/skills/backend/migrations.md +8 -2
- package/docs/ai/skills/backend/validation.md +132 -32
- package/docs/ai/skills/frontend/forms.md +43 -15
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +1 -0
- package/package.json +1 -1
- package/src/Auth.js +15 -2
- package/src/Database/ConnectionFactory.js +1 -0
- package/src/Database/Migration.js +26 -1
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +122 -11
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +49 -30
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/test/{Auth.test.js → Auth/check.test.js} +91 -5
- package/test/Client/data.test.js +91 -0
- package/test/Client/get.test.js +90 -0
- package/test/Client/storage.test.js +87 -0
- package/test/Client/token.test.js +82 -0
- package/test/Client/ws.test.js +118 -0
- package/test/Config/deepMerge.test.js +14 -0
- package/test/Config/init.test.js +66 -0
- package/test/Config/interpolate.test.js +35 -0
- package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
- package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
- package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
- package/test/Database/Migration/migrate_column.test.js +52 -0
- package/test/Database/Migration/migrate_files.test.js +70 -0
- package/test/Database/Migration/migrate_index.test.js +89 -0
- package/test/Database/Migration/migrate_nanoid.test.js +160 -0
- package/test/Database/Migration/migrate_seed.test.js +77 -0
- package/test/Database/Migration/migrate_table.test.js +88 -0
- package/test/Database/Migration/rollback.test.js +61 -0
- package/test/Database/Migration/snapshot.test.js +38 -0
- package/test/Database/Migration/status.test.js +41 -0
- package/test/Database/autoNanoid.test.js +215 -0
- package/test/Database/nanoid.test.js +19 -0
- package/test/Lang/constructor.test.js +25 -0
- package/test/Lang/get.test.js +65 -0
- package/test/Lang/set.test.js +49 -0
- package/test/Odac/init.test.js +42 -0
- package/test/Odac/instance.test.js +58 -0
- package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
- package/test/Route/Middleware/use.test.js +35 -0
- package/test/{Route.test.js → Route/check.test.js} +100 -50
- package/test/Route/set.test.js +52 -0
- package/test/Route/ws.test.js +23 -0
- package/test/View/EarlyHints/cache.test.js +32 -0
- package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
- package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
- package/test/View/EarlyHints/send.test.js +99 -0
- package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
- package/test/View/constructor.test.js +22 -0
- package/test/View/print.test.js +19 -0
- package/test/WebSocket/Client/limits.test.js +55 -0
- package/test/WebSocket/Server/broadcast.test.js +33 -0
- package/test/WebSocket/Server/route.test.js +37 -0
- package/test/Client.test.js +0 -197
- package/test/Config.test.js +0 -119
- package/test/Database/ConnectionFactory.test.js +0 -80
- package/test/Lang.test.js +0 -92
- package/test/Migration.test.js +0 -943
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- package/test/WebSocket.test.js +0 -238
package/src/Ipc.js
CHANGED
|
@@ -269,6 +269,43 @@ class Ipc extends EventEmitter {
|
|
|
269
269
|
interval.unref()
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Tears down IPC resources. For Redis driver, disconnects clients.
|
|
274
|
+
* For memory driver, clears stores and removes cluster listeners.
|
|
275
|
+
*/
|
|
276
|
+
async close() {
|
|
277
|
+
if (this.config.driver === 'redis') {
|
|
278
|
+
if (this.subRedis) {
|
|
279
|
+
await this.subRedis.quit().catch(() => {})
|
|
280
|
+
this.subRedis = null
|
|
281
|
+
}
|
|
282
|
+
if (this.redis) {
|
|
283
|
+
await this.redis.quit().catch(() => {})
|
|
284
|
+
this.redis = null
|
|
285
|
+
}
|
|
286
|
+
} else if (cluster.isPrimary) {
|
|
287
|
+
if (global.__odac_ipc_message_handler) {
|
|
288
|
+
cluster.removeListener('message', global.__odac_ipc_message_handler)
|
|
289
|
+
global.__odac_ipc_message_handler = null
|
|
290
|
+
}
|
|
291
|
+
if (global.__odac_ipc_exit_handler) {
|
|
292
|
+
cluster.removeListener('exit', global.__odac_ipc_exit_handler)
|
|
293
|
+
global.__odac_ipc_exit_handler = null
|
|
294
|
+
}
|
|
295
|
+
if (this._memoryStore) this._memoryStore.clear()
|
|
296
|
+
if (this._memorySubs) this._memorySubs.clear()
|
|
297
|
+
} else {
|
|
298
|
+
// Worker: reject all pending requests so they don't hang
|
|
299
|
+
for (const req of this._requests.values()) {
|
|
300
|
+
clearTimeout(req.timeout)
|
|
301
|
+
req.reject(new Error('IPC shutting down'))
|
|
302
|
+
}
|
|
303
|
+
this._requests.clear()
|
|
304
|
+
this._subs.clear()
|
|
305
|
+
}
|
|
306
|
+
this.initialized = false
|
|
307
|
+
}
|
|
308
|
+
|
|
272
309
|
_handlePrimaryMessage(worker, msg) {
|
|
273
310
|
const {type, id, key, value, ttl, channel, message} = msg
|
|
274
311
|
const action = type.replace('ipc:', '')
|
package/src/Odac.js
CHANGED
|
@@ -149,7 +149,7 @@ module.exports = {
|
|
|
149
149
|
return hash ? _odac.Token.check(hash) : _odac.Token.generate()
|
|
150
150
|
}
|
|
151
151
|
_odac.validator = function () {
|
|
152
|
-
return new (require('./Validator.js'))(_odac.Request)
|
|
152
|
+
return new (require('./Validator.js'))(_odac.Request, _odac)
|
|
153
153
|
}
|
|
154
154
|
_odac.write = function (value) {
|
|
155
155
|
return _odac.Request.write(value)
|
package/src/Route/Cron.js
CHANGED
|
@@ -12,6 +12,17 @@ class Cron {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Stops the cron scheduler. Called during graceful shutdown to prevent
|
|
17
|
+
* new cron jobs from firing while the process is terminating.
|
|
18
|
+
*/
|
|
19
|
+
stop() {
|
|
20
|
+
if (this.#interval) {
|
|
21
|
+
clearInterval(this.#interval)
|
|
22
|
+
this.#interval = null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
check() {
|
|
16
27
|
const now = new Date()
|
|
17
28
|
const minute = now.getMinutes()
|
package/src/Route.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const fsPromises = fs.promises
|
|
3
|
+
const path = require('path')
|
|
3
4
|
|
|
4
5
|
const Cron = require('./Route/Cron.js')
|
|
5
6
|
const Internal = require('./Route/Internal.js')
|
|
@@ -219,42 +220,52 @@ class Route {
|
|
|
219
220
|
if (typeof page === 'string') Odac.Request.page = page
|
|
220
221
|
return await this.#executeController(Odac, pageController)
|
|
221
222
|
}
|
|
222
|
-
if (url && !url.includes('/../')) {
|
|
223
|
-
const publicPath = `${__dir}/public${url}`
|
|
224
223
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return fs.createReadStream(publicPath)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
const stat = await fsPromises.stat(publicPath)
|
|
236
|
-
if (stat.isFile()) {
|
|
237
|
-
let type = 'text/html'
|
|
238
|
-
if (url.includes('.')) {
|
|
239
|
-
let arr = url.split('.')
|
|
240
|
-
type = mime[arr[arr.length - 1]]
|
|
241
|
-
}
|
|
224
|
+
let decodedUrl
|
|
225
|
+
try {
|
|
226
|
+
decodedUrl = decodeURIComponent(url)
|
|
227
|
+
} catch {
|
|
228
|
+
decodedUrl = null // Invalid URI encoding
|
|
229
|
+
}
|
|
242
230
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
}
|
|
231
|
+
if (decodedUrl && !decodedUrl.includes('\0')) {
|
|
232
|
+
const publicDir = path.normalize(`${__dir}/public`)
|
|
233
|
+
const safeUrl = decodedUrl.replace(/^[/\\]+/, '')
|
|
234
|
+
const publicPath = path.normalize(path.join(publicDir, safeUrl))
|
|
235
|
+
const relativePath = path.relative(publicDir, publicPath)
|
|
250
236
|
|
|
251
|
-
|
|
237
|
+
if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) {
|
|
238
|
+
// PROD CACHE HIT (Metadata)
|
|
239
|
+
if (!Odac.Config.debug && this.#publicCache[publicPath]) {
|
|
240
|
+
const cached = this.#publicCache[publicPath]
|
|
241
|
+
Odac.Request.header('Content-Type', cached.type)
|
|
252
242
|
Odac.Request.header('Cache-Control', 'public, max-age=31536000')
|
|
253
|
-
Odac.Request.header('Content-Length',
|
|
243
|
+
Odac.Request.header('Content-Length', cached.size)
|
|
254
244
|
return fs.createReadStream(publicPath)
|
|
255
245
|
}
|
|
256
|
-
|
|
257
|
-
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const stat = await fsPromises.stat(publicPath)
|
|
249
|
+
if (stat.isFile()) {
|
|
250
|
+
const extension = path.extname(publicPath).slice(1)
|
|
251
|
+
const type = mime[extension] || 'text/html'
|
|
252
|
+
|
|
253
|
+
// PROD CACHE SET (Metadata Only)
|
|
254
|
+
if (!Odac.Config.debug) {
|
|
255
|
+
this.#publicCache[publicPath] = {
|
|
256
|
+
type,
|
|
257
|
+
size: stat.size
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
Odac.Request.header('Content-Type', type)
|
|
262
|
+
Odac.Request.header('Cache-Control', 'public, max-age=31536000')
|
|
263
|
+
Odac.Request.header('Content-Length', stat.size)
|
|
264
|
+
return fs.createReadStream(publicPath)
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// File not found in public
|
|
268
|
+
}
|
|
258
269
|
}
|
|
259
270
|
}
|
|
260
271
|
|
|
@@ -806,6 +817,14 @@ class Route {
|
|
|
806
817
|
return Cron.job(controller)
|
|
807
818
|
}
|
|
808
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Stops the cron scheduler during graceful shutdown.
|
|
822
|
+
* Prevents new cron jobs from spawning while the process is terminating.
|
|
823
|
+
*/
|
|
824
|
+
stopCron() {
|
|
825
|
+
Cron.stop()
|
|
826
|
+
}
|
|
827
|
+
|
|
809
828
|
ws(path, handler, options = {}) {
|
|
810
829
|
this.setWs('ws', path, handler, options)
|
|
811
830
|
return this
|
package/src/Server.js
CHANGED
|
@@ -33,36 +33,73 @@ module.exports = {
|
|
|
33
33
|
}
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* GRACEFUL SHUTDOWN — PRIMARY PROCESS
|
|
38
|
+
* ────────────────────────────────────
|
|
39
|
+
* Shutdown order (deterministic, sequential):
|
|
40
|
+
* 1. Stop accepting new work → Cron, session GC
|
|
41
|
+
* 2. Drain active workers → send 'shutdown', disconnect, wait
|
|
42
|
+
* 3. Release shared resources → IPC, Database, Storage
|
|
43
|
+
* 4. Exit 0
|
|
44
|
+
*
|
|
45
|
+
* A 30-second hard timeout protects against hung workers or I/O.
|
|
46
|
+
*/
|
|
47
|
+
const gracefulShutdown = async signal => {
|
|
38
48
|
if (isShuttingDown) return
|
|
39
49
|
isShuttingDown = true
|
|
40
50
|
|
|
41
51
|
console.log(`\n\x1b[33m[Shutdown]\x1b[0m ${signal} received, shutting down gracefully...`)
|
|
42
52
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
53
|
+
// Force exit safety net — must be set immediately
|
|
54
|
+
const forceTimer = setTimeout(() => {
|
|
55
|
+
console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}, 30000)
|
|
58
|
+
forceTimer.unref()
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
// Phase 1: Stop schedulers so no new work is queued
|
|
61
|
+
Odac.Route.stopCron()
|
|
62
|
+
Odac.Storage.stopSessionGC()
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
// Phase 2: Gracefully drain all workers
|
|
65
|
+
await new Promise(resolve => {
|
|
66
|
+
const workerIds = Object.keys(cluster.workers)
|
|
67
|
+
let workersAlive = workerIds.length
|
|
68
|
+
|
|
69
|
+
if (workersAlive === 0) return resolve()
|
|
70
|
+
|
|
71
|
+
cluster.on('exit', () => {
|
|
72
|
+
workersAlive--
|
|
73
|
+
if (workersAlive <= 0) resolve()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
for (const id of workerIds) {
|
|
77
|
+
const worker = cluster.workers[id]
|
|
78
|
+
if (worker) {
|
|
79
|
+
worker.send('shutdown')
|
|
80
|
+
worker.disconnect()
|
|
81
|
+
}
|
|
58
82
|
}
|
|
59
83
|
})
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
|
|
86
|
+
|
|
87
|
+
// Phase 3: Release shared resources (order matters: IPC → DB → Storage)
|
|
88
|
+
try {
|
|
89
|
+
await Odac.Ipc.close()
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing IPC: ${e.message}`)
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await Odac.Database.close()
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing Database: ${e.message}`)
|
|
97
|
+
}
|
|
98
|
+
Odac.Storage.close()
|
|
99
|
+
|
|
100
|
+
console.log('\x1b[32m[Shutdown]\x1b[0m Resources released. Goodbye!')
|
|
101
|
+
clearTimeout(forceTimer)
|
|
102
|
+
process.exit(0)
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
@@ -113,11 +150,28 @@ module.exports = {
|
|
|
113
150
|
|
|
114
151
|
server.listen(port)
|
|
115
152
|
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
/**
|
|
154
|
+
* GRACEFUL SHUTDOWN — WORKER PROCESS
|
|
155
|
+
* ──────────────────────────────────
|
|
156
|
+
* 1. Stop accepting new connections (server.close)
|
|
157
|
+
* 2. Release worker-scoped resources (IPC pending requests, DB pools)
|
|
158
|
+
* 3. Exit 0
|
|
159
|
+
*/
|
|
160
|
+
process.on('message', async msg => {
|
|
118
161
|
if (msg === 'shutdown') {
|
|
119
162
|
console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Closing server...`)
|
|
120
|
-
|
|
163
|
+
|
|
164
|
+
server.close(async () => {
|
|
165
|
+
try {
|
|
166
|
+
await Odac.Ipc.close()
|
|
167
|
+
} catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await Odac.Database.close()
|
|
172
|
+
} catch {
|
|
173
|
+
/* best-effort */
|
|
174
|
+
}
|
|
121
175
|
console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Server closed.`)
|
|
122
176
|
process.exit(0)
|
|
123
177
|
})
|
package/src/Storage.js
CHANGED
|
@@ -5,6 +5,7 @@ class OdacStorage {
|
|
|
5
5
|
constructor() {
|
|
6
6
|
this.db = null
|
|
7
7
|
this.ready = false
|
|
8
|
+
this.gcInterval = null
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
init() {
|
|
@@ -72,10 +73,14 @@ class OdacStorage {
|
|
|
72
73
|
return null
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
if (this.gcInterval) {
|
|
77
|
+
clearInterval(this.gcInterval)
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
const BATCH_THRESHOLD = 10000
|
|
76
81
|
const BATCH_SIZE = 1000
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
this.gcInterval = setInterval(() => {
|
|
79
84
|
try {
|
|
80
85
|
// Count sessions to decide mode
|
|
81
86
|
let sessionCount = 0
|
|
@@ -94,6 +99,15 @@ class OdacStorage {
|
|
|
94
99
|
console.error('\x1b[31m[Storage GC Error]\x1b[0m', error.message)
|
|
95
100
|
}
|
|
96
101
|
}, intervalMs)
|
|
102
|
+
|
|
103
|
+
return this.gcInterval
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stopSessionGC() {
|
|
107
|
+
if (this.gcInterval) {
|
|
108
|
+
clearInterval(this.gcInterval)
|
|
109
|
+
this.gcInterval = null
|
|
110
|
+
}
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
// Simple mode: Load all sessions at once (fast for small datasets)
|
package/src/Validator.js
CHANGED
|
@@ -93,9 +93,11 @@ class Validator {
|
|
|
93
93
|
#method = 'POST'
|
|
94
94
|
#name = ''
|
|
95
95
|
#request
|
|
96
|
+
#odac
|
|
96
97
|
|
|
97
|
-
constructor(Request) {
|
|
98
|
+
constructor(Request, Odac) {
|
|
98
99
|
this.#request = Request
|
|
100
|
+
this.#odac = Odac || global.Odac
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
check(rules) {
|
|
@@ -189,37 +191,37 @@ class Validator {
|
|
|
189
191
|
error = !value || (value !== 1 && value !== '1' && value !== 'on' && value !== 'yes' && value !== true)
|
|
190
192
|
break
|
|
191
193
|
case 'numeric':
|
|
192
|
-
error = value && value !== '' && !
|
|
194
|
+
error = value && value !== '' && !this.#odac.Var(value).is('numeric')
|
|
193
195
|
break
|
|
194
196
|
case 'alpha':
|
|
195
|
-
error = value && value !== '' && !
|
|
197
|
+
error = value && value !== '' && !this.#odac.Var(value).is('alpha')
|
|
196
198
|
break
|
|
197
199
|
case 'alphaspace':
|
|
198
|
-
error = value && value !== '' && !
|
|
200
|
+
error = value && value !== '' && !this.#odac.Var(value).is('alphaspace')
|
|
199
201
|
break
|
|
200
202
|
case 'alphanumeric':
|
|
201
|
-
error = value && value !== '' && !
|
|
203
|
+
error = value && value !== '' && !this.#odac.Var(value).is('alphanumeric')
|
|
202
204
|
break
|
|
203
205
|
case 'alphanumericspace':
|
|
204
|
-
error = value && value !== '' && !
|
|
206
|
+
error = value && value !== '' && !this.#odac.Var(value).is('alphanumericspace')
|
|
205
207
|
break
|
|
206
208
|
case 'email':
|
|
207
|
-
error = value && value !== '' && !
|
|
209
|
+
error = value && value !== '' && !this.#odac.Var(value).is('email')
|
|
208
210
|
break
|
|
209
211
|
case 'ip':
|
|
210
|
-
error = value && value !== '' && !
|
|
212
|
+
error = value && value !== '' && !this.#odac.Var(value).is('ip')
|
|
211
213
|
break
|
|
212
214
|
case 'float':
|
|
213
|
-
error = value && value !== '' && !
|
|
215
|
+
error = value && value !== '' && !this.#odac.Var(value).is('float')
|
|
214
216
|
break
|
|
215
217
|
case 'mac':
|
|
216
|
-
error = value && value !== '' && !
|
|
218
|
+
error = value && value !== '' && !this.#odac.Var(value).is('mac')
|
|
217
219
|
break
|
|
218
220
|
case 'domain':
|
|
219
|
-
error = value && value !== '' && !
|
|
221
|
+
error = value && value !== '' && !this.#odac.Var(value).is('domain')
|
|
220
222
|
break
|
|
221
223
|
case 'url':
|
|
222
|
-
error = value && value !== '' && !
|
|
224
|
+
error = value && value !== '' && !this.#odac.Var(value).is('url')
|
|
223
225
|
break
|
|
224
226
|
case 'username':
|
|
225
227
|
error = value && value !== '' && !/^[a-zA-Z0-9]+$/.test(value)
|
|
@@ -228,7 +230,7 @@ class Validator {
|
|
|
228
230
|
error = value && value !== '' && /<[^>]*>/g.test(value)
|
|
229
231
|
break
|
|
230
232
|
case 'usercheck':
|
|
231
|
-
error = !(await
|
|
233
|
+
error = !(await this.#odac.Auth.check())
|
|
232
234
|
break
|
|
233
235
|
case 'array':
|
|
234
236
|
error = value && !Array.isArray(value)
|
|
@@ -283,12 +285,12 @@ class Validator {
|
|
|
283
285
|
error = value && value !== '' && vars[1] && !new RegExp(vars[1]).test(value)
|
|
284
286
|
break
|
|
285
287
|
case 'user': {
|
|
286
|
-
if (!(await
|
|
288
|
+
if (!(await this.#odac.Auth.check())) {
|
|
287
289
|
error = true
|
|
288
290
|
} else {
|
|
289
|
-
const userData =
|
|
290
|
-
if (
|
|
291
|
-
error = !
|
|
291
|
+
const userData = this.#odac.Auth.user(vars[1])
|
|
292
|
+
if (this.#odac.Var(userData).is('hash')) {
|
|
293
|
+
error = !this.#odac.Var(userData).hashCheck(value)
|
|
292
294
|
} else {
|
|
293
295
|
error = value !== userData
|
|
294
296
|
}
|
|
@@ -344,7 +346,7 @@ class Validator {
|
|
|
344
346
|
const ip = this.#request.ip()
|
|
345
347
|
const now = new Date().toISOString().slice(0, 13).replace(/[-:T]/g, '')
|
|
346
348
|
const page = this.#request.path()
|
|
347
|
-
const storage =
|
|
349
|
+
const storage = this.#odac.storage('sys')
|
|
348
350
|
const validation = storage.get('validation') || {}
|
|
349
351
|
|
|
350
352
|
this.#name = '_odac_form'
|
|
@@ -358,8 +360,8 @@ class Validator {
|
|
|
358
360
|
validation.brute[now][page][ip]++
|
|
359
361
|
|
|
360
362
|
if (validation.brute[now][page][ip] >= maxAttempts) {
|
|
361
|
-
this.#message['_odac_form'] =
|
|
362
|
-
?
|
|
363
|
+
this.#message['_odac_form'] = this.#odac.Lang
|
|
364
|
+
? this.#odac.Lang.get('Too many failed attempts. Please try again later.')
|
|
363
365
|
: 'Too many failed attempts. Please try again later.'
|
|
364
366
|
}
|
|
365
367
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const Auth = require('
|
|
1
|
+
const Auth = require('../../src/Auth.js')
|
|
2
2
|
|
|
3
|
-
describe('Auth
|
|
3
|
+
describe('Auth.check()', () => {
|
|
4
4
|
let reqMock
|
|
5
5
|
let authInstance
|
|
6
6
|
|
|
@@ -65,7 +65,8 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
65
65
|
return cookieStore[name] || null
|
|
66
66
|
}),
|
|
67
67
|
header: jest.fn(() => 'TestBrowser'),
|
|
68
|
-
ip: '127.0.0.1'
|
|
68
|
+
ip: '127.0.0.1',
|
|
69
|
+
res: {} // HTTP context (non-null res indicates Set-Cookie can be delivered)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
authInstance = new Auth(reqMock)
|
|
@@ -173,12 +174,11 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
173
174
|
|
|
174
175
|
it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
|
|
175
176
|
// Simulate a rotated token where 10 seconds have passed since original rotation
|
|
176
|
-
// Client still has old cookies
|
|
177
|
+
// Client still has old cookies -> recovery rotation should trigger
|
|
177
178
|
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
178
179
|
const timeSinceRotation = 10000 // 10 seconds since original rotation
|
|
179
180
|
// active was set to: rotationTime - maxAge + 60000
|
|
180
181
|
// So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
|
|
181
|
-
// timeSinceRotation formula: inactiveAge - maxAge + 60000 = timeSinceRotation = 10000
|
|
182
182
|
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
|
|
183
183
|
|
|
184
184
|
const mockRecord = {
|
|
@@ -276,6 +276,92 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
276
276
|
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
277
277
|
})
|
|
278
278
|
|
|
279
|
+
it('should skip rotation for WebSocket connections (res === null) and update active instead', async () => {
|
|
280
|
+
const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
|
|
281
|
+
|
|
282
|
+
const wsReqMock = {
|
|
283
|
+
cookie: jest.fn((name, value) => {
|
|
284
|
+
if (value !== undefined) return
|
|
285
|
+
return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
|
|
286
|
+
}),
|
|
287
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
288
|
+
ip: '127.0.0.1',
|
|
289
|
+
res: null // WebSocket context: no HTTP response available
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const wsAuth = new Auth(wsReqMock)
|
|
293
|
+
|
|
294
|
+
const mockRecord = {
|
|
295
|
+
active: new Date(),
|
|
296
|
+
browser: 'TestBrowser',
|
|
297
|
+
date: new Date(createdAt),
|
|
298
|
+
id: 'token_ws',
|
|
299
|
+
ip: '127.0.0.1',
|
|
300
|
+
token_x: 'old_x',
|
|
301
|
+
token_y: 'hashed_old_y',
|
|
302
|
+
user: 'user_10'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const dbMock = createDbMock([mockRecord])
|
|
306
|
+
global.Odac.DB.user_tokens = dbMock
|
|
307
|
+
global.Odac.DB.users = dbMock
|
|
308
|
+
|
|
309
|
+
const result = await wsAuth.check()
|
|
310
|
+
|
|
311
|
+
expect(result).toBe(true)
|
|
312
|
+
// No rotation: no new token inserted
|
|
313
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
314
|
+
// Active timestamp should be refreshed instead
|
|
315
|
+
expect(dbMock.tracker.updateCalls.length).toBe(1)
|
|
316
|
+
expect(dbMock.tracker.updateCalls[0].active).toBeInstanceOf(Date)
|
|
317
|
+
// No new cookies set (nothing to deliver over WS)
|
|
318
|
+
const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
319
|
+
expect(setCalls.length).toBe(0)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('should skip recovery rotation for WebSocket connections (res === null)', async () => {
|
|
323
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
324
|
+
const timeSinceRotation = 10000 // 10 seconds since original rotation
|
|
325
|
+
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
|
|
326
|
+
|
|
327
|
+
const wsReqMock = {
|
|
328
|
+
cookie: jest.fn((name, value) => {
|
|
329
|
+
if (value !== undefined) return
|
|
330
|
+
return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
|
|
331
|
+
}),
|
|
332
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
333
|
+
ip: '127.0.0.1',
|
|
334
|
+
res: null // WebSocket context
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const wsAuth = new Auth(wsReqMock)
|
|
338
|
+
|
|
339
|
+
const mockRecord = {
|
|
340
|
+
active: rotatedActiveDate,
|
|
341
|
+
browser: 'TestBrowser',
|
|
342
|
+
date: new Date(0), // Epoch marker = rotated
|
|
343
|
+
id: 'token_ws_recovery',
|
|
344
|
+
ip: '127.0.0.1',
|
|
345
|
+
token_x: 'old_x',
|
|
346
|
+
token_y: 'hashed_old_y',
|
|
347
|
+
user: 'user_10'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const dbMock = createDbMock([mockRecord])
|
|
351
|
+
global.Odac.DB.user_tokens = dbMock
|
|
352
|
+
global.Odac.DB.users = dbMock
|
|
353
|
+
|
|
354
|
+
const result = await wsAuth.check()
|
|
355
|
+
|
|
356
|
+
expect(result).toBe(true)
|
|
357
|
+
// No recovery rotation: no insert, no delete
|
|
358
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
359
|
+
expect(dbMock.tracker.deleteCalls.length).toBe(0)
|
|
360
|
+
// No cookies set
|
|
361
|
+
const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
362
|
+
expect(setCalls.length).toBe(0)
|
|
363
|
+
})
|
|
364
|
+
|
|
279
365
|
it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
|
|
280
366
|
const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
|
|
281
367
|
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
describe('Odac.data()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: '{}',
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: '',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should retrieve data from odac-data script tag', () => {
|
|
78
|
+
const mockData = {user: 'emre'}
|
|
79
|
+
document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
|
|
80
|
+
const result = window.Odac.data()
|
|
81
|
+
expect(result).toEqual(mockData)
|
|
82
|
+
expect(document.getElementById).toHaveBeenCalledWith('odac-data')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('should return specific key from data', () => {
|
|
86
|
+
const mockData = {user: 'emre', role: 'admin'}
|
|
87
|
+
document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
|
|
88
|
+
expect(window.Odac.data('user')).toBe('emre')
|
|
89
|
+
expect(window.Odac.data('role')).toBe('admin')
|
|
90
|
+
})
|
|
91
|
+
})
|