odac 1.4.0 → 1.4.2
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 +8 -0
- package/.github/workflows/release.yml +1 -1
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +61 -0
- package/README.md +10 -0
- package/bin/odac.js +193 -2
- package/client/odac.js +32 -13
- package/docs/ai/skills/SKILL.md +4 -3
- package/docs/ai/skills/backend/authentication.md +7 -0
- package/docs/ai/skills/backend/config.md +7 -0
- package/docs/ai/skills/backend/controllers.md +7 -0
- package/docs/ai/skills/backend/cron.md +9 -2
- package/docs/ai/skills/backend/database.md +37 -2
- package/docs/ai/skills/backend/forms.md +112 -11
- package/docs/ai/skills/backend/ipc.md +7 -0
- package/docs/ai/skills/backend/mail.md +7 -0
- package/docs/ai/skills/backend/migrations.md +86 -0
- package/docs/ai/skills/backend/request_response.md +7 -0
- package/docs/ai/skills/backend/routing.md +7 -0
- package/docs/ai/skills/backend/storage.md +7 -0
- package/docs/ai/skills/backend/streaming.md +7 -0
- package/docs/ai/skills/backend/structure.md +8 -1
- package/docs/ai/skills/backend/translations.md +7 -0
- package/docs/ai/skills/backend/utilities.md +7 -0
- package/docs/ai/skills/backend/validation.md +138 -31
- package/docs/ai/skills/backend/views.md +7 -0
- package/docs/ai/skills/frontend/core.md +7 -0
- package/docs/ai/skills/frontend/forms.md +48 -13
- package/docs/ai/skills/frontend/navigation.md +7 -0
- package/docs/ai/skills/frontend/realtime.md +7 -0
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +259 -37
- package/package.json +1 -1
- package/src/Auth.js +82 -43
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +70 -0
- package/src/Database/Migration.js +1228 -0
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +157 -46
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +8 -0
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/template/schema/users.js +23 -0
- package/test/{Auth.test.js → Auth/check.test.js} +153 -6
- package/test/Client/data.test.js +91 -0
- package/test/Client/get.test.js +90 -0
- package/test/Client/storage.test.js +87 -0
- package/test/Client/token.test.js +82 -0
- package/test/Client/ws.test.js +86 -0
- package/test/Config/deepMerge.test.js +14 -0
- package/test/Config/init.test.js +66 -0
- package/test/Config/interpolate.test.js +35 -0
- package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
- package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
- package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
- package/test/Database/Migration/migrate_column.test.js +52 -0
- package/test/Database/Migration/migrate_files.test.js +70 -0
- package/test/Database/Migration/migrate_index.test.js +89 -0
- package/test/Database/Migration/migrate_nanoid.test.js +160 -0
- package/test/Database/Migration/migrate_seed.test.js +77 -0
- package/test/Database/Migration/migrate_table.test.js +88 -0
- package/test/Database/Migration/rollback.test.js +61 -0
- package/test/Database/Migration/snapshot.test.js +38 -0
- package/test/Database/Migration/status.test.js +41 -0
- package/test/Database/autoNanoid.test.js +215 -0
- package/test/Database/nanoid.test.js +19 -0
- package/test/Lang/constructor.test.js +25 -0
- package/test/Lang/get.test.js +65 -0
- package/test/Lang/set.test.js +49 -0
- package/test/Odac/init.test.js +42 -0
- package/test/Odac/instance.test.js +58 -0
- package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
- package/test/Route/Middleware/use.test.js +35 -0
- package/test/{Route.test.js → Route/check.test.js} +4 -55
- package/test/Route/set.test.js +52 -0
- package/test/Route/ws.test.js +23 -0
- package/test/View/EarlyHints/cache.test.js +32 -0
- package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
- package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
- package/test/View/EarlyHints/send.test.js +99 -0
- package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
- package/test/View/constructor.test.js +22 -0
- package/test/View/print.test.js +19 -0
- package/test/WebSocket/Client/limits.test.js +55 -0
- package/test/WebSocket/Server/broadcast.test.js +33 -0
- package/test/WebSocket/Server/route.test.js +37 -0
- package/test/Client.test.js +0 -197
- package/test/Config.test.js +0 -112
- package/test/Lang.test.js +0 -92
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- package/test/WebSocket.test.js +0 -238
|
@@ -118,19 +118,59 @@ await Odac.DB.users.where('id', 1).delete();
|
|
|
118
118
|
|
|
119
119
|
ODAC includes a built-in helper for generating robust, unique string IDs (NanoID) without needing external packages. Secure, URL-friendly, and collision-resistant.
|
|
120
120
|
|
|
121
|
+
### Automatic Generation (Recommended)
|
|
122
|
+
|
|
123
|
+
When you define a column as `type: 'nanoid'` in your schema file, ODAC **automatically generates** the ID on every `insert()` — no manual code needed.
|
|
124
|
+
|
|
125
|
+
**Schema definition:**
|
|
121
126
|
```javascript
|
|
122
|
-
//
|
|
123
|
-
|
|
127
|
+
// schema/posts.js
|
|
128
|
+
module.exports = {
|
|
129
|
+
columns: {
|
|
130
|
+
id: { type: 'nanoid', primary: true },
|
|
131
|
+
title: { type: 'string', length: 255 }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
124
135
|
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
**Usage — just insert, the ID is auto-generated:**
|
|
137
|
+
```javascript
|
|
138
|
+
await Odac.DB.posts.insert({ title: 'My First Post' });
|
|
139
|
+
// → { id: 'V1StGXR8Z5jdHi6BmyTa', title: 'My First Post' }
|
|
127
140
|
```
|
|
128
141
|
|
|
129
|
-
This
|
|
142
|
+
This works for single inserts and bulk inserts. If you provide an `id` explicitly, the auto-generation is skipped.
|
|
130
143
|
|
|
131
144
|
```javascript
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
title: '
|
|
135
|
-
}
|
|
145
|
+
// Bulk insert — each row gets its own unique nanoid
|
|
146
|
+
await Odac.DB.posts.insert([
|
|
147
|
+
{ title: 'Post A' },
|
|
148
|
+
{ title: 'Post B' }
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
// Explicit ID — auto-generation is skipped
|
|
152
|
+
await Odac.DB.posts.insert({ id: 'my-custom-id', title: 'Custom' });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can also customize the ID length:
|
|
156
|
+
```javascript
|
|
157
|
+
// schema/codes.js
|
|
158
|
+
module.exports = {
|
|
159
|
+
columns: {
|
|
160
|
+
code: { type: 'nanoid', length: 8, primary: true },
|
|
161
|
+
label: { type: 'string' }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Manual Generation
|
|
167
|
+
|
|
168
|
+
You can also generate NanoIDs manually when needed:
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
// Generate a standard 21-character ID
|
|
172
|
+
const id = Odac.DB.nanoid();
|
|
173
|
+
|
|
174
|
+
// Generate a custom length ID
|
|
175
|
+
const shortId = Odac.DB.nanoid(10);
|
|
136
176
|
```
|
|
@@ -1,48 +1,270 @@
|
|
|
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
|
+
| `nanoid` | NanoID string key (auto-generated on insert) | `length` (default: 21) |
|
|
95
|
+
| `integer` | Integer | `unsigned` |
|
|
96
|
+
| `bigInteger` | Big integer | `unsigned` |
|
|
97
|
+
| `float` | Floating point | `precision`, `scale` |
|
|
98
|
+
| `decimal` | Exact decimal | `precision`, `scale` |
|
|
99
|
+
| `string` | Varchar | `length` (default: 255) |
|
|
100
|
+
| `text` | Text blob | `textType` ('text', 'mediumtext', 'longtext') |
|
|
101
|
+
| `boolean` | Boolean | — |
|
|
102
|
+
| `date` | Date only | — |
|
|
103
|
+
| `datetime` | Date and time | — |
|
|
104
|
+
| `timestamp` | Timestamp | — |
|
|
105
|
+
| `timestamps` | Virtual: creates `created_at` + `updated_at` | — |
|
|
106
|
+
| `time` | Time only | — |
|
|
107
|
+
| `binary` | Binary data | `length` |
|
|
108
|
+
| `json` | JSON | — |
|
|
109
|
+
| `jsonb` | Binary JSON (PostgreSQL) | — |
|
|
110
|
+
| `uuid` | UUID | — |
|
|
111
|
+
| `enum` | Enumeration | `values` (array) |
|
|
112
|
+
|
|
113
|
+
## Column Modifiers
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
{
|
|
117
|
+
type: 'string',
|
|
118
|
+
length: 100,
|
|
119
|
+
nullable: false, // NOT NULL constraint
|
|
120
|
+
default: 'untitled', // Default value
|
|
121
|
+
unsigned: true, // Unsigned integer
|
|
122
|
+
unique: true, // Unique constraint (inline)
|
|
123
|
+
primary: true, // Primary key
|
|
124
|
+
comment: 'The title', // Column comment
|
|
125
|
+
references: { // Foreign key
|
|
126
|
+
table: 'categories',
|
|
127
|
+
column: 'id'
|
|
128
|
+
},
|
|
129
|
+
onDelete: 'CASCADE',
|
|
130
|
+
onUpdate: 'CASCADE'
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Seed Data
|
|
137
|
+
|
|
138
|
+
Schema files can include declarative seed data that is applied idempotently on every migration:
|
|
10
139
|
|
|
11
140
|
```javascript
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
141
|
+
// schema/roles.js
|
|
142
|
+
module.exports = {
|
|
143
|
+
columns: {
|
|
144
|
+
id: {type: 'increments'},
|
|
145
|
+
name: {type: 'string', length: 50},
|
|
146
|
+
level: {type: 'integer', default: 0}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
indexes: [],
|
|
150
|
+
|
|
151
|
+
seed: [
|
|
152
|
+
{name: 'admin', level: 100},
|
|
153
|
+
{name: 'editor', level: 50},
|
|
154
|
+
{name: 'user', level: 1}
|
|
155
|
+
],
|
|
156
|
+
seedKey: 'name'
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- **`seed`** — Array of rows to ensure exist
|
|
161
|
+
- **`seedKey`** — Column used for uniqueness check (required when `seed` is present)
|
|
162
|
+
|
|
163
|
+
**Behavior:** If the row exists (matched by `seedKey`), it updates if values differ. If not, it inserts. Safe to run repeatedly.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Data Migrations
|
|
168
|
+
|
|
169
|
+
For **one-time data transformations** (splitting columns, backfilling, etc.), use imperative migration files:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
migration/
|
|
173
|
+
20260225_001_split_names.js
|
|
174
|
+
```
|
|
35
175
|
|
|
36
176
|
```javascript
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
177
|
+
// migration/20260225_001_split_names.js
|
|
178
|
+
module.exports = {
|
|
179
|
+
async up(db) {
|
|
180
|
+
const users = await db('users').select('id', 'full_name')
|
|
181
|
+
for (const user of users) {
|
|
182
|
+
const [first, ...rest] = user.full_name.split(' ')
|
|
183
|
+
await db('users').where('id', user.id).update({
|
|
184
|
+
first_name: first,
|
|
185
|
+
last_name: rest.join(' ')
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async down(db) {
|
|
191
|
+
const users = await db('users').select('id', 'first_name', 'last_name')
|
|
192
|
+
for (const user of users) {
|
|
193
|
+
await db('users').where('id', user.id).update({
|
|
194
|
+
full_name: `${user.first_name} ${user.last_name}`
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Migration files run **once** and are tracked in the `_odac_migrations` table.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Multiple Databases
|
|
206
|
+
|
|
207
|
+
If your project has multiple database connections, organize schemas by connection:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
schema/
|
|
211
|
+
users.js ← default connection
|
|
212
|
+
posts.js ← default connection
|
|
213
|
+
analytics/ ← 'analytics' connection
|
|
214
|
+
events.js
|
|
215
|
+
pageviews.js
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The folder name matches the connection key in your `odac.json`:
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"database": {
|
|
223
|
+
"default": {"type": "mysql", "database": "main_db"},
|
|
224
|
+
"analytics": {"type": "postgres", "database": "analytics_db"}
|
|
225
|
+
}
|
|
44
226
|
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Migration files follow the same convention:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
migration/
|
|
233
|
+
20260225_001_auto.js ← default connection
|
|
234
|
+
analytics/
|
|
235
|
+
20260225_001_backfill.js ← analytics connection
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## CLI Commands
|
|
45
241
|
|
|
46
|
-
|
|
47
|
-
|
|
242
|
+
```bash
|
|
243
|
+
# Run all pending migrations (schema diff + files + seeds)
|
|
244
|
+
npx odac migrate
|
|
245
|
+
|
|
246
|
+
# Target a specific database connection
|
|
247
|
+
npx odac migrate --db=analytics
|
|
248
|
+
|
|
249
|
+
# Show pending changes without applying (dry-run)
|
|
250
|
+
npx odac migrate:status
|
|
251
|
+
|
|
252
|
+
# Rollback the last batch of migration files
|
|
253
|
+
npx odac migrate:rollback
|
|
254
|
+
|
|
255
|
+
# Reverse-engineer current database into schema/ files
|
|
256
|
+
npx odac migrate:snapshot
|
|
257
|
+
npx odac migrate:snapshot --db=analytics
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Snapshot — Importing Existing Databases
|
|
263
|
+
|
|
264
|
+
For existing projects, use `migrate:snapshot` to generate schema files from your current database:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
npx odac migrate:snapshot
|
|
48
268
|
```
|
|
269
|
+
|
|
270
|
+
This creates a schema file for each table. Review and adjust the generated files, then use them as your source of truth going forward.
|
package/package.json
CHANGED
package/src/Auth.js
CHANGED
|
@@ -140,30 +140,67 @@ class Auth {
|
|
|
140
140
|
this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
|
|
141
141
|
if (!this.#user) return false
|
|
142
142
|
|
|
143
|
+
let triggerRotation = false
|
|
144
|
+
let isRecoveryRotation = false
|
|
145
|
+
|
|
146
|
+
// WebSocket connections (res === null) cannot deliver Set-Cookie headers.
|
|
147
|
+
// Rotating a token during a WS upgrade would invalidate the browser's cookies
|
|
148
|
+
// with no way to deliver replacements, causing silent logout on the next HTTP request.
|
|
149
|
+
const canDeliverCookies = !!this.#request.res
|
|
150
|
+
|
|
143
151
|
if (!isRotated) {
|
|
144
152
|
if (shouldRotate && tokenAge > rotationAge) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
browser: sql_token[0].browser,
|
|
154
|
-
ip: this.#request.ip,
|
|
155
|
-
date: new Date(),
|
|
156
|
-
active: new Date()
|
|
153
|
+
if (canDeliverCookies) {
|
|
154
|
+
triggerRotation = true
|
|
155
|
+
} else {
|
|
156
|
+
// WebSocket: Can't deliver rotated cookies, refresh active timestamp instead
|
|
157
|
+
Odac.DB[tokenTable]
|
|
158
|
+
.where('id', sql_token[0].id)
|
|
159
|
+
.update({active: new Date()})
|
|
160
|
+
.catch(() => {})
|
|
157
161
|
}
|
|
162
|
+
} else if (inactiveAge > updateAge) {
|
|
163
|
+
// Fallback simple active update if rotation is not triggered
|
|
164
|
+
Odac.DB[tokenTable]
|
|
165
|
+
.where('id', sql_token[0].id)
|
|
166
|
+
.update({active: new Date()})
|
|
167
|
+
.catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// Client still presenting a rotated (grace period) token.
|
|
171
|
+
// This means the previous rotation response was lost (network hiccup, page navigation, etc.)
|
|
172
|
+
// Give the client one more chance by re-issuing new credentials.
|
|
173
|
+
const timeSinceRotation = inactiveAge - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS
|
|
174
|
+
if (timeSinceRotation > 5000 && canDeliverCookies) {
|
|
175
|
+
triggerRotation = true
|
|
176
|
+
isRecoveryRotation = true
|
|
177
|
+
}
|
|
178
|
+
}
|
|
158
179
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
180
|
+
if (triggerRotation) {
|
|
181
|
+
// --- Token Rotation ---
|
|
182
|
+
const newTokenX = nodeCrypto.randomBytes(32).toString('hex')
|
|
183
|
+
const newTokenY = nodeCrypto.randomBytes(32).toString('hex')
|
|
184
|
+
const newToken = {
|
|
185
|
+
id: Odac.DB.nanoid(),
|
|
186
|
+
user: sql_token[0].user,
|
|
187
|
+
token_x: newTokenX,
|
|
188
|
+
token_y: Odac.Var(newTokenY).hash(),
|
|
189
|
+
browser: sql_token[0].browser,
|
|
190
|
+
ip: this.#request.ip,
|
|
191
|
+
date: new Date(),
|
|
192
|
+
active: new Date()
|
|
193
|
+
}
|
|
164
194
|
|
|
165
|
-
|
|
166
|
-
|
|
195
|
+
// 1. Persist new token (await to ensure it exists before client uses new cookies)
|
|
196
|
+
const insertOk = await Odac.DB[tokenTable].insert(newToken).catch(e => {
|
|
197
|
+
console.error('Odac Auth Error: Token rotation failed', e.message)
|
|
198
|
+
return false
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (insertOk !== false) {
|
|
202
|
+
if (!isRecoveryRotation) {
|
|
203
|
+
// 2a. Normal rotation: Mark old token as rotated with 60s grace period
|
|
167
204
|
// Non-blocking I/O (Fire & Forget) -> High Throughput
|
|
168
205
|
const rotatedActiveDate = new Date(now - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS)
|
|
169
206
|
const epochDate = new Date(0)
|
|
@@ -175,27 +212,29 @@ class Auth {
|
|
|
175
212
|
date: epochDate
|
|
176
213
|
})
|
|
177
214
|
.catch(() => {})
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.#request.cookie('odac_y', newTokenY, {
|
|
187
|
-
httpOnly: true,
|
|
188
|
-
secure: true,
|
|
189
|
-
sameSite: 'Lax',
|
|
190
|
-
maxAge: maxAge
|
|
191
|
-
})
|
|
215
|
+
} else {
|
|
216
|
+
// 2b. Recovery rotation: Delete old rotated token immediately.
|
|
217
|
+
// Why: Prevents unbounded token multiplication. The old token already
|
|
218
|
+
// had its grace period; one recovery attempt is the maximum.
|
|
219
|
+
Odac.DB[tokenTable]
|
|
220
|
+
.where('id', sql_token[0].id)
|
|
221
|
+
.delete()
|
|
222
|
+
.catch(() => {})
|
|
192
223
|
}
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
224
|
+
|
|
225
|
+
// 3. Issue new cookies immediately
|
|
226
|
+
this.#request.cookie('odac_x', newTokenX, {
|
|
227
|
+
httpOnly: true,
|
|
228
|
+
secure: true,
|
|
229
|
+
sameSite: 'Lax',
|
|
230
|
+
'max-age': Math.floor(maxAge / 1000)
|
|
231
|
+
})
|
|
232
|
+
this.#request.cookie('odac_y', newTokenY, {
|
|
233
|
+
httpOnly: true,
|
|
234
|
+
secure: true,
|
|
235
|
+
sameSite: 'Lax',
|
|
236
|
+
'max-age': Math.floor(maxAge / 1000)
|
|
237
|
+
})
|
|
199
238
|
}
|
|
200
239
|
}
|
|
201
240
|
|
|
@@ -238,13 +277,13 @@ class Auth {
|
|
|
238
277
|
httpOnly: true,
|
|
239
278
|
secure: true,
|
|
240
279
|
sameSite: 'Lax',
|
|
241
|
-
|
|
280
|
+
'max-age': Math.floor(maxAge / 1000)
|
|
242
281
|
})
|
|
243
282
|
this.#request.cookie('odac_y', token_y, {
|
|
244
283
|
httpOnly: true,
|
|
245
284
|
secure: true,
|
|
246
285
|
sameSite: 'Lax',
|
|
247
|
-
|
|
286
|
+
'max-age': Math.floor(maxAge / 1000)
|
|
248
287
|
})
|
|
249
288
|
|
|
250
289
|
// Knex insert returns ids on some dbs, promise resolves to result
|
|
@@ -392,8 +431,8 @@ class Auth {
|
|
|
392
431
|
await Odac.DB[tokenTable].where('user', userId).where('browser', browser).delete()
|
|
393
432
|
}
|
|
394
433
|
|
|
395
|
-
this.#request.cookie('odac_x', '', {
|
|
396
|
-
this.#request.cookie('odac_y', '', {
|
|
434
|
+
this.#request.cookie('odac_x', '', {'max-age': -1})
|
|
435
|
+
this.#request.cookie('odac_y', '', {'max-age': -1})
|
|
397
436
|
|
|
398
437
|
this.#user = null
|
|
399
438
|
return true
|
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,70 @@
|
|
|
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
|
+
connections[key]._odacConnectionKey = key
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return connections
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
buildConnections,
|
|
68
|
+
buildConnectionConfig,
|
|
69
|
+
resolveClient
|
|
70
|
+
}
|