odac 1.4.15 → 1.4.17
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/CHANGELOG.md +29 -0
- package/client/odac.js +38 -1
- package/docs/ai/skills/backend/forms.md +54 -3
- package/docs/ai/skills/backend/validation.md +30 -2
- package/docs/backend/05-forms/01-custom-forms.md +52 -0
- package/docs/backend/09-validation/01-the-validator-service.md +40 -0
- package/docs/backend/13-utilities/02-ipc.md +26 -1
- package/docs/frontend/03-forms/01-form-handling.md +30 -13
- package/package.json +2 -1
- package/src/Auth.js +252 -24
- package/src/Config.js +5 -1
- package/src/Ipc.js +87 -14
- package/src/Odac.js +23 -4
- package/src/Request.js +235 -34
- package/src/Route/Internal.js +32 -3
- package/src/Validator.js +143 -2
- package/src/View/Form.js +56 -0
- package/src/View/Image.js +15 -6
- package/src/View.js +2 -2
- package/src/WebSocket.js +20 -1
- package/test/Auth/check.test.js +219 -18
- package/test/Auth/verifyMagicLink.test.js +8 -1
- package/test/Ipc/subscribe.test.js +154 -0
- package/test/Ipc/subscribeRedis.test.js +141 -0
- package/test/Odac/cache.test.js +5 -2
- package/test/Odac/image.test.js +8 -5
- package/test/Odac/instance.test.js +5 -2
- package/test/Request/cache.test.js +1 -0
- package/test/Request/multipart.test.js +164 -0
- package/test/Validator/file.test.js +172 -0
- package/test/View/Form/generateFieldHtml.test.js +56 -0
- package/test/View/Image/serve.test.js +9 -4
- package/test/View/Image/url.test.js +10 -4
- package/test/View/addNavigateAttribute.test.js +10 -1
- package/test/View/print.test.js +10 -1
- package/test/WebSocket/Client/fragmentation.test.js +24 -5
- package/test/WebSocket/Client/limits.test.js +21 -2
- package/test/WebSocket/Client/readyState.test.js +29 -10
- package/test/WebSocket/Client/send.test.js +148 -0
package/src/Auth.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
const ROTATED_TOKEN_EPOCH_THRESHOLD_MS = 31536000000
|
|
3
|
-
|
|
3
|
+
// Window after rotation during which the old token can still recover a lost
|
|
4
|
+
// rotation response (one-shot). Shorter = smaller replay surface; too short
|
|
5
|
+
// breaks legitimate retries on flaky mobile networks (stall + retry ≈ 10-20s).
|
|
6
|
+
const TOKEN_ROTATION_GRACE_PERIOD_MS = 30 * 1000
|
|
7
|
+
const TOKEN_SWEEP_INTERVAL_MS = 60 * 60 * 1000
|
|
8
|
+
const TOKEN_SWEEP_BOOT_DELAY_MS = 5 * 60 * 1000
|
|
4
9
|
class Auth {
|
|
5
10
|
#request = null
|
|
6
11
|
#table = null
|
|
7
12
|
#token = null
|
|
8
13
|
#user = null
|
|
9
14
|
static #migrationCache = new Set()
|
|
15
|
+
// First sweep fires ~5 minutes after boot, then once per interval, driven by
|
|
16
|
+
// live traffic. The boot delay keeps short-lived processes (tests, scripts)
|
|
17
|
+
// from sweeping, while frequently-restarted servers (dev sync) still get a
|
|
18
|
+
// sweep instead of resetting a full interval on every restart.
|
|
19
|
+
static #lastTokenSweep = Date.now() - TOKEN_SWEEP_INTERVAL_MS + TOKEN_SWEEP_BOOT_DELAY_MS
|
|
10
20
|
|
|
11
21
|
constructor(request) {
|
|
12
22
|
this.#request = request
|
|
@@ -110,16 +120,54 @@ class Auth {
|
|
|
110
120
|
console.error('Odac Auth Error: Failed to ensure token table exists:', e.message)
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
// Query token
|
|
114
|
-
|
|
123
|
+
// Query token by its unique public identifier only.
|
|
124
|
+
// User-Agent is validated as a heuristic signal below (see #detectAnomaly),
|
|
125
|
+
// not as a hard WHERE key, so that a legitimate browser update doesn't
|
|
126
|
+
// silently orphan the row.
|
|
127
|
+
let sql_token = await Odac.DB[tokenTable].where('token_x', odac_x)
|
|
115
128
|
|
|
116
129
|
if (!sql_token || sql_token.length !== 1) return false
|
|
117
130
|
|
|
118
|
-
|
|
131
|
+
// Verify the secret. New tokens are SHA-256 (fast, high-entropy);
|
|
132
|
+
// legacy scrypt tokens are still accepted and upgraded on next rotation.
|
|
133
|
+
if (!this.#verifyToken(sql_token[0].token_y, odac_y)) return false
|
|
134
|
+
|
|
135
|
+
// Session hijack / stale-session heuristics.
|
|
136
|
+
// On a strong anomaly (OS/browser change, version downgrade or implausible
|
|
137
|
+
// jump, or a moved IP paired with a UA/language mismatch) drop the token.
|
|
138
|
+
const acceptLanguage = this.#request.header('accept-language')
|
|
139
|
+
const anomaly = this.#detectAnomaly(sql_token[0], browser, this.#request.ip, acceptLanguage)
|
|
140
|
+
if (anomaly) {
|
|
141
|
+
await Odac.DB[tokenTable].where('id', sql_token[0].id).delete()
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// In-place hash upgrade: once a legacy scrypt token verifies, rehash the
|
|
146
|
+
// same secret with SHA-256 so every subsequent request skips the
|
|
147
|
+
// expensive scrypt path — including setups where rotation never fires
|
|
148
|
+
// (rotation disabled, WebSocket-only clients). The cookie value is
|
|
149
|
+
// unchanged, so no new cookies are needed.
|
|
150
|
+
if (sql_token[0].token_y.startsWith('$scrypt$')) {
|
|
151
|
+
Odac.DB[tokenTable]
|
|
152
|
+
.where('id', sql_token[0].id)
|
|
153
|
+
.update({token_y: this.#hashToken(odac_y)})
|
|
154
|
+
.catch(() => {})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// One-time backfill for rows created before the accept_language column
|
|
158
|
+
// existed, so the IP+language rule gains its baseline without waiting
|
|
159
|
+
// for a rotation or activity update.
|
|
160
|
+
if (sql_token[0].accept_language == null && acceptLanguage) {
|
|
161
|
+
Odac.DB[tokenTable]
|
|
162
|
+
.where('id', sql_token[0].id)
|
|
163
|
+
.update({accept_language: acceptLanguage})
|
|
164
|
+
.catch(() => {})
|
|
165
|
+
}
|
|
119
166
|
|
|
120
167
|
const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
|
|
121
168
|
const updateAge = Odac.Config.auth?.updateAge || 24 * 60 * 60 * 1000
|
|
122
169
|
const rotationAge = Odac.Config.auth?.rotationAge || 15 * 60 * 1000 // Default 15 mins for rotation
|
|
170
|
+
const rotationGrace = Odac.Config.auth?.rotationGrace || TOKEN_ROTATION_GRACE_PERIOD_MS
|
|
123
171
|
const shouldRotate = Odac.Config.auth?.rotation !== false // Allow disabling rotation
|
|
124
172
|
const now = Date.now()
|
|
125
173
|
|
|
@@ -143,6 +191,16 @@ class Auth {
|
|
|
143
191
|
|
|
144
192
|
this.#token = sql_token[0]
|
|
145
193
|
|
|
194
|
+
// Periodic sweep of expired tokens and rotated (grace-elapsed) epoch-marker
|
|
195
|
+
// rows, driven by live traffic. Why: cleanup used to run only on login(),
|
|
196
|
+
// but token-based sessions rarely log in again, so rotation leftovers
|
|
197
|
+
// accumulated indefinitely.
|
|
198
|
+
const sweepInterval = Odac.Config.auth?.sweepInterval || TOKEN_SWEEP_INTERVAL_MS
|
|
199
|
+
if (now - Auth.#lastTokenSweep > sweepInterval) {
|
|
200
|
+
Auth.#lastTokenSweep = now
|
|
201
|
+
this.#cleanupExpiredTokens(tokenTable)
|
|
202
|
+
}
|
|
203
|
+
|
|
146
204
|
let triggerRotation = false
|
|
147
205
|
let isRecoveryRotation = false
|
|
148
206
|
|
|
@@ -159,21 +217,22 @@ class Auth {
|
|
|
159
217
|
// WebSocket: Can't deliver rotated cookies, refresh active timestamp instead
|
|
160
218
|
Odac.DB[tokenTable]
|
|
161
219
|
.where('id', sql_token[0].id)
|
|
162
|
-
.update(
|
|
220
|
+
.update(this.#activityRefresh(sql_token[0], browser, acceptLanguage))
|
|
163
221
|
.catch(() => {})
|
|
164
222
|
}
|
|
165
223
|
} else if (inactiveAge > updateAge) {
|
|
166
|
-
// Fallback
|
|
224
|
+
// Fallback active update if rotation is not triggered; also refreshes
|
|
225
|
+
// the client baseline (see #activityRefresh)
|
|
167
226
|
Odac.DB[tokenTable]
|
|
168
227
|
.where('id', sql_token[0].id)
|
|
169
|
-
.update(
|
|
228
|
+
.update(this.#activityRefresh(sql_token[0], browser, acceptLanguage))
|
|
170
229
|
.catch(() => {})
|
|
171
230
|
}
|
|
172
231
|
} else {
|
|
173
232
|
// Client still presenting a rotated (grace period) token.
|
|
174
233
|
// This means the previous rotation response was lost (network hiccup, page navigation, etc.)
|
|
175
234
|
// Give the client one more chance by re-issuing new credentials.
|
|
176
|
-
const timeSinceRotation = inactiveAge - maxAge +
|
|
235
|
+
const timeSinceRotation = inactiveAge - maxAge + rotationGrace
|
|
177
236
|
if (timeSinceRotation > 5000 && canDeliverCookies) {
|
|
178
237
|
triggerRotation = true
|
|
179
238
|
isRecoveryRotation = true
|
|
@@ -188,8 +247,12 @@ class Auth {
|
|
|
188
247
|
id: Odac.DB.nanoid(),
|
|
189
248
|
user: sql_token[0].user,
|
|
190
249
|
token_x: newTokenX,
|
|
191
|
-
token_y:
|
|
192
|
-
|
|
250
|
+
token_y: this.#hashToken(newTokenY),
|
|
251
|
+
// Refresh the client fingerprint baseline on each rotation so that
|
|
252
|
+
// legitimate gradual drift (e.g. version bumps) stays tracked and
|
|
253
|
+
// doesn't accumulate into a false "version jump" later.
|
|
254
|
+
browser: this.#request.header('user-agent') || sql_token[0].browser,
|
|
255
|
+
accept_language: this.#request.header('accept-language') || sql_token[0].accept_language,
|
|
193
256
|
ip: this.#request.ip,
|
|
194
257
|
date: new Date(),
|
|
195
258
|
active: new Date()
|
|
@@ -205,7 +268,7 @@ class Auth {
|
|
|
205
268
|
if (!isRecoveryRotation) {
|
|
206
269
|
// 2a. Normal rotation: Mark old token as rotated with 60s grace period
|
|
207
270
|
// Non-blocking I/O (Fire & Forget) -> High Throughput
|
|
208
|
-
const rotatedActiveDate = new Date(now - maxAge +
|
|
271
|
+
const rotatedActiveDate = new Date(now - maxAge + rotationGrace)
|
|
209
272
|
const epochDate = new Date(0)
|
|
210
273
|
|
|
211
274
|
Odac.DB[tokenTable]
|
|
@@ -269,8 +332,9 @@ class Auth {
|
|
|
269
332
|
id: Odac.DB.nanoid(),
|
|
270
333
|
user: user[key],
|
|
271
334
|
token_x: nodeCrypto.randomBytes(32).toString('hex'),
|
|
272
|
-
token_y:
|
|
335
|
+
token_y: this.#hashToken(token_y),
|
|
273
336
|
browser: this.#request.header('user-agent'),
|
|
337
|
+
accept_language: this.#request.header('accept-language'),
|
|
274
338
|
ip: this.#request.ip
|
|
275
339
|
}
|
|
276
340
|
|
|
@@ -429,7 +493,7 @@ class Auth {
|
|
|
429
493
|
|
|
430
494
|
if (odacX && browser) {
|
|
431
495
|
// Delete current token AND any rotated grace-period tokens for this user+browser
|
|
432
|
-
// Why: After rotation, the old token stays alive for
|
|
496
|
+
// Why: After rotation, the old token stays alive for the grace period. Explicit logout must kill it too.
|
|
433
497
|
const userId = this.#user[primaryKey]
|
|
434
498
|
await Odac.DB[tokenTable].where('user', userId).where('browser', browser).delete()
|
|
435
499
|
}
|
|
@@ -673,17 +737,55 @@ class Auth {
|
|
|
673
737
|
// --- MIGRATION HELPERS (Code-First) ---
|
|
674
738
|
|
|
675
739
|
async #ensureTokenTableV2(tableName) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
740
|
+
const exists = await Odac.DB.schema.hasTable(tableName)
|
|
741
|
+
|
|
742
|
+
if (!exists) {
|
|
743
|
+
await Odac.DB.schema.createTable(tableName, t => {
|
|
744
|
+
t.string('id', 21).primary()
|
|
745
|
+
t.string('user', 21).notNullable()
|
|
746
|
+
t.string('token_x').notNullable().index() // hot-path lookup key
|
|
747
|
+
t.string('token_y').notNullable()
|
|
748
|
+
t.string('browser').notNullable()
|
|
749
|
+
t.string('accept_language')
|
|
750
|
+
t.string('ip').notNullable()
|
|
751
|
+
t.timestamp('date').defaultTo(Odac.DB.fn.now())
|
|
752
|
+
t.timestamp('active').defaultTo(Odac.DB.fn.now()).index() // sweep range scans
|
|
753
|
+
})
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Migrate existing token tables. .schema()/createTable are no-ops once the
|
|
758
|
+
// table exists, so column and index additions must be applied explicitly.
|
|
759
|
+
try {
|
|
760
|
+
if (!(await Odac.DB.schema.hasColumn(tableName, 'accept_language'))) {
|
|
761
|
+
await Odac.DB.schema.alterTable(tableName, t => {
|
|
762
|
+
t.string('accept_language')
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
} catch (e) {
|
|
766
|
+
console.error('Odac Auth Error: Failed to add accept_language column:', e.message)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Ensure token_x is indexed for the per-request lookup. Adding a duplicate
|
|
770
|
+
// index throws on most drivers, so a failure here means it already exists.
|
|
771
|
+
try {
|
|
772
|
+
await Odac.DB.schema.alterTable(tableName, t => {
|
|
773
|
+
t.index('token_x')
|
|
774
|
+
})
|
|
775
|
+
} catch {
|
|
776
|
+
/* index already present */
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Ensure active is indexed for the periodic expired-token sweep, which
|
|
780
|
+
// range-scans on it. Applied separately so one existing index doesn't
|
|
781
|
+
// block the other from being created.
|
|
782
|
+
try {
|
|
783
|
+
await Odac.DB.schema.alterTable(tableName, t => {
|
|
784
|
+
t.index('active')
|
|
785
|
+
})
|
|
786
|
+
} catch {
|
|
787
|
+
/* index already present */
|
|
788
|
+
}
|
|
687
789
|
}
|
|
688
790
|
|
|
689
791
|
async #ensureUserTableV2(tableName, primaryKey, passwordField, uniqueFields, sampleData) {
|
|
@@ -717,6 +819,132 @@ class Auth {
|
|
|
717
819
|
})
|
|
718
820
|
}
|
|
719
821
|
|
|
822
|
+
// --- TOKEN HASHING ---
|
|
823
|
+
|
|
824
|
+
// token_x/token_y are 256-bit CSPRNG values, so they are infeasible to brute-force.
|
|
825
|
+
// A slow password hash (scrypt/bcrypt) is therefore unnecessary here, and its
|
|
826
|
+
// per-request cost is significant at scale — a plain SHA-256 is cryptographically
|
|
827
|
+
// sufficient for high-entropy secrets and ~1000x cheaper on the hot auth path.
|
|
828
|
+
#hashToken(value) {
|
|
829
|
+
return '$sha256$' + nodeCrypto.createHash('sha256').update(String(value)).digest('hex')
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
#verifyToken(stored, provided) {
|
|
833
|
+
if (typeof stored !== 'string' || !provided) return false
|
|
834
|
+
|
|
835
|
+
if (stored.startsWith('$sha256$')) {
|
|
836
|
+
const expected = Buffer.from(stored.slice(8), 'hex')
|
|
837
|
+
const actual = nodeCrypto.createHash('sha256').update(String(provided)).digest()
|
|
838
|
+
if (expected.length !== actual.length) return false
|
|
839
|
+
return nodeCrypto.timingSafeEqual(expected, actual)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Backward compatibility: legacy scrypt tokens. Rehashed in place to
|
|
843
|
+
// SHA-256 immediately after the first successful verification (see check()).
|
|
844
|
+
if (stored.startsWith('$scrypt$')) {
|
|
845
|
+
return Odac.Var(stored).hashCheck(provided)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return false
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// --- SESSION ANOMALY DETECTION ---
|
|
852
|
+
|
|
853
|
+
// Extracts a coarse, version-agnostic fingerprint from a User-Agent string:
|
|
854
|
+
// browser family, OS family and *major* browser version only, so that routine
|
|
855
|
+
// minor updates don't churn the stored baseline.
|
|
856
|
+
#parseUA(ua) {
|
|
857
|
+
ua = ua || ''
|
|
858
|
+
|
|
859
|
+
// iOS variants (CriOS/FxiOS/EdgiOS) identify the same browser families.
|
|
860
|
+
const browser = /Edg(iOS)?\//.test(ua)
|
|
861
|
+
? 'Edge'
|
|
862
|
+
: /OPR\/|Opera/.test(ua)
|
|
863
|
+
? 'Opera'
|
|
864
|
+
: /(Firefox|FxiOS)\//.test(ua)
|
|
865
|
+
? 'Firefox'
|
|
866
|
+
: /(Chrome|CriOS)\//.test(ua)
|
|
867
|
+
? 'Chrome'
|
|
868
|
+
: /Safari\//.test(ua)
|
|
869
|
+
? 'Safari'
|
|
870
|
+
: 'Other'
|
|
871
|
+
|
|
872
|
+
const os = /Windows/.test(ua)
|
|
873
|
+
? 'Windows'
|
|
874
|
+
: /Mac OS X|Macintosh/.test(ua)
|
|
875
|
+
? 'macOS'
|
|
876
|
+
: /Android/.test(ua)
|
|
877
|
+
? 'Android'
|
|
878
|
+
: /iPhone|iPad|iPod/.test(ua)
|
|
879
|
+
? 'iOS'
|
|
880
|
+
: /Linux|X11/.test(ua)
|
|
881
|
+
? 'Linux'
|
|
882
|
+
: 'Other'
|
|
883
|
+
|
|
884
|
+
const pattern =
|
|
885
|
+
browser === 'Edge'
|
|
886
|
+
? /Edg(?:iOS)?\/(\d+)/
|
|
887
|
+
: browser === 'Opera'
|
|
888
|
+
? /OPR\/(\d+)/
|
|
889
|
+
: browser === 'Firefox'
|
|
890
|
+
? /(?:Firefox|FxiOS)\/(\d+)/
|
|
891
|
+
: browser === 'Chrome'
|
|
892
|
+
? /(?:Chrome|CriOS)\/(\d+)/
|
|
893
|
+
: browser === 'Safari'
|
|
894
|
+
? /Version\/(\d+)/
|
|
895
|
+
: null
|
|
896
|
+
|
|
897
|
+
const match = pattern ? ua.match(pattern) : null
|
|
898
|
+
const version = match ? parseInt(match[1], 10) || 0 : 0
|
|
899
|
+
|
|
900
|
+
return {browser, os, version}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Returns a short reason string when the presented request is anomalous enough
|
|
904
|
+
// to invalidate the token, or null when it should be accepted.
|
|
905
|
+
#detectAnomaly(token, currentUA, currentIP, currentLang) {
|
|
906
|
+
const storedUA = token.browser || ''
|
|
907
|
+
const prev = this.#parseUA(storedUA)
|
|
908
|
+
const cur = this.#parseUA(currentUA)
|
|
909
|
+
|
|
910
|
+
// Under the same cookie jar the OS and browser family cannot legitimately change.
|
|
911
|
+
if (prev.os !== cur.os) return 'os_change'
|
|
912
|
+
if (prev.browser !== cur.browser) return 'browser_change'
|
|
913
|
+
|
|
914
|
+
// A browser version downgrade, or an implausibly large jump for the elapsed
|
|
915
|
+
// idle time (a long absence justifies proportionally larger jumps).
|
|
916
|
+
if (cur.version < prev.version) return 'version_downgrade'
|
|
917
|
+
|
|
918
|
+
const lastActive = new Date(token.active).getTime()
|
|
919
|
+
const monthsInactive = Number.isFinite(lastActive) ? Math.max(0, (Date.now() - lastActive) / 2592000000) : 0
|
|
920
|
+
const allowedJump = 3 + monthsInactive * 2
|
|
921
|
+
if (cur.version - prev.version > allowedJump) return 'version_jump'
|
|
922
|
+
|
|
923
|
+
// IP-change-gated signals: a moved network paired with any other client
|
|
924
|
+
// change is treated as suspicious.
|
|
925
|
+
const ipChanged = !!token.ip && !!currentIP && token.ip !== currentIP
|
|
926
|
+
if (ipChanged) {
|
|
927
|
+
if (storedUA !== currentUA) return 'ip_ua_mismatch'
|
|
928
|
+
// Rows from before the accept_language column existed have no baseline;
|
|
929
|
+
// the rule activates once it is backfilled.
|
|
930
|
+
if (token.accept_language != null && (currentLang || '') !== token.accept_language) return 'ip_lang_mismatch'
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return null
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Builds the payload for activity-timestamp updates, refreshing the stored
|
|
937
|
+
// client baseline (UA / language) alongside it. Without this, tokens that
|
|
938
|
+
// never rotate (rotation disabled, WebSocket-only clients) keep their
|
|
939
|
+
// login-day fingerprint forever and legitimate gradual drift eventually
|
|
940
|
+
// accumulates into a false version_jump.
|
|
941
|
+
#activityRefresh(token, currentUA, currentLang) {
|
|
942
|
+
const payload = {active: new Date()}
|
|
943
|
+
if (currentUA && currentUA !== token.browser) payload.browser = currentUA
|
|
944
|
+
if (currentLang && currentLang !== token.accept_language) payload.accept_language = currentLang
|
|
945
|
+
return payload
|
|
946
|
+
}
|
|
947
|
+
|
|
720
948
|
/**
|
|
721
949
|
* Retrieves the active auth token record or a specific column from it.
|
|
722
950
|
* Why: To provide access to the current session's token metadata (e.g., auth ID, IP, date).
|
package/src/Config.js
CHANGED
|
@@ -8,7 +8,11 @@ module.exports = {
|
|
|
8
8
|
token: 'odac_auth' // This is the TABLE NAME for tokens, not a secret token.
|
|
9
9
|
},
|
|
10
10
|
request: {
|
|
11
|
-
timeout: 10000
|
|
11
|
+
timeout: 10000,
|
|
12
|
+
maxBodySize: 1e6,
|
|
13
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
14
|
+
maxFiles: 10,
|
|
15
|
+
uploadDir: null
|
|
12
16
|
},
|
|
13
17
|
encrypt: {
|
|
14
18
|
key: 'odac' // Default encryption key. MUST be overridden in production.
|
package/src/Ipc.js
CHANGED
|
@@ -8,6 +8,7 @@ class Ipc extends EventEmitter {
|
|
|
8
8
|
this.config = {}
|
|
9
9
|
this._requests = new Map() // For memory driver response tracking
|
|
10
10
|
this._subs = new Map() // For memory driver subscriptions
|
|
11
|
+
this._redisBridges = new Map() // channel -> the single node-redis listener bridging to our EventEmitter
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -263,17 +264,47 @@ class Ipc extends EventEmitter {
|
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A single channel may carry several independent consumers (e.g. a long-lived stats
|
|
269
|
+
* listener and a short-lived ack listener on the same stream). The callback reference
|
|
270
|
+
* is what tells them apart, so both drivers key subscriptions on it and both must be
|
|
271
|
+
* able to remove one without disturbing the others.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} channel
|
|
274
|
+
* @param {function} callback
|
|
275
|
+
* @returns {Promise<{channel: string, callback: function, unsubscribe: function}>} Handle
|
|
276
|
+
* that removes only this subscription, so callers need not retain the callback themselves.
|
|
277
|
+
*/
|
|
266
278
|
async subscribe(channel, callback) {
|
|
279
|
+
if (typeof callback !== 'function') {
|
|
280
|
+
throw new TypeError(`Odac.Ipc.subscribe('${channel}') requires a callback function.`)
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
if (this.config.driver === 'redis') {
|
|
268
284
|
if (!this.subRedis) {
|
|
269
285
|
this.subRedis = this.redis.duplicate()
|
|
270
286
|
await this.subRedis.connect()
|
|
271
|
-
this.subRedis.on('message', (chan, msg) => {
|
|
272
|
-
this.emit(chan, JSON.parse(msg))
|
|
273
|
-
})
|
|
274
287
|
}
|
|
275
|
-
//
|
|
276
|
-
|
|
288
|
+
// node-redis v4+ delivers messages to a per-channel listener passed to subscribe();
|
|
289
|
+
// it does not emit a client-wide 'message' event. We register exactly one bridge
|
|
290
|
+
// listener per channel and fan out locally, so removing one consumer never tears
|
|
291
|
+
// down the Redis subscription the other consumers still depend on.
|
|
292
|
+
if (!this._redisBridges.has(channel)) {
|
|
293
|
+
const bridge = raw => {
|
|
294
|
+
try {
|
|
295
|
+
this.emit(channel, JSON.parse(raw))
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('[Odac Ipc] Failed to parse message on channel "' + channel + '":', err)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this._redisBridges.set(channel, bridge)
|
|
301
|
+
try {
|
|
302
|
+
await this.subRedis.subscribe(channel, bridge)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this._redisBridges.delete(channel)
|
|
305
|
+
throw err
|
|
306
|
+
}
|
|
307
|
+
}
|
|
277
308
|
this.on(channel, callback)
|
|
278
309
|
} else {
|
|
279
310
|
// Memory driver subscription
|
|
@@ -284,27 +315,67 @@ class Ipc extends EventEmitter {
|
|
|
284
315
|
}
|
|
285
316
|
this._subs.get(channel).add(callback)
|
|
286
317
|
}
|
|
318
|
+
|
|
319
|
+
return {channel, callback, unsubscribe: () => this.unsubscribe(channel, callback)}
|
|
287
320
|
}
|
|
288
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Removes a single subscription. Other consumers of the same channel keep receiving
|
|
324
|
+
* messages; the underlying Redis/Primary subscription is torn down only once the last
|
|
325
|
+
* consumer is gone.
|
|
326
|
+
*/
|
|
289
327
|
async unsubscribe(channel, callback) {
|
|
328
|
+
if (typeof callback !== 'function') {
|
|
329
|
+
// Without a callback there is no way to know which consumer is leaving, so we
|
|
330
|
+
// refuse to guess: silently dropping the call leaks the listener, and dropping
|
|
331
|
+
// every listener would kill co-tenants on the channel.
|
|
332
|
+
console.warn(
|
|
333
|
+
`[Odac Ipc] unsubscribe('${channel}') was called without a callback and did nothing. ` +
|
|
334
|
+
`Pass the callback (or the handle returned by subscribe()) to remove one subscription, ` +
|
|
335
|
+
`or call unsubscribeAll('${channel}') to remove every subscription on the channel. ` +
|
|
336
|
+
`This call will throw in the next major version.`
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
if (this.config.driver === 'redis') {
|
|
291
342
|
this.removeListener(channel, callback)
|
|
292
343
|
// If no more listeners for this channel, unsubscribe from redis to save resources
|
|
293
|
-
if (this.listenerCount(channel) === 0
|
|
294
|
-
await this.
|
|
344
|
+
if (this.listenerCount(channel) === 0) {
|
|
345
|
+
await this._teardownRedisChannel(channel)
|
|
295
346
|
}
|
|
296
347
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this._sendMemory('unsubscribe', {channel})
|
|
303
|
-
}
|
|
348
|
+
const callbacks = this._subs.get(channel)
|
|
349
|
+
if (!callbacks || !callbacks.delete(callback)) return
|
|
350
|
+
if (callbacks.size === 0) {
|
|
351
|
+
this._subs.delete(channel)
|
|
352
|
+
this._sendMemory('unsubscribe', {channel})
|
|
304
353
|
}
|
|
305
354
|
}
|
|
306
355
|
}
|
|
307
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Removes every subscription on a channel. Kept separate from unsubscribe() so that
|
|
359
|
+
* tearing down a shared channel is always a deliberate act, never an accident.
|
|
360
|
+
*/
|
|
361
|
+
async unsubscribeAll(channel) {
|
|
362
|
+
if (this.config.driver === 'redis') {
|
|
363
|
+
this.removeAllListeners(channel)
|
|
364
|
+
await this._teardownRedisChannel(channel)
|
|
365
|
+
} else {
|
|
366
|
+
if (this._subs.delete(channel)) {
|
|
367
|
+
this._sendMemory('unsubscribe', {channel})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _teardownRedisChannel(channel) {
|
|
373
|
+
const bridge = this._redisBridges.get(channel)
|
|
374
|
+
if (!bridge) return
|
|
375
|
+
this._redisBridges.delete(channel)
|
|
376
|
+
if (this.subRedis) await this.subRedis.unsubscribe(channel, bridge)
|
|
377
|
+
}
|
|
378
|
+
|
|
308
379
|
// --- Drivers ---
|
|
309
380
|
|
|
310
381
|
async _initRedis() {
|
|
@@ -587,6 +658,8 @@ class Ipc extends EventEmitter {
|
|
|
587
658
|
*/
|
|
588
659
|
async close() {
|
|
589
660
|
if (this.config.driver === 'redis') {
|
|
661
|
+
for (const channel of this._redisBridges.keys()) this.removeAllListeners(channel)
|
|
662
|
+
this._redisBridges.clear()
|
|
590
663
|
if (this.subRedis) {
|
|
591
664
|
await this.subRedis.quit().catch(() => {})
|
|
592
665
|
this.subRedis = null
|
package/src/Odac.js
CHANGED
|
@@ -33,20 +33,36 @@ module.exports = {
|
|
|
33
33
|
_odac._ipcSubs = []
|
|
34
34
|
const ipcSingleton = require('./Ipc.js')
|
|
35
35
|
|
|
36
|
+
const forgetSub = (channel, callback) => {
|
|
37
|
+
const index = _odac._ipcSubs.findIndex(s => s.channel === channel && s.callback === callback)
|
|
38
|
+
if (index > -1) _odac._ipcSubs.splice(index, 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
_odac.Ipc = new Proxy(ipcSingleton, {
|
|
37
42
|
get(target, prop) {
|
|
38
43
|
if (prop === 'subscribe') {
|
|
39
44
|
return async (channel, callback) => {
|
|
40
|
-
const
|
|
45
|
+
const handle = await target.subscribe(channel, callback)
|
|
41
46
|
_odac._ipcSubs.push({channel, callback})
|
|
42
|
-
|
|
47
|
+
// Route the handle's unsubscribe back through this instance so that releasing a
|
|
48
|
+
// subscription via the handle also drops it from the request-scoped cleanup list.
|
|
49
|
+
return {
|
|
50
|
+
...handle,
|
|
51
|
+
unsubscribe: () => _odac.Ipc.unsubscribe(channel, callback)
|
|
52
|
+
}
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
if (prop === 'unsubscribe') {
|
|
46
56
|
return async (channel, callback) => {
|
|
47
57
|
const res = await target.unsubscribe(channel, callback)
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
forgetSub(channel, callback)
|
|
59
|
+
return res
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (prop === 'unsubscribeAll') {
|
|
63
|
+
return async channel => {
|
|
64
|
+
const res = await target.unsubscribeAll(channel)
|
|
65
|
+
_odac._ipcSubs = _odac._ipcSubs.filter(s => s.channel !== channel)
|
|
50
66
|
return res
|
|
51
67
|
}
|
|
52
68
|
}
|
|
@@ -138,6 +154,9 @@ module.exports = {
|
|
|
138
154
|
_odac.request = function (key) {
|
|
139
155
|
return _odac.Request.request(key)
|
|
140
156
|
}
|
|
157
|
+
_odac.file = function (key) {
|
|
158
|
+
return _odac.Request.file(key)
|
|
159
|
+
}
|
|
141
160
|
_odac.set = function (key, value) {
|
|
142
161
|
return _odac.Request.set(key, value)
|
|
143
162
|
}
|