# Whispers [![CI](https://github.com/Atelier-Dakroub/whispers/actions/workflows/ci.yml/badge.svg)](https://github.com/Atelier-Dakroub/whispers/actions/workflows/ci.yml) **A headline-only news site you own.** One scannable page: your logo, and links in descending date order under a heading for each day. Every headline is a link out. That is the whole product, and the restraint is the point — a reader takes in forty stories in the time a normal news site spends loading one. For people who catch stories early and want somewhere to put them: a beat reporter, a trade newsletter, a neighborhood wire, an internal feed for a team that reads the same twenty sources. **The reader's page ships zero JavaScript.** Not "a small amount" — none. The theme, the fonts and the colors arrive as custom properties the server wrote, resolved by the browser before the first paint, so light and dark switch with no flash and no script. The admin ships one file — 2.3 KB, 800 bytes compressed — and only so the settings form can preview a color while you pick it. **You get the repository, not an account.** Nothing phones home, nothing expires, and there is no key to enter. ```sh npm install npm run db:migrate npm run dev # http://localhost:1960 ``` Open it and the first visitor to an unclaimed site gets a setup page and makes the first account. No seed script, no hash to generate on a command line, nothing to edit before it will let you in. ## What it does - **An admin built for links.** Paste a URL, write the headline, publish. Tracking parameters are stripped, and a link already posted is refused — so the same story never runs twice. Save as a draft and it stays off the site. - **Breaking stories that stop being breaking.** Mark one and it sits above the day headings in its own color. It expires on its own after a window you set, because a `BREAKING` banner that is three days old costs you every future one. - **Yours to look at.** Eight colors, two font stacks, light and dark, reading density, day headings, sources, times, the rules between lines, and whether a headline opens in a new tab — all from the admin, with a live preview. Two logos, one for a light page and one for a dark one. - **Contrast you can trust.** The admin measures your palette with [APCA](https://git.apcacontrast.com) and tells you what is hard to read. It reports rather than refuses: your masthead is your call. - **More than one person.** Everyone gets their own passphrase, so removing somebody locks them out on its own and disturbs nobody else. - **A feed**, at `/feed.xml`, from the same headlines. Nothing to configure. - **Six languages** for the reader's chrome, with dates, number systems and *today*/*yesterday* from `Intl` — so locales the translation table never saw are still right. Right-to-left works. - **Anywhere JavaScript runs.** Node, Bun, Deno or Cloudflare Workers. A SQLite file by default; Turso, Postgres and Cloudflare D1 from the same code. ## What it costs **Free** for personal, hobby and other noncommercial sites. **$99 once** if money changes hands because of the site — ads, sponsorships, affiliate links, a paid newsletter, or a company. Unlimited sites on both, including sites you build for clients. Same code either way. See [LICENSE.md](LICENSE.md), or [whispers.news](https://whispers.news) to buy. ## What the owner can change Sign in. All of the controls are below `/admin`. | | | | --- | --- | | `/admin` | post a headline, and the list of everything posted, 50 to a page | | `/admin/articles/` | edit, publish, unpublish, mark breaking, delete | | `/admin/settings` | name, tagline, time zone, language, fonts, colors, light/dark, day headings, source, time, rules, density, headlines per page, new-tab links, how long a story stays breaking, the footer credit, logo, whether the name is written beside it, share card | | `/admin/people` | add, remove, and reset a passphrase | An article has a headline, a link, an optional source, and a date. An article is a draft, or it is published. A draft is on no page and in no feed. ## The database is yours The app uses Drizzle. `DB_DRIVER` selects the driver. One schema serves the SQLite family, and a second schema serves Postgres. The two schemas are `app/data/schema.sqlite.js` and `app/data/schema.pg.js`. They declare the same tables with the same names. | `DB_DRIVER` | `DATABASE_URL` | | | --- | --- | --- | | `libsql` *(default)* | `file:./data/whispers.db` | a file, or Turso with a `libsql://` URL | | `better-sqlite3` | `./data/whispers.db` | `npm install better-sqlite3` | | `postgres` | `postgres://…` | `npm install postgres` | | — | — | Cloudflare D1, wired in `worker.js` from the binding | ```sh npm run db:generate # regenerate both migration sets after a schema change npm run db:migrate # apply them to whatever DB_DRIVER names npm run db:reset -- --yes # erase everything and start at /setup again ``` To add a column, edit both schema files in the same commit. Then run `db:generate`. That command builds the two dialects together, for this reason. No file above `app/data/` imports Drizzle. The routes read through `articles.js`, `settings.js`, `members.js`, and `assets.js`. A third dialect is therefore one new schema file, not a change through the full app. ## Accounts Each person has a different passphrase. This is why removal of a person works. With a shared secret, removal of an address is only bookkeeping: the person knows the secret and can type the address of a colleague. Here the row is the credential, and deletion of the row removes the access. The app makes a passphrase when you add a person. The app shows the passphrase one time. The app stores only a PBKDF2 verifier. You cannot read a passphrase again. Reset it instead. ```sh npm run member:list npm run member:add -- them@example.com "Their Name" npm run member:reset -- you@example.com ``` Use `member:reset` when the last passphrase is lost. The command needs shell access to the server. This is intentional: it is the one door that does not open over HTTP. `db:reset` drops the tables. It does not delete the file. The command therefore operates on a server that runs, and you do not restart the server. No old process continues to serve the data that you erased. **The app hashes a passphrase at 100,000 PBKDF2 rounds. OWASP asks for 600,000.** 100,000 is the limit in workerd. Above the limit, the WebCrypto of Cloudflare throws `NotSupportedError`. It does not become slow. This is true on each Cloudflare plan. An install that runs only on Node, Deno, or Bun can increase `passphraseRounds` in the settings table. The app writes the count into each stored verifier, so a change keeps the existing passphrases correct. Do not increase the count if the install can move to Cloudflare later. The verifiers move with the data and fail there. Then each person must reset a passphrase. ## Links The app makes each URL canonical before it stores the URL: - If the scheme is absent, the app uses `https`. - The app removes the tracking parameters. - The app removes the fragment. - The app refuses a URL that is already in the table. The app accepts `http` and `https` only. The app refuses credentials in a URL. The app also refuses a private host and a host that is not public. Set `GOOGLE_SAFE_BROWSING_API_KEY`. The app then checks each link against Google Safe Browsing. It checks the link at submission, and again before publication. The check is advisory: - A match shows a warning and a **Post anyway** button. - The app keeps the reason on the record. - A network failure lets the post through. - An absent key lets the post through. Without a key, nothing else changes. ## The reader's page The owner can enable or disable these items: - the day headings - the source after a headline - the time of a story - the line between the headlines - the reading density The density is compact, normal, or relaxed. It moves the line height and the space around each row together. That pair is the meaning of density to a reader. The time has one rule behind it. Below a day heading, the page shows the time alone, because the heading gives the day. If the headings are off, the time also carries the date. A bare `06:47` on a story of three days ago looks like this morning. The colors have a **Reset colors to default** button. It resets the eight colors and changes nothing else. A reset that also changed the typography must be a different button. ## Breaking A breaking story is above the day headings. It has its own color and a `BREAKING` label. The label is necessary, because not every reader receives a color signal. A story stops to be breaking without help. The column keeps the time when you marked the story. It does not keep a true or false value. The page compares that time to a cutoff when it renders. There is therefore no cron job, no cleanup job, and no state that becomes stuck. You mark a story in the morning. By the evening the story leaves the band and joins the list, still published, with nothing lost. The window is a setting, in hours. `0` keeps a story breaking until a person removes the mark. The reason for the limit is not tidiness. Readers will not trust your next `BREAKING` label if the last one is three days old. ## The sponsor line The license charges for a site that shows ads, so the app carries a place to put one. It holds text and a link. It does not hold a script. The slot sits below the masthead and above the headlines. It is never inside the list. A reader takes in the full list in one movement, and an interruption inside it removes the thing this product is for. Three parts, all in Settings: - **Disclosure.** The words above the line, `Sponsored` by default. In the United States the FTC requires a paid placement to say so, and other markets have their own rule. It is a setting because the right word differs by market and by language. It renders whenever the slot renders. - **Text and link.** The link is optional. Without one, the line is text. - **Artwork.** Optional, uploaded like the logo, and it replaces the text. The text becomes the `alt` value. A paid link gets `rel="sponsored nofollow noopener"`. Google requires `sponsored` on a link that somebody paid for, and a site without it can receive a manual action. The link keeps its query. A headline link goes through `canonical()`, which strips the tracking parameters. That is right for a headline and wrong here: the `utm_` tags on a sponsor's link are how they measure what they bought. **An ad network needs JavaScript from another domain, and this app has no place for one.** The policy is `script-src 'self'` and `img-src 'self' data:`, so a network script and its images are blocked. To run one, an owner must: 1. Restate the full Content-Security-Policy. `csp: true` replaces the directives; it does not merge them. The site then stops to inherit later defaults. 2. Give up the "no JavaScript on the reader's page" claim. 3. Accept a third party that watches the readers. Direct sponsorship costs none of those three, and it is what a trade wire or a niche newsletter usually sells. ## The footer credit The footer shows `Powered by Whispers` by default, and a setting removes it. Nothing enforces the credit. The personal license asks you to keep it. The commercial license does not require it. But this app promises that there is no key to enter and that nothing counts the installs. A technical lock would make that promise false. The admin states what each license expects, and then trusts the owner. ## Fonts There are two settings, for the two roles on the page: - the face for the headlines - the face for the labels, the dates, and the admin Both lists come from [Modern Font Stacks](https://modernfontstacks.com/). Those faces are already on the machine that reads the page. The browser therefore downloads nothing, nothing delays the first paint, and there is no license to host. The settings table holds a stack **id**. It never holds a `font-family` value. An id cannot carry text into a style attribute. You can also correct a stack in `app/lib/fonts.js` later, and no data changes. ## Language and direction The locale setting controls four things. Only one of them is a translation. `Intl` controls three of them, for each locale that it supports: - the format of a date - the format of a number - the words *today* and *yesterday*, for example `aujourd'hui`, `أمس`, `今日` Nobody maintains a table for those three. The fourth is the 15 strings that the reader sees but that are not the news: - the skip link - the footer - the pager - the empty state - the not-found page They are in `app/lib/strings.js`, in English, French, Spanish, German, Portuguese, and Arabic. To add a language, add a key. An incomplete language falls back to English, one string at a time. The locale also sets `lang` and `dir` on ``. Each direction-sensitive rule in the stylesheet is logical: it uses `margin-inline-start`, not `margin-left`. An Arabic site or a Hebrew site therefore mirrors correctly, and you change nothing else. **The admin is in English and stays in English.** It has approximately 150 strings. One to five people see them, and those people selected this product. This file describes the part that the *audience* of a buyer reads. **`404.html` is the exception.** The build writes it to a file. At that moment there is no database to read the setting from. The page therefore takes its language from `SITE_LOCALE` in the build environment. ## The logo, light and dark The preview box is the upload control. The full box is a `