English | Deutsch
A desktop-style web interface in plain JavaScript: windows, a menu bar, a dock and apps, in the browser.
Live demo · Your own site in 10 minutes · Use this template (the button at the top of the repository page) to start your own copy.
JPKCom Desktop turns a website into a desktop built on the classic desktop metaphor. Pages open in windows that can be moved, snapped to an edge and restored on the next visit. A menu bar, a dock, desktop icons, an "All apps" grid and a quick search lead to the content. The project includes an editor, notes, tasks, a calculator, a terminal, media players and more. Everything is a set of static files: native ES modules, no build step, no runtime dependencies and a strict Content Security Policy. You can put it on any web server, at the root or in a sub-folder.
JPKCom Desktop by Jean Pierre Kolb · JPKCom · MIT License (except the JPK monogram and the JPKCom logo, see brand assets)
- Features
- Quick start
- Project layout
- Configuration and content
- Languages
- Look and feel
- Extending
- Deployment
- Private bookmarks (vault)
- Development
- Privacy and security
- Credits, license and author
Windows
- Move, resize, minimise and zoom windows; double-click or double-tap a title bar to zoom.
- Drag a window to the left or right edge to fill that half of the screen, or to the top edge to zoom it. A preview shows where it will go. Rest the pointer on the zoom button to open a tile menu (left half, right half, full size, both side by side).
- Overview (F3 or Ctrl+↑) shows every window scaled side by side. Ctrl+` switches to the next window.
- Session restore: open windows come back on the next visit. Visitors can switch this off.
- Deep links:
#app=<id>,#search=<words>and#/pathopen an app, the search or a page directly. - Window controls on the left or right, as coloured dots or as plain monochrome glyphs.
Around the windows
- Menu bar with the brand menu, the menus of your site, the menu of the active app, the search button, the language switch, the weather and a clock that opens the calendar.
- Dock with pinned apps, running apps and the trash. Visitors can pin and reorder apps, change the size and switch on magnification. Desktop icons, and All apps (a full-screen grid of every app).
- Search (Ctrl/⌘+K or
/) over all apps, collections and any search provider a module adds. An optional Pagefind full-text index can be added for the pages of your site. - Context menus everywhere: right-click, long press on touch screens, Shift+F10 or the ContextMenu key.
- Drop files on the desktop: text opens in the editor, pictures in the image viewer, music and videos in the players. Nothing is uploaded.
- A boot screen, plus restart and shut-down screens. Shut down can lead to a URL of your choice.
Apps (each one is optional; switch it on or off in site/config.js)
| App | What it does |
|---|---|
| Editor | Plain-text editor with tabs, find and replace, word wrap and visible whitespace. Opens and saves local files and keeps a draft in the browser. |
| Notes | Notes with search. Deleted notes go to the trash. |
| Tasks | A to-do list with filters and reordering. Deleted tasks go to the trash. |
| Calculator | Operator precedence, percent, a history and full keyboard control. |
| Terminal | ls, cd, open, cat, man, search, cal, neofetch, browser, df, du, lang, theme and more, with tab completion and a history. dig/host are available as an opt-in. |
| Audio player, Video player | Play files from the visitor's device, with a playlist and tags (MP3 ID3, FLAC). Nothing leaves the device. |
| Image viewer | Pictures of the site and from the device, with an info bar (format, size, dimensions). |
| Fortune | A saying, tip or joke from local files. An online source is available as an opt-in. |
| Catalog | Browses a collection (bookmarks, tools, a portfolio …) by group, with a search field. |
| Reader | Shows the HTML pages of your site natively in a window, sanitised, with no iframe and no scripts. |
Settings, Wallpaper, Backup, Trash, "About this desktop" and "How it works" are part of the core.
Modules: a calendar with ISO week numbers; public holidays that are computed, never fetched (the
de-by region is included as an example); weather from Open-Meteo or Bright Sky (opt-in); notification
banners from a JSON Feed per language; and the vault, encrypted private bookmarks that unlock with
login in the terminal.
Your data, your look
- Backup and trash: visitors can download everything they made as one file and restore it, with a preview. Deleted notes and tasks stay in the trash for 30 days.
- Themes: dark, light or automatic; accent colours (or any colour, with a contrast check); tile tints; wallpapers made of colours, gradients, pictures and generated motifs.
- Any number of languages: German and English are included. Another language is a folder of translations.
- Installable and usable offline (service worker and web app manifest).
- Accessible: the menu bar works with the arrow keys, windows are named dialogs, focus is managed and restored, changes are announced through one live region, and reduced motion is respected. Everything works with the keyboard.
- Strict CSP, no
eval, noinnerHTML, no inline styles, no third-party code, zero runtime dependencies. Nothing contacts another server unless the site switches a service on and the visitor agrees.
| Light theme | Window overview | All apps |
|---|---|---|
![]() |
![]() |
![]() |
| Settings | Phone layout |
|---|---|
![]() |
![]() |
For your own site, start with "Use this template" on the repository page: GitHub creates a repository of your own with all files, ready to change and to publish on GitHub Pages. Then clone that repository instead of this one. Your own site in 10 minutes walks through the five steps from there.
You need Node.js 24 or newer, but only for the tools. The desktop itself is static files.
git clone https://github.com/JPKCom/jpkcom-desktop.git
cd jpkcom-desktop
sfw npm ci # development tools only: Tabler icon sources, headless browser checks
npm run serve # http://127.0.0.1:8080/ with the production security headersOpen http://127.0.0.1:8080/. There is no build step: edit a file and reload the page.
npm run serve itself needs no packages (node tools/serve.mjs works without npm ci).
sfw is Socket Firewall Free, which blocks malicious packages
during the install; plain npm ci works too. Install scripts of packages are switched off (.npmrc) —
why, and how to update the tools: CONTRIBUTING.md → Supply chain.
npm run serve -- --base /desktop/serves the desktop in a sub-folder (http://127.0.0.1:8080/desktop/).npm run serve -- --port 3000 --host 0.0.0.0uses another port and makes the server reachable from your LAN.--connect,--frame,--wasmand--geolocationopen the policy the same way you would on your server (see Deployment).
Why not just open index.html? Browsers do not load ES modules from file://, and fetch(), the
service worker and the stored settings need a real origin. Any static web server works; npm run serve
also sends the same security headers as production, so what works locally also works behind the real
policy.
index.html, manifest.webmanifest, sw.js the page, the web app manifest, the service worker
site/ EVERYTHING a site owner changes
config.js window.DESKTOP_CONFIG — every option, documented inline, all optional
apps.js the site manifest: apps, collections, menus, files for the terminal
content/ pages for the Reader, one folder per language (+ demos and images)
data/ fortunes and notification feeds per language
vault/ sealed private bookmarks (none shipped)
wallpapers/ picture wallpapers (none shipped)
src/ the desktop itself — core, window manager, shell, panels, modules, apps, CSS, icons
locales/ the texts, one folder per language (en is the reference)
assets/ favicon and app icons
tools/ development tools (serve, icons, checks, sealing) — not uploaded
tests/ unit tests (node --test) — not uploaded
docs/ architecture, package documentation, deployment guide, server configurations
The rule: site/ is yours, src/ is the project. Changing site/ (and locales/ for a new
language) adapts the desktop. If you keep src/ unchanged, you can update by replacing it. index.html links site/theme.css: when you update an existing site, copy
that file into your site/ (an empty file is fine) and do not delete it.
One classic script that sets window.DESKTOP_CONFIG. Every key is optional; what is missing falls back
to the defaults in src/core/config.js. An invalid value is reported in the browser console and
replaced by its default, so it never breaks the page. Objects merge key by key with the defaults; arrays
and plain values replace them. Texts that differ per language are maps such as
{ en: 'Tools', de: 'Werkzeuge' }. The comments in the file describe every key; the most common ones:
window.DESKTOP_CONFIG = {
brand: { name: 'My Desktop', shortName: 'My Desktop', menuLabel: 'My Site', themeColor: '#1c2935',
glyph: 'ti-device-desktop', logo: null, asciiLogo: ['My Site'] }, // replace the JPK defaults (brand assets, see CREDITS.md)
author: { name: 'Jean Pierre Kolb', brand: 'JPKCom', url: 'https://www.jpkc.com/', links: [/* … */] },
credit: true, // "JPKCom Desktop by Jean Pierre Kolb" in About, boot screen, terminal — please keep it
languages: ['de', 'en'], // folders in locales/, in menu order
defaultLang: 'en',
theme: { default: 'auto', accent: 'teal', windowControls: { side: 'right', style: 'minimal' } },
wallpaper: { default: { type: 'gradient', from: '#2b6cb0', to: '#0b1a33', dir: 'diag' } },
modules: ['reader', 'viewer', 'catalog', 'search', 'calendar', 'holidays', 'weather', 'notify'],
apps: ['editor', 'notes', 'todo', 'calc', 'terminal', 'media', 'fortune'],
services: { weather: true }, // online services: off unless listed here, and each visitor still agrees
weather: { provider: 'open-meteo', defaultPlace: 'berlin' },
holidays: { region: 'de-by' }
};| Area | Keys |
|---|---|
| Brand and credit | brand (name, short name, menu label, glyph, logo, terminal art, theme colour), author (name, website, profile links that become apps), credit |
| Site | site.home (a "Classic website" entry), site.legal (imprint/privacy in the brand menu), site.hosts, site.routes, site.description, about |
| Languages | languages, defaultLang |
| Appearance | theme (default theme, accent, accents, tints, windowControls), wallpaper (default, motifs, colors, gradients, images) |
| Desktop | wm (snapping, sizes, iframe policy), session, dock, desktop.icons, boot, power.shutdownUrl, ui |
| Modules and apps | modules (reader, viewer, catalog, search, calendar, holidays, weather, notify, vault, or your own as { id, src }), apps (editor, notes, todo, calc, terminal, media, fortune). Anything you leave out is not loaded at all. |
| Online services | services: { weather, geolocation, fortune, dns } (all false by default) |
| Per module | reader.rules, search (pagefind, shortcut), notify.feeds, holidays.region, calendar, weather (provider, units, places), fortune (remote, local, texts), media, editor, calc, terminal (doh, manUrl), trash, backup, vault, pwa, offline |
Weather places are { id, name, lat, lon, tz } entries in weather.places. Notification feeds are
notify.feeds: { en: 'site/data/feed.en.json', de: 'site/data/feed.de.json' }. Several desktops on one
origin need different namespace values — best ones of which neither is the other plus -… (desk-a,
desk-b, not jpkdesk and jpkdesk-next; docs/deploy.md §3).
The site manifest is an ES module. The shipped one is an example site to replace with your own:
export default {
apps: [
{ id: 'about', kind: 'page', icon: 'ti-user-circle', tint: 'slate', desktop: true, dock: true,
name: { en: 'About', de: 'Über' },
url: { en: 'site/content/en/about.html', de: 'site/content/de/about.html' } },
{ id: 'status', kind: 'web', icon: 'ti-activity', tint: 'green', url: 'status/', name: 'Status' },
{ id: 'repo', kind: 'link', icon: 'ti-brand-github', tint: 'black', url: 'https://github.com/JPKCom/jpkcom-desktop', name: 'Source' },
{ id: 'notes', dock: true } // an override record: changes an app a module brings
],
collections: [{
id: 'bookmarks', prefix: 'link', icon: 'ti-bookmarks', tint: 'indigo', sort: 'alpha',
name: { en: 'Bookmarks', de: 'Lesezeichen' },
groups: [{ id: 'reference', name: { en: 'Reference', de: 'Nachschlagen' }, icon: 'ti-book-2' }],
items: [{ slug: 'mdn', group: 'reference', name: 'MDN Web Docs', url: 'https://developer.mozilla.org/' }]
}],
menus: [{ id: 'pages', label: { en: 'Pages', de: 'Seiten' }, items: ['about', '-', { collection: 'bookmarks' }] }],
files: { about: { en: 'site/content/en/about.md', de: 'site/content/de/about.md' }, license: 'LICENSE' }
};- App kinds:
page(a page of your site in the Reader),web(a page in an iframe window),link(an external page in a new tab, https only),collection(a Catalog window), andalias: '<id>'(shows and launches another app — keep an old id this way after a rename; an alias never takes a desktop icon or dock place of its own, a visitor's dock pin of it moves to the target).desktop,dock,hidden,sizeandtintplace and style an app. - Collections: each item becomes an app
<prefix>-<slug>. A collection gets a Catalog window, a search group and menu entries ({ collection: id }) without any code. A collection's web overview page can open in a window of its own: name a hidden web app inwebApp. - Menus take app ids,
'-'(a separator),{ collection },{ label, url }and submenus{ label, items }. - Files: what the terminal can
cat..mdfiles are rendered as Markdown. - Paths are relative to the desktop's folder, so they work in a sub-folder too.
The full format is in docs/ARCHITECTURE.md §7. Check your manifest before you publish:
npm run validate # ids, kinds, references, urls (local files exist), icons, a text for every languageAn app of your own lives in site/, next to your content — nothing in src/ changes, so updates of the
desktop do not touch it. Start from the example app Hello in site/modules/hello/: a window with its
own texts in every language (locales/<lang>/hello.js), its own CSS, a stored value with backup and reset,
and a terminal command. Copy the folder, rename it as the comment at the top of its index.js explains,
and list it in site/config.js:
apps: [ …, { id: 'my-app', src: 'site/modules/my-app/index.js' } ],npm run i18n:check checks its texts like the desktop's own. The full descriptor reference is in
docs/ARCHITECTURE.md §8 and
§21.
Plain HTML files in site/content/<lang>/. The default rule (config.reader.rules) takes the first
main article, article or main as the content, its h1 as the title and a .lead paragraph as the
lead:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Page title | My Site</title> <!-- window title: the part before " | " -->
<link rel="alternate" hreflang="de" href="../de/page.html"> <!-- the page after a language switch -->
<link rel="stylesheet" href="../content.css"> <!-- only used outside the desktop -->
</head>
<body><main><article>
<h1>Page title</h1>
<p class="lead">One or two sentences about the page.</p>
<p>Text, <a href="other.html">links</a>, lists, tables, figures …</p>
</article></main></body>
</html>The Reader parses pages without running them and keeps only allowed elements. Scripts, styles,
style attributes, forms, iframes and event handlers are removed. Write pages without them, because
the CSP would also report them while a page is parsed. Classes get a c- prefix, so a page cannot pick
up the desktop's styles. Relative links open in the same window and links to other sites open in a new
tab. The example page "Writing pages" (site/content/en/docs/pages.html)
has the details.
-
Fortunes:
site/data/fortunes/<lang>.json, with plain text only (at most 1000 characters per saying). List the languages that have a file infortune.langs.{ "lang": "en", "by": "My Site", "categories": { "tips": "Tips" }, "items": ["A plain saying.", { "text": "Press F3 to see all windows.", "cat": "tips" }] } -
Online only / own source:
fortune.local: falseruns the app without local sayings; a site module adds its own source withfortuneProviders(see docs/packages/p11-fortune-site.md). -
Notifications: a JSON Feed 1.1 per language, on the same origin (
notify.feeds). New items show up as banners and in the calendar. Items need atitle, a pastdate_publishedand a same-originurl. A banner shows the app its article opens in (notify.app, or the one the router picks withapp: null);notify.labelputs a name in front of it ("News · Blog").
German (de) and English (en) are included. No code assumes a fixed number of languages. With two
languages the menu bar shows a toggle; with three or more it shows a menu. To add one, for example French:
- Copy the reference folder:
cp -r locales/en locales/fr. - Edit
locales/fr/_meta.js:export default { name: 'Français', intl: 'fr-FR', dir: 'ltr', yes: '^(o|oui|y|yes)$' };
nameis the language's own name,intlthe BCP 47 tag for dates, numbers and plurals,diris'ltr'or'rtl', andyesmatches "yes" answers in the terminal. - Translate the values in every file. Keep the keys and the
{placeholders}. Plurals are objects with the categories your language needs (one,few,many,other, …). - Add the code to
site/config.js:languages: ['de', 'en', 'fr']. - Check it:
npm run i18n:check -- fr # missing keys, placeholder mismatches, plural categories npm run validate # every text map in site/ has a value for every language
- Optional: pages in
site/content/fr/,site/data/fortunes/fr.json(and'fr'infortune.langs),site/data/feed.fr.json(andnotify.feeds.fr), and a<p lang="fr">line in the<noscript>block ofindex.html.
A missing key never breaks anything. It falls back to the base language (pt-BR → pt), then
defaultLang, then English. With debug: true the console lists every key that fell back. The details,
including the keys that are formats rather than sentences, are in locales/README.md.
Everything below is set in site/config.js. Visitors can then choose for themselves in Settings and
Wallpaper.
theme: {
default: 'dark', // 'dark' | 'light' | 'auto' (follows the system)
accent: 'blue',
allowCustomAccent: true, // a colour picker; text on it turns black or white
accents: { brand: '#0f6b8f', pink: null }, // add your own (white text needs ≥ 4.5:1), null removes one
tints: { brand: ['#3fb6e0', '#0f6b8f'] }, // tile gradients [top, bottom]; apps use tint: 'brand'
windowControls: { side: 'left', style: 'classic' } // 'left' | 'right', 'classic' (dots) | 'minimal' (glyphs)
},
wallpaper: {
default: { type: 'svg', id: 'waves' }, // or { type: 'gradient' | 'color' | 'image', … }
motifs: ['waves', 'dunes', 'aurora', 'orbit', 'horizon', 'graphite'],
colors: [{ id: 'petrol', color: '#0f4c52', name: { en: 'Petrol', de: 'Petrol' } }],
gradients: [{ id: 'ocean', from: '#2b6cb0', to: '#0b1a33', dir: 'diag', name: { en: 'Ocean', de: 'Ozean' } }],
images: [{ id: 'harbour', src: 'site/wallpapers/harbour.webp', name: { en: 'Harbour', de: 'Hafen' },
credit: 'Photo: Jane Doe, CC BY 4.0', tone: 'dark' }]
}- Gradients take a direction:
glow(light from the top),down,diagorradial. - Generated motifs are SVG pictures drawn in the browser.
author-monogramandauthor-emblemshow the JPK monogram and the JPKCom logo (brand assets, not MIT — see CREDITS.md),author-blueprinttheir construction lines;waves,dunes,aurora,orbit,horizonandgraphiteare neutral. - Pictures go into
site/wallpapers/(WebP or AVIF, about 2560 × 1600). Settone: 'light'for a bright picture so the menu bar and icon labels stay readable. See site/wallpapers/README.md. - Accent names: name a new accent in the
settingsnamespace of each language asaccent.<id>. Otherwise its id is shown.
CSS tokens. Every colour, radius, font and size is a custom property in src/css/tokens.css (the
groups are listed in ARCHITECTURE §17).
Your own theme. Put token overrides into site/theme.css inside @layer themes. It is loaded before the
first paint and kept offline, and the themes layer wins over every other layer. Families such as
--radius-control or --glass-backdrop change every part at once. All tokens and the rules (light and
dark, dark islands, phones) are in docs/theming.md.
/* site/theme.css */
@layer themes {
:root { --radius-control: 2px; --radius-panel: 4px; --radius-win: 4px; }
body.compact { --radius-win: 4px; }
}Rebranding: brand in site/config.js (with glyph, logo and asciiLogo); name, short_name
and theme_color in manifest.webmanifest; the static lines of index.html (title, description,
<noscript>); your own assets/icons/favicon.svg and maskable.svg, then npm run icons:pwa;
wallpaper.motifs without author-monogram and author-emblem. The JPK monogram and the JPKCom logo
are not MIT: you may show them unchanged as the default brand, but not use them as your own logo
(brand assets). Please keep credit: true and
the author/generator meta tags. docs/deploy.md §11
lists every line.
Everything optional is a module: a descriptor object that declares apps, stored data, translations,
styles and contributions to extension points. Modules never import each other; they use the public
API (window.JPKDesk, or import Desk from 'src/core/api.js'), services and events. A site module lives
in site/ and is listed as { id, src }:
/* site/modules/hello/index.js
site/config.js: modules: [ …, { id: 'hello', src: 'site/modules/hello/index.js' } ] */
import Desk from '../../../src/core/api.js';
const { h, L } = Desk;
export default {
id: 'hello',
kind: 'module',
/* The app this module brings (its id is the module id) */
app: {
name: { en: 'Hello', de: 'Hallo' },
desc: { en: 'A tiny example app', de: 'Eine winzige Beispiel-App' },
icon: 'ti-mood-smile',
tint: 'green',
size: [420, 240]
},
/* Window hook: build the content into the window body (no innerHTML: h() and text) */
mount(win, body) {
body.append(h('p', { class: 'hello-text', text: L({ en: 'Hello from a site module.', de: 'Hallo aus einem Site-Modul.' }) }));
},
/* A contribution to the terminal: the command `hello` */
terminal: {
hello: {
help: { en: 'say hello', de: 'Hallo sagen' },
run(args, io) {
io.say(L({ en: 'Hello, world!', de: 'Hallo, Welt!' }));
}
}
}
};Use a new icon? Run npm run icons (see below).
| Extension point | Descriptor field | Example |
|---|---|---|
| Apps and window hooks | app / apps, mount, serialize, restore, menu, beforeClose, … |
above |
| Terminal commands | terminal: { name: { run(args, io, ctx), help, usage, man, complete } } |
above; at runtime Desk.terminal.register() |
| Search providers | search: [{ id, label, order, async search(q, ctx) → [{ title, sub, app, url, run }] }] |
at runtime Desk.search.addProvider() |
| Settings | settingsSections: [{ id, label, icon, order }], settings: [{ id, section, order, render(ctx) }] |
a row with a built-in id replaces it |
| Keyboard shortcuts | shortcuts: [{ id, keys: 'Alt+N', scope, run(e) }] |
|
| Context menus | contextMenu: [{ selector, items(el, ctx) }] |
|
| File drops | files: { kind: { accept: ['.ext'], label, open(file, ctx) } } |
|
| Calendar sections | calendar: [{ id, order, render(ctx) }] |
|
| Stored data | storage, resetGroups, trash (backup, reset and trash work automatically) |
|
| Online services | consent: [{ id, hosts, label, hint }] + Desk.net.getJson(url, { service: id }) |
|
| Wallpaper motifs, weather providers, holiday regions | Desk.wallpaper.register(), Desk.weather.addProvider(), Desk.holidays.addRegion() |
Icons come from Tabler Icons: outline ti-<name>, filled tif-<name>.
Only the icons that are actually used ship. npm run icons scans src/, site/ and index.html, then
writes src/icons/tabler.js. Icons that no source file names (for example ones used only in sealed vault
data) go into site/icons.json as a JSON array (["ti-briefcase"]). npm run icons:check verifies the
file is up to date. A site can bring its own icons (for example a set it holds a licence for) as a site
icon set: a JSON file in site/icon-sets/, listed in iconSets of site/config.js, with two-tone
support. With iconReplace the desktop draws its own glyphs (menu bar, window controls, settings,
weather …) with icons of that set as well ({ 'ti-settings': 'acme-cog' }) — see
docs/ARCHITECTURE.md §13.
The complete contract is in docs/ARCHITECTURE.md: module descriptor (§8), public API (§9), services (§10), events (§11), i18n (§12), storage (§14), CSS (§17), "How to add …" (§21). Each package is documented in docs/packages/.
- Any static web server that can send a few headers. There is no server-side code and no build step.
- HTTPS. The service worker (offline use, installation), the vault (Web Crypto) and "My location"
for the weather only work in a secure context. Over plain HTTP the desktop still runs, without those
features. (
http://localhostcounts as secure during development.) - The web root or any sub-folder. Every path is relative to the folder that holds
index.html.https://example.org/desktopmust redirect tohttps://example.org/desktop/, and every configuration below does this. - Caching:
Cache-Control: no-cachefor HTML, JS, CSS, JSON, the manifest andsw.js, because the file names carry no version. Browsers revalidate their copies (304), so an update shows on the next reload (with the service worker's fast start: one reload later, after the desktop offered it — feeds and files undersite/data/right away). Images, fonts and media get one day. - No directory listings anywhere, and no dotfiles (
.git,.env). - The security headers below, on every response including errors.
What to upload: index.html, manifest.webmanifest, sw.js, assets/, locales/, site/,
src/, LICENSE and CREDITS.md. Do not upload node_modules/, tools/, tests/, docs/,
.git/, package.json or package-lock.json. The configurations only refuse dotfiles; they do not hide
those folders for you.
Same origin = full trust. Every page on the desktop's origin can read its stored data. Serve only
your own code there. Third-party demos belong on another origin, or in a web app with
sandbox: 'allow-scripts' (never together with allow-same-origin).
docs/deploy.md §3 explains why.
GitHub Pages. The workflow .github/workflows/pages.yml publishes the site as it is, without a
build step: the live demo, and your copy of the template at
https://<user>.github.io/<repo>/ once you choose "GitHub Actions" as the Pages source and set the
repository variable PAGES to true. Unlike the upload list above, it also publishes docs/ and the
package and top-level files, which is harmless. Pages cannot send headers, so the workflow adds the policy as a
<meta> tag. That tag cannot carry frame-ancestors, and Permissions-Policy and the other headers are
missing. For the full set, use your own server.
docs/deploy.md has the details.
Every configuration sends this policy. On HTTPS it adds upgrade-insecure-requests and HSTS. Apache
adds both only when the request came over HTTPS; npm run serve sends neither because it speaks plain
HTTP.
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:;
font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'
It also sends Permissions-Policy (everything off, geolocation only when you offer "My location"),
X-Frame-Options: SAMEORIGIN (windows frame same-origin pages, so DENY would break them),
X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin,
Cross-Origin-Opener-Policy: same-origin and Strict-Transport-Security: max-age=31536000; includeSubDomains.
Keep blob: in img-src, media-src and connect-src: pictures, music and videos from the visitor's
device play from blob: URLs and never leave the device.
Every online service is off by default (services in site/config.js), and each visitor is asked
before the first request. When you switch one on, also allow its host in your server's policy. Otherwise
the browser blocks the request.
| Feature | site/config.js |
Add to the policy |
|---|---|---|
| Weather, worldwide | 'weather' in modules, services.weather: true, weather.provider: 'open-meteo' |
connect-src https://api.open-meteo.com |
| Weather, Germany | as above, weather.provider: 'brightsky' |
connect-src https://api.brightsky.dev |
| "My location" for the weather | services.geolocation: true |
Permissions-Policy: … geolocation=(self) … |
dig, host, nslookup in the terminal (DNS-over-HTTPS) |
services.dns: true, terminal.doh: { url: 'https://dns.google/resolve', name: 'dns.google' } |
connect-src https://dns.google (the host of terminal.doh.url) |
| Fortune, online jokes | services.fortune: true, fortune.remote: 'jokeapi' |
connect-src https://v2.jokeapi.dev |
| Fortune, online facts | services.fortune: true, fortune.remote: 'uselessfacts' |
connect-src https://uselessfacts.jsph.pl |
| Fortune app, an online source from your own module | services.fortune: true, fortune.remote: '<id>' |
connect-src https://<its hosts> |
| Full-text search (Pagefind) | search.pagefind: { path: 'pagefind/pagefind.js' } |
script-src 'wasm-unsafe-eval' (see below; nothing else needs it) |
web apps from another origin (external iframes) |
an app with kind: 'web' and a foreign url |
frame-src https://apps.example.org |
Pagefind and 'wasm-unsafe-eval'. Pagefind compiles its WebAssembly in a worker that it starts from
pagefind-worker.js next to pagefind.js; that worker runs under the policy your server sends with
that file, not under the desktop page's. When the worker fails, or has not started within 5 seconds
(Pagefind 1.5 — on a slow connection that includes downloading it), Pagefind falls back to the page and
compiles the WebAssembly under the desktop's policy (console: "falling back to main thread"). Whether that
happens is decided in each visitor's browser, not by your deployment. So:
- the desktop's policy needs
'wasm-unsafe-eval'as soon assearch.pagefindis set — without it the search fails for every visitor whose browser falls back; - the policy sent with
pagefind-worker.jsneeds it too. The configurations below send the desktop's policy with every file they serve (Apache: the desktop's folder and below; the others: the whole host), so for a bundle they serve that is the same line. A bundle your server sends without a policy needs nothing for the worker — the fallback still needs the desktop's.
'wasm-unsafe-eval' allows compiling WebAssembly only; it does not allow eval. Details:
docs/deploy.md §6.
How to edit the configuration. Next to its active Content-Security-Policy line, each file below
has a comment that lists these hosts and two commented example lines (weather + DNS-over-HTTPS;
Pagefind + an app from another origin). Copy the active line, add only what you switch on, and put the
copy in place of the active line. Keep upgrade-insecure-requests where the active line has it. For
"My location", swap the active Permissions-Policy line for the commented one below it, which contains
geolocation=(self). Example with weather and the DNS commands:
connect-src 'self' blob: https://api.open-meteo.com https://dns.google;
Try the same additions locally first:
npm run serve -- --connect https://api.open-meteo.com,https://dns.google --frame https://apps.example.org --wasm --geolocationEach configuration is complete on its own and works at the web root and in a sub-folder without
changes. Each one was tested by running the server, requesting the desktop at / and at /desktop/,
and checking the redirect, headers, policy, MIME types, caching, 304, listings and dotfiles. In every
file, replace example.org with your host name and /var/www/example.org with the folder your files
are in (the desktop can be in a sub-folder of it). Where present, also replace the certificate paths
/etc/ssl/example.org/…. The files are also in docs/server/, and
docs/deploy.md explains the background.
- Where: copy
docs/server/apache.htaccessas.htaccessinto the folder that holdsindex.html. There is nothing to replace inside. - Modules: Apache 2.4.10+ with
mod_headers,mod_mimeandmod_alias.mod_dir,mod_deflateandmod_brotliare used when present. On Debian/Ubuntu runa2enmod headers mime alias. Ifmod_expiresis loaded, the file also switches it off for the desktop (ExpiresActive Off), as hardening against a host-wide expiry rule — its headers already sendno-cachewithoutExpires. - AllowOverride: the vhost must allow the directives for that folder, otherwise Apache answers
500:(<Directory "/var/www/example.org"> AllowOverride FileInfo Indexes Options=Indexes </Directory>
AllowOverride Allworks too.) - HTTPS comes from your vhost (certificate there).
upgrade-insecure-requestsand HSTS are added only when%{HTTPS}is on. Behind a proxy that terminates TLS, change both conditions from"expr=%{HTTPS} == 'on'"to"expr=%{HTTP:X-Forwarded-Proto} == 'https'". - Online services: edit the
Header always set Content-Security-Policyline, and theHeader always set Permissions-Policyline for "My location". The Apache lines carry noupgrade-insecure-requests; it is appended on HTTPS. - Check and reload:
apachectl configtest && apachectl graceful(Debian:apache2ctl).configtestdoes not read.htaccess; an error in it shows up as500on a request (see the error log).
.htaccess (complete)
# ============================================================================
# JPKCom Desktop — Apache configuration (.htaccess) — © Jean Pierre Kolb — MIT License
# ============================================================================
#
# Copy this file as ".htaccess" into the folder that holds index.html — the web
# root or a sub-folder such as /desktop/; nothing in it depends on the folder.
# Apache 2.4.10 or newer with mod_headers, mod_mime and mod_alias (mod_dir,
# mod_deflate, mod_brotli are used when present; mod_expires, when present, is
# switched off for the desktop). The host must allow it:
# AllowOverride FileInfo Indexes Options=Indexes
# (or "AllowOverride All"). Nothing here inherits from a parent .htaccess on
# purpose: the header set is complete on its own and replaces security headers
# a parent .htaccess or the server config may send.
#
# Service worker, installation, the vault and "My location" need a secure
# context: serve the desktop over HTTPS (localhost is the only exception).
#
# Check after a deploy:
# curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
# ---------- Files and folders ------------------------------------------------
# No directory listings anywhere (the vault's file names must stay secret)
Options -Indexes
# /desktop → /desktop/ (manifest, service worker and data are resolved relative to the page)
<IfModule mod_dir.c>
DirectorySlash On
DirectoryIndex index.html
</IfModule>
# Dotfiles (.git, .env, this file, …) are never served — /.well-known/ stays reachable.
# mod_alias is part of every standard Apache; deliberately without <IfModule>: should it
# be missing, Apache answers 500 instead of handing out .git/ or .env
RedirectMatch 404 "/\.(?!well-known/)"
# ---------- MIME types ---------------------------------------------------------
<IfModule mod_mime.c>
AddType text/javascript .js .mjs
AddType application/manifest+json .webmanifest
AddType application/json .json
AddType image/svg+xml .svg
AddType application/octet-stream .bin
AddCharset utf-8 .html .js .mjs .css .json .webmanifest .svg .md .txt
</IfModule>
# ---------- Compression ------------------------------------------------------
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/javascript application/json application/manifest+json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/json application/manifest+json image/svg+xml
</IfModule>
# ---------- Expiry -----------------------------------------------------------
# mod_expires stays off for the desktop: a parent .htaccess or the server config with
# "ExpiresActive On" / ExpiresByType adds Expires and Cache-Control max-age, and a browser
# could keep an old module next to new ones after an update. The caching headers below
# replace both already; this keeps it so whatever order the modules run in (hardening).
# Inside <Files> for the same reason as the headers below.
<IfModule mod_expires.c>
<Files "*">
ExpiresActive Off
</Files>
</IfModule>
<IfModule mod_headers.c>
# Inside <Files>: Apache applies <Files>/<FilesMatch> sections after all directory and .htaccess
# directives, parents first — so only here do these headers win over a parent's <FilesMatch>.
<Files "*">
# ---------- Security headers -----------------------------------------------
# A parent .htaccess or the server config may set these with plain "Header set" (another
# table than "always"): remove those copies, or browsers would get two policies and apply both
Header unset Content-Security-Policy
Header unset Permissions-Policy
Header unset X-Frame-Options
Header unset X-Content-Type-Options
Header unset Referrer-Policy
Header unset Cross-Origin-Opener-Policy
Header unset Strict-Transport-Security
# Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
#
# connect-src add the hosts of the online services set to true in site/config.js → services:
# weather, provider 'open-meteo': https://api.open-meteo.com
# a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
# weather, provider 'brightsky': https://api.brightsky.dev
# terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
# fortune, remote 'jokeapi': https://v2.jokeapi.dev
# fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
# frame-src add the origins of 'web' apps whose url lives on another origin
# script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
# WebAssembly runs in pagefind-worker.js (under this policy when served from here)
# or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
#
# Then replace the line below with your extended copy, e.g. weather + DoH:
# Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
# or with Pagefind and a framed app from another origin:
# Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
# On HTTPS: upgrade-insecure-requests and HSTS. Behind a proxy that terminates TLS, use
# "expr=%{HTTP:X-Forwarded-Proto} == 'https'" instead of "expr=%{HTTPS} == 'on'".
Header always edit Content-Security-Policy "$" "; upgrade-insecure-requests" "expr=%{HTTPS} == 'on'"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on'"
# Geolocation stays off unless the site offers "My location" for the weather
# (services.geolocation: true) — then use the second line instead
Header always set Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# Header always set Permissions-Policy "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# SAMEORIGIN, not DENY: windows show same-origin pages in iframes
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Cross-Origin-Opener-Policy "same-origin"
# ---------- Caching --------------------------------------------------------
# The files carry no version in their names: browsers revalidate them on every use (ETag /
# Last-Modified → 304), the service worker does the same. Images, fonts and media: one day.
Header set Cache-Control "no-cache"
Header set Cache-Control "public, max-age=86400" "expr=%{REQUEST_URI} =~ m#\.(png|jpe?g|gif|webp|avif|ico|woff2?|mp3|ogg|oga|m4a|wav|mp4|webm)$#i"
Header unset Expires
# ---------- Sealed vault files (site/vault/) -------------------------------
# The file name is derived from the credentials: never listed (see Options above),
# always revalidated (a re-sealed file must not come from a cache), not for search engines
Header always set X-Robots-Tag "noindex, nofollow, noarchive" "expr=%{REQUEST_URI} =~ m#/site/vault/#"
</Files>
</IfModule>- Where: copy
docs/server/nginx.confto/etc/nginx/conf.d/desktop.conf(or tosites-available/with a link insites-enabled/). It is read insidehttp { }and holds twomapblocks, an HTTP → HTTPS redirect and the HTTPS server. - Replace:
server_name(twice),root,ssl_certificateandssl_certificate_key. Requires nginx 1.19+;http2 on;needs 1.25.1+ (on older versions writelisten 443 ssl http2;instead). - Never put
add_headerinto alocationblock you add: in nginx it removes every server-level header for that location. The path-dependent values come from the two maps for that reason. The server block also saysexpires off;, so anexpiresfrom thehttp { }block puts no long expiry on the code. - Online services: edit the
add_header Content-Security-Policy … always;line and, for "My location", theadd_header Permissions-Policyline. - Check and reload:
nginx -t && nginx -s reload(orsystemctl reload nginx).
nginx.conf (complete)
# ============================================================================
# JPKCom Desktop — nginx configuration — © Jean Pierre Kolb — MIT License
# ============================================================================
#
# A complete site file for /etc/nginx/conf.d/ (or sites-available/): it is read
# inside the http { } block, which is why the map blocks may stand at the top.
# Replace example.org, the certificate paths and the root folder. nginx 1.19+.
#
# Root or sub-folder: copy the desktop's files into the root folder itself
# (https://example.org/) or into a folder below it (https://example.org/desktop/).
# Nothing else changes — the rules below match the desktop's paths wherever they
# are. A request for /desktop (without the slash) is answered with a redirect to
# /desktop/ by nginx itself (a directory; no try_files with $uri/ here, which would
# serve the page without the slash and break its relative paths).
#
# Why the maps: an add_header inside a location removes every add_header of the
# server block for that location. All headers are therefore set once, at server
# level, with values that depend on the path (an empty value sends no header).
#
# Service worker, installation, the vault and "My location" need a secure
# context: serve the desktop over HTTPS (localhost is the only exception).
#
# Check after a deploy:
# curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
# ---------- Path-dependent header values ------------------------------------
# Images, fonts and media: one day. Everything else (HTML, JS, CSS, JSON, manifest, sw.js,
# sealed vault files): revalidated on every use (ETag / Last-Modified → 304).
map $uri $desk_cache_control {
default "no-cache";
~*\.(?:png|jpe?g|gif|webp|avif|ico|woff2?|mp3|ogg|oga|m4a|wav|mp4|webm)$ "public, max-age=86400";
}
# Sealed vault files: not for search engines
map $uri $desk_robots {
default "";
~/site/vault/ "noindex, nofollow, noarchive";
}
# ---------- HTTP → HTTPS -----------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name example.org;
server_tokens off; # no version number in the Server header, here as in the HTTPS server
return 301 https://$host$request_uri;
}
# ---------- The site -----------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.org;
ssl_certificate /etc/ssl/example.org/fullchain.pem;
ssl_certificate_key /etc/ssl/example.org/privkey.pem;
root /var/www/example.org;
index index.html;
# No directory listings (the vault's file names must stay secret); relative redirects
autoindex off;
absolute_redirect off;
server_tokens off;
charset utf-8;
charset_types text/css text/javascript application/json application/manifest+json image/svg+xml text/plain text/markdown;
gzip on;
gzip_vary on;
gzip_types text/css text/javascript application/json application/manifest+json image/svg+xml text/plain text/markdown;
# ---------- Security headers ---------------------------------------------
# Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
#
# connect-src add the hosts of the online services set to true in site/config.js → services:
# weather, provider 'open-meteo': https://api.open-meteo.com
# a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
# weather, provider 'brightsky': https://api.brightsky.dev
# terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
# fortune, remote 'jokeapi': https://v2.jokeapi.dev
# fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
# frame-src add the origins of 'web' apps whose url lives on another origin
# script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
# WebAssembly runs in pagefind-worker.js (under this policy when served from here)
# or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
#
# Then replace the line below with your extended copy, e.g. weather + DoH:
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
# or with Pagefind and a framed app from another origin:
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
# Geolocation stays off unless the site offers "My location" for the weather
# (services.geolocation: true) — then use the second line instead
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
# add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
# SAMEORIGIN, not DENY: windows show same-origin pages in iframes
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Caching and the vault (values from the maps above). "expires off": an expires directive
# of the http block (a distribution default, a copied snippet) would otherwise add Expires
# and a second Cache-Control with max-age to the code — after an update a browser could
# keep an old module next to new ones
expires off;
add_header Cache-Control $desk_cache_control;
add_header X-Robots-Tag $desk_robots always;
# ---------- Locations (no add_header in here — see above) -------------------
# Dotfiles (.git, .env, …) are never served
location ~ /\.(?!well-known/) {
return 404;
}
# MIME types that the stock mime.types lacks or names differently (.js is still
# application/javascript there; RFC 9239 says text/javascript). A types { } block here
# would replace the whole list of the http block, so each one gets its own small location.
location ~* \.webmanifest$ {
types { }
default_type application/manifest+json;
}
location ~* \.m?js$ {
types { }
default_type text/javascript;
}
location ~* \.bin$ {
types { }
default_type application/octet-stream;
}
location / {
}
}- Where: copy
docs/server/Caddyfileto/etc/caddy/Caddyfile, orimportit from yours. Requires Caddy 2.7+. - Replace: the site address
example.organdroot * /var/www/example.org. HTTPS is automatic: Caddy fetches the certificate and redirects HTTP to HTTPS. - Keep the
-and?header lines out of the mainheader { … }block, or error answers lose their security headers (the comment in the file explains why). - Online services: edit the
Content-Security-Policyline insideheader { … }and, for "My location", thePermissions-Policyline. - Check and reload:
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.
Caddyfile (complete)
# ============================================================================
# JPKCom Desktop — Caddy configuration (Caddyfile) — © Jean Pierre Kolb — MIT License
# ============================================================================
#
# Caddy 2.7+. Replace example.org and the root folder. Caddy fetches the TLS
# certificate itself and redirects http to https (automatic HTTPS) — the secure
# context that the service worker, installation, the vault and "My location" need.
#
# Root or sub-folder: copy the desktop's files into the root folder itself
# (https://example.org/) or into a folder below it (https://example.org/desktop/).
# Nothing else changes — the matchers below find the desktop's paths wherever they
# are. file_server answers /desktop (a directory without the slash) with a redirect
# to /desktop/ by itself, and it lists no directories (no "browse").
#
# Check after a deploy:
# curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
example.org {
root * /var/www/example.org
encode zstd gzip
# ---------- Security headers -----------------------------------------------
header {
# Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
#
# connect-src add the hosts of the online services set to true in site/config.js → services:
# weather, provider 'open-meteo': https://api.open-meteo.com
# a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
# weather, provider 'brightsky': https://api.brightsky.dev
# terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
# fortune, remote 'jokeapi': https://v2.jokeapi.dev
# fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
# frame-src add the origins of 'web' apps whose url lives on another origin
# script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
# WebAssembly runs in pagefind-worker.js (under this policy when served from here)
# or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
#
# Then replace the line below with your extended copy, e.g. weather + DoH:
# Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# or with Pagefind and a framed app from another origin:
# Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# Geolocation stays off unless the site offers "My location" for the weather
# (services.geolocation: true) — then use the second line instead
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# Permissions-Policy "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# SAMEORIGIN, not DENY: windows show same-origin pages in iframes
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Cross-Origin-Opener-Policy "same-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
# A header block that deletes (-) or defaults (?) a field is deferred until the response
# is written — and file_server's own error answers (a missing file: 404) never pass
# through it. Keep these two apart, so the security headers above are set at once and
# reach 404 answers too; never add a "-" or "?" line to the block above.
header -Server
# Default, revalidated on every use (ETag / Last-Modified → 304): HTML, JS, CSS, JSON,
# the manifest, sw.js and the sealed vault files carry no version in their names.
# The "?" sets it only where the rule for images below did not.
header ?Cache-Control "no-cache"
# ---------- Caching, MIME types, the vault -------------------------------------
# Images, fonts and media: one day
@static path_regexp static (?i)\.(png|jpe?g|gif|webp|avif|ico|woff2?|mp3|ogg|oga|m4a|wav|mp4|webm)$
header @static Cache-Control "public, max-age=86400"
@manifest path *.webmanifest
header @manifest Content-Type "application/manifest+json; charset=utf-8"
@modules path *.mjs
header @modules Content-Type "text/javascript; charset=utf-8"
# Caddy sends JSON (feeds, fortunes) without a charset; browsers decode it as UTF-8
# anyway, but say it, like every other text type
@json path *.json
header @json Content-Type "application/json; charset=utf-8"
# Sealed vault files: not for search engines
@vault path */site/vault/*
header @vault X-Robots-Tag "noindex, nofollow, noarchive"
# ---------- Files ----------------------------------------------------------
# Dotfiles (.git, .env, …) are never served
@hidden {
path */.*
not path /.well-known/*
}
respond @hidden 404
file_server {
index index.html
}
# Error answers (a missing file): the security headers set above stay on the response;
# answering here lets the deferred "-Server" run as well. The Content-Type line replaces
# the type that a matcher above set for the missing path (a missing .json is plain text)
handle_errors {
header -Server
header Content-Type "text/plain; charset=utf-8"
respond "{err.status_code} {err.status_text}" {err.status_code}
}
}Both files do the same. Ferron 3 uses its own format; Ferron 2, the stable line, uses KDL.
- Where: Ferron 3:
docs/server/ferron.conf→/etc/ferron/ferron.conf. Ferron 2:docs/server/ferron.kdl→/etc/ferron.kdl. - Replace: the host block
example.organdroot. Certificates (automatic TLS) and the HTTP → HTTPS redirect are automatic. - Ferron 2 only: a block that sets a directive replaces every inherited entry of it. The vault block
therefore repeats
use "DESK_HEADERS", so keep that line if you change the block. - Online services: edit the
header Content-Security-Policyline (Ferron 3) or theheader "Content-Security-Policy"line in theDESK_HEADERSsnippet (Ferron 2), and thePermissions-Policyline below it. - Check and restart: Ferron 3:
ferron validate -c /etc/ferron/ferron.conf. Then restart the service (for examplesystemctl restart ferron).
ferron.conf — Ferron 3 (complete)
# ============================================================================
# JPKCom Desktop — Ferron 3 configuration (ferron.conf) — © Jean Pierre Kolb — MIT License
# ============================================================================
#
# Ferron 3 (https://ferron.sh; validated with 3.0.0-rc.9), usually
# /etc/ferron/ferron.conf: ferron validate -c /etc/ferron/ferron.conf
# For Ferron 2 (KDL) use ferron.kdl next to this file instead.
#
# Replace example.org and the root folder. Ferron obtains the TLS certificate
# for a host name itself and redirects http to https — the secure context that
# the service worker, installation, the vault and "My location" need.
#
# Root or sub-folder: copy the desktop's files into the root folder itself
# (https://example.org/) or into a folder below it (https://example.org/desktop/).
# Nothing else changes — the matchers below find the desktop's paths wherever
# they are, and /desktop (a directory without the slash) is redirected to
# /desktop/ (trailing_slash_redirect).
#
# Check after a deploy:
# curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
# Images, fonts and media (cached for a day; everything else is revalidated)
match desk_static {
request.uri.path ~ r"(?i)\.(png|jpe?g|gif|webp|avif|ico|woff2?|mp3|ogg|oga|m4a|wav|mp4|webm)$"
}
# Sealed vault files (not for search engines)
match desk_vault {
request.uri.path ~ "/site/vault/"
}
# Dotfiles (.git, .env, …), except /.well-known/
match desk_hidden {
request.uri.path ~ r"/\."
request.uri.path !~ r"^/\.well-known/"
}
example.org {
root /var/www/example.org
index index.html
trailing_slash_redirect true
# No directory listings: the vault's file names must stay secret
directory_listing false
compressed true
etag true
mime_type .html "text/html; charset=utf-8"
mime_type .css "text/css; charset=utf-8"
mime_type .json "application/json; charset=utf-8"
mime_type .webmanifest "application/manifest+json; charset=utf-8"
mime_type .mjs "text/javascript; charset=utf-8"
mime_type .js "text/javascript; charset=utf-8"
mime_type .bin "application/octet-stream"
# ---------- Security headers ---------------------------------------------
# Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
#
# connect-src add the hosts of the online services set to true in site/config.js → services:
# weather, provider 'open-meteo': https://api.open-meteo.com
# a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
# weather, provider 'brightsky': https://api.brightsky.dev
# terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
# fortune, remote 'jokeapi': https://v2.jokeapi.dev
# fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
# frame-src add the origins of 'web' apps whose url lives on another origin
# script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
# WebAssembly runs in pagefind-worker.js (under this policy when served from here)
# or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
#
# Then replace the line below with your extended copy, e.g. weather + DoH:
# header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# or with Pagefind and a framed app from another origin:
# header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# Geolocation stays off unless the site offers "My location" for the weather
# (services.geolocation: true) — then use the second line instead
header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# header Permissions-Policy "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# SAMEORIGIN, not DENY: windows show same-origin pages in iframes
header X-Frame-Options "SAMEORIGIN"
header X-Content-Type-Options "nosniff"
header Referrer-Policy "strict-origin-when-cross-origin"
header Cross-Origin-Opener-Policy "same-origin"
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
# ---------- Caching, the vault, dotfiles -----------------------------------
# Revalidated on every use (ETag → 304): HTML, JS, CSS, JSON, the manifest, sw.js and
# the sealed vault files carry no version in their names
file_cache_control "no-cache"
if desk_static {
file_cache_control "public, max-age=86400"
}
if desk_vault {
header X-Robots-Tag "noindex, nofollow, noarchive"
}
if desk_hidden {
status 404
}
}
ferron.kdl — Ferron 2 (complete)
// ============================================================================
// JPKCom Desktop — Ferron 2 configuration (ferron.kdl) — © Jean Pierre Kolb — MIT License
// ============================================================================
//
// Ferron 2.x, KDL format (https://ferron.sh/docs/v2; validated with 2.8.1), usually
// /etc/ferron.kdl. Ferron 3 uses another format: see ferron.conf next to this file.
//
// Replace example.org and the root folder. Ferron obtains the TLS certificate for
// a host name itself (auto_tls) and redirects http to https — the secure context
// that the service worker, installation, the vault and "My location" need.
//
// Root or sub-folder: copy the desktop's files into the root folder itself
// (https://example.org/) or into a folder below it (https://example.org/desktop/).
// Nothing else changes — the conditions below match the desktop's paths wherever
// they are, and /desktop (a directory without the slash) is redirected to /desktop/.
//
// Inheritance: a block that sets a directive replaces ALL inherited entries of that
// directive. Every conditional block that adds a header therefore uses the snippet
// with the whole header set again.
//
// Check after a deploy:
// curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
snippet "DESK_HEADERS" {
// Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
//
// connect-src add the hosts of the online services set to true in site/config.js → services:
// weather, provider 'open-meteo': https://api.open-meteo.com
// a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
// weather, provider 'brightsky': https://api.brightsky.dev
// terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
// fortune, remote 'jokeapi': https://v2.jokeapi.dev
// fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
// frame-src add the origins of 'web' apps whose url lives on another origin
// script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
// WebAssembly runs in pagefind-worker.js (under this policy when served from here)
// or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
//
// Then replace the line below with your extended copy, e.g. weather + DoH:
// header "Content-Security-Policy" "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
// or with Pagefind and a framed app from another origin:
// header "Content-Security-Policy" "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
header "Content-Security-Policy" "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
// Geolocation stays off unless the site offers "My location" for the weather
// (services.geolocation: true) — then use the second line instead
header "Permissions-Policy" "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
// header "Permissions-Policy" "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
// SAMEORIGIN, not DENY: windows show same-origin pages in iframes
header "X-Frame-Options" "SAMEORIGIN"
header "X-Content-Type-Options" "nosniff"
header "Referrer-Policy" "strict-origin-when-cross-origin"
header "Cross-Origin-Opener-Policy" "same-origin"
header "Strict-Transport-Security" "max-age=31536000; includeSubDomains"
}
example.org {
root "/var/www/example.org"
index "index.html"
// No directory listings: the vault's file names must stay secret
directory_listing #false
no_trailing_redirect #false
compressed
etag
mime_type ".html" "text/html; charset=utf-8"
mime_type ".css" "text/css; charset=utf-8"
mime_type ".json" "application/json; charset=utf-8"
mime_type ".webmanifest" "application/manifest+json; charset=utf-8"
mime_type ".mjs" "text/javascript; charset=utf-8"
mime_type ".js" "text/javascript; charset=utf-8"
mime_type ".bin" "application/octet-stream"
use "DESK_HEADERS"
// Revalidated on every use (ETag → 304): HTML, JS, CSS, JSON, the manifest, sw.js and
// the sealed vault files carry no version in their names
file_cache_control "no-cache"
// Images, fonts and media: one day
condition "DESK_STATIC" {
is_regex "{path}" "\\.(png|jpe?g|gif|webp|avif|ico|woff2?|mp3|ogg|oga|m4a|wav|mp4|webm)$" case_insensitive=#true
}
if "DESK_STATIC" {
file_cache_control "public, max-age=86400"
}
// Sealed vault files: not for search engines
condition "DESK_VAULT" {
is_regex "{path}" "/site/vault/"
}
if "DESK_VAULT" {
use "DESK_HEADERS"
header "X-Robots-Tag" "noindex, nofollow, noarchive"
}
// Dotfiles (.git, .env, …) are never served, except /.well-known/
condition "DESK_HIDDEN" {
is_regex "{path}" "/\\."
is_not_regex "{path}" "^/\\.well-known/"
}
if "DESK_HIDDEN" {
status 404
}
}- Where: copy
docs/server/static-web-server.tomlto/etc/static-web-server/config.toml. - Replace:
root,http2-tls-cert,http2-tls-key,https-redirect-hostandhttps-redirect-from-hosts. Behind a proxy that terminates TLS, setport = 8080andhttp2 = falseinstead, and remove thehttp2-tls-*andhttps-redirect*lines. - Certificates:
ignore-hidden-files = truerefuses every dotfile,/.well-known/included. Obtain certificates with a DNS challenge or through the TLS proxy in front of it. - Online services: edit the
Content-Security-Policy = …line in the first[[advanced.headers]]block and, for "My location", thePermissions-Policy = …line. - Start:
static-web-server --config-file /etc/static-web-server/config.toml.
static-web-server.toml (complete)
# ============================================================================
# JPKCom Desktop — static-web-server configuration (TOML) — © Jean Pierre Kolb — MIT License
# ============================================================================
#
# static-web-server 2.x (https://static-web-server.net), started with
# static-web-server --config-file /etc/static-web-server/config.toml
# Replace the root folder; for TLS either give it a certificate (http2 settings
# below) or put it behind a proxy that terminates HTTPS. The service worker,
# installation, the vault and "My location" need a secure context (HTTPS;
# localhost is the only exception).
#
# Root or sub-folder: copy the desktop's files into the root folder itself
# (https://example.org/) or into a folder below it (https://example.org/desktop/).
# Nothing else changes — the header globs below match the desktop's paths
# wherever they are, and /desktop (without the slash) is redirected to /desktop/
# (redirect-trailing-slash).
#
# Header rules: every [[advanced.headers]] entry whose glob matches the request
# path adds its headers; a later matching entry overrides the same header name
# of an earlier one (as for Cache-Control on images below).
#
# Check after a deploy:
# curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-)'
[general]
host = "::"
port = 443
root = "/var/www/example.org"
#### HTTPS (certificate files) — or port = 8080, http2 = false behind a TLS proxy
http2 = true
http2-tls-cert = "/etc/ssl/example.org/fullchain.pem"
http2-tls-key = "/etc/ssl/example.org/privkey.pem"
https-redirect = true
https-redirect-host = "example.org"
https-redirect-from-port = 80
https-redirect-from-hosts = "example.org"
#### Files: no directory listings (the vault's file names must stay secret), no dotfiles.
#### ignore-hidden-files has no exception: /.well-known/ is refused too (404) — get certificates with a
#### DNS challenge or through the TLS proxy, or set it to false if the root holds no other dotfiles.
directory-listing = false
ignore-hidden-files = true
redirect-trailing-slash = true
index-files = "index.html"
compression = true
#### The headers below replace the built-in sets (their CSP and caching do not fit)
security-headers = false
cache-control-headers = false
[advanced]
#### Security headers and the default caching — every path
[[advanced.headers]]
source = "**"
[advanced.headers.headers]
# Content-Security-Policy. Opt-in extensions — switch on ONLY what the site offers:
#
# connect-src add the hosts of the online services set to true in site/config.js → services:
# weather, provider 'open-meteo': https://api.open-meteo.com
# a place search by name, if you add one: https://geocoding-api.open-meteo.com (the shipped providers never call it)
# weather, provider 'brightsky': https://api.brightsky.dev
# terminal dig/host (DoH): https://dns.google (the host of terminal.doh.url)
# fortune, remote 'jokeapi': https://v2.jokeapi.dev
# fortune, remote 'uselessfacts': https://uselessfacts.jsph.pl
# frame-src add the origins of 'web' apps whose url lives on another origin
# script-src add 'wasm-unsafe-eval' when the site uses the optional Pagefind search: its
# WebAssembly runs in pagefind-worker.js (under this policy when served from here)
# or, when that worker fails or is slow to start, in the page — docs/deploy.md §6
#
# Then replace the line below with your extended copy, e.g. weather + DoH:
# Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob: https://api.open-meteo.com https://dns.google; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# or with Pagefind and a framed app from another origin:
# Content-Security-Policy = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self' https://apps.example.org; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests"
# Geolocation stays off unless the site offers "My location" for the weather
# (services.geolocation: true) — then use the second line instead
Permissions-Policy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# Permissions-Policy = "accelerometer=(), camera=(), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# SAMEORIGIN, not DENY: windows show same-origin pages in iframes
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Cross-Origin-Opener-Policy = "same-origin"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
# Revalidated on every use (ETag / Last-Modified → 304): HTML, JS, CSS, JSON, the manifest,
# sw.js and the sealed vault files carry no version in their names
Cache-Control = "no-cache"
#### Images, fonts and media: one day
[[advanced.headers]]
source = "**/*.{png,jpg,jpeg,gif,webp,avif,ico,woff,woff2,mp3,ogg,oga,m4a,wav,mp4,webm}"
headers = { Cache-Control = "public, max-age=86400" }
#### Text types with their charset (the built-in table sends text/javascript, text/css, application/json without one)
[[advanced.headers]]
source = "**/*.{js,mjs}"
headers = { Content-Type = "text/javascript; charset=utf-8" }
[[advanced.headers]]
source = "**/*.css"
headers = { Content-Type = "text/css; charset=utf-8" }
[[advanced.headers]]
source = "**/*.json"
headers = { Content-Type = "application/json; charset=utf-8" }
#### The web app manifest (older MIME tables do not know .webmanifest)
[[advanced.headers]]
source = "**/*.webmanifest"
headers = { Content-Type = "application/manifest+json; charset=utf-8" }
#### Sealed vault files: not for search engines
[[advanced.headers]]
source = "**/site/vault/**"
headers = { X-Robots-Tag = "noindex, nofollow, noarchive" }Any server works that can (1) serve static files with the right MIME types (.js as text/javascript,
.webmanifest as application/manifest+json), (2) send the headers above with every response, errors
included, (3) redirect a folder without the trailing slash, (4) list no directories, (5) send
Cache-Control as described and (6) refuse dotfiles.
# Security headers and caching of the page
curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-|strict)'
# The trailing-slash redirect: expect 301 or 308 with location: /desktop/
curl -sI https://example.org/desktop | grep -iE '^(HTTP|location)'
# MIME types of a module (text/javascript) and of the manifest (application/manifest+json)
curl -sI https://example.org/desktop/src/boot/main.js | grep -i '^content-type'
curl -sI https://example.org/desktop/manifest.webmanifest | grep -i '^content-type'
# Dotfiles and the vault folder are refused: expect 404, and 403 or 404
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/.git/config
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/site/vault/At the web root, leave out /desktop. In the browser, open the developer tools and reload. A blocked
request appears in the console as a CSP violation that names the directive. More checks and a
troubleshooting table are in docs/deploy.md §12–13.
The optional vault module keeps bookmarks that only you can see. They are encrypted into a file
on the server, and login in the terminal decrypts them in the browser. PBKDF2-HMAC-SHA-256 turns the
user name and password into an AES-256-GCM key and the file name. Wrong credentials ask for a file that
does not exist.
# 1. A salt of your own, once per deployment
node tools/seal-vault.mjs --new-salt
# → site/config.js: vault: { salt: '<the salt>', iterations: 600000 }, and add 'vault' to modules
# 2. Write the bookmarks as JSON OUTSIDE the project and outside every web root, e.g. ~/private/bookmarks.json
# { "groups": [{ "id": "work", "name": { "en": "Work", "de": "Arbeit" }, "icon": "ti-briefcase" }],
# "items": [{ "slug": "wiki", "group": "work", "name": "Team wiki", "url": "https://wiki.example.org/" }] }
# 3. Seal: asks for user name and password, checks a round trip, writes site/vault/<32 hex>.bin
npm run seal -- --in ~/private/bookmarks.json
# Show the sealed files and whether they match the current salt and iterations
node tools/seal-vault.mjs --listUpload the .bin file to site/vault/ and type login in the desktop's terminal. logout hides the
bookmarks again. Seal once per user. When other .bin files are in the folder, the tool asks whether to
keep them; --keep keeps and --prune removes them without asking.
Security notes:
- HTTPS is required (Web Crypto only works in a secure context).
- No directory listing. File names are derived from the credentials. The shipped configurations
also send
Cache-Control: no-cacheandX-Robots-Tag: noindexfor these files, and the service worker never caches them. - Use your own salt and a long password. Anyone can download a sealed file whose name they know and try passwords offline. The protection is the password's strength and the PBKDF2 cost. This is meant for private links, not for secrets.
- Never commit
.binfiles, and never put the plain-text JSON where a web server publishes it. The tool refuses an input file belowsite/or the web root. - "Stay logged in" keeps only a non-extractable key in IndexedDB, never the password or the plain text. Every page on the same origin could use that key (same origin = full trust).
Step by step: site/vault/README.md.
| Script | Does |
|---|---|
npm run serve |
local static server with the production headers (--port, --host, --base, --root, --connect, --frame, --wasm, --geolocation) |
npm test |
unit tests (node --test "tests/*.test.mjs") |
npm run validate / validate:strict |
checks site/apps.js against the configuration (strict also fails on warnings) |
npm run i18n:check [-- <lang>] |
compares every language with English: missing keys, placeholders, plural forms |
npm run icons / icons:check |
builds or verifies src/icons/tabler.js from the Tabler icons the sources use |
npm run preload / preload:check |
builds or verifies src/boot/preload.js, the preload hints that let the start fetch all its files at once — run it after adding or changing a module, app or site module |
npm run browsers |
downloads the headless Chromium for the browser checks and icons:pwa |
npm run icons:pwa |
renders the PNG app icons from assets/icons/favicon.svg and maskable.svg (headless Chromium) |
npm run seal |
seals private bookmarks for the vault |
npm run check:browser |
headless browser check under the production headers |
Browser check. tools/browser-check.mjs starts serve.mjs, opens the desktop in headless Chromium
and fails on console errors, page errors, CSP violations and failed requests. It can run a scenario
module, switch the language, use a phone viewport, a sub-folder or another site config, and take a
screenshot. Install the browser once:
npm run browsers
npm run check:browser -- --lang de-DE --mobile --screenshot /tmp/desk.pngOn machines with little RAM, run browser checks one at a time:
flock /tmp/jpkcom-desktop-browser.lock node tools/browser-check.mjs … (all options are in the file's
header).
Coding rules (enforced by review and partly by tests):
- No
innerHTML/outerHTML/insertAdjacentHTML, noeval, no inline scripts, nostyleattributes. Build DOM withh()andtextContent, and set styles through the CSSOM. - Every text goes through
t()with named placeholders or a language map. Never concatenate sentences and never branch on a language code. - CSS lives in cascade layers (
@layer modules { … }), uses tokens instead of colour literals and logical properties, andbody.compactfor phones. - Tabler icons only (
npm run iconsafter using a new one). Ids are[a-z0-9-]. - Each source file starts with the header
JPKCom Desktop — <purpose> — © Jean Pierre Kolb — MIT License. - Validate everything that comes from storage, the manifest or the network. Bad input is warned about and skipped, never fatal.
How to contribute: CONTRIBUTING.md. Changes per version: CHANGELOG.md.
- No tracking, no analytics, no cookies, no third-party code.
- The visitor's data stays in their browser: settings, notes, tasks, the editor draft and the
terminal history live in
localStorageand IndexedDB on their device. A backup is a file they download themselves. Files they open or drop are read locally and never uploaded. - Online services are off by default. A site has to switch each one on, and each visitor has to agree before the first request. Consent can be withdrawn at any time in Settings → Online services. Requests go out without cookies and without a referrer. "My location" stores only a position rounded to about 1 km.
- Strict CSP with no exceptions by default. Content shown in the Reader is sanitised; files from the device never open as a document.
- The example site ships templates for an imprint and a privacy policy (
site/content/<lang>/imprint.html,privacy.html). Fill them in before you publish.
To report a vulnerability, see SECURITY.md.
- License: MIT © 2026 Jean Pierre Kolb — except the brand assets.
- Icons: a generated subset of Tabler Icons (MIT, © Paweł Kuna).
- Brand assets (not MIT): the JPK monogram and the JPKCom logo are the author's personal logo,
in use since 1996 — © 1996–2026 Jean Pierre Kolb, all rights reserved; not a registered trademark.
You may copy and distribute them unchanged as part of JPKCom Desktop or a fork of it and show them as
its default brand (also in your deployment or in a fork that has not rebranded yet) and in the
credit; how the desktop itself renders them (size, colouring by theme or accent colour, the PNG
renders, the wallpaper motifs) counts as unchanged. You may not use them as your own logo or brand,
for other projects or products, or altered. To rebrand, replace them through
brand.glyph,brand.logo,brand.asciiLogo, the icon files and the motifsauthor-monogramandauthor-emblem. Please keep the credit line. Details: CREDITS.md. - Third-party notices: CREDITS.md.
Author: Jean Pierre Kolb — JPKCom · Website https://www.jpkc.com/ · GitHub @JPKCom · Mastodon @JPKCom@mastodon.social





