odac 1.0.1 → 1.2.0

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/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +33 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/auto-pr-description.yml +3 -1
  6. package/.github/workflows/release.yml +42 -1
  7. package/.github/workflows/test-coverage.yml +6 -5
  8. package/.github/workflows/test-publish.yml +36 -0
  9. package/.husky/pre-commit +10 -0
  10. package/.husky/pre-push +13 -0
  11. package/.releaserc.js +3 -3
  12. package/CHANGELOG.md +184 -0
  13. package/README.md +53 -34
  14. package/bin/odac.js +181 -49
  15. package/client/odac.js +878 -995
  16. package/docs/backend/01-overview/03-development-server.md +39 -46
  17. package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
  18. package/docs/backend/03-config/00-configuration-overview.md +15 -6
  19. package/docs/backend/03-config/01-database-connection.md +3 -3
  20. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  21. package/docs/backend/03-config/03-request-timeout.md +1 -1
  22. package/docs/backend/03-config/04-environment-variables.md +4 -4
  23. package/docs/backend/03-config/05-early-hints.md +2 -2
  24. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  25. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  26. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  27. package/docs/backend/04-routing/09-websocket.md +29 -0
  28. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  29. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  30. package/docs/backend/05-controllers/03-controller-classes.md +61 -55
  31. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  32. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  33. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  34. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  35. package/docs/backend/07-views/03-variables.md +5 -5
  36. package/docs/backend/07-views/04-request-data.md +1 -1
  37. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  38. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  39. package/docs/backend/08-database/01-getting-started.md +100 -0
  40. package/docs/backend/08-database/02-basics.md +136 -0
  41. package/docs/backend/08-database/03-advanced.md +84 -0
  42. package/docs/backend/08-database/04-migrations.md +48 -0
  43. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  44. package/docs/backend/10-authentication/03-register.md +9 -2
  45. package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
  46. package/docs/backend/10-authentication/05-session-management.md +16 -2
  47. package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
  48. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  49. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  50. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  51. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  52. package/docs/backend/13-utilities/02-ipc.md +73 -0
  53. package/docs/frontend/01-overview/01-introduction.md +5 -1
  54. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  55. package/docs/index.json +21 -125
  56. package/eslint.config.mjs +5 -47
  57. package/jest.config.js +1 -1
  58. package/package.json +16 -7
  59. package/src/Auth.js +414 -121
  60. package/src/Config.js +12 -7
  61. package/src/Database.js +188 -0
  62. package/src/Env.js +3 -1
  63. package/src/Ipc.js +337 -0
  64. package/src/Lang.js +9 -2
  65. package/src/Mail.js +408 -37
  66. package/src/Odac.js +105 -40
  67. package/src/Request.js +71 -49
  68. package/src/Route/Cron.js +62 -18
  69. package/src/Route/Internal.js +215 -12
  70. package/src/Route/Middleware.js +7 -2
  71. package/src/Route.js +372 -109
  72. package/src/Server.js +118 -12
  73. package/src/Storage.js +169 -0
  74. package/src/Token.js +6 -4
  75. package/src/Validator.js +95 -3
  76. package/src/Var.js +22 -6
  77. package/src/View/EarlyHints.js +43 -33
  78. package/src/View/Form.js +210 -28
  79. package/src/View.js +108 -7
  80. package/src/WebSocket.js +18 -3
  81. package/template/odac.json +5 -0
  82. package/template/package.json +3 -1
  83. package/template/route/www.js +12 -10
  84. package/template/view/content/home.html +3 -3
  85. package/template/view/head/main.html +2 -2
  86. package/test/Client.test.js +168 -0
  87. package/test/Config.test.js +112 -0
  88. package/test/Lang.test.js +92 -0
  89. package/test/Odac.test.js +86 -0
  90. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  91. package/test/{framework/Route.test.js → Route.test.js} +1 -1
  92. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  93. package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
  94. package/test/scripts/check-coverage.js +4 -4
  95. package/docs/backend/08-database/01-database-connection.md +0 -99
  96. package/docs/backend/08-database/02-using-mysql.md +0 -322
  97. package/src/Mysql.js +0 -575
  98. package/template/config.json +0 -5
  99. package/test/cli/Cli.test.js +0 -36
  100. package/test/core/Candy.test.js +0 -234
  101. package/test/core/Commands.test.js +0 -538
  102. package/test/core/Config.test.js +0 -1432
  103. package/test/core/Lang.test.js +0 -250
  104. package/test/core/Process.test.js +0 -156
  105. package/test/server/Api.test.js +0 -647
  106. package/test/server/DNS.test.js +0 -2050
  107. package/test/server/DNS.test.js.bak +0 -2084
  108. package/test/server/Hub.test.js +0 -497
  109. package/test/server/Log.test.js +0 -73
  110. package/test/server/Mail.account.test_.js +0 -460
  111. package/test/server/Mail.init.test_.js +0 -411
  112. package/test/server/Mail.test_.js +0 -1340
  113. package/test/server/SSL.test_.js +0 -1491
  114. package/test/server/Server.test.js +0 -765
  115. package/test/server/Service.test_.js +0 -1127
  116. package/test/server/Subdomain.test.js +0 -440
  117. package/test/server/Web/Firewall.test.js +0 -175
  118. package/test/server/Web/Proxy.test.js +0 -397
  119. package/test/server/Web.test.js +0 -1494
  120. package/test/server/__mocks__/acme-client.js +0 -17
  121. package/test/server/__mocks__/bcrypt.js +0 -50
  122. package/test/server/__mocks__/child_process.js +0 -389
  123. package/test/server/__mocks__/crypto.js +0 -432
  124. package/test/server/__mocks__/fs.js +0 -450
  125. package/test/server/__mocks__/globalOdac.js +0 -227
  126. package/test/server/__mocks__/http.js +0 -575
  127. package/test/server/__mocks__/https.js +0 -272
  128. package/test/server/__mocks__/index.js +0 -249
  129. package/test/server/__mocks__/mail/server.js +0 -100
  130. package/test/server/__mocks__/mail/smtp.js +0 -31
  131. package/test/server/__mocks__/mailparser.js +0 -81
  132. package/test/server/__mocks__/net.js +0 -369
  133. package/test/server/__mocks__/node-forge.js +0 -328
  134. package/test/server/__mocks__/os.js +0 -320
  135. package/test/server/__mocks__/path.js +0 -291
  136. package/test/server/__mocks__/selfsigned.js +0 -8
  137. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  138. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  139. package/test/server/__mocks__/smtp-server.js +0 -106
  140. package/test/server/__mocks__/sqlite3.js +0 -394
  141. package/test/server/__mocks__/testFactories.js +0 -299
  142. package/test/server/__mocks__/testHelpers.js +0 -363
  143. package/test/server/__mocks__/tls.js +0 -229
package/src/Auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ const nodeCrypto = require('crypto')
1
2
  class Auth {
2
3
  #request = null
3
4
  #table = null
@@ -23,60 +24,113 @@ class Auth {
23
24
  if (!this.#table) return false
24
25
  if (where) {
25
26
  if (!this.#validateInput(where)) return false
26
- let sql = Odac.Mysql.table(this.#table)
27
- if (!sql) {
28
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
27
+
28
+ // Using new DB API
29
+ let query = Odac.DB[this.#table]
30
+
31
+ if (!query) {
32
+ console.error('Odac Auth Error: Database not configured.')
29
33
  return false
30
34
  }
31
- for (let key in where) sql = sql.orWhere(key, where[key] instanceof Promise ? await where[key] : where[key])
32
- if (!sql.rows()) return false
33
- let get = await sql.get()
34
- let equal = false
35
- for (var user of get) {
36
- equal = Object.keys(where).length > 0
37
- for (let key of Object.keys(where)) {
38
- if (where[key] instanceof Promise) where[key] = await where[key]
39
- if (!user[key]) equal = false
40
- if (user[key] === where[key]) equal = equal && true
41
- else if (Odac.Var(user[key]).is('bcrypt')) equal = equal && Odac.Var(user[key]).hashCheck(where[key])
42
- else if (Odac.Var(user[key]).is('md5')) equal = equal && Odac.Var(where[key]).md5() === user[key]
35
+
36
+ // Knex build queries differently than previous builder
37
+ // Need to chain where clauses
38
+ // Resolve input promises upfront to avoid side effects and race conditions
39
+ const criteria = {}
40
+ const keys = Object.keys(where)
41
+
42
+ if (keys.length === 0) return false
43
+
44
+ for (const key of keys) {
45
+ criteria[key] = where[key] instanceof Promise ? await where[key] : where[key]
46
+ }
47
+
48
+ // Chain where clauses
49
+ for (const key in criteria) {
50
+ query = query.orWhere(key, criteria[key])
51
+ }
52
+
53
+ // Execute query
54
+ const candidates = await query
55
+
56
+ if (!candidates || candidates.length === 0) return false
57
+
58
+ // Iterate candidates to find the exact match
59
+ candidateLoop: for (const user of candidates) {
60
+ for (const key of keys) {
61
+ const userValue = user[key]
62
+ const targetValue = criteria[key]
63
+
64
+ if (!userValue) continue candidateLoop
65
+
66
+ // Strict equality check
67
+ if (userValue === targetValue) continue
68
+
69
+ // Security: Check hashed fields (Bcrypt/MD5)
70
+ const valueHandler = Odac.Var(userValue)
71
+ let hashMatch = false
72
+
73
+ if (valueHandler.is('hash')) {
74
+ hashMatch = valueHandler.hashCheck(targetValue)
75
+ } else if (valueHandler.is('md5')) {
76
+ hashMatch = Odac.Var(targetValue).md5() === userValue
77
+ }
78
+
79
+ if (!hashMatch) continue candidateLoop
43
80
  }
44
- if (equal) break
81
+
82
+ return user
45
83
  }
46
- if (!equal) return false
47
- return user
84
+
85
+ return false
48
86
  } else if (this.#user) {
49
87
  return true
50
88
  } else {
51
- let check_table = await Odac.Mysql.run('SHOW TABLES LIKE ?', [this.#table])
52
- if (check_table.length == 0) return false
89
+ // Checking for token
53
90
  let odac_x = this.#request.cookie('odac_x')
54
91
  let odac_y = this.#request.cookie('odac_y')
55
92
  let browser = this.#request.header('user-agent')
93
+
56
94
  if (!odac_x || !odac_y || !browser) return false
95
+
57
96
  const tokenTable = Odac.Config.auth.token || 'odac_auth'
58
97
  const primaryKey = Odac.Config.auth.key || 'id'
59
- let sql_token = await Odac.Mysql.table(tokenTable).where(['token_x', odac_x], ['browser', browser]).get()
60
- if (sql_token.length !== 1) return false
98
+
99
+ // Code First Migration: Ensure token table exists and clean up old tokens
100
+ try {
101
+ await this.#ensureTokenTableV2(tokenTable)
102
+ } catch (e) {
103
+ console.error('Odac Auth Error: Failed to ensure token table exists:', e.message)
104
+ }
105
+
106
+ // Query token
107
+ let sql_token = await Odac.DB[tokenTable].where('token_x', odac_x).where('browser', browser)
108
+
109
+ if (!sql_token || sql_token.length !== 1) return false
110
+
61
111
  if (!Odac.Var(sql_token[0].token_y).hashCheck(odac_y)) return false
62
112
 
63
113
  const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
64
114
  const updateAge = Odac.Config.auth?.updateAge || 24 * 60 * 60 * 1000
65
115
  const now = Date.now()
116
+
117
+ // Active comes as Date object usually from drivers
66
118
  const lastActive = new Date(sql_token[0].active).getTime()
67
119
  const inactiveAge = now - lastActive
68
120
 
69
121
  if (inactiveAge > maxAge) {
70
- await Odac.Mysql.table(tokenTable).where('id', sql_token[0].id).delete()
122
+ await Odac.DB[tokenTable].where('id', sql_token[0].id).delete()
71
123
  return false
72
124
  }
73
125
 
74
- this.#user = await Odac.Mysql.table(this.#table).where(primaryKey, sql_token[0].user).first()
126
+ this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
127
+ if (!this.#user) return false
75
128
 
76
129
  if (inactiveAge > updateAge) {
77
- Odac.Mysql.table(tokenTable)
130
+ // Use update instead of set for Knex
131
+ Odac.DB[tokenTable]
78
132
  .where('id', sql_token[0].id)
79
- .set({active: new Date()})
133
+ .update({active: new Date()}) // knex uses .update
80
134
  .catch(() => {})
81
135
  }
82
136
 
@@ -88,51 +142,47 @@ class Auth {
88
142
  this.#user = null
89
143
  let user = await this.check(where)
90
144
  if (!user) return false
145
+
91
146
  if (!Odac.Config.auth) Odac.Config.auth = {}
92
147
  let key = Odac.Config.auth.key || 'id'
93
148
  let token = Odac.Config.auth.token || 'odac_auth'
94
- const mysql = require('mysql2')
95
- const safeTokenTable = mysql.escapeId(token)
96
- let check_table = await Odac.Mysql.run('SHOW TABLES LIKE ?', [token])
97
- if (check_table === false) {
98
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
99
- return false
100
- }
101
- if (check_table.length == 0)
102
- await Odac.Mysql.run(
103
- `CREATE TABLE ${safeTokenTable} (id INT NOT NULL AUTO_INCREMENT, user INT NOT NULL, token_x VARCHAR(255) NOT NULL, token_y VARCHAR(255) NOT NULL, browser VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, \`date\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, \`active\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id))`
104
- )
149
+
150
+ await this.#ensureTokenTableV2(token)
105
151
 
106
152
  this.#cleanupExpiredTokens(token)
107
153
 
108
- let token_y = Odac.Var(Math.random().toString() + Date.now().toString() + this.#request.id + this.#request.ip).md5()
154
+ // Generate secure token using generic CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
155
+ // Why: Math.random() is predictable and MD5 is a broken hashing algorithm.
156
+ // We use 32 bytes (256 bits) of entropy which is industry standard.
157
+ let token_y = nodeCrypto.randomBytes(32).toString('hex')
158
+
109
159
  let cookie = {
160
+ id: Odac.DB.nanoid(),
110
161
  user: user[key],
111
- token_x: Odac.Var(Math.random().toString() + Date.now().toString()).md5(),
162
+ token_x: nodeCrypto.randomBytes(32).toString('hex'),
112
163
  token_y: Odac.Var(token_y).hash(),
113
164
  browser: this.#request.header('user-agent'),
114
165
  ip: this.#request.ip
115
166
  }
167
+
116
168
  this.#request.cookie('odac_x', cookie.token_x, {
117
169
  httpOnly: true,
118
170
  secure: true,
119
- sameSite: 'Strict'
171
+ sameSite: 'Lax'
120
172
  })
121
- this.#request.cookie('odac_y', token_y, {httpOnly: true, secure: true, sameSite: 'Strict'})
122
- let mysqlTable = Odac.Mysql.table(token)
123
- if (!mysqlTable) {
124
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
125
- return false
126
- }
127
- let sql = await mysqlTable.insert(cookie)
128
- return sql !== false
173
+ this.#request.cookie('odac_y', token_y, {httpOnly: true, secure: true, sameSite: 'Lax'})
174
+
175
+ // Knex insert returns ids on some dbs, promise resolves to result
176
+ const result = await Odac.DB[token].insert(cookie)
177
+ return !!result
129
178
  }
130
179
 
131
180
  async #cleanupExpiredTokens(tokenTable) {
132
181
  const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
182
+ // Knex handles dates well, but better to pass JS Date object
133
183
  const cutoffDate = new Date(Date.now() - maxAge)
134
184
 
135
- Odac.Mysql.table(tokenTable)
185
+ Odac.DB[tokenTable]
136
186
  .where('active', '<', cutoffDate)
137
187
  .delete()
138
188
  .catch(() => {})
@@ -148,58 +198,81 @@ class Auth {
148
198
  const passwordField = options.passwordField || 'password'
149
199
  const uniqueFields = options.uniqueFields || ['email']
150
200
 
151
- const checkTable = await Odac.Mysql.run('SHOW TABLES LIKE ?', [this.#table])
152
- if (checkTable === false) {
153
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
154
- return {success: false, error: 'Database connection not configured'}
155
- }
156
- if (checkTable.length === 0) {
157
- await this.#createUserTable(this.#table, primaryKey, passwordField, uniqueFields, data)
201
+ try {
202
+ await this.#ensureUserTableV2(this.#table, primaryKey, passwordField, uniqueFields, data)
203
+ } catch (e) {
204
+ // If DB not configured or connection failed
205
+ console.error('Odac Auth Error:', e.message)
206
+ return {success: false, error: 'Database connection failed'}
158
207
  }
159
208
 
160
209
  if (!data || typeof data !== 'object') {
161
210
  return {success: false, error: 'Invalid data provided'}
162
211
  }
163
212
 
164
- if (data[passwordField] && !Odac.Var(data[passwordField]).is('bcrypt')) {
213
+ if (data[passwordField] && !Odac.Var(data[passwordField]).is('hash')) {
165
214
  data[passwordField] = Odac.Var(data[passwordField]).hash()
166
215
  }
167
216
 
217
+ // Check unique fields
168
218
  for (const field of uniqueFields) {
169
219
  if (data[field]) {
170
- const mysqlTable = Odac.Mysql.table(this.#table)
171
- if (!mysqlTable) {
172
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
173
- return {success: false, error: 'Database connection not configured'}
220
+ try {
221
+ const existing = await Odac.DB[this.#table].where(field, data[field]).first()
222
+ if (existing) {
223
+ return {success: false, error: `${field} already exists`, field}
224
+ }
225
+ } catch (e) {
226
+ console.error('Odac Auth Error checking unique:', e.message)
227
+ return {success: false, error: 'A database error occurred during registration.'}
174
228
  }
175
- const existing = await mysqlTable.where(field, data[field]).first()
176
- if (existing) {
177
- return {success: false, error: `${field} already exists`, field}
229
+ }
230
+ }
231
+
232
+ // Auto-detect ID strategy (NanoID vs Auto-Increment)
233
+ let shouldGenerateId = true
234
+
235
+ // 1. Check User Config Preference
236
+ if (Odac.Config.auth.idType === 'int' || Odac.Config.auth.idType === 'auto') shouldGenerateId = false
237
+ else if (Odac.Config.auth.idType === 'string' || Odac.Config.auth.idType === 'nanoid') shouldGenerateId = true
238
+ else {
239
+ // 2. Detect from Database Schema (and Cache it)
240
+ if (!Odac.Config.auth._viewedPkType) {
241
+ try {
242
+ // Determine column type of primary key
243
+ const colInfo = await Odac.DB[this.#table].columnInfo(primaryKey)
244
+ const type = colInfo?.type ? colInfo.type.toLowerCase() : 'string'
245
+
246
+ // Common integer types in various SQL dialects
247
+ if (type.includes('int') || type.includes('serial') || type.includes('number')) {
248
+ Odac.Config.auth._viewedPkType = 'int'
249
+ } else {
250
+ Odac.Config.auth._viewedPkType = 'string'
251
+ }
252
+ } catch {
253
+ // If table doesn't exist yet or error, default to string (NanoID) as per our new standard
254
+ Odac.Config.auth._viewedPkType = 'string'
178
255
  }
179
256
  }
257
+
258
+ if (Odac.Config.auth._viewedPkType === 'int') shouldGenerateId = false
180
259
  }
181
260
 
182
261
  try {
183
- const mysqlTable = Odac.Mysql.table(this.#table)
184
- if (!mysqlTable) {
185
- console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
186
- return {success: false, error: 'Database connection not configured'}
262
+ if (shouldGenerateId && !data[primaryKey]) {
263
+ data[primaryKey] = Odac.DB.nanoid()
187
264
  }
188
- const insertResult = await mysqlTable.insert(data)
189
- if (insertResult === false) {
190
- console.error('Odac Auth Error: Failed to insert user into database - query failed')
191
- console.error('Data attempted to insert:', {...data, [passwordField]: '[REDACTED]'})
192
- return {success: false, error: 'Failed to create user'}
193
- }
194
- if (!insertResult.affected || insertResult.affected === 0) {
195
- console.error('Odac Auth Error: Insert query succeeded but no rows were affected')
196
- console.error('Insert result:', insertResult)
197
- console.error('Data attempted to insert:', {...data, [passwordField]: '[REDACTED]'})
265
+
266
+ await Odac.DB[this.#table].insert(data)
267
+
268
+ let userId = data[primaryKey]
269
+
270
+ if (!userId) {
271
+ console.error('Odac Auth Error: Could not determine new user ID')
198
272
  return {success: false, error: 'Failed to create user'}
199
273
  }
200
274
 
201
- const userId = insertResult.id
202
- const newUser = await Odac.Mysql.table(this.#table).where(primaryKey, userId).first()
275
+ const newUser = await Odac.DB[this.#table].where(primaryKey, userId).first()
203
276
 
204
277
  if (!newUser) {
205
278
  return {success: false, error: 'User created but could not be retrieved'}
@@ -221,7 +294,6 @@ class Auth {
221
294
  } catch (error) {
222
295
  console.error('Odac Auth Error: Registration failed with exception')
223
296
  console.error('Error:', error.message)
224
- console.error('Stack:', error.stack)
225
297
  return {success: false, error: error.message || 'Registration failed'}
226
298
  }
227
299
  }
@@ -235,10 +307,7 @@ class Auth {
235
307
  const browser = this.#request.header('user-agent')
236
308
 
237
309
  if (odacX && browser) {
238
- const mysqlTable = Odac.Mysql.table(token)
239
- if (mysqlTable) {
240
- await mysqlTable.where(['token_x', odacX], ['browser', browser]).delete()
241
- }
310
+ await Odac.DB[token].where('token_x', odacX).where('browser', browser).delete()
242
311
  }
243
312
 
244
313
  this.#request.cookie('odac_x', '', {maxAge: -1})
@@ -248,61 +317,285 @@ class Auth {
248
317
  return true
249
318
  }
250
319
 
251
- async #createUserTable(tableName, primaryKey, passwordField, uniqueFields, sampleData) {
252
- const mysql = require('mysql2')
253
- const columns = []
320
+ // --- MAGIC LINK START ---
254
321
 
255
- const safePrimaryKey = mysql.escapeId(primaryKey)
256
- columns.push(`${safePrimaryKey} INT NOT NULL AUTO_INCREMENT`)
322
+ async magic(email, options = {}) {
323
+ if (!Odac.Config.auth) Odac.Config.auth = {}
324
+ this.#table = Odac.Config.auth.table || 'users'
325
+ const magicTable = Odac.Config.auth.magicTable || 'odac_magic'
257
326
 
258
- for (const field of uniqueFields) {
259
- if (field !== primaryKey) {
260
- const safeField = mysql.escapeId(field)
261
- columns.push(`${safeField} VARCHAR(255) NOT NULL UNIQUE`)
327
+ // Ensure magic table exists
328
+ try {
329
+ await this.#ensureMagicLinkTable(magicTable)
330
+ } catch (e) {
331
+ console.error('Failed to ensure magic link table exists:', e)
332
+ // Consider returning an error here to prevent further execution.
333
+ }
334
+
335
+ // Rate limiting: Check recent requests from this IP and email
336
+ // Magic link requires only email input, so rate limits should be very strict
337
+ const rateLimitWindow = Odac.Config.auth?.magicLinkRateLimit || 60 * 60 * 1000 // 1 hour default
338
+ const maxAttempts = Odac.Config.auth?.magicLinkMaxAttempts || 2 // Per email - very strict
339
+ const maxAttemptsPerIP = Odac.Config.auth?.magicLinkMaxAttemptsPerIP || 5 // Per IP
340
+ const sessionCooldown = Odac.Config.auth?.magicLinkSessionCooldown || 30 * 1000 // 30 seconds default
341
+
342
+ // 1. Session Rate Limit (Fastest, no DB access)
343
+ const lastRequestTime = this.#request.session('magic_last_request')
344
+ if (lastRequestTime && Date.now() - lastRequestTime < sessionCooldown) {
345
+ const remaining = Math.ceil((sessionCooldown - (Date.now() - lastRequestTime)) / 1000)
346
+ return {success: false, error: `Please wait ${remaining} seconds before requesting another link.`}
347
+ }
348
+ this.#request.session('magic_last_request', Date.now())
349
+
350
+ try {
351
+ // 2. Database Rate Limits
352
+ // Check email-based rate limit
353
+ const recentEmailRequests = await Odac.DB[magicTable]
354
+ .where('email', email)
355
+ .where('created_at', '>', new Date(Date.now() - rateLimitWindow))
356
+
357
+ if (recentEmailRequests && recentEmailRequests.length >= maxAttempts) {
358
+ return {success: false, error: 'Too many login attempts. Please wait a while before trying again.'}
359
+ }
360
+
361
+ // Check IP-based rate limit (prevents mass enumeration attacks)
362
+ const clientIP = this.#request.ip
363
+ const recentIPRequests = await Odac.DB[magicTable]
364
+ .where('ip', clientIP)
365
+ .where('created_at', '>', new Date(Date.now() - rateLimitWindow))
366
+
367
+ if (recentIPRequests && recentIPRequests.length >= maxAttemptsPerIP) {
368
+ return {success: false, error: 'Too many requests from this IP. Please wait a while.'}
262
369
  }
370
+ } catch {
371
+ // Ignore rate limit check errors, proceed with request
263
372
  }
264
373
 
265
- if (!uniqueFields.includes(passwordField) && passwordField !== primaryKey) {
266
- const safePasswordField = mysql.escapeId(passwordField)
267
- columns.push(`${safePasswordField} VARCHAR(255) NOT NULL`)
374
+ // Cleanup: Remove expired tokens periodically
375
+ this.#cleanupExpiredMagicLinks(magicTable)
376
+
377
+ // 1. Check if user exists.
378
+ // We proceed regardless of whether the user exists or not.
379
+ // If they exist, it's a login. If not, it will accept the link and Auto-Register them (Passwordless Signup).
380
+ // let user = null
381
+ try {
382
+ // Check if user exists (logic preserved but unused 'user' variable issue fixed)
383
+ const existingUser = await Odac.DB[this.#table].where('email', email).first()
384
+ if (existingUser) {
385
+ /* user exists */
386
+ }
387
+ } catch (e) {
388
+ // Ignore table not found error, treat as user not found
389
+ if (e.code !== '42P01' && !e.message.includes('no such table')) {
390
+ throw e
391
+ }
268
392
  }
269
393
 
270
- for (const key in sampleData) {
271
- if (key === primaryKey || uniqueFields.includes(key) || key === passwordField) continue
394
+ // If user doesn't exist, we still proceed to send the link to allow for "Sign Up via Magic Link" (Passwordless Signup)
395
+ // The user will be created upon verification.
272
396
 
273
- const value = sampleData[key]
274
- let columnType = 'VARCHAR(255)'
397
+ // 2. Generate secure token
398
+ const tokenRaw = nodeCrypto.randomBytes(32).toString('hex')
399
+ const tokenHash = Odac.Var(tokenRaw).hash() // Hash it for DB storage
275
400
 
276
- if (typeof value === 'number') {
277
- if (Number.isInteger(value)) {
278
- columnType = value > 2147483647 ? 'BIGINT' : 'INT'
279
- } else {
280
- columnType = 'DECIMAL(10,2)'
281
- }
282
- } else if (typeof value === 'boolean') {
283
- columnType = 'TINYINT(1)'
284
- } else if (value && value.length > 255) {
285
- columnType = 'TEXT'
401
+ // 3. Save to DB
402
+ await Odac.DB[magicTable].insert({
403
+ email: email,
404
+ token_hash: tokenHash,
405
+ ip: this.#request.ip,
406
+ browser: this.#request.header('user-agent'),
407
+ expires_at: new Date(Date.now() + 15 * 60 * 1000) // 15 mins
408
+ })
409
+
410
+ // 4. Send Email
411
+ let link = `${(this.#request.ssl ? 'https://' : 'http://') + this.#request.host}/_odac/magic-verify?token=${tokenRaw}&email=${encodeURIComponent(email)}`
412
+ if (options.redirect) link += `&redirect_url=${encodeURIComponent(options.redirect)}`
413
+
414
+ try {
415
+ let mail = Odac.Mail(options.template || 'auth/magic-link')
416
+ .to(email)
417
+ .subject(options.subject || 'Login to our site')
418
+
419
+ if (options.from) {
420
+ if (typeof options.from === 'object') mail.from(options.from.email, options.from.name)
421
+ else mail.from(options.from)
422
+ }
423
+
424
+ await mail.send({
425
+ link: link,
426
+ magic_link: link,
427
+ network: this.#request.host,
428
+ ip: this.#request.ip
429
+ })
430
+ } catch (e) {
431
+ console.error('Magic Link Email Error:', e)
432
+ return {success: false, error: 'Failed to send email'}
433
+ }
434
+
435
+ return {success: true, message: 'Magic link sent!'}
436
+ }
437
+
438
+ async verifyMagicLink(tokenRaw, email) {
439
+ if (!tokenRaw || !email) {
440
+ return {success: false, error: 'Invalid link'}
441
+ }
442
+
443
+ const magicTable = Odac.Config.auth?.magicTable || 'odac_magic'
444
+ this.#table = Odac.Config.auth?.table || 'users'
445
+ const primaryKey = Odac.Config.auth?.key || 'id'
446
+
447
+ // 1. Find potential tokens for this email
448
+ const records = await Odac.DB[magicTable].where('email', email).where('expires_at', '>', new Date())
449
+
450
+ if (!records || records.length === 0) {
451
+ return {success: false, error: 'Link expired or invalid'}
452
+ }
453
+
454
+ // 2. Find the matching token (verify hash)
455
+ let validRecord = null
456
+ // Iterate through all records without an early exit to mitigate timing attacks.
457
+ for (const record of records) {
458
+ if (Odac.Var(record.token_hash).hashCheck(tokenRaw)) {
459
+ validRecord = record
460
+ }
461
+ }
462
+
463
+ if (!validRecord) {
464
+ return {success: false, error: 'Invalid token'}
465
+ }
466
+
467
+ // 3. Consume all tokens for this email to prevent reuse of other valid links.
468
+ await Odac.DB[magicTable].where('email', email).delete()
469
+
470
+ // 4. Log in user (or Register if new)
471
+ let user = await Odac.DB[this.#table].where('email', email).first()
472
+
473
+ if (!user) {
474
+ // Auto-Register the user
475
+
476
+ const passwordField = Odac.Config.auth?.passwordField || 'password'
477
+ // Optimization: If explicitly configured as passwordless, skip password generation overhead
478
+ const isPasswordless = Odac.Config.auth?.passwordless === true
479
+
480
+ const registerData = {
481
+ email: email
482
+ }
483
+
484
+ if (!isPasswordless) {
485
+ // Generate a random high-entropy password since they are using passwordless auth but DB might require password
486
+ registerData[passwordField] = nodeCrypto.randomBytes(32).toString('hex')
487
+ }
488
+
489
+ let regResult = await this.register(registerData)
490
+
491
+ // Fallback: If we tried to be secure (sent password) but DB failed because column doesn't exist, retry without password
492
+ if (
493
+ !isPasswordless &&
494
+ !regResult.success &&
495
+ regResult.error &&
496
+ (regResult.error.includes(`column "${passwordField}"`) || regResult.error.includes(`Unknown column '${passwordField}'`)) &&
497
+ (regResult.error.includes('does not exist') || regResult.error.includes('field list'))
498
+ ) {
499
+ regResult = await this.register({
500
+ email: email
501
+ })
286
502
  }
287
503
 
288
- const safeKey = mysql.escapeId(key)
289
- columns.push(`${safeKey} ${columnType} NULL`)
504
+ if (!regResult.success) {
505
+ return {success: false, error: 'Registration failed: ' + regResult.error}
506
+ }
507
+
508
+ user = regResult.user
290
509
  }
291
510
 
292
- columns.push(`${mysql.escapeId('created_at')} TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
293
- columns.push(`${mysql.escapeId('updated_at')} TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`)
294
- columns.push(`PRIMARY KEY (${safePrimaryKey})`)
511
+ // Login logic similar to login()
512
+ const loginData = {}
513
+ loginData[primaryKey] = user[primaryKey]
514
+ await this.login(loginData)
515
+
516
+ return {success: true, user: user}
517
+ }
518
+
519
+ async #ensureMagicLinkTable(tableName) {
520
+ await Odac.DB[tableName].schema(t => {
521
+ t.increments('id')
522
+ t.string('email').notNullable().index()
523
+ t.string('token_hash').notNullable()
524
+ t.string('ip')
525
+ t.string('browser')
526
+ t.timestamp('created_at').defaultTo(Odac.DB.fn.now())
527
+ t.timestamp('expires_at')
528
+ })
529
+ }
530
+
531
+ #cleanupExpiredMagicLinks(tableName) {
532
+ // Run cleanup asynchronously without awaiting (fire and forget)
533
+ Odac.DB[tableName]
534
+ .where('expires_at', '<', new Date())
535
+ .delete()
536
+ .catch(() => {}) // Silently ignore cleanup errors
537
+ }
538
+
539
+ // --- MAGIC LINK END ---
540
+
541
+ // --- MIGRATION HELPERS (Code-First) ---
542
+
543
+ async #ensureTokenTableV2(tableName) {
544
+ // Using .schema helper
545
+ await Odac.DB[tableName].schema(t => {
546
+ t.string('id', 21).primary()
547
+ t.string('user', 21).notNullable()
548
+ t.string('token_x').notNullable()
549
+ t.string('token_y').notNullable()
550
+ t.string('browser').notNullable()
551
+ t.string('ip').notNullable()
552
+ t.timestamp('date').defaultTo(Odac.DB.fn.now())
553
+ t.timestamp('active').defaultTo(Odac.DB.fn.now())
554
+ })
555
+ }
556
+
557
+ async #ensureUserTableV2(tableName, primaryKey, passwordField, uniqueFields, sampleData) {
558
+ await Odac.DB[tableName].schema(t => {
559
+ t.string(primaryKey, 21).primary()
295
560
 
296
- const safeTableName = mysql.escapeId(tableName)
297
- const sql = `CREATE TABLE ${safeTableName} (${columns.join(', ')}) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
561
+ for (const field of uniqueFields) {
562
+ if (field !== primaryKey) t.string(field).notNullable().unique()
563
+ }
298
564
 
299
- await Odac.Mysql.run(sql)
565
+ if (!uniqueFields.includes(passwordField) && passwordField !== primaryKey) {
566
+ t.string(passwordField).notNullable()
567
+ }
568
+
569
+ // Heuristic type guessing from sampleData
570
+ for (const key in sampleData) {
571
+ if (key === primaryKey || uniqueFields.includes(key) || key === passwordField) continue
572
+
573
+ const val = sampleData[key]
574
+ if (typeof val === 'number') {
575
+ if (Number.isInteger(val)) t.integer(key)
576
+ else t.float(key)
577
+ } else if (typeof val === 'boolean') {
578
+ t.boolean(key)
579
+ } else {
580
+ t.string(key)
581
+ }
582
+ }
583
+
584
+ t.timestamps(true, true) // created_at, updated_at
585
+ })
300
586
  }
301
587
 
302
- user(col) {
588
+ /**
589
+ * Retrieves the authenticated user or a specific column.
590
+ * Why: To provide access to the current user's session data securely.
591
+ *
592
+ * @param {string|null} [col=null] - The column to retrieve, or null for the full user object.
593
+ * @returns {object|string|number|boolean|false} The user object, column value, or false if not logged in.
594
+ */
595
+ user(col = null) {
303
596
  if (!this.#user) return false
304
597
  if (col === null) return this.#user
305
- else return this.#user[col]
598
+ return this.#user[col]
306
599
  }
307
600
  }
308
601