Files
2026-03-27 10:16:00 +01:00

22 KiB

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):

npm install jszip

JSZip ships its own TypeScript declarations. No @types/jszip needed (that package is 6 years stale).


Architecture Patterns

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)

// 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)

// 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)

// 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.

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)

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.

// 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)

// 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)

// 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

// 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)

// 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)

Secondary (MEDIUM confidence)

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)