@meddleware/frontend-config 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -2
- package/SECURITY.md +30 -0
- package/dist/a11y.d.ts +19 -6
- package/dist/audit-gate.d.ts +2 -0
- package/dist/bundle.d.ts +21 -0
- package/dist/check-csp-inline.d.ts +2 -0
- package/dist/check-prod-bundle.d.ts +2 -0
- package/dist/csp.d.ts +46 -0
- package/dist/sync.d.ts +13 -0
- package/dist/third-party-licenses.d.ts +2 -0
- package/dist/vite.d.ts +25 -0
- package/dist/vitest.d.ts +20 -0
- package/package.json +23 -4
- package/src/a11y.js +144 -38
- package/src/audit-gate.js +67 -0
- package/src/bundle.js +57 -0
- package/src/check-csp-inline.js +68 -0
- package/src/check-prod-bundle.js +45 -0
- package/src/csp.js +67 -0
- package/src/sync.js +80 -0
- package/src/third-party-licenses.js +77 -0
- package/src/vite.js +31 -0
- package/src/vitest.js +29 -0
- package/templates/audit-allowlist.lhci.json +12 -0
- package/templates/dependabot.app.yml +32 -0
- package/templates/dockerignore +13 -0
- package/templates/postcss.config.js +1 -0
- package/templates/stylelint.config.js +3 -0
- package/tsconfig/vue-app.json +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.0.3] - 2026-11-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `mw-audit-gate`, `mw-third-party-licenses` and `mw-check-csp-inline`: the dependency-audit gate, the licence bundle and the inline-script
|
|
13
|
+
CSP check that every app repository carried as its own copy of a script.
|
|
14
|
+
- `/csp`: `buildCsp`, `cspForProfile` and six named profiles that reproduce, byte for byte, the policies the app images served.
|
|
15
|
+
- `meddlewareApp` (`/vite`), `vueTestConfig` (`/vitest`) and `tsconfig/vue-app.json`: the Vite, Vitest and TypeScript configuration that eleven
|
|
16
|
+
apps each repeated.
|
|
17
|
+
- `mw-sync --check|--write` and `templates/`: the files that cannot be shared by reference stay identical across repositories, and the
|
|
18
|
+
Dockerfile's `ARG CSP` is held equal to the repository's profile.
|
|
19
|
+
|
|
20
|
+
## [0.0.2] - 2026-11-01
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `check-prod-bundle` (and `scanBundle` at `/bundle`): the production-artefact scan for the e2e harness, moved here from token-issuance-ui so
|
|
25
|
+
every wallet app runs the same one (the mock-wallet hooks; `--also` / `--also-regex` add an app's own stand-in marker).
|
|
26
|
+
|
|
27
|
+
- `expectKeyboardFocusVisible` (and so `defineA11ySuite`) now also fails a stop that is entirely hidden behind sticky or fixed content when it
|
|
28
|
+
takes focus (WCAG 2.4.11 focus not obscured; axe has no rule for it), going forward and then backwards through the tab order; the message names the covering element, and the pointer is parked first so a stale hover tooltip cannot cover the walk; each stop is judged after the scroll has settled (WebKit animates the scroll that focus causes).
|
|
29
|
+
|
|
30
|
+
- `defineA11ySuite` takes `noKeyboard: [state names]` for states with nothing to operate (they skip the keyboard pass; every other state must
|
|
31
|
+
have a stop). `expectKeyboardFocusVisible` no longer reports "no keyboard stops" for a page reached with focus on its last control.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- The keyboard walk follows element identity (a repeated link label no longer ends it early), stops at 300 stops and fails when it is capped (`maxStops` raises the cap); its test limit scales with the cap (WebKit needs about half a minute for a page of a hundred stops).
|
|
36
|
+
- `SECURITY.md` is part of the published files.
|
|
37
|
+
|
|
8
38
|
## [0.0.1] - 2026-10-31
|
|
9
39
|
|
|
10
40
|
### Added
|
package/README.md
CHANGED
|
@@ -10,14 +10,22 @@ same accessibility and browser-support bar.
|
|
|
10
10
|
| `/stylelint` | `stylelintConfig({ tokens?, compat?, browsers?, rules? })` — standard config, no hex, token-only colour and `z-index`, `@double-great/stylelint-a11y`, `stylelint-no-unsupported-browser-features`. |
|
|
11
11
|
| `/html-validate` | `htmlValidateConfig({ elements?, rules? })` — recommended + `html-validate:a11y` + Vue, with `@meddleware/ui` components described as the native element they render. |
|
|
12
12
|
| `/postcss` | Autoprefixer for the repo's browserslist. |
|
|
13
|
-
| `/vite` | `viteTarget()` — Vite `build.target` derived from the browserslist; `themeInit({ default })` — a Vite plugin that loads ui's `theme-init.js` first in `<head>` (the saved or default colour mode and the season applied before first paint, no inline script, no CSP hash) and adds `color-scheme` and the browser-chrome `theme-color` from the tokens. |
|
|
13
|
+
| `/vite` | `viteTarget()` — Vite `build.target` derived from the browserslist; `meddlewareApp({ plugins, theme?, wallet?, config? })` — the whole Vite configuration of a tool app (target, theme-init, e2e guard, wallet-adapter kept out of pre-bundling); `themeInit({ default })` — a Vite plugin that loads ui's `theme-init.js` first in `<head>` (the saved or default colour mode and the season applied before first paint, no inline script, no CSP hash) and adds `color-scheme` and the browser-chrome `theme-color` from the tokens. |
|
|
14
14
|
| `/playwright` | `definePlaywrightConfig({ port, kind?, command?, browsers?, extra? })`. |
|
|
15
15
|
| `/axe` | `expectAccessible(page, options)` — WCAG 2.2 AA gate; AAA as `aaa: 'gate'`, `'report'` or `'off'`. |
|
|
16
16
|
| `/look` | `looks()`, `applyLook(page, theme, season)`, `offline(page)`, `VIEWPORTS`, `THEMES`, `SEASONS` — the viewport × colour mode × season matrix. |
|
|
17
17
|
| `/shots` | `defineShots(states)` — one screenshot per state × look, for looking at the UI. |
|
|
18
18
|
| `/a11y` | `defineA11ySuite(states, { aaa })` — the sweep every app runs, one call: each state in every colour mode × season at WCAG 2.2 AA and AAA, a 320 px reflow, 200% text size, forced colours, reduced motion and a keyboard pass. `expectNoHorizontalScroll`, `expectNoRunningAnimations` and `expectKeyboardFocusVisible` are exported on their own. |
|
|
19
|
-
| `/vitest` | `
|
|
19
|
+
| `/vitest` | `vueTestConfig({ plugins, test? })` — the whole Vitest configuration of a Vue repository (jsdom, `tests/**/*.test.ts`, one copy of Vue, the UI packages processed through Vite); `inlineMeddleware` — those packages on their own; `installDialogShim()` — jsdom's missing `<dialog>` modality. |
|
|
20
|
+
| `/bundle` + `check-prod-bundle` | `scanBundle(dist, { also })` and the `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` command: fails when the e2e harness (the mock-wallet hooks, or an app's own stand-in marker) is found in a production build. Run it after the production build in CI and in the Dockerfile. |
|
|
21
|
+
| `/wallet` | `connectMockWallet(page)`, `exposeSigner(page, signer)`, `E2E_ADDRESS` — the Playwright side of the mock wallet. |
|
|
20
22
|
| `/lighthouserc` | `lighthouseConfig({ port, paths?, … })` — Lighthouse CI (accessibility must score 100). |
|
|
23
|
+
| `/tsconfig/vue-app.json` | The compiler options of a Vue app (`"extends": "@meddleware/frontend-config/tsconfig/vue-app.json"`); the repository keeps only its `include`. |
|
|
24
|
+
| `/csp` | `buildCsp(options)`, `cspForProfile(name, current?)`, `PROFILES`, `scriptHashesOf(csp)` — the Content-Security-Policy every app image serves, as six named profiles (`static`, `static-strict`, `console`, `chain`, `chain-wasm`, `docs`), so the policy is written once. |
|
|
25
|
+
| `mw-sync` | `mw-sync --check` / `--write` in a repository root: the files every frontend repository copies (dependabot.yml, .dockerignore, postcss/stylelint configs, the Lighthouse allowlist) equal this package's `templates/`, and the Dockerfile's `ARG CSP` equals its profile. Declared in package.json: `"meddleware": { "kind": "app", "csp": "chain-wasm", "lhci": true }`. |
|
|
26
|
+
| `mw-audit-gate` | CI dependency gate: `npm audit` at high/critical minus the dated, reasoned entries of `.github/audit-allowlist.json` (an entry with no reason or an expired date fails). |
|
|
27
|
+
| `mw-third-party-licenses` | Writes `dist/THIRD_PARTY_LICENSES` from the lockfile's production packages and their installed licence texts; `--check` fails when none can be written. |
|
|
28
|
+
| `mw-check-csp-inline` | `mw-check-csp-inline <policy or @Dockerfile> [dist]`: every inline script in the built pages is allowed by a `sha256` source in the policy, and the policy never allows `'unsafe-inline'` scripts. |
|
|
21
29
|
|
|
22
30
|
## Use
|
|
23
31
|
|
|
@@ -108,6 +116,14 @@ await connectMockWallet(page) // the app's wallet dialog → "Test Wallet"
|
|
|
108
116
|
|
|
109
117
|
The page side is `@meddleware/wallet-adapter/testing` (`installE2eWallet`), registered by an app's e2e build.
|
|
110
118
|
|
|
119
|
+
## Keeping the e2e harness out of production
|
|
120
|
+
|
|
121
|
+
An app's end-to-end build (`vite build --mode e2e`) registers a mock wallet and a stub chain. Three things keep that out of every
|
|
122
|
+
other build: the app's entry loads it only when `import.meta.env.MODE === 'e2e' && import.meta.env.VITE_E2E === '1'` (both static, so
|
|
123
|
+
the block is removed); `assertE2eOnlyInE2eMode(mode)` from `/vite` fails a build with `VITE_E2E` set in any other mode; and
|
|
124
|
+
`check-prod-bundle dist` (`/bundle`) scans the built files for the harness's hooks and fails if any is present. An app whose e2e build also
|
|
125
|
+
swaps a module for a stand-in adds a marker of that stand-in: `check-prod-bundle dist --also 'a string only the stand-in contains'` (`--also-regex` takes a pattern).
|
|
126
|
+
|
|
111
127
|
## Peers
|
|
112
128
|
|
|
113
129
|
`eslint` ^10 (required for `/eslint`), and optional peers per subpath: `stylelint` ^17, `html-validate` ^11, `postcss` ^8,
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
This policy covers `@meddleware/frontend-config`: lint, build and test configuration for MeddleWare frontends. It runs only in a
|
|
6
|
+
consumer's development and CI processes (ESLint, Stylelint, html-validate, PostCSS, Vite, Playwright, Lighthouse); it ships nothing
|
|
7
|
+
to end users and makes no network requests of its own.
|
|
8
|
+
|
|
9
|
+
It does not cover a consuming application, or the tools and plugins it configures.
|
|
10
|
+
|
|
11
|
+
## What it guarantees
|
|
12
|
+
|
|
13
|
+
The rules are a **guard rail**: they catch accidents and shortcuts, not deliberate evasion (a `stylelint-disable` or
|
|
14
|
+
`eslint-disable` comment defeats them). Accessibility checks are automated and cover only what axe and the linters can detect;
|
|
15
|
+
they are not a substitute for manual testing with assistive technology.
|
|
16
|
+
|
|
17
|
+
## Supply chain
|
|
18
|
+
|
|
19
|
+
Releases are published from this repository's `npm-publish.yml` with npm trusted publishing (OIDC and provenance); tokens are not
|
|
20
|
+
used. A compromised release could weaken the lint, accessibility and browser-support gates in every consumer, or run code in their
|
|
21
|
+
build, so report anything that suggests tampering.
|
|
22
|
+
|
|
23
|
+
## Supported versions
|
|
24
|
+
|
|
25
|
+
Only the latest published npm version receives fixes.
|
|
26
|
+
|
|
27
|
+
## Reporting a vulnerability
|
|
28
|
+
|
|
29
|
+
Please **do not** open a public GitHub issue for security vulnerabilities. Use GitHub's private vulnerability reporting for this
|
|
30
|
+
repository, or email dev@meddleware.co.uk.
|
package/dist/a11y.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** @typedef {(page: import('@playwright/test').Page) => Promise<void>}
|
|
1
|
+
/** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
|
|
2
|
+
/** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
|
|
2
3
|
/**
|
|
3
4
|
* The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
|
|
4
5
|
* @param {import('@playwright/test').Page} page
|
|
@@ -10,7 +11,9 @@ export function expectNoHorizontalScroll(page: import("@playwright/test").Page):
|
|
|
10
11
|
*/
|
|
11
12
|
export function expectNoRunningAnimations(page: import("@playwright/test").Page): Promise<void>;
|
|
12
13
|
/**
|
|
13
|
-
* Tab through the page: every stop is visible
|
|
14
|
+
* Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
|
|
15
|
+
* content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
|
|
16
|
+
* scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
|
|
14
17
|
* @param {import('@playwright/test').Page} page
|
|
15
18
|
* @param {{ maxStops?: number }} [options]
|
|
16
19
|
*/
|
|
@@ -19,10 +22,20 @@ export function expectKeyboardFocusVisible(page: import("@playwright/test").Page
|
|
|
19
22
|
}): Promise<void>;
|
|
20
23
|
/**
|
|
21
24
|
* @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
|
|
22
|
-
* @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo
|
|
23
|
-
* in the release that clears them)
|
|
25
|
+
* @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
|
|
26
|
+
* moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
|
|
27
|
+
* only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
|
|
24
28
|
*/
|
|
25
|
-
export function defineA11ySuite(states: Record<string, State>, { aaa }?: {
|
|
29
|
+
export function defineA11ySuite(states: Record<string, State>, { aaa, noKeyboard, maxStops }?: {
|
|
26
30
|
aaa?: "gate" | "report" | "off";
|
|
31
|
+
noKeyboard?: string[];
|
|
32
|
+
maxStops?: number;
|
|
27
33
|
}): void;
|
|
28
|
-
export type
|
|
34
|
+
export type Open = (page: import("@playwright/test").Page) => Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks).
|
|
37
|
+
*/
|
|
38
|
+
export type State = Open | {
|
|
39
|
+
open: Open;
|
|
40
|
+
disableRules?: string[];
|
|
41
|
+
};
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan the built files under `dist` for the harness.
|
|
3
|
+
* @param {string} dist The build output directory.
|
|
4
|
+
* @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
|
|
5
|
+
* @returns {{ scanned: number, hits: string[] }}
|
|
6
|
+
* @throws When `dist` cannot be read.
|
|
7
|
+
*/
|
|
8
|
+
export function scanBundle(dist: string, { also }?: {
|
|
9
|
+
also?: (string | RegExp)[];
|
|
10
|
+
}): {
|
|
11
|
+
scanned: number;
|
|
12
|
+
hits: string[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
|
|
16
|
+
* bundle that includes it: the hooks it puts on `window`. Property names survive minification.
|
|
17
|
+
*/
|
|
18
|
+
export const E2E_MARKERS: readonly {
|
|
19
|
+
name: string;
|
|
20
|
+
re: RegExp;
|
|
21
|
+
}[];
|
package/dist/csp.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {CspOptions} options
|
|
3
|
+
* @returns {string}
|
|
4
|
+
*/
|
|
5
|
+
export function buildCsp(options?: CspOptions): string;
|
|
6
|
+
/** The `'sha256-…'` hashes in a policy's script-src (kept when a profile such as `docs` is rebuilt). */
|
|
7
|
+
export function scriptHashesOf(csp: string): string[];
|
|
8
|
+
/** @param {string} name @param {string} [current] the policy now in the Dockerfile (its script hashes are kept) */
|
|
9
|
+
export function cspForProfile(name: string, current?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} CspOptions
|
|
12
|
+
* @property {boolean} [wasm] allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
|
|
13
|
+
* @property {boolean} [chain] chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
|
|
14
|
+
* @property {boolean} [worker] allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
|
|
15
|
+
* @property {boolean} [strict] `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
|
|
16
|
+
* @property {'self' | 'none'} [frameAncestors] who may frame the page (default `'self'`)
|
|
17
|
+
* @property {string[]} [scriptHashes] `'sha256-…'` sources for the inline scripts a static-site generator emits
|
|
18
|
+
*/
|
|
19
|
+
/** @type {Record<string, CspOptions>} */
|
|
20
|
+
export const PROFILES: Record<string, CspOptions>;
|
|
21
|
+
export type CspOptions = {
|
|
22
|
+
/**
|
|
23
|
+
* allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
|
|
24
|
+
*/
|
|
25
|
+
wasm?: boolean | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
|
|
28
|
+
*/
|
|
29
|
+
chain?: boolean | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
|
|
32
|
+
*/
|
|
33
|
+
worker?: boolean | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
|
|
36
|
+
*/
|
|
37
|
+
strict?: boolean | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* who may frame the page (default `'self'`)
|
|
40
|
+
*/
|
|
41
|
+
frameAncestors?: "none" | "self" | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* `'sha256-…'` sources for the inline scripts a static-site generator emits
|
|
44
|
+
*/
|
|
45
|
+
scriptHashes?: string[] | undefined;
|
|
46
|
+
};
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @param {string} root repository root
|
|
4
|
+
* @param {{ write?: boolean }} [options]
|
|
5
|
+
* @returns {{ file: string, ok: boolean, note: string }[]}
|
|
6
|
+
*/
|
|
7
|
+
export function sync(root: string, options?: {
|
|
8
|
+
write?: boolean;
|
|
9
|
+
}): {
|
|
10
|
+
file: string;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
note: string;
|
|
13
|
+
}[];
|
package/dist/vite.d.ts
CHANGED
|
@@ -41,3 +41,28 @@ export function themeInit({ default: mode, scriptPath, colors }?: {
|
|
|
41
41
|
* @param {Record<string, string | undefined>} env The environment (default: the process's).
|
|
42
42
|
*/
|
|
43
43
|
export function assertE2eOnlyInE2eMode(mode: string, env?: Record<string, string | undefined>): void;
|
|
44
|
+
/**
|
|
45
|
+
* The Vite configuration every MeddleWare tool app shares: the browserslist build target, the colour mode and season before first paint
|
|
46
|
+
* ({@link themeInit}), the e2e-build guard ({@link assertE2eOnlyInE2eMode}), and `@meddleware/wallet-adapter` excluded from dependency
|
|
47
|
+
* pre-bundling. The wallet adapter ships TS and `.vue` source and holds the shared wallet singleton; pre-bundling would put its TS in
|
|
48
|
+
* `.vite/deps` while its `.vue` files (which esbuild cannot bundle) are served raw and import the raw module, so dev would run two
|
|
49
|
+
* singletons and the guard and the app would see different connection state.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* import vue from '@vitejs/plugin-vue'
|
|
53
|
+
* export default meddlewareApp({ plugins: [vue()] })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {import('vite').PluginOption[]} options.plugins The app's plugins (`vue()` …); `themeInit` is added after them.
|
|
58
|
+
* @param {'light' | 'dark' | 'system'} [options.theme] The mode a first visit gets (default `dark`, the brand default).
|
|
59
|
+
* @param {boolean} [options.wallet] The app uses `@meddleware/wallet-adapter` (default true): keep it out of pre-bundling.
|
|
60
|
+
* @param {import('vite').UserConfig} [options.config] Anything else (`base`, `build.rollupOptions`, `server` …); `build` is merged.
|
|
61
|
+
* @returns {import('vite').UserConfigFnObject}
|
|
62
|
+
*/
|
|
63
|
+
export function meddlewareApp({ plugins, theme, wallet, config }: {
|
|
64
|
+
plugins: import("vite").PluginOption[];
|
|
65
|
+
theme?: "light" | "dark" | "system" | undefined;
|
|
66
|
+
wallet?: boolean | undefined;
|
|
67
|
+
config?: import("vite").UserConfig | undefined;
|
|
68
|
+
}): import("vite").UserConfigFnObject;
|
package/dist/vitest.d.ts
CHANGED
|
@@ -4,6 +4,26 @@
|
|
|
4
4
|
* renders a dialog; the browser's real behaviour (top layer, inert background) is checked by the Playwright suites, not jsdom.
|
|
5
5
|
*/
|
|
6
6
|
export function installDialogShim(): void;
|
|
7
|
+
/**
|
|
8
|
+
* The Vitest configuration of a MeddleWare Vue repository: jsdom, `tests/**\/*.test.ts`, a single copy of Vue for the code under test and
|
|
9
|
+
* `@vue/test-utils`, and the UI packages processed through Vite ({@link inlineMeddleware}).
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import vue from '@vitejs/plugin-vue'
|
|
13
|
+
* export default defineConfig(vueTestConfig({ plugins: [vue()] }))
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {import('vite').PluginOption[]} options.plugins The Vue plugin (and any other the tests need).
|
|
18
|
+
* @param {import('vitest/config').TestUserConfig} [options.test] Overrides merged into `test` (`include`, `setupFiles`, `environment` …).
|
|
19
|
+
* @returns {import('vitest/config').ViteUserConfig & { test: import('vitest/config').TestUserConfig }}
|
|
20
|
+
*/
|
|
21
|
+
export function vueTestConfig({ plugins, test }: {
|
|
22
|
+
plugins: import("vite").PluginOption[];
|
|
23
|
+
test?: import("vitest/node").TestUserConfig | undefined;
|
|
24
|
+
}): import("vitest/config").ViteUserConfig & {
|
|
25
|
+
test: import("vitest/config").TestUserConfig;
|
|
26
|
+
};
|
|
7
27
|
/**
|
|
8
28
|
* Packages to process through Vite in tests: the ones that import CSS or `.vue` files. Vitest matches these against the module's resolved
|
|
9
29
|
* file path (`…/node_modules/@meddleware/ui/dist/…`), not the package name, so the patterns match the path.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/frontend-config",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared frontend tooling for MeddleWare apps: browser support, ESLint, Stylelint, html-validate, PostCSS, Playwright, axe, screenshots, Lighthouse and the Vite target.",
|
|
6
6
|
"license": "0BSD",
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"dist",
|
|
25
|
-
"CHANGELOG.md"
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"SECURITY.md",
|
|
27
|
+
"templates",
|
|
28
|
+
"tsconfig"
|
|
26
29
|
],
|
|
27
30
|
"exports": {
|
|
28
31
|
".": {
|
|
@@ -81,7 +84,16 @@
|
|
|
81
84
|
"./wallet": {
|
|
82
85
|
"types": "./dist/wallet.d.ts",
|
|
83
86
|
"default": "./src/wallet.js"
|
|
84
|
-
}
|
|
87
|
+
},
|
|
88
|
+
"./bundle": {
|
|
89
|
+
"types": "./dist/bundle.d.ts",
|
|
90
|
+
"default": "./src/bundle.js"
|
|
91
|
+
},
|
|
92
|
+
"./csp": {
|
|
93
|
+
"types": "./dist/csp.d.ts",
|
|
94
|
+
"default": "./src/csp.js"
|
|
95
|
+
},
|
|
96
|
+
"./tsconfig/vue-app.json": "./tsconfig/vue-app.json"
|
|
85
97
|
},
|
|
86
98
|
"scripts": {
|
|
87
99
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
@@ -157,5 +169,12 @@
|
|
|
157
169
|
"last 2 Samsung versions",
|
|
158
170
|
"Firefox ESR",
|
|
159
171
|
"not dead"
|
|
160
|
-
]
|
|
172
|
+
],
|
|
173
|
+
"bin": {
|
|
174
|
+
"check-prod-bundle": "./src/check-prod-bundle.js",
|
|
175
|
+
"mw-audit-gate": "./src/audit-gate.js",
|
|
176
|
+
"mw-third-party-licenses": "./src/third-party-licenses.js",
|
|
177
|
+
"mw-check-csp-inline": "./src/check-csp-inline.js",
|
|
178
|
+
"mw-sync": "./src/sync.js"
|
|
179
|
+
}
|
|
161
180
|
}
|
package/src/a11y.js
CHANGED
|
@@ -9,7 +9,8 @@ import { expect, test } from '@playwright/test'
|
|
|
9
9
|
import { expectAccessible } from './axe.js'
|
|
10
10
|
import { applyLook, looks, offline } from './look.js'
|
|
11
11
|
|
|
12
|
-
/** @typedef {(page: import('@playwright/test').Page) => Promise<void>}
|
|
12
|
+
/** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
|
|
13
|
+
/** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
|
|
@@ -31,54 +32,154 @@ export async function expectNoRunningAnimations(page) {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
+
* What the focused element looks like: visible, shows an indicator, and is not hidden behind other content. Runs in the page.
|
|
36
|
+
* @param {import('@playwright/test').Page} page
|
|
37
|
+
*/
|
|
38
|
+
async function probeFocus(page) {
|
|
39
|
+
// A browser may animate the scroll that brings the focused control into view (WebKit does, and `scroll-behavior: smooth` makes the
|
|
40
|
+
// others): judge where it comes to rest, not where it passes through.
|
|
41
|
+
await page.evaluate(
|
|
42
|
+
() =>
|
|
43
|
+
new Promise((done) => {
|
|
44
|
+
let last = [scrollX, scrollY]
|
|
45
|
+
let still = 0
|
|
46
|
+
let frames = 0
|
|
47
|
+
const tick = () => {
|
|
48
|
+
const now = [scrollX, scrollY]
|
|
49
|
+
if (now[0] === last[0] && now[1] === last[1]) still++
|
|
50
|
+
else {
|
|
51
|
+
still = 0
|
|
52
|
+
last = now
|
|
53
|
+
}
|
|
54
|
+
if (still >= 3 || ++frames > 60) done(undefined)
|
|
55
|
+
else requestAnimationFrame(tick)
|
|
56
|
+
}
|
|
57
|
+
requestAnimationFrame(tick)
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
return page.evaluate(() => {
|
|
61
|
+
const el = document.activeElement
|
|
62
|
+
// Once Tab has left the last control, Firefox hands focus to the browser's own chrome: the document no longer has focus, yet
|
|
63
|
+
// `activeElement` still names the control it left. That is the end of the tab order, not a stop.
|
|
64
|
+
if (!el || el === document.body || !document.hasFocus()) return null
|
|
65
|
+
// A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
|
|
66
|
+
const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
|
|
67
|
+
const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
|
|
68
|
+
const box = target.getBoundingClientRect()
|
|
69
|
+
const shows = (/** @type {Element} */ node) => {
|
|
70
|
+
const style = getComputedStyle(node)
|
|
71
|
+
return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
|
|
72
|
+
}
|
|
73
|
+
const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
|
|
74
|
+
const s = getComputedStyle(target, p)
|
|
75
|
+
return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
|
|
76
|
+
})
|
|
77
|
+
// WCAG 2.4.11 (focus not obscured): once focus has scrolled the stop into view, it is not entirely hidden behind sticky or fixed
|
|
78
|
+
// content. Five points are sampled (the centre and four inset corners of the part inside the viewport); hidden means all are covered.
|
|
79
|
+
const x0 = Math.max(box.left, 0)
|
|
80
|
+
const x1 = Math.min(box.right, innerWidth)
|
|
81
|
+
const y0 = Math.max(box.top, 0)
|
|
82
|
+
const y1 = Math.min(box.bottom, innerHeight)
|
|
83
|
+
const inView = x1 > x0 && y1 > y0
|
|
84
|
+
const samples = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]]
|
|
85
|
+
/** @type {Element | null} */
|
|
86
|
+
let cover = null
|
|
87
|
+
const obscured = inView && samples.every(([fx, fy]) => {
|
|
88
|
+
const top = document.elementFromPoint(x0 + (x1 - x0) * /** @type {number} */ (fx), y0 + (y1 - y0) * /** @type {number} */ (fy))
|
|
89
|
+
cover = top
|
|
90
|
+
return !!top && !target.contains(top) && !top.contains(target)
|
|
91
|
+
})
|
|
92
|
+
const coveredBy = obscured && cover ? `${/** @type {Element} */ (cover).tagName.toLowerCase()}${/** @type {Element} */ (cover).id ? `#${/** @type {Element} */ (cover).id}` : ''}${/** @type {Element} */ (cover).className && typeof (/** @type {Element} */ (cover).className) === 'string' ? `.${String(/** @type {Element} */ (cover).className).trim().split(/\s+/).slice(0, 2).join('.')}` : ''}` : ''
|
|
93
|
+
// The walk is complete when focus comes back to an element it has already stopped on: elements are compared by identity (two links can
|
|
94
|
+
// share a label, as the same link often appears in a menu and in a sidebar), through a set the walk resets at its start.
|
|
95
|
+
const seen = /** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen
|
|
96
|
+
const repeat = !!seen && seen.has(el)
|
|
97
|
+
seen?.add(el)
|
|
98
|
+
const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
|
|
99
|
+
return { label, visible: box.width > 0 && box.height > 0, indicator, obscured, coveredBy, repeat }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Put the keyboard at the start of the document: nothing focused, the pointer out of the way, and the point sequential navigation continues
|
|
105
|
+
* from at the top (a button that was just clicked is where the next Tab would otherwise start).
|
|
106
|
+
* @param {import('@playwright/test').Page} page
|
|
107
|
+
*/
|
|
108
|
+
async function startAtTop(page) {
|
|
109
|
+
// The pointer where an earlier click left it would keep a hover tooltip open over the controls the keyboard walk is about to reach.
|
|
110
|
+
await page.mouse.move(0, 0)
|
|
111
|
+
await page.evaluate(() => {
|
|
112
|
+
;/** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen = new WeakSet()
|
|
113
|
+
const active = document.activeElement
|
|
114
|
+
if (active instanceof HTMLElement && active !== document.body) active.blur()
|
|
115
|
+
const selection = getSelection()
|
|
116
|
+
if (!selection) return
|
|
117
|
+
selection.removeAllRanges()
|
|
118
|
+
const range = document.createRange()
|
|
119
|
+
range.setStart(document.body, 0)
|
|
120
|
+
range.collapse(true)
|
|
121
|
+
selection.addRange(range)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
|
|
127
|
+
* content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
|
|
128
|
+
* scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
|
|
35
129
|
* @param {import('@playwright/test').Page} page
|
|
36
130
|
* @param {{ maxStops?: number }} [options]
|
|
37
131
|
*/
|
|
38
|
-
export async function expectKeyboardFocusVisible(page, { maxStops =
|
|
132
|
+
export async function expectKeyboardFocusVisible(page, { maxStops = 300 } = {}) {
|
|
39
133
|
/** @type {string[]} */
|
|
40
134
|
const problems = []
|
|
41
135
|
let stops = 0
|
|
42
|
-
|
|
136
|
+
let wrapped = false
|
|
137
|
+
let finished = false
|
|
138
|
+
await startAtTop(page)
|
|
43
139
|
for (let i = 0; i < maxStops; i++) {
|
|
44
140
|
await page.keyboard.press('Tab')
|
|
45
|
-
const stop = await page
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const shows = (/** @type {Element} */ node) => {
|
|
53
|
-
const style = getComputedStyle(node)
|
|
54
|
-
return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
|
|
141
|
+
const stop = await probeFocus(page)
|
|
142
|
+
if (!stop) {
|
|
143
|
+
// Focus left the document: the cycle is complete. But a page that is reached with focus already on its last control (a button just
|
|
144
|
+
// clicked) leaves on the first Tab, before any stop was seen: that press only wraps round to the start.
|
|
145
|
+
if (stops === 0 && !wrapped) {
|
|
146
|
+
wrapped = true
|
|
147
|
+
continue
|
|
55
148
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
if (!stop) break // focus left the document: the cycle is complete
|
|
64
|
-
const key = stop.label
|
|
65
|
-
if (seen.has(key) && seen.size > 1 && i > seen.size + 1) break
|
|
66
|
-
seen.add(key)
|
|
149
|
+
finished = true
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
if (stop.repeat) {
|
|
153
|
+
finished = true // back at a stop already seen: a focus cycle (a modal dialog holds focus inside itself), so the walk is complete
|
|
154
|
+
break
|
|
155
|
+
}
|
|
67
156
|
stops++
|
|
68
157
|
if (!stop.visible) problems.push(`${stop.label} takes focus but is not visible`)
|
|
69
158
|
if (!stop.indicator) problems.push(`${stop.label} shows no focus indicator`)
|
|
159
|
+
if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when focused (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
|
|
70
160
|
}
|
|
71
161
|
expect(stops, 'the page has no keyboard stops').toBeGreaterThan(0)
|
|
72
|
-
|
|
162
|
+
// A walk that hit its cap has not seen the rest of the page: say so instead of passing it.
|
|
163
|
+
if (!finished) problems.push(`the tab order has more than ${maxStops} stops, so the rest of the page was not checked (raise maxStops for this state)`)
|
|
164
|
+
for (let i = 0; i < Math.min(stops, maxStops); i++) {
|
|
165
|
+
await page.keyboard.press('Shift+Tab')
|
|
166
|
+
const stop = await probeFocus(page)
|
|
167
|
+
if (!stop) break
|
|
168
|
+
if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when reached backwards (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
|
|
169
|
+
}
|
|
170
|
+
expect([...new Set(problems)]).toEqual([])
|
|
73
171
|
}
|
|
74
172
|
|
|
75
173
|
/**
|
|
76
174
|
* @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
|
|
77
|
-
* @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo
|
|
78
|
-
* in the release that clears them)
|
|
175
|
+
* @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
|
|
176
|
+
* moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
|
|
177
|
+
* only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
|
|
79
178
|
*/
|
|
80
|
-
export function defineA11ySuite(states, { aaa = 'report' } = {}) {
|
|
81
|
-
for (const [name,
|
|
179
|
+
export function defineA11ySuite(states, { aaa = 'report', noKeyboard = [], maxStops } = {}) {
|
|
180
|
+
for (const [name, state] of Object.entries(states)) {
|
|
181
|
+
const open = typeof state === 'function' ? state : state.open
|
|
182
|
+
const disableRules = typeof state === 'function' ? [] : (state.disableRules ?? [])
|
|
82
183
|
test.describe(name, () => {
|
|
83
184
|
test.beforeEach(async ({ page }) => {
|
|
84
185
|
await offline(page)
|
|
@@ -89,7 +190,7 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
|
|
|
89
190
|
await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
|
|
90
191
|
await open(page)
|
|
91
192
|
await applyLook(page, theme, season)
|
|
92
|
-
await expectAccessible(page, { aaa })
|
|
193
|
+
await expectAccessible(page, { aaa, disableRules })
|
|
93
194
|
})
|
|
94
195
|
}
|
|
95
196
|
|
|
@@ -97,21 +198,21 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
|
|
|
97
198
|
await page.setViewportSize({ width: 320, height: 640 })
|
|
98
199
|
await open(page)
|
|
99
200
|
await expectNoHorizontalScroll(page)
|
|
100
|
-
await expectAccessible(page, { aaa: 'off' })
|
|
201
|
+
await expectAccessible(page, { aaa: 'off', disableRules })
|
|
101
202
|
})
|
|
102
203
|
|
|
103
204
|
test('holds at 200% text size', async ({ page }) => {
|
|
104
205
|
await open(page)
|
|
105
206
|
await page.addStyleTag({ content: 'html { font-size: 200% !important; }' })
|
|
106
207
|
await expectNoHorizontalScroll(page)
|
|
107
|
-
await expectAccessible(page, { aaa: 'off' })
|
|
208
|
+
await expectAccessible(page, { aaa: 'off', disableRules })
|
|
108
209
|
})
|
|
109
210
|
|
|
110
211
|
test('works in forced colours', async ({ page }) => {
|
|
111
212
|
await page.emulateMedia({ forcedColors: 'active' })
|
|
112
213
|
await open(page)
|
|
113
214
|
// The browser chooses the colours in forced-colours mode, so axe's contrast arithmetic on the declared ones does not apply.
|
|
114
|
-
await expectAccessible(page, { aaa: 'off', disableRules: ['color-contrast'] })
|
|
215
|
+
await expectAccessible(page, { aaa: 'off', disableRules: [...disableRules, 'color-contrast'] })
|
|
115
216
|
})
|
|
116
217
|
|
|
117
218
|
test('holds still with reduced motion', async ({ page }) => {
|
|
@@ -120,10 +221,15 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
|
|
|
120
221
|
await expectNoRunningAnimations(page)
|
|
121
222
|
})
|
|
122
223
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
224
|
+
if (!noKeyboard.includes(name)) {
|
|
225
|
+
test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
|
|
226
|
+
// The walk probes every stop twice (forward, then backward), and WebKit animates the scroll each one causes: a page of a
|
|
227
|
+
// hundred stops takes about half a minute there, longer than the default 30 s. Scale the limit with the cap.
|
|
228
|
+
test.setTimeout(Math.max(60_000, (maxStops || 300) * 400))
|
|
229
|
+
await open(page)
|
|
230
|
+
await expectKeyboardFocusVisible(page, maxStops ? { maxStops } : {})
|
|
231
|
+
})
|
|
232
|
+
}
|
|
127
233
|
})
|
|
128
234
|
}
|
|
129
235
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mw-audit-gate — CI dependency gate: `npm audit` at high/critical, minus advisories listed in
|
|
3
|
+
// .github/audit-allowlist.json. Each entry needs a reason and an expiry date, so an
|
|
4
|
+
// accepted advisory is re-reviewed instead of being ignored forever.
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
const ALLOWLIST = '.github/audit-allowlist.json'
|
|
9
|
+
const LEVELS = new Set(['high', 'critical'])
|
|
10
|
+
const GHSA = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/
|
|
11
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
12
|
+
|
|
13
|
+
let failed = false
|
|
14
|
+
/** @param {string} msg */
|
|
15
|
+
const fail = (msg) => {
|
|
16
|
+
console.log(`::error::${msg}`)
|
|
17
|
+
failed = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const entries = existsSync(ALLOWLIST) ? JSON.parse(readFileSync(ALLOWLIST, 'utf8')) : []
|
|
21
|
+
if (!Array.isArray(entries)) throw new Error(`${ALLOWLIST} must be an array`)
|
|
22
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
23
|
+
const allowed = new Map()
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
if (!GHSA.test(e?.id) || typeof e.reason !== 'string' || !e.reason || !DATE.test(e.expires)) {
|
|
26
|
+
fail(`invalid allowlist entry ${JSON.stringify(e)} (needs id, reason, expires YYYY-MM-DD)`)
|
|
27
|
+
} else if (e.expires < today) {
|
|
28
|
+
fail(`allowlist entry ${e.id} expired on ${e.expires}; re-review it`)
|
|
29
|
+
} else {
|
|
30
|
+
allowed.set(e.id, e)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let raw
|
|
35
|
+
try {
|
|
36
|
+
raw = execFileSync('npm', ['audit', '--json'], { encoding: 'utf8', maxBuffer: 64 << 20 })
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// npm audit exits non-zero whenever it reports anything; the JSON is still on stdout.
|
|
39
|
+
raw = /** @type {{ stdout: string }} */ (err).stdout
|
|
40
|
+
}
|
|
41
|
+
const report = JSON.parse(raw)
|
|
42
|
+
if (report.error) throw new Error(`npm audit failed: ${JSON.stringify(report.error)}`)
|
|
43
|
+
|
|
44
|
+
const found = new Map()
|
|
45
|
+
for (const vuln of Object.values(report.vulnerabilities ?? {})) {
|
|
46
|
+
for (const via of vuln.via) {
|
|
47
|
+
if (typeof via === 'object' && LEVELS.has(via.severity)) {
|
|
48
|
+
found.set(via.url.split('/').pop(), via)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [id, via] of found) {
|
|
54
|
+
const entry = allowed.get(id)
|
|
55
|
+
if (entry) {
|
|
56
|
+
console.log(`::warning::${id} (${via.name}, ${via.severity}) allowed until ${entry.expires}: ${entry.reason}`)
|
|
57
|
+
} else {
|
|
58
|
+
fail(`${id} (${via.name}, ${via.severity}): ${via.title}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const id of allowed.keys()) {
|
|
62
|
+
if (!found.has(id)) console.log(`::warning::allowlist entry ${id} no longer matches an advisory; remove it`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const open = [...found.keys()].filter((id) => !allowed.has(id)).length
|
|
66
|
+
console.log(`${found.size} high/critical advisories, ${open} not allowlisted`)
|
|
67
|
+
process.exit(failed ? 1 : 0)
|
package/src/bundle.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Production-artefact scan for the end-to-end harness. An app's e2e build (`vite build --mode e2e`) registers a mock wallet, a stub chain
|
|
3
|
+
// and sometimes a stand-in for a module that needs a live service; none of it may reach a production build. The app's entry loads the
|
|
4
|
+
// harness only when the mode is `e2e` and `VITE_E2E` is `1` (both statically replaced), so every other build drops the block. This scan
|
|
5
|
+
// proves it on the built files instead of trusting that: it fails when a hook or marker is found in `dist/`.
|
|
6
|
+
//
|
|
7
|
+
// // package.json: "check:bundle": "check-prod-bundle dist"
|
|
8
|
+
// // CI, after the production build; and in the Dockerfile, right after `npm run build`
|
|
9
|
+
// // an app with its own stand-in adds a marker: "check-prod-bundle dist --also 'e2e session key'"
|
|
10
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
11
|
+
import { join, relative } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
|
|
15
|
+
* bundle that includes it: the hooks it puts on `window`. Property names survive minification.
|
|
16
|
+
*/
|
|
17
|
+
export const E2E_MARKERS = Object.freeze([
|
|
18
|
+
{ name: '__registerMockWallet hook', re: /__registerMockWallet/ },
|
|
19
|
+
{ name: '__unregisterMockWallet hook', re: /__unregisterMockWallet/ },
|
|
20
|
+
{ name: '__e2eAddress hook', re: /__e2eAddress/ },
|
|
21
|
+
{ name: '__e2eWalletAddress hook', re: /__e2eWalletAddress/ },
|
|
22
|
+
{ name: '__e2eSignTransaction hook', re: /__e2eSignTransaction/ },
|
|
23
|
+
{ name: '__e2eSignPersonalMessage hook', re: /__e2eSignPersonalMessage/ },
|
|
24
|
+
{ name: '__getSuiClient hook', re: /__getSuiClient/ },
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/** @param {string} dir @returns {Generator<string>} */
|
|
28
|
+
function* files(dir) {
|
|
29
|
+
for (const name of readdirSync(dir)) {
|
|
30
|
+
const path = join(dir, name)
|
|
31
|
+
if (statSync(path).isDirectory()) yield* files(path)
|
|
32
|
+
else if (/\.(m?js|html)$/.test(name)) yield path
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scan the built files under `dist` for the harness.
|
|
38
|
+
* @param {string} dist The build output directory.
|
|
39
|
+
* @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
|
|
40
|
+
* @returns {{ scanned: number, hits: string[] }}
|
|
41
|
+
* @throws When `dist` cannot be read.
|
|
42
|
+
*/
|
|
43
|
+
export function scanBundle(dist, { also = [] } = {}) {
|
|
44
|
+
const markers = [
|
|
45
|
+
...E2E_MARKERS,
|
|
46
|
+
...also.map((m) => ({ name: `marker ${String(m)}`, re: typeof m === 'string' ? new RegExp(m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : m })),
|
|
47
|
+
]
|
|
48
|
+
let scanned = 0
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const hits = []
|
|
51
|
+
for (const file of files(dist)) {
|
|
52
|
+
scanned++
|
|
53
|
+
const text = readFileSync(file, 'utf8')
|
|
54
|
+
for (const { name, re } of markers) if (re.test(text)) hits.push(`${relative(dist, file)}: ${name}`)
|
|
55
|
+
}
|
|
56
|
+
return { scanned, hits }
|
|
57
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when dist/ contains an inline <script> that the CSP's script-src does not allow, or when script-src allows
|
|
3
|
+
// 'unsafe-inline'. VitePress inlines a couple of small scripts (the appearance/theme bootstrap and the page-hash map); the image serves a
|
|
4
|
+
// hash-based CSP, so a VitePress upgrade (or a change of the `appearance` option) that changes them must update the Dockerfile's CSP ARG
|
|
5
|
+
// in the same change — this check makes that impossible to miss, in CI as well as in the image build.
|
|
6
|
+
//
|
|
7
|
+
// Usage: mw-check-csp-inline "<csp>" [distDir]
|
|
8
|
+
// mw-check-csp-inline @Dockerfile [distDir] (reads the Dockerfile's `ARG CSP="…"` default)
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const [source, dist = 'dist'] = process.argv.slice(2)
|
|
14
|
+
if (!source) {
|
|
15
|
+
console.error('usage: mw-check-csp-inline "<csp>"|@Dockerfile [distDir]')
|
|
16
|
+
process.exit(2)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let csp = source
|
|
20
|
+
if (source.startsWith('@')) {
|
|
21
|
+
const m = readFileSync(source.slice(1), 'utf8').match(/^ARG CSP="([^"]*)"/m)
|
|
22
|
+
if (!m?.[1]) {
|
|
23
|
+
console.error(`CSP check: no 'ARG CSP="…"' default found in ${source.slice(1)}`)
|
|
24
|
+
process.exit(2)
|
|
25
|
+
}
|
|
26
|
+
csp = m[1]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const scriptSrc = (csp.split(';').map((d) => d.trim()).find((d) => d.startsWith('script-src ')) ?? '').split(/\s+/)
|
|
30
|
+
if (scriptSrc.includes("'unsafe-inline'")) {
|
|
31
|
+
console.error("CSP check: script-src allows 'unsafe-inline'; the page must not.")
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
const allowed = new Set(scriptSrc.filter((t) => t.startsWith("'sha256-")).map((t) => t.slice(1, -1)))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} dir
|
|
38
|
+
* @returns {Generator<string>}
|
|
39
|
+
*/
|
|
40
|
+
function* htmlFiles(dir) {
|
|
41
|
+
for (const name of readdirSync(dir)) {
|
|
42
|
+
const p = join(dir, name)
|
|
43
|
+
if (statSync(p).isDirectory()) yield* htmlFiles(p)
|
|
44
|
+
else if (name.endsWith('.html')) yield p
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const missing = new Map()
|
|
49
|
+
let files = 0
|
|
50
|
+
for (const file of htmlFiles(dist)) {
|
|
51
|
+
files++
|
|
52
|
+
const html = readFileSync(file, 'utf8')
|
|
53
|
+
for (const m of html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)) {
|
|
54
|
+
if (!m[1].trim()) continue
|
|
55
|
+
const hash = 'sha256-' + createHash('sha256').update(m[1]).digest('base64')
|
|
56
|
+
if (!allowed.has(hash)) missing.set(hash, file)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (files === 0) {
|
|
60
|
+
console.error(`CSP check: no HTML file found in ${dist}; build first.`)
|
|
61
|
+
process.exit(2)
|
|
62
|
+
}
|
|
63
|
+
if (missing.size) {
|
|
64
|
+
console.error(`CSP check: ${missing.size} inline script(s) not allowed by script-src:`)
|
|
65
|
+
for (const [hash, file] of missing) console.error(` '${hash}' (first seen in ${file})`)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
console.log(`CSP check: every inline script in ${files} HTML file(s) is allowed by script-src.`)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// CLI for `scanBundle`: `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` (text is matched literally). Exit 1 when the harness is found, when `dist` is missing or when it
|
|
4
|
+
// holds no .js/.html file (a build that has not run must not pass).
|
|
5
|
+
import { scanBundle } from './bundle.js'
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
/** @type {(string | RegExp)[]} */
|
|
9
|
+
const also = []
|
|
10
|
+
/** @type {string[]} */
|
|
11
|
+
const rest = []
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
if (args[i] === '--also' || args[i] === '--also-regex') {
|
|
14
|
+
const flag = args[i]
|
|
15
|
+
const marker = args[++i]
|
|
16
|
+
if (!marker) {
|
|
17
|
+
console.error(`check-prod-bundle: ${flag} needs a marker`)
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
also.push(flag === '--also' ? marker : new RegExp(marker))
|
|
22
|
+
} catch {
|
|
23
|
+
console.error(`check-prod-bundle: ${marker} is not a valid pattern`)
|
|
24
|
+
process.exit(2)
|
|
25
|
+
}
|
|
26
|
+
} else rest.push(/** @type {string} */ (args[i]))
|
|
27
|
+
}
|
|
28
|
+
const dist = rest[0] ?? 'dist'
|
|
29
|
+
|
|
30
|
+
let result
|
|
31
|
+
try {
|
|
32
|
+
result = scanBundle(dist, { also })
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(`check-prod-bundle: cannot read ${dist}/ (${/** @type {Error} */ (e).message}) — build first.`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
if (result.scanned === 0) {
|
|
38
|
+
console.error(`check-prod-bundle: no .js/.html files under ${dist}/ — build first.`)
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
if (result.hits.length > 0) {
|
|
42
|
+
console.error(`check-prod-bundle: the e2e harness is in the production bundle:\n ${result.hits.join('\n ')}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
console.log(`check-prod-bundle: ${result.scanned} files clean (no e2e hooks${also.length ? ` or the ${also.length} extra marker(s)` : ''}).`)
|
package/src/csp.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The Content-Security-Policy of a MeddleWare static site, built from a named profile. Every frontend image serves one (the Dockerfile's `ARG CSP`,
|
|
3
|
+
// handed to static-server): twelve hand-copied strings drifted in small ways, so the profiles live here and `mw-sync` checks each Dockerfile against its
|
|
4
|
+
// profile. A policy has no 'unsafe-inline' for scripts (`mw-check-csp-inline` fails the build on an inline script that is not allowed by hash).
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} CspOptions
|
|
8
|
+
* @property {boolean} [wasm] allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
|
|
9
|
+
* @property {boolean} [chain] chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
|
|
10
|
+
* @property {boolean} [worker] allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
|
|
11
|
+
* @property {boolean} [strict] `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
|
|
12
|
+
* @property {'self' | 'none'} [frameAncestors] who may frame the page (default `'self'`)
|
|
13
|
+
* @property {string[]} [scriptHashes] `'sha256-…'` sources for the inline scripts a static-site generator emits
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @type {Record<string, CspOptions>} */
|
|
17
|
+
export const PROFILES = {
|
|
18
|
+
/** Same-origin only: the landing page. */
|
|
19
|
+
static: {},
|
|
20
|
+
/** Same-origin only, no forms and no `<base>`: the status page and the public roadmap. */
|
|
21
|
+
'static-strict': { strict: true },
|
|
22
|
+
/** An operator console: same-origin only and never framed. */
|
|
23
|
+
console: { frameAncestors: 'none' },
|
|
24
|
+
/** A chain-facing tool: https connections and images, blob workers. */
|
|
25
|
+
chain: { chain: true },
|
|
26
|
+
/** A chain-facing tool that runs WebAssembly (Walrus, Move bytecode). */
|
|
27
|
+
'chain-wasm': { chain: true, wasm: true },
|
|
28
|
+
/** A VitePress site: same-origin connections, https images, blob workers, inline scripts allowed by hash (the hashes are kept from the current policy). */
|
|
29
|
+
docs: { worker: true },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {CspOptions} options
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function buildCsp(options = {}) {
|
|
37
|
+
const { wasm = false, chain = false, worker = false, strict = false, frameAncestors = 'self', scriptHashes = [] } = options
|
|
38
|
+
const script = ["'self'", ...(wasm ? ["'wasm-unsafe-eval'"] : []), ...scriptHashes.map((h) => `'${h.replace(/^'|'$/g, '')}'`)]
|
|
39
|
+
const richImages = chain || worker
|
|
40
|
+
const directives = [
|
|
41
|
+
["default-src", "'self'"],
|
|
42
|
+
['script-src', script.join(' ')],
|
|
43
|
+
['style-src', "'self' 'unsafe-inline'"],
|
|
44
|
+
['img-src', richImages ? "'self' data: blob: https:" : "'self' data:"],
|
|
45
|
+
['font-src', "'self' data:"],
|
|
46
|
+
['connect-src', chain ? "'self' https:" : "'self'"],
|
|
47
|
+
...(chain || worker ? [['worker-src', "'self' blob:"]] : []),
|
|
48
|
+
['object-src', "'none'"],
|
|
49
|
+
['base-uri', strict ? "'none'" : "'self'"],
|
|
50
|
+
['form-action', strict ? "'none'" : "'self'"],
|
|
51
|
+
['frame-ancestors', `'${frameAncestors}'`],
|
|
52
|
+
]
|
|
53
|
+
return [...directives.map(([d, v]) => `${d} ${v}`), 'upgrade-insecure-requests'].join('; ')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The `'sha256-…'` hashes in a policy's script-src (kept when a profile such as `docs` is rebuilt). */
|
|
57
|
+
export function scriptHashesOf(/** @type {string} */ csp) {
|
|
58
|
+
const src = csp.split(';').map((d) => d.trim()).find((d) => d.startsWith('script-src ')) ?? ''
|
|
59
|
+
return src.split(/\s+/).filter((t) => t.startsWith("'sha256-")).map((t) => t.slice(1, -1))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} name @param {string} [current] the policy now in the Dockerfile (its script hashes are kept) */
|
|
63
|
+
export function cspForProfile(name, current = '') {
|
|
64
|
+
const profile = PROFILES[name]
|
|
65
|
+
if (!profile) throw new Error(`unknown CSP profile "${name}" (known: ${Object.keys(PROFILES).join(', ')})`)
|
|
66
|
+
return buildCsp({ ...profile, ...(name === 'docs' ? { scriptHashes: scriptHashesOf(current) } : {}) })
|
|
67
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// mw-sync — keep the files that every MeddleWare frontend repository copies from this package identical to the package's templates, and the
|
|
4
|
+
// Dockerfile's Content-Security-Policy equal to its named profile. Run in a repository root:
|
|
5
|
+
//
|
|
6
|
+
// mw-sync --check fail (exit 1) when a file differs; print which and how to fix
|
|
7
|
+
// mw-sync --write overwrite the files with the templates / the profile's policy
|
|
8
|
+
//
|
|
9
|
+
// The repository declares what it is in package.json:
|
|
10
|
+
// "meddleware": { "kind": "app", "csp": "chain-wasm", "lhci": true }
|
|
11
|
+
// kind "app" → .github/dependabot.yml, .dockerignore, postcss.config.js, stylelint.config.js are the templates
|
|
12
|
+
// csp a profile of ./csp.js → the Dockerfile's `ARG CSP="…"` is that profile's policy (`docs` keeps the script hashes already there)
|
|
13
|
+
// lhci true → .github/audit-allowlist.json is the template (the dated allowlist for the Lighthouse CI tool chain; one expiry, reviewed together)
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { cspForProfile } from './csp.js'
|
|
18
|
+
|
|
19
|
+
const templates = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates')
|
|
20
|
+
const tpl = (/** @type {string} */ name) => readFileSync(join(templates, name), 'utf8')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} root repository root
|
|
24
|
+
* @param {{ write?: boolean }} [options]
|
|
25
|
+
* @returns {{ file: string, ok: boolean, note: string }[]}
|
|
26
|
+
*/
|
|
27
|
+
export function sync(root, options = {}) {
|
|
28
|
+
const pkgPath = join(root, 'package.json')
|
|
29
|
+
const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf8')) : {}
|
|
30
|
+
const cfg = pkg.meddleware ?? {}
|
|
31
|
+
/** @type {{ file: string, ok: boolean, note: string }[]} */
|
|
32
|
+
const out = []
|
|
33
|
+
/** @param {string} file @param {string} want */
|
|
34
|
+
const expectFile = (file, want) => {
|
|
35
|
+
const p = join(root, file)
|
|
36
|
+
const have = existsSync(p) ? readFileSync(p, 'utf8') : null
|
|
37
|
+
if (have === want) return out.push({ file, ok: true, note: 'matches the template' })
|
|
38
|
+
if (options.write) {
|
|
39
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
40
|
+
writeFileSync(p, want)
|
|
41
|
+
return out.push({ file, ok: true, note: have === null ? 'created' : 'rewritten' })
|
|
42
|
+
}
|
|
43
|
+
out.push({ file, ok: false, note: have === null ? 'missing (mw-sync --write creates it)' : 'differs from the template (mw-sync --write restores it)' })
|
|
44
|
+
}
|
|
45
|
+
if (cfg.kind === 'app') {
|
|
46
|
+
expectFile('.github/dependabot.yml', tpl('dependabot.app.yml'))
|
|
47
|
+
expectFile('.dockerignore', tpl('dockerignore'))
|
|
48
|
+
expectFile('postcss.config.js', tpl('postcss.config.js'))
|
|
49
|
+
expectFile('stylelint.config.js', tpl('stylelint.config.js'))
|
|
50
|
+
}
|
|
51
|
+
if (cfg.lhci === true) expectFile('.github/audit-allowlist.json', tpl('audit-allowlist.lhci.json'))
|
|
52
|
+
if (cfg.csp) {
|
|
53
|
+
const df = join(root, 'Dockerfile')
|
|
54
|
+
const text = existsSync(df) ? readFileSync(df, 'utf8') : ''
|
|
55
|
+
const m = text.match(/^ARG CSP="([^"]*)"/m)
|
|
56
|
+
if (!m) out.push({ file: 'Dockerfile', ok: false, note: `no 'ARG CSP="…"' default found (profile ${cfg.csp})` })
|
|
57
|
+
else {
|
|
58
|
+
const want = cspForProfile(cfg.csp, m[1])
|
|
59
|
+
if (m[1] === want) out.push({ file: 'Dockerfile', ok: true, note: `CSP equals the "${cfg.csp}" profile` })
|
|
60
|
+
else if (options.write) {
|
|
61
|
+
writeFileSync(df, text.replace(/^ARG CSP="[^"]*"/m, () => `ARG CSP="${want}"`))
|
|
62
|
+
out.push({ file: 'Dockerfile', ok: true, note: `CSP rewritten to the "${cfg.csp}" profile` })
|
|
63
|
+
} else out.push({ file: 'Dockerfile', ok: false, note: `CSP differs from the "${cfg.csp}" profile:\n have ${m[1]}\n want ${want}` })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (out.length === 0) out.push({ file: 'package.json', ok: false, note: 'no "meddleware" section: nothing to check (add kind / csp / lhci)' })
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
71
|
+
const args = process.argv.slice(2)
|
|
72
|
+
const write = args.includes('--write')
|
|
73
|
+
if (!write && !args.includes('--check')) {
|
|
74
|
+
console.error('usage: mw-sync --check | --write')
|
|
75
|
+
process.exit(2)
|
|
76
|
+
}
|
|
77
|
+
const results = sync(process.cwd(), { write })
|
|
78
|
+
for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.file}: ${r.note}`)
|
|
79
|
+
process.exit(results.every((r) => r.ok) ? 0 : 1)
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Writes dist/THIRD_PARTY_LICENSES: the licence and NOTICE texts of every production dependency in
|
|
3
|
+
// package-lock.json. The site ships a bundle, so the packages' own files do not travel with it;
|
|
4
|
+
// Apache-2.0 (§4), MIT and BSD all require the notice to accompany a redistribution. The file is served
|
|
5
|
+
// with the site (/THIRD_PARTY_LICENSES) and read by licence audits.
|
|
6
|
+
//
|
|
7
|
+
// mw-third-party-licenses write the file
|
|
8
|
+
// mw-third-party-licenses --check also fail when the file would be empty
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
// The project root is the directory the command runs in (an app's npm script), not this package.
|
|
13
|
+
const root = process.cwd()
|
|
14
|
+
const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'))
|
|
15
|
+
const check = process.argv.includes('--check')
|
|
16
|
+
const LICENSE_FILE = /^(licen[cs]e|notice|copying|unlicense)(\.|$|-)/i
|
|
17
|
+
|
|
18
|
+
const out = []
|
|
19
|
+
const missing = []
|
|
20
|
+
const apacheMissing = []
|
|
21
|
+
/** @param {any} pkg */
|
|
22
|
+
const declared = (pkg) => (typeof pkg.license === 'string' ? pkg.license : JSON.stringify(pkg.license ?? pkg.licenses ?? 'unknown'))
|
|
23
|
+
/** @param {any} pkg */
|
|
24
|
+
const holder = (pkg) =>
|
|
25
|
+
[pkg.author && (typeof pkg.author === 'string' ? pkg.author : pkg.author.name), pkg.repository && (typeof pkg.repository === 'string' ? pkg.repository : pkg.repository.url)]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' · ')
|
|
28
|
+
const names = Object.keys(lock.packages)
|
|
29
|
+
.filter((p) => p.startsWith('node_modules/') && !lock.packages[p].dev && !lock.packages[p].optional)
|
|
30
|
+
.sort()
|
|
31
|
+
for (const p of names) {
|
|
32
|
+
const dir = join(root, p)
|
|
33
|
+
if (!existsSync(dir)) continue
|
|
34
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
35
|
+
const files = readdirSync(dir).filter((f) => LICENSE_FILE.test(f)).sort()
|
|
36
|
+
out.push(`${'='.repeat(78)}\n${pkg.name}@${pkg.version} (${declared(pkg)})\n${'='.repeat(78)}`)
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
missing.push(`${pkg.name}@${pkg.version}`)
|
|
39
|
+
if (/Apache-2\.0/.test(declared(pkg))) apacheMissing.push(`${pkg.name}@${pkg.version}`)
|
|
40
|
+
}
|
|
41
|
+
for (const f of files) out.push(`--- ${f} ---\n${readFileSync(join(dir, f), 'utf8').trim()}\n`)
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
const apache = /Apache-2\.0/.test(declared(pkg))
|
|
44
|
+
out.push(`(the package ships no licence file; declared licence: ${declared(pkg)}${holder(pkg) ? `; ${holder(pkg)}` : ''}${apache ? '; the Apache License 2.0 text follows at the end of this file' : ''})\n`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Apache-2.0 §4(a) asks that a recipient gets a copy of the License. A package that declares it but ships no
|
|
48
|
+
// file is covered by the canonical text, taken from any installed package that carries it.
|
|
49
|
+
if (apacheMissing.length > 0) {
|
|
50
|
+
let text = null
|
|
51
|
+
for (const p of Object.keys(lock.packages).filter((k) => k.startsWith('node_modules/'))) {
|
|
52
|
+
const dir = join(root, p)
|
|
53
|
+
if (!existsSync(dir)) continue
|
|
54
|
+
for (const f of readdirSync(dir).filter((x) => LICENSE_FILE.test(x))) {
|
|
55
|
+
const t = readFileSync(join(dir, f), 'utf8')
|
|
56
|
+
if (/Apache License\s+Version 2\.0, January 2004/.test(t) && /END OF TERMS AND CONDITIONS/.test(t)) {
|
|
57
|
+
text = t.slice(0, t.indexOf('END OF TERMS AND CONDITIONS') + 'END OF TERMS AND CONDITIONS'.length).trim()
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (text) break
|
|
62
|
+
}
|
|
63
|
+
out.push(`${'='.repeat(78)}\nApache License 2.0 — applies to: ${apacheMissing.join(', ')}\n${'='.repeat(78)}`)
|
|
64
|
+
out.push(text ?? '(canonical text not found in the installed packages: see https://www.apache.org/licenses/LICENSE-2.0)')
|
|
65
|
+
if (check && text === null) {
|
|
66
|
+
console.error('Apache-2.0 packages without a licence file, and no canonical Apache License text installed')
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
mkdirSync(join(root, 'dist'), { recursive: true })
|
|
71
|
+
writeFileSync(join(root, 'dist', 'THIRD_PARTY_LICENSES'), `${out.join('\n')}\n`)
|
|
72
|
+
console.log(`THIRD_PARTY_LICENSES: ${names.length} production packages, ${missing.length} without a licence file`)
|
|
73
|
+
if (missing.length) console.log(` no licence file: ${missing.join(', ')}`)
|
|
74
|
+
if (check && out.length === 0) {
|
|
75
|
+
console.error('no production packages found in package-lock.json')
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
package/src/vite.js
CHANGED
|
@@ -85,3 +85,34 @@ export function assertE2eOnlyInE2eMode(mode, env = process.env) {
|
|
|
85
85
|
throw new Error(`VITE_E2E is set but the build mode is "${mode}": the end-to-end build is \`vite build --mode e2e\`, and nothing else may include the test wallet.`)
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The Vite configuration every MeddleWare tool app shares: the browserslist build target, the colour mode and season before first paint
|
|
91
|
+
* ({@link themeInit}), the e2e-build guard ({@link assertE2eOnlyInE2eMode}), and `@meddleware/wallet-adapter` excluded from dependency
|
|
92
|
+
* pre-bundling. The wallet adapter ships TS and `.vue` source and holds the shared wallet singleton; pre-bundling would put its TS in
|
|
93
|
+
* `.vite/deps` while its `.vue` files (which esbuild cannot bundle) are served raw and import the raw module, so dev would run two
|
|
94
|
+
* singletons and the guard and the app would see different connection state.
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* import vue from '@vitejs/plugin-vue'
|
|
98
|
+
* export default meddlewareApp({ plugins: [vue()] })
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @param {object} options
|
|
102
|
+
* @param {import('vite').PluginOption[]} options.plugins The app's plugins (`vue()` …); `themeInit` is added after them.
|
|
103
|
+
* @param {'light' | 'dark' | 'system'} [options.theme] The mode a first visit gets (default `dark`, the brand default).
|
|
104
|
+
* @param {boolean} [options.wallet] The app uses `@meddleware/wallet-adapter` (default true): keep it out of pre-bundling.
|
|
105
|
+
* @param {import('vite').UserConfig} [options.config] Anything else (`base`, `build.rollupOptions`, `server` …); `build` is merged.
|
|
106
|
+
* @returns {import('vite').UserConfigFnObject}
|
|
107
|
+
*/
|
|
108
|
+
export function meddlewareApp({ plugins, theme = 'dark', wallet = true, config = {} }) {
|
|
109
|
+
return ({ mode }) => {
|
|
110
|
+
assertE2eOnlyInE2eMode(mode)
|
|
111
|
+
return {
|
|
112
|
+
...config,
|
|
113
|
+
build: { target: viteTarget(), ...config.build },
|
|
114
|
+
plugins: [...plugins, themeInit({ default: theme })],
|
|
115
|
+
...(wallet ? { optimizeDeps: { ...config.optimizeDeps, exclude: ['@meddleware/wallet-adapter', ...(config.optimizeDeps?.exclude ?? [])] } } : {}),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/vitest.js
CHANGED
|
@@ -50,3 +50,32 @@ export function installDialogShim() {
|
|
|
50
50
|
this.dispatchEvent(new Event('close'))
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The Vitest configuration of a MeddleWare Vue repository: jsdom, `tests/**\/*.test.ts`, a single copy of Vue for the code under test and
|
|
56
|
+
* `@vue/test-utils`, and the UI packages processed through Vite ({@link inlineMeddleware}).
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* import vue from '@vitejs/plugin-vue'
|
|
60
|
+
* export default defineConfig(vueTestConfig({ plugins: [vue()] }))
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {import('vite').PluginOption[]} options.plugins The Vue plugin (and any other the tests need).
|
|
65
|
+
* @param {import('vitest/config').TestUserConfig} [options.test] Overrides merged into `test` (`include`, `setupFiles`, `environment` …).
|
|
66
|
+
* @returns {import('vitest/config').ViteUserConfig & { test: import('vitest/config').TestUserConfig }}
|
|
67
|
+
*/
|
|
68
|
+
export function vueTestConfig({ plugins, test = {} }) {
|
|
69
|
+
const inline = test.server?.deps?.inline
|
|
70
|
+
return {
|
|
71
|
+
plugins,
|
|
72
|
+
resolve: { dedupe: ['vue', '@vue/test-utils'] },
|
|
73
|
+
test: {
|
|
74
|
+
environment: 'jsdom',
|
|
75
|
+
include: ['tests/**/*.test.ts'],
|
|
76
|
+
exclude: ['node_modules/**'],
|
|
77
|
+
...test,
|
|
78
|
+
server: { ...test.server, deps: { ...test.server?.deps, inline: inline === true ? true : [...inlineMeddleware, ...(inline ?? [])] } },
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "GHSA-jmr9-qjv8-65gv",
|
|
4
|
+
"reason": "extract-zip has no patched release. It is a dev-only transitive of @lhci/cli (lighthouse > puppeteer-core > @puppeteer/browsers) and only unpacks browser archives that Lighthouse CI downloads itself; nothing from it ships in the image.",
|
|
5
|
+
"expires": "2027-01-31"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"id": "GHSA-7pqw-9j4j-h8q3",
|
|
9
|
+
"reason": "extract-zip has no patched release. It is a dev-only transitive of @lhci/cli (lighthouse > puppeteer-core > @puppeteer/browsers) and only unpacks browser archives that Lighthouse CI downloads itself; nothing from it ships in the image.",
|
|
10
|
+
"expires": "2027-01-31"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: npm
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
open-pull-requests-limit: 5
|
|
8
|
+
ignore:
|
|
9
|
+
- dependency-name: "typescript"
|
|
10
|
+
update-types: ["version-update:semver-major"]
|
|
11
|
+
groups:
|
|
12
|
+
npm-minor-patch:
|
|
13
|
+
update-types: [minor, patch]
|
|
14
|
+
- package-ecosystem: docker
|
|
15
|
+
directory: /
|
|
16
|
+
schedule:
|
|
17
|
+
interval: weekly
|
|
18
|
+
open-pull-requests-limit: 5
|
|
19
|
+
ignore:
|
|
20
|
+
- dependency-name: "golang"
|
|
21
|
+
update-types: ["version-update:semver-minor", "version-update:semver-major"]
|
|
22
|
+
groups:
|
|
23
|
+
docker:
|
|
24
|
+
patterns: ['*']
|
|
25
|
+
- package-ecosystem: github-actions
|
|
26
|
+
directory: /
|
|
27
|
+
schedule:
|
|
28
|
+
interval: weekly
|
|
29
|
+
open-pull-requests-limit: 5
|
|
30
|
+
groups:
|
|
31
|
+
github-actions:
|
|
32
|
+
patterns: ['*']
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Build context: sources only. The image runs its own `npm ci` and production build, so local
|
|
2
|
+
# installs and build output never enter it — and local-only env files (Vite loads `.env*.local` in
|
|
3
|
+
# every mode) can never be baked into the production bundle. Committed `.env` / `.env.production`
|
|
4
|
+
# defaults stay in the context on purpose.
|
|
5
|
+
node_modules
|
|
6
|
+
dist
|
|
7
|
+
.git
|
|
8
|
+
.github
|
|
9
|
+
.env*.local
|
|
10
|
+
*.log
|
|
11
|
+
test-results
|
|
12
|
+
playwright-report
|
|
13
|
+
coverage
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@meddleware/frontend-config/postcss'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"types": ["node"],
|
|
16
|
+
"noEmit": true
|
|
17
|
+
}
|
|
18
|
+
}
|