odac 1.3.0 → 1.4.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.
- package/.agent/rules/memory.md +7 -1
- package/.github/workflows/release.yml +0 -4
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/bin/odac.js +169 -6
- package/client/odac.js +15 -11
- package/docs/ai/README.md +49 -0
- package/docs/ai/skills/SKILL.md +39 -0
- package/docs/ai/skills/backend/authentication.md +67 -0
- package/docs/ai/skills/backend/config.md +32 -0
- package/docs/ai/skills/backend/controllers.md +62 -0
- package/docs/ai/skills/backend/cron.md +50 -0
- package/docs/ai/skills/backend/database.md +21 -0
- package/docs/ai/skills/backend/forms.md +19 -0
- package/docs/ai/skills/backend/ipc.md +55 -0
- package/docs/ai/skills/backend/mail.md +34 -0
- package/docs/ai/skills/backend/request_response.md +35 -0
- package/docs/ai/skills/backend/routing.md +51 -0
- package/docs/ai/skills/backend/storage.md +43 -0
- package/docs/ai/skills/backend/streaming.md +34 -0
- package/docs/ai/skills/backend/structure.md +57 -0
- package/docs/ai/skills/backend/translations.md +42 -0
- package/docs/ai/skills/backend/utilities.md +24 -0
- package/docs/ai/skills/backend/validation.md +53 -0
- package/docs/ai/skills/backend/views.md +61 -0
- package/docs/ai/skills/frontend/core.md +66 -0
- package/docs/ai/skills/frontend/forms.md +21 -0
- package/docs/ai/skills/frontend/navigation.md +20 -0
- package/docs/ai/skills/frontend/realtime.md +47 -0
- 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 +100 -15
- 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/test/Auth.test.js +249 -0
- package/test/Client.test.js +29 -0
- package/test/View/Form.test.js +37 -0
package/src/View/Form.js
CHANGED
|
@@ -3,6 +3,18 @@ const nodeCrypto = require('crypto')
|
|
|
3
3
|
class Form {
|
|
4
4
|
static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
|
|
5
5
|
|
|
6
|
+
static escapeHtml(value) {
|
|
7
|
+
if (value === null || value === undefined) return ''
|
|
8
|
+
const map = {
|
|
9
|
+
'&': '&',
|
|
10
|
+
'<': '<',
|
|
11
|
+
'>': '>',
|
|
12
|
+
'"': '"',
|
|
13
|
+
"'": '''
|
|
14
|
+
}
|
|
15
|
+
return String(value).replace(/[&<>"']/g, ch => map[ch])
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
static parse(content, Odac) {
|
|
7
19
|
for (const type of this.FORM_TYPES) {
|
|
8
20
|
content = this.parseFormType(content, Odac, type)
|
|
@@ -14,8 +26,14 @@ class Form {
|
|
|
14
26
|
const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
|
|
15
27
|
return content.replace(regex, match => {
|
|
16
28
|
const formConfig = this.extractConfig(match, null, type)
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
let configStr = JSON.stringify(formConfig)
|
|
30
|
+
let matchStr = JSON.stringify(match)
|
|
31
|
+
|
|
32
|
+
// Unquote dynamic variables to make them live JS expressions in the compiled view
|
|
33
|
+
// We avoid {{ }} here because View engine would turn them into ${ } which is invalid in naked JS
|
|
34
|
+
configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await $1)')
|
|
35
|
+
matchStr = matchStr.replace(/\{\{([\s\S]*?)\}\}/g, '" + (await Odac.Var(await $1).html()) + "')
|
|
36
|
+
|
|
19
37
|
return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
|
|
20
38
|
})
|
|
21
39
|
}
|
|
@@ -91,7 +109,7 @@ class Form {
|
|
|
91
109
|
if (redirectMatch) config.redirect = redirectMatch[1]
|
|
92
110
|
if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
|
|
93
111
|
|
|
94
|
-
const submitMatch = html.match(/<odac:submit([
|
|
112
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
95
113
|
if (submitMatch) {
|
|
96
114
|
const submitTag = submitMatch[1]
|
|
97
115
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -145,22 +163,25 @@ class Form {
|
|
|
145
163
|
id: null,
|
|
146
164
|
unique: false,
|
|
147
165
|
skip: false,
|
|
166
|
+
value: null,
|
|
148
167
|
validations: []
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
const typeMatch = fieldTag.match(/type=
|
|
152
|
-
const placeholderMatch = fieldTag.match(/placeholder=
|
|
153
|
-
const labelMatch = fieldTag.match(/label=
|
|
154
|
-
const classMatch = fieldTag.match(/class=
|
|
155
|
-
const idMatch = fieldTag.match(/id=
|
|
170
|
+
const typeMatch = fieldTag.match(/type=(["'])(.*?)\1/)
|
|
171
|
+
const placeholderMatch = fieldTag.match(/placeholder=(["'])(.*?)\1/)
|
|
172
|
+
const labelMatch = fieldTag.match(/label=(["'])(.*?)\1/)
|
|
173
|
+
const classMatch = fieldTag.match(/class=(["'])(.*?)\1/)
|
|
174
|
+
const idMatch = fieldTag.match(/id=(["'])(.*?)\1/)
|
|
175
|
+
const valueMatch = fieldTag.match(/value=(["'])(.*?)\1/)
|
|
156
176
|
const uniqueMatch = fieldTag.match(/unique=["']([^"']+)["']/) || fieldTag.match(/\sunique[\s/>]/)
|
|
157
177
|
const skipMatch = fieldTag.match(/skip=["']([^"']+)["']/) || fieldTag.match(/\sskip[\s/>]/)
|
|
158
178
|
|
|
159
|
-
if (typeMatch) field.type = typeMatch[
|
|
160
|
-
if (placeholderMatch) field.placeholder = placeholderMatch[
|
|
161
|
-
if (labelMatch) field.label = labelMatch[
|
|
162
|
-
if (classMatch) field.class = classMatch[
|
|
163
|
-
if (idMatch) field.id = idMatch[
|
|
179
|
+
if (typeMatch) field.type = typeMatch[2]
|
|
180
|
+
if (placeholderMatch) field.placeholder = placeholderMatch[2]
|
|
181
|
+
if (labelMatch) field.label = labelMatch[2]
|
|
182
|
+
if (classMatch) field.class = classMatch[2]
|
|
183
|
+
if (idMatch) field.id = idMatch[2]
|
|
184
|
+
if (valueMatch) field.value = valueMatch[2]
|
|
164
185
|
if (uniqueMatch) field.unique = uniqueMatch[1] !== 'false'
|
|
165
186
|
if (skipMatch) field.skip = skipMatch[1] !== 'false'
|
|
166
187
|
|
|
@@ -181,7 +202,7 @@ class Form {
|
|
|
181
202
|
|
|
182
203
|
// Capture generic attributes
|
|
183
204
|
const extraAttrs = {}
|
|
184
|
-
const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
|
|
205
|
+
const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
|
|
185
206
|
const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
|
|
186
207
|
let attrMatch
|
|
187
208
|
// Clean tag to just attributes part for safer regex matching if needed,
|
|
@@ -203,11 +224,11 @@ class Form {
|
|
|
203
224
|
}
|
|
204
225
|
|
|
205
226
|
static parseSet(html) {
|
|
206
|
-
const nameMatch = html.match(/name=
|
|
227
|
+
const nameMatch = html.match(/name=(["'])(.*?)\1/)
|
|
207
228
|
if (!nameMatch) return null
|
|
208
229
|
|
|
209
230
|
const set = {
|
|
210
|
-
name: nameMatch[
|
|
231
|
+
name: nameMatch[2],
|
|
211
232
|
value: null,
|
|
212
233
|
compute: null,
|
|
213
234
|
callback: null,
|
|
@@ -215,14 +236,14 @@ class Form {
|
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
const valueMatch = html.match(/value=(["'])(.*?)\1/)
|
|
218
|
-
const computeMatch = html.match(/compute=
|
|
219
|
-
const callbackMatch = html.match(/callback=
|
|
220
|
-
const ifEmptyMatch = html.match(/if-empty=
|
|
239
|
+
const computeMatch = html.match(/compute=(["'])(.*?)\1/)
|
|
240
|
+
const callbackMatch = html.match(/callback=(["'])(.*?)\1/)
|
|
241
|
+
const ifEmptyMatch = html.match(/if-empty=(["'])(.*?)\1/) || html.match(/\sif-empty[\s/>]/)
|
|
221
242
|
|
|
222
243
|
if (valueMatch) set.value = valueMatch[2]
|
|
223
|
-
if (computeMatch) set.compute = computeMatch[
|
|
224
|
-
if (callbackMatch) set.callback = callbackMatch[
|
|
225
|
-
if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[
|
|
244
|
+
if (computeMatch) set.compute = computeMatch[2]
|
|
245
|
+
if (callbackMatch) set.callback = callbackMatch[2]
|
|
246
|
+
if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[2] !== 'false'
|
|
226
247
|
|
|
227
248
|
return set
|
|
228
249
|
}
|
|
@@ -253,6 +274,9 @@ class Form {
|
|
|
253
274
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
254
275
|
const field = this.parseInput(fieldMatch)
|
|
255
276
|
if (!field) return fieldMatch
|
|
277
|
+
// Sync with resolved config value if available
|
|
278
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
279
|
+
if (configField) field.value = configField.value
|
|
256
280
|
return this.generateFieldHtml(field)
|
|
257
281
|
})
|
|
258
282
|
|
|
@@ -279,31 +303,38 @@ class Form {
|
|
|
279
303
|
|
|
280
304
|
static generateFieldHtml(field) {
|
|
281
305
|
let html = ''
|
|
306
|
+
const escapedName = this.escapeHtml(field.name)
|
|
307
|
+
const escapedType = this.escapeHtml(field.type)
|
|
308
|
+
const escapedPlaceholder = this.escapeHtml(field.placeholder)
|
|
282
309
|
|
|
283
310
|
if (field.label && field.type !== 'checkbox') {
|
|
284
|
-
const fieldId = field.id || `odac-${field.name}`
|
|
285
|
-
html += `<label for="${fieldId}">${field.label}</label>\n`
|
|
311
|
+
const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
|
|
312
|
+
html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
|
|
286
313
|
}
|
|
287
314
|
|
|
288
|
-
const classAttr = field.class ? ` class="${field.class}"` : ''
|
|
289
|
-
const idAttr = field.id ? ` id="${field.id}"` : ` id="odac-${field.name}"`
|
|
315
|
+
const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
|
|
316
|
+
const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
|
|
317
|
+
const valueAttr = field.value !== null ? ` value="${this.escapeHtml(field.value)}"` : ''
|
|
290
318
|
|
|
291
319
|
if (field.type === 'checkbox') {
|
|
292
320
|
const attrs = this.buildHtml5Attributes(field)
|
|
321
|
+
const checkedAttr = field.value === '1' || field.value === true || field.value === 'true' ? ' checked' : ''
|
|
293
322
|
if (field.label) {
|
|
294
323
|
html += `<label>\n`
|
|
295
|
-
html += ` <input type="checkbox"${idAttr} name="${
|
|
296
|
-
html += ` ${field.label}\n`
|
|
324
|
+
html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
325
|
+
html += ` ${this.escapeHtml(field.label)}\n`
|
|
297
326
|
html += `</label>\n`
|
|
298
327
|
} else {
|
|
299
|
-
html += `<input type="checkbox"${idAttr} name="${
|
|
328
|
+
html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
300
329
|
}
|
|
301
330
|
} else if (field.type === 'textarea') {
|
|
302
331
|
const attrs = this.buildHtml5Attributes(field)
|
|
303
|
-
html += `<textarea${idAttr} name="${
|
|
332
|
+
html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
|
|
333
|
+
field.value || ''
|
|
334
|
+
)}</textarea>\n`
|
|
304
335
|
} else {
|
|
305
336
|
const attrs = this.buildHtml5Attributes(field)
|
|
306
|
-
html += `<input type="${
|
|
337
|
+
html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
|
|
307
338
|
}
|
|
308
339
|
|
|
309
340
|
return html
|
|
@@ -319,7 +350,7 @@ class Form {
|
|
|
319
350
|
if (val === '') {
|
|
320
351
|
attrs += ` ${key}`
|
|
321
352
|
} else {
|
|
322
|
-
attrs += ` ${key}="${
|
|
353
|
+
attrs += ` ${key}="${this.escapeHtml(val)}"`
|
|
323
354
|
}
|
|
324
355
|
}
|
|
325
356
|
}
|
|
@@ -406,11 +437,11 @@ class Form {
|
|
|
406
437
|
if (html5Rules.max) attrs += ` max="${html5Rules.max}"`
|
|
407
438
|
if (html5Rules.pattern) attrs += ` pattern="${html5Rules.pattern}"`
|
|
408
439
|
|
|
409
|
-
if (errorMessages.required) attrs += ` data-error-required="${errorMessages.required
|
|
410
|
-
if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength
|
|
411
|
-
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength
|
|
412
|
-
if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern
|
|
413
|
-
if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email
|
|
440
|
+
if (errorMessages.required) attrs += ` data-error-required="${this.escapeHtml(errorMessages.required)}"`
|
|
441
|
+
if (errorMessages.minlength) attrs += ` data-error-minlength="${this.escapeHtml(errorMessages.minlength)}"`
|
|
442
|
+
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
|
|
443
|
+
if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
|
|
444
|
+
if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
|
|
414
445
|
|
|
415
446
|
attrs = this.appendExtraAttributes(attrs, field)
|
|
416
447
|
|
|
@@ -434,7 +465,7 @@ class Form {
|
|
|
434
465
|
|
|
435
466
|
if (redirectMatch) config.redirect = redirectMatch[1]
|
|
436
467
|
|
|
437
|
-
const submitMatch = html.match(/<odac:submit([
|
|
468
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
438
469
|
if (submitMatch) {
|
|
439
470
|
const submitTag = submitMatch[1]
|
|
440
471
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -489,6 +520,9 @@ class Form {
|
|
|
489
520
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
490
521
|
const field = this.parseInput(fieldMatch)
|
|
491
522
|
if (!field) return fieldMatch
|
|
523
|
+
// Sync with resolved config value if available
|
|
524
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
525
|
+
if (configField) field.value = configField.value
|
|
492
526
|
return this.generateFieldHtml(field)
|
|
493
527
|
})
|
|
494
528
|
|
|
@@ -551,8 +585,10 @@ class Form {
|
|
|
551
585
|
if (tableMatch) config.table = tableMatch
|
|
552
586
|
if (redirectMatch) config.redirect = redirectMatch
|
|
553
587
|
if (successMatch) config.successMessage = successMatch
|
|
588
|
+
const clearMatch = extractAttr('clear')
|
|
589
|
+
if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
|
|
554
590
|
|
|
555
|
-
const submitMatch = html.match(/<odac:submit([
|
|
591
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
556
592
|
if (submitMatch) {
|
|
557
593
|
const submitTag = submitMatch[1]
|
|
558
594
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -618,29 +654,30 @@ class Form {
|
|
|
618
654
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
619
655
|
const field = this.parseInput(fieldMatch)
|
|
620
656
|
if (!field) return fieldMatch
|
|
657
|
+
// Sync with resolved config value if available
|
|
658
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
659
|
+
if (configField) field.value = configField.value
|
|
621
660
|
return this.generateFieldHtml(field)
|
|
622
661
|
})
|
|
623
662
|
|
|
624
|
-
const escapeHtml = str =>
|
|
625
|
-
String(str).replace(/[&<>"']/g, m => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[m])
|
|
626
|
-
|
|
627
663
|
const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
|
|
628
664
|
if (submitMatch) {
|
|
629
|
-
let submitAttrs = `type="submit" data-submit-text="${escapeHtml(submitText)}" data-loading-text="${escapeHtml(submitLoading)}"`
|
|
630
|
-
if (config.submitClass) submitAttrs += ` class="${escapeHtml(config.submitClass)}"`
|
|
631
|
-
if (config.submitStyle) submitAttrs += ` style="${escapeHtml(config.submitStyle)}"`
|
|
632
|
-
if (config.submitId) submitAttrs += ` id="${escapeHtml(config.submitId)}"`
|
|
633
|
-
const submitButton = `<button ${submitAttrs}>${escapeHtml(submitText)}</button>`
|
|
665
|
+
let submitAttrs = `type="submit" data-submit-text="${this.escapeHtml(submitText)}" data-loading-text="${this.escapeHtml(submitLoading)}"`
|
|
666
|
+
if (config.submitClass) submitAttrs += ` class="${this.escapeHtml(config.submitClass)}"`
|
|
667
|
+
if (config.submitStyle) submitAttrs += ` style="${this.escapeHtml(config.submitStyle)}"`
|
|
668
|
+
if (config.submitId) submitAttrs += ` id="${this.escapeHtml(config.submitId)}"`
|
|
669
|
+
const submitButton = `<button ${submitAttrs}>${this.escapeHtml(submitText)}</button>`
|
|
634
670
|
innerContent = innerContent.replace(submitMatch[0], submitButton)
|
|
635
671
|
}
|
|
636
672
|
|
|
637
673
|
innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
|
|
638
674
|
|
|
639
|
-
let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
|
|
640
|
-
if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
|
|
675
|
+
let formAttrs = `class="odac-custom-form${config.class ? ' ' + this.escapeHtml(config.class) : ''}" data-odac-form="${this.escapeHtml(formToken)}" method="${this.escapeHtml(method)}" action="${this.escapeHtml(formAction)}" novalidate`
|
|
676
|
+
if (config.id) formAttrs += ` id="${this.escapeHtml(config.id)}"`
|
|
677
|
+
if (config.clear !== undefined) formAttrs += ` clear="${config.clear}"`
|
|
641
678
|
|
|
642
679
|
let html = `<form ${formAttrs}>\n`
|
|
643
|
-
html += ` <input type="hidden" name="_odac_form_token" value="${escapeHtml(formToken)}">\n`
|
|
680
|
+
html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
|
|
644
681
|
html += innerContent
|
|
645
682
|
html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
|
|
646
683
|
html += `</form>`
|
|
@@ -693,7 +730,7 @@ class Form {
|
|
|
693
730
|
})
|
|
694
731
|
}
|
|
695
732
|
|
|
696
|
-
const submitMatch = html.match(/<odac:submit([
|
|
733
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
697
734
|
if (submitMatch) {
|
|
698
735
|
const submitTag = submitMatch[1]
|
|
699
736
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -751,6 +788,9 @@ class Form {
|
|
|
751
788
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
752
789
|
const field = this.parseInput(fieldMatch)
|
|
753
790
|
if (!field) return fieldMatch
|
|
791
|
+
// Sync with resolved config value if available
|
|
792
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
793
|
+
if (configField) field.value = configField.value
|
|
754
794
|
return this.generateFieldHtml(field)
|
|
755
795
|
})
|
|
756
796
|
}
|
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 "
|
|
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.
|
|
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,249 @@
|
|
|
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
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should NOT rotate a token already marked as rotated (Epoch Date marker)', async () => {
|
|
143
|
+
const mockRecord = {
|
|
144
|
+
active: new Date(), // Still within maxAge
|
|
145
|
+
browser: 'TestBrowser',
|
|
146
|
+
date: new Date(0), // Epoch marker = already rotated
|
|
147
|
+
id: 'token_2',
|
|
148
|
+
ip: '127.0.0.1',
|
|
149
|
+
token_x: 'old_x',
|
|
150
|
+
token_y: 'hashed_old_y',
|
|
151
|
+
user: 'user_10'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const dbMock = createDbMock([mockRecord])
|
|
155
|
+
global.Odac.DB.user_tokens = dbMock
|
|
156
|
+
global.Odac.DB.users = dbMock
|
|
157
|
+
|
|
158
|
+
const result = await authInstance.check()
|
|
159
|
+
|
|
160
|
+
expect(result).toBe(true)
|
|
161
|
+
// No rotation should occur
|
|
162
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
163
|
+
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
|
|
167
|
+
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
|
|
168
|
+
|
|
169
|
+
const mockRecord = {
|
|
170
|
+
active: new Date(),
|
|
171
|
+
browser: 'TestBrowser',
|
|
172
|
+
date: new Date(recentDate),
|
|
173
|
+
id: 'token_3',
|
|
174
|
+
ip: '127.0.0.1',
|
|
175
|
+
token_x: 'old_x',
|
|
176
|
+
token_y: 'hashed_old_y',
|
|
177
|
+
user: 'user_10'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const dbMock = createDbMock([mockRecord])
|
|
181
|
+
global.Odac.DB.user_tokens = dbMock
|
|
182
|
+
global.Odac.DB.users = dbMock
|
|
183
|
+
|
|
184
|
+
const result = await authInstance.check()
|
|
185
|
+
|
|
186
|
+
expect(result).toBe(true)
|
|
187
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
188
|
+
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should delete token and return false when inactiveAge exceeds maxAge', async () => {
|
|
192
|
+
const staleActive = Date.now() - 31 * 24 * 60 * 60 * 1000 // 31 days ago -> exceeds 30 day maxAge
|
|
193
|
+
|
|
194
|
+
const mockRecord = {
|
|
195
|
+
active: new Date(staleActive),
|
|
196
|
+
browser: 'TestBrowser',
|
|
197
|
+
date: new Date(),
|
|
198
|
+
id: 'token_4',
|
|
199
|
+
ip: '127.0.0.1',
|
|
200
|
+
token_x: 'old_x',
|
|
201
|
+
token_y: 'hashed_old_y',
|
|
202
|
+
user: 'user_10'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const dbMock = createDbMock([mockRecord])
|
|
206
|
+
global.Odac.DB.user_tokens = dbMock
|
|
207
|
+
global.Odac.DB.users = dbMock
|
|
208
|
+
|
|
209
|
+
const result = await authInstance.check()
|
|
210
|
+
|
|
211
|
+
expect(result).toBe(false)
|
|
212
|
+
// Token should be deleted
|
|
213
|
+
expect(dbMock.tracker.deleteCalls.length).toBe(1)
|
|
214
|
+
// No rotation should occur
|
|
215
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
|
|
219
|
+
const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
|
|
220
|
+
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
|
|
221
|
+
|
|
222
|
+
const mockRecord = {
|
|
223
|
+
active: new Date(staleActive),
|
|
224
|
+
browser: 'TestBrowser',
|
|
225
|
+
date: new Date(recentDate),
|
|
226
|
+
id: 'token_5',
|
|
227
|
+
ip: '127.0.0.1',
|
|
228
|
+
token_x: 'old_x',
|
|
229
|
+
token_y: 'hashed_old_y',
|
|
230
|
+
user: 'user_10'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const dbMock = createDbMock([mockRecord])
|
|
234
|
+
global.Odac.DB.user_tokens = dbMock
|
|
235
|
+
global.Odac.DB.users = dbMock
|
|
236
|
+
|
|
237
|
+
const result = await authInstance.check()
|
|
238
|
+
|
|
239
|
+
expect(result).toBe(true)
|
|
240
|
+
// Should NOT rotate (tokenAge within threshold)
|
|
241
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
242
|
+
// Should update active timestamp (fallback path)
|
|
243
|
+
expect(dbMock.tracker.updateCalls.length).toBe(1)
|
|
244
|
+
const updatePayload = dbMock.tracker.updateCalls[0]
|
|
245
|
+
expect(updatePayload.active).toBeInstanceOf(Date)
|
|
246
|
+
// Should NOT have Epoch marker
|
|
247
|
+
expect(updatePayload.date).toBeUndefined()
|
|
248
|
+
})
|
|
249
|
+
})
|
package/test/Client.test.js
CHANGED
|
@@ -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})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const Form = require('../../src/View/Form')
|
|
2
|
+
|
|
3
|
+
describe('Form HTML escaping', () => {
|
|
4
|
+
test('should escape textarea content to prevent tag breakout XSS', () => {
|
|
5
|
+
const html = Form.generateFieldHtml({
|
|
6
|
+
name: 'bio',
|
|
7
|
+
type: 'textarea',
|
|
8
|
+
placeholder: 'About me',
|
|
9
|
+
label: null,
|
|
10
|
+
class: '',
|
|
11
|
+
id: null,
|
|
12
|
+
value: '</textarea><script>alert(1)</script>',
|
|
13
|
+
validations: [],
|
|
14
|
+
extraAttributes: {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
expect(html).toContain('</textarea><script>alert(1)</script>')
|
|
18
|
+
expect(html).not.toContain('</textarea><script>alert(1)</script>')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('should escape full HTML entities in input value attributes', () => {
|
|
22
|
+
const html = Form.generateFieldHtml({
|
|
23
|
+
name: 'displayName',
|
|
24
|
+
type: 'text',
|
|
25
|
+
placeholder: 'Name',
|
|
26
|
+
label: null,
|
|
27
|
+
class: '',
|
|
28
|
+
id: null,
|
|
29
|
+
value: 'a&b"c<d>e\'f',
|
|
30
|
+
validations: [],
|
|
31
|
+
extraAttributes: {}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('value="a&b"c<d>e'f"')
|
|
35
|
+
expect(html).not.toContain('value="a&b"c<d>e\'f"')
|
|
36
|
+
})
|
|
37
|
+
})
|