From 0397bc96c9b0ebbb48f380e1adc4e311b6aef599 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 27 Mar 2026 10:16:00 +0100 Subject: [PATCH] =?UTF-8?q?docs(04):=20research=20phase=204=20=E2=80=94=20?= =?UTF-8?q?review,=20download=20&=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../04-RESEARCH.md | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 .planning/phases/04-review-download-security/04-RESEARCH.md diff --git a/.planning/phases/04-review-download-security/04-RESEARCH.md b/.planning/phases/04-review-download-security/04-RESEARCH.md new file mode 100644 index 0000000..d6957ef --- /dev/null +++ b/.planning/phases/04-review-download-security/04-RESEARCH.md @@ -0,0 +1,432 @@ +# Phase 4: Review, Download & Security — Research + +**Researched:** 2026-03-27 +**Domain:** Browser-side file generation, download triggers, clipboard API, ZIP bundling, security UX, React wizard step +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| CONF-02 | User sees a live preview of the generated rclone.conf content as they fill the form | `buildRcloneConf(state)` already exists — call it in a React component that subscribes to WizardContext; update on every state change via `useMemo` | +| CONF-03 | User can copy the rclone.conf content to clipboard | `navigator.clipboard.writeText(text)` — no library needed; HTTPS / localhost required | +| DOWN-01 | User can download the rclone.conf file individually | Blob download helper: `new Blob([content], { type: 'text/plain' })` + anchor click | +| DOWN-02 | User can download the Intune install script individually | Same Blob helper, filename `intune-install.ps1` | +| DOWN-03 | User can download the Intune detection script individually | Same Blob helper, filename `intune-detection.ps1` | +| DOWN-04 | User can download the RMM script individually | Same Blob helper, filename `rmm-script.ps1` | +| DOWN-05 | User can download all artifacts as a single ZIP bundle | JSZip `generateAsync({ type: 'blob' })` + anchor click; no FileSaver needed | +| DOWN-06 | User can copy any output block to clipboard directly | Same `navigator.clipboard.writeText` as CONF-03, applied per-block | +| SECU-01 | Security warning gate — credentials are plaintext — user must acknowledge before downloads enabled | Checkbox + boolean gate on all download buttons; no library needed | +| SECU-02 | App prominently states no data is sent to any server | Static banner / badge in ReviewStep UI | +| SECU-03 | Wizard state never persisted to localStorage, sessionStorage, or any external service | Already enforced by `types.ts` SECURITY comment and pure in-memory useReducer; verification test needed | + + +--- + +## Summary + +Phase 4 is the final layer of the Ready2Blob wizard. It adds a fourth wizard step (`ReviewStep`, step index 3) that renders live-generated file content, enforces a security acknowledgement gate, exposes individual download buttons, a clipboard copy button per output block, and a ZIP bundle download. All operations are client-side only. + +The project already has all four generator functions (`buildRcloneConf`, `buildIntuneInstall`, `buildIntuneDetection`, `buildRmmScript`) exported from `src/generators/index.ts`. The wizard state is in-memory React context (useReducer). App.tsx already clamps step index and is explicitly annotated "phase 4 will add step 3" — the extension point is ready. + +The two non-trivial browser APIs are (1) blob URL download (zero dependencies, native since 2012) and (2) JSZip for the ZIP bundle (the only new dependency this phase adds). Clipboard is a native async API — no library needed. + +**Primary recommendation:** Add one new component `ReviewStep`, wire it as step 3 in `App.tsx`, and introduce JSZip as the single new runtime dependency. Everything else is native browser APIs on top of existing generator functions. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React + useContext | 18.3 (already installed) | State access in ReviewStep | Already the project state pattern | +| Native Blob API | Browser built-in | File content → downloadable file | No dependency, ~100% modern browser support | +| `navigator.clipboard` | Browser built-in | Copy text to clipboard | Modern async API, secure-context only | +| JSZip | ^3.10.1 | Client-side ZIP generation | 14M+ weekly downloads, ships own TS types, zero native dependencies | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `URL.createObjectURL` | Browser built-in | Turn a Blob into a downloadable URL | Used inside the blob download helper | +| `URL.revokeObjectURL` | Browser built-in | Release Blob memory after download | Must call after anchor click to prevent memory leak | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| JSZip | `client-zip` (Touffy) | client-zip is streaming and lighter but less documented; JSZip is the established standard | +| JSZip | FileSaver.js | FileSaver is a wrapper only, adds no value when you already have a Blob — not needed | +| `navigator.clipboard` | `document.execCommand('copy')` | execCommand is deprecated; clipboard API is the correct modern approach | + +**Installation (new dependency only):** +```bash +npm install jszip +``` + +JSZip ships its own TypeScript declarations. No `@types/jszip` needed (that package is 6 years stale). + +--- + +## Architecture Patterns + +### Recommended Project Structure +``` +src/ +├── components/ +│ └── wizard/ +│ ├── ReviewStep.tsx # new — step 3, main Phase 4 component +│ └── OutputBlock.tsx # new — reusable code block with copy + download buttons +├── generators/ +│ └── index.ts # unchanged — already exports all four generators +└── App.tsx # update — add ReviewStep as step index 3 +``` + +### Pattern 1: Blob Download Helper (pure utility function) + +**What:** A standalone function that accepts file content (string), MIME type, and filename, creates a Blob, generates an object URL, programmatically clicks a hidden anchor, then revokes the URL. + +**When to use:** DOWN-01, DOWN-02, DOWN-03, DOWN-04 (individual file downloads) + +```typescript +// src/utils/downloadFile.ts +export function downloadFile(content: string, filename: string, mimeType = 'text/plain'): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} +``` + +### Pattern 2: ZIP Bundle Download (JSZip) + +**What:** Calls all four generators, adds each output to a JSZip instance, generates a Blob asynchronously, then triggers download via the same Blob/anchor pattern. + +**When to use:** DOWN-05 (ZIP bundle) + +```typescript +// Source: https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html +import JSZip from 'jszip'; +import { downloadFile } from '../utils/downloadFile'; + +async function downloadZip(state: WizardState): Promise { + const zip = new JSZip(); + zip.file('rclone.conf', buildRcloneConf(state)); + zip.file('intune-install.ps1', buildIntuneInstall(state)); + zip.file('intune-detection.ps1', buildIntuneDetection(state)); + zip.file('rmm-script.ps1', buildRmmScript(state)); + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'rclone-deployment.zip'; + a.click(); + URL.revokeObjectURL(url); +} +``` + +### Pattern 3: Clipboard Copy (navigator.clipboard) + +**What:** Async call to `navigator.clipboard.writeText(text)`. Must be triggered by a user gesture (button click). Requires secure context (HTTPS or localhost — both are satisfied in dev and production for this app). + +**When to use:** CONF-03, DOWN-06 (copy any output block) + +```typescript +// Source: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText +async function copyToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} +``` + +For UI feedback (button label changes to "Copied!" for 2 seconds), use a local `useState` boolean in the OutputBlock component — no external library needed. + +### Pattern 4: Security Gate (SECU-01) + +**What:** A React `useState` boolean `acknowledged` controls whether all download buttons are enabled. The user must check a checkbox to set it true. Buttons use `disabled={!acknowledged}`. + +**When to use:** All download and copy buttons are gated behind this — render them, but disable until acknowledged. + +```typescript +const [acknowledged, setAcknowledged] = useState(false); + +// Warning UI + + +// Download button + +``` + +### Pattern 5: Live Config Preview (CONF-02) + +**What:** Call `buildRcloneConf(state)` inside a `useMemo` that depends on the full wizard state. Render in a `
` / `` block. Because WizardContext updates on every dispatch, the preview updates in real time as the user fills in the form.
+
+**When to use:** CONF-02 (live preview of rclone.conf)
+
+```typescript
+const { state } = useWizard();
+
+const rcloneConfContent = useMemo(() => {
+  try {
+    return buildRcloneConf(state);
+  } catch {
+    // backendType or name not yet set — show placeholder
+    return '# Fill in the wizard to generate your rclone.conf';
+  }
+}, [state]);
+```
+
+`buildRcloneConf` throws when `backendType` is null or `name` is empty — the try/catch makes the preview gracefully show a placeholder during incomplete form states.
+
+### Pattern 6: Wiring ReviewStep into App.tsx
+
+App.tsx already clamps `stepIndex` to `steps.length - 1`. To add step 3, import `ReviewStep` and add it to the `steps` array. No other changes needed.
+
+```typescript
+// App.tsx change — add one import and one array entry
+import { ReviewStep } from './components/wizard/ReviewStep';
+
+const steps = [
+  ,
+  ,
+  ,
+  ,   // step index 3
+];
+```
+
+StepIndicator STEP_LABELS must also gain a fourth entry: `'Review'`.
+
+### Anti-Patterns to Avoid
+
+- **Adding FileSaver.js:** Unnecessary — `URL.createObjectURL` + anchor click achieves the same result with zero dependencies.
+- **Importing from individual generator files:** Always import from `src/generators/index.ts` barrel — the barrel was explicitly annotated for Phase 4 use.
+- **Calling generators unconditionally when state is incomplete:** Wrap in try/catch (or check `state.remote.backendType !== null`) to prevent thrown errors crashing the ReviewStep render.
+- **Persisting state to localStorage for "convenience":** SECU-03 explicitly forbids this. The types.ts file has a SECURITY comment to this effect.
+- **Showing downloads before acknowledgement:** All download and copy-to-clipboard actions must be disabled until the SECU-01 checkbox is checked.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| ZIP archive creation | Custom ZIP binary writer | JSZip `generateAsync` | ZIP format has CRC32, compression, local/central directory headers — complex binary encoding |
+| Clipboard write | `document.execCommand('copy')` | `navigator.clipboard.writeText()` | execCommand is deprecated in all major browsers as of 2023 |
+| File download | Server-side download endpoint | Blob + createObjectURL pattern | No server exists; Blob approach is idiomatic for pure-frontend apps |
+
+**Key insight:** All three "hard-looking" problems (ZIP, clipboard, download) have mature browser-native or single-library solutions. The actual complexity in Phase 4 is UI composition and security gate wiring, not the download mechanics.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: buildRcloneConf throws on incomplete state
+**What goes wrong:** `buildRcloneConf` throws `Error: backendType is required` when called before the user has selected a backend. If called unconditionally during render, this crashes the component.
+**Why it happens:** ReviewStep will be mounted as step 3, but the user could theoretically arrive with incomplete state (or state is observed mid-dispatch).
+**How to avoid:** Always wrap generator calls in try/catch in the preview/render path. For download buttons, additionally disable them when `state.remote.backendType === null`.
+**Warning signs:** Uncaught error in console when navigating to step 3 without completing earlier steps.
+
+### Pitfall 2: URL.revokeObjectURL called too early
+**What goes wrong:** The download is cancelled or fails if `revokeObjectURL` is called synchronously before the browser has processed the anchor click.
+**Why it happens:** `a.click()` is synchronous but the browser's download pipeline is asynchronous. In practice the click initiates the download before revocation, but best practice is to revoke in a `setTimeout` with 0ms delay or immediately after click (the URL stays valid just long enough).
+**How to avoid:** Call `URL.revokeObjectURL(url)` immediately after `a.click()` — modern browsers queue the download before processing the revocation. This is the standard pattern per MDN.
+
+### Pitfall 3: navigator.clipboard requires secure context
+**What goes wrong:** `navigator.clipboard` is `undefined` in HTTP contexts (not localhost, not HTTPS).
+**Why it happens:** Browser security policy — clipboard write access is restricted to secure origins.
+**How to avoid:** In production this app will be served over HTTPS. In development, Vite's dev server runs on localhost which is a secure context. No workaround needed. If testing outside localhost, use HTTPS.
+**Warning signs:** `Cannot read properties of undefined (reading 'writeText')` in the console.
+
+### Pitfall 4: Vitest/jsdom lacks URL.createObjectURL
+**What goes wrong:** Tests that call `downloadFile()` or `downloadZip()` throw `TypeError: URL.createObjectURL is not a function` because jsdom does not implement it.
+**Why it happens:** jsdom omits browser download/blob APIs that have no meaningful DOM simulation.
+**How to avoid:** Mock `URL.createObjectURL` and `URL.revokeObjectURL` in test setup or per-test: `vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() })`. Test the generator output separately (already tested in Phase 2), test that the download function was called with the right arguments.
+
+### Pitfall 5: Intune/RMM scripts not in ZIP when scriptTargets is partial
+**What goes wrong:** User unchecked 'rmm' in DeploymentStep — the ZIP bundle should omit the RMM script, or should it always include all four files?
+**Why it happens:** The requirements say "all artifacts as a ZIP bundle" (DOWN-05) without specifying conditional inclusion.
+**How to avoid:** Simplest and safest: always include all four files in the ZIP. The deployment options (`scriptTargets`) control what the generated scripts DO, not what files appear. This avoids confusing partial ZIPs and matches the "download any combination" goal.
+
+---
+
+## Code Examples
+
+Verified patterns from browser platform APIs and JSZip official docs:
+
+### Individual File Download (no dependency)
+```typescript
+// Source: MDN Web Docs — https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static
+export function downloadFile(content: string, filename: string): void {
+  const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.click();
+  URL.revokeObjectURL(url);
+}
+```
+
+### ZIP Bundle Download (JSZip)
+```typescript
+// Source: https://stuk.github.io/jszip/documentation/examples/download-zip-file.html (adapted)
+import JSZip from 'jszip';
+
+export async function downloadZip(files: { name: string; content: string }[], zipName: string): Promise {
+  const zip = new JSZip();
+  for (const { name, content } of files) {
+    zip.file(name, content);
+  }
+  const blob = await zip.generateAsync({ type: 'blob' });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = zipName;
+  a.click();
+  URL.revokeObjectURL(url);
+}
+```
+
+### Clipboard Copy with Feedback
+```typescript
+// Source: MDN — https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText
+import { useState } from 'react';
+
+export function useCopyToClipboard() {
+  const [copied, setCopied] = useState(false);
+
+  async function copy(text: string) {
+    await navigator.clipboard.writeText(text);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  }
+
+  return { copy, copied };
+}
+```
+
+### SECU-03 Verification Pattern (test)
+```typescript
+// Verify localStorage and sessionStorage are never touched
+it('SECU-03: wizard state never written to storage', () => {
+  const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
+  // render full app, simulate dispatch actions
+  expect(setItemSpy).not.toHaveBeenCalled();
+});
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `document.execCommand('copy')` | `navigator.clipboard.writeText()` | Deprecated ~2020, removed intent Chrome 2023 | Must use async Clipboard API |
+| FileSaver.js for downloads | Native Blob + createObjectURL | Blob API well-supported since 2013 | No extra dependency for downloads |
+| `@types/jszip` separate package | JSZip ships own TypeScript types | JSZip 3.x series | Only install `jszip`, not `@types/jszip` |
+
+**Deprecated/outdated:**
+- `document.execCommand('copy')`: Deprecated. Do not use.
+- `@types/jszip`: Last published 6 years ago, stale — JSZip 3.x includes its own types.
+
+---
+
+## Open Questions
+
+1. **Should the ZIP always include all 4 files regardless of `scriptTargets`?**
+   - What we know: DOWN-05 says "all artifacts as a single ZIP bundle"; `scriptTargets` controls which scripts are generated but not what the user can download.
+   - What's unclear: Whether omitting unchecked script targets from the ZIP is a desirable UX or confusing.
+   - Recommendation: Always include all four files in the ZIP. Simpler, less surprising.
+
+2. **Should individual download buttons for Intune/RMM scripts be hidden when that target is unchecked in DeploymentStep?**
+   - What we know: Requirements say "download each artifact individually" without conditionality.
+   - Recommendation: Show all four download buttons always. A user may want to download an Intune script even if they unchecked it during deployment config.
+
+3. **Does `buildRcloneConf` need to be called defensively in the live preview when the backend form is only partially filled?**
+   - What we know: `buildRcloneConf` throws only for null backendType or empty name — partial params (e.g., empty password) are allowed (they're filtered out with `if (value !== '')`).
+   - Recommendation: Wrap in try/catch; show placeholder text when thrown. Separate issue from download gate.
+
+---
+
+## Validation Architecture
+
+nyquist_validation is enabled in `.planning/config.json`.
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest ^4.1.1 |
+| Config file | `vite.config.ts` (test block, environment: jsdom) |
+| Quick run command | `npm test` |
+| Full suite command | `npm test` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CONF-02 | rclone.conf preview updates when state changes | unit | `npm test -- --reporter=verbose src/components/wizard/ReviewStep.test.tsx` | Wave 0 |
+| CONF-03 | clipboard.writeText called with correct conf content | unit | same file | Wave 0 |
+| DOWN-01 | downloadFile called with rclone.conf content and filename | unit | same file | Wave 0 |
+| DOWN-02 | downloadFile called with intune-install.ps1 content | unit | same file | Wave 0 |
+| DOWN-03 | downloadFile called with intune-detection.ps1 content | unit | same file | Wave 0 |
+| DOWN-04 | downloadFile called with rmm-script.ps1 content | unit | same file | Wave 0 |
+| DOWN-05 | zip.generateAsync called; anchor download triggered with .zip name | unit | same file | Wave 0 |
+| DOWN-06 | clipboard.writeText called for each output block's copy button | unit | same file | Wave 0 |
+| SECU-01 | download buttons disabled when acknowledged=false; enabled after check | unit | same file | Wave 0 |
+| SECU-02 | "no data sent to server" text present in ReviewStep render | unit | same file | Wave 0 |
+| SECU-03 | localStorage.setItem / sessionStorage.setItem never called | unit | `npm test -- src/store/reducer.test.ts` | exists (add assertion) |
+
+### Sampling Rate
+- **Per task commit:** `npm test`
+- **Per wave merge:** `npm test`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `src/components/wizard/ReviewStep.test.tsx` — covers CONF-02, CONF-03, DOWN-01 through DOWN-06, SECU-01, SECU-02
+- [ ] Mock setup for `URL.createObjectURL`, `URL.revokeObjectURL`, `navigator.clipboard.writeText` — required for download and clipboard tests in jsdom
+- [ ] `src/utils/downloadFile.ts` — utility referenced by tests (created in implementation wave)
+
+*(SECU-03 can be tested by adding a Storage.prototype.setItem spy to the existing `reducer.test.ts`)*
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- JSZip official docs — https://stuk.github.io/jszip/documentation/examples/download-zip-file.html — generateAsync API, blob download example
+- JSZip generateAsync API — https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html — type:'blob' parameter
+- MDN Clipboard API — https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API — writeText, secure context requirement
+- MDN Blob — https://developer.mozilla.org/en-US/docs/Web/API/Blob — Blob constructor, type parameter
+- Project source files (generators/index.ts, store/types.ts, App.tsx, vite.config.ts) — confirmed existing API surfaces
+
+### Secondary (MEDIUM confidence)
+- JSZip npm page — https://www.npmjs.com/package/jszip — version 3.10.1, weekly downloads, TypeScript types bundled
+- LogRocket Clipboard API in React — https://blog.logrocket.com/implementing-copy-clipboard-react-clipboard-api/ — React pattern confirmation
+- CoreUI file downloader in React — https://coreui.io/answers/how-to-create-a-file-downloader-in-react/ — Blob + anchor pattern confirmation
+
+### Tertiary (LOW confidence)
+- None — all claims verified against official docs or project source.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — JSZip is the dominant client-side ZIP library; Blob/Clipboard are native APIs documented on MDN
+- Architecture: HIGH — generator functions and WizardContext are already implemented; extension points are explicit in source code
+- Pitfalls: HIGH — jsdom URL.createObjectURL gap is a well-known vitest/jest issue; the other pitfalls derive from the existing codebase
+
+**Research date:** 2026-03-27
+**Valid until:** 2026-06-27 (stable APIs — Blob, Clipboard, JSZip 3.x all in maintenance mode)