odac 1.3.0 → 1.4.1

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 (52) hide show
  1. package/.agent/rules/memory.md +10 -1
  2. package/.github/workflows/release.yml +1 -5
  3. package/AGENTS.md +47 -0
  4. package/CHANGELOG.md +58 -0
  5. package/README.md +11 -1
  6. package/bin/odac.js +359 -6
  7. package/client/odac.js +15 -11
  8. package/docs/ai/README.md +49 -0
  9. package/docs/ai/skills/SKILL.md +40 -0
  10. package/docs/ai/skills/backend/authentication.md +74 -0
  11. package/docs/ai/skills/backend/config.md +39 -0
  12. package/docs/ai/skills/backend/controllers.md +69 -0
  13. package/docs/ai/skills/backend/cron.md +57 -0
  14. package/docs/ai/skills/backend/database.md +37 -0
  15. package/docs/ai/skills/backend/forms.md +26 -0
  16. package/docs/ai/skills/backend/ipc.md +62 -0
  17. package/docs/ai/skills/backend/mail.md +41 -0
  18. package/docs/ai/skills/backend/migrations.md +80 -0
  19. package/docs/ai/skills/backend/request_response.md +42 -0
  20. package/docs/ai/skills/backend/routing.md +58 -0
  21. package/docs/ai/skills/backend/storage.md +50 -0
  22. package/docs/ai/skills/backend/streaming.md +41 -0
  23. package/docs/ai/skills/backend/structure.md +64 -0
  24. package/docs/ai/skills/backend/translations.md +49 -0
  25. package/docs/ai/skills/backend/utilities.md +31 -0
  26. package/docs/ai/skills/backend/validation.md +60 -0
  27. package/docs/ai/skills/backend/views.md +68 -0
  28. package/docs/ai/skills/frontend/core.md +73 -0
  29. package/docs/ai/skills/frontend/forms.md +28 -0
  30. package/docs/ai/skills/frontend/navigation.md +27 -0
  31. package/docs/ai/skills/frontend/realtime.md +54 -0
  32. package/docs/backend/08-database/04-migrations.md +258 -37
  33. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
  34. package/docs/backend/10-authentication/05-session-management.md +25 -3
  35. package/package.json +1 -1
  36. package/src/Auth.js +128 -17
  37. package/src/Config.js +1 -1
  38. package/src/Database/ConnectionFactory.js +69 -0
  39. package/src/Database/Migration.js +1203 -0
  40. package/src/Database.js +35 -35
  41. package/src/Route/Internal.js +21 -18
  42. package/src/Route/MimeTypes.js +56 -0
  43. package/src/Route.js +40 -63
  44. package/src/View/Form.js +91 -51
  45. package/src/View.js +8 -3
  46. package/template/schema/users.js +23 -0
  47. package/test/Auth.test.js +310 -0
  48. package/test/Client.test.js +29 -0
  49. package/test/Config.test.js +7 -0
  50. package/test/Database/ConnectionFactory.test.js +80 -0
  51. package/test/Migration.test.js +943 -0
  52. package/test/View/Form.test.js +37 -0
@@ -1,48 +1,269 @@
1
- # Code-First Migrations
1
+ # Schema-First Migrations
2
2
 
3
- Migration files are great, but sometimes (especially in rapid development or zero-config apps) you want dependencies to define their own table structures automatically.
3
+ ODAC uses a **declarative, schema-first** approach to database migrations. Instead of writing sequential migration files, you define the **desired final state** of each table in a schema file. The engine automatically diffs the schema against your database and applies the necessary changes.
4
4
 
5
- ODAC uses the `.schema()` helper for this logic.
5
+ > **AI Agent Friendly:** A single schema file per table = instant understanding of the final database state. No need to scan hundreds of migration files.
6
6
 
7
- ## Ensuring Tables Exist
7
+ ---
8
8
 
9
- The `.schema()` method checks if a table exists. If it **does not exist**, it runs the provided callback to create it. If it **already exists**, it does nothing.
9
+ ## Quick Start
10
+
11
+ ### 1. Define Your Schema
12
+
13
+ Create a file in the `schema/` directory for each table:
14
+
15
+ ```javascript
16
+ // schema/users.js
17
+ 'use strict'
18
+
19
+ module.exports = {
20
+ columns: {
21
+ id: {type: 'increments'},
22
+ name: {type: 'string', length: 255, nullable: false},
23
+ email: {type: 'string', length: 255, nullable: false},
24
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
25
+ is_active: {type: 'boolean', default: true},
26
+ timestamps: {type: 'timestamps'}
27
+ },
28
+
29
+ indexes: [
30
+ {columns: ['email'], unique: true},
31
+ {columns: ['role', 'is_active']}
32
+ ]
33
+ }
34
+ ```
35
+
36
+ ### 2. Start Your App
37
+
38
+ Migrations run **automatically** when the application starts. No manual commands needed:
39
+
40
+ ```bash
41
+ npx odac dev # Development
42
+ npx odac start # Production
43
+ ```
44
+
45
+ On startup, the engine detects `schema/users.js`, creates the table, and applies indexes — all before the server accepts traffic.
46
+
47
+ > **Zero-Config:** Just define the schema file and deploy. The framework handles the rest.
48
+
49
+ You can also run migrations manually via CLI for inspection or rollback:
50
+
51
+ ### 3. Modify Your Schema
52
+
53
+ Simply edit the schema file. Add a column, remove a column, add an index — the engine handles the rest:
54
+
55
+ ```javascript
56
+ // schema/users.js — added 'bio' column, removed 'is_active'
57
+ module.exports = {
58
+ columns: {
59
+ id: {type: 'increments'},
60
+ name: {type: 'string', length: 255, nullable: false},
61
+ email: {type: 'string', length: 255, nullable: false},
62
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
63
+ bio: {type: 'text', nullable: true},
64
+ timestamps: {type: 'timestamps'}
65
+ },
66
+
67
+ indexes: [
68
+ {columns: ['email'], unique: true}
69
+ ]
70
+ }
71
+ ```
72
+
73
+ ```bash
74
+ npx odac migrate
75
+ ```
76
+
77
+ ```
78
+ [default]
79
+ + ADD COLUMN users.bio
80
+ - DROP COLUMN users.is_active
81
+ - DROP INDEX users (role, is_active)
82
+
83
+ ✅ 3 operation(s) completed.
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Column Types
89
+
90
+ | Type | Usage | Options |
91
+ |------|-------|---------|
92
+ | `increments` | Auto-increment primary key | — |
93
+ | `bigIncrements` | Big auto-increment | — |
94
+ | `integer` | Integer | `unsigned` |
95
+ | `bigInteger` | Big integer | `unsigned` |
96
+ | `float` | Floating point | `precision`, `scale` |
97
+ | `decimal` | Exact decimal | `precision`, `scale` |
98
+ | `string` | Varchar | `length` (default: 255) |
99
+ | `text` | Text blob | `textType` ('text', 'mediumtext', 'longtext') |
100
+ | `boolean` | Boolean | — |
101
+ | `date` | Date only | — |
102
+ | `datetime` | Date and time | — |
103
+ | `timestamp` | Timestamp | — |
104
+ | `timestamps` | Virtual: creates `created_at` + `updated_at` | — |
105
+ | `time` | Time only | — |
106
+ | `binary` | Binary data | `length` |
107
+ | `json` | JSON | — |
108
+ | `jsonb` | Binary JSON (PostgreSQL) | — |
109
+ | `uuid` | UUID | — |
110
+ | `enum` | Enumeration | `values` (array) |
111
+
112
+ ## Column Modifiers
113
+
114
+ ```javascript
115
+ {
116
+ type: 'string',
117
+ length: 100,
118
+ nullable: false, // NOT NULL constraint
119
+ default: 'untitled', // Default value
120
+ unsigned: true, // Unsigned integer
121
+ unique: true, // Unique constraint (inline)
122
+ primary: true, // Primary key
123
+ comment: 'The title', // Column comment
124
+ references: { // Foreign key
125
+ table: 'categories',
126
+ column: 'id'
127
+ },
128
+ onDelete: 'CASCADE',
129
+ onUpdate: 'CASCADE'
130
+ }
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Seed Data
136
+
137
+ Schema files can include declarative seed data that is applied idempotently on every migration:
10
138
 
11
139
  ```javascript
12
- // Ensure 'products' table exists on the fly
13
- await Odac.DB.products.schema(t => {
14
- t.increments('id');
15
- t.string('name').notNullable();
16
- t.decimal('price', 10, 2);
17
- t.boolean('is_active').defaultTo(true);
18
-
19
- // Automatic timestamps (created_at, updated_at)
20
- t.timestamps(true, true);
21
- });
22
- ```
23
-
24
- The `t` argument is a Schema Builder. You can define columns using standard types like:
25
- - `t.string()`
26
- - `t.integer()`
27
- - `t.boolean()`
28
- - `t.text()`
29
- - `t.date()`
30
- - `t.json()`
31
-
32
- ## Usage Example
33
-
34
- A typical pattern is to define schemas in your module's initialization or before the first insert.
140
+ // schema/roles.js
141
+ module.exports = {
142
+ columns: {
143
+ id: {type: 'increments'},
144
+ name: {type: 'string', length: 50},
145
+ level: {type: 'integer', default: 0}
146
+ },
147
+
148
+ indexes: [],
149
+
150
+ seed: [
151
+ {name: 'admin', level: 100},
152
+ {name: 'editor', level: 50},
153
+ {name: 'user', level: 1}
154
+ ],
155
+ seedKey: 'name'
156
+ }
157
+ ```
158
+
159
+ - **`seed`** — Array of rows to ensure exist
160
+ - **`seedKey`** — Column used for uniqueness check (required when `seed` is present)
161
+
162
+ **Behavior:** If the row exists (matched by `seedKey`), it updates if values differ. If not, it inserts. Safe to run repeatedly.
163
+
164
+ ---
165
+
166
+ ## Data Migrations
167
+
168
+ For **one-time data transformations** (splitting columns, backfilling, etc.), use imperative migration files:
169
+
170
+ ```
171
+ migration/
172
+ 20260225_001_split_names.js
173
+ ```
35
174
 
36
175
  ```javascript
37
- // In your controller or module
38
- async function init() {
39
- await Odac.DB.logs.schema(t => {
40
- t.string('level');
41
- t.text('message');
42
- t.timestamps();
43
- });
176
+ // migration/20260225_001_split_names.js
177
+ module.exports = {
178
+ async up(db) {
179
+ const users = await db('users').select('id', 'full_name')
180
+ for (const user of users) {
181
+ const [first, ...rest] = user.full_name.split(' ')
182
+ await db('users').where('id', user.id).update({
183
+ first_name: first,
184
+ last_name: rest.join(' ')
185
+ })
186
+ }
187
+ },
188
+
189
+ async down(db) {
190
+ const users = await db('users').select('id', 'first_name', 'last_name')
191
+ for (const user of users) {
192
+ await db('users').where('id', user.id).update({
193
+ full_name: `${user.first_name} ${user.last_name}`
194
+ })
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ Migration files run **once** and are tracked in the `_odac_migrations` table.
201
+
202
+ ---
203
+
204
+ ## Multiple Databases
205
+
206
+ If your project has multiple database connections, organize schemas by connection:
207
+
208
+ ```
209
+ schema/
210
+ users.js ← default connection
211
+ posts.js ← default connection
212
+ analytics/ ← 'analytics' connection
213
+ events.js
214
+ pageviews.js
215
+ ```
216
+
217
+ The folder name matches the connection key in your `odac.json`:
218
+
219
+ ```json
220
+ {
221
+ "database": {
222
+ "default": {"type": "mysql", "database": "main_db"},
223
+ "analytics": {"type": "postgres", "database": "analytics_db"}
224
+ }
44
225
  }
226
+ ```
227
+
228
+ Migration files follow the same convention:
229
+
230
+ ```
231
+ migration/
232
+ 20260225_001_auto.js ← default connection
233
+ analytics/
234
+ 20260225_001_backfill.js ← analytics connection
235
+ ```
236
+
237
+ ---
238
+
239
+ ## CLI Commands
45
240
 
46
- // Later...
47
- await Odac.DB.logs.insert({ level: 'info', message: 'App started' });
241
+ ```bash
242
+ # Run all pending migrations (schema diff + files + seeds)
243
+ npx odac migrate
244
+
245
+ # Target a specific database connection
246
+ npx odac migrate --db=analytics
247
+
248
+ # Show pending changes without applying (dry-run)
249
+ npx odac migrate:status
250
+
251
+ # Rollback the last batch of migration files
252
+ npx odac migrate:rollback
253
+
254
+ # Reverse-engineer current database into schema/ files
255
+ npx odac migrate:snapshot
256
+ npx odac migrate:snapshot --db=analytics
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Snapshot — Importing Existing Databases
262
+
263
+ For existing projects, use `migrate:snapshot` to generate schema files from your current database:
264
+
265
+ ```bash
266
+ npx odac migrate:snapshot
48
267
  ```
268
+
269
+ This creates a schema file for each table. Review and adjust the generated files, then use them as your source of truth going forward.
@@ -11,6 +11,8 @@ The `Odac.Auth` service is your bouncer, managing who gets in and who stays out.
11
11
 
12
12
  When you call this, `Auth` creates a secure session for the user.
13
13
 
14
+ > **💡 Enterprise Security:** ODAC automatically handles **Token Rotation** every 15 minutes (configurable) and includes built-in **CSRF protection** for all forms. Sessions are persistent across browser restarts by default.
15
+
14
16
  #### Checking the Guest List
15
17
 
16
18
  * `Odac.Auth.isLogin()`: Is the current user logged in? Returns `true` or `false`.
@@ -29,6 +29,20 @@ Sessions use a **sliding window** approach (similar to NextAuth.js):
29
29
  3. User inactive for 30 days, session expires
30
30
  4. Active users stay logged in indefinitely (up to 30 days of inactivity)
31
31
 
32
+ ### Token Rotation (Enterprise Grade)
33
+
34
+ Odac implements a non-blocking **Refresh Token Rotation** mechanism to enhance security:
35
+
36
+ - **rotationAge**: How often to rotate tokens (default: 15 minutes)
37
+ - **Grace Period**: When a token is rotated, the old token remains valid for **60 seconds** to prevent race conditions in Single Page Applications (SPAs) making concurrent requests.
38
+
39
+ **How it works:**
40
+ 1. A request is made with an active token older than `rotationAge`.
41
+ 2. Odac issues a brand new token set and sends them as cookies.
42
+ 3. The old token is marked as "rotated" and assigned a 60-second lütuf (grace) period.
43
+ 4. Subsequent concurrent requests using the old token still pass within those 60 seconds.
44
+ 5. After 60 seconds, the old token is naturally expired.
45
+
32
46
  ### Configuration
33
47
 
34
48
  Configure session behavior in `odac.json`:
@@ -39,21 +53,29 @@ Configure session behavior in `odac.json`:
39
53
  "table": "users",
40
54
  "token": "user_tokens",
41
55
  "maxAge": 2592000000,
42
- "updateAge": 86400000
56
+ "updateAge": 86400000,
57
+ "rotationAge": 900000,
58
+ "rotation": true
43
59
  }
44
60
  }
45
61
  ```
46
62
 
47
63
  **Options:**
48
64
 
49
- - `maxAge` (milliseconds): Maximum inactivity period before session expires
65
+ - `maxAge` (milliseconds): Maximum inactivity period before session expires. **Note:** Cookies also use this value for persistence.
50
66
  - Default: `2592000000` (30 days)
51
67
  - Example: `604800000` (7 days)
52
68
 
53
- - `updateAge` (milliseconds): How often to update the session timestamp
69
+ - `updateAge` (milliseconds): How often to update the session timestamp (heartbeat)
54
70
  - Default: `86400000` (1 day)
55
71
  - Example: `3600000` (1 hour)
56
72
 
73
+ - `rotationAge` (milliseconds): How often to rotate the session tokens
74
+ - Default: `900000` (15 minutes)
75
+
76
+ - `rotation` (boolean): Enable or disable token rotation
77
+ - Default: `true`
78
+
57
79
  ### Common Configurations
58
80
 
59
81
  **Short sessions (banking apps):**
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.3.0",
10
+ "version": "1.4.1",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Auth.js CHANGED
@@ -1,8 +1,11 @@
1
1
  const nodeCrypto = require('crypto')
2
+ const ROTATED_TOKEN_EPOCH_THRESHOLD_MS = 31536000000
3
+ const TOKEN_ROTATION_GRACE_PERIOD_MS = 60 * 1000
2
4
  class Auth {
3
5
  #request = null
4
6
  #table = null
5
7
  #user = null
8
+ static #migrationCache = new Set()
6
9
 
7
10
  constructor(request) {
8
11
  this.#request = request
@@ -98,7 +101,10 @@ class Auth {
98
101
 
99
102
  // Code First Migration: Ensure token table exists and clean up old tokens
100
103
  try {
101
- await this.#ensureTokenTableV2(tokenTable)
104
+ if (!Auth.#migrationCache.has(tokenTable)) {
105
+ await this.#ensureTokenTableV2(tokenTable)
106
+ Auth.#migrationCache.add(tokenTable)
107
+ }
102
108
  } catch (e) {
103
109
  console.error('Odac Auth Error: Failed to ensure token table exists:', e.message)
104
110
  }
@@ -112,13 +118,21 @@ class Auth {
112
118
 
113
119
  const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
114
120
  const updateAge = Odac.Config.auth?.updateAge || 24 * 60 * 60 * 1000
121
+ const rotationAge = Odac.Config.auth?.rotationAge || 15 * 60 * 1000 // Default 15 mins for rotation
122
+ const shouldRotate = Odac.Config.auth?.rotation !== false // Allow disabling rotation
115
123
  const now = Date.now()
116
124
 
117
125
  // Active comes as Date object usually from drivers
118
126
  const lastActive = new Date(sql_token[0].active).getTime()
127
+ const tokenDate = new Date(sql_token[0].date).getTime()
119
128
  const inactiveAge = now - lastActive
129
+ const tokenAge = now - tokenDate
130
+
131
+ // If date is before 1971, it's a marker for a rotated (grace period) token
132
+ const isRotated = tokenDate < ROTATED_TOKEN_EPOCH_THRESHOLD_MS
120
133
 
121
134
  if (inactiveAge > maxAge) {
135
+ // Naturally cleans up expired tokens and rotated tokens after grace period
122
136
  await Odac.DB[tokenTable].where('id', sql_token[0].id).delete()
123
137
  return false
124
138
  }
@@ -126,12 +140,89 @@ class Auth {
126
140
  this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
127
141
  if (!this.#user) return false
128
142
 
129
- if (inactiveAge > updateAge) {
130
- // Use update instead of set for Knex
131
- Odac.DB[tokenTable]
132
- .where('id', sql_token[0].id)
133
- .update({active: new Date()}) // knex uses .update
134
- .catch(() => {})
143
+ let triggerRotation = false
144
+ let isRecoveryRotation = false
145
+
146
+ if (!isRotated) {
147
+ if (shouldRotate && tokenAge > rotationAge) {
148
+ triggerRotation = true
149
+ } else if (inactiveAge > updateAge) {
150
+ // Fallback simple active update if rotation is not triggered
151
+ Odac.DB[tokenTable]
152
+ .where('id', sql_token[0].id)
153
+ .update({active: new Date()})
154
+ .catch(() => {})
155
+ }
156
+ } else {
157
+ // Client still presenting a rotated (grace period) token.
158
+ // This means the previous rotation response was lost (network hiccup, page navigation, etc.)
159
+ // Give the client one more chance by re-issuing new credentials.
160
+ const timeSinceRotation = inactiveAge - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS
161
+ if (timeSinceRotation > 5000) {
162
+ triggerRotation = true
163
+ isRecoveryRotation = true
164
+ }
165
+ }
166
+
167
+ if (triggerRotation) {
168
+ // --- Token Rotation ---
169
+ const newTokenX = nodeCrypto.randomBytes(32).toString('hex')
170
+ const newTokenY = nodeCrypto.randomBytes(32).toString('hex')
171
+ const newToken = {
172
+ id: Odac.DB.nanoid(),
173
+ user: sql_token[0].user,
174
+ token_x: newTokenX,
175
+ token_y: Odac.Var(newTokenY).hash(),
176
+ browser: sql_token[0].browser,
177
+ ip: this.#request.ip,
178
+ date: new Date(),
179
+ active: new Date()
180
+ }
181
+
182
+ // 1. Persist new token (await to ensure it exists before client uses new cookies)
183
+ const insertOk = await Odac.DB[tokenTable].insert(newToken).catch(e => {
184
+ console.error('Odac Auth Error: Token rotation failed', e.message)
185
+ return false
186
+ })
187
+
188
+ if (insertOk !== false) {
189
+ if (!isRecoveryRotation) {
190
+ // 2a. Normal rotation: Mark old token as rotated with 60s grace period
191
+ // Non-blocking I/O (Fire & Forget) -> High Throughput
192
+ const rotatedActiveDate = new Date(now - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS)
193
+ const epochDate = new Date(0)
194
+
195
+ Odac.DB[tokenTable]
196
+ .where('id', sql_token[0].id)
197
+ .update({
198
+ active: rotatedActiveDate,
199
+ date: epochDate
200
+ })
201
+ .catch(() => {})
202
+ } else {
203
+ // 2b. Recovery rotation: Delete old rotated token immediately.
204
+ // Why: Prevents unbounded token multiplication. The old token already
205
+ // had its grace period; one recovery attempt is the maximum.
206
+ Odac.DB[tokenTable]
207
+ .where('id', sql_token[0].id)
208
+ .delete()
209
+ .catch(() => {})
210
+ }
211
+
212
+ // 3. Issue new cookies immediately
213
+ this.#request.cookie('odac_x', newTokenX, {
214
+ httpOnly: true,
215
+ secure: true,
216
+ sameSite: 'Lax',
217
+ 'max-age': Math.floor(maxAge / 1000)
218
+ })
219
+ this.#request.cookie('odac_y', newTokenY, {
220
+ httpOnly: true,
221
+ secure: true,
222
+ sameSite: 'Lax',
223
+ 'max-age': Math.floor(maxAge / 1000)
224
+ })
225
+ }
135
226
  }
136
227
 
137
228
  return true
@@ -143,11 +234,13 @@ class Auth {
143
234
  let user = await this.check(where)
144
235
  if (!user) return false
145
236
 
146
- if (!Odac.Config.auth) Odac.Config.auth = {}
147
237
  let key = Odac.Config.auth.key || 'id'
148
238
  let token = Odac.Config.auth.token || 'odac_auth'
149
239
 
150
- await this.#ensureTokenTableV2(token)
240
+ if (!Auth.#migrationCache.has(token)) {
241
+ await this.#ensureTokenTableV2(token)
242
+ Auth.#migrationCache.add(token)
243
+ }
151
244
 
152
245
  this.#cleanupExpiredTokens(token)
153
246
 
@@ -165,12 +258,20 @@ class Auth {
165
258
  ip: this.#request.ip
166
259
  }
167
260
 
261
+ const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
262
+
168
263
  this.#request.cookie('odac_x', cookie.token_x, {
169
264
  httpOnly: true,
170
265
  secure: true,
171
- sameSite: 'Lax'
266
+ sameSite: 'Lax',
267
+ 'max-age': Math.floor(maxAge / 1000)
268
+ })
269
+ this.#request.cookie('odac_y', token_y, {
270
+ httpOnly: true,
271
+ secure: true,
272
+ sameSite: 'Lax',
273
+ 'max-age': Math.floor(maxAge / 1000)
172
274
  })
173
- this.#request.cookie('odac_y', token_y, {httpOnly: true, secure: true, sameSite: 'Lax'})
174
275
 
175
276
  // Knex insert returns ids on some dbs, promise resolves to result
176
277
  const result = await Odac.DB[token].insert(cookie)
@@ -199,7 +300,10 @@ class Auth {
199
300
  const uniqueFields = options.uniqueFields || ['email']
200
301
 
201
302
  try {
202
- await this.#ensureUserTableV2(this.#table, primaryKey, passwordField, uniqueFields, data)
303
+ if (!Auth.#migrationCache.has(this.#table)) {
304
+ await this.#ensureUserTableV2(this.#table, primaryKey, passwordField, uniqueFields, data)
305
+ Auth.#migrationCache.add(this.#table)
306
+ }
203
307
  } catch (e) {
204
308
  // If DB not configured or connection failed
205
309
  console.error('Odac Auth Error:', e.message)
@@ -302,16 +406,20 @@ class Auth {
302
406
  if (!this.#user) return false
303
407
 
304
408
  if (!Odac.Config.auth) Odac.Config.auth = {}
305
- const token = Odac.Config.auth.token || 'user_tokens'
409
+ const tokenTable = Odac.Config.auth.token || 'user_tokens'
410
+ const primaryKey = Odac.Config.auth.key || 'id'
306
411
  const odacX = this.#request.cookie('odac_x')
307
412
  const browser = this.#request.header('user-agent')
308
413
 
309
414
  if (odacX && browser) {
310
- await Odac.DB[token].where('token_x', odacX).where('browser', browser).delete()
415
+ // Delete current token AND any rotated grace-period tokens for this user+browser
416
+ // Why: After rotation, the old token stays alive for ~60s. Explicit logout must kill it too.
417
+ const userId = this.#user[primaryKey]
418
+ await Odac.DB[tokenTable].where('user', userId).where('browser', browser).delete()
311
419
  }
312
420
 
313
- this.#request.cookie('odac_x', '', {maxAge: -1})
314
- this.#request.cookie('odac_y', '', {maxAge: -1})
421
+ this.#request.cookie('odac_x', '', {'max-age': -1})
422
+ this.#request.cookie('odac_y', '', {'max-age': -1})
315
423
 
316
424
  this.#user = null
317
425
  return true
@@ -326,7 +434,10 @@ class Auth {
326
434
 
327
435
  // Ensure magic table exists
328
436
  try {
329
- await this.#ensureMagicLinkTable(magicTable)
437
+ if (!Auth.#migrationCache.has(magicTable)) {
438
+ await this.#ensureMagicLinkTable(magicTable)
439
+ Auth.#migrationCache.add(magicTable)
440
+ }
330
441
  } catch (e) {
331
442
  console.error('Failed to ensure magic link table exists:', e)
332
443
  // Consider returning an error here to prevent further execution.
package/src/Config.js CHANGED
@@ -46,7 +46,7 @@ module.exports = {
46
46
 
47
47
  _interpolate: function (obj) {
48
48
  if (typeof obj === 'string') {
49
- return obj.replace(/\$\{(\w+)\}/g, (_, key) => {
49
+ return obj.replace(/\$\{([^{}]+)\}/g, (_, key) => {
50
50
  // Special variables
51
51
  if (key === 'odac') {
52
52
  return __dirname.replace(/\/src$/, '/client')
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const knex = require('knex')
4
+
5
+ /**
6
+ * Resolves knex client driver from ODAC database type.
7
+ * Why: Keeps connection driver mapping consistent across runtime and CLI migration paths.
8
+ * @param {string} type Database type from config.
9
+ * @returns {string} Knex client name.
10
+ */
11
+ function resolveClient(type) {
12
+ if (type === 'postgres' || type === 'pg' || type === 'postgresql') return 'pg'
13
+ if (type === 'sqlite' || type === 'sqlite3') return 'sqlite3'
14
+ return 'mysql2'
15
+ }
16
+
17
+ /**
18
+ * Builds knex connection config from ODAC database node.
19
+ * Why: Normalizes connection options for all call sites and avoids drift.
20
+ * @param {object} db Single database config node.
21
+ * @param {string} client Knex client name.
22
+ * @returns {object} Knex connection object.
23
+ */
24
+ function buildConnectionConfig(db, client) {
25
+ if (client === 'sqlite3') {
26
+ return {filename: db.filename || db.database || './dev.sqlite3'}
27
+ }
28
+
29
+ return {
30
+ host: db.host || '127.0.0.1',
31
+ user: db.user,
32
+ password: db.password,
33
+ database: db.database,
34
+ port: db.port
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Creates knex connections map from ODAC database config.
40
+ * Why: Centralizes zero-config connection bootstrap used by runtime and migration CLI.
41
+ * @param {object} databaseConfig ODAC database config (single or multiple).
42
+ * @returns {Record<string, any>} Knex connections by key.
43
+ */
44
+ function buildConnections(databaseConfig) {
45
+ const isMultiple = typeof databaseConfig[Object.keys(databaseConfig)[0]] === 'object'
46
+ const dbs = isMultiple ? databaseConfig : {default: databaseConfig}
47
+ const connections = {}
48
+
49
+ for (const key of Object.keys(dbs)) {
50
+ const db = dbs[key]
51
+ const client = resolveClient(db.type)
52
+ const connection = buildConnectionConfig(db, client)
53
+
54
+ connections[key] = knex({
55
+ client,
56
+ connection,
57
+ pool: {min: 0, max: db.connectionLimit || 10},
58
+ useNullAsDefault: true
59
+ })
60
+ }
61
+
62
+ return connections
63
+ }
64
+
65
+ module.exports = {
66
+ buildConnections,
67
+ buildConnectionConfig,
68
+ resolveClient
69
+ }