From e80fee86b3d97268efbabf21787f439b7b52f30d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 11:51:59 +0200 Subject: [PATCH] feat: record and replay raw app sessions step by step (#10318) * feat: record and replay raw app sessions step by step * fix: address review findings on raw app session recorder * fix: stamp replay target before pruning the snapshot clone * fix: redact step metadata, lock down replayed frames, fix control pre-state * feat: add a checkpoint timeline to the app recording player * fix: parser-based replay CSP, fold label clicks, drop stale frame indices * fix: scrub redacted attributes, keep scroll, neutralize replay navigation * fix: bound replay payloads, strip namespaced nav links, keep control pre-frames * fix: strip SMIL navigation, redact metadata sources, capture pre-edit on beforeinput * fix: redact template content, drop shadow templates, make replays inert * test: pin snapshot redaction and replay sanitization with DOM tests * fix: allow-list no-record attributes and cover a marked document root * fix: classify input types positively so pickers get pre-change frames * fix: one step per control interaction and bound step metadata * fix: keep button inputs recordable and coalesce only continuous controls * fix: no frames for coalesced repeats and drop inline styles when redacting * fix: fold only the label's own click and keep marked stylesheets out * fix: keep label-forwarded and radio-group pre-frames, fold submitter clicks * fix: bound key pre-frames to their gesture and clear ancestor pointer frames * fix: age-bound pre-frames and treat a radio group as one target * fix: consume pre-frames per interaction and coalesce on the browser repeat flag * fix: spend only the pre-frame a step actually used * fix: settle a step from its successor's pre-state and drop stale pointer frames * fix: bound remote frame payloads and snapshot stylesheets as rendered * fix: let a control change spend its own frame and dedupe Enter activations * fix: record Escape on controls and drop disabled stylesheets * feat: collapse the replay step list by default behind a toggle * fix: neutralize disabled sheets in place and fold Enter submissions * fix: withhold redacted control state, fold key repeats, validate remote metadata * fix: drop noscript markup and fold implicit form submissions * fix: mask a select whose chosen option is redacted * fix: mask redacted select choices before the clone diverges * fix: run clone-paired passes before removals and fold only Enter submissions * feat: record a raw app demo from the publish flow instead of the viewer * fix: wait for in-flight runnable jobs before settling a step * feat: record from the editor menu and replay publicly at /replay * feat: export the app recording player and its loader for the hub * feat: publish from folders only, drop iframe sharing * fix: observe runnable responses where they land and mount the hub recording route * fix: respect the app's sandbox opt-in when recording a session * fix: let stop wait for the runnable the last step is still running * fix: filter redacted class/id to styled tokens and gate publish on admin * fix: drop marked sheets from the token vocabulary and bound the replay error * test: pin the remote app-recording validator * fix: carry in-flight runnables across a reload and fold held keys into one step * fix: bind runnable responses off the request and honor base in the replay handoff * fix: close the settling step when a new fill starts and always re-read stylesheets * fix: empty the no-record marker so it carries nothing of its own * fix: decode css escapes so utility classes survive redaction * fix: read keyDriven from the frame the change starts from * docs: condense recorder comments to the invariant each protects * fix: rewrite only real url() tokens and accept leading css escapes * feat: play flow, script and pipeline recordings on the public /replay page (#10327) * feat: play flow, script and pipeline recordings on the public /replay page * fix: render a recorded approval result inert while replaying * fix: bound an asset sample's cell product and validate recording headers * fix: make a replayed approval step inert and bound nested recording structures * fix: stop recorded markup from fetching and bound flow/script render trees * fix: gate recorded markdown at its renderer and close remaining render-budget gaps * fix: replace per-key render caps with one structural budget per recorded value * fix: bound component fan-out and text alongside the structural budget * fix: make component fan-out cumulative and cap the parsed data-test checklist * fix: bound the whole recording, graph contents, metadata strings and timer bursts * fix: keep the published loader path, charge object keys, refuse huge serialized fan-out * fix: cap flat maps a renderer turns into rows (args, schema properties) * fix: refuse structure hidden past the depth ceiling and bound errored samples * fix: count array-shaped argument collections against the row cap * feat: paint canvas pixels into the snapshot * fix: budget canvas encoding per snapshot and bound the unknown-kind error * fix: cap flow graph overlay fan-out and condense budget comments * docs: teach the raw-app prompt about data-wm-no-record --- backend/windmill-api/openapi.yaml | 34 + backend/windmill-api/src/hub_publish.rs | 10 + cli/src/guidance/skills.gen.ts | 13 + frontend/package-lock.json | 641 +++++++++++-- frontend/package.json | 14 + .../src/lib/components/DisplayResult.svelte | 68 +- .../FlowStatusWaitingForEvents.svelte | 119 +-- .../src/lib/components/GfmMarkdown.svelte | 14 +- frontend/src/lib/components/LogViewer.svelte | 19 +- .../raw_apps/RawAppEditorHeader.svelte | 54 +- .../components/raw_apps/RawAppPreview.svelte | 14 +- .../recording/RawAppRecordingReplay.svelte | 364 ++++++++ .../recording/RecordingPlayer.svelte | 83 ++ .../recording/offlineReplay.svelte.ts | 48 + .../recording/rawAppRecording.svelte.ts | 858 ++++++++++++++++++ .../recording/rawAppRecordingLoad.test.ts | 600 ++++++++++++ .../recording/rawAppRecordingLoad.ts | 584 ++++++++++++ .../recording/rawAppSnapshot.dom.test.ts | 334 +++++++ .../recording/rawAppSnapshot.test.ts | 33 + .../components/recording/rawAppSnapshot.ts | 675 ++++++++++++++ .../src/lib/components/recording/types.ts | 34 + .../workspaceSettings/DeployToHub.svelte | 194 ++-- .../RawAppRecordSession.svelte | 208 +++++ .../deployToHubItems.test.ts | 36 + .../workspaceSettings/deployToHubItems.ts | 47 + .../deployToHubSession.svelte.ts | 226 +---- .../deployToHubSession.test.ts | 54 -- .../(logged)/pipeline_replay/+page.svelte | 296 ++---- .../routes/(root)/(logged)/replay/+page.ts | 9 - frontend/src/routes/replay/+page.svelte | 126 +++ frontend/vite.config.js | 13 +- system_prompts/auto-generated/prompts.ts | 13 + .../auto-generated/skills/raw-app/SKILL.md | 13 + system_prompts/base/raw-app.md | 13 + 34 files changed, 5111 insertions(+), 750 deletions(-) create mode 100644 frontend/src/lib/components/recording/RawAppRecordingReplay.svelte create mode 100644 frontend/src/lib/components/recording/RecordingPlayer.svelte create mode 100644 frontend/src/lib/components/recording/offlineReplay.svelte.ts create mode 100644 frontend/src/lib/components/recording/rawAppRecording.svelte.ts create mode 100644 frontend/src/lib/components/recording/rawAppRecordingLoad.test.ts create mode 100644 frontend/src/lib/components/recording/rawAppRecordingLoad.ts create mode 100644 frontend/src/lib/components/recording/rawAppSnapshot.dom.test.ts create mode 100644 frontend/src/lib/components/recording/rawAppSnapshot.test.ts create mode 100644 frontend/src/lib/components/recording/rawAppSnapshot.ts create mode 100644 frontend/src/lib/components/workspaceSettings/RawAppRecordSession.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/deployToHubItems.test.ts create mode 100644 frontend/src/lib/components/workspaceSettings/deployToHubItems.ts delete mode 100644 frontend/src/lib/components/workspaceSettings/deployToHubSession.test.ts delete mode 100644 frontend/src/routes/(root)/(logged)/replay/+page.ts create mode 100644 frontend/src/routes/replay/+page.svelte diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1d0fc94904..2b611188e0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -23318,6 +23318,40 @@ paths: schema: type: string + /w/{workspace}/hub/raw_apps/{id}/recording: + post: + summary: attach a recorded session to a hub raw app + description: | + Requires the caller to be a workspace admin. Forwards the request to the + configured Hub scoped to the `{workspace}:{folder}` source and returns + the Hub's status code and raw response body. + operationId: publishHubRawAppRecording + tags: + - hubPublish + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + description: hub id of the raw app + schema: + type: integer + format: int64 + - $ref: "#/components/parameters/HubPublishFolder" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecordingBody" + responses: + "200": + description: raw Hub response body (status code is passed through from the Hub) + content: + text/plain: + schema: + type: string + /w/{workspace}/hub/scripts/{ask_id}/recording: post: summary: attach a recording to a hub script diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index 9c1412cb0b..697da9407b 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -23,6 +23,7 @@ pub fn workspaced_service() -> Router { .route("/apps", post(publish_app)) .route("/raw_apps", post(publish_raw_app)) .route("/raw_apps/{id}/embed", post(publish_raw_app_embed)) + .route("/raw_apps/{id}/recording", post(publish_raw_app_recording)) .route( "/scripts/{ask_id}/recording", post(publish_script_recording), @@ -327,6 +328,15 @@ struct RecordingBody { project_slug: ProjectSlug, } +async fn publish_raw_app_recording( + ctx: HubPublishCtx, + Path((_workspace, id)): Path<(String, i64)>, + Json(body): Json, +) -> Result { + ctx.post(&format!("/raw_apps/{}/recording", id), &body) + .await +} + async fn publish_script_recording( ctx: HubPublishCtx, Path((_workspace, ask_id)): Path<(String, i64)>, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 75084ce204..b85ae6fec9 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5719,6 +5719,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.(args)\` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +\`\`\`tsx + +\`\`\` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -5821,6 +5833,7 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — \`get_user\`, not \`a\`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first. +6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. `, "triggers": `--- name: triggers diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f0ff36020..5a75c157e8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -133,6 +133,7 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", "fake-indexeddb": "^6.2.5", + "jsdom": "^29.1.1", "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", @@ -231,6 +232,173 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@aws-crypto/sha256-js": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-4.0.0.tgz", @@ -329,6 +497,40 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", @@ -806,6 +1008,26 @@ "@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", @@ -880,7 +1102,6 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -892,7 +1113,6 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -903,7 +1123,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1010,6 +1229,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1419,7 +1656,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1568,7 +1804,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,7 +1820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1602,7 +1836,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1619,7 +1852,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1636,7 +1868,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1653,7 +1884,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,7 +1900,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1687,7 +1916,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1704,7 +1932,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1721,7 +1948,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1738,7 +1964,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1755,7 +1980,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1772,7 +1996,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1791,7 +2014,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1808,7 +2030,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2114,7 +2335,6 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3427,6 +3647,16 @@ "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4915,6 +5145,58 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -5008,6 +5290,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -6942,6 +7231,19 @@ "license": "ISC", "peer": true }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -7267,6 +7569,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -7350,7 +7659,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7392,6 +7701,167 @@ "node": ">=0.1.90" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7885,7 +8355,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7906,7 +8375,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7927,7 +8395,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7948,7 +8415,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7969,7 +8435,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7990,7 +8455,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8011,7 +8475,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8032,7 +8495,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8053,7 +8515,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8074,7 +8535,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8095,7 +8555,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8229,10 +8688,10 @@ } }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -11847,6 +12306,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -12789,21 +13261,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -13072,6 +13529,13 @@ "node": ">= 10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -13382,6 +13846,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "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", @@ -13405,6 +13889,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -13543,7 +14040,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14257,6 +14754,19 @@ "node": ">=14.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -14279,6 +14789,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14551,12 +15071,29 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", "license": "CC0-1.0" }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37b3aff639..caebcdfe86 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", "fake-indexeddb": "^6.2.5", + "jsdom": "^29.1.1", "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", @@ -309,10 +310,23 @@ "svelte": "./package/components/recording/PipelineRecordingReplay.svelte", "default": "./package/components/recording/PipelineRecordingReplay.svelte" }, + "./components/RawAppRecordingReplay.svelte": { + "types": "./package/components/recording/RawAppRecordingReplay.svelte.d.ts", + "svelte": "./package/components/recording/RawAppRecordingReplay.svelte", + "default": "./package/components/recording/RawAppRecordingReplay.svelte" + }, "./components/recording/types": { "types": "./package/components/recording/types.d.ts", "default": "./package/components/recording/types.js" }, + "./components/recording/rawAppRecordingLoad": { + "types": "./package/components/recording/rawAppRecordingLoad.d.ts", + "default": "./package/components/recording/rawAppRecordingLoad.js" + }, + "./components/recording/rawAppSnapshot": { + "types": "./package/components/recording/rawAppSnapshot.d.ts", + "default": "./package/components/recording/rawAppSnapshot.js" + }, "./components/FlowWrapper.svelte": { "types": "./package/components/FlowWrapper.svelte.d.ts", "svelte": "./package/components/FlowWrapper.svelte", diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 9505bbe210..99b47ea86b 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -45,6 +45,7 @@ import { getContext, hasContext, createEventDispatcher, onDestroy, untrack } from 'svelte' import { toJsonStr } from '$lib/utils' import { userStore } from '$lib/stores' + import { isOfflineReplay, isReplaying } from './recording/offlineReplay.svelte' import ResultStreamDisplay from './ResultStreamDisplay.svelte' import { twMerge } from 'tailwind-merge' import DOMPurify from 'dompurify' @@ -83,6 +84,16 @@ | 'pdf' | undefined let resultKind: ResultKind = $state() + /** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or + * table, and `approval` renders buttons that `fetch` URLs carried in the result. + * A recording is caller-supplied, so on the public page those would aim a + * credentialed request at an arbitrary origin. */ + const REPLAY_INERT_KINDS: ResultKind[] = ['s3object', 's3object-list', 'materialized', 'approval'] + /** Kinds whose markup pulls subresources: DOMPurify stops scripting but keeps + * `` and SVG ``, and `map` tiles are requests by + * construction. Kinds absent here carry their bytes as `data:` and reach nothing. + * Inert only on the public page, which promises to issue no requests. */ + const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map'] let length = $state(1) let hasBigInt = $state(false) @@ -350,7 +361,10 @@ keys.includes('filename') && keys.includes('autodownload') ) { - if (result.autodownload) { + // Not via REPLAY_INERT_KINDS: this download is a side effect *inside* kind + // inference, already done by the time a caller could reclassify. Replaying + // must never write caller-chosen bytes into the viewer's downloads. + if (result.autodownload && !isReplaying()) { const a = document.createElement('a') a.href = 'data:application/octet-stream;base64,' + result.file @@ -583,8 +597,19 @@ $effect(() => { ;[result] + const replaying = isReplaying() + const offlineReplay = isOfflineReplay() untrack(() => { resultKind = inferResultKind(result) + // A recording carries the result JSON, nothing the result points at, and a + // replay has no session to go get it: show the recorded value instead. + const inert = + (replaying && REPLAY_INERT_KINDS.includes(resultKind)) || + (offlineReplay && OFFLINE_INERT_KINDS.includes(resultKind)) + if (inert) { + resultKind = 'json' + largeObject = false + } }) }) $effect(() => { @@ -600,19 +625,27 @@ ) }) - // Per-test breakdown of a managed `// materialize` run, rendered as a - // checklist above the raw result. On success it rides the result - // (`data_tests: [{ test, violating }]`, a one-row array). On failure the job - // result is the error, whose message is the worker's breakdown text — parsed - // back into the same shape so the checklist shows on both outcomes. Both - // formats are produced by this repo's worker (see duckdb_executor.rs); the - // derivation is inert (undefined) for every other DisplayResult use. + // Per-test breakdown of a managed `// materialize` run. On success it rides the + // result as `data_tests`; on failure the job result is the error, whose message + // is the worker's breakdown text, parsed back into the same shape so the + // checklist shows either way. Inert for every other DisplayResult use. let dataTests = $derived.by(() => { - // Both structured shapes carry `[{ test, violating, sample? }]`; the - // sample (bounded violating-row rows) may arrive as a JSON string (the - // worker keeps it string-typed through the summary row) and is optional - // by contract — anything malformed degrades to no sample, never to a - // dropped checklist. + // `DataTestsResult` renders an item per entry, and the message-derived branch + // below builds them from *lines of text*, so no bound on the result's structure + // can see them. A run with this many tests is unreadable anyway, and the cap has + // to live where the parse happens rather than be predicted from the payload. + const MAX_RENDERED = 1000 + // A per-test `sample` arrives as its own JSON string, so nothing that measures + // the enclosing result's structure can see inside it — parsing an 8 MB string of + // `{}` would allocate millions of objects before any row cap applied. Bound the + // text first, then the rows. + const MAX_SAMPLE_CHARS = 256 * 1024 + const MAX_SAMPLE_ROWS = 1000 + const capped = (tests: T[]): T[] => + tests.length > MAX_RENDERED ? tests.slice(0, MAX_RENDERED) : tests + // Both shapes carry `[{ test, violating, sample? }]`. The sample may arrive as + // a JSON string and is optional by contract, so anything malformed degrades to + // no sample — never to a dropped checklist. const normalize = ( dt: any ): Array<{ test: string; violating: number; sample?: Record[] }> | undefined => { @@ -634,13 +667,15 @@ let sample = x.sample if (typeof sample === 'string') { try { - sample = JSON.parse(sample) + sample = sample.length > MAX_SAMPLE_CHARS ? undefined : JSON.parse(sample) } catch { sample = undefined } } if (!Array.isArray(sample) || !sample.every((r) => r && typeof r === 'object')) { sample = undefined + } else if (sample.length > MAX_SAMPLE_ROWS) { + sample = sample.slice(0, MAX_SAMPLE_ROWS) } return { test: x.test, violating: x.violating, sample } }) @@ -648,17 +683,18 @@ // Success: structured column on the summary row. const row = Array.isArray(result) ? (result as any)?.[0] : (result as any) const fromRow = normalize(row?.data_tests) - if (fromRow) return fromRow + if (fromRow) return capped(fromRow) // Failure: the worker attaches the same structured breakdown (plus // per-failed-test samples) to the error payload. const fromError = normalize((result as any)?.error?.data_tests) - if (fromError) return fromError + if (fromError) return capped(fromError) // Failure fallback for results predating the structured error payload: // parse the worker's breakdown out of the error message. const msg = (result as any)?.error?.message if (typeof msg === 'string' && msg.includes('data tests failed on')) { const out: Array<{ test: string; violating: number }> = [] for (const line of msg.split('\n')) { + if (out.length >= MAX_RENDERED) break const fail = line.match(/^\s*✗\s*(.+?)\s*—\s*(\d+)\s+violating/) const pass = line.match(/^\s*✓\s*(.+?)\s*$/) if (fail) out.push({ test: fail[1], violating: parseInt(fail[2], 10) }) diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 0b311ebe60..1769e0386f 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -10,6 +10,7 @@ import SchemaForm from './SchemaForm.svelte' import { twMerge } from 'tailwind-merge' import { untrack } from 'svelte' + import { isReplaying } from './recording/offlineReplay.svelte' interface Props { isOwner: boolean @@ -82,68 +83,80 @@ } } let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) + // Everything this panel shows (description, resume form enums, approval page) is + // fetched from the suspended job — a recording carries none of it — and Resume / + // Cancel act on a job that only exists in the recording. So a replay states the + // recorded fact and offers nothing to click. + let replaying = $derived(isReplaying()) $effect(() => { - job && untrack(() => getDefaultArgs()) + if (!replaying) { + job && untrack(() => getDefaultArgs()) + } }) -
- {#if description != undefined} - -
- {/if} -
-
- {#if !hide_cancel} +{#if replaying} +
This step was waiting for approval.
+{:else} +
+ {#if description != undefined} + +
+ {/if} +
+
+ {#if !hide_cancel} +
+
+ {/if}
- {/if} -
- + + {#if approvalPageUrl} + + Approval page + + {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
+ +
+ + The payload is optional, it is passed to the following step through the `resume` + variable + + {/if}
- - {#if approvalPageUrl} - - Approval page - - {/if} - - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
- -
- - The payload is optional, it is passed to the following step through the `resume` variable - - {/if}
-
+{/if} diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 377951b97b..01aeeef9f0 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,16 +1,28 @@
- + {#if asPlainText} +

{md}

+ {:else} + + {/if}

everything here is private

` + ) + doc.documentElement.setAttribute('data-wm-no-record', '') + doc.documentElement.setAttribute('class', 'theme-dark') + doc.documentElement.setAttribute('hidden', '') + doc.documentElement.setAttribute('cite', 'https://host/private-source') + + const html = serializeDocument(doc) + expect(html).not.toContain('everything here is private') + expect(html).not.toContain('private-source') + expect(html).not.toContain('theme-dark') + expect(html).toContain('hidden') + }) + + it('keeps no attribute that could carry content, listed or not', () => { + const doc = docFrom( + `` + + `` + ) + const html = serializeDocument(doc) + expect(html).not.toContain('embedded secret') + expect(html).not.toContain('future secret') + expect(html).not.toContain('/cited') + expect(html).not.toContain('salary-92000') + // The layout-bearing class survives: the snapshot's own CSS selects on it. + expect(html).toContain('class="frame"') + }) + + it('does not let a marked stylesheet justify keeping the class it selects', () => { + // The marked sheet is scrubbed, so its selectors are not part of the + // snapshot's vocabulary: honouring them would launder the very token the + // author marked the sheet to withhold. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).not.toContain('salary-92000') + }) + + it('keeps only the class and id tokens the snapshot styles', () => { + // `class` and `id` stay on a redacted element so its box keeps its shape, + // but the values are the app's to choose and can name what the marker hides. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="card"') + expect(html).not.toContain('customer-acme-secret') + expect(html).not.toContain('salary-92000') + }) + + it('withholds even the state of a redacted control', () => { + // Whether a marked box is ticked is exactly what the marker exists to hide; + // the step's value is masked to match, so label and snapshot agree. + const doc = docFrom(``) + const box = doc.querySelector('input') as HTMLInputElement + box.checked = true + + const html = serializeDocument(doc) + expect(html).not.toContain('checked') + expect(html).not.toContain('acquisition target') + }) + + it('does not launder a marked stylesheet into the snapshot by inlining it', () => { + // The inliner exists for sheets whose rules live only in the CSSOM (an empty + // `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="2xl:block !flex"') + }) + + it('keeps a utility class whose selector is escaped', () => { + // A framework writes `md:flex` as `.md\\:flex`; reading the selector up to the + // backslash would drop the real token and leave the placeholder unstyled. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="md:flex w-1/2"') + expect(html).not.toContain('not-styled-92000') + }) + + it('paints a canvas into the snapshot, but never a redacted one', () => { + // A canvas keeps its picture in a bitmap `outerHTML` cannot see, so without + // this the chart replays blank. A marked one must stay blank: the painted + // background rides on `style`, which redaction strips. + const doc = docFrom( + `
` + ) + for (const c of Array.from(doc.querySelectorAll('canvas'))) { + Object.defineProperty(c, 'width', { value: 200 }) + Object.defineProperty(c, 'height', { value: 100 }) + Object.defineProperty(c, 'toDataURL', { + value: () => `data:image/webp;base64,PIXELS-${c.id}` + }) + Object.defineProperty(c, 'getBoundingClientRect', { + value: () => ({ width: 200, height: 100 }) + }) + } + + const html = serializeDocument(doc) + expect(html).toContain('PIXELS-chart') + expect(html).not.toContain('PIXELS-secret') + }) + + it('stops encoding once a snapshot has spent its canvas budget', () => { + // Encoding is synchronous and on the app's event path, so a wall of charts + // must not each cost an encode — the per-canvas cap alone would allow it. + const doc = docFrom( + Array.from({ length: 6 }, (_, i) => ``).join('') + ) + const encoded: string[] = [] + for (const c of Array.from(doc.querySelectorAll('canvas'))) { + Object.defineProperty(c, 'width', { value: 2000 }) + Object.defineProperty(c, 'height', { value: 1500 }) // 3M pixels each + Object.defineProperty(c, 'getBoundingClientRect', { + value: () => ({ width: 200, height: 150 }) + }) + Object.defineProperty(c, 'toDataURL', { + value: () => { + encoded.push(c.id) + return `data:image/webp;base64,PIXELS-${c.id}` + } + }) + } + + serializeDocument(doc) + expect(encoded).toEqual(['c0', 'c1']) + }) + + it('keeps a disabled sheet inert without shifting what follows it', () => { + // Neutralizing a disabled sheet must not remove its node: every later path + // resolution — other sheets, and the target stamp — is by sibling index. + const off = document.createElement('style') + off.textContent = `.disabled-rule { color: red; }` + const on = document.createElement('style') + document.head.append(off, on) + try { + off.sheet!.disabled = true + on.sheet?.insertRule(`.live-rule { color: green; }`, 0) + + const html = serializeDocument(document) + expect(html).toContain('live-rule') + expect(html).not.toContain('disabled-rule') + expect(html).toContain('media="not all"') + } finally { + off.remove() + on.remove() + } + }) + + it('drops
+ {/if} +
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ac927801dc..2f42a1889d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -138,9 +138,20 @@ const config = { name: 'server', environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/**/*.dom.{test,spec}.{js,ts}'], setupFiles: ['src/lib/test-setup.ts'] } + }, + { + // `*.dom.test.ts` — for the pure DOM utilities (snapshot serialization, + // replay sanitization) whose contracts can only be asserted against a + // real document. + extends: './vite.config.js', + test: { + name: 'dom', + environment: 'jsdom', + include: ['src/**/*.dom.{test,spec}.{js,ts}'] + } } ] } diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index cb933dfb0d..1d8449817f 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -647,6 +647,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.(args)\` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +\`\`\`tsx + +\`\`\` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -749,6 +761,7 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — \`get_user\`, not \`a\`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first. +6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. `; export const PIPELINE_BASE = `# Data pipeline authoring diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 2251af985f..d3c3c78bc3 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -284,6 +284,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +```tsx + +``` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -386,3 +398,4 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — `get_user`, not `a`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. +6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 63f26de115..622484ad69 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -49,6 +49,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +```tsx + +``` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -151,3 +163,4 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — `get_user`, not `a`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. +6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.