Commit Graph
9 Commits
Author SHA1 Message Date
connor ba90a30e41 Add the app shell: sidebar, header, and routing
The view and the focused date live in the URL. That makes the back button
work and a week shareable, and gives "which week am I looking at" exactly
one answer -- v1 kept the selected date in localStorage *and* in the
database with no defined source of truth between them.

Date arithmetic is a pure module with its own tests, run natively rather
than in a browser. v1 did this inline in a 1,431-line week view where the
only way to check it was to build, deploy and look. Thirteen tests,
including that a month covers whole weeks and needs six rows when the
month does -- v1 shipped "Fix print preview to display all 6 rows for
6-week months" -- and that stepping a month from the 31st lands on the
last day of a shorter one instead of skipping February.

The sidebar shows each calendar in its own colour, read from the CalDAV
server rather than hashed from its path, and hiding one narrows the
time-range query rather than filtering in the browser.

Two things the browser loop found. chrono::Local traps on wasm without the
wasmbind feature, which surfaces as a bare "unreachable" with no message
in a release build. And toggling a calendar's visibility silently
reordered the sidebar, because the insert wrote position 0 -- position is
nullable now, for the same reason colour is: "never arranged" is a
different statement from "first".

e2e/dev.sh brings the whole stack up seeded with a week of events. It
exists partly to record that trunk serve owns dist/: running trunk build
against it concurrently rewrites the files without rewriting the integrity
hashes in the served index.html, and the browser then blocks its own
scripts, which looks like a blank page and is nothing of the sort.
2026-08-26 20:48:56 -04:00
connor 16b1ad9a14 Add the frontend shell, the token system, and a login screen
Two axes on <html>: data-theme for colour, data-style for shape and
density, independent of each other. v1 shipped 12 themes x 3 layout styles
as 36 hand-maintained stylesheets -- 8,300 lines of CSS, 553 custom
properties, 116 !important declarations, and the last fifteen commits in
the repo were print-preview CSS tweaks. Here a theme is a list of colours
and a style is a list of measurements. Every token is semantic: nothing is
named --blue-500, because a theme has to be able to change what blue is.

The release bundle is 209 KB, 87 KB gzipped. v1's was 2.5 MB, a
meaningful share of which was a 638-line dead CalDAV client that kept
reqwest, ical and regex in the dependency list. Feature-gating runway-core
is what that structural fix buys.

One typed API client returning Result<T, ApiError> with a matchable code,
replacing v1's seventeen raw RequestInit call sites across three modules,
each with its own header, error and JSON handling and all returning
Result<T, String>. The session cookie is HttpOnly and this code cannot
read it; asking the server who you are is the only way to find out.

Trunk proxies /api to the backend so the cookie is same-origin in
development exactly as in production, rather than weakening it to
SameSite=None for a local convenience.

And the browser loop the audit called the biggest change between v1 and
v2: e2e/shoot.mjs drives a real Chromium, screenshots every state across
both token axes using the actual controls, and reports whatever the
console said. It found that wasm-opt needed telling bulk-memory is
allowed, and that offering a reference number for a mistyped password
suggests a fault at our end.
2026-08-26 19:13:59 -04:00
connor 2e5bd6ec34 Add request tracing and the rules about what must not be logged
v1's backend had 157 println! calls with emoji prefixes, no levels, no
filtering and no structure -- and one of them printed "Password length:"
from the login handler. There was no way to turn any of it down, and no
way to find the lines belonging to one request.

Every request now gets an id, echoed in x-request-id and attached to every
line logged while handling it. A supplied id is kept, so a reverse proxy
correlates with us. Internal errors return that id in the body: "something
went wrong" is only useful if it leads somewhere, and the detail stays in
the log where it belongs rather than describing the inside of the server
to whoever asked.

The middleware instruments the inner future rather than tagging its own
events with a parent. A test caught the difference: with the parent form,
an error raised inside a handler logged outside the span, so the id the
client was told to quote led nowhere.

Four tests assert what must not appear. A password never reaches the log,
nor does its length -- knowing it is nine characters is knowing something.
A session token never reaches it either. Query strings are not logged at
all, because they carry calendar paths, and the path alone says what
happened.

And a test greps the source for println!, print!, eprintln! and dbg! in
runway-core, runway-caldav and runway-server. main.rs is exempt: genkey
printing a key to stdout is its whole job. A rule that only lives in a
document gets forgotten.
2026-08-26 18:49:30 -04:00
connor c4e8ede28c Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
2026-08-26 17:07:38 -04:00
connor c7e22f4431 Add authentication
Proving who somebody is and starting a session for them are separate
operations. login_with_caldav does the first by asking the CalDAV server
whether the credentials work; begin_session does the second and knows
nothing about how the question was answered. OIDC arrives as a second way
to reach begin_session, not as a second scheme threaded through everything
-- which is what v1 had, with a JWT for most of the app and a separate
SQLite session_token used only by the preferences API.

The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and
the CalDAV password in localStorage, readable by any script on the origin,
and re-sent the password in a header on every request. Here the password
never leaves the server: it is encrypted with XChaCha20-Poly1305 and
caldav_for is the only path back, handing out a client rather than a
credential.

Failed decryption is an error, not a subtly wrong password -- the AEAD tag
is checked, so a tampered row surfaces here instead of as a mysterious
CalDAV rejection later. A wrong password and an unreachable server stay
distinct, because telling somebody their password is wrong when the server
is down sends them to reset one that was fine.

Errors carry a stable code alongside their message, so a client can branch
on them. Internal ones say nothing about the inside of the server; the
detail goes to the log.

Tests go through router(), the same function main calls -- v1's suite
rebuilt the route table and tested a copy until it stopped compiling.
Skipping is now loud: a skipped test reports "ok", so run.sh sets
RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure.
2026-08-26 16:27:59 -04:00
connor 82d05dc67a Add the database schema and repository layer
The per-calendar JSON blob is gone. v1 kept every calendar's colour, every
visibility toggle and a custom palette inside one calendar_colors TEXT
column, so hiding one calendar rewrote the whole document -- which is where
"Fix calendar visibility preservation during event updates" came from.
Those are rows now, and set_visible touches visibility alone.

Foreign keys are switched on. v1 declared external_calendars.user_id as
INTEGER against a TEXT users.id; SQLite enforces neither the type nor the
constraint unless asked, so it was decorative and could never match.

Session tokens are stored as SHA-256, never in the clear, so a copy of the
database cannot be used to impersonate anyone. The CalDAV password is the
one secret that cannot be hashed -- it has to be replayed to the server --
so it gets an encrypted column with the algorithm recorded alongside, and
one way in and one way out instead of v1's eight localStorage reads.

Preferences are one column each with CHECK constraints, so a bad view or a
nonsense time increment is refused whatever route it arrives by. A NULL
display timezone means "follow the browser", which is a state v1 could not
express -- as with a NULL calendar colour meaning "defer to the server",
which is why it hashed paths to invent one.

Feed caching stores a content hash beside the ETag, because a published
Outlook feed sends neither ETag nor Last-Modified and staleness has to be
detectable anyway.

Thirty tests against real in-memory SQLite, not mocks. One caught that
create() returned nanosecond timestamps while the column stores
microseconds, so a session never compared equal to itself read back.
2026-08-26 15:54:28 -04:00
connor c197e08af0 Default the CLI to the system time zone
It shipped with America/Denver hardcoded, taken from what dominates the
historical data on the server. That data is old; the reader has moved.
A zone belongs to whoever is looking at the calendar, and baking one in is
the same mistake as v1's offset-instead-of-zone in miniature -- it looks
right until the reader is somewhere else.

Falls back to UTC rather than to a populated guess: an obviously neutral
wrong answer gets noticed, a plausible one does not.
2026-08-26 15:38:55 -04:00
connor f8e4a497fa Add the CalDAV client and a CLI to drive it
Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
2026-08-26 15:32:08 -04:00
connor bf63024711 Set up workspace skeleton
Five crates: runway-core (pure domain), runway-caldav (protocol),
runway-server (axum), runway-web (leptos), runway-cli (smoke tool).

runway-core is feature-gated into model/ical/recurrence so the frontend
can depend on the shared types without pulling icalendar and rrule into
the WASM bundle. The previous iteration shipped reqwest, ical and regex
to the browser for a dead module; the feature split makes that mistake
structurally hard to repeat.

Guardrails are compiler- and CI-enforced rather than aspirational:
workspace lints deny unwrap_used/expect_used, dead_code and unsafe_code,
clippy.toml caps function length and arity, deny.toml pins licences.

Toolchain is pinned per-project so the machine-wide default is untouched.
Cargo.lock is committed this time.

docs/legacy-audit.md carries the marked-up feature decisions and is the
spec for the rewrite.
2026-08-26 12:07:44 -04:00