odac 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +42 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/release.yml +68 -1
  6. package/.github/workflows/test-coverage.yml +6 -5
  7. package/.husky/pre-commit +10 -0
  8. package/.husky/pre-push +13 -0
  9. package/.releaserc.js +1 -1
  10. package/CHANGELOG.md +99 -0
  11. package/README.md +16 -0
  12. package/bin/odac.js +196 -38
  13. package/client/odac.js +238 -171
  14. package/docs/backend/01-overview/03-development-server.md +38 -45
  15. package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
  16. package/docs/backend/03-config/00-configuration-overview.md +6 -6
  17. package/docs/backend/03-config/01-database-connection.md +2 -2
  18. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  19. package/docs/backend/03-config/03-request-timeout.md +1 -1
  20. package/docs/backend/03-config/04-environment-variables.md +4 -4
  21. package/docs/backend/03-config/05-early-hints.md +2 -2
  22. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  23. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  24. package/docs/backend/04-routing/09-websocket.md +14 -1
  25. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  26. package/docs/backend/05-controllers/03-controller-classes.md +40 -20
  27. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  28. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  29. package/docs/backend/08-database/01-getting-started.md +2 -2
  30. package/docs/backend/10-authentication/03-register.md +1 -1
  31. package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
  32. package/docs/backend/10-authentication/05-session-management.md +15 -1
  33. package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
  34. package/docs/backend/10-authentication/07-magic-links.md +1 -1
  35. package/docs/index.json +5 -1
  36. package/jest.config.js +1 -1
  37. package/package.json +18 -14
  38. package/src/Auth.js +58 -23
  39. package/src/Config.js +7 -7
  40. package/src/Database.js +1 -1
  41. package/src/Env.js +3 -1
  42. package/src/Ipc.js +7 -0
  43. package/src/Lang.js +9 -2
  44. package/src/Mail.js +19 -9
  45. package/src/Odac.js +56 -44
  46. package/src/Request.js +6 -2
  47. package/src/Route/Cron.js +58 -17
  48. package/src/Route/Internal.js +1 -1
  49. package/src/Route.js +375 -125
  50. package/src/Server.js +40 -3
  51. package/src/Storage.js +4 -0
  52. package/src/Token.js +6 -4
  53. package/src/Validator.js +24 -15
  54. package/src/Var.js +22 -6
  55. package/src/View/EarlyHints.js +43 -33
  56. package/src/View/Form.js +17 -11
  57. package/src/View.js +68 -12
  58. package/src/WebSocket.js +45 -12
  59. package/template/package.json +3 -1
  60. package/template/view/content/home.html +3 -3
  61. package/template/view/head/main.html +2 -2
  62. package/test/Client.test.js +168 -0
  63. package/test/Config.test.js +112 -0
  64. package/test/Lang.test.js +92 -0
  65. package/test/Odac.test.js +88 -0
  66. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  67. package/test/{framework/Route.test.js → Route.test.js} +105 -1
  68. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  69. package/test/WebSocket.test.js +238 -0
  70. package/test/scripts/check-coverage.js +4 -4
  71. package/test/cli/Cli.test.js +0 -36
  72. package/test/core/Commands.test.js +0 -538
  73. package/test/core/Config.test.js +0 -1432
  74. package/test/core/Lang.test.js +0 -250
  75. package/test/core/Odac.test.js +0 -234
  76. package/test/core/Process.test.js +0 -156
  77. package/test/framework/WebSocket.test.js +0 -100
  78. package/test/server/Api.test.js +0 -647
  79. package/test/server/DNS.test.js +0 -2050
  80. package/test/server/DNS.test.js.bak +0 -2084
  81. package/test/server/Hub.test.js +0 -497
  82. package/test/server/Log.test.js +0 -73
  83. package/test/server/Mail.account.test_.js +0 -460
  84. package/test/server/Mail.init.test_.js +0 -411
  85. package/test/server/Mail.test_.js +0 -1340
  86. package/test/server/SSL.test_.js +0 -1491
  87. package/test/server/Server.test.js +0 -765
  88. package/test/server/Service.test_.js +0 -1127
  89. package/test/server/Subdomain.test.js +0 -440
  90. package/test/server/Web/Firewall.test.js +0 -175
  91. package/test/server/Web/Proxy.test.js +0 -397
  92. package/test/server/Web.test.js +0 -1494
  93. package/test/server/__mocks__/acme-client.js +0 -17
  94. package/test/server/__mocks__/bcrypt.js +0 -50
  95. package/test/server/__mocks__/child_process.js +0 -389
  96. package/test/server/__mocks__/crypto.js +0 -432
  97. package/test/server/__mocks__/fs.js +0 -450
  98. package/test/server/__mocks__/globalOdac.js +0 -227
  99. package/test/server/__mocks__/http.js +0 -575
  100. package/test/server/__mocks__/https.js +0 -272
  101. package/test/server/__mocks__/index.js +0 -249
  102. package/test/server/__mocks__/mail/server.js +0 -100
  103. package/test/server/__mocks__/mail/smtp.js +0 -31
  104. package/test/server/__mocks__/mailparser.js +0 -81
  105. package/test/server/__mocks__/net.js +0 -369
  106. package/test/server/__mocks__/node-forge.js +0 -328
  107. package/test/server/__mocks__/os.js +0 -320
  108. package/test/server/__mocks__/path.js +0 -291
  109. package/test/server/__mocks__/selfsigned.js +0 -8
  110. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  111. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  112. package/test/server/__mocks__/smtp-server.js +0 -106
  113. package/test/server/__mocks__/sqlite3.js +0 -394
  114. package/test/server/__mocks__/testFactories.js +0 -299
  115. package/test/server/__mocks__/testHelpers.js +0 -363
  116. package/test/server/__mocks__/tls.js +0 -229
  117. /package/template/{config.json → odac.json} +0 -0
@@ -1,4 +1,4 @@
1
- const Route = require('../../framework/src/Route')
1
+ const Route = require('../src/Route')
2
2
 
3
3
  describe('Route', () => {
4
4
  let route
@@ -240,6 +240,110 @@ describe('Route', () => {
240
240
  })
241
241
  })
242
242
 
243
+ describe('parametric route matching (#controller via check)', () => {
244
+ const createMockOdac = (url, method = 'get') => ({
245
+ Auth: {check: jest.fn().mockResolvedValue(true)},
246
+ Config: {},
247
+ Request: {
248
+ abort: jest.fn().mockReturnValue('aborted'),
249
+ cookie: jest.fn(() => null),
250
+ data: {url: {}},
251
+ header: jest.fn(() => ''),
252
+ host: 'example.com',
253
+ isAjaxLoad: false,
254
+ method,
255
+ page: null,
256
+ res: {finished: false, writableEnded: false},
257
+ route: 'test_route',
258
+ setSession: jest.fn(),
259
+ ssl: false,
260
+ url
261
+ },
262
+ request: jest.fn().mockResolvedValue(null),
263
+ token: jest.fn().mockReturnValue(true)
264
+ })
265
+
266
+ const setupParamRoute = async (routeInstance, urlPattern, handler) => {
267
+ global.Odac.Route.buff = 'test_route'
268
+ routeInstance.set('get', urlPattern, handler, {token: false})
269
+ await Promise.all(routeInstance._pendingRouteLoads)
270
+ routeInstance._pendingRouteLoads = []
271
+ }
272
+
273
+ it('should match single parametric segment and extract params', async () => {
274
+ const handler = jest.fn().mockReturnValue({ok: true})
275
+ await setupParamRoute(route, '/users/{id}', handler)
276
+
277
+ const mockOdac = createMockOdac('/users/42')
278
+ await route.check(mockOdac)
279
+
280
+ expect(handler).toHaveBeenCalled()
281
+ expect(mockOdac.Request.data.url.id).toBe('42')
282
+ })
283
+
284
+ it('should match multi-parameter route and extract all params', async () => {
285
+ const handler = jest.fn().mockReturnValue({ok: true})
286
+ await setupParamRoute(route, '/users/{userId}/posts/{postId}', handler)
287
+
288
+ const mockOdac = createMockOdac('/users/7/posts/99')
289
+ await route.check(mockOdac)
290
+
291
+ expect(handler).toHaveBeenCalled()
292
+ expect(mockOdac.Request.data.url.userId).toBe('7')
293
+ expect(mockOdac.Request.data.url.postId).toBe('99')
294
+ })
295
+
296
+ it('should not match when static segments differ', async () => {
297
+ const handler = jest.fn().mockReturnValue({ok: true})
298
+ await setupParamRoute(route, '/users/{id}', handler)
299
+
300
+ const mockOdac = createMockOdac('/posts/42')
301
+ await route.check(mockOdac)
302
+
303
+ expect(handler).not.toHaveBeenCalled()
304
+ })
305
+
306
+ it('should not match when segment count differs', async () => {
307
+ const handler = jest.fn().mockReturnValue({ok: true})
308
+ await setupParamRoute(route, '/users/{id}', handler)
309
+
310
+ const mockOdac = createMockOdac('/users/42/extra')
311
+ await route.check(mockOdac)
312
+
313
+ expect(handler).not.toHaveBeenCalled()
314
+ })
315
+
316
+ it('should correctly match among multiple parametric routes (no arr mutation bug)', async () => {
317
+ const usersHandler = jest.fn().mockReturnValue({users: true})
318
+ const postsHandler = jest.fn().mockReturnValue({posts: true})
319
+
320
+ await setupParamRoute(route, '/users/{id}', usersHandler)
321
+ await setupParamRoute(route, '/posts/{id}', postsHandler)
322
+
323
+ // Request to /posts/5 should match postsHandler, not usersHandler
324
+ const mockOdac = createMockOdac('/posts/5')
325
+ await route.check(mockOdac)
326
+
327
+ expect(usersHandler).not.toHaveBeenCalled()
328
+ expect(postsHandler).toHaveBeenCalled()
329
+ expect(mockOdac.Request.data.url.id).toBe('5')
330
+ })
331
+
332
+ it('should prefer exact match over parametric match', async () => {
333
+ const exactHandler = jest.fn().mockReturnValue({exact: true})
334
+ const paramHandler = jest.fn().mockReturnValue({param: true})
335
+
336
+ await setupParamRoute(route, '/users/admin', exactHandler)
337
+ await setupParamRoute(route, '/users/{id}', paramHandler)
338
+
339
+ const mockOdac = createMockOdac('/users/admin')
340
+ await route.check(mockOdac)
341
+
342
+ expect(exactHandler).toHaveBeenCalled()
343
+ expect(paramHandler).not.toHaveBeenCalled()
344
+ })
345
+ })
346
+
243
347
  describe('WebSocket cleanup', () => {
244
348
  it('should call ws() method successfully', () => {
245
349
  const handler = jest.fn()
@@ -1,4 +1,4 @@
1
- const EarlyHints = require('../../../framework/src/View/EarlyHints')
1
+ const EarlyHints = require('../../src/View/EarlyHints')
2
2
 
3
3
  describe('EarlyHints', () => {
4
4
  let earlyHints
@@ -0,0 +1,238 @@
1
+ const {WebSocketServer} = require('../src/WebSocket.js')
2
+
3
+ describe('WebSocketServer', () => {
4
+ let server
5
+
6
+ beforeEach(() => {
7
+ server = new WebSocketServer()
8
+ })
9
+
10
+ describe('route', () => {
11
+ it('should register a route', () => {
12
+ const handler = jest.fn()
13
+ server.route('/chat', handler)
14
+ expect(server.getRoute('/chat').handler).toBe(handler)
15
+ })
16
+
17
+ it('should return null for unregistered route', () => {
18
+ expect(server.getRoute('/unknown')).toBeNull()
19
+ })
20
+
21
+ it('should match parameterized routes', () => {
22
+ const handler = jest.fn()
23
+ server.route('/room/{id}', handler)
24
+
25
+ const result = server.getRoute('/room/123')
26
+ expect(result).toBeDefined()
27
+ expect(result.handler).toBe(handler)
28
+ expect(result.params).toEqual({id: '123'})
29
+ })
30
+
31
+ it('should match multiple parameters', () => {
32
+ const handler = jest.fn()
33
+ server.route('/chat/{room}/user/{userId}', handler)
34
+
35
+ const result = server.getRoute('/chat/general/user/42')
36
+ expect(result.params).toEqual({room: 'general', userId: '42'})
37
+ })
38
+ })
39
+
40
+ describe('rooms', () => {
41
+ it('should join and leave rooms', () => {
42
+ server.joinRoom('client1', 'room1')
43
+ server.joinRoom('client2', 'room1')
44
+
45
+ server.leaveRoom('client1', 'room1')
46
+ server.leaveRoom('client2', 'room1')
47
+ })
48
+ })
49
+
50
+ describe('broadcast', () => {
51
+ it('should send message to all connected clients', () => {
52
+ const client1 = {id: 'c1', send: jest.fn()}
53
+ const client2 = {id: 'c2', send: jest.fn()}
54
+
55
+ server.clients.set('c1', client1)
56
+ server.clients.set('c2', client2)
57
+
58
+ server.broadcast('hello')
59
+
60
+ expect(client1.send).toHaveBeenCalledWith('hello')
61
+ expect(client2.send).toHaveBeenCalledWith('hello')
62
+ })
63
+
64
+ it('should exclude specified client from broadcast', () => {
65
+ const client1 = {id: 'c1', send: jest.fn()}
66
+ const client2 = {id: 'c2', send: jest.fn()}
67
+
68
+ server.clients.set('c1', client1)
69
+ server.clients.set('c2', client2)
70
+
71
+ server.broadcast('hello', 'c1')
72
+
73
+ expect(client1.send).not.toHaveBeenCalled()
74
+ expect(client2.send).toHaveBeenCalledWith('hello')
75
+ })
76
+ })
77
+
78
+ describe('clients', () => {
79
+ it('should track client count', () => {
80
+ expect(server.clientCount).toBe(0)
81
+ })
82
+ })
83
+
84
+ describe('cleanup on disconnect', () => {
85
+ it('should be handled by Route.setWs wrapper', () => {
86
+ expect(true).toBe(true)
87
+ })
88
+ })
89
+ describe('maxPayload', () => {
90
+ it('should close connection if payload exceeds limit', () => {
91
+ const socket = {
92
+ pause: jest.fn(),
93
+ resume: jest.fn(),
94
+ on: jest.fn(),
95
+ write: jest.fn(),
96
+ end: jest.fn(),
97
+ removeAllListeners: jest.fn()
98
+ }
99
+ const {WebSocketClient} = require('../src/WebSocket.js')
100
+ new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
101
+
102
+ // We must send a MASKED frame because server expects masked frames from client
103
+ const buffer = Buffer.alloc(100)
104
+ buffer[0] = 0x81 // fin + text
105
+ buffer[1] = 0x80 | 20 // masked + length 20
106
+ // Mask key (4 bytes) + Payload (20 bytes) needed but header check happens first
107
+
108
+ const dataHandler = socket.on.mock.calls.find(call => call[0] === 'data')[1]
109
+ dataHandler(buffer)
110
+
111
+ expect(socket.end).toHaveBeenCalled()
112
+ // Verify close frame sent with 1009
113
+ // socket.write is called to send the Close frame
114
+ const writeCall = socket.write.mock.calls[0][0]
115
+ expect(writeCall[2]).toBe(0x03) // 1009 >> 8
116
+ expect(writeCall[3]).toBe(0xf1) // 1009 & 0xff
117
+ })
118
+ })
119
+
120
+ describe('rateLimit', () => {
121
+ it('should close connection if rate limit exceeded', () => {
122
+ const socket = {
123
+ pause: jest.fn(),
124
+ resume: jest.fn(),
125
+ on: jest.fn(),
126
+ write: jest.fn(),
127
+ end: jest.fn(),
128
+ removeAllListeners: jest.fn()
129
+ }
130
+ const {WebSocketClient} = require('../src/WebSocket.js')
131
+ new WebSocketClient(socket, server, 'test-id', {
132
+ rateLimit: {max: 2, window: 1000}
133
+ })
134
+
135
+ // Valid MASKED frame (exact size 7 bytes)
136
+ const buffer = Buffer.alloc(7)
137
+ buffer[0] = 0x81
138
+ buffer[1] = 0x80 | 1 // masked + length 1
139
+ buffer[2] = 0x00
140
+ buffer[3] = 0x00
141
+ buffer[4] = 0x00
142
+ buffer[5] = 0x00 // mask key (0)
143
+ buffer[6] = 0x61 // 'a' (masked with 0 remains 'a')
144
+
145
+ const dataHandler = socket.on.mock.calls.find(call => call[0] === 'data')[1]
146
+
147
+ // Send 3 messages (limit is 2)
148
+ dataHandler(buffer)
149
+ dataHandler(buffer)
150
+ dataHandler(buffer)
151
+
152
+ expect(socket.end).toHaveBeenCalled()
153
+ // Verify close frame sent with 1008
154
+ const writeCalls = socket.write.mock.calls
155
+ const writeCall = writeCalls[writeCalls.length - 1][0]
156
+ expect(writeCall[2]).toBe(0x03) // 1008 >> 8
157
+ expect(writeCall[3]).toBe(0xf0) // 1008 & 0xff
158
+ })
159
+
160
+ it('should reset count after window', done => {
161
+ const socket = {
162
+ pause: jest.fn(),
163
+ resume: jest.fn(),
164
+ on: jest.fn(),
165
+ write: jest.fn(),
166
+ end: jest.fn(),
167
+ removeAllListeners: jest.fn()
168
+ }
169
+ const {WebSocketClient} = require('../src/WebSocket.js')
170
+ const client = new WebSocketClient(socket, server, 'test-id', {
171
+ rateLimit: {max: 2, window: 200}
172
+ })
173
+
174
+ const buffer = Buffer.alloc(7)
175
+ buffer[0] = 0x81
176
+ buffer[1] = 0x80 | 1
177
+ buffer[2] = 0x00
178
+ buffer[3] = 0x00
179
+ buffer[4] = 0x00
180
+ buffer[5] = 0x00
181
+ buffer[6] = 0x61
182
+
183
+ const dataHandler = socket.on.mock.calls.find(call => call[0] === 'data')[1]
184
+
185
+ // Send 2 messages
186
+ dataHandler(buffer)
187
+ dataHandler(buffer)
188
+ expect(socket.end).not.toHaveBeenCalled()
189
+
190
+ setTimeout(() => {
191
+ // Send 1 more after window reset
192
+ dataHandler(buffer)
193
+
194
+ try {
195
+ expect(socket.end).not.toHaveBeenCalled()
196
+ client.close()
197
+ done()
198
+ } catch (error) {
199
+ client.close()
200
+ done(error)
201
+ }
202
+ }, 300)
203
+ })
204
+ })
205
+ })
206
+
207
+ describe('Route WebSocket Integration', () => {
208
+ const Route = require('../src/Route.js')
209
+
210
+ beforeEach(() => {
211
+ global.Odac = {
212
+ Route: new Route()
213
+ }
214
+ })
215
+
216
+ it('should support ws() method', () => {
217
+ expect(typeof Odac.Route.ws).toBe('function')
218
+ })
219
+
220
+ it('should support auth.ws() method', () => {
221
+ expect(typeof Odac.Route.auth.ws).toBe('function')
222
+ })
223
+
224
+ it('should support middleware with ws()', () => {
225
+ const chain = Odac.Route.use('test-middleware')
226
+ expect(typeof chain.ws).toBe('function')
227
+ })
228
+
229
+ it('should support middleware with auth.ws()', () => {
230
+ const chain = Odac.Route.use('test-middleware')
231
+ expect(typeof chain.auth.ws).toBe('function')
232
+ })
233
+
234
+ it('should support auth.use() with ws()', () => {
235
+ const chain = Odac.Route.auth.use('test-middleware')
236
+ expect(typeof chain.ws).toBe('function')
237
+ })
238
+ })
@@ -17,7 +17,7 @@ function getStagedFiles() {
17
17
  return output
18
18
  .split('\n')
19
19
  .filter(file => file.endsWith('.js'))
20
- .filter(file => file.startsWith('core/') || file.startsWith('server/'))
20
+ .filter(file => file.startsWith('src/') || file.startsWith('client/'))
21
21
  .filter(file => !file.includes('.test.js') && !file.includes('.spec.js'))
22
22
  } catch (err) {
23
23
  console.error('Error getting staged files:', err.message)
@@ -34,7 +34,7 @@ function checkTestFiles(changedFiles) {
34
34
  if (!fs.existsSync(file)) continue
35
35
 
36
36
  // Determine test file path
37
- const testFile = file.replace(/^(core|server)\//, 'test/$1/').replace(/\.js$/, '.test.js')
37
+ const testFile = file.replace(/^(src|client)\//, 'test/').replace(/\.js$/, '.test.js')
38
38
 
39
39
  if (!fs.existsSync(testFile)) {
40
40
  missingTests.push({
@@ -60,7 +60,7 @@ function runTestsForFiles(files) {
60
60
  // Create a pattern to match test files for changed source files
61
61
  const testPatterns = files
62
62
  .map(file => {
63
- const testFile = file.replace(/^(core|server)\//, 'test/$1/').replace(/\.js$/, '.test.js')
63
+ const testFile = file.replace(/^(src|client)\//, 'test/').replace(/\.js$/, '.test.js')
64
64
  return testFile
65
65
  })
66
66
  .filter(testFile => fs.existsSync(testFile))
@@ -98,7 +98,7 @@ function main() {
98
98
  const changedFiles = getStagedFiles()
99
99
 
100
100
  if (changedFiles.length === 0) {
101
- console.log('✓ No core or server files changed\n')
101
+ console.log('✓ No src or client files changed\n')
102
102
  process.exit(0)
103
103
  }
104
104
 
@@ -1,36 +0,0 @@
1
- const Cli = require('../../cli/src/Cli.js')
2
-
3
- describe('CLI Prefix Argument Parsing', () => {
4
- test('parseArg should extract value after prefix', () => {
5
- const args = ['web', 'create', '-d', 'example.com', '--other', 'value']
6
-
7
- expect(Cli.parseArg(args, ['-d', '--domain'])).toBe('example.com')
8
- expect(Cli.parseArg(args, ['--other'])).toBe('value')
9
- expect(Cli.parseArg(args, ['-x', '--missing'])).toBeNull()
10
- })
11
-
12
- test('parseArg should handle multiple prefix options', () => {
13
- const args = ['mail', 'create', '--email', 'test@example.com', '-p', 'password123']
14
-
15
- expect(Cli.parseArg(args, ['-e', '--email'])).toBe('test@example.com')
16
- expect(Cli.parseArg(args, ['-p', '--password'])).toBe('password123')
17
- })
18
-
19
- test('parseArg should return null for missing arguments', () => {
20
- const args = ['web', 'create']
21
-
22
- expect(Cli.parseArg(args, ['-d', '--domain'])).toBeNull()
23
- expect(Cli.parseArg(null, ['-d'])).toBeNull()
24
- expect(Cli.parseArg(args, null)).toBeNull()
25
- })
26
-
27
- test('parseArg should handle edge cases', () => {
28
- const args = ['command', '-d']
29
-
30
- // Missing value after prefix
31
- expect(Cli.parseArg(args, ['-d'])).toBeNull()
32
-
33
- // Empty args
34
- expect(Cli.parseArg([], ['-d'])).toBeNull()
35
- })
36
- })