From ff87d4146574c876057f83c78288a658c47bc089 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 09:18:24 -0700 Subject: [PATCH 01/23] cleaned up some comments --- src/directives.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/directives.js b/src/directives.js index 2d313f2..1c91129 100644 --- a/src/directives.js +++ b/src/directives.js @@ -105,8 +105,7 @@ export async function processContent(el, context) { * * @param {Element} el the element to potentially be repeated * @param {Object} context the rendering context - * @returns {Promise} if the node was repeated - * the net number of nodes added/removed as a result of the repeat directive + * @returns {Promise} if there was a repeat directive */ export async function processRepeat(el, context) { const repeatAttrName = el.getAttributeNames().find((attrName) => attrName.startsWith('data-fly-repeat')); @@ -154,7 +153,7 @@ export async function processRepeat(el, context) { * * @param {Element} el the element to process * @param {Object} context the rendering context - * @returns {Promise} if there was a include directive + * @returns {Promise} if there was an include directive */ export async function processInclude(el, context) { if (!el.hasAttribute('data-fly-include')) return false; @@ -185,7 +184,7 @@ export async function processInclude(el, context) { } /** - * process the unwrap directive, leavving the attribute only if it resolves to true + * process the unwrap directive, leaving the attribute only if it resolves to true * * @param {Element} el the element to process * @param {Object} context the rendering context From fd363cc408c107e3b27e0e79940c7020c1774252 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:12:10 -0700 Subject: [PATCH 02/23] fix(render): skip child traversal when include runs; content takes precedence --- src/render.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/render.js b/src/render.js index e9317df..5782946 100644 --- a/src/render.js +++ b/src/render.js @@ -30,8 +30,13 @@ export async function processNode(node, context) { await processAttributes(node, context); - processChildren = (await processContent(node, context)) - || (await processInclude(node, context)) || true; + // Determine child processing based on content/include directives: + // 1) If content ran, skip include and still process children + // 2) If content did not run but include did, skip processing children (already rendered) + // 3) If neither ran, process children + const hadContent = await processContent(node, context); + const hadInclude = hadContent ? false : await processInclude(node, context); + processChildren = !hadInclude; await resolveUnwrap(node, context); } else if (node.nodeType === Node.TEXT_NODE) { From ba7e458ede1901cf1cc4e6582f8d850b7762f641 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:12:23 -0700 Subject: [PATCH 03/23] test(render): add traversal tests and fixtures for include vs content semantics --- .../static-block/escaped-expression.html | 12 +++++ .../static-block/resolvable-expression.html | 12 +++++ test/render/contentTraversal.test.js | 51 +++++++++++++++++++ test/render/defaultTraversal.test.js | 23 +++++++++ test/render/includeChildProcessing.test.js | 39 ++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 test/fixtures/blocks/static-block/escaped-expression.html create mode 100644 test/fixtures/blocks/static-block/resolvable-expression.html create mode 100644 test/render/contentTraversal.test.js create mode 100644 test/render/defaultTraversal.test.js create mode 100644 test/render/includeChildProcessing.test.js diff --git a/test/fixtures/blocks/static-block/escaped-expression.html b/test/fixtures/blocks/static-block/escaped-expression.html new file mode 100644 index 0000000..a79f62a --- /dev/null +++ b/test/fixtures/blocks/static-block/escaped-expression.html @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/blocks/static-block/resolvable-expression.html b/test/fixtures/blocks/static-block/resolvable-expression.html new file mode 100644 index 0000000..9ed2f92 --- /dev/null +++ b/test/fixtures/blocks/static-block/resolvable-expression.html @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/test/render/contentTraversal.test.js b/test/render/contentTraversal.test.js new file mode 100644 index 0000000..de71170 --- /dev/null +++ b/test/render/contentTraversal.test.js @@ -0,0 +1,51 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-template-curly-in-string */ + +import { expect } from '@esm-bundle/chai'; +import { processNode } from '../../src/render.js'; + +describe('render/content vs include and traversal', () => { + it('content wins over include; include is not executed', async () => { + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + // both directives present + el.setAttribute('data-fly-content', 'contentNode'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/resolvable-expression.html#resolvable'); + wrapper.append(el); + + const injected = document.createElement('div'); + injected.className = 'injected'; + injected.textContent = 'Injected'; + + await processNode(wrapper, { + contentNode: injected, + shouldResolve: 'SHOULD_NOT_BE_USED', + }); + + const inner = wrapper.querySelector('.injected'); + expect(inner).to.not.be.null; + expect(inner.textContent).to.equal('Injected'); + // include should not have run; attribute remains + expect(el.hasAttribute('data-fly-include')).to.equal(true); + }); + + it('children are processed after content injection (expressions resolve)', async () => { + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + el.setAttribute('data-fly-content', 'contentNode'); + wrapper.append(el); + + const injected = document.createElement('div'); + injected.className = 'expr'; + injected.textContent = '${greet}'; + + await processNode(wrapper, { + contentNode: injected, + greet: 'hello', + }); + + const expr = wrapper.querySelector('.expr'); + expect(expr).to.not.be.null; + expect(expr.textContent).to.equal('hello'); + }); +}); diff --git a/test/render/defaultTraversal.test.js b/test/render/defaultTraversal.test.js new file mode 100644 index 0000000..9d42165 --- /dev/null +++ b/test/render/defaultTraversal.test.js @@ -0,0 +1,23 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-template-curly-in-string */ + +import { expect } from '@esm-bundle/chai'; +import { processNode } from '../../src/render.js'; + +describe('render/default traversal without content/include', () => { + it('resolves text expressions in children by default', async () => { + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + const child = document.createElement('span'); + child.className = 'expr'; + child.textContent = '${value}'; + el.append(child); + wrapper.append(el); + + await processNode(wrapper, { value: 'ok' }); + + const expr = wrapper.querySelector('.expr'); + expect(expr).to.not.be.null; + expect(expr.textContent).to.equal('ok'); + }); +}); diff --git a/test/render/includeChildProcessing.test.js b/test/render/includeChildProcessing.test.js new file mode 100644 index 0000000..bf5a225 --- /dev/null +++ b/test/render/includeChildProcessing.test.js @@ -0,0 +1,39 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-template-curly-in-string */ + +import { expect } from '@esm-bundle/chai'; +import { processNode } from '../../src/render.js'; + +describe('render/processNode include child processing', () => { + it('does not reprocess included children expressions (escaped remains literal)', async () => { + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/escaped-expression.html#escaped'); + wrapper.append(el); + + await processNode(wrapper, { + shouldNotResolve: 'WILL_RESOLVE_IF_BUG_PRESENT', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + }); + + const inner = wrapper.querySelector('.inner'); + expect(inner).to.not.be.null; + expect(inner.textContent).to.equal('${shouldNotResolve}'); + }); + + it('resolves expressions inside included template (non-escaped)', async () => { + const wrapper = document.createElement('div'); + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/resolvable-expression.html#resolvable'); + wrapper.append(el); + + await processNode(wrapper, { + shouldResolve: 'OK', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + }); + + const inner = wrapper.querySelector('.inner'); + expect(inner).to.not.be.null; + expect(inner.textContent).to.equal('OK'); + }); +}); From b59c6fc3f4b770e6d1a1c2f7ec74e8f58c068885 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:12:36 -0700 Subject: [PATCH 04/23] chore(templates): remove redundant case-insensitive flag in template id sanitization --- src/templates.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/templates.js b/src/templates.js index 05677db..62a6be0 100644 --- a/src/templates.js +++ b/src/templates.js @@ -10,7 +10,7 @@ export default async function resolveTemplate(context) { context.template = context.template || {}; context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`; - const templateId = `faintly-template-${context.template.path}#${context.template.name || ''}`.toLowerCase().replace(/[^0-9a-z]/gi, '-'); + const templateId = `faintly-template-${context.template.path}#${context.template.name || ''}`.toLowerCase().replace(/[^0-9a-z]/g, '-'); let template = document.getElementById(templateId); if (!template) { const resp = await fetch(context.template.path); @@ -22,7 +22,7 @@ export default async function resolveTemplate(context) { templateDom.querySelectorAll('template').forEach((t) => { const name = t.getAttribute('data-fly-name') || ''; - t.id = `faintly-template-${context.template.path}#${name}`.toLowerCase().replace(/[^0-9a-z]/gi, '-'); + t.id = `faintly-template-${context.template.path}#${name}`.toLowerCase().replace(/[^0-9a-z]/g, '-'); document.body.append(t); }); From 5e0b902fb60801ae46ab5b7e7ea331154f0a84cc Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:12:47 -0700 Subject: [PATCH 05/23] docs: document content vs include precedence, traversal behavior, and escaping --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 393e55a..2ee25fc 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Faintly supports the following directives. * `data-fly-repeat` - Repeat an element for each item of a collection. Attribute value should be an expression that resolves to a collection of Nodes/Elements. * `data-fly-attributes` - Set attributes on the element. Attribute value should be an expression that resolves to a collection of key/value pairs. * `data-fly-content` - Replace the elements content/children. Attribute value should be an expression that resolves to a Node/Element/String, or a collection there-of. + * Content has precedence over include: if both `data-fly-content` and `data-fly-include` are present on the same element, only content is executed. * `data-fly-include` - Replace the elements content/children with another template. Attribute value can be: * the name of a template: `data-fly-include="a-template-name"` * the absolute path to a template file: `data-fly-include="/blocks/some-block/some-template.html"` @@ -89,11 +90,15 @@ Faintly supports the following directives. > Directives are evaluated in a fixed order, as listed above, regardless of the order you place them on the element. > > This means, for example, that the context item set in `data-fly-repeat` can be used in `data-fly-include` on the same element, but not in a `data-fly-test`. +> +> When `data-fly-include` runs, the included template is fully rendered before being inserted and the element's children are not traversed again. This prevents double-processing. Conversely, when `data-fly-content` runs, the injected nodes are traversed so that any directives/expressions inside them are processed. ## Expressions Faintly supports a simple expression syntax for resolving data from the rendering context. It supports only object dot-notation, but will call (optionally async) functions as well. This means that if you need to do something that can't be expressed in dot-notation, then you need to define a custom function for it, and add that function to the rendering context. -For `data-fly-include`, HTML text, and normal attributes, wrap your expression in `${}`. +For `data-fly-include`, HTML text, and normal attributes, wrap your expression in `${}`. + +Escaping: use a leading backslash to prevent evaluation of an expression in text/attributes, e.g. `\${some.value}` will remain literal `${some.value}`. In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. \ No newline at end of file From d571048b2331b92d13be4870617b3f9bb4f0ab93 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:12:54 -0700 Subject: [PATCH 06/23] build: update dist/faintly.js --- dist/faintly.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dist/faintly.js b/dist/faintly.js index dd610bd..29f168d 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -3,7 +3,7 @@ var dp = new DOMParser(); async function resolveTemplate(context) { context.template = context.template || {}; context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`; - const templateId = `faintly-template-${context.template.path}#${context.template.name || ""}`.toLowerCase().replace(/[^0-9a-z]/gi, "-"); + const templateId = `faintly-template-${context.template.path}#${context.template.name || ""}`.toLowerCase().replace(/[^0-9a-z]/g, "-"); let template = document.getElementById(templateId); if (!template) { const resp = await fetch(context.template.path); @@ -12,7 +12,7 @@ async function resolveTemplate(context) { const templateDom = dp.parseFromString(markup, "text/html"); templateDom.querySelectorAll("template").forEach((t) => { const name = t.getAttribute("data-fly-name") || ""; - t.id = `faintly-template-${context.template.path}#${name}`.toLowerCase().replace(/[^0-9a-z]/gi, "-"); + t.id = `faintly-template-${context.template.path}#${name}`.toLowerCase().replace(/[^0-9a-z]/g, "-"); document.body.append(t); }); } @@ -198,7 +198,9 @@ async function processNode(node, context) { const repeated = await processRepeat(node, context); if (repeated) return; await processAttributes(node, context); - processChildren = await processContent(node, context) || await processInclude(node, context) || true; + const hadContent = await processContent(node, context); + const hadInclude = hadContent ? false : await processInclude(node, context); + processChildren = !hadInclude; await resolveUnwrap(node, context); } else if (node.nodeType === Node.TEXT_NODE) { await processTextExpressions(node, context); From b56d0bdd876ee290eb4ec1fde59eabd5f0e0bdc6 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:23:25 -0700 Subject: [PATCH 07/23] docs(agents): use build:strict in checklist; add size-check scripts and CI uses build:strict --- .github/workflows/main.yaml | 10 +-------- AGENTS.md | 8 ++++---- package.json | 5 ++++- scripts/check-size.mjs | 41 +++++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 scripts/check-size.mjs diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index b6e0a01..f38edf0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -22,15 +22,7 @@ jobs: - run: npm ci - run: npm run lint - run: npm run test - - run: npm run build - - name: Check gzipped dist size - run: | - gz_size=$(gzip -c dist/faintly.js | wc -c) - echo "Gzipped size: ${gz_size} bytes" - if [ "$gz_size" -gt 5120 ]; then - echo "Error: dist/faintly.js gzipped size ${gz_size} exceeds 5KB (5120 bytes)" - exit 1 - fi + - run: npm run build:strict - name: Commit dist if changed run: | git config user.name "github-actions[bot]" diff --git a/AGENTS.md b/AGENTS.md index b560155..f45fb97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ Authoritative guide for AI/code agents contributing to this repository. - **Lint (auto-fix)**: `npm run lint:fix` - **Unit tests + coverage**: `npm test` - **Performance tests**: `npm run test:perf` -- **Build bundle**: `npm run build` → outputs `dist/faintly.js` -- **Build (watch)**: `npm run build:watch` +- **Build bundle**: `npm run build` → outputs `dist/faintly.js` and prints gzipped size (warns if over limit) +- **Build (strict)**: `npm run build:strict` → fails if gzipped size exceeds 5120 bytes - **Clean**: `npm run clean` ### Tests and coverage @@ -49,7 +49,7 @@ Authoritative guide for AI/code agents contributing to this repository. ### CI behavior (GitHub Actions) - Workflow: `.github/workflows/main.yaml` runs on pull requests (open/sync/reopen). -- Steps: checkout → Node 20 → `npm ci` → `npm run lint` → `npm test` → `npm run build` → gzip size check (<= 5120 bytes). +- Steps: checkout → Node 20 → `npm ci` → `npm run lint` → `npm test` → `npm run build:strict`. - The workflow will attempt to commit updated `dist/` artifacts back to the PR branch if they changed. ### Repo layout @@ -63,7 +63,7 @@ Authoritative guide for AI/code agents contributing to this repository. 2. Make focused edits under `src/` and relevant tests under `test/`. 3. Run `npm run lint:fix` then `npm run lint` and resolve any remaining issues. 4. Run `npm test` and ensure coverage stays at 100%. -5. Run `npm run build` and verify `dist/faintly.js` updates (if source changed). +5. Run `npm run build:strict` and verify `dist/faintly.js` updates (if source changed). 6. Ensure gzipped size of `dist/faintly.js` remains <= 5120 bytes (CI will enforce). 7. Update `README.md` if you change public behavior or usage. 8. Commit changes; open a PR. CI will validate and may commit updated `dist/` to the PR branch. diff --git a/package.json b/package.json index 017f6c2..0feb2d9 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "test:perf:watch": "web-test-runner --node-resolve --watch --group perf", "lint": "eslint .", "lint:fix": "eslint . --fix", - "build": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js", + "build": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js && node scripts/check-size.mjs", + "build:strict": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js && node scripts/check-size.mjs --strict", "build:watch": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js --watch", + "check:size": "node scripts/check-size.mjs", + "check:size:strict": "node scripts/check-size.mjs --strict", "clean": "rm -rf dist" }, "author": "Sean Steimer", diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs new file mode 100644 index 0000000..e150461 --- /dev/null +++ b/scripts/check-size.mjs @@ -0,0 +1,41 @@ +/* eslint-disable no-console */ +import fs from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import path from 'node:path'; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const DIST_FILE = path.join(ROOT, 'dist', 'faintly.js'); +const LIMIT = Number(process.env.FAINTLY_GZIP_LIMIT || 5120); +const STRICT = process.argv.includes('--strict'); + +function formatBytes(bytes) { + return `${bytes} bytes`; +} + +function main() { + if (!fs.existsSync(DIST_FILE)) { + console.error(`[size-check] dist file not found: ${DIST_FILE}`); + process.exit(1); + } + + const buf = fs.readFileSync(DIST_FILE); + const gz = gzipSync(buf); + const size = gz.length; + + const ok = size <= LIMIT; + const msg = `[size-check] gzipped dist/faintly.js: ${formatBytes(size)} (limit ${formatBytes(LIMIT)})`; + + if (ok) { + console.log(msg); + process.exit(0); + } + + if (STRICT) { + console.error(`${msg} — over limit.`); + process.exit(1); + } + + console.warn(`${msg} — over limit (warning only).`); +} + +main(); From 1f57a3b126136b80f5d004ae3daa94cd92d9f4d2 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 10:31:51 -0700 Subject: [PATCH 08/23] chore(dev): update dev tooling and apply security overrides\n\n- @web/test-runner -> 0.20.2\n- @babel/eslint-parser -> 7.28.5\n- eslint-plugin-import -> 2.32.0\n- esbuild -> 0.25.11\n- overrides: koa@2.16.3, @babel/helpers@^7.28.4\n- refresh lockfile\n\nAll tests pass; build stays under size limit., --- package-lock.json | 961 ++++++++++++++++++++++++---------------------- package.json | 12 +- 2 files changed, 508 insertions(+), 465 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50b7c3b..d6f991b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,15 @@ "version": "1.0.0", "license": "Apache-2.0", "devDependencies": { - "@babel/eslint-parser": "7.25.9", + "@babel/eslint-parser": "7.28.5", "@esm-bundle/chai": "4.3.4-fix.0", - "@web/test-runner": "0.19.0", + "@web/test-runner": "0.20.2", "@web/test-runner-commands": "0.9.0", "@web/test-runner-mocha": "0.9.0", - "esbuild": "0.25.0", + "esbuild": "0.25.11", "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", - "eslint-plugin-import": "2.31.0" + "eslint-plugin-import": "2.32.0" } }, "node_modules/@ampproject/remapping": { @@ -36,15 +36,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -94,9 +94,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.9.tgz", - "integrity": "sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.5.tgz", + "integrity": "sha512-fcdRcWahONYo+JRnJg1/AekOacGvKx12Gu0qXJXFi2WBqQA1i7+O5PaxRB7kxE/Op94dExnCiiar6T09pvdHpA==", "dev": true, "license": "MIT", "dependencies": { @@ -183,9 +183,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "peer": true, @@ -194,9 +194,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -215,29 +215,29 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -247,16 +247,16 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -283,24 +283,24 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -315,9 +315,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], @@ -332,9 +332,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], @@ -349,9 +349,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], @@ -366,9 +366,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], @@ -383,9 +383,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "cpu": [ "x64" ], @@ -400,9 +400,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], @@ -417,9 +417,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], @@ -434,9 +434,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], @@ -451,9 +451,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], @@ -468,9 +468,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], @@ -485,9 +485,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], @@ -502,9 +502,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], @@ -519,9 +519,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], @@ -536,9 +536,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], @@ -553,9 +553,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], @@ -570,9 +570,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "cpu": [ "x64" ], @@ -587,9 +587,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -604,9 +604,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], @@ -621,9 +621,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -638,9 +638,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], @@ -654,10 +654,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -672,9 +689,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], @@ -689,9 +706,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -706,9 +723,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "cpu": [ "x64" ], @@ -973,19 +990,18 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", - "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz", + "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.0", + "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { @@ -996,9 +1012,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -1783,16 +1799,16 @@ } }, "node_modules/@web/test-runner": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.19.0.tgz", - "integrity": "sha512-qLUupi88OK1Kl52cWPD/2JewUCRUxYsZ1V1DyLd05P7u09zCdrUYrtkB/cViWyxlBe/TOvqkSNpcTv6zLJ9GoA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.20.2.tgz", + "integrity": "sha512-zfEGYEDnS0EI8qgoWFjmtkIXhqP15W40NW3dCaKtbxj5eU0a7E53f3GV/tZGD0GlZKF8d4Fyw+AFrwOJU9Z4GA==", "dev": true, "license": "MIT", "dependencies": { "@web/browser-logs": "^0.4.0", "@web/config-loader": "^0.3.0", "@web/dev-server": "^0.4.0", - "@web/test-runner-chrome": "^0.17.0", + "@web/test-runner-chrome": "^0.18.1", "@web/test-runner-commands": "^0.9.0", "@web/test-runner-core": "^0.13.0", "@web/test-runner-mocha": "^0.9.0", @@ -1815,17 +1831,16 @@ } }, "node_modules/@web/test-runner-chrome": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@web/test-runner-chrome/-/test-runner-chrome-0.17.0.tgz", - "integrity": "sha512-Il5N9z41NKWCrQM1TVgRaDWWYoJtG5Ha4fG+cN1MWL2OlzBS4WoOb4lFV3EylZ7+W3twZOFr1zy2Rx61yDYd/A==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@web/test-runner-chrome/-/test-runner-chrome-0.18.1.tgz", + "integrity": "sha512-eO6ctCaqSguGM6G3cFobGHnrEs9wlv9Juj/Akyr4XLjeEMTheNULdvOXw9Bygi+QC/ir/0snMmt+/YKnfy8rYA==", "dev": true, "license": "MIT", "dependencies": { "@web/test-runner-core": "^0.13.0", "@web/test-runner-coverage-v8": "^0.8.0", - "async-mutex": "0.4.0", "chrome-launcher": "^0.15.0", - "puppeteer-core": "^23.2.0" + "puppeteer-core": "^24.0.0" }, "engines": { "node": ">=18.0.0" @@ -1961,9 +1976,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { @@ -2077,18 +2092,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2108,18 +2125,19 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2221,16 +2239,6 @@ "lodash": "^4.17.14" } }, - "node_modules/async-mutex": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2248,11 +2256,19 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -2262,24 +2278,33 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", - "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.1.tgz", + "integrity": "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", - "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.0.tgz", + "integrity": "sha512-GljgCjeupKZJNetTqxKaQArLK10vpmK28or0+RwWjEl5Rk+/xG3wkpmkv+WrcBm3q1BwHKlnhXzR8O37kcvkXQ==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" @@ -2294,9 +2319,9 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -2316,9 +2341,9 @@ } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -2338,26 +2363,16 @@ } } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "node_modules/bare-url": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.1.tgz", + "integrity": "sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } }, "node_modules/basic-ftp": { "version": "5.0.5", @@ -2427,31 +2442,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -2506,9 +2496,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2520,14 +2510,14 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -2650,14 +2640,14 @@ } }, "node_modules/chromium-bidi": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", - "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-10.5.1.tgz", + "integrity": "sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "mitt": "3.0.1", - "zod": "3.23.8" + "mitt": "^3.0.1", + "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" @@ -2964,9 +2954,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3118,9 +3108,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1367902", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", - "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "version": "0.0.1508733", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", + "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, "license": "BSD-3-Clause" }, @@ -3208,9 +3198,9 @@ } }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", "dependencies": { @@ -3225,28 +3215,29 @@ "license": "MIT" }, "node_modules/es-abstract": { - "version": "1.23.6", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.6.tgz", - "integrity": "sha512-Ifco6n3yj2tMZDWNLyloZrytt9lqqlwvS83P3HtaETR0NUOYnIULGGHpktqYGObGy+8wc1okO25p8TjemhImvA==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", + "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.7", - "get-intrinsic": "^1.2.6", - "get-symbol-description": "^1.0.2", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", @@ -3254,31 +3245,36 @@ "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.4", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", - "is-shared-array-buffer": "^1.0.3", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.1.0", - "math-intrinsics": "^1.0.0", - "object-inspect": "^1.13.3", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.3", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.3", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.16" + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -3315,9 +3311,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -3328,28 +3324,32 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -3371,9 +3371,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3384,31 +3384,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" } }, "node_modules/escalade": { @@ -3584,9 +3585,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -3612,30 +3613,30 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -3876,6 +3877,16 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4094,13 +4105,19 @@ "license": "ISC" }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/fresh": { @@ -4198,22 +4215,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", - "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "dunder-proto": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "math-intrinsics": "^1.0.0" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4222,6 +4239,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4254,9 +4285,9 @@ } }, "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dev": true, "license": "MIT", "dependencies": { @@ -4565,27 +4596,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4687,15 +4697,11 @@ } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "dev": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -4801,9 +4807,9 @@ } }, "node_modules/is-core-module": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.0.tgz", - "integrity": "sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -5141,13 +5147,13 @@ } }, "node_modules/is-weakref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", - "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -5272,13 +5278,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5352,9 +5351,9 @@ } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.3.tgz", + "integrity": "sha512-zPPuIt+ku1iCpFBRwseMcPYQ1cJL8l60rSmKeOuGfOXyE6YnTBmf2aEFNL2HQGrD0cPcLO/t+v9RTgC+fwEh/g==", "dev": true, "license": "MIT", "dependencies": { @@ -5595,9 +5594,9 @@ } }, "node_modules/marky": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", - "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "dev": true, "license": "Apache-2.0" }, @@ -5810,9 +5809,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -6002,6 +6001,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-event": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", @@ -6074,9 +6091,9 @@ } }, "node_modules/pac-proxy-agent": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", - "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", "dev": true, "license": "MIT", "dependencies": { @@ -6317,9 +6334,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", "dependencies": { @@ -6338,27 +6355,28 @@ } }, "node_modules/puppeteer-core": { - "version": "23.11.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", - "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "version": "24.26.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.26.1.tgz", + "integrity": "sha512-YHZdo3chJ5b9pTYVnuDuoI3UX/tWJFJyRZvkLbThGy6XeHWC+0KI8iN0UMCkvde5l/YOk3huiVZ/PvwgSbwdrA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.6.1", - "chromium-bidi": "0.11.0", - "debug": "^4.4.0", - "devtools-protocol": "0.0.1367902", + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "10.5.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1508733", "typed-query-selector": "^2.12.0", - "ws": "^8.18.0" + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" }, "engines": { "node": ">=18" } }, "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", "engines": { @@ -6414,13 +6432,6 @@ ], "license": "MIT" }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "dev": true, - "license": "MIT" - }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -6502,15 +6513,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -6761,6 +6774,23 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -6830,6 +6860,21 @@ "node": ">= 0.4" } }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6983,13 +7028,13 @@ } }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -7022,13 +7067,6 @@ "node": ">= 8" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -7039,19 +7077,30 @@ "node": ">= 0.6" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamx": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", - "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string-width": { @@ -7268,13 +7317,6 @@ "dev": true, "license": "MIT" }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7508,17 +7550,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -7603,6 +7634,13 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -7711,16 +7749,17 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", - "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "for-each": "^0.3.3", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, @@ -7877,9 +7916,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index 0feb2d9..018ec4e 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,19 @@ }, "author": "Sean Steimer", "license": "Apache-2.0", + "overrides": { + "koa": "2.16.3", + "@babel/helpers": "^7.28.4" + }, "devDependencies": { - "@babel/eslint-parser": "7.25.9", + "@babel/eslint-parser": "7.28.5", "@esm-bundle/chai": "4.3.4-fix.0", - "@web/test-runner": "0.19.0", + "@web/test-runner": "0.20.2", "@web/test-runner-commands": "0.9.0", "@web/test-runner-mocha": "0.9.0", "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", - "eslint-plugin-import": "2.31.0", - "esbuild": "0.25.0" + "eslint-plugin-import": "2.32.0", + "esbuild": "0.25.11" } } \ No newline at end of file From 5a592c33822cb3a7d440bd7ca0a7f62dd63b0153 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 28 Oct 2025 11:31:43 -0700 Subject: [PATCH 09/23] chore: deps updates --- package-lock.json | 6 +++--- package.json | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6f991b..949df6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2385,9 +2385,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 018ec4e..239951f 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,6 @@ }, "author": "Sean Steimer", "license": "Apache-2.0", - "overrides": { - "koa": "2.16.3", - "@babel/helpers": "^7.28.4" - }, "devDependencies": { "@babel/eslint-parser": "7.28.5", "@esm-bundle/chai": "4.3.4-fix.0", From 21e582d8327a9e8a4e339c19ce319c2a57852efc Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 07:50:34 -0700 Subject: [PATCH 10/23] build: use .mjs scripts with watch and size-check; lint: include .mjs and node env for scripts; size: add core+total gzip caps --- .eslintrc.js | 11 ++++++- package.json | 14 ++++---- scripts/build.mjs | 73 ++++++++++++++++++++++++++++++++++++++++++ scripts/check-size.mjs | 51 +++++++++++++++++------------ 4 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 scripts/build.mjs diff --git a/.eslintrc.js b/.eslintrc.js index 34c6b71..756d66a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,9 +10,18 @@ module.exports = { sourceType: 'module', requireConfigFile: false, }, + settings: { + 'import/extensions': ['.js', '.mjs'], + }, rules: { - 'import/extensions': ['error', { js: 'always' }], // require js file extensions in imports + 'import/extensions': ['error', { js: 'always', mjs: 'always' }], // require extensions in imports 'linebreak-style': ['error', 'unix'], // enforce unix linebreaks 'no-param-reassign': [2, { props: false }], // allow modifying properties of param }, + overrides: [ + { + files: ['scripts/**/*.mjs', 'scripts/**/*.js'], + env: { node: true }, + }, + ], }; diff --git a/package.json b/package.json index 239951f..de0ae97 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,13 @@ "test:watch": "web-test-runner --node-resolve --coverage --watch --group unit", "test:perf": "web-test-runner --node-resolve --group perf", "test:perf:watch": "web-test-runner --node-resolve --watch --group perf", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "build": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js && node scripts/check-size.mjs", - "build:strict": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js && node scripts/check-size.mjs --strict", - "build:watch": "esbuild src/index.js --bundle --format=esm --platform=browser --outfile=dist/faintly.js --watch", - "check:size": "node scripts/check-size.mjs", - "check:size:strict": "node scripts/check-size.mjs --strict", + "lint": "eslint . --ext .js,.mjs", + "lint:fix": "eslint . --ext .js,.mjs --fix", + "build": "node scripts/build.mjs", + "build:strict": "node scripts/build.mjs --strict", + "build:watch": "node scripts/build.mjs --watch", + "check:size": "node scripts/build.mjs --check-size", + "check:size:strict": "node scripts/build.mjs --check-size --strict", "clean": "rm -rf dist" }, "author": "Sean Steimer", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..462c5ba --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,73 @@ +/* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ +import { build, context } from 'esbuild'; +import runSizeCheck from './check-size.mjs'; + +const args = process.argv.slice(2); +const isStrict = args.includes('--strict'); +const isWatch = args.includes('--watch'); +const isCheckSizeOnly = args.includes('--check-size'); + +function makeCoreOptions(plugins = []) { + return { + entryPoints: ['src/index.js'], + outfile: 'dist/faintly.js', + bundle: true, + format: 'esm', + platform: 'browser', + // Ensure the security helper is NOT bundled into the core output + external: ['./faintly.security.js'], + plugins, + }; +} + +function makeSecurityOptions() { + return { + entryPoints: ['src/faintly.security.js'], + outfile: 'dist/faintly.security.js', + bundle: true, + format: 'esm', + platform: 'browser', + }; +} + +function runSizeCheckNow() { + const code = runSizeCheck({ strict: isStrict }); + if (code !== 0) throw new Error(`size check failed (${code})`); +} + +async function main() { + if (isCheckSizeOnly) { + runSizeCheckNow(); + return; + } + + if (isWatch) { + const sizePlugin = { + name: 'size-check', + setup(b) { + b.onEnd((res) => { + if (!res.errors || res.errors.length === 0) runSizeCheckNow(); + }); + }, + }; + + const coreCtx = await context(makeCoreOptions([sizePlugin])); + const secCtx = await context(makeSecurityOptions()); + await coreCtx.watch(); + await secCtx.watch(); + // Keep process alive in watch mode + // eslint-disable-next-line no-console + console.log('[build] Watching for changes...'); + return; + } + + await build(makeCoreOptions()); + await build(makeSecurityOptions()); + runSizeCheckNow(); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs index e150461..e67f7c8 100644 --- a/scripts/check-size.mjs +++ b/scripts/check-size.mjs @@ -4,38 +4,49 @@ import { gzipSync } from 'node:zlib'; import path from 'node:path'; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); -const DIST_FILE = path.join(ROOT, 'dist', 'faintly.js'); -const LIMIT = Number(process.env.FAINTLY_GZIP_LIMIT || 5120); +const CORE_FILE = path.join(ROOT, 'dist', 'faintly.js'); +const SECURITY_FILE = path.join(ROOT, 'dist', 'faintly.security.js'); +const CORE_LIMIT = Number(process.env.FAINTLY_CORE_GZIP_LIMIT || 4096); +const TOTAL_LIMIT = Number(process.env.FAINTLY_TOTAL_GZIP_LIMIT || 6144); const STRICT = process.argv.includes('--strict'); function formatBytes(bytes) { return `${bytes} bytes`; } -function main() { - if (!fs.existsSync(DIST_FILE)) { - console.error(`[size-check] dist file not found: ${DIST_FILE}`); +export default function runSizeCheck({ strict } = {}) { + if (!fs.existsSync(CORE_FILE)) { + console.error(`[size-check] core dist file not found: ${CORE_FILE}`); process.exit(1); } - const buf = fs.readFileSync(DIST_FILE); - const gz = gzipSync(buf); - const size = gz.length; + const coreBuf = fs.readFileSync(CORE_FILE); + const coreGz = gzipSync(coreBuf).length; - const ok = size <= LIMIT; - const msg = `[size-check] gzipped dist/faintly.js: ${formatBytes(size)} (limit ${formatBytes(LIMIT)})`; + const secGz = fs.existsSync(SECURITY_FILE) + ? gzipSync(fs.readFileSync(SECURITY_FILE)).length + : 0; - if (ok) { - console.log(msg); - process.exit(0); - } + const totalGz = coreGz + secGz; - if (STRICT) { - console.error(`${msg} — over limit.`); - process.exit(1); - } + const coreOk = coreGz <= CORE_LIMIT; + const totalOk = totalGz <= TOTAL_LIMIT; - console.warn(`${msg} — over limit (warning only).`); + const coreMsg = `[size-check] core gz (dist/faintly.js): ${formatBytes(coreGz)} (limit ${formatBytes(CORE_LIMIT)})`; + const totalMsg = `[size-check] total gz (core + security): ${formatBytes(totalGz)} (limit ${formatBytes(TOTAL_LIMIT)})`; + + if (coreOk) console.log(coreMsg); else console.error(`${coreMsg} — over limit.`); + if (totalOk) console.log(totalMsg); else console.error(`${totalMsg} — over limit.`); + + const ok = coreOk && totalOk; + if (ok) return 0; + if (strict) return 1; + console.warn('[size-check] Limits exceeded (warning only).'); + return 0; } -main(); +// Execute when run directly via node scripts/check-size.mjs +if (import.meta.url === new URL(`file://${process.argv[1]}`).href) { + const code = runSizeCheck({ strict: STRICT }); + process.exit(code); +} From ce336b2622f816e86c55186c5a29147d360a289b Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 07:52:56 -0700 Subject: [PATCH 11/23] feat(security): default config, include path policy via , dynamic import, docs & tests --- AGENTS.md | 22 ++- README.md | 43 ++++- dist/faintly.js | 36 +++- dist/faintly.security.js | 80 ++++++++ src/directives.js | 55 +++++- src/faintly.security.js | 103 ++++++++++ .../attributes/attributeSanitization.test.js | 182 ++++++++++++++++++ .../include/processIncludeSecurity.test.js | 112 +++++++++++ 8 files changed, 617 insertions(+), 16 deletions(-) create mode 100644 dist/faintly.security.js create mode 100644 src/faintly.security.js create mode 100644 test/directives/attributes/attributeSanitization.test.js create mode 100644 test/directives/include/processIncludeSecurity.test.js diff --git a/AGENTS.md b/AGENTS.md index f45fb97..865c6da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,10 @@ Authoritative guide for AI/code agents contributing to this repository. - **Lint (auto-fix)**: `npm run lint:fix` - **Unit tests + coverage**: `npm test` - **Performance tests**: `npm run test:perf` -- **Build bundle**: `npm run build` → outputs `dist/faintly.js` and prints gzipped size (warns if over limit) -- **Build (strict)**: `npm run build:strict` → fails if gzipped size exceeds 5120 bytes +- **Build**: `npm run build` → builds `dist/faintly.js` (core) and `dist/faintly.security.js` (security helper) and prints gzipped sizes; warns if caps are exceeded. +- **Build (watch)**: `npm run build:watch` → watches both bundles and runs size checks on rebuilds. +- **Build (strict)**: `npm run build:strict` → fails if gzipped size caps are exceeded. +- **Size check only**: `npm run check:size` (or `check:size:strict`) - **Clean**: `npm run clean` ### Tests and coverage @@ -43,9 +45,14 @@ Authoritative guide for AI/code agents contributing to this repository. - Keep modules small and readable; avoid deep nesting; avoid unnecessary try/catch. ### Build and artifacts -- Bundling uses `esbuild` to produce a single ESM file at `dist/faintly.js` for browser usage. -- CI enforces a gzipped bundle size limit of **5KB (5120 bytes)**. Keep additions small; avoid adding heavy dependencies. -- If you change source under `src/`, run `npm run build` so `dist/faintly.js` is up to date. +- Bundling uses `esbuild` to produce ESM bundles: + - Core: `dist/faintly.js` (browser usage) + - Security helper: `dist/faintly.security.js` (optional, dynamically imported by consumers) +- Size caps (gzip): + - Core cap: 4KB (4096 bytes) + - Total cap: 6KB (6144 bytes) for `core + security` +- Strict mode and CI enforce these caps. Keep additions small; avoid heavy deps. +- If you change source under `src/`, run `npm run build` so `dist/` is up to date. ### CI behavior (GitHub Actions) - Workflow: `.github/workflows/main.yaml` runs on pull requests (open/sync/reopen). @@ -53,8 +60,8 @@ Authoritative guide for AI/code agents contributing to this repository. - The workflow will attempt to commit updated `dist/` artifacts back to the PR branch if they changed. ### Repo layout -- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`). -- `dist/`: built artifact (`faintly.js`). +- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`, `faintly.security.js`). +- `dist/`: built artifacts (`faintly.js`, `faintly.security.js`). - `test/`: unit/perf tests, fixtures, snapshots, and utilities. - `coverage/`: coverage output when tests are run with coverage. @@ -78,6 +85,7 @@ Authoritative guide for AI/code agents contributing to this repository. - Keep the bundle tiny; avoid adding runtime deps. - Maintain 100% test coverage; do not reduce thresholds or exclude more files. - Respect ESM and `.js` extension import rule. +- Build scripts are `.mjs` and are linted; Node APIs are allowed only under `scripts/**`. - Do not introduce Node-only APIs into browser code paths. diff --git a/README.md b/README.md index 2ee25fc..e7dfb37 100644 --- a/README.md +++ b/README.md @@ -101,4 +101,45 @@ For `data-fly-include`, HTML text, and normal attributes, wrap your expression i Escaping: use a leading backslash to prevent evaluation of an expression in text/attributes, e.g. `\${some.value}` will remain literal `${some.value}`. -In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. \ No newline at end of file +In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. + +## Security + +Faintly avoids injecting raw HTML from expressions: text nodes are set via `textContent` and `data-fly-content` string values are converted to text nodes. + +Security modes: +- Default (safe): core applies the default policy (same as `createSecurity()`), no configuration needed. +- Unsafe: explicitly disable checks with `security: 'unsafe'` or `security: false`. +- Custom: provide your own hooks or pass options to `createSecurity(...)` and attach as `security`. + +Defaults (safe mode or when using the helper): +- Block attribute names matching `/^on/i` (event handlers like `onclick`, `onload`) and the `srcdoc` attribute. +- For URL-bearing attributes (`href`, `src`, `action`, `formaction`, `xlink:href`), only allow schemes: `http:`, `https:`, `mailto:`, `tel:`, or no scheme (relative URLs/fragments). Others (e.g., `javascript:`) are removed. +- Include restrictions: same-origin and paths under `${codeBasePath}/blocks/` (or inferred from the current template path if `codeBasePath` is unset). + +Custom usage (optional): +```js +import { renderBlock } from './dist/faintly.js'; +import createSecurity from './dist/faintly.security.js'; + +await renderBlock(block, { + // your data... + codeBasePath: '/blocks-base', + security: createSecurity({ + // Optional overrides + blockedAttributePattern: /^data-/i, + blockedAttributes: new Set(['style']), + allowedUrlSchemes: new Set(['http:', 'https:', 'mailto:', 'tel:', '', 'data:']), + includeBasePath: '/blocks-base/blocks/', + }), +}); +``` + +Unsafe usage (not recommended): +```js +await renderBlock(block, { security: 'unsafe' }); +``` + +Notes: +- Sanitization only affects attributes; the library does not inject HTML strings. +- If an attribute is disallowed, it is removed rather than set. diff --git a/dist/faintly.js b/dist/faintly.js index 29f168d..4fe4afa 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -62,18 +62,30 @@ async function processTextExpressions(node, context) { } // src/directives.js +async function getSecurity(context) { + const cfg = context && context.security; + if (cfg === false || cfg === "unsafe") return null; + if (cfg && typeof cfg.allowIncludePath === "function" && typeof cfg.shouldAllowAttribute === "function") { + return cfg; + } + const createSecurity = (await import("./faintly.security.js")).default; + if (cfg && typeof cfg === "object") return createSecurity(cfg); + return createSecurity(); +} async function processAttributesDirective(el, context) { if (!el.hasAttribute("data-fly-attributes")) return; const attrsExpression = el.getAttribute("data-fly-attributes"); const attrsData = await resolveExpression(attrsExpression, context); el.removeAttribute("data-fly-attributes"); if (attrsData) { + const sec = await getSecurity(context); Object.entries(attrsData).forEach(([k, v]) => { + const name = String(k); if (v === void 0) { - el.removeAttribute(k); - } else { - el.setAttribute(k, v); - } + el.removeAttribute(name); + } else if (!sec || sec.shouldAllowAttribute(name, v, context)) { + el.setAttribute(name, v); + } else el.removeAttribute(name); }); } } @@ -81,7 +93,12 @@ async function processAttributes(el, context) { await processAttributesDirective(el, context); const attrPromises = el.getAttributeNames().filter((attrName) => !attrName.startsWith("data-fly-")).map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); - if (updated) el.setAttribute(attrName, updatedText); + if (updated) { + const sec = await getSecurity(context); + if (!sec || sec.shouldAllowAttribute(attrName, updatedText, context)) { + el.setAttribute(attrName, updatedText); + } else el.removeAttribute(attrName); + } }); await Promise.all(attrPromises); } @@ -161,6 +178,15 @@ async function processInclude(el, context) { templatePath = path; templateName = name; } + if (templatePath) { + const sec = await getSecurity(context); + const allowed = !sec || sec.allowIncludePath(templatePath, context); + if (!allowed) { + console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); + el.removeAttribute("data-fly-include"); + return true; + } + } const includeContext = { ...context, template: { diff --git a/dist/faintly.security.js b/dist/faintly.security.js new file mode 100644 index 0000000..b18ece8 --- /dev/null +++ b/dist/faintly.security.js @@ -0,0 +1,80 @@ +// src/faintly.security.js +var DEFAULT_SECURITY = { + blockedAttributePattern: /^on/i, + blockedAttributes: /* @__PURE__ */ new Set(["srcdoc"]), + allowedUrlSchemes: /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:", ""]), + includeBasePath: void 0 +}; +function createSecurity(options = {}) { + const blockedAttributePattern = options.blockedAttributePattern || DEFAULT_SECURITY.blockedAttributePattern; + const blockedAttributes = new Set(DEFAULT_SECURITY.blockedAttributes); + if (options.blockedAttributes) { + Array.from(options.blockedAttributes).forEach((a) => blockedAttributes.add(String(a).toLowerCase())); + } + const allowedUrlSchemes = new Set( + options.allowedUrlSchemes ? Array.from(options.allowedUrlSchemes) : Array.from(DEFAULT_SECURITY.allowedUrlSchemes) + ); + const { includeBasePath } = options; + function isBlockedAttributeName(attrName) { + const name = String(attrName || "").toLowerCase(); + return blockedAttributePattern.test(name) || blockedAttributes.has(name); + } + function extractUrlScheme(value) { + const v = String(value || "").trim(); + if (!v) return ""; + if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./") || v.startsWith("../") || v.startsWith("?")) return ""; + const idx = v.indexOf(":"); + if (idx > -1 && idx < v.indexOf("/") || idx > -1 && v.indexOf("/") === -1) { + return `${v.slice(0, idx + 1).toLowerCase()}`; + } + return ""; + } + function isUrlAttribute(attrName) { + const urlAttrs = /* @__PURE__ */ new Set([ + "href", + "src", + "action", + "formaction", + "xlink:href" + ]); + return urlAttrs.has(String(attrName || "").toLowerCase()); + } + function shouldAllowAttribute(attrName, value) { + if (isBlockedAttributeName(attrName)) return false; + if (isUrlAttribute(attrName)) { + const scheme = extractUrlScheme(value); + return allowedUrlSchemes.has(scheme); + } + return true; + } + function allowIncludePath(templatePath, context) { + if (!templatePath) return true; + let basePrefix = includeBasePath; + if (!basePrefix) { + if (context.codeBasePath) { + basePrefix = `${context.codeBasePath}/blocks/`; + } else { + const current = context.template && context.template.path || ""; + const marker = "/blocks/"; + const idx = current.indexOf(marker); + basePrefix = idx >= 0 ? current.slice(0, idx + marker.length) : "/"; + } + } + const includeUrl = new URL(templatePath, window.location.origin); + const sameOrigin = includeUrl.origin === window.location.origin; + const withinBase = includeUrl.pathname.startsWith(basePrefix || "/"); + return sameOrigin && withinBase; + } + return { + blockedAttributePattern, + blockedAttributes, + allowedUrlSchemes, + includeBasePath, + shouldAllowAttribute, + allowIncludePath + }; +} +export { + DEFAULT_SECURITY, + createSecurity as default +}; diff --git a/src/directives.js b/src/directives.js index 1c91129..6efd2fe 100644 --- a/src/directives.js +++ b/src/directives.js @@ -2,6 +2,32 @@ import { resolveExpression, resolveExpressions } from './expressions.js'; // eslint-disable-next-line import/no-cycle import { processNode, renderElement } from './render.js'; +async function getSecurity(context) { + const { security } = context; + + // unsafe mode + if (security === false || security === 'unsafe') { + return { + shouldAllowAttribute: (() => true), + allowIncludePath: (() => true), + }; + } + + // default mode + if (!security) { + const securityMod = await import('./faintly.security.js'); + if (securityMod && securityMod.default) { + return securityMod.default(); + } + } + + // custom mode, ensure neededfunctions are present, use no-ops for missing ones + return { + shouldAllowAttribute: security.shouldAllowAttribute || (() => true), + allowIncludePath: security.allowIncludePath || (() => true), + }; +} + async function processAttributesDirective(el, context) { if (!el.hasAttribute('data-fly-attributes')) return; @@ -10,11 +36,15 @@ async function processAttributesDirective(el, context) { el.removeAttribute('data-fly-attributes'); if (attrsData) { + const sec = await getSecurity(context); Object.entries(attrsData).forEach(([k, v]) => { + const name = String(k); if (v === undefined) { - el.removeAttribute(k); + el.removeAttribute(name); + } else if (sec.shouldAllowAttribute(name, v, context)) { + el.setAttribute(name, v); } else { - el.setAttribute(k, v); + el.removeAttribute(name); } }); } @@ -33,7 +63,14 @@ export async function processAttributes(el, context) { .filter((attrName) => !attrName.startsWith('data-fly-')) .map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); - if (updated) el.setAttribute(attrName, updatedText); + if (updated) { + const sec = await getSecurity(context); + if (!sec.shouldAllowAttribute(attrName, updatedText, context)) { + el.removeAttribute(attrName); + } else { + el.setAttribute(attrName, updatedText); + } + } }); await Promise.all(attrPromises); } @@ -170,6 +207,18 @@ export async function processInclude(el, context) { templateName = name; } + // Enforce include path restrictions: same-origin and within allowed base path + if (templatePath) { + const sec = await getSecurity(context); + const allowed = sec.allowIncludePath(templatePath, context); + if (!allowed) { + // eslint-disable-next-line no-console + console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); + el.removeAttribute('data-fly-include'); + return true; + } + } + const includeContext = { ...context, template: { diff --git a/src/faintly.security.js b/src/faintly.security.js new file mode 100644 index 0000000..bdc492d --- /dev/null +++ b/src/faintly.security.js @@ -0,0 +1,103 @@ +/* eslint-disable no-template-curly-in-string */ +/** + * Default security configuration. + * Adjust these defaults by passing overrides to `createSecurity(options)`. + */ +export const DEFAULT_SECURITY = { + blockedAttributePatterns: ['^on/i'], + blockedAttributes: ['srcdoc'], + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', ''], + includeBasePath: '${codeBasePath}', +}; + +/** + * Create a security policy for Faintly. Attach the returned hooks to `context.security`. + * + * @param {Object} [options] + * @param {RegExp} [options.blockedAttributePattern] regex for blocked attribute names + * @param {Set} [options.blockedAttributes] extra blocked attribute names + * @param {Set} [options.allowedUrlSchemes] allowed URL schemes for URL attrs + * @param {string} [options.includeBasePath] base path prefix for allowed includes + */ +export default function createSecurity(options = DEFAULT_SECURITY) { + const { + blockedAttributePattern, blockedAttributes, allowedUrlSchemes, includeBasePath, + } = options; + + function resolveIncludeBase(base, context) { + if (!base) return ''; + if (typeof base === 'function') return String(base(context) || ''); + let tpl = String(base); + tpl = tpl.replaceAll('${codeBasePath}', String(context.codeBasePath || '')); + return tpl; + } + + function isBlockedAttributeName(attrName) { + const name = String(attrName || '').toLowerCase(); + return blockedAttributePattern.test(name) || blockedAttributes.has(name); + } + + function extractUrlScheme(value) { + const v = String(value || '').trim(); + if (!v) return ''; + if ( + v.startsWith('#') || v.startsWith('/') + || v.startsWith('./') || v.startsWith('../') || v.startsWith('?') + ) return ''; + const idx = v.indexOf(':'); + if ((idx > -1 && idx < v.indexOf('/')) || (idx > -1 && (v.indexOf('/') === -1))) { + return `${v.slice(0, idx + 1).toLowerCase()}`; + } + return ''; + } + + function isUrlAttribute(attrName) { + const urlAttrs = new Set([ + 'href', 'src', 'action', 'formaction', 'xlink:href', + ]); + return urlAttrs.has(String(attrName || '').toLowerCase()); + } + + function shouldAllowAttribute(attrName, value) { + if (isBlockedAttributeName(attrName)) return false; + if (isUrlAttribute(attrName)) { + const scheme = extractUrlScheme(value); + return allowedUrlSchemes.has(scheme); + } + return true; + } + + function allowIncludePath(templatePath, context) { + if (!templatePath) return true; + + let basePrefix = resolveIncludeBase( + includeBasePath || DEFAULT_SECURITY.includeBasePath, + context, + ); + if (!basePrefix) { + if (context.codeBasePath) { + basePrefix = String(context.codeBasePath); + } else { + const current = (context.template && context.template.path) || ''; + const lastSlash = current.lastIndexOf('/'); + basePrefix = lastSlash >= 0 ? current.slice(0, lastSlash + 1) : '/'; + } + } + + if (!basePrefix.endsWith('/')) basePrefix = `${basePrefix}/`; + + const includeUrl = new URL(templatePath, window.location.origin); + const sameOrigin = includeUrl.origin === window.location.origin; + const withinBase = includeUrl.pathname.startsWith(basePrefix || '/'); + return sameOrigin && withinBase; + } + + return { + blockedAttributePattern, + blockedAttributes, + allowedUrlSchemes, + includeBasePath, + shouldAllowAttribute, + allowIncludePath, + }; +} diff --git a/test/directives/attributes/attributeSanitization.test.js b/test/directives/attributes/attributeSanitization.test.js new file mode 100644 index 0000000..6e0b5be --- /dev/null +++ b/test/directives/attributes/attributeSanitization.test.js @@ -0,0 +1,182 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-script-url, no-template-curly-in-string */ + +import { expect } from '@esm-bundle/chai'; +import { processAttributes } from '../../../src/directives.js'; +import createSecurity from '../../../src/faintly.security.js'; + +describe('attribute sanitization', () => { + it('defaults to safe policy when no security is provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { onclick: 'alert(1)' }, + }); + expect(el.hasAttribute('onclick')).to.equal(false); + }); + + it('allows unsafe mode when explicitly disabled', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { onclick: 'alert(1)' }, + security: 'unsafe', + }); + expect(el.getAttribute('onclick')).to.equal('alert(1)'); + }); + it('blocks event handler attributes from data-fly-attributes', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { onclick: 'alert(1)', onLoad: 'doSomething()' }, + security: createSecurity(), + }); + + expect(el.hasAttribute('onclick')).to.equal(false); + expect(el.hasAttribute('onload')).to.equal(false); + }); + + it('blocks srcdoc attribute', async () => { + const el = document.createElement('iframe'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { srcdoc: '' }, + security: createSecurity(), + }); + + expect(el.hasAttribute('srcdoc')).to.equal(false); + }); + + it('blocks javascript: in URL attributes (resolved expressions)', async () => { + const a = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'javascript:alert(1)', security: createSecurity() }); + expect(a.hasAttribute('href')).to.equal(false); + }); + + it('allows http/https/mailto/tel and relative URLs', async () => { + const a = document.createElement('a'); + a.setAttribute('href', 'about:blank'); // will be sanitized out later + // eslint-disable-next-line no-template-curly-in-string + a.setAttribute('href', '${ link }'); + + await processAttributes(a, { link: 'https://example.com', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('https://example.com'); + + // http + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'http://example.com', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('http://example.com'); + + // mailto + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'mailto:test@example.com', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('mailto:test@example.com'); + + // tel + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'tel:+123456789', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('tel:+123456789'); + + // relative + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: '/path', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('/path'); + + // fragment + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: '#hash', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('#hash'); + + // query-only + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: '?q=1', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('?q=1'); + + // dot-relative + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: './file', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('./file'); + + // parent-relative + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: '../up', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('../up'); + }); + + it('applies URL checks to data-fly-attributes', async () => { + const img = document.createElement('img'); + img.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(img, { attrs: { src: 'javascript:alert(1)' }, security: createSecurity() }); + expect(img.hasAttribute('src')).to.equal(false); + + img.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(img, { attrs: { src: 'https://example.com/x.png' }, security: createSecurity() }); + expect(img.getAttribute('src')).to.equal('https://example.com/x.png'); + }); + + it('respects context.security.allowedUrlSchemes overrides', async () => { + const img = document.createElement('img'); + img.setAttribute('data-fly-attributes', 'attrs'); + const dataGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + await processAttributes(img, { + attrs: { src: dataGif }, + security: createSecurity({ allowedUrlSchemes: new Set(['http:', 'https:', 'mailto:', 'tel:', '', 'data:']) }), + }); + expect(img.getAttribute('src')).to.equal(dataGif); + }); + + it('removes disallowed about: URL', async () => { + const a = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'about:blank', security: createSecurity() }); + expect(a.hasAttribute('href')).to.equal(false); + }); + + it('blocks xlink:href on SVG when using javascript:', async () => { + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + // eslint-disable-next-line no-template-curly-in-string + use.setAttribute('xlink:href', '${ link }'); + await processAttributes(use, { link: 'javascript:alert(1)', security: createSecurity() }); + expect(use.hasAttribute('xlink:href')).to.equal(false); + }); + + it('allows blocking additional attributes via context.security.blockedAttributes', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { style: 'color:red' }, + security: createSecurity({ blockedAttributes: new Set(['style']) }), + }); + expect(el.hasAttribute('style')).to.equal(false); + }); + + it('treats colon after slash as relative (no scheme)', async () => { + const a = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: '/a:b', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('/a:b'); + }); + + it('falls back to no-scheme for non-scheme strings containing colon', async () => { + const a = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + a.setAttribute('href', '${ link }'); + await processAttributes(a, { link: 'foo/bar:baz', security: createSecurity() }); + expect(a.getAttribute('href')).to.equal('foo/bar:baz'); + }); + + it('respects blockedAttributePattern override', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + await processAttributes(el, { + attrs: { 'data-test': 'x', title: 'ok' }, + security: createSecurity({ blockedAttributePattern: /^data-/i }), + }); + expect(el.hasAttribute('data-test')).to.equal(false); + expect(el.getAttribute('title')).to.equal('ok'); + }); +}); diff --git a/test/directives/include/processIncludeSecurity.test.js b/test/directives/include/processIncludeSecurity.test.js new file mode 100644 index 0000000..0c3a602 --- /dev/null +++ b/test/directives/include/processIncludeSecurity.test.js @@ -0,0 +1,112 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions */ +import { expect } from '@esm-bundle/chai'; +import { processInclude } from '../../../src/directives.js'; +import createSecurity from '../../../src/faintly.security.js'; + +describe('processInclude security restrictions', () => { + it('allows include within default base (codeBasePath/blocks)', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity(), + }); + + expect(result).to.equal(true); + // include should have removed the attribute and replaced children + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it('blocks include outside of allowed base path', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/not-allowed/blocks/elsewhere.html'); + + const result = await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity(), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + // Element should remain unchanged (no children added) + expect(el.childNodes.length).to.equal(0); + }); + + it('honors context.security.includeBasePath override', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity({ includeBasePath: '/test/fixtures/blocks/static-block/' }), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + it('allows include when base is inferred from current template path', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + // no codeBasePath provided + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity(), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + it('allows include under default includeBasePath = codeBasePath', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity(), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + // eslint-disable-next-line no-template-curly-in-string + it('supports includeBasePath template substitution (${codeBasePath})', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + // eslint-disable-next-line no-template-curly-in-string + security: createSecurity({ includeBasePath: '${codeBasePath}/blocks/' }), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + it('supports includeBasePath as a function and normalizes trailing slash', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const result = await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: createSecurity({ includeBasePath: (ctx) => `${ctx.codeBasePath}/blocks` }), + }); + + expect(result).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + // Removed tests for ${templatePath} and ${templateDir}; only ${codeBasePath} is supported now +}); From 5fee57abb67f146543568e6d18ad0410c7f3757c Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 08:31:19 -0700 Subject: [PATCH 12/23] Refactor size check logic in build scripts - Simplified size check execution in `build.mjs` to directly exit with the appropriate code. - Updated `check-size.mjs` to use a consistent return code structure and improved logging for size limits. - Removed redundant function calls and streamlined the size check process for better clarity and maintainability. --- scripts/build.mjs | 18 +++++++++--------- scripts/check-size.mjs | 29 ++++++++++++----------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 4e149ec..c733ef7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -32,15 +32,10 @@ function makeSecurityOptions() { }; } -function runSizeCheckNow() { - const code = runSizeCheck({ strict: isStrict }); - if (code !== 0) throw new Error(`size check failed (${code})`); -} - async function main() { if (isCheckSizeOnly) { - runSizeCheckNow(); - return; + const code = runSizeCheck(isStrict); + process.exit(code); } const securitySrcPath = path.join(process.cwd(), 'src', 'faintly.security.js'); @@ -51,7 +46,10 @@ async function main() { name: 'size-check', setup(b) { b.onEnd((res) => { - if (!res.errors || res.errors.length === 0) runSizeCheckNow(); + if (!res.errors || res.errors.length === 0) { + // Always non-strict in watch mode - warn but don't exit + runSizeCheck(false); + } }); }, }; @@ -68,7 +66,9 @@ async function main() { await build(makeCoreOptions()); if (hasSecurity) await build(makeSecurityOptions()); - runSizeCheckNow(); + + const code = runSizeCheck(isStrict); + process.exit(code); } main().catch((err) => { diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs index e67f7c8..2ec8520 100644 --- a/scripts/check-size.mjs +++ b/scripts/check-size.mjs @@ -8,16 +8,16 @@ const CORE_FILE = path.join(ROOT, 'dist', 'faintly.js'); const SECURITY_FILE = path.join(ROOT, 'dist', 'faintly.security.js'); const CORE_LIMIT = Number(process.env.FAINTLY_CORE_GZIP_LIMIT || 4096); const TOTAL_LIMIT = Number(process.env.FAINTLY_TOTAL_GZIP_LIMIT || 6144); -const STRICT = process.argv.includes('--strict'); -function formatBytes(bytes) { - return `${bytes} bytes`; -} +const RETURN_CODES = { + OK: 0, + FAILED: 1, +}; -export default function runSizeCheck({ strict } = {}) { +export default function runSizeCheck(strict = false) { if (!fs.existsSync(CORE_FILE)) { console.error(`[size-check] core dist file not found: ${CORE_FILE}`); - process.exit(1); + return RETURN_CODES.FAILED; } const coreBuf = fs.readFileSync(CORE_FILE); @@ -32,21 +32,16 @@ export default function runSizeCheck({ strict } = {}) { const coreOk = coreGz <= CORE_LIMIT; const totalOk = totalGz <= TOTAL_LIMIT; - const coreMsg = `[size-check] core gz (dist/faintly.js): ${formatBytes(coreGz)} (limit ${formatBytes(CORE_LIMIT)})`; - const totalMsg = `[size-check] total gz (core + security): ${formatBytes(totalGz)} (limit ${formatBytes(TOTAL_LIMIT)})`; + const coreMsg = `[size-check] core gz (dist/faintly.js): ${coreGz} bytes (limit ${CORE_LIMIT} bytes)`; + const totalMsg = `[size-check] total gz (core + security): ${totalGz} bytes (limit ${TOTAL_LIMIT} bytes)`; if (coreOk) console.log(coreMsg); else console.error(`${coreMsg} — over limit.`); if (totalOk) console.log(totalMsg); else console.error(`${totalMsg} — over limit.`); const ok = coreOk && totalOk; - if (ok) return 0; - if (strict) return 1; + if (ok) return RETURN_CODES.OK; + if (strict) return RETURN_CODES.FAILED; + console.warn('[size-check] Limits exceeded (warning only).'); - return 0; -} - -// Execute when run directly via node scripts/check-size.mjs -if (import.meta.url === new URL(`file://${process.argv[1]}`).href) { - const code = runSizeCheck({ strict: STRICT }); - process.exit(code); + return RETURN_CODES.OK; } From 8c298b3022fd60b7c976fb2f5b96fe8825294084 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 09:27:43 -0700 Subject: [PATCH 13/23] Phase 1: Clean slate with directives.js security integration - Keep all directives.js changes with getSecurity() integration - Revert all other files to main branch baseline - Create minimal security stub (allows everything by default) - Add comprehensive tests for security integration in directives - Test custom security hooks - Test unsafe mode (security: false and 'unsafe') - Add placeholder skipped tests for default security validation - Delete complex security test files for incremental rebuild - All tests pass: 72 passed, 2 skipped - 100% code coverage maintained - Bundle size: 2680 bytes (under 4096 limit) --- .eslintrc.js | 3 - AGENTS.md | 23 +-- README.md | 41 ---- dist/faintly.js | 37 ++-- dist/faintly.security.js | 80 +------- scripts/build.mjs | 18 +- scripts/check-size.mjs | 14 +- src/directives.js | 2 +- src/faintly.security.js | 108 ++--------- .../attributes/attributeSanitization.test.js | 182 ------------------ .../attributes/processAttributes.test.js | 75 ++++++++ .../directives/include/processInclude.test.js | 70 +++++++ .../include/processIncludeSecurity.test.js | 112 ----------- 13 files changed, 210 insertions(+), 555 deletions(-) delete mode 100644 test/directives/attributes/attributeSanitization.test.js delete mode 100644 test/directives/include/processIncludeSecurity.test.js diff --git a/.eslintrc.js b/.eslintrc.js index 74e84f0..1e20485 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,9 +10,6 @@ module.exports = { sourceType: 'module', requireConfigFile: false, }, - settings: { - 'import/extensions': ['.js', '.mjs'], - }, rules: { // Globally require .js file extensions in imports 'import/extensions': ['error', { js: 'always' }], diff --git a/AGENTS.md b/AGENTS.md index 865c6da..b29a758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,8 @@ Authoritative guide for AI/code agents contributing to this repository. - **Lint (auto-fix)**: `npm run lint:fix` - **Unit tests + coverage**: `npm test` - **Performance tests**: `npm run test:perf` -- **Build**: `npm run build` → builds `dist/faintly.js` (core) and `dist/faintly.security.js` (security helper) and prints gzipped sizes; warns if caps are exceeded. -- **Build (watch)**: `npm run build:watch` → watches both bundles and runs size checks on rebuilds. -- **Build (strict)**: `npm run build:strict` → fails if gzipped size caps are exceeded. -- **Size check only**: `npm run check:size` (or `check:size:strict`) +- **Build bundle**: `npm run build` → outputs `dist/faintly.js` and prints gzipped size (warns if over limit) +- **Build (strict)**: `npm run build:strict` → fails if gzipped size exceeds 5120 bytes - **Clean**: `npm run clean` ### Tests and coverage @@ -45,14 +43,9 @@ Authoritative guide for AI/code agents contributing to this repository. - Keep modules small and readable; avoid deep nesting; avoid unnecessary try/catch. ### Build and artifacts -- Bundling uses `esbuild` to produce ESM bundles: - - Core: `dist/faintly.js` (browser usage) - - Security helper: `dist/faintly.security.js` (optional, dynamically imported by consumers) -- Size caps (gzip): - - Core cap: 4KB (4096 bytes) - - Total cap: 6KB (6144 bytes) for `core + security` -- Strict mode and CI enforce these caps. Keep additions small; avoid heavy deps. -- If you change source under `src/`, run `npm run build` so `dist/` is up to date. +- Bundling uses `esbuild` to produce a single ESM file at `dist/faintly.js` for browser usage. +- CI enforces a gzipped bundle size limit of **5KB (5120 bytes)**. Keep additions small; avoid adding heavy dependencies. +- If you change source under `src/`, run `npm run build` so `dist/faintly.js` is up to date. ### CI behavior (GitHub Actions) - Workflow: `.github/workflows/main.yaml` runs on pull requests (open/sync/reopen). @@ -60,8 +53,8 @@ Authoritative guide for AI/code agents contributing to this repository. - The workflow will attempt to commit updated `dist/` artifacts back to the PR branch if they changed. ### Repo layout -- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`, `faintly.security.js`). -- `dist/`: built artifacts (`faintly.js`, `faintly.security.js`). +- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`). +- `dist/`: built artifact (`faintly.js`). - `test/`: unit/perf tests, fixtures, snapshots, and utilities. - `coverage/`: coverage output when tests are run with coverage. @@ -85,7 +78,7 @@ Authoritative guide for AI/code agents contributing to this repository. - Keep the bundle tiny; avoid adding runtime deps. - Maintain 100% test coverage; do not reduce thresholds or exclude more files. - Respect ESM and `.js` extension import rule. -- Build scripts are `.mjs` and are linted; Node APIs are allowed only under `scripts/**`. - Do not introduce Node-only APIs into browser code paths. + diff --git a/README.md b/README.md index e7dfb37..f91895b 100644 --- a/README.md +++ b/README.md @@ -102,44 +102,3 @@ For `data-fly-include`, HTML text, and normal attributes, wrap your expression i Escaping: use a leading backslash to prevent evaluation of an expression in text/attributes, e.g. `\${some.value}` will remain literal `${some.value}`. In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. - -## Security - -Faintly avoids injecting raw HTML from expressions: text nodes are set via `textContent` and `data-fly-content` string values are converted to text nodes. - -Security modes: -- Default (safe): core applies the default policy (same as `createSecurity()`), no configuration needed. -- Unsafe: explicitly disable checks with `security: 'unsafe'` or `security: false`. -- Custom: provide your own hooks or pass options to `createSecurity(...)` and attach as `security`. - -Defaults (safe mode or when using the helper): -- Block attribute names matching `/^on/i` (event handlers like `onclick`, `onload`) and the `srcdoc` attribute. -- For URL-bearing attributes (`href`, `src`, `action`, `formaction`, `xlink:href`), only allow schemes: `http:`, `https:`, `mailto:`, `tel:`, or no scheme (relative URLs/fragments). Others (e.g., `javascript:`) are removed. -- Include restrictions: same-origin and paths under `${codeBasePath}/blocks/` (or inferred from the current template path if `codeBasePath` is unset). - -Custom usage (optional): -```js -import { renderBlock } from './dist/faintly.js'; -import createSecurity from './dist/faintly.security.js'; - -await renderBlock(block, { - // your data... - codeBasePath: '/blocks-base', - security: createSecurity({ - // Optional overrides - blockedAttributePattern: /^data-/i, - blockedAttributes: new Set(['style']), - allowedUrlSchemes: new Set(['http:', 'https:', 'mailto:', 'tel:', '', 'data:']), - includeBasePath: '/blocks-base/blocks/', - }), -}); -``` - -Unsafe usage (not recommended): -```js -await renderBlock(block, { security: 'unsafe' }); -``` - -Notes: -- Sanitization only affects attributes; the library does not inject HTML strings. -- If an attribute is disallowed, it is removed rather than set. diff --git a/dist/faintly.js b/dist/faintly.js index 4fe4afa..8444d33 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -63,14 +63,23 @@ async function processTextExpressions(node, context) { // src/directives.js async function getSecurity(context) { - const cfg = context && context.security; - if (cfg === false || cfg === "unsafe") return null; - if (cfg && typeof cfg.allowIncludePath === "function" && typeof cfg.shouldAllowAttribute === "function") { - return cfg; + const { security } = context; + if (security === false || security === "unsafe") { + return { + shouldAllowAttribute: (() => true), + allowIncludePath: (() => true) + }; } - const createSecurity = (await import("./faintly.security.js")).default; - if (cfg && typeof cfg === "object") return createSecurity(cfg); - return createSecurity(); + if (!security) { + const securityMod = await import("./faintly.security.js"); + if (securityMod && securityMod.default) { + return securityMod.default(); + } + } + return { + shouldAllowAttribute: security.shouldAllowAttribute || (() => true), + allowIncludePath: security.allowIncludePath || (() => true) + }; } async function processAttributesDirective(el, context) { if (!el.hasAttribute("data-fly-attributes")) return; @@ -83,9 +92,11 @@ async function processAttributesDirective(el, context) { const name = String(k); if (v === void 0) { el.removeAttribute(name); - } else if (!sec || sec.shouldAllowAttribute(name, v, context)) { + } else if (sec.shouldAllowAttribute(name, v, context)) { el.setAttribute(name, v); - } else el.removeAttribute(name); + } else { + el.removeAttribute(name); + } }); } } @@ -95,9 +106,11 @@ async function processAttributes(el, context) { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); if (updated) { const sec = await getSecurity(context); - if (!sec || sec.shouldAllowAttribute(attrName, updatedText, context)) { + if (!sec.shouldAllowAttribute(attrName, updatedText, context)) { + el.removeAttribute(attrName); + } else { el.setAttribute(attrName, updatedText); - } else el.removeAttribute(attrName); + } } }); await Promise.all(attrPromises); @@ -180,7 +193,7 @@ async function processInclude(el, context) { } if (templatePath) { const sec = await getSecurity(context); - const allowed = !sec || sec.allowIncludePath(templatePath, context); + const allowed = sec.allowIncludePath(templatePath, context); if (!allowed) { console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); el.removeAttribute("data-fly-include"); diff --git a/dist/faintly.security.js b/dist/faintly.security.js index b18ece8..be6bcbc 100644 --- a/dist/faintly.security.js +++ b/dist/faintly.security.js @@ -1,80 +1,14 @@ // src/faintly.security.js -var DEFAULT_SECURITY = { - blockedAttributePattern: /^on/i, - blockedAttributes: /* @__PURE__ */ new Set(["srcdoc"]), - allowedUrlSchemes: /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:", ""]), - includeBasePath: void 0 -}; -function createSecurity(options = {}) { - const blockedAttributePattern = options.blockedAttributePattern || DEFAULT_SECURITY.blockedAttributePattern; - const blockedAttributes = new Set(DEFAULT_SECURITY.blockedAttributes); - if (options.blockedAttributes) { - Array.from(options.blockedAttributes).forEach((a) => blockedAttributes.add(String(a).toLowerCase())); - } - const allowedUrlSchemes = new Set( - options.allowedUrlSchemes ? Array.from(options.allowedUrlSchemes) : Array.from(DEFAULT_SECURITY.allowedUrlSchemes) - ); - const { includeBasePath } = options; - function isBlockedAttributeName(attrName) { - const name = String(attrName || "").toLowerCase(); - return blockedAttributePattern.test(name) || blockedAttributes.has(name); - } - function extractUrlScheme(value) { - const v = String(value || "").trim(); - if (!v) return ""; - if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./") || v.startsWith("../") || v.startsWith("?")) return ""; - const idx = v.indexOf(":"); - if (idx > -1 && idx < v.indexOf("/") || idx > -1 && v.indexOf("/") === -1) { - return `${v.slice(0, idx + 1).toLowerCase()}`; - } - return ""; - } - function isUrlAttribute(attrName) { - const urlAttrs = /* @__PURE__ */ new Set([ - "href", - "src", - "action", - "formaction", - "xlink:href" - ]); - return urlAttrs.has(String(attrName || "").toLowerCase()); - } - function shouldAllowAttribute(attrName, value) { - if (isBlockedAttributeName(attrName)) return false; - if (isUrlAttribute(attrName)) { - const scheme = extractUrlScheme(value); - return allowedUrlSchemes.has(scheme); - } - return true; - } - function allowIncludePath(templatePath, context) { - if (!templatePath) return true; - let basePrefix = includeBasePath; - if (!basePrefix) { - if (context.codeBasePath) { - basePrefix = `${context.codeBasePath}/blocks/`; - } else { - const current = context.template && context.template.path || ""; - const marker = "/blocks/"; - const idx = current.indexOf(marker); - basePrefix = idx >= 0 ? current.slice(0, idx + marker.length) : "/"; - } - } - const includeUrl = new URL(templatePath, window.location.origin); - const sameOrigin = includeUrl.origin === window.location.origin; - const withinBase = includeUrl.pathname.startsWith(basePrefix || "/"); - return sameOrigin && withinBase; - } +function createSecurity() { return { - blockedAttributePattern, - blockedAttributes, - allowedUrlSchemes, - includeBasePath, - shouldAllowAttribute, - allowIncludePath + shouldAllowAttribute() { + return true; + }, + allowIncludePath() { + return true; + } }; } export { - DEFAULT_SECURITY, createSecurity as default }; diff --git a/scripts/build.mjs b/scripts/build.mjs index c733ef7..4e149ec 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -32,10 +32,15 @@ function makeSecurityOptions() { }; } +function runSizeCheckNow() { + const code = runSizeCheck({ strict: isStrict }); + if (code !== 0) throw new Error(`size check failed (${code})`); +} + async function main() { if (isCheckSizeOnly) { - const code = runSizeCheck(isStrict); - process.exit(code); + runSizeCheckNow(); + return; } const securitySrcPath = path.join(process.cwd(), 'src', 'faintly.security.js'); @@ -46,10 +51,7 @@ async function main() { name: 'size-check', setup(b) { b.onEnd((res) => { - if (!res.errors || res.errors.length === 0) { - // Always non-strict in watch mode - warn but don't exit - runSizeCheck(false); - } + if (!res.errors || res.errors.length === 0) runSizeCheckNow(); }); }, }; @@ -66,9 +68,7 @@ async function main() { await build(makeCoreOptions()); if (hasSecurity) await build(makeSecurityOptions()); - - const code = runSizeCheck(isStrict); - process.exit(code); + runSizeCheckNow(); } main().catch((err) => { diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs index 2ec8520..28b2b32 100644 --- a/scripts/check-size.mjs +++ b/scripts/check-size.mjs @@ -9,15 +9,10 @@ const SECURITY_FILE = path.join(ROOT, 'dist', 'faintly.security.js'); const CORE_LIMIT = Number(process.env.FAINTLY_CORE_GZIP_LIMIT || 4096); const TOTAL_LIMIT = Number(process.env.FAINTLY_TOTAL_GZIP_LIMIT || 6144); -const RETURN_CODES = { - OK: 0, - FAILED: 1, -}; - export default function runSizeCheck(strict = false) { if (!fs.existsSync(CORE_FILE)) { console.error(`[size-check] core dist file not found: ${CORE_FILE}`); - return RETURN_CODES.FAILED; + process.exit(1); } const coreBuf = fs.readFileSync(CORE_FILE); @@ -39,9 +34,8 @@ export default function runSizeCheck(strict = false) { if (totalOk) console.log(totalMsg); else console.error(`${totalMsg} — over limit.`); const ok = coreOk && totalOk; - if (ok) return RETURN_CODES.OK; - if (strict) return RETURN_CODES.FAILED; - + if (ok) return 0; + if (strict) return 1; console.warn('[size-check] Limits exceeded (warning only).'); - return RETURN_CODES.OK; + return 0; } diff --git a/src/directives.js b/src/directives.js index 6efd2fe..ddbe67d 100644 --- a/src/directives.js +++ b/src/directives.js @@ -21,7 +21,7 @@ async function getSecurity(context) { } } - // custom mode, ensure neededfunctions are present, use no-ops for missing ones + // custom mode, ensure needed functions are present, use no-ops for missing ones return { shouldAllowAttribute: security.shouldAllowAttribute || (() => true), allowIncludePath: security.allowIncludePath || (() => true), diff --git a/src/faintly.security.js b/src/faintly.security.js index bdc492d..3eb9a9d 100644 --- a/src/faintly.security.js +++ b/src/faintly.security.js @@ -1,103 +1,17 @@ -/* eslint-disable no-template-curly-in-string */ /** - * Default security configuration. - * Adjust these defaults by passing overrides to `createSecurity(options)`. - */ -export const DEFAULT_SECURITY = { - blockedAttributePatterns: ['^on/i'], - blockedAttributes: ['srcdoc'], - allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', ''], - includeBasePath: '${codeBasePath}', -}; - -/** - * Create a security policy for Faintly. Attach the returned hooks to `context.security`. + * Minimal security stub for faintly. + * This is a placeholder that allows all attributes and includes by default. + * Full implementation coming soon. * - * @param {Object} [options] - * @param {RegExp} [options.blockedAttributePattern] regex for blocked attribute names - * @param {Set} [options.blockedAttributes] extra blocked attribute names - * @param {Set} [options.allowedUrlSchemes] allowed URL schemes for URL attrs - * @param {string} [options.includeBasePath] base path prefix for allowed includes + * @returns {Object} Security hooks */ -export default function createSecurity(options = DEFAULT_SECURITY) { - const { - blockedAttributePattern, blockedAttributes, allowedUrlSchemes, includeBasePath, - } = options; - - function resolveIncludeBase(base, context) { - if (!base) return ''; - if (typeof base === 'function') return String(base(context) || ''); - let tpl = String(base); - tpl = tpl.replaceAll('${codeBasePath}', String(context.codeBasePath || '')); - return tpl; - } - - function isBlockedAttributeName(attrName) { - const name = String(attrName || '').toLowerCase(); - return blockedAttributePattern.test(name) || blockedAttributes.has(name); - } - - function extractUrlScheme(value) { - const v = String(value || '').trim(); - if (!v) return ''; - if ( - v.startsWith('#') || v.startsWith('/') - || v.startsWith('./') || v.startsWith('../') || v.startsWith('?') - ) return ''; - const idx = v.indexOf(':'); - if ((idx > -1 && idx < v.indexOf('/')) || (idx > -1 && (v.indexOf('/') === -1))) { - return `${v.slice(0, idx + 1).toLowerCase()}`; - } - return ''; - } - - function isUrlAttribute(attrName) { - const urlAttrs = new Set([ - 'href', 'src', 'action', 'formaction', 'xlink:href', - ]); - return urlAttrs.has(String(attrName || '').toLowerCase()); - } - - function shouldAllowAttribute(attrName, value) { - if (isBlockedAttributeName(attrName)) return false; - if (isUrlAttribute(attrName)) { - const scheme = extractUrlScheme(value); - return allowedUrlSchemes.has(scheme); - } - return true; - } - - function allowIncludePath(templatePath, context) { - if (!templatePath) return true; - - let basePrefix = resolveIncludeBase( - includeBasePath || DEFAULT_SECURITY.includeBasePath, - context, - ); - if (!basePrefix) { - if (context.codeBasePath) { - basePrefix = String(context.codeBasePath); - } else { - const current = (context.template && context.template.path) || ''; - const lastSlash = current.lastIndexOf('/'); - basePrefix = lastSlash >= 0 ? current.slice(0, lastSlash + 1) : '/'; - } - } - - if (!basePrefix.endsWith('/')) basePrefix = `${basePrefix}/`; - - const includeUrl = new URL(templatePath, window.location.origin); - const sameOrigin = includeUrl.origin === window.location.origin; - const withinBase = includeUrl.pathname.startsWith(basePrefix || '/'); - return sameOrigin && withinBase; - } - +export default function createSecurity() { return { - blockedAttributePattern, - blockedAttributes, - allowedUrlSchemes, - includeBasePath, - shouldAllowAttribute, - allowIncludePath, + shouldAllowAttribute() { + return true; + }, + allowIncludePath() { + return true; + }, }; } diff --git a/test/directives/attributes/attributeSanitization.test.js b/test/directives/attributes/attributeSanitization.test.js deleted file mode 100644 index 6e0b5be..0000000 --- a/test/directives/attributes/attributeSanitization.test.js +++ /dev/null @@ -1,182 +0,0 @@ -/* eslint-env mocha */ -/* eslint-disable no-unused-expressions, no-script-url, no-template-curly-in-string */ - -import { expect } from '@esm-bundle/chai'; -import { processAttributes } from '../../../src/directives.js'; -import createSecurity from '../../../src/faintly.security.js'; - -describe('attribute sanitization', () => { - it('defaults to safe policy when no security is provided', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { onclick: 'alert(1)' }, - }); - expect(el.hasAttribute('onclick')).to.equal(false); - }); - - it('allows unsafe mode when explicitly disabled', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { onclick: 'alert(1)' }, - security: 'unsafe', - }); - expect(el.getAttribute('onclick')).to.equal('alert(1)'); - }); - it('blocks event handler attributes from data-fly-attributes', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { onclick: 'alert(1)', onLoad: 'doSomething()' }, - security: createSecurity(), - }); - - expect(el.hasAttribute('onclick')).to.equal(false); - expect(el.hasAttribute('onload')).to.equal(false); - }); - - it('blocks srcdoc attribute', async () => { - const el = document.createElement('iframe'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { srcdoc: '' }, - security: createSecurity(), - }); - - expect(el.hasAttribute('srcdoc')).to.equal(false); - }); - - it('blocks javascript: in URL attributes (resolved expressions)', async () => { - const a = document.createElement('a'); - // eslint-disable-next-line no-template-curly-in-string - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'javascript:alert(1)', security: createSecurity() }); - expect(a.hasAttribute('href')).to.equal(false); - }); - - it('allows http/https/mailto/tel and relative URLs', async () => { - const a = document.createElement('a'); - a.setAttribute('href', 'about:blank'); // will be sanitized out later - // eslint-disable-next-line no-template-curly-in-string - a.setAttribute('href', '${ link }'); - - await processAttributes(a, { link: 'https://example.com', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('https://example.com'); - - // http - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'http://example.com', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('http://example.com'); - - // mailto - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'mailto:test@example.com', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('mailto:test@example.com'); - - // tel - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'tel:+123456789', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('tel:+123456789'); - - // relative - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: '/path', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('/path'); - - // fragment - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: '#hash', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('#hash'); - - // query-only - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: '?q=1', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('?q=1'); - - // dot-relative - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: './file', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('./file'); - - // parent-relative - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: '../up', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('../up'); - }); - - it('applies URL checks to data-fly-attributes', async () => { - const img = document.createElement('img'); - img.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(img, { attrs: { src: 'javascript:alert(1)' }, security: createSecurity() }); - expect(img.hasAttribute('src')).to.equal(false); - - img.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(img, { attrs: { src: 'https://example.com/x.png' }, security: createSecurity() }); - expect(img.getAttribute('src')).to.equal('https://example.com/x.png'); - }); - - it('respects context.security.allowedUrlSchemes overrides', async () => { - const img = document.createElement('img'); - img.setAttribute('data-fly-attributes', 'attrs'); - const dataGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; - await processAttributes(img, { - attrs: { src: dataGif }, - security: createSecurity({ allowedUrlSchemes: new Set(['http:', 'https:', 'mailto:', 'tel:', '', 'data:']) }), - }); - expect(img.getAttribute('src')).to.equal(dataGif); - }); - - it('removes disallowed about: URL', async () => { - const a = document.createElement('a'); - // eslint-disable-next-line no-template-curly-in-string - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'about:blank', security: createSecurity() }); - expect(a.hasAttribute('href')).to.equal(false); - }); - - it('blocks xlink:href on SVG when using javascript:', async () => { - const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); - // eslint-disable-next-line no-template-curly-in-string - use.setAttribute('xlink:href', '${ link }'); - await processAttributes(use, { link: 'javascript:alert(1)', security: createSecurity() }); - expect(use.hasAttribute('xlink:href')).to.equal(false); - }); - - it('allows blocking additional attributes via context.security.blockedAttributes', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { style: 'color:red' }, - security: createSecurity({ blockedAttributes: new Set(['style']) }), - }); - expect(el.hasAttribute('style')).to.equal(false); - }); - - it('treats colon after slash as relative (no scheme)', async () => { - const a = document.createElement('a'); - // eslint-disable-next-line no-template-curly-in-string - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: '/a:b', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('/a:b'); - }); - - it('falls back to no-scheme for non-scheme strings containing colon', async () => { - const a = document.createElement('a'); - // eslint-disable-next-line no-template-curly-in-string - a.setAttribute('href', '${ link }'); - await processAttributes(a, { link: 'foo/bar:baz', security: createSecurity() }); - expect(a.getAttribute('href')).to.equal('foo/bar:baz'); - }); - - it('respects blockedAttributePattern override', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { - attrs: { 'data-test': 'x', title: 'ok' }, - security: createSecurity({ blockedAttributePattern: /^data-/i }), - }); - expect(el.hasAttribute('data-test')).to.equal(false); - expect(el.getAttribute('title')).to.equal('ok'); - }); -}); diff --git a/test/directives/attributes/processAttributes.test.js b/test/directives/attributes/processAttributes.test.js index 68cab34..4c06c1f 100644 --- a/test/directives/attributes/processAttributes.test.js +++ b/test/directives/attributes/processAttributes.test.js @@ -47,4 +47,79 @@ describe('processAttributes', () => { await processAttributes(el); expect(el.hasAttribute('data-fly-attributes')).to.equal(false); }); + + describe('security integration', () => { + it('calls security hooks when context.security is provided with custom hooks', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + let shouldAllowCalled = false; + const customSecurity = { + shouldAllowAttribute: (name) => { + shouldAllowCalled = true; + return name !== 'blocked'; + }, + allowIncludePath: () => true, + }; + + await processAttributes(el, { + attrs: { allowed: 'value', blocked: 'value' }, + security: customSecurity, + }); + + expect(shouldAllowCalled).to.equal(true); + expect(el.getAttribute('allowed')).to.equal('value'); + expect(el.hasAttribute('blocked')).to.equal(false); + }); + + it('allows all attributes in unsafe mode with security: false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + await processAttributes(el, { + // eslint-disable-next-line no-script-url + attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, + security: false, + }); + + expect(el.getAttribute('onclick')).to.equal('alert(1)'); + // eslint-disable-next-line no-script-url + expect(el.getAttribute('href')).to.equal('javascript:void(0)'); + }); + + it('allows all attributes in unsafe mode with security: "unsafe"', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + await processAttributes(el, { + // eslint-disable-next-line no-script-url + attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, + security: 'unsafe', + }); + + expect(el.getAttribute('onclick')).to.equal('alert(1)'); + // eslint-disable-next-line no-script-url + expect(el.getAttribute('href')).to.equal('javascript:void(0)'); + }); + + it.skip('loads default security module when no security context provided - test once real security blocks attributes'); + + it('applies security checks to expression-resolved attributes', async () => { + const el = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + el.setAttribute('href', '${ link }'); + + const customSecurity = { + shouldAllowAttribute: (name, value) => value !== 'blocked-value', + allowIncludePath: () => true, + }; + + await processAttributes(el, { + link: 'blocked-value', + security: customSecurity, + }); + + expect(el.hasAttribute('href')).to.equal(false); + }); + }); }); diff --git a/test/directives/include/processInclude.test.js b/test/directives/include/processInclude.test.js index 98fea1c..1e669ea 100644 --- a/test/directives/include/processInclude.test.js +++ b/test/directives/include/processInclude.test.js @@ -56,4 +56,74 @@ describe('processInclude', () => { }); expect(el.hasAttribute('data-fly-include')).to.equal(false); }); + + describe('security integration', () => { + it('calls allowIncludePath hook when security context is provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + let allowIncludePathCalled = false; + const customSecurity = { + shouldAllowAttribute: () => true, + allowIncludePath: (path) => { + allowIncludePathCalled = true; + return path.startsWith('/test/fixtures'); + }, + }; + + await processInclude(el, { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: customSecurity, + }); + + expect(allowIncludePathCalled).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + it('blocks include when allowIncludePath returns false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/blocked/path/template.html'); + + const customSecurity = { + shouldAllowAttribute: () => true, + allowIncludePath: () => false, + }; + + await processInclude(el, { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: customSecurity, + }); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.equal(0); + }); + + it('allows includes in unsafe mode with security: false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + await processInclude(el, { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: false, + }); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it('allows includes in unsafe mode with security: "unsafe"', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + await processInclude(el, { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: 'unsafe', + }); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it.skip('loads default security module when no security context provided - test once real security enforces path restrictions'); + }); }); diff --git a/test/directives/include/processIncludeSecurity.test.js b/test/directives/include/processIncludeSecurity.test.js deleted file mode 100644 index 0c3a602..0000000 --- a/test/directives/include/processIncludeSecurity.test.js +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-env mocha */ -/* eslint-disable no-unused-expressions */ -import { expect } from '@esm-bundle/chai'; -import { processInclude } from '../../../src/directives.js'; -import createSecurity from '../../../src/faintly.security.js'; - -describe('processInclude security restrictions', () => { - it('allows include within default base (codeBasePath/blocks)', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity(), - }); - - expect(result).to.equal(true); - // include should have removed the attribute and replaced children - expect(el.hasAttribute('data-fly-include')).to.equal(false); - expect(el.childNodes.length).to.be.greaterThan(0); - }); - - it('blocks include outside of allowed base path', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/not-allowed/blocks/elsewhere.html'); - - const result = await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity(), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - // Element should remain unchanged (no children added) - expect(el.childNodes.length).to.equal(0); - }); - - it('honors context.security.includeBasePath override', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity({ includeBasePath: '/test/fixtures/blocks/static-block/' }), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - }); - - it('allows include when base is inferred from current template path', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - // no codeBasePath provided - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity(), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - }); - - it('allows include under default includeBasePath = codeBasePath', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity(), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - }); - - // eslint-disable-next-line no-template-curly-in-string - it('supports includeBasePath template substitution (${codeBasePath})', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - // eslint-disable-next-line no-template-curly-in-string - security: createSecurity({ includeBasePath: '${codeBasePath}/blocks/' }), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - }); - - it('supports includeBasePath as a function and normalizes trailing slash', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - - const result = await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - security: createSecurity({ includeBasePath: (ctx) => `${ctx.codeBasePath}/blocks` }), - }); - - expect(result).to.equal(true); - expect(el.hasAttribute('data-fly-include')).to.equal(false); - }); - - // Removed tests for ${templatePath} and ${templateDir}; only ${codeBasePath} is supported now -}); From 17b9d020143dd09c6d2d6f3bc9831c38a51dbfa3 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:03:05 -0700 Subject: [PATCH 14/23] Implement shouldAllowAttribute with TDD approach - Add comprehensive test suite for security module (15 tests) - Implement attribute blocking by pattern (event handlers) and name (srcdoc) - Implement URL scheme validation with URL constructor - Refactor helper functions to top level with JSDoc - Relative URLs now explicitly use window.location.protocol - All tests pass: 88 passed, 2 skipped - 100% code coverage maintained - Bundle: 3361 bytes gzipped (under 6144 limit) --- dist/faintly.security.js | 52 ++++++++- src/faintly.security.js | 106 ++++++++++++++++++- test/security/createSecurity.test.js | 151 +++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 test/security/createSecurity.test.js diff --git a/dist/faintly.security.js b/dist/faintly.security.js index be6bcbc..4660373 100644 --- a/dist/faintly.security.js +++ b/dist/faintly.security.js @@ -1,7 +1,54 @@ // src/faintly.security.js -function createSecurity() { +var DEFAULT_CONFIG = { + // Attribute security + blockedAttributePatterns: [/^on/i], + // Block event handlers (onclick, onload, etc.) + blockedAttributes: ["srcdoc"], + // Block dangerous attributes + // URL attribute security + urlAttributes: ["href", "src", "action", "formaction", "xlink:href"], + allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"], + // Include path security + includeBasePath: null +}; +function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { + const name = attrName.toLowerCase(); + return blockedAttributes.includes(name) || blockedAttributePatterns.some((pattern) => pattern.test(name)); +} +function extractUrlScheme(value) { + const v = value.trim(); + if (!v) return window.location.protocol; + const colonIndex = v.indexOf(":"); + const slashIndex = v.indexOf("/"); + if (colonIndex === -1 || slashIndex !== -1 && colonIndex > slashIndex) { + return window.location.protocol; + } + const url = new URL(v, window.location.origin); + return url.protocol; +} +function isUrlAttribute(attrName, urlAttributes) { + return urlAttributes.includes(attrName.toLowerCase()); +} +function createSecurity(config = {}) { + const mergedConfig = { + ...DEFAULT_CONFIG, + ...config + }; + const { + blockedAttributePatterns, + blockedAttributes, + urlAttributes, + allowedUrlSchemes + } = mergedConfig; return { - shouldAllowAttribute() { + shouldAllowAttribute(attrName, value) { + if (isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes)) { + return false; + } + if (isUrlAttribute(attrName, urlAttributes)) { + const scheme = extractUrlScheme(value); + return allowedUrlSchemes.includes(scheme); + } return true; }, allowIncludePath() { @@ -10,5 +57,6 @@ function createSecurity() { }; } export { + DEFAULT_CONFIG, createSecurity as default }; diff --git a/src/faintly.security.js b/src/faintly.security.js index 3eb9a9d..b08e691 100644 --- a/src/faintly.security.js +++ b/src/faintly.security.js @@ -1,16 +1,112 @@ /** - * Minimal security stub for faintly. - * This is a placeholder that allows all attributes and includes by default. - * Full implementation coming soon. + * Default security configuration. + * Users can see and override these defaults by passing custom values to createSecurity(). + */ +export const DEFAULT_CONFIG = { + // Attribute security + blockedAttributePatterns: [/^on/i], // Block event handlers (onclick, onload, etc.) + blockedAttributes: ['srcdoc'], // Block dangerous attributes + + // URL attribute security + urlAttributes: ['href', 'src', 'action', 'formaction', 'xlink:href'], + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:'], + + // Include path security + includeBasePath: null, +}; + +/** + * Check if an attribute name is blocked by pattern or specific name + * @param {string} attrName The attribute name to check + * @param {Array} blockedAttributePatterns Regex patterns to test against + * @param {Array} blockedAttributes Specific attribute names to block + * @returns {boolean} True if the attribute is blocked + */ +function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { + const name = attrName.toLowerCase(); + + return blockedAttributes.includes(name) + || blockedAttributePatterns.some((pattern) => pattern.test(name)); +} + +/** + * Extract URL scheme from a value + * @param {string} value The URL value to parse + * @returns {string} The URL scheme (e.g., 'http:', 'javascript:'). + * Relative URLs return the current page's protocol. + */ +function extractUrlScheme(value) { + const v = value.trim(); + if (!v) return window.location.protocol; + + const colonIndex = v.indexOf(':'); + const slashIndex = v.indexOf('/'); + + // No colon, or colon after slash = relative URL (use current protocol) + if (colonIndex === -1 || (slashIndex !== -1 && colonIndex > slashIndex)) { + return window.location.protocol; + } + + const url = new URL(v, window.location.origin); + return url.protocol; +} + +/** + * Check if an attribute is a URL attribute + * @param {string} attrName The attribute name to check + * @param {Array} urlAttributes List of attributes that contain URLs + * @returns {boolean} True if the attribute is a URL attribute + */ +function isUrlAttribute(attrName, urlAttributes) { + return urlAttributes.includes(attrName.toLowerCase()); +} + +/** + * Create a security policy for Faintly. + * Pass custom configuration to override defaults. * + * @param {Object} [config] Custom security configuration + * @param {Array} [config.blockedAttributePatterns] + * Regex patterns for blocked attribute names + * @param {Array} [config.blockedAttributes] + * Array of specific attribute names to block + * @param {Array} [config.urlAttributes] + * Attributes to check for URL scheme validation + * @param {Array} [config.allowedUrlSchemes] + * Array of allowed URL schemes (e.g., ['http:', 'https:']). + * Relative URLs are always allowed. + * @param {string|Function} [config.includeBasePath] + * Base path for allowed includes * @returns {Object} Security hooks */ -export default function createSecurity() { +export default function createSecurity(config = {}) { + const mergedConfig = { + ...DEFAULT_CONFIG, + ...config, + }; + + const { + blockedAttributePatterns, + blockedAttributes, + urlAttributes, + allowedUrlSchemes, + } = mergedConfig; + return { - shouldAllowAttribute() { + shouldAllowAttribute(attrName, value) { + if (isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes)) { + return false; + } + + if (isUrlAttribute(attrName, urlAttributes)) { + const scheme = extractUrlScheme(value); + return allowedUrlSchemes.includes(scheme); + } + return true; }, allowIncludePath() { + // TODO: Implement include path restriction logic return true; }, }; diff --git a/test/security/createSecurity.test.js b/test/security/createSecurity.test.js new file mode 100644 index 0000000..246fd66 --- /dev/null +++ b/test/security/createSecurity.test.js @@ -0,0 +1,151 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-script-url */ + +import { expect } from '@esm-bundle/chai'; +import createSecurity from '../../src/faintly.security.js'; + +describe('createSecurity', () => { + describe('shouldAllowAttribute - blocked attributes', () => { + it('blocks event handler attributes by pattern (onclick, onload, etc.)', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('onclick', 'alert(1)')).to.equal(false); + expect(security.shouldAllowAttribute('onload', 'doSomething()')).to.equal(false); + expect(security.shouldAllowAttribute('ONCLICK', 'alert(1)')).to.equal(false); // case insensitive + expect(security.shouldAllowAttribute('OnMouseOver', 'hack()')).to.equal(false); + }); + + it('blocks srcdoc attribute', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('srcdoc', '')).to.equal(false); + }); + + it('allows normal attributes', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('class', 'some-class')).to.equal(true); + expect(security.shouldAllowAttribute('id', 'some-id')).to.equal(true); + expect(security.shouldAllowAttribute('aria-label', 'description')).to.equal(true); + expect(security.shouldAllowAttribute('data-test', 'value')).to.equal(true); + }); + }); + + describe('shouldAllowAttribute - URL validation', () => { + it('blocks javascript: URLs in href', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'javascript:alert(1)')).to.equal(false); + }); + + it('blocks javascript: URLs in src', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('src', 'javascript:alert(1)')).to.equal(false); + }); + + it('blocks data: URLs by default', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'data:text/html,')).to.equal(false); + }); + + it('allows http: and https: URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'http://example.com')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'https://example.com')).to.equal(true); + expect(security.shouldAllowAttribute('src', 'https://cdn.example.com/image.jpg')).to.equal(true); + }); + + it('allows mailto: and tel: URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'mailto:test@example.com')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'tel:+1234567890')).to.equal(true); + }); + + it('always allows relative URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', '/path/to/page')).to.equal(true); + expect(security.shouldAllowAttribute('href', './relative')).to.equal(true); + expect(security.shouldAllowAttribute('href', '../parent')).to.equal(true); + expect(security.shouldAllowAttribute('href', '#hash')).to.equal(true); + expect(security.shouldAllowAttribute('href', '?query=value')).to.equal(true); + }); + + it('does not apply URL validation to non-URL attributes', () => { + const security = createSecurity(); + + // These should be allowed even though they contain "javascript:" + expect(security.shouldAllowAttribute('title', 'javascript: is dangerous')).to.equal(true); + expect(security.shouldAllowAttribute('alt', 'data:image/png')).to.equal(true); + }); + + it('handles edge cases in URL scheme extraction', () => { + const security = createSecurity(); + + // Empty or whitespace values + expect(security.shouldAllowAttribute('href', '')).to.equal(true); + expect(security.shouldAllowAttribute('href', ' ')).to.equal(true); + + // Colon after slash (not a scheme) + expect(security.shouldAllowAttribute('href', '/path:with:colons')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'path/file:name')).to.equal(true); + + // String with colon before slash is parsed as scheme (even if unusual) + // Should be blocked since 'not:' is not in allowedUrlSchemes + expect(security.shouldAllowAttribute('href', 'not:a:valid:url')).to.equal(false); + }); + }); + + describe('shouldAllowAttribute - custom configuration', () => { + it('allows overriding blockedAttributes', () => { + const security = createSecurity({ + blockedAttributes: ['style', 'class'], + }); + + expect(security.shouldAllowAttribute('style', 'color:red')).to.equal(false); + expect(security.shouldAllowAttribute('class', 'test')).to.equal(false); + expect(security.shouldAllowAttribute('srcdoc', 'test')).to.equal(true); // Original default not included + }); + + it('allows overriding blockedAttributePatterns', () => { + const security = createSecurity({ + blockedAttributePatterns: [/^data-/i], + }); + + expect(security.shouldAllowAttribute('data-test', 'value')).to.equal(false); + expect(security.shouldAllowAttribute('onclick', 'alert(1)')).to.equal(true); // Original default not included + }); + + it('allows adding to allowedUrlSchemes', () => { + const dataGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + const security = createSecurity({ + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'], + }); + + expect(security.shouldAllowAttribute('src', dataGif)).to.equal(true); + }); + + it('allows overriding urlAttributes', () => { + const security = createSecurity({ + urlAttributes: ['href'], // Only check href, not src + }); + + expect(security.shouldAllowAttribute('href', 'javascript:alert(1)')).to.equal(false); + expect(security.shouldAllowAttribute('src', 'javascript:alert(1)')).to.equal(true); // Not a URL attribute anymore + }); + }); + + describe('allowIncludePath', () => { + it('allows all paths (stub implementation)', () => { + const security = createSecurity(); + + // Currently a stub that allows everything + expect(security.allowIncludePath('/any/path')).to.equal(true); + expect(security.allowIncludePath('/another/path')).to.equal(true); + }); + }); +}); From 166d54f3e1e4780b2989e9c8e972632c5ff9b643 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:06:30 -0700 Subject: [PATCH 15/23] Remove inline comments from DEFAULT_CONFIG - Clean up DEFAULT_CONFIG object (comments weren't adding value) - Property names are self-explanatory - Reduces bundle size by 61 bytes (3361 -> 3300 bytes) - All tests still pass --- dist/faintly.security.js | 5 ----- src/faintly.security.js | 9 ++------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/dist/faintly.security.js b/dist/faintly.security.js index 4660373..ee9715c 100644 --- a/dist/faintly.security.js +++ b/dist/faintly.security.js @@ -1,14 +1,9 @@ // src/faintly.security.js var DEFAULT_CONFIG = { - // Attribute security blockedAttributePatterns: [/^on/i], - // Block event handlers (onclick, onload, etc.) blockedAttributes: ["srcdoc"], - // Block dangerous attributes - // URL attribute security urlAttributes: ["href", "src", "action", "formaction", "xlink:href"], allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"], - // Include path security includeBasePath: null }; function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { diff --git a/src/faintly.security.js b/src/faintly.security.js index b08e691..14662fb 100644 --- a/src/faintly.security.js +++ b/src/faintly.security.js @@ -3,15 +3,10 @@ * Users can see and override these defaults by passing custom values to createSecurity(). */ export const DEFAULT_CONFIG = { - // Attribute security - blockedAttributePatterns: [/^on/i], // Block event handlers (onclick, onload, etc.) - blockedAttributes: ['srcdoc'], // Block dangerous attributes - - // URL attribute security + blockedAttributePatterns: [/^on/i], + blockedAttributes: ['srcdoc'], urlAttributes: ['href', 'src', 'action', 'formaction', 'xlink:href'], allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:'], - - // Include path security includeBasePath: null, }; From 54759a44c9e913158a7812ed8ac3f9b18f245b72 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:08:26 -0700 Subject: [PATCH 16/23] Unskip default security test for processAttributes - Test now verifies default security module loads and blocks dangerous attributes - Validates that onclick is blocked while safe attributes like class are allowed - 89 tests passing, 1 skipped (down from 2 skipped) - 100% coverage maintained --- .../directives/attributes/processAttributes.test.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/directives/attributes/processAttributes.test.js b/test/directives/attributes/processAttributes.test.js index 4c06c1f..7c48174 100644 --- a/test/directives/attributes/processAttributes.test.js +++ b/test/directives/attributes/processAttributes.test.js @@ -102,7 +102,18 @@ describe('processAttributes', () => { expect(el.getAttribute('href')).to.equal('javascript:void(0)'); }); - it.skip('loads default security module when no security context provided - test once real security blocks attributes'); + it('loads default security module when no security context provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + // Default security should block event handlers + await processAttributes(el, { + attrs: { onclick: 'alert(1)', class: 'safe' }, + }); + + expect(el.hasAttribute('onclick')).to.equal(false); // Blocked by default security + expect(el.getAttribute('class')).to.equal('safe'); // Safe attribute allowed + }); it('applies security checks to expression-resolved attributes', async () => { const el = document.createElement('a'); From 3dbadf85b70d2d7613be3e3553cfa46a0ccb57f7 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:19:18 -0700 Subject: [PATCH 17/23] Refactor to allowedTemplatePaths and unskip all tests - Replace includeBasePath with allowedTemplatePaths array - Remove variable substitution complexity - Default to context.codeBasePath (or / as fallback) - Users can provide custom array for more control - Unskip and implement last processInclude test - Add test for blocking paths outside codeBasePath - All tests pass: 98 passed, 0 skipped - 100% coverage maintained - Bundle: 3456 bytes gzipped (under 6144 limit) --- dist/faintly.security.js | 23 +++++-- src/faintly.security.js | 38 ++++++++-- .../directives/include/processInclude.test.js | 28 +++++++- test/security/createSecurity.test.js | 69 +++++++++++++++++-- 4 files changed, 143 insertions(+), 15 deletions(-) diff --git a/dist/faintly.security.js b/dist/faintly.security.js index ee9715c..982d8f5 100644 --- a/dist/faintly.security.js +++ b/dist/faintly.security.js @@ -4,7 +4,7 @@ var DEFAULT_CONFIG = { blockedAttributes: ["srcdoc"], urlAttributes: ["href", "src", "action", "formaction", "xlink:href"], allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"], - includeBasePath: null + allowedTemplatePaths: null }; function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { const name = attrName.toLowerCase(); @@ -33,7 +33,8 @@ function createSecurity(config = {}) { blockedAttributePatterns, blockedAttributes, urlAttributes, - allowedUrlSchemes + allowedUrlSchemes, + allowedTemplatePaths } = mergedConfig; return { shouldAllowAttribute(attrName, value) { @@ -46,8 +47,22 @@ function createSecurity(config = {}) { } return true; }, - allowIncludePath() { - return true; + allowIncludePath(templatePath, context) { + if (!templatePath) { + return true; + } + const templateUrl = new URL(templatePath, window.location.origin); + if (templateUrl.origin !== window.location.origin) { + return false; + } + const paths = allowedTemplatePaths || [context.codeBasePath || "/"]; + return paths.some((allowedPath) => { + let normalizedPath = String(allowedPath); + if (!normalizedPath.endsWith("/")) { + normalizedPath = `${normalizedPath}/`; + } + return templateUrl.pathname.startsWith(normalizedPath); + }); } }; } diff --git a/src/faintly.security.js b/src/faintly.security.js index 14662fb..a727ee0 100644 --- a/src/faintly.security.js +++ b/src/faintly.security.js @@ -7,7 +7,7 @@ export const DEFAULT_CONFIG = { blockedAttributes: ['srcdoc'], urlAttributes: ['href', 'src', 'action', 'formaction', 'xlink:href'], allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:'], - includeBasePath: null, + allowedTemplatePaths: null, }; /** @@ -70,8 +70,9 @@ function isUrlAttribute(attrName, urlAttributes) { * @param {Array} [config.allowedUrlSchemes] * Array of allowed URL schemes (e.g., ['http:', 'https:']). * Relative URLs are always allowed. - * @param {string|Function} [config.includeBasePath] - * Base path for allowed includes + * @param {Array} [config.allowedTemplatePaths] + * Array of allowed template path prefixes (e.g., ['/blocks', '/shared']). + * Defaults to context.codeBasePath if not provided. * @returns {Object} Security hooks */ export default function createSecurity(config = {}) { @@ -85,6 +86,7 @@ export default function createSecurity(config = {}) { blockedAttributes, urlAttributes, allowedUrlSchemes, + allowedTemplatePaths, } = mergedConfig; return { @@ -100,9 +102,33 @@ export default function createSecurity(config = {}) { return true; }, - allowIncludePath() { - // TODO: Implement include path restriction logic - return true; + allowIncludePath(templatePath, context) { + // Empty or null paths are allowed + if (!templatePath) { + return true; + } + + // Parse URL to check origin and extract pathname + const templateUrl = new URL(templatePath, window.location.origin); + + // Enforce same-origin + if (templateUrl.origin !== window.location.origin) { + return false; + } + + // Determine allowed paths (default to codeBasePath if not configured) + const paths = allowedTemplatePaths || [context.codeBasePath || '/']; + + // Check if pathname matches any allowed path prefix + return paths.some((allowedPath) => { + // Normalize (ensure trailing slash) + let normalizedPath = String(allowedPath); + if (!normalizedPath.endsWith('/')) { + normalizedPath = `${normalizedPath}/`; + } + + return templateUrl.pathname.startsWith(normalizedPath); + }); }, }; } diff --git a/test/directives/include/processInclude.test.js b/test/directives/include/processInclude.test.js index 1e669ea..c466dc0 100644 --- a/test/directives/include/processInclude.test.js +++ b/test/directives/include/processInclude.test.js @@ -124,6 +124,32 @@ describe('processInclude', () => { expect(el.childNodes.length).to.be.greaterThan(0); }); - it.skip('loads default security module when no security context provided - test once real security enforces path restrictions'); + it('loads default security module when no security context provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + // Default security should allow includes within codeBasePath + await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + }); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it('loads default security module and blocks paths outside codeBasePath', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/not-allowed/template.html'); + + // Default security should block this path + await processInclude(el, { + codeBasePath: '/test/fixtures', + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + }); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.equal(0); // Should be blocked, no children added + }); }); }); diff --git a/test/security/createSecurity.test.js b/test/security/createSecurity.test.js index 246fd66..f2c74e2 100644 --- a/test/security/createSecurity.test.js +++ b/test/security/createSecurity.test.js @@ -140,12 +140,73 @@ describe('createSecurity', () => { }); describe('allowIncludePath', () => { - it('allows all paths (stub implementation)', () => { + it('allows paths within default base (context.codeBasePath)', () => { const security = createSecurity(); + const context = { codeBasePath: '/blocks' }; - // Currently a stub that allows everything - expect(security.allowIncludePath('/any/path')).to.equal(true); - expect(security.allowIncludePath('/another/path')).to.equal(true); + expect(security.allowIncludePath('/blocks/card/card.html', context)).to.equal(true); + expect(security.allowIncludePath('/blocks/header/header.html', context)).to.equal(true); + }); + + it('blocks paths outside codeBasePath', () => { + const security = createSecurity(); + const context = { codeBasePath: '/blocks' }; + + expect(security.allowIncludePath('/scripts/evil.js', context)).to.equal(false); + expect(security.allowIncludePath('/other/path.html', context)).to.equal(false); + }); + + it('allows custom allowedTemplatePaths array', () => { + const security = createSecurity({ + allowedTemplatePaths: ['/templates', '/shared'], + }); + const context = { codeBasePath: '/blocks' }; + + expect(security.allowIncludePath('/templates/card.html', context)).to.equal(true); + expect(security.allowIncludePath('/shared/util.html', context)).to.equal(true); + expect(security.allowIncludePath('/blocks/card.html', context)).to.equal(false); + }); + + it('handles empty or null paths', () => { + const security = createSecurity(); + const context = { codeBasePath: '/blocks' }; + + expect(security.allowIncludePath('', context)).to.equal(true); + expect(security.allowIncludePath(null, context)).to.equal(true); + }); + + it('normalizes trailing slashes in allowed paths', () => { + const security = createSecurity({ + allowedTemplatePaths: ['/templates'], + }); + const context = {}; + + expect(security.allowIncludePath('/templates/card.html', context)).to.equal(true); + }); + + it('blocks cross-origin URLs', () => { + const security = createSecurity(); + const context = { codeBasePath: '/blocks' }; + + expect(security.allowIncludePath('https://evil.com/blocks/card.html', context)).to.equal(false); + expect(security.allowIncludePath('http://evil.com/blocks/card.html', context)).to.equal(false); + expect(security.allowIncludePath('//evil.com/blocks/card.html', context)).to.equal(false); + }); + + it('allows same-origin full URLs', () => { + const security = createSecurity(); + const context = { codeBasePath: '/blocks' }; + + const sameOriginUrl = `${window.location.origin}/blocks/card.html`; + expect(security.allowIncludePath(sameOriginUrl, context)).to.equal(true); + }); + + it('fallsback to root (/) when codeBasePath is undefined', () => { + const security = createSecurity(); + const context = {}; + + // Should fallback to / (allow everything from root) + expect(security.allowIncludePath('/anything/file.html', context)).to.equal(true); }); }); }); From 35ac24777a78a366775bab62604ffa5ad6d055ee Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:28:26 -0700 Subject: [PATCH 18/23] Simplify allowIncludePath to same-origin only - Remove allowedTemplatePaths config entirely - Default security enforces same-origin for includes (critical boundary) - Users can provide custom allowIncludePath for additional restrictions - Simpler implementation and API - All tests pass: 95 passed, 0 skipped - 100% coverage maintained - Bundle: 3326 bytes gzipped (130 bytes smaller!) --- dist/faintly.security.js | 20 ++---- src/faintly.security.js | 28 +-------- .../directives/include/processInclude.test.js | 19 +----- test/security/createSecurity.test.js | 61 ++++--------------- 4 files changed, 19 insertions(+), 109 deletions(-) diff --git a/dist/faintly.security.js b/dist/faintly.security.js index 982d8f5..c7581cb 100644 --- a/dist/faintly.security.js +++ b/dist/faintly.security.js @@ -3,8 +3,7 @@ var DEFAULT_CONFIG = { blockedAttributePatterns: [/^on/i], blockedAttributes: ["srcdoc"], urlAttributes: ["href", "src", "action", "formaction", "xlink:href"], - allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"], - allowedTemplatePaths: null + allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"] }; function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { const name = attrName.toLowerCase(); @@ -33,8 +32,7 @@ function createSecurity(config = {}) { blockedAttributePatterns, blockedAttributes, urlAttributes, - allowedUrlSchemes, - allowedTemplatePaths + allowedUrlSchemes } = mergedConfig; return { shouldAllowAttribute(attrName, value) { @@ -47,22 +45,12 @@ function createSecurity(config = {}) { } return true; }, - allowIncludePath(templatePath, context) { + allowIncludePath(templatePath) { if (!templatePath) { return true; } const templateUrl = new URL(templatePath, window.location.origin); - if (templateUrl.origin !== window.location.origin) { - return false; - } - const paths = allowedTemplatePaths || [context.codeBasePath || "/"]; - return paths.some((allowedPath) => { - let normalizedPath = String(allowedPath); - if (!normalizedPath.endsWith("/")) { - normalizedPath = `${normalizedPath}/`; - } - return templateUrl.pathname.startsWith(normalizedPath); - }); + return templateUrl.origin === window.location.origin; } }; } diff --git a/src/faintly.security.js b/src/faintly.security.js index a727ee0..5f6d165 100644 --- a/src/faintly.security.js +++ b/src/faintly.security.js @@ -7,7 +7,6 @@ export const DEFAULT_CONFIG = { blockedAttributes: ['srcdoc'], urlAttributes: ['href', 'src', 'action', 'formaction', 'xlink:href'], allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:'], - allowedTemplatePaths: null, }; /** @@ -70,9 +69,6 @@ function isUrlAttribute(attrName, urlAttributes) { * @param {Array} [config.allowedUrlSchemes] * Array of allowed URL schemes (e.g., ['http:', 'https:']). * Relative URLs are always allowed. - * @param {Array} [config.allowedTemplatePaths] - * Array of allowed template path prefixes (e.g., ['/blocks', '/shared']). - * Defaults to context.codeBasePath if not provided. * @returns {Object} Security hooks */ export default function createSecurity(config = {}) { @@ -86,7 +82,6 @@ export default function createSecurity(config = {}) { blockedAttributes, urlAttributes, allowedUrlSchemes, - allowedTemplatePaths, } = mergedConfig; return { @@ -102,33 +97,14 @@ export default function createSecurity(config = {}) { return true; }, - allowIncludePath(templatePath, context) { - // Empty or null paths are allowed + allowIncludePath(templatePath) { if (!templatePath) { return true; } - // Parse URL to check origin and extract pathname const templateUrl = new URL(templatePath, window.location.origin); - // Enforce same-origin - if (templateUrl.origin !== window.location.origin) { - return false; - } - - // Determine allowed paths (default to codeBasePath if not configured) - const paths = allowedTemplatePaths || [context.codeBasePath || '/']; - - // Check if pathname matches any allowed path prefix - return paths.some((allowedPath) => { - // Normalize (ensure trailing slash) - let normalizedPath = String(allowedPath); - if (!normalizedPath.endsWith('/')) { - normalizedPath = `${normalizedPath}/`; - } - - return templateUrl.pathname.startsWith(normalizedPath); - }); + return templateUrl.origin === window.location.origin; }, }; } diff --git a/test/directives/include/processInclude.test.js b/test/directives/include/processInclude.test.js index c466dc0..a8490c9 100644 --- a/test/directives/include/processInclude.test.js +++ b/test/directives/include/processInclude.test.js @@ -124,32 +124,17 @@ describe('processInclude', () => { expect(el.childNodes.length).to.be.greaterThan(0); }); - it('loads default security module when no security context provided', async () => { + it('loads default security module and allows same-origin paths', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - // Default security should allow includes within codeBasePath + // Default security allows all same-origin paths await processInclude(el, { - codeBasePath: '/test/fixtures', template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, }); expect(el.hasAttribute('data-fly-include')).to.equal(false); expect(el.childNodes.length).to.be.greaterThan(0); }); - - it('loads default security module and blocks paths outside codeBasePath', async () => { - const el = document.createElement('div'); - el.setAttribute('data-fly-include', '/not-allowed/template.html'); - - // Default security should block this path - await processInclude(el, { - codeBasePath: '/test/fixtures', - template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); - - expect(el.hasAttribute('data-fly-include')).to.equal(false); - expect(el.childNodes.length).to.equal(0); // Should be blocked, no children added - }); }); }); diff --git a/test/security/createSecurity.test.js b/test/security/createSecurity.test.js index f2c74e2..a31e4d6 100644 --- a/test/security/createSecurity.test.js +++ b/test/security/createSecurity.test.js @@ -140,73 +140,34 @@ describe('createSecurity', () => { }); describe('allowIncludePath', () => { - it('allows paths within default base (context.codeBasePath)', () => { + it('allows all same-origin paths', () => { const security = createSecurity(); - const context = { codeBasePath: '/blocks' }; - expect(security.allowIncludePath('/blocks/card/card.html', context)).to.equal(true); - expect(security.allowIncludePath('/blocks/header/header.html', context)).to.equal(true); - }); - - it('blocks paths outside codeBasePath', () => { - const security = createSecurity(); - const context = { codeBasePath: '/blocks' }; - - expect(security.allowIncludePath('/scripts/evil.js', context)).to.equal(false); - expect(security.allowIncludePath('/other/path.html', context)).to.equal(false); - }); - - it('allows custom allowedTemplatePaths array', () => { - const security = createSecurity({ - allowedTemplatePaths: ['/templates', '/shared'], - }); - const context = { codeBasePath: '/blocks' }; - - expect(security.allowIncludePath('/templates/card.html', context)).to.equal(true); - expect(security.allowIncludePath('/shared/util.html', context)).to.equal(true); - expect(security.allowIncludePath('/blocks/card.html', context)).to.equal(false); + expect(security.allowIncludePath('/blocks/card/card.html')).to.equal(true); + expect(security.allowIncludePath('/scripts/file.js')).to.equal(true); + expect(security.allowIncludePath('/any/path.html')).to.equal(true); }); it('handles empty or null paths', () => { const security = createSecurity(); - const context = { codeBasePath: '/blocks' }; - expect(security.allowIncludePath('', context)).to.equal(true); - expect(security.allowIncludePath(null, context)).to.equal(true); - }); - - it('normalizes trailing slashes in allowed paths', () => { - const security = createSecurity({ - allowedTemplatePaths: ['/templates'], - }); - const context = {}; - - expect(security.allowIncludePath('/templates/card.html', context)).to.equal(true); + expect(security.allowIncludePath('')).to.equal(true); + expect(security.allowIncludePath(null)).to.equal(true); }); it('blocks cross-origin URLs', () => { const security = createSecurity(); - const context = { codeBasePath: '/blocks' }; - expect(security.allowIncludePath('https://evil.com/blocks/card.html', context)).to.equal(false); - expect(security.allowIncludePath('http://evil.com/blocks/card.html', context)).to.equal(false); - expect(security.allowIncludePath('//evil.com/blocks/card.html', context)).to.equal(false); + expect(security.allowIncludePath('https://evil.com/blocks/card.html')).to.equal(false); + expect(security.allowIncludePath('http://evil.com/blocks/card.html')).to.equal(false); + expect(security.allowIncludePath('//evil.com/blocks/card.html')).to.equal(false); }); it('allows same-origin full URLs', () => { const security = createSecurity(); - const context = { codeBasePath: '/blocks' }; - - const sameOriginUrl = `${window.location.origin}/blocks/card.html`; - expect(security.allowIncludePath(sameOriginUrl, context)).to.equal(true); - }); - - it('fallsback to root (/) when codeBasePath is undefined', () => { - const security = createSecurity(); - const context = {}; - // Should fallback to / (allow everything from root) - expect(security.allowIncludePath('/anything/file.html', context)).to.equal(true); + const sameOriginUrl = `${window.location.origin}/any/path/card.html`; + expect(security.allowIncludePath(sameOriginUrl)).to.equal(true); }); }); }); From 668db23327229bc8bfb19ea16778e244b3d54397 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:38:54 -0700 Subject: [PATCH 19/23] Add comprehensive security documentation to README - Document default security features (attribute sanitization, URL validation, same-origin) - Show custom security hooks and configuration options - Move unsafe mode section to bottom with stronger warnings - Add explicit WARNING about user-supplied data in context - Clarify trust boundaries (what is protected vs trusted by design) - Update Getting Started to mention faintly.security.js file - Add security to rendering context list --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f91895b..31d271d 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ I've experimented with other existing libraries (ejs templates, etc.) but wanted ## Getting Started -1. copy the /dist/faintly.js file to the scripts directory of your project -2. in the folder for your block, add a `blockName.html` file for the block template -3. in your block javascript, call the `renderBlock` function: +1. Copy the `/dist/faintly.js` and `/dist/faintly.security.js` files to the scripts directory of your project +2. In the folder for your block, add a `blockName.html` file for the block template +3. In your block javascript, call the `renderBlock` function: ``` import { renderBlock } from '../scripts/faintly.js'; @@ -55,6 +55,7 @@ The rendering context is a javascript object used to provide data to the templat * template * path - the path to the template being rendered * name - the template name, if there is one +* security - security configuration (see Security section below) When in a repeat loop, it will also include: @@ -70,6 +71,105 @@ When in a repeat loop, it will also include: > [!NOTE] > Because element attributes are case-insensitive, context names are converted to lower case. e.g. `data-fly-test.myTest` will be set in the context as `mytest`. +## Security + +Faintly includes built-in security features to help protect against XSS (Cross-Site Scripting) attacks. By default, security is **enabled** and provides: + +* **Attribute sanitization** - Blocks dangerous attributes like event handlers (`onclick`, `onerror`, etc.) and `srcdoc` +* **URL scheme validation** - Restricts URLs in attributes like `href` and `src` to safe schemes (`http:`, `https:`, `mailto:`, `tel:`) +* **Same-origin enforcement** - Template includes are restricted to same-origin URLs only + +### Default Security + +When you call `renderBlock()` without a security context, default security is automatically applied: + +```javascript +await renderBlock(block); // Default security enabled +``` + +The default security module (`dist/faintly.security.js`) is dynamically loaded on first use. + +### Custom Security + +For more control, you can provide a custom security object with `shouldAllowAttribute` and `allowIncludePath` hooks: + +```javascript +await renderBlock(block, { + security: { + shouldAllowAttribute(attrName, value) { + // Return true to allow the attribute, false to block it + // Your custom logic here + return true; + }, + allowIncludePath(templatePath) { + // Return true to allow the template include, false to block it + // Your custom logic here + return true; + }, + }, +}); +``` + +You can also use the default security module and override specific configuration: + +```javascript +import createSecurity from './scripts/faintly.security.js'; + +await renderBlock(block, { + security: createSecurity({ + // Add 'data:' URLs to allowed schemes + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'], + // Block additional attributes + blockedAttributes: ['srcdoc', 'sandbox'], + }), +}); +``` + +### Security Configuration Options + +The default security module accepts the following configuration: + +* `blockedAttributePatterns` (Array) - Regex patterns for blocked attribute names (default: `/^on/i` blocks all event handlers) +* `blockedAttributes` (Array) - Specific attribute names to block (default: `['srcdoc']`) +* `urlAttributes` (Array) - Attributes that contain URLs to validate (default: `['href', 'src', 'action', 'formaction', 'xlink:href']`) +* `allowedUrlSchemes` (Array) - Allowed URL schemes; relative URLs are always allowed (default: `['http:', 'https:', 'mailto:', 'tel:']`) + + +### Disabling Security (Unsafe Mode) + +You can disable security if needed. **THIS IS NOT RECOMMENDED** + + +> [!CAUTION] +> **THIS IS NOT RECOMMENDED** and bypasses all XSS protection. + +```javascript +await renderBlock(block, { + security: false, // or 'unsafe' +}); +``` + + +### Trust Boundaries + +It's important to understand what Faintly's security does and doesn't protect: + +**Protected:** +- ✅ Dangerous attributes (event handlers, `srcdoc`) +- ✅ Malicious URL schemes (`javascript:`, `data:` by default) +- ✅ Cross-origin template includes + +**Trusted (by design):** +- The rendering context you provide is fully trusted +- Templates fetched from your same-origin are trusted +- DOM Node objects provided in context are inserted directly + +> [!WARNING] +> **Be extremely careful when adding user-supplied data to the rendering context.** URL parameters, form inputs, cookies, and other user-controlled data should be validated and sanitized before adding to the context. The context is fully trusted, so untrusted data placed in it can bypass security protections. + +> [!TIP] +> Security works best in layers. Faintly's security helps prevent common XSS vectors, but you should also: validate and sanitize user input before adding it to context, use Content Security Policy headers, and follow secure coding practices. + ## Directives Faintly supports the following directives. From 6bb84eb2a1814d47b165f9cf73b2ca8e036a8a8e Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 10:42:05 -0700 Subject: [PATCH 20/23] Update AGENTS.md with security module documentation - Add security module to project purpose - Document dual bundle architecture (core + security) - Update build size limits (4KB core, 6KB total) - Add security module section with development guidelines - Update repo layout to include test/security/ - Clarify that security is enabled by default - Emphasize testing and conservative defaults for security changes --- AGENTS.md | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b29a758..95ecb49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Authoritative guide for AI/code agents contributing to this repository. ### Project purpose - **What this is**: A small, dependency-free HTML templating/DOM rendering library for AEM Edge Delivery Services blocks (and other HTML fragments). - **Public API**: `renderBlock(element, context?)`, `renderElement(element, context?)` exported from `src/index.js` and bundled to `dist/faintly.js`. +- **Security**: Built-in XSS protection via `src/faintly.security.js`, bundled to `dist/faintly.security.js` (dynamically loaded by default). ### Environment - **Node**: 20 (CI uses Node 20). @@ -43,9 +44,12 @@ Authoritative guide for AI/code agents contributing to this repository. - Keep modules small and readable; avoid deep nesting; avoid unnecessary try/catch. ### Build and artifacts -- Bundling uses `esbuild` to produce a single ESM file at `dist/faintly.js` for browser usage. -- CI enforces a gzipped bundle size limit of **5KB (5120 bytes)**. Keep additions small; avoid adding heavy dependencies. -- If you change source under `src/`, run `npm run build` so `dist/faintly.js` is up to date. +- Bundling uses `esbuild` to produce two ESM bundles for browser usage: + - `dist/faintly.js` (core library, gzipped limit: **4KB / 4096 bytes**) + - `dist/faintly.security.js` (security module, separate to allow tree-shaking) +- CI enforces a **combined gzipped size limit of 6KB (6144 bytes)** for both files. +- Keep additions small; avoid adding heavy dependencies. +- If you change source under `src/`, run `npm run build` so `dist/` artifacts are up to date. ### CI behavior (GitHub Actions) - Workflow: `.github/workflows/main.yaml` runs on pull requests (open/sync/reopen). @@ -53,26 +57,30 @@ Authoritative guide for AI/code agents contributing to this repository. - The workflow will attempt to commit updated `dist/` artifacts back to the PR branch if they changed. ### Repo layout -- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`). -- `dist/`: built artifact (`faintly.js`). -- `test/`: unit/perf tests, fixtures, snapshots, and utilities. -- `coverage/`: coverage output when tests are run with coverage. +- `src/`: library source + - Core: `index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js` + - Security: `faintly.security.js` +- `dist/`: built artifacts (`faintly.js`, `faintly.security.js`) +- `test/`: unit/perf tests, fixtures, snapshots, and utilities + - `test/security/`: tests for security module +- `coverage/`: coverage output when tests are run with coverage ### Contribution checklist for agents 1. Install deps with `npm ci`. 2. Make focused edits under `src/` and relevant tests under `test/`. 3. Run `npm run lint:fix` then `npm run lint` and resolve any remaining issues. 4. Run `npm test` and ensure coverage stays at 100%. -5. Run `npm run build:strict` and verify `dist/faintly.js` updates (if source changed). -6. Ensure gzipped size of `dist/faintly.js` remains <= 5120 bytes (CI will enforce). +5. Run `npm run build:strict` and verify `dist/` artifacts update (if source changed). +6. Ensure combined gzipped size remains <= 6144 bytes (CI will enforce). 7. Update `README.md` if you change public behavior or usage. 8. Commit changes; open a PR. CI will validate and may commit updated `dist/` to the PR branch. ### Public API and usage (for context) -- Consumers copy `dist/faintly.js` into their AEM project and use: +- Consumers copy `dist/faintly.js` and `dist/faintly.security.js` into their AEM project and use: - `renderBlock(block, context?)` - `renderElement(element, context?)` -- See `README.md` for examples, directives, and expression syntax. +- Security is **enabled by default** and dynamically loads `faintly.security.js` on first use. +- See `README.md` for examples, directives, expression syntax, and security configuration. ### Guardrails and constraints - Keep the bundle tiny; avoid adding runtime deps. @@ -80,5 +88,16 @@ Authoritative guide for AI/code agents contributing to this repository. - Respect ESM and `.js` extension import rule. - Do not introduce Node-only APIs into browser code paths. +### Security module (`src/faintly.security.js`) +- Provides default XSS protection: attribute sanitization, URL scheme validation, same-origin enforcement. +- Exported as a separate bundle (`dist/faintly.security.js`) for tree-shaking in opt-out scenarios. +- Dynamically imported by `directives.js` when `context.security` is undefined. +- Users can disable (`security: false`), provide custom hooks, or override default configuration. +- When modifying security: + - **Test thoroughly** - security bugs have serious consequences. + - Use TDD approach with comprehensive test coverage. + - Document changes in `README.md` security section. + - Consider backwards compatibility for existing users. + - Be conservative about what is allowed by default. From f58d2a16603a0f9a1b7cbf2558f7679b0a12ff83 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 11:00:06 -0700 Subject: [PATCH 21/23] Refactor security initialization to renderElement - Move security initialization to renderElement() - happens once per render - Add security check to resolveTemplate() - protects initial template fetch - Export initializeSecurity() for tests that call directives directly - Remove getSecurity() helper - no longer needed - Directives now use context.security directly (always initialized) - Clean separation: renderElement handles init, directives use it - Update all tests to initialize security when calling directives directly - Add test for template security blocking - All tests pass: 94 passed, 0 skipped - 100% coverage maintained - Bundle: 3430 bytes gzipped --- dist/faintly.js | 55 +++++++++-------- src/directives.js | 35 +---------- src/render.js | 33 ++++++++++ src/templates.js | 10 +++ .../attributes/processAttributes.test.js | 53 ++++++++++------ .../directives/include/processInclude.test.js | 61 +++++++++++++------ test/render/includeChildProcessing.test.js | 14 +++-- test/templates/resolveTemplate.test.js | 17 ++++++ 8 files changed, 179 insertions(+), 99 deletions(-) diff --git a/dist/faintly.js b/dist/faintly.js index 8444d33..07b32d6 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -3,6 +3,13 @@ var dp = new DOMParser(); async function resolveTemplate(context) { context.template = context.template || {}; context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`; + if (context.security && context.template.path) { + const allowed = context.security.allowIncludePath(context.template.path, context); + if (!allowed) { + console.warn(`Blocked template fetch outside allowed scope: ${new URL(context.template.path, window.location.origin).href}`); + throw new Error(`Template fetch blocked by security policy: ${context.template.path}`); + } + } const templateId = `faintly-template-${context.template.path}#${context.template.name || ""}`.toLowerCase().replace(/[^0-9a-z]/g, "-"); let template = document.getElementById(templateId); if (!template) { @@ -62,37 +69,17 @@ async function processTextExpressions(node, context) { } // src/directives.js -async function getSecurity(context) { - const { security } = context; - if (security === false || security === "unsafe") { - return { - shouldAllowAttribute: (() => true), - allowIncludePath: (() => true) - }; - } - if (!security) { - const securityMod = await import("./faintly.security.js"); - if (securityMod && securityMod.default) { - return securityMod.default(); - } - } - return { - shouldAllowAttribute: security.shouldAllowAttribute || (() => true), - allowIncludePath: security.allowIncludePath || (() => true) - }; -} async function processAttributesDirective(el, context) { if (!el.hasAttribute("data-fly-attributes")) return; const attrsExpression = el.getAttribute("data-fly-attributes"); const attrsData = await resolveExpression(attrsExpression, context); el.removeAttribute("data-fly-attributes"); if (attrsData) { - const sec = await getSecurity(context); Object.entries(attrsData).forEach(([k, v]) => { const name = String(k); if (v === void 0) { el.removeAttribute(name); - } else if (sec.shouldAllowAttribute(name, v, context)) { + } else if (context.security.shouldAllowAttribute(name, v, context)) { el.setAttribute(name, v); } else { el.removeAttribute(name); @@ -105,8 +92,7 @@ async function processAttributes(el, context) { const attrPromises = el.getAttributeNames().filter((attrName) => !attrName.startsWith("data-fly-")).map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); if (updated) { - const sec = await getSecurity(context); - if (!sec.shouldAllowAttribute(attrName, updatedText, context)) { + if (!context.security.shouldAllowAttribute(attrName, updatedText, context)) { el.removeAttribute(attrName); } else { el.setAttribute(attrName, updatedText); @@ -192,8 +178,7 @@ async function processInclude(el, context) { templateName = name; } if (templatePath) { - const sec = await getSecurity(context); - const allowed = sec.allowIncludePath(templatePath, context); + const allowed = context.security.allowIncludePath(templatePath, context); if (!allowed) { console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); el.removeAttribute("data-fly-include"); @@ -256,11 +241,31 @@ async function renderTemplate(template, context) { processUnwraps(templateClone.content); return templateClone; } +async function initializeSecurity(context) { + const { security } = context; + if (security === false || security === "unsafe") { + return { + shouldAllowAttribute: (() => true), + allowIncludePath: (() => true) + }; + } + if (!security) { + const securityMod = await import("./faintly.security.js"); + if (securityMod && securityMod.default) { + return securityMod.default(); + } + } + return { + shouldAllowAttribute: security.shouldAllowAttribute || (() => true), + allowIncludePath: security.allowIncludePath || (() => true) + }; +} async function renderElementWithTemplate(el, template, context) { const rendered = await renderTemplate(template, context); el.replaceChildren(rendered.content); } async function renderElement(el, context) { + context.security = await initializeSecurity(context); const template = await resolveTemplate(context); await renderElementWithTemplate(el, template, context); } diff --git a/src/directives.js b/src/directives.js index ddbe67d..d3c5c10 100644 --- a/src/directives.js +++ b/src/directives.js @@ -2,32 +2,6 @@ import { resolveExpression, resolveExpressions } from './expressions.js'; // eslint-disable-next-line import/no-cycle import { processNode, renderElement } from './render.js'; -async function getSecurity(context) { - const { security } = context; - - // unsafe mode - if (security === false || security === 'unsafe') { - return { - shouldAllowAttribute: (() => true), - allowIncludePath: (() => true), - }; - } - - // default mode - if (!security) { - const securityMod = await import('./faintly.security.js'); - if (securityMod && securityMod.default) { - return securityMod.default(); - } - } - - // custom mode, ensure needed functions are present, use no-ops for missing ones - return { - shouldAllowAttribute: security.shouldAllowAttribute || (() => true), - allowIncludePath: security.allowIncludePath || (() => true), - }; -} - async function processAttributesDirective(el, context) { if (!el.hasAttribute('data-fly-attributes')) return; @@ -36,12 +10,11 @@ async function processAttributesDirective(el, context) { el.removeAttribute('data-fly-attributes'); if (attrsData) { - const sec = await getSecurity(context); Object.entries(attrsData).forEach(([k, v]) => { const name = String(k); if (v === undefined) { el.removeAttribute(name); - } else if (sec.shouldAllowAttribute(name, v, context)) { + } else if (context.security.shouldAllowAttribute(name, v, context)) { el.setAttribute(name, v); } else { el.removeAttribute(name); @@ -64,8 +37,7 @@ export async function processAttributes(el, context) { .map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); if (updated) { - const sec = await getSecurity(context); - if (!sec.shouldAllowAttribute(attrName, updatedText, context)) { + if (!context.security.shouldAllowAttribute(attrName, updatedText, context)) { el.removeAttribute(attrName); } else { el.setAttribute(attrName, updatedText); @@ -209,8 +181,7 @@ export async function processInclude(el, context) { // Enforce include path restrictions: same-origin and within allowed base path if (templatePath) { - const sec = await getSecurity(context); - const allowed = sec.allowIncludePath(templatePath, context); + const allowed = context.security.allowIncludePath(templatePath, context); if (!allowed) { // eslint-disable-next-line no-console console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); diff --git a/src/render.js b/src/render.js index 5782946..de914ea 100644 --- a/src/render.js +++ b/src/render.js @@ -66,6 +66,37 @@ export async function renderTemplate(template, context) { return templateClone; } +/** + * Initialize security for the rendering context + * @param {Object} context the rendering context + * @returns {Promise} security hooks + */ +export async function initializeSecurity(context) { + const { security } = context; + + // unsafe mode + if (security === false || security === 'unsafe') { + return { + shouldAllowAttribute: (() => true), + allowIncludePath: (() => true), + }; + } + + // default mode + if (!security) { + const securityMod = await import('./faintly.security.js'); + if (securityMod && securityMod.default) { + return securityMod.default(); + } + } + + // custom mode, ensure needed functions are present, use no-ops for missing ones + return { + shouldAllowAttribute: security.shouldAllowAttribute || (() => true), + allowIncludePath: security.allowIncludePath || (() => true), + }; +} + /** * transform the element, replacing it's children with the content from the template * @param {Element} el the element @@ -84,6 +115,8 @@ export async function renderElementWithTemplate(el, template, context) { * @param {Object} context the rendering context */ export async function renderElement(el, context) { + context.security = await initializeSecurity(context); + const template = await resolveTemplate(context); await renderElementWithTemplate(el, template, context); diff --git a/src/templates.js b/src/templates.js index 62a6be0..5dacefa 100644 --- a/src/templates.js +++ b/src/templates.js @@ -10,6 +10,16 @@ export default async function resolveTemplate(context) { context.template = context.template || {}; context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`; + // Enforce template path security before fetching + if (context.security && context.template.path) { + const allowed = context.security.allowIncludePath(context.template.path, context); + if (!allowed) { + // eslint-disable-next-line no-console + console.warn(`Blocked template fetch outside allowed scope: ${new URL(context.template.path, window.location.origin).href}`); + throw new Error(`Template fetch blocked by security policy: ${context.template.path}`); + } + } + const templateId = `faintly-template-${context.template.path}#${context.template.name || ''}`.toLowerCase().replace(/[^0-9a-z]/g, '-'); let template = document.getElementById(templateId); if (!template) { diff --git a/test/directives/attributes/processAttributes.test.js b/test/directives/attributes/processAttributes.test.js index 7c48174..378cb5f 100644 --- a/test/directives/attributes/processAttributes.test.js +++ b/test/directives/attributes/processAttributes.test.js @@ -3,13 +3,16 @@ import { expect } from '@esm-bundle/chai'; import { processAttributes } from '../../../src/directives.js'; +import { initializeSecurity } from '../../../src/render.js'; describe('processAttributes', () => { it('resolves expressions in non data-fly-* attributes', async () => { const el = document.createElement('div'); + const context = { divClass: 'some-class' }; + context.security = await initializeSecurity(context); // eslint-disable-next-line no-template-curly-in-string el.setAttribute('class', '${ divClass }'); - await processAttributes(el, { divClass: 'some-class' }); + await processAttributes(el, context); expect(el.getAttribute('class')).to.equal('some-class'); }); @@ -17,13 +20,15 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('class', 'some-class'); el.setAttribute('data-fly-attributes', 'divAttrs'); - await processAttributes(el, { + const context = { divAttrs: { class: 'some-other-class', id: 'some-id', 'aria-label': 'some-label', }, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.getAttribute('class')).to.equal('some-other-class'); expect(el.getAttribute('id')).to.equal('some-id'); expect(el.getAttribute('aria-label')).to.equal('some-label'); @@ -33,18 +38,22 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('class', 'some-class'); el.setAttribute('data-fly-attributes', 'divAttrs'); - await processAttributes(el, { + const context = { divAttrs: { class: undefined, }, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.hasAttribute('class')).to.equal(false); }); it('removes attributes directive when complete', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-attributes', ''); - await processAttributes(el); + const context = {}; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.hasAttribute('data-fly-attributes')).to.equal(false); }); @@ -62,10 +71,12 @@ describe('processAttributes', () => { allowIncludePath: () => true, }; - await processAttributes(el, { + const context = { attrs: { allowed: 'value', blocked: 'value' }, security: customSecurity, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(shouldAllowCalled).to.equal(true); expect(el.getAttribute('allowed')).to.equal('value'); @@ -76,11 +87,13 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { + const context = { // eslint-disable-next-line no-script-url attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, security: false, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.getAttribute('onclick')).to.equal('alert(1)'); // eslint-disable-next-line no-script-url @@ -91,11 +104,13 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('data-fly-attributes', 'attrs'); - await processAttributes(el, { + const context = { // eslint-disable-next-line no-script-url attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, security: 'unsafe', - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.getAttribute('onclick')).to.equal('alert(1)'); // eslint-disable-next-line no-script-url @@ -106,10 +121,12 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('data-fly-attributes', 'attrs'); - // Default security should block event handlers - await processAttributes(el, { + const context = { attrs: { onclick: 'alert(1)', class: 'safe' }, - }); + }; + context.security = await initializeSecurity(context); + // Default security should block event handlers + await processAttributes(el, context); expect(el.hasAttribute('onclick')).to.equal(false); // Blocked by default security expect(el.getAttribute('class')).to.equal('safe'); // Safe attribute allowed @@ -125,10 +142,12 @@ describe('processAttributes', () => { allowIncludePath: () => true, }; - await processAttributes(el, { + const context = { link: 'blocked-value', security: customSecurity, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.hasAttribute('href')).to.equal(false); }); diff --git a/test/directives/include/processInclude.test.js b/test/directives/include/processInclude.test.js index a8490c9..9a6e426 100644 --- a/test/directives/include/processInclude.test.js +++ b/test/directives/include/processInclude.test.js @@ -2,24 +2,29 @@ /* eslint-disable no-unused-expressions */ import { expect } from '@esm-bundle/chai'; import { processInclude } from '../../../src/directives.js'; +import { initializeSecurity } from '../../../src/render.js'; import { compareDom } from '../../test-utils.js'; describe('processInclude', () => { it('returns false when the directive is absent', async () => { const el = document.createElement('div'); el.textContent = 'Some text'; - const result = await processInclude(el); + const context = {}; + context.security = await initializeSecurity(context); + const result = await processInclude(el, context); expect(result).to.equal(false); }); it('replaces elements from a template by name', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', 'static-alt'); - const result = await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + const result = await processInclude(el, context); expect(result).to.equal(true); await compareDom(el, 'templates/static-block-alt'); }); @@ -27,33 +32,39 @@ describe('processInclude', () => { it('replaces elements from a template by path', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); await compareDom(el, 'templates/static-block-custom-template'); }); it('replaces elements from a template by name and path', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html#custom-alt'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); await compareDom(el, 'templates/static-block-custom-named-template'); }); it('removes include directive when complete', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', ''); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); }); @@ -71,10 +82,12 @@ describe('processInclude', () => { }, }; - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, security: customSecurity, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(allowIncludePathCalled).to.equal(true); expect(el.hasAttribute('data-fly-include')).to.equal(false); @@ -89,10 +102,12 @@ describe('processInclude', () => { allowIncludePath: () => false, }; - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, security: customSecurity, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); expect(el.childNodes.length).to.equal(0); @@ -102,10 +117,12 @@ describe('processInclude', () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, security: false, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); expect(el.childNodes.length).to.be.greaterThan(0); @@ -115,10 +132,12 @@ describe('processInclude', () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, security: 'unsafe', - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); expect(el.childNodes.length).to.be.greaterThan(0); @@ -128,10 +147,12 @@ describe('processInclude', () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - // Default security allows all same-origin paths - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); + }; + context.security = await initializeSecurity(context); + // Default security allows all same-origin paths + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); expect(el.childNodes.length).to.be.greaterThan(0); diff --git a/test/render/includeChildProcessing.test.js b/test/render/includeChildProcessing.test.js index bf5a225..9cdefc7 100644 --- a/test/render/includeChildProcessing.test.js +++ b/test/render/includeChildProcessing.test.js @@ -2,7 +2,7 @@ /* eslint-disable no-unused-expressions, no-template-curly-in-string */ import { expect } from '@esm-bundle/chai'; -import { processNode } from '../../src/render.js'; +import { processNode, initializeSecurity } from '../../src/render.js'; describe('render/processNode include child processing', () => { it('does not reprocess included children expressions (escaped remains literal)', async () => { @@ -11,10 +11,12 @@ describe('render/processNode include child processing', () => { el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/escaped-expression.html#escaped'); wrapper.append(el); - await processNode(wrapper, { + const context = { shouldNotResolve: 'WILL_RESOLVE_IF_BUG_PRESENT', template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); + }; + context.security = await initializeSecurity(context); + await processNode(wrapper, context); const inner = wrapper.querySelector('.inner'); expect(inner).to.not.be.null; @@ -27,10 +29,12 @@ describe('render/processNode include child processing', () => { el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/resolvable-expression.html#resolvable'); wrapper.append(el); - await processNode(wrapper, { + const context = { shouldResolve: 'OK', template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); + }; + context.security = await initializeSecurity(context); + await processNode(wrapper, context); const inner = wrapper.querySelector('.inner'); expect(inner).to.not.be.null; diff --git a/test/templates/resolveTemplate.test.js b/test/templates/resolveTemplate.test.js index 941c36f..2c8f5f9 100644 --- a/test/templates/resolveTemplate.test.js +++ b/test/templates/resolveTemplate.test.js @@ -81,4 +81,21 @@ describe('resolveTemplates', () => { expect(e.message).to.be.a('string').and.matches(/^Failed to find template/); } }); + + it('throws an error if security blocks the template path', async () => { + try { + await resolveTemplate({ + template: { + path: '/blocked/path/template.html', + }, + security: { + shouldAllowAttribute: () => true, + allowIncludePath: () => false, + }, + }); + expect.fail('exception not thrown'); + } catch (e) { + expect(e.message).to.be.a('string').and.matches(/^Template fetch blocked by security policy/); + } + }); }); From 75985c36daedfdef69ca860de2e7dd48997a9233 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 11:02:11 -0700 Subject: [PATCH 22/23] Bump version to 1.1.0 for security feature release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6e2e477..4d16147 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "faintly", - "version": "1.0.1", + "version": "1.1.0", "description": "HTML Markup Transformation Library for AEM Blocks", "main": "src/index.js", "scripts": { From e96b27266ba127ba02e9b8cff5a38222423f12d0 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 29 Oct 2025 11:12:07 -0700 Subject: [PATCH 23/23] Address PR feedback: improve consistency - Change negative check to positive check in processAttributes for consistency - Remove console.warn calls for silent security violations - Security failures now consistently silent (attributes removed, includes blocked) - Still throws error for template fetch blocking (prevents app from continuing) --- dist/faintly.js | 8 +++----- src/directives.js | 8 +++----- src/templates.js | 2 -- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/dist/faintly.js b/dist/faintly.js index 07b32d6..0090cc6 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -6,7 +6,6 @@ async function resolveTemplate(context) { if (context.security && context.template.path) { const allowed = context.security.allowIncludePath(context.template.path, context); if (!allowed) { - console.warn(`Blocked template fetch outside allowed scope: ${new URL(context.template.path, window.location.origin).href}`); throw new Error(`Template fetch blocked by security policy: ${context.template.path}`); } } @@ -92,10 +91,10 @@ async function processAttributes(el, context) { const attrPromises = el.getAttributeNames().filter((attrName) => !attrName.startsWith("data-fly-")).map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); if (updated) { - if (!context.security.shouldAllowAttribute(attrName, updatedText, context)) { - el.removeAttribute(attrName); - } else { + if (context.security.shouldAllowAttribute(attrName, updatedText, context)) { el.setAttribute(attrName, updatedText); + } else { + el.removeAttribute(attrName); } } }); @@ -180,7 +179,6 @@ async function processInclude(el, context) { if (templatePath) { const allowed = context.security.allowIncludePath(templatePath, context); if (!allowed) { - console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); el.removeAttribute("data-fly-include"); return true; } diff --git a/src/directives.js b/src/directives.js index d3c5c10..f6a5ca4 100644 --- a/src/directives.js +++ b/src/directives.js @@ -37,10 +37,10 @@ export async function processAttributes(el, context) { .map(async (attrName) => { const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context); if (updated) { - if (!context.security.shouldAllowAttribute(attrName, updatedText, context)) { - el.removeAttribute(attrName); - } else { + if (context.security.shouldAllowAttribute(attrName, updatedText, context)) { el.setAttribute(attrName, updatedText); + } else { + el.removeAttribute(attrName); } } }); @@ -183,8 +183,6 @@ export async function processInclude(el, context) { if (templatePath) { const allowed = context.security.allowIncludePath(templatePath, context); if (!allowed) { - // eslint-disable-next-line no-console - console.warn(`Blocked include outside allowed scope: ${new URL(templatePath, window.location.origin).href}`); el.removeAttribute('data-fly-include'); return true; } diff --git a/src/templates.js b/src/templates.js index 5dacefa..672480c 100644 --- a/src/templates.js +++ b/src/templates.js @@ -14,8 +14,6 @@ export default async function resolveTemplate(context) { if (context.security && context.template.path) { const allowed = context.security.allowIncludePath(context.template.path, context); if (!allowed) { - // eslint-disable-next-line no-console - console.warn(`Blocked template fetch outside allowed scope: ${new URL(context.template.path, window.location.origin).href}`); throw new Error(`Template fetch blocked by security policy: ${context.template.path}`); } }