Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Changelog

## 1.0.0

This section describes the planned v1.0.0 release candidate. The Git tag and GitHub release are intentionally separate release-manager actions and are not created by Sprint 11.

### PDF tools

- Convert ordered JPEG, PNG, and WebP images into a PDF.
- Merge, split, organize, and export PDFs locally without rasterizing source pages.
- Convert PDF pages into PNG, JPEG, or WebP files and predictable ZIP archives.
- Inspect supported document-info metadata and save a verified cleaned copy.

### Privacy

- Process file contents locally in the browser with no upload service, accounts, analytics, advertising, or tracking pixels.
- Keep theme and language preferences as the only application values stored in `localStorage`.
- Serve processing libraries, locale catalogs, fonts, modules, and workers from the same origin.

### Accessibility

- Provide semantic controls, keyboard-operable queues and page ordering, visible focus treatment, live status messaging, and reduced-motion foundations.
- Preserve native file-input paths alongside drag-and-drop interactions.

### Localization

- Provide complete English, Korean, Japanese, Spanish, German, and French interface catalogs.
- Detect supported browser languages, persist manual selection, and update document language and metadata without resetting tool state.

### Security

- Enforce a restrictive Content Security Policy with no inline code, runtime CDN, remote API, or evaluation exception.
- Validate supported file signatures and apply queue, file-size, image-dimension, decoded-pixel, render, and metadata-display limits.
- Pin four audited browser dependencies with package metadata, licenses, upstream provenance, and runtime hashes in the repository.

### Reliability

- Cover corrupt, encrypted, empty, spoofed, boundary-size, repeated-operation, deterministic-naming, cancellation, and save-error paths.
- Use a shared File System Access save path when supported and a revoking Blob-download fallback elsewhere.
- Run syntax, functional, route/resource, privacy/network, security, localization, responsive-contract, accessibility, CI, dependency-integrity, and save-path checks through one test command.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Secure Tools is a privacy-first web hub for everyday file utilities. Production

The project uses a category-first architecture, a Pull Request/main CI gate, and static GitHub Pages deployment.

Release-candidate context is recorded in the [v1.0.0 changelog](./CHANGELOG.md) and [release QA checklist](./docs/release-qa.md). The v1.0.0 Git tag and GitHub release are deliberate release-manager actions after manual sign-off; Sprint 11 does not create them.

## Available tools

- [Images to PDF](./tools/pdf/images-to-pdf/) — arrange JPEG, PNG, and WebP images and save them as one PDF.
Expand Down Expand Up @@ -91,9 +93,10 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E
│ └── image-to-pdf/
│ └── index.html
├── assets/vendor/
│ ├── jszip/
│ ├── jspdf/
│ └── pdf-lib/
│ ├── jszip/
│ ├── pdf-lib/
│ └── pdfjs/
└── tests/
├── ci-foundation.test.mjs
├── home-structure.test.mjs
Expand Down Expand Up @@ -263,7 +266,7 @@ Run the complete local and CI validation entry point with:
node tests/run-all.mjs
```

It checks JavaScript syntax and runs Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, ZIP, and CI workflow regression coverage. PDF fixtures are generated deterministically during tests; CI never processes real user files.
It checks JavaScript syntax and runs Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, dependency-integrity, save-path, ZIP, and CI workflow regression coverage. PDF fixtures are generated deterministically during tests; CI never processes real user files.

## Production security controls

Expand Down
104 changes: 104 additions & 0 deletions docs/release-qa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# v1.0.0 Release QA

This document is the release-candidate gate for Secure Tools v1.0.0. It records completed automated evidence separately from manual browser sign-off. An unchecked item is pending, not implicitly passed.

## Current decision

- Automated release gate: passed on 2026-08-20 with `node tests/run-all.mjs` and `git diff --check`.
- Source-level privacy, security, dependency, license, route, resource, localization, responsive-contract, and accessibility audits: passed.
- Manual browser matrix: pending.
- v1.0.0 tag and GitHub release: not created by Sprint 11.

The Sprint is eligible for integration after CI passes, but publication of the v1.0.0 tag/release remains blocked until a human completes the manual matrix below. This manual gate does not invalidate the automated Sprint result.

## Automated release evidence

| Area | Result | Evidence |
| --- | --- | --- |
| Six production PDF tools | Pass | Functional, repeated-operation, corrupt/encrypted input, boundary, naming, cancellation, and output tests |
| Routes and resources | Pass | Production-route, relative-resource, legacy redirect, and category-navigation tests |
| Privacy and network | Pass | No first-party runtime `fetch`, XHR, WebSocket, EventSource, or beacon APIs; `connect-src 'none'` |
| CSP and code loading | Pass | Same-origin scripts/styles/workers; no inline code, `unsafe-inline`, `unsafe-eval`, runtime CDN, or remote worker fallback |
| Accessibility contracts | Pass | Semantic controls, labels, status regions, keyboard alternatives, focus/reduced-motion, and responsive source checks |
| Localization | Pass | Catalog parity, placeholders, detection, persistence, metadata, dynamic state, and long-copy checks for six languages |
| Save behavior | Pass | File System Access options/order, unsupported-browser fallback, Blob URL revocation, and write-failure propagation |
| CI configuration | Pass | Pull request and `main` triggers, Node.js 24, least-privilege permissions, whitespace gate, and authoritative test command |

The local Node runner emits `MODULE_TYPELESS_PACKAGE_JSON` warnings because a parent user-level `package.json` does not declare a module type. All modules and tests execute successfully; the warning is outside this static repository and is not a release blocker.

## Dependency and license audit

The approved inventory contains one version of each runtime dependency and no additional vendor directory. Full package provenance and SHA-256 values remain beside each build in its linked vendor README; `tests/release-gate.test.mjs` verifies the exact file inventory, metadata, and runtime hashes.

| Dependency | Version | Purpose | Project license choice | Audit result |
| --- | --- | --- | --- | --- |
| [jsPDF](../assets/vendor/jspdf/README.md) | 4.2.1 | Images to PDF | MIT | Pass |
| [pdf-lib](../assets/vendor/pdf-lib/README.md) | 1.17.1 | PDF inspection, cleaning, copying, merge, split, and organization | MIT | Pass |
| [JSZip](../assets/vendor/jszip/README.md) | 3.10.1 | Local split and image ZIP archives | MIT from the package's MIT/GPL option | Pass |
| [PDF.js](../assets/vendor/pdfjs/README.md) | 6.2.108 | Local rendering for Organizer and PDF to Images | Apache-2.0 | Pass |

## Defect classification

A blocker is any reproducible data-loss/corruption issue, broken primary workflow, unexplained network request, privacy-claim violation, inaccessible keyboard path, unusable supported viewport/locale/theme combination, failed save/download, missing license, integrity mismatch, or failing automated/CI check. Fix and retest before release.

A non-blocker is a cosmetic issue that does not hide content, change meaning, block interaction, reduce privacy, or impair supported accessibility. Record it with the route, browser, viewport, locale, theme, reproduction steps, screenshot, and follow-up owner. Ambiguous findings are blockers until triaged.

## Manual browser prerequisites

Use current stable Chromium, Firefox, and Safari/WebKit where available. Serve the repository over HTTP, start from cleared site preferences when testing detection, use synthetic non-sensitive fixtures, keep DevTools Network open with cache disabled, and verify that processing causes no request beyond same-origin static resources already loaded by navigation.

A bounded in-app browser attempt on 2026-08-20 failed before browser connection or page rendering because the Windows sandbox helper could not apply its read ACLs. It produced no visual evidence. No alternative automated browser result is claimed, and every item below remains pending for human sign-off.

## Viewports and global chrome

At each width, check the homepage, every category route, every production tool route, Privacy, About, and the 404 page. Confirm no horizontal page overflow, overlap, clipping, inaccessible navigation, truncated controls, or obscured focus indicators.

- [ ] 320 CSS px
- [ ] 360 CSS px
- [ ] 390 CSS px
- [ ] 768 CSS px
- [ ] 1024 CSS px
- [ ] 1280 CSS px or wider

## Locale and theme matrix

For each row, test Light, Dark, and System. Confirm correct `<html lang>`, translated title/metadata/controls/status copy, preserved tool state after switching, readable contrast, stable header layout, and no clipped long labels.

| Locale | Representative width | Light | Dark | System |
| --- | ---: | :---: | :---: | :---: |
| English (`en`) | 390 px | [ ] | [ ] | [ ] |
| 한국어 (`ko`) | 360 px | [ ] | [ ] | [ ] |
| 日本語 (`ja`) | 360 px | [ ] | [ ] | [ ] |
| Español (`es`) | 390 px | [ ] | [ ] | [ ] |
| Deutsch (`de`) | 320 px | [ ] | [ ] | [ ] |
| Français (`fr`) | 320 px | [ ] | [ ] | [ ] |

Repeat at least one desktop width for every locale and theme. For System, change the OS preference while the page is open and confirm that System follows it while explicit Light/Dark selections do not.

## High-priority interaction checklist

- [ ] Header controls remain operable with German and French at 320 px and Korean and Japanese at 360 px.
- [ ] Homepage category navigation matches the intended category-first information architecture; no retired Ready/tool-grid section returns.
- [ ] Native file selection and drag-and-drop both work; canceling a picker does not alter an existing queue.
- [ ] Queue cards support long filenames, duplicate handling, removal, clearing, and keyboard reordering without layout loss.
- [ ] Images to PDF preserves order and options, rejects invalid/oversized images clearly, and recovers after generation/save failure.
- [ ] Merge PDF handles multiple files, duplicates, reordering, encrypted/corrupt rejection, repeated merges, and saving.
- [ ] Split PDF handles range/per-page/interval modes, deterministic filenames, ZIP saving, repeated runs, and validation errors.
- [ ] PDF Organizer populated state supports preview loading, pointer and button reordering, rotation, removal, reset, export, and source replacement.
- [ ] PDF to Images supports format/scale/quality changes, ordered preview/output, progress, cancellation/source replacement, single download, and ZIP download.
- [ ] Metadata populated state safely displays long and multilingual values, distinguishes missing fields, supports selected/all removal, verifies output, and supports repeated cleaning.
- [ ] Empty, corrupt, encrypted/password-protected, zero-byte, spoofed, oversized, and unsupported inputs produce localized recoverable errors.
- [ ] File System Access saving works where supported; canceling is harmless; Blob-download fallback works elsewhere and repeated downloads remain usable.
- [ ] Keyboard-only navigation reaches every action in a logical order with visible focus; status/error changes are announced without unexpected focus movement.
- [ ] Reduced-motion preference removes nonessential motion, zoom at 200% remains usable, and high-contrast/forced-colors inspection reveals no hidden state.
- [ ] Network inspection shows no file upload, analytics, remote font, CDN, API, or remote worker request during every processing workflow.

## Sign-off

Record browser/OS versions, completed boxes, defects and classifications, retest evidence, reviewer, and date here before creating the v1.0.0 tag or GitHub release.

- Reviewer: pending
- Date: pending
- Blocking defects: pending
- Non-blocking follow-ups: pending
- Final manual decision: pending
140 changes: 140 additions & 0 deletions tests/release-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
downloadBlob,
requestPdfSaveHandle,
requestSaveHandle,
writeBlobToHandle,
} from "../tools/shared/save.js";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
const sha256 = (relative) => createHash("sha256").update(fs.readFileSync(path.join(root, relative))).digest("hex");

const vendors = {
jspdf: {
name: "jspdf",
version: "4.2.1",
license: "MIT",
files: ["LICENSE.txt", "README.md", "jspdf.umd.min.js", "package.json"],
runtimes: {
"jspdf.umd.min.js": "e6551fcdc32f09d6853b2c5126d18d01d9447e0da618a41a11ebeee0f6c20d54",
},
},
jszip: {
name: "jszip",
version: "3.10.1",
license: "(MIT OR GPL-3.0-or-later)",
files: ["LICENSE.markdown", "README.md", "jszip.min.js", "package.json"],
runtimes: {
"jszip.min.js": "acc7e41455a80765b5fd9c7ee1b8078a6d160bbbca455aeae854de65c947d59e",
},
},
"pdf-lib": {
name: "pdf-lib",
version: "1.17.1",
license: "MIT",
files: ["LICENSE.md", "README.md", "package.json", "pdf-lib.min.js"],
runtimes: {
"pdf-lib.min.js": "0f9a5cad07941f0826586c94e089d89b918c46e5c17cf2d5a3c6f666e3bc694f",
},
},
pdfjs: {
name: "pdfjs-dist",
version: "6.2.108",
license: "Apache-2.0",
files: ["LICENSE", "README.md", "package.json", "pdf.min.mjs", "pdf.worker.min.mjs"],
runtimes: {
"pdf.min.mjs": "e0be3863c23c8af2305b16548febd58e7f8874a460253317d7771cddbc1c0f6d",
"pdf.worker.min.mjs": "0613f41490dd6aaceed7a93fbbd38c85e6d6aa60474b6588c6e7709cfbe18cb3",
},
},
};

assert.deepEqual(fs.readdirSync(path.join(root, "assets/vendor")).sort(), Object.keys(vendors).sort());
for (const [directory, expected] of Object.entries(vendors)) {
const base = path.join(root, "assets/vendor", directory);
const metadata = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8"));
assert.equal(metadata.name, expected.name, `${directory}: package name`);
assert.equal(metadata.version, expected.version, `${directory}: package version`);
assert.equal(metadata.license, expected.license, `${directory}: package license`);
assert.deepEqual(fs.readdirSync(base).sort(), expected.files.slice().sort(), `${directory}: approved files only`);
assert.ok(read(`assets/vendor/${directory}/README.md`).length > 0, `${directory}: README`);
for (const [runtime, hash] of Object.entries(expected.runtimes)) {
assert.equal(sha256(`assets/vendor/${directory}/${runtime}`), hash, `${directory}/${runtime}: integrity`);
}
}
assert.match(read("assets/vendor/jszip/README.md"), /License choice: MIT/);

const renderer = read("tools/shared/pdf-renderer.js");
assert.match(renderer, /new URL\("\.\.\/\.\.\/assets\/vendor\/pdfjs\/pdf\.worker\.min\.mjs", import\.meta\.url\)/);
assert.doesNotMatch(renderer, /https?:\/\//);
for (const relative of [
"tools/pdf/organize/index.html",
"tools/pdf/to-images/index.html",
"tools/pdf/metadata/index.html",
]) {
const csp = read(relative).match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/)?.[1] || "";
assert.ok(csp.includes("worker-src 'self'") || (!csp.includes("worker-src") && csp.includes("default-src 'self'")), `${relative}: same-origin worker policy`);
}

const blob = new Blob(["release candidate"]);
const downloadEvents = [];
let deferredRevoke;
const anchor = {
click() { downloadEvents.push("click"); },
remove() { downloadEvents.push("remove"); },
};
downloadBlob(blob, "secure-tools.txt", {
documentObject: {
createElement(tag) { assert.equal(tag, "a"); return anchor; },
body: { append(node) { assert.equal(node, anchor); downloadEvents.push("append"); } },
},
urlObject: {
createObjectURL(value) { assert.equal(value, blob); return "blob:release-gate"; },
revokeObjectURL(value) { assert.equal(value, "blob:release-gate"); downloadEvents.push("revoke"); },
},
schedule(callback, delay) { assert.equal(delay, 1500); deferredRevoke = callback; },
});
assert.equal(anchor.href, "blob:release-gate");
assert.equal(anchor.download, "secure-tools.txt");
assert.deepEqual(downloadEvents, ["append", "click", "remove"]);
deferredRevoke();
assert.deepEqual(downloadEvents, ["append", "click", "remove", "revoke"]);

assert.equal(await requestSaveHandle({}, {
suggestedName: "ignored.pdf", description: "PDF", mimeType: "application/pdf", extension: ".pdf",
}), null);
let pickerOptions;
const saveHandle = { id: "save-handle" };
assert.equal(await requestPdfSaveHandle({
async showSaveFilePicker(options) { pickerOptions = options; return saveHandle; },
}, "document.pdf", "PDF document"), saveHandle);
assert.deepEqual(pickerOptions, {
suggestedName: "document.pdf",
types: [{ description: "PDF document", accept: { "application/pdf": [".pdf"] } }],
});

const writeEvents = [];
await writeBlobToHandle({
async createWritable() {
writeEvents.push("create");
return {
async write(value) { assert.equal(value, blob); writeEvents.push("write"); },
async close() { writeEvents.push("close"); },
};
},
}, blob);
assert.deepEqual(writeEvents, ["create", "write", "close"]);

const writeFailure = new Error("disk full");
await assert.rejects(writeBlobToHandle({
async createWritable() {
return { async write() { throw writeFailure; }, async close() { assert.fail("close must not mask a failed write"); } };
},
}, blob), writeFailure);

console.log("v1.0.0 release gate checks passed.");
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ for (const test of [
"tests/file-input-queue-state.test.mjs",
"tests/pdf-split.test.mjs",
"tests/security-hardening.test.mjs",
"tests/release-gate.test.mjs",
"tests/home-structure.test.mjs",
"tests/typography-i18n-layout.test.mjs",
"tests/pdf-to-images.test.mjs",
Expand Down
Loading