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