Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e593080
feat(push): wake a closed browser, over Web Push
Sycatle Aug 25, 2026
b0848a6
refactor(ui): settings open over the application, not instead of it
Sycatle Aug 25, 2026
39601f3
docs(threat-model): push is no longer half-built, so stop saying it is
Sycatle Aug 25, 2026
c73ed0a
test(push): ask a real push service, and say what it answered
Sycatle Aug 25, 2026
c58f3e5
feat(ui): errors float beside confirmations instead of shrinking the …
Sycatle Aug 25, 2026
0b63e38
refactor(web): one build that belongs to no deployment
Sycatle Aug 25, 2026
974dd9b
Merge pull request #40 from Sycatle/feat/web-push
Sycatle Aug 25, 2026
af1e79d
feat(release): publish what the web client is, where the server canno…
Sycatle Aug 25, 2026
04a14e2
fix(release): pin node to the patch, or the manifest describes nobody
Sycatle Aug 25, 2026
a72fe9e
feat(extension): check the served code, and say so where the server c…
Sycatle Aug 25, 2026
ed931c5
fix(csp): derive both media origins however the variable is spelled
Sycatle Aug 25, 2026
f720805
docs(threat-model): what a checkable build establishes, and what it d…
Sycatle Aug 25, 2026
9e7f5eb
Merge pull request #41 from Sycatle/feat/verifiable-web
Sycatle Aug 25, 2026
7787d70
fix(dev-env): pass the whole of .env through, not seven names of it
Sycatle Aug 25, 2026
de1b126
Merge pull request #42 from Sycatle/fix/dev-env-passes-the-whole-file
Sycatle Aug 25, 2026
6a9ff37
fix(dev-env): read the export-prefixed lines a .env may carry
Sycatle Aug 25, 2026
5eb9c73
Merge pull request #43 from Sycatle/fix/dev-env-reads-export-lines
Sycatle Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ SERVER_ADDR=127.0.0.1:8787

# The transparency log's public key, base64, 32 bytes — as printed by the server on first boot.
#
# **No longer compiled into the web client**, and the variable is kept only for the desktop build
# and for whoever verifies a log head by hand. `apps/web/src/lib/pinning.ts` argues why: on the web
# the server ships the pin along with the code it constrains, so it was never a defence there, and
# taking it out is what makes every deployment's bundle byte-identical.
#
# Optional, and empty here on purpose. Set it and the client refuses any log head signed by a
# different key, which is the only check that works on a first contact with a server: everything
# else compares the server against its own past. It closes that hole in the **desktop binary**,
Expand Down
115 changes: 115 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Publishes the manifest of the web client, attested to this repository.
#
# # Why this workflow is the point, and not the script it runs
#
# `scripts/release-web.sh` can be run by anybody, which is what makes the build verifiable. But a
# manifest produced on a laptop says only "somebody hashed some files"; the reader has no way to
# tell it from a manifest describing a hostile build. What closes that is `attest-build-provenance`:
# it binds the artefact to **this commit** and to **this workflow**, signed through Sigstore, and
# nobody — the maintainer included — can produce that binding outside GitHub Actions.
#
# That is the whole reason the check means anything. The repository already carries an Ed25519 key
# for the desktop release, and `verify-release.sh` says in as many words what it is worth: the key
# lives in the repository, so whoever controls the repository can replace it. For a manifest whose
# job is to be independent of the party serving the code, a key the same party carries is not
# independence.
#
# # Why a tag and not every push
#
# A manifest is a claim about a build somebody can install. `dev` moves several times a day and
# nothing deploys from it; a manifest per commit would be a list of hashes nobody could act on,
# and would make the released ones harder to find.
name: Release

on:
push:
tags:
- "v*"
# For rehearsing the workflow before there is a tag to rehearse it on. It publishes nothing:
# `gh release` only runs on a tag ref.
workflow_dispatch:

# Absent from every other workflow in this repository, and required by two of the steps below.
# `id-token` is what lets the runner prove to Sigstore which workflow it is; `attestations` is what
# lets it record the result. `contents: write` is for creating the release itself.
permissions:
contents: write
id-token: write
attestations: write

jobs:
web:
name: Web client manifest
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5

# **The exact version, from `apps/web/.nvmrc`, and this is the line the manifest rests on.**
#
# Measured: node 22.21.0 and node 22.23.2 produce different bytes for two of the fourteen
# generated files — `index-*.js` and `PdfViewer-*.js` — while the CSS and the other chunks
# match. A manifest built here and a deployment built elsewhere would therefore disagree on
# two files, and the disagreement would look exactly like an attack.
#
# `node-version: 22` resolves to whatever 22.x the runner has that week. It happened to
# match `deploy/Dockerfile.web` on the day this was written, which is not a property anybody
# should rely on — it is the kind of agreement that holds until it silently stops.
- uses: actions/setup-node@v5
with:
node-version-file: apps/web/.nvmrc
# The cache keys on a lockfile path this repository does not have at the root, and a
# half-hit cache is a slower build with a confusing log.
package-manager-cache: false

# Pinned rather than left to corepack's default, the same statement `deploy/Dockerfile.web`
# makes: `apps/web/package.json` declares no `packageManager`, so nothing else in the tree
# records which pnpm produced `pnpm-lock.yaml`.
#
# It matters more here than anywhere else. A manifest is a claim that a given commit
# produces given bytes; if the tool that produces them is whatever version happened to be
# current that day, the claim is about a build nobody can reproduce.
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@11.22.0 --activate

- name: Build and hash
run: scripts/release-web.sh

# Everything worth reading in the log, because a mismatch reported weeks later is
# investigated from here.
- name: What was built
run: |
cat release/web/BUILD-INFO
echo
head -5 release/web/WEB-SHA256SUMS

- uses: actions/attest-build-provenance@v3
with:
subject-path: release/web/WEB-SHA256SUMS

# The manifest and nothing else. The bundle itself is not published: a reader does not need
# our copy of the files, they need the hashes to compare against the copy their own browser
# was served — and publishing the bundle would invite verifying the wrong thing.
- name: Publish
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes "Manifest of the web client for \`${GITHUB_SHA}\`.

Check a deployment against it:

\`\`\`sh
gh release download ${GITHUB_REF_NAME} --pattern WEB-SHA256SUMS
gh attestation verify WEB-SHA256SUMS --repo ${GITHUB_REPOSITORY}
scripts/verify-web.sh https://your.deployment WEB-SHA256SUMS
\`\`\`

The second line is the one that matters: it establishes that this manifest came out of
this repository's workflow rather than out of somebody's laptop. Verifying the hashes
without it checks that a server is consistent with a file you were handed, which is not
the same claim." \
release/web/WEB-SHA256SUMS \
release/web/BUILD-INFO
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ jobs:
working-directory: apps/web
run: pnpm test

# The extension's comparison, which is the part of it that can be checked without a browser.
#
# In this job rather than one of its own: it needs node and nothing else, and a job that
# spends thirty seconds installing a runtime to run nine tests would cost more than it
# reports. It runs unconditionally for the same reason the rest of this job does — the
# extension is what makes the published manifest mean anything, and a silent regression in
# it would turn a green icon into a claim nobody checked.
- name: Extension
run: node --test extension/*.test.js

# The bundle, and it is not decoration.
#
# `typecheck`, `lint` and `test` all read the source; none of them resolve an import,
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ not their equal and does not try to be.

## What does not work

- **Push notifications are half-built.** The server records tokens and decides who to wake,
and then sends nothing. There is no FCM or APNs provider, no configuration, no device-side
token registration and no user-facing setting. It is inert without configuration, and a
self-hosted deployment that talks to neither Apple nor Google stays fully functional.
- **Push reaches browsers, not the packaged mobile app.** Web Push works end to end — a browser
subscribes, the server signs a VAPID token, a notification arrives with the tab closed — and it
is off until a deployment sets `VAPID_SUBJECT`. FCM and APNs are not written: device-side
registration needs a Tauri plugin that does not exist, so the Tauri build is only notified while
it is open. The wake-up carries no text, no sender and no group id.
- **Biometric unlock has never been executed.** The code exists; not one line of it has run.
There is no Android NDK and no physical device on the development machine, so even the
compilation of its dependency is unconfirmed.
Expand All @@ -70,8 +71,11 @@ docker compose up -d
# 2. Configuration. The committed defaults point at that container.
cp .env.example .env

# 3. Server — listens on 127.0.0.1:8787. The script loads .env, which the
# server does not do itself, and gives the branch its own database and port.
# 3. Server — listens on 127.0.0.1:8787. The script passes .env through, which
# the server does not read itself, and gives the branch its own database and
# port. Everything the file defines reaches the server: the two values the
# script computes — the database and the address — are the only ones it
# overrides.
./scripts/dev-server.sh

# 4. Client, in a second terminal. `wasm` builds crypto-core to WebAssembly
Expand Down
1 change: 1 addition & 0 deletions apps/web/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.23.2
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-tooltip": "^1.2.16",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-opener": "^2.5.4",
Expand Down
36 changes: 36 additions & 0 deletions apps/web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* The service worker, and what it deliberately is not.
*
* # Why this file exists at all, when the project refused one
*
* `src/lib/notifications.ts` refuses a service worker in as many words: "one would be a cache of
* the application shell served by the same server the desktop build exists to stop trusting". That
* objection is about **caching**. A worker that caches the shell keeps a copy of the application
* alive across visits, so a server that served a hostile bundle once keeps its victim even after
* it is fixed — which is a real and serious thing to refuse.
*
* This worker caches nothing. It registers no `fetch` handler, opens no `Cache`, keeps no
* precache manifest, and intercepts no request. Every load of the page comes from the network
* exactly as it did before this file existed, and deleting it changes nothing except that
* notifications stop arriving. It cannot serve a stale application because it cannot serve an
* application.
*
* The reason a worker is needed at all is that the Push API has no other delivery point: a push
* message wakes the *worker*, not the page, and there is no version of Web Push that reaches a
* document directly.
*
* # Why the text is a constant
*
* The worker cannot decrypt. The MLS keys live in a WASM module inside the page, in memory the
* worker has no access to, and moving them here would mean handing the decryption keys to a
* context that outlives every tab. So the notification says that something arrived, and nothing
* about what: the same answer iOS forces on every messenger, arrived at here on purpose rather
* than by constraint.
*
* That is also the third of the three limits in `migrations/0011_push.sql`: the wake-up carries
* no text, no sender and no group id. There is nothing here to display even if this file wanted
* to.
*/

// Kept in step with `NOTICE_TITLE` and `NOTICE_BODY_ONE` in `src/lib/notifications.ts`. Duplicated
// rather than imported: a service worker is its own module graph, served as a plain file so that
// what is deployed is what can be read, and a build step to share two strings would cost more
// clarity than it saves. `push.test.ts` pins them against their source.
const TITLE = "Whispee";
const BODY = "New message";

self.addEventListener("push", (event) => {
// `waitUntil` or the worker may be killed before the notification is shown. Browsers also
// require that a push handler show *something*: staying silent gets the subscription revoked
// after a few offences, and on some browsers displays a "this site was updated in the
// background" notice instead — worse than ours, and not ours to write.
event.waitUntil(
self.registration.showNotification(TITLE, {
body: BODY,
// The collapse key. Ten messages while the phone is in a pocket are one notification, not
// ten — the page does the same with `tag: conversation`, except this side does not know
// which conversation, so everything collapses into one.
tag: "whispee-wake",
// No `renotify`: the point of collapsing is not to buzz again for each one.
silent: false,
}),
);
});

self.addEventListener("notificationclick", (event) => {
event.notification.close();

// Focus a tab that is already open before opening another. Somebody who clicks a notification
// wants the conversation they were already in, not a second copy of the application signing in
// from scratch.
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});

for (const client of clients) {
if ("focus" in client) return client.focus();
}

// No deep link, and it is not an oversight: the wake-up does not say which conversation,
// so the honest destination is the application's front door.
return self.clients.openWindow("/");
})(),
);
});
Loading
Loading