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.
- package/.agent/rules/memory.md +10 -1
- package/.github/workflows/release.yml +1 -5
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +58 -0
- package/README.md +11 -1
- package/bin/odac.js +359 -6
- package/client/odac.js +15 -11
- package/docs/ai/README.md +49 -0
- package/docs/ai/skills/SKILL.md +40 -0
- package/docs/ai/skills/backend/authentication.md +74 -0
- package/docs/ai/skills/backend/config.md +39 -0
- package/docs/ai/skills/backend/controllers.md +69 -0
- package/docs/ai/skills/backend/cron.md +57 -0
- package/docs/ai/skills/backend/database.md +37 -0
- package/docs/ai/skills/backend/forms.md +26 -0
- package/docs/ai/skills/backend/ipc.md +62 -0
- package/docs/ai/skills/backend/mail.md +41 -0
- package/docs/ai/skills/backend/migrations.md +80 -0
- package/docs/ai/skills/backend/request_response.md +42 -0
- package/docs/ai/skills/backend/routing.md +58 -0
- package/docs/ai/skills/backend/storage.md +50 -0
- package/docs/ai/skills/backend/streaming.md +41 -0
- package/docs/ai/skills/backend/structure.md +64 -0
- package/docs/ai/skills/backend/translations.md +49 -0
- package/docs/ai/skills/backend/utilities.md +31 -0
- package/docs/ai/skills/backend/validation.md +60 -0
- package/docs/ai/skills/backend/views.md +68 -0
- package/docs/ai/skills/frontend/core.md +73 -0
- package/docs/ai/skills/frontend/forms.md +28 -0
- package/docs/ai/skills/frontend/navigation.md +27 -0
- package/docs/ai/skills/frontend/realtime.md +54 -0
- package/docs/backend/08-database/04-migrations.md +258 -37
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
- package/docs/backend/10-authentication/05-session-management.md +25 -3
- package/package.json +1 -1
- package/src/Auth.js +128 -17
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +69 -0
- package/src/Database/Migration.js +1203 -0
- package/src/Database.js +35 -35
- package/src/Route/Internal.js +21 -18
- package/src/Route/MimeTypes.js +56 -0
- package/src/Route.js +40 -63
- package/src/View/Form.js +91 -51
- package/src/View.js +8 -3
- package/template/schema/users.js +23 -0
- package/test/Auth.test.js +310 -0
- package/test/Client.test.js +29 -0
- package/test/Config.test.js +7 -0
- package/test/Database/ConnectionFactory.test.js +80 -0
- package/test/Migration.test.js +943 -0
- package/test/View/Form.test.js +37 -0
|
@@ -1,48 +1,269 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Schema-First Migrations
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
---
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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', '', {
|
|
314
|
-
this.#request.cookie('odac_y', '', {
|
|
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
|
-
|
|
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(/\$\{(
|
|
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
|
+
}
|