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
package/src/View.js CHANGED
@@ -58,6 +58,7 @@ class View {
58
58
  function: '{ let _arr = $constructor; for(let $key in _arr){ let $value = _arr[$key];',
59
59
  end: '}}',
60
60
  arguments: {
61
+ in: null,
61
62
  var: null,
62
63
  get: null,
63
64
  key: 'key',
@@ -83,6 +84,7 @@ class View {
83
84
  },
84
85
  list: {
85
86
  arguments: {
87
+ in: null,
86
88
  var: null,
87
89
  get: null,
88
90
  key: 'key',
@@ -453,12 +455,15 @@ class View {
453
455
  let fun = func.function
454
456
 
455
457
  if (key === 'for' || key === 'list') {
456
- if (!vars.var && !vars.get) {
457
- console.error(`"var" or "get" is required for "${match}"\n in "${file}"`)
458
+ if (!vars.var && !vars.get && !vars.in) {
459
+ console.error(`"var", "get" or "in" is required for "${match}"\n in "${file}"`)
458
460
  continue
459
461
  }
460
462
  let constructor
461
- if (vars.var) {
463
+ if (vars.in) {
464
+ constructor = `await ${vars.in}`
465
+ delete vars.in
466
+ } else if (vars.var) {
462
467
  constructor = `await ${vars.var}`
463
468
  delete vars.var
464
469
  } else if (vars.get) {
@@ -0,0 +1,23 @@
1
+ // Schema definition for 'users' — example schema file
2
+ // This is the single source of truth for the 'users' table.
3
+ // AI agents read this file to understand the final database state.
4
+ 'use strict'
5
+
6
+ module.exports = {
7
+ columns: {
8
+ id: {type: 'increments'},
9
+ name: {type: 'string', length: 255, nullable: false},
10
+ email: {type: 'string', length: 255, nullable: false},
11
+ password: {type: 'string', length: 255, nullable: false},
12
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
13
+ is_active: {type: 'boolean', default: true},
14
+ timestamps: {type: 'timestamps'}
15
+ },
16
+
17
+ indexes: [{columns: ['email'], unique: true}, {columns: ['role', 'is_active']}],
18
+
19
+ // Seed data — idempotent, runs on every migrate.
20
+ // seedKey determines the uniqueness check for upsert.
21
+ seed: [{name: 'Admin', email: 'admin@example.com', password: 'changeme', role: 'admin'}],
22
+ seedKey: 'email'
23
+ }
@@ -0,0 +1,310 @@
1
+ const Auth = require('../src/Auth.js')
2
+
3
+ describe('Auth - Refresh Token Rotation', () => {
4
+ let reqMock
5
+ let authInstance
6
+
7
+ /**
8
+ * Why: Builds a chainable DB mock that resolves query results via .then() (thenable).
9
+ * This simulates Knex's chainable query builder pattern.
10
+ *
11
+ * @param {Array} rows - The rows the query should resolve to.
12
+ * @returns {object} Mock object with insert, update, delete, first, where tracking.
13
+ */
14
+ const createDbMock = rows => {
15
+ const tracker = {
16
+ deleteCalls: [],
17
+ firstCalls: 0,
18
+ insertCalls: [],
19
+ updateCalls: []
20
+ }
21
+
22
+ const chainable = () => ({
23
+ delete: jest.fn((...args) => {
24
+ tracker.deleteCalls.push(args)
25
+ return Promise.resolve(true)
26
+ }),
27
+ first: jest.fn(() => {
28
+ tracker.firstCalls++
29
+ return Promise.resolve(rows[0] ? {id: rows[0].user, name: 'TestUser'} : null)
30
+ }),
31
+ update: jest.fn(payload => {
32
+ tracker.updateCalls.push(payload)
33
+ return Promise.resolve(true)
34
+ }),
35
+ then: cb => cb(rows),
36
+ where: jest.fn(() => chainable())
37
+ })
38
+
39
+ return {
40
+ chainable,
41
+ insert: jest.fn(payload => {
42
+ tracker.insertCalls.push(payload)
43
+ return Promise.resolve(true)
44
+ }),
45
+ tracker,
46
+ where: jest.fn(() => chainable())
47
+ }
48
+ }
49
+
50
+ beforeEach(() => {
51
+ // Cookie storage to separate get/set behavior
52
+ const cookieStore = {
53
+ odac_x: 'old_x',
54
+ odac_y: 'old_y'
55
+ }
56
+
57
+ reqMock = {
58
+ cookie: jest.fn((name, value, options) => {
59
+ // Setter mode: 2+ arguments
60
+ if (value !== undefined) {
61
+ cookieStore[name] = value
62
+ return
63
+ }
64
+ // Getter mode: 1 argument
65
+ return cookieStore[name] || null
66
+ }),
67
+ header: jest.fn(() => 'TestBrowser'),
68
+ ip: '127.0.0.1'
69
+ }
70
+
71
+ authInstance = new Auth(reqMock)
72
+
73
+ global.Odac = {
74
+ Config: {
75
+ auth: {
76
+ key: 'id',
77
+ rotationAge: 15 * 60 * 1000,
78
+ table: 'users',
79
+ token: 'user_tokens'
80
+ }
81
+ },
82
+ DB: {
83
+ fn: {now: () => new Date()},
84
+ nanoid: () => 'nano_' + Date.now()
85
+ },
86
+ Var: jest.fn(() => ({
87
+ hash: jest.fn(() => 'hashed_value'),
88
+ hashCheck: jest.fn(() => true)
89
+ }))
90
+ }
91
+ })
92
+
93
+ afterEach(() => {
94
+ delete global.Odac
95
+ })
96
+
97
+ it('should rotate token when tokenAge exceeds rotationAge and set Epoch Date marker', async () => {
98
+ const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
99
+
100
+ const mockRecord = {
101
+ active: new Date(),
102
+ browser: 'TestBrowser',
103
+ date: new Date(createdAt),
104
+ id: 'token_1',
105
+ ip: '127.0.0.1',
106
+ token_x: 'old_x',
107
+ token_y: 'hashed_old_y',
108
+ user: 'user_10'
109
+ }
110
+
111
+ const dbMock = createDbMock([mockRecord])
112
+ global.Odac.DB.user_tokens = dbMock
113
+ global.Odac.DB.users = dbMock
114
+
115
+ const result = await authInstance.check()
116
+
117
+ expect(result).toBe(true)
118
+
119
+ // Verify new token was inserted
120
+ expect(dbMock.insert).toHaveBeenCalledTimes(1)
121
+ const inserted = dbMock.tracker.insertCalls[0]
122
+ expect(inserted.user).toBe('user_10')
123
+ expect(inserted.token_x).toBeDefined()
124
+ expect(inserted.token_y).toBe('hashed_value')
125
+
126
+ // Verify old token was marked with Epoch Date
127
+ expect(dbMock.tracker.updateCalls.length).toBe(1)
128
+ const updatePayload = dbMock.tracker.updateCalls[0]
129
+ expect(updatePayload.date.getTime()).toBe(0) // Epoch
130
+
131
+ // Verify new cookies were issued (setter calls)
132
+ const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
133
+ const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
134
+ const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
135
+ expect(xSet).toBeDefined()
136
+ expect(ySet).toBeDefined()
137
+ // New cookie values must differ from old ones
138
+ expect(xSet[1]).not.toBe('old_x')
139
+ expect(ySet[1]).not.toBe('old_y')
140
+ // Cookie max-age must use proper HTTP attribute name (hyphenated, not camelCase)
141
+ expect(xSet[2]['max-age']).toBeDefined()
142
+ expect(xSet[2].maxAge).toBeUndefined()
143
+ })
144
+
145
+ it('should NOT rotate a recently-rotated token still within 5s threshold', async () => {
146
+ // Simulate a rotated token where active was set to give ~60s grace period
147
+ // and the rotation JUST happened (< 5 seconds ago)
148
+ const maxAge = 30 * 24 * 60 * 60 * 1000
149
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 60000) // Grace period: ~60s left
150
+
151
+ const mockRecord = {
152
+ active: rotatedActiveDate,
153
+ browser: 'TestBrowser',
154
+ date: new Date(0), // Epoch marker = already rotated
155
+ id: 'token_2',
156
+ ip: '127.0.0.1',
157
+ token_x: 'old_x',
158
+ token_y: 'hashed_old_y',
159
+ user: 'user_10'
160
+ }
161
+
162
+ const dbMock = createDbMock([mockRecord])
163
+ global.Odac.DB.user_tokens = dbMock
164
+ global.Odac.DB.users = dbMock
165
+
166
+ const result = await authInstance.check()
167
+
168
+ expect(result).toBe(true)
169
+ // No rotation should occur (timeSinceRotation < 5000)
170
+ expect(dbMock.insert).not.toHaveBeenCalled()
171
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
172
+ })
173
+
174
+ it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
175
+ // Simulate a rotated token where 10 seconds have passed since original rotation
176
+ // Client still has old cookies → recovery rotation should trigger
177
+ const maxAge = 30 * 24 * 60 * 60 * 1000
178
+ const timeSinceRotation = 10000 // 10 seconds since original rotation
179
+ // active was set to: rotationTime - maxAge + 60000
180
+ // So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
181
+ // timeSinceRotation formula: inactiveAge - maxAge + 60000 = timeSinceRotation = 10000
182
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
183
+
184
+ const mockRecord = {
185
+ active: rotatedActiveDate,
186
+ browser: 'TestBrowser',
187
+ date: new Date(0), // Epoch marker = rotated
188
+ id: 'token_recovery',
189
+ ip: '127.0.0.1',
190
+ token_x: 'old_x',
191
+ token_y: 'hashed_old_y',
192
+ user: 'user_10'
193
+ }
194
+
195
+ const dbMock = createDbMock([mockRecord])
196
+ global.Odac.DB.user_tokens = dbMock
197
+ global.Odac.DB.users = dbMock
198
+
199
+ const result = await authInstance.check()
200
+
201
+ expect(result).toBe(true)
202
+
203
+ // Should insert a new token (recovery rotation)
204
+ expect(dbMock.insert).toHaveBeenCalledTimes(1)
205
+ const inserted = dbMock.tracker.insertCalls[0]
206
+ expect(inserted.user).toBe('user_10')
207
+ expect(inserted.token_x).toBeDefined()
208
+
209
+ // Old token should be DELETED, not updated (prevents token multiplication)
210
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
211
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
212
+
213
+ // New cookies should be issued
214
+ const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
215
+ const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
216
+ const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
217
+ expect(xSet).toBeDefined()
218
+ expect(ySet).toBeDefined()
219
+ expect(xSet[1]).not.toBe('old_x')
220
+ expect(ySet[1]).not.toBe('old_y')
221
+
222
+ // Cookie max-age attribute should use proper HTTP naming (hyphenated)
223
+ expect(xSet[2]['max-age']).toBeDefined()
224
+ expect(xSet[2].maxAge).toBeUndefined()
225
+ })
226
+
227
+ it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
228
+ const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
229
+
230
+ const mockRecord = {
231
+ active: new Date(),
232
+ browser: 'TestBrowser',
233
+ date: new Date(recentDate),
234
+ id: 'token_3',
235
+ ip: '127.0.0.1',
236
+ token_x: 'old_x',
237
+ token_y: 'hashed_old_y',
238
+ user: 'user_10'
239
+ }
240
+
241
+ const dbMock = createDbMock([mockRecord])
242
+ global.Odac.DB.user_tokens = dbMock
243
+ global.Odac.DB.users = dbMock
244
+
245
+ const result = await authInstance.check()
246
+
247
+ expect(result).toBe(true)
248
+ expect(dbMock.insert).not.toHaveBeenCalled()
249
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
250
+ })
251
+
252
+ it('should delete token and return false when inactiveAge exceeds maxAge', async () => {
253
+ const staleActive = Date.now() - 31 * 24 * 60 * 60 * 1000 // 31 days ago -> exceeds 30 day maxAge
254
+
255
+ const mockRecord = {
256
+ active: new Date(staleActive),
257
+ browser: 'TestBrowser',
258
+ date: new Date(),
259
+ id: 'token_4',
260
+ ip: '127.0.0.1',
261
+ token_x: 'old_x',
262
+ token_y: 'hashed_old_y',
263
+ user: 'user_10'
264
+ }
265
+
266
+ const dbMock = createDbMock([mockRecord])
267
+ global.Odac.DB.user_tokens = dbMock
268
+ global.Odac.DB.users = dbMock
269
+
270
+ const result = await authInstance.check()
271
+
272
+ expect(result).toBe(false)
273
+ // Token should be deleted
274
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
275
+ // No rotation should occur
276
+ expect(dbMock.insert).not.toHaveBeenCalled()
277
+ })
278
+
279
+ it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
280
+ const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
281
+ const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
282
+
283
+ const mockRecord = {
284
+ active: new Date(staleActive),
285
+ browser: 'TestBrowser',
286
+ date: new Date(recentDate),
287
+ id: 'token_5',
288
+ ip: '127.0.0.1',
289
+ token_x: 'old_x',
290
+ token_y: 'hashed_old_y',
291
+ user: 'user_10'
292
+ }
293
+
294
+ const dbMock = createDbMock([mockRecord])
295
+ global.Odac.DB.user_tokens = dbMock
296
+ global.Odac.DB.users = dbMock
297
+
298
+ const result = await authInstance.check()
299
+
300
+ expect(result).toBe(true)
301
+ // Should NOT rotate (tokenAge within threshold)
302
+ expect(dbMock.insert).not.toHaveBeenCalled()
303
+ // Should update active timestamp (fallback path)
304
+ expect(dbMock.tracker.updateCalls.length).toBe(1)
305
+ const updatePayload = dbMock.tracker.updateCalls[0]
306
+ expect(updatePayload.active).toBeInstanceOf(Date)
307
+ // Should NOT have Epoch marker
308
+ expect(updatePayload.date).toBeUndefined()
309
+ })
310
+ })
@@ -151,6 +151,35 @@ describe('Client (odac.js)', () => {
151
151
  })
152
152
  })
153
153
 
154
+ describe('get()', () => {
155
+ test('should automatically parse JSON if Content-Type is application/json', () => {
156
+ const mockCallback = jest.fn()
157
+ const mockData = {success: true}
158
+
159
+ mockXhr.open.mockClear()
160
+ mockXhr.send.mockClear()
161
+
162
+ // Mock token() to avoid side effects and XHR calls
163
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
164
+
165
+ // Setup response for the get request
166
+ mockXhr.responseText = JSON.stringify(mockData)
167
+ mockXhr.status = 200
168
+ mockXhr.statusText = 'OK'
169
+ mockXhr.getResponseHeader.mockImplementation(header => {
170
+ if (header === 'Content-Type') return 'application/json'
171
+ return null
172
+ })
173
+
174
+ window.Odac.get('/api/test', mockCallback)
175
+
176
+ // Trigger the completion of the get request
177
+ if (mockXhr.onload) mockXhr.onload()
178
+
179
+ expect(mockCallback).toHaveBeenCalledWith(mockData, expect.anything(), expect.anything())
180
+ })
181
+ })
182
+
154
183
  describe('OdacWebSocket', () => {
155
184
  test('should connect to WebSocket and handle events', () => {
156
185
  const ws = window.Odac.ws('/test-ws', {token: false})
@@ -77,6 +77,13 @@ describe('Config', () => {
77
77
  expect(result).toBe('hello-bar')
78
78
  })
79
79
 
80
+ it('should replace ${VAR} when variable name includes hyphen', () => {
81
+ process.env['MY-VAR'] = 'hyphen-value'
82
+ const result = Config._interpolate('hello-${MY-VAR}')
83
+ expect(result).toBe('hello-hyphen-value')
84
+ delete process.env['MY-VAR']
85
+ })
86
+
80
87
  it('should replace ${odac} with client path', () => {
81
88
  // __dirname in Config.js is /.../src, so it replaces /src with /client
82
89
  const result = Config._interpolate('path-${odac}')
@@ -0,0 +1,80 @@
1
+ const mockKnex = jest.fn()
2
+
3
+ jest.mock(
4
+ 'knex',
5
+ () =>
6
+ (...args) =>
7
+ mockKnex(...args)
8
+ )
9
+
10
+ const {buildConnections, buildConnectionConfig, resolveClient} = require('../../src/Database/ConnectionFactory')
11
+
12
+ describe('Database ConnectionFactory', () => {
13
+ beforeEach(() => {
14
+ mockKnex.mockReset()
15
+ mockKnex.mockImplementation(options => ({
16
+ options,
17
+ raw: jest.fn()
18
+ }))
19
+ })
20
+
21
+ it('resolveClient should map known database aliases', () => {
22
+ expect(resolveClient('postgres')).toBe('pg')
23
+ expect(resolveClient('postgresql')).toBe('pg')
24
+ expect(resolveClient('pg')).toBe('pg')
25
+ expect(resolveClient('sqlite')).toBe('sqlite3')
26
+ expect(resolveClient('sqlite3')).toBe('sqlite3')
27
+ expect(resolveClient('mysql')).toBe('mysql2')
28
+ })
29
+
30
+ it('buildConnectionConfig should create sqlite filename config', () => {
31
+ const config = buildConnectionConfig({database: 'db.sqlite3'}, 'sqlite3')
32
+ expect(config).toEqual({filename: 'db.sqlite3'})
33
+ })
34
+
35
+ it('buildConnectionConfig should create host based config for non-sqlite', () => {
36
+ const config = buildConnectionConfig(
37
+ {
38
+ user: 'root',
39
+ password: 'secret',
40
+ database: 'app',
41
+ port: 3306
42
+ },
43
+ 'mysql2'
44
+ )
45
+
46
+ expect(config).toEqual({
47
+ host: '127.0.0.1',
48
+ user: 'root',
49
+ password: 'secret',
50
+ database: 'app',
51
+ port: 3306
52
+ })
53
+ })
54
+
55
+ it('buildConnections should support single database config', () => {
56
+ const connections = buildConnections({
57
+ type: 'mysql',
58
+ user: 'root',
59
+ database: 'app'
60
+ })
61
+
62
+ expect(Object.keys(connections)).toEqual(['default'])
63
+ expect(mockKnex).toHaveBeenCalledTimes(1)
64
+ expect(mockKnex.mock.calls[0][0]).toMatchObject({
65
+ client: 'mysql2',
66
+ pool: {min: 0, max: 10},
67
+ useNullAsDefault: true
68
+ })
69
+ })
70
+
71
+ it('buildConnections should support multi database config', () => {
72
+ const connections = buildConnections({
73
+ analytics: {type: 'postgres', user: 'u', database: 'a'},
74
+ default: {type: 'sqlite', filename: './dev.sqlite3'}
75
+ })
76
+
77
+ expect(Object.keys(connections).sort()).toEqual(['analytics', 'default'])
78
+ expect(mockKnex).toHaveBeenCalledTimes(2)
79
+ })
80
+ })