odac 1.4.14 → 1.4.16

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 (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/odac.js +1 -0
  3. package/client/odac.js +57 -1
  4. package/docs/ai/skills/backend/forms.md +54 -3
  5. package/docs/ai/skills/backend/validation.md +30 -2
  6. package/docs/ai/skills/frontend/core.md +5 -5
  7. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
  8. package/docs/backend/05-forms/01-custom-forms.md +96 -0
  9. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  10. package/docs/frontend/01-overview/01-introduction.md +8 -8
  11. package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
  12. package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
  13. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
  14. package/docs/frontend/03-forms/01-form-handling.md +84 -31
  15. package/docs/frontend/04-api-requests/01-get-post.md +25 -25
  16. package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
  17. package/docs/frontend/06-websocket/00-overview.md +1 -1
  18. package/index.js +11 -1
  19. package/package.json +2 -1
  20. package/src/Auth.js +252 -24
  21. package/src/Config.js +5 -1
  22. package/src/Odac.js +3 -0
  23. package/src/Request.js +235 -34
  24. package/src/Route/Internal.js +32 -3
  25. package/src/Validator.js +143 -2
  26. package/src/View/Form.js +56 -0
  27. package/src/View/Image.js +15 -6
  28. package/src/View.js +2 -2
  29. package/test/Auth/check.test.js +219 -18
  30. package/test/Auth/verifyMagicLink.test.js +8 -1
  31. package/test/Odac/cache.test.js +5 -2
  32. package/test/Odac/image.test.js +8 -5
  33. package/test/Odac/instance.test.js +5 -2
  34. package/test/Request/cache.test.js +1 -0
  35. package/test/Request/multipart.test.js +164 -0
  36. package/test/Validator/file.test.js +172 -0
  37. package/test/View/Form/generateFieldHtml.test.js +56 -0
  38. package/test/View/Image/serve.test.js +9 -4
  39. package/test/View/Image/url.test.js +10 -4
  40. package/test/View/addNavigateAttribute.test.js +10 -1
  41. package/test/View/print.test.js +10 -1
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.14",
10
+ "version": "1.4.16",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@tailwindcss/cli": "^4.1.18",
25
+ "busboy": "^1.6.0",
25
26
  "esbuild": "^0.25.12",
26
27
  "knex": "^3.1.0",
27
28
  "lmdb": "^3.4.4",
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
- const TOKEN_ROTATION_GRACE_PERIOD_MS = 60 * 1000
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
- let sql_token = await Odac.DB[tokenTable].where('token_x', odac_x).where('browser', browser)
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
- if (!Odac.Var(sql_token[0].token_y).hashCheck(odac_y)) return false
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({active: new Date()})
220
+ .update(this.#activityRefresh(sql_token[0], browser, acceptLanguage))
163
221
  .catch(() => {})
164
222
  }
165
223
  } else if (inactiveAge > updateAge) {
166
- // Fallback simple active update if rotation is not triggered
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({active: new Date()})
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 + TOKEN_ROTATION_GRACE_PERIOD_MS
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: Odac.Var(newTokenY).hash(),
192
- browser: sql_token[0].browser,
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 + TOKEN_ROTATION_GRACE_PERIOD_MS)
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: Odac.Var(token_y).hash(),
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 ~60s. Explicit logout must kill it too.
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
- // Using .schema helper
677
- await Odac.DB[tableName].schema(t => {
678
- t.string('id', 21).primary()
679
- t.string('user', 21).notNullable()
680
- t.string('token_x').notNullable()
681
- t.string('token_y').notNullable()
682
- t.string('browser').notNullable()
683
- t.string('ip').notNullable()
684
- t.timestamp('date').defaultTo(Odac.DB.fn.now())
685
- t.timestamp('active').defaultTo(Odac.DB.fn.now())
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/Odac.js CHANGED
@@ -138,6 +138,9 @@ module.exports = {
138
138
  _odac.request = function (key) {
139
139
  return _odac.Request.request(key)
140
140
  }
141
+ _odac.file = function (key) {
142
+ return _odac.Request.file(key)
143
+ }
141
144
  _odac.set = function (key, value) {
142
145
  return _odac.Request.set(key, value)
143
146
  }