docs(04): research phase 4 — review, download & security

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 10:16:00 +01:00
co-authored by Claude Sonnet 4.6
parent 3185e46fd1
commit 0397bc96c9
@@ -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>
## 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 |
</phase_requirements>
---
## 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<void> {
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<void> {
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
<label>
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
I understand that the generated files contain credentials in plain text.
</label>
// Download button
<button disabled={!acknowledged} onClick={() => downloadFile(content, filename)}>
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 `<pre>` / `<code>` 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 = [
<BackendSelectionStep key="backend" />,
<RemoteConfigStep key={state.remote.backendType ?? 'none'} />,
<DeploymentStep key="deployment" />,
<ReviewStep key="review" />, // 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<void> {
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)