Conversation
The server registered wake tokens and woke nobody: `Waker`'s only
implementation was `Silent`. A messenger that does not wake the phone is not
a messenger, and this was the first thing between the deployment and somebody
able to use it.
The roadmap described FCM and APNs and called the missing half "the part that
requires secrets". That was true and it was not the hard part. Device-side
registration needs a Tauri plugin that does not exist, therefore Kotlin and
Swift, and none of it compiles or runs here — no NDK, no macOS host, no
device. Writing it would have produced what this repository refuses
elsewhere: integration code that has never been executed and looks like a
feature.
Web Push removed that wall for one reason. The wake-up carries nothing, so
there is no payload to encrypt, so the whole content-encryption half of Web
Push — RFC 8291, aes128gcm, the `p256dh` and `auth` secrets — is unused. What
is left is one ES256 signature. It needed no migration for the addresses
either: without a payload the only thing worth keeping is the endpoint, so
`Address { provider, token }` holds a subscription unchanged.
One variable turns it on, `VAPID_SUBJECT`, and there is no private key to
supply: the pair belongs to the server and is created on first start, the
shape `log_key` has had since the transparency log. Unset, the waker stays
`Silent`, the key route answers 503 and the client hides the control — the
second of the three limits in `0011_push.sql`, still the behaviour to
preserve first.
Verified against the real thing, which is what separates this from a hope:
Chrome subscribed through fcm.googleapis.com, the server signed and pushed,
Google accepted, and the worker showed "New message" with the tab closed. The
test that proves it is committed `#[ignore]` with the command to replay it.
Six more run against a fake service and check what no real one can be asked
to: that the body on the wire is empty, that the token verifies under the
advertised key, that a 410 drops the subscription and a 500 does not.
Two things this cost elsewhere, both stated in the roadmap:
- The server has an outbound HTTP client now, which it never had. reqwest
with rustls, no cookie store, no redirect following — a push endpoint
answering with a redirect is not one to follow carrying a bearer token.
- There is a service worker, and `notifications.ts` argued against one. The
objection was a cache of the application shell served by the server the
desktop build exists to stop trusting. This one caches nothing, registers
no fetch handler, and `push.test.ts` asserts that rather than trusting the
comment.
Found while using it: two toggles a second apart left the switch claiming
this browser would be woken with nothing subscribed, and the reverse after.
Subscribing and unsubscribing both reach the push service and finish in an
order nobody chose. The displayed state is now read back from the browser
instead of inferred from the call, and toggles are chained so only one is in
flight.
No FCM, no APNs: the packaged mobile application is still only notified while
it is open. `Vapid::wake` matches on the provider name, so a second emitter
lands beside it without touching the call site.
Settings were a route that replaced the centre column, so opening them made the whole window claim you had gone somewhere: the conversation you were reading disappeared to show a theme picker, and coming back meant navigating rather than closing. They are an interruption of the application, not a place in it, and now they say so — a dialog over whatever was on screen, with the rail and the thread still behind it. **The URL is kept, and that is the part worth defending.** `#/settings/notifications` still deep-links, the back gesture still steps out of a section before leaving, and `RouteAnnouncer` still has something to announce. A modal driven by local state would have been shorter and would have thrown all of that away. So `open` is derived from the route and closing navigates: one source of truth for whether settings are showing. `Dialog` gains `size="panel"` rather than a second modal being written beside it. That file exists for four things Radix does and every hand-rolled modal omits — the focus trap, `inert` on the rest, the scroll lock, focus restored on close — and a settings modal built next to it would have omitted the same four invisibly. `panel` changes layout only: wider, taller, no padding of its own, and no close button of its own because the content already carries one. A second cross would have been off-screen and still in the tab order, which is the mirror image of the hover-only control this project refuses. Checked in a browser: opened over a conversation, Escape closed it and landed back in that conversation, and a direct link to a section opened the dialog with the focus inside it.
§4 opened with "the server records tokens, decides who to wake, and sends nothing". That was true when it was written and stopped being true with Web Push, and a threat model describing a feature as inert while it delivers is worse than one that omits it: a reader checks what the project claims to do before checking what it does. What is written instead is what holds now — it works on browsers, only there, and only once a deployment sets `VAPID_SUBJECT`. The three limits that follow are re-pointed at the service that actually does the waking, and one of them gains the reason the wake-up is empty is now structural rather than a policy: with no payload there is nothing to encrypt, so RFC 8291 is unused and the subscription's secrets are never read. The limitations table needed no change. It described the cost, and the cost did not move — a server that chooses whom to wake still gains a targeted activity trigger, and no cryptography answers that.
The suites check this server against RFC 8292 as we read it, using a fake service that verifies the signature and asserts the body is empty. A service disagreeing with that reading would pass every one of them. `a_real_push_service_accepts_the_token` is the other half, ignored by default because it needs a subscription minted by a real browser. It signs exactly as the emitter does and sends to the live endpoint, so the answer reaches the test instead of a `tracing::warn!`. Run once against Chrome's service: `201 Created`, and the notification appeared with every tab closed — server to FCM to service worker to screen. Mozilla has not been tried, and the roadmap says so rather than implying otherwise. `docs/DEPLOY.md` gains the command beside the browser pass, because it is what separates "the service refused us" from "the browser showed nothing".
…room A confirmation already landed well: portalled, animated, announced politely, expired by `report.ts` rather than by a timer inside the component. An error did not. It was a `Banner` with `rounded-none border-x-0 border-b-0` mounted as a flex child of the shell, so it took height from the conversation for as long as it stood, and up to four of those could stack. Both halves come through `ui/Toast.tsx` now. The lifetime does not move — an error still waits to be dismissed or replaced, because it usually means something is left to decide. Only the rendering does. # Radix, for one reason A message can now carry an action, and a button that appears unbidden has to be reachable by keyboard **without stealing focus**. That is a viewport in the tab order, a recall hotkey, and an announcement whose urgency matches the message — `type="foreground"` for an error, `"background"` for a confirmation, the same distinction `Banner` draws between `alert` and `status`. It also buys an exit. `Overlays.tsx` records that `useEntered` has no counterpart because Radix unmounts on close; a toast keeps its node through `data-state="closed"` and fades instead of blinking out. The expiry stays in `report.ts` and Radix's own `duration` is `Infinity`: two owners of one timer is one too many. # What the browser pass changed The action was wired nowhere, which would have shipped a path never executed. It is on the failing poll now: "Retry" does at once what the thirty-second timer would have done, and a second failure replaces the message rather than stacking. And Radix's `label` prop — documented as the accessible name, accepted by its types — reaches no attribute in 1.2.23: the rendered `<ol>` carried `tabindex` and `class` and nothing else. `aria-label` is set directly, and checked in the browser rather than assumed. Verified by hand, since `node --test` has no DOM and no component in this repository is testable: the confirmation expires by itself, the error persists and is cleared by the next successful poll, one `<li>` after several failures and a retry, and the whole thing draws above the settings dialog — which matters more since settings became one.
The web client shows a banner saying the server "could deliver a version
that exfiltrates your keys. No browser API fixes that." Making that sentence
false means letting somebody check the delivered code against something the
delivering server does not control — a manifest of hashes published by CI.
That was impossible while three variables were substituted into the bundle at
build time: every deployment produced different bytes, so a manifest could
only ever describe one instance.
Measured before and after, which is the only reason to believe any of this:
two builds with completely different configuration used to differ in four
files. They are now byte-identical, all 226 of them.
- `VITE_API_URL` is gone. The client asks the origin that served it, which
`deploy/` already arranges through Caddy, and development gets the same
code path through a Vite proxy rather than a second one. The desktop shell
is the one target whose own origin names nothing reachable, so it keeps a
literal — the same one `tauri.conf.json` already pins, and `csp.test.ts`
fails if the two disagree.
- `VITE_LOG_PUBKEY` leaves the web bundle and stays on the desktop.
`pinning.ts` already argued that on the web the pin is "not a defence
against the party that builds the bundle": the server ships the pin along
with the code it constrains. What it did buy — a substitution that breaks
every client at once instead of silently — is what a verifiable build
provides, and provides better.
- `VITE_MEDIA_URL` stays, and is the acknowledged exception. A media host has
to be named in the policy, so a deployment configuring calls stops matching
the published build: verifiable or calls, not both, until the media server
sits behind the same origin.
`connect-src` is now `'self'` and nothing else, which is strictly tighter
than naming an origin. It covers the WebSocket too — CSP level 3 matches
`wss:` under an `https:` document — and that was the doubtful part, so it was
checked in a browser rather than read in a specification: the API answers 200
on the page's own origin and `/v1/gateway` opens.
`socketUrl` exists because `BASE_URL.replace(/^http/, "ws")` was fine while
the base was absolute and is not now: an empty base leaves a bare path, and
`new WebSocket("/v1/gateway")` throws at the one moment the real-time session
is being opened.
What this simplifies rather than complicates: the deployment no longer takes
the domain as a build argument, and changing it is a restart instead of a
rebuild. `docs/DEPLOY.md` said the opposite in a section of its own; it says
this instead.
feat: wake a closed browser, over Web Push
…t reach The client tells its user that the server "could deliver a version that exfiltrates your keys. No browser API fixes that." Nothing removes that sentence, but something answers it: a list of hashes published somewhere the delivering server does not control, so that a substitution stops being invisible. `scripts/release-web.sh` produces the list. It refuses a dirty tree for the reason `release.sh` does — a manifest matching no commit is one nobody can rebuild to compare — and exports `SOURCE_DATE_EPOCH` from the commit, which changes nothing measurable today and means the day a dependency starts embedding a timestamp, it embeds the same one everywhere. `.github/workflows/release.yml` is where the mechanism actually acquires its meaning, and it is not the script. A manifest built anywhere says "somebody hashed some files"; `attest-build-provenance` binds it to this commit and this workflow through Sigstore, and nobody outside Actions — the maintainer included — can produce that binding. The repository's own Ed25519 release key would not do: `verify-release.sh` already says it lives in the repository, so whoever controls the repository can replace it, and a manifest meant to be independent of the party serving the code cannot rest on a key that party holds. It is the first workflow here to declare `permissions:` — no other one does — because `id-token: write` is what lets the runner prove which workflow it is. `scripts/verify-web.sh` is the other end, and it states its own ceiling on success rather than in a header nobody reads: it establishes that a server serves the build a manifest describes, not that the server is honest. One willing to serve one build to the world and another to one person passes this for everybody who runs it and fails only for the person it is attacking — who is the person not running it. That is what the extension is for, and why this script is the version a human can run today rather than the answer. The manifest describes every deployment at once, which is only true because of the commit before this one: the bundle no longer carries any deployment's configuration, so two instances of a commit serve identical bytes. Without that, this would have been a service to the official deployment and to nobody else.
The rehearsal tag found this, which is what a rehearsal is for. `Build and hash` succeeded in CI, and its manifest agrees byte for byte with one built in `node:22-bookworm-slim` — the image the deployment uses. Three of the five sampled chunks also matched this development machine. Two did not: `index-*.js` and `PdfViewer-*.js` differ between node 22.21.0 and node 22.23.2, while the CSS and the remaining chunks are identical. So reproducibility holds across machines, and holds on the version of node. `node-version: 22` in the workflow and `node:22-bookworm-slim` in the Dockerfile both resolve to "whatever 22.x is current", and they happened to agree on the day this was written. That is not a property to rest a manifest on: the day they drift, a deployment stops matching its own published hashes and the mismatch looks exactly like the substitution this mechanism exists to make visible. `apps/web/.nvmrc` is now the one place that says which node, read by the workflow through `node-version-file` and repeated by hand in the Dockerfile — whose comment says to change both in the same commit. The same discipline `rust-toolchain.toml` already applies to the compiler, and for the same reason it gives: "recent" is not a version. Not fixed here, and deliberately: `test.yml` still says `node-version: 22`. It builds nothing anybody verifies, so pinning it would be a change with no argument behind it beyond symmetry.
…annot The manifest published on a tag is only worth what somebody does with it. `scripts/verify-web.sh` is the version a human runs, and it has a ceiling it states itself: a server willing to serve one build to the world and another to one person passes it for everybody who runs it and fails only for the person it is attacking — who is the person not running it. This is the half that runs for that person. **The verdict is in the toolbar icon and never in the page.** That is the reason an extension exists rather than a badge in the application: everything a page displays is drawn by the server being checked, so a "verified" mark there would be forged by exactly the server it is meant to catch. **The manifest is fetched from GitHub, never from the inspected origin.** A server handing over both the code and the hashes of that code has certified itself, which is the defect the whole mechanism removes — the same one the threat model records about the transparency log being signed by the server it watches. `verify.js` holds the comparison and takes every input as an argument, so it runs under `node --test` without a browser. Nine tests, and the four that matter are the failures: an altered byte, a script at a path the manifest never described, a resource pulled from another origin, and one that cannot be re-fetched. Each is a way past a hash check that a verifier walking only the manifest would report as clean. Checking nothing answers `unknown` rather than `ok`, which is the single most dangerous thing it could display. The compromise it cannot avoid, written at the top of `background.js` rather than discovered: Chrome exposes no way to read the bytes a page actually received, so resources are re-requested with `cache: "force-cache"`. A server answering differently to a second request defeats that, and nothing here detects it. What it raises is the cost of an attack from "serve anything" to "serve one thing consistently and hope nobody compares". Host access is requested at a click and declared nowhere. An extension able to read every site from the moment it is installed is a worse thing than the problem it solves. The banner splits, which was worth doing on its own. It used to make two unrelated claims in one paragraph — that this code arrives from a server on every load, and that the project is unaudited — so the desktop build, where the first is false, silently dropped the second as well. Somebody who installed the signed binary was told nothing about the audit, which is the half that still applies to them. The delivery half now names what to check with; the audit half is untouched by any of this and shows everywhere.
`connect-src` matched on `media.replace(/^http/, "ws")`, which assumes the deployment wrote `http://`. Given `ws://` — what `.env.example` recommends for a local media server, and how a LiveKit URL is written everywhere — the replacement matched nothing and returned its input. The policy then listed the same origin twice and **omitted the HTTP one**. That form is not decoration: the comment three lines above says the SDK asks the media server over HTTP why a connection failed, so a broken call reported a vaguer reason than the browser had. Exactly the class of omission the file exists to catch. `bothSchemes` normalises to HTTP first and returns the pair, so all four spellings — `ws`, `wss`, `http`, `https` — produce the same two sources. Found by configuring calls locally and reading the policy the dev server served: `connect-src 'self' ws://127.0.0.1:7880 ws://127.0.0.1:7880`. The existing test agreed with the bug because it only ever passed `https://`; the new one passes the other spelling and fails against the old code with "ws://127.0.0.1:7880 does not allow http://127.0.0.1:7880".
…oes not §2.3 lists "serve hostile JavaScript, on every load, to one person" among a malicious server's powers, with no answer beside it but the desktop binary. There is one now, and it is worth less than it first reads — which is exactly why it needs a section rather than a line. §4quinquies says what was built: a manifest of the bundle's hashes published per release and attested by GitHub Actions through Sigstore, binding it to a commit and to the workflow. It says why that binding is the part that matters, and why the repository's own Ed25519 key would not have done — `verify-release.sh` already records that the key lives in the repository, so whoever controls the repository can replace it, and a manifest whose job is independence from the party serving the code cannot rest on that party's key. The rest of the section is what it does not establish, ordered by how easily each is over-read: - the banner does not go away and no version of this removes it, because a badge drawn by the page would be forged by the server being checked; - a targeted attack on somebody who does not check is untouched — the manual script passes for everybody who runs it and fails only for the person being attacked, who is the person not running it; - the extension re-requests rather than reads, so a server answering differently the second time defeats it; - verifiable or calls, not both, while `VITE_MEDIA_URL` still enters the policy; - the extension is its own supply chain, and unpublished; - none of it concerns the audit. Two existing entries were false after the change and are corrected. §2.3 gains the pointer. The limitations table said the web build pins the log key through `VITE_LOG_PUBKEY`; it does not any more, and the row now says why the removal was deliberate rather than a regression — the server shipped the pin alongside the code it constrained, and compiling it in was what stopped one manifest from describing every deployment.
feat: a web client anybody can check against its source
The script sources `.env` and then printed seven `export` lines. Everything else
it had just read died with the subshell, because both callers do
`eval "$(scripts/dev-env.sh)"` — so a variable added to `.env` never reached the
server. `README.md` said "the script loads .env, which the server does not do
itself", and it loaded seven keys.
# How it presents, which is the worst part
Silently, and split in two. `MEDIA_URL` unset makes the call route answer 503,
while `VITE_MEDIA_URL` — read by Vite from `apps/web/.env`, a different file —
still shows the call button. The client offers a call the server refuses and
neither side says why: finding it takes reading the network panel for a 503 on
`/v1/groups/{id}/call/token`.
`VAPID_SUBJECT` behaves the same way, which is why web push had to be started
with the variable on the command line. `ACCOUNT_STORAGE_BYTES` silently reverts
to its default.
# What is emitted now
Every name the file defines, minus the five this script computes. Re-emitting
`DATABASE_URL` or `SERVER_ADDR` from the file would put every branch back on one
database and one port, which is the thing this file exists to prevent.
The names come from the file rather than from the environment: `set -a` exported
`.env` into this process, but so is `PATH` and everything else a shell carries,
and emitting the environment would hand the caller a copy of ours.
# Checked by hand, since nothing here is unit-testable
- `scripts/dev-server.sh --release` alone now starts a server whose `/proc`
environment carries `MEDIA_URL`, `MEDIA_API_KEY`, `MEDIA_API_SECRET`,
`RELAY_URLS` and `RELAY_SECRET`. It carried none before.
- `DATABASE_URL` still resolves to the branch's own database, and `SERVER_ADDR`
to its own port.
- A value with an apostrophe survives the `eval` — that is what the escaping is
for, and it is the case that would have broken it.
- An empty value stays empty rather than failing under `set -u`.
fix(dev-env): pass the whole of .env through, not seven names of it
A `.env` is sourced, so `export FOO=bar` is as valid in it as `FOO=bar`, and a file copied from somewhere else very often carries the prefix. The pattern required `=` immediately after the name, so those lines were skipped — **silently**, which is the same failure #42 removed: a variable present in the file, absent from the server, and nothing anywhere saying so. Only the name has to be recognised. The value needs no unwrapping, because `set -a` and the `.` above already sourced the file: the shell treated `export FOO=bar` as the assignment it is, so `${!name-}` reads what the file set, prefix or not. Nothing else in this script has to know about the two spellings — worth saying in the comment, or the next reader wonders whether the prefix must be stripped somewhere. Named and not fixed: two assignments on one line, `export FOO=bar baz=qux`. Valid shell, and the pattern takes only the first. Nobody writes that in a `.env` and no file here does, but the silence has the same shape as the bug above, so it is written down rather than left to be found. # Checked by hand, in a fresh shell The test that means something is not that the name is recognised — it is that it survives the round trip with the right value. Sourced, emitted, re-evaluated in a separate `bash -c`, read back: - `export PREFIXED=with_export` → `with_export`, where `dev` gives nothing at all - `export SPACED_PREFIX=…` with several spaces → read - `export APOSTROPHE="it's exported"` → `it's exported`, the two difficulties at once, since an apostrophe is what breaks the emitted quoting - `export EMPTY_EXPORT=` → empty string, not unset - `PLAIN=bare` still read, and `DATABASE_URL` still derived per branch One trap worth recording, because it cost both of us an hour between us: a test `.env` must itself be valid shell. A bare apostrophe in it makes the sourcing fail silently and every value comes out empty, which reads exactly like a broken patch.
fix(dev-env): read the export-prefixed lines a .env may carry
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5eb9c730ee
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let request = self | ||
| .http | ||
| .post(endpoint) | ||
| .header( | ||
| "Authorization", | ||
| format!("vapid t={}, k={}", self.token_for(&audience), self.key.public_key()), |
There was a problem hiding this comment.
Restrict push requests to valid public service endpoints
When a group containing an attacker-controlled device is woken, the endpoint stored by that device is passed directly to reqwest; registration accepts any token with provider = "webpush", and audience_of permits arbitrary HTTP(S) authorities, including loopback and private-network addresses. A registered user can therefore arrange blind authenticated POST requests from the server to internal services by storing an endpoint such as http://127.0.0.1:... and triggering a group message. Validate that endpoints are HTTPS Web Push endpoints and reject destinations resolving to non-public address ranges before sending.
Useful? React with 👍 / 👎.
| const manifest = await fetch(asset.browser_download_url); | ||
| if (!manifest.ok) throw new Error(`manifest unreachable (${manifest.status})`); | ||
|
|
||
| return { tag: body.tag_name, entries: parseManifest(await manifest.text()) }; |
There was a problem hiding this comment.
Verify the attestation before trusting the manifest
The extension downloads a mutable release asset and immediately treats it as authoritative without checking the Sigstore/GitHub attestation that the workflow and threat model identify as the trust boundary. A maintainer or compromised repository token can replace WEB-SHA256SUMS on the latest release with hashes for a hostile bundle, after which the extension paints that bundle green even though no Actions build produced the manifest. The extension must validate the provenance or consume the manifest through an equivalently immutable, workflow-authenticated channel.
Useful? React with 👍 / 👎.
| "version": "0.1.0", | ||
| "description": "Checks that a Whispee deployment is serving the code its published manifest describes.", | ||
| "permissions": ["storage", "scripting", "activeTab"], | ||
| "host_permissions": ["https://api.github.com/*", "https://objects.githubusercontent.com/*"], |
There was a problem hiding this comment.
Grant access to the release download host
GitHub's browser_download_url points initially at github.com/.../releases/download/... and redirects to a release-assets host, but the service worker is granted access only to api.github.com and objects.githubusercontent.com. Since the optional wildcard is requested only for the deployment origin, the manifest fetch is blocked in Chrome and every check ends as unknown. Add host permissions for the actual release-download URL and redirect host, or download the asset through an already permitted API endpoint.
Useful? React with 👍 / 👎.
| ...performance | ||
| .getEntriesByType("resource") | ||
| .filter((entry) => entry.initiatorType !== "beacon") | ||
| .map((entry) => entry.name), |
There was a problem hiding this comment.
Exclude API requests from the resource manifest check
On a normal signed-in page, the immediate poll and subsequent application operations create same-origin PerformanceResourceTiming entries for /v1/... fetches. This filter retains those entries, but the release manifest contains only files from apps/web/dist; verifyResources consequently marks each API URL as unlisted and the verdict can never be ok after the application has contacted its server. Limit the collected entries to static resources that are expected to appear in the build manifest rather than treating application data requests as bundle files.
Useful? React with 👍 / 👎.
Fifteen commits, three merged pull requests, one day. Nothing here has been on
mainbefore.What lands
A deployment that exists.
deploy/stands the stack up on one host — Postgres, the delivery service, Caddy serving the client on/and proxying/v1. Before this, noDockerfile, no TLS, no way for anybody but the author to run it.docs/DEPLOY.mdcarries what it still does not do.Push notifications, over Web Push. A browser subscribes, the server signs a VAPID token, and a notification arrives with the tab closed — verified against the real Google service, not only against a double. FCM and APNs are not written, and the roadmap says why: the wall is device-side registration, not the secrets.
A web client anybody can check against its source. The bundle carries no deployment's configuration any more, so two instances of a commit serve byte-identical files — measured, all 226. Every release publishes a manifest of hashes attested by GitHub Actions through Sigstore, and
extension/compares what a browser actually received.docs/THREAT-MODEL.md§4quinquies states what that establishes and, at greater length, what it does not.Five review findings, three of them on the disappearing-messages feature that shipped just before: an archive that outlived the lifetime meant to erase it, a group context an ordinary member could reshape, and a throttle bucket that made the erasure fail after the commit was already public.
Two defects found by using the thing, not by reading it: a toggle that claimed a browser would be woken while nothing was subscribed, and
dev-env.shsilently dropping every.envvariable but seven — which is why a call button appeared while the server answered 503.What is knowingly incomplete
attest-build-provenanceneeds a public repository. The rehearsal tag proved the workflow runs and proved this refusal; thePublishstep has never executed.VITE_MEDIA_URLstill enters the policy.What was run
Every branch was green before its merge, and each branch was cut from the previous one rather than from a stale
dev— so this is not three independent things meeting for the first time. This pull request is the check on the combined result, which nothing else covers.Locally, across the day:
cargo test --workspace --release(425),cargo clippy --workspace --all-targets --release -- -D warnings,pnpm test(722),node --test extension/(9), typecheck and lint. Plus browser passes for push, the settings dialog, the API on a relative origin, andverify-web.shagainst a live instance in its three states.Merging this to
maintriggersandroid.yml, which builds an unsigned debug APK and spends Actions minutes. That is the repository's stated behaviour, not a surprise.🤖 Generated with Claude Code
https://claude.ai/code/session_01PDvUtP9fUQRwHYszVpJtnS