Commit Graph
15 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 6eee422421 Add the typed API client and the stores behind it
One client covering every endpoint, returning Result<T, ApiError> with a
matchable code. v1 had seventeen raw RequestInit call sites across three
modules, each with its own header, error and JSON handling, all returning
Result<T, String> -- so a 401 and a parse failure arrived looking
identical.

The types crossing the wire are runway-core's own. Preferences moved there
because it is the body of /api/preferences and both ends hold it; a copy
on the frontend would be a fifth parallel representation of the kind that
lost data at every hop in v1. Moving it surfaced that the schema's DEFAULT
for theme and the type's own Default disagreed, which is the same drift in
miniature. They now agree on 'system': following the operating system is a
better first impression than picking a side.

Two stores offered through context, each with one writer, replacing v1's
2,008-line component with 35 use_state hooks prop-drilled through
components that only passed them along -- and localStorage acting as a
hidden global any component could write to.

Preference saves are coalesced, which the browser loop proved is not an
optimisation. Changing four settings quickly started four requests whose
replies did not come back in order, and the answer to the first change
overwrote the third: "opens on Month" was silently still Week after a
reload. Only one save is in flight now, and whatever the person has landed
on when it returns is what goes next. The screenshot script checks all
four settings survive a reload, which is how the bug was found.

runway-web is a library as well as a binary, so the client is a public
surface rather than dead code awaiting its first caller, and the pure
parts have somewhere to be tested from.
2026-08-26 20:02:26 -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 3dcd8f76ad Add subscribed ICS feeds
The parse is the whole story. v1's importer had two open TODOs -- no
RECURRENCE-ID and no VTIMEZONE -- so a correct feed came back looking full
of duplicates, because a series master and its modified occurrences share
a UID and a SUMMARY and read as a flat list they look like one event
repeated. Rather than fix that, v1 added ~500 lines that stripped
punctuation from titles, grouped by the result, scored the collisions for
"completeness" and threw the losers away. Nothing here compares titles,
because nothing looks like a duplicate in the first place.

The regression test is two genuinely different meetings called "Stand-up"
and "Stand up" at the same moment: both survive. Against the real 1 MB
Outlook feed, 103 UIDs expand to 644 occurrences over twenty years with
every UID accounted for and no series producing two occurrences at one
instant.

Freshness has three answers, cheapest first: If-None-Match, then
If-Modified-Since, then a hash of the body -- and the hash is not academic,
because a published Outlook calendar sends neither validator, so without
it every poll would reparse a megabyte to discover nothing changed. v1 had
`let etag = None; // TODO`.

An unreachable feed serves yesterday's copy rather than blanking the week,
and says so. A URL that returns a login page with a 200 is refused at
subscribe time, when the person still has the link to hand, and the failed
subscription is removed rather than left as a broken row. webcal:// is
rewritten, because that is the scheme Outlook and Google hand people.
2026-08-26 18:16:44 -04:00
connor c619660adc Add the calendars and preferences APIs
Two kinds of setting, split deliberately. A calendar's name and colour are
properties of the calendar, so they go to the server with PROPPATCH and
every other client agrees -- v1 kept its colours in a JSON blob, disagreed
with all of them, and where the server said nothing it hashed the
calendar's path to invent one. Visibility and order are how one person
arranges their own sidebar and stay local: a household sharing a calendar
must not have one member's hidden calendar vanish for everybody. A
per-user colour override sits between the two, and the response says which
of the three a colour came from so the interface can offer to undo it.

PROPPATCH needed the multistatus parser to track per-property failures. A
207 is returned even when nothing changed, with the real answer inside, so
without that a refused rename would look like a success and simply not
happen.

New calendars get a readable slug rather than a uuid, because the path is
what other clients show in their settings, with a numeric suffix only when
the obvious name is taken.

Preferences are validated twice on purpose: the schema is the backstop
that makes a bad value impossible by any route, and the handler is the
layer that can say why instead of surfacing a constraint violation as a
500. A UTC offset is refused as a time zone with a message explaining that
an offset cannot tell January from July.
2026-08-26 17:51:02 -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 689b78154b Add recurrence expansion
Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.

Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.

TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.

Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.

Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
2026-08-26 14:50:52 -04:00
connor 7043a151f6 Add the iCalendar round-trip
Parsing goes through icalendar's low-level parser, which keeps properties
in order and keeps repeated ones. Writing is ours: that crate's writer
escapes a whole property value as text, so CATEGORIES:Work,Personal would
go out as one category named "Work,Personal" to every other client.

Anything the model does not interpret is carried rather than dropped --
X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO
set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL
components. A calendar has several clients writing to it and this one is
not the authority on which properties matter.

VTIMEZONE is modelled properly, and TZID is stored exactly as written:
Exchange names its zones "Pacific Standard Time", which no IANA lookup
resolves, and normalising at parse time would make the document
unrepresentable. Mapping to a real zone belongs at the point of use.

Tested against a golden corpus captured from the live Baikal (seven
producing clients over five years) and a published Outlook feed, scrubbed
of private content with the structure left byte-for-byte. Eight
hand-written fixtures cover what neither server had: DURATION, floating
times, RDATE, DST boundaries, leap day, and the full escape set.

The contract is that parse -> write -> parse is stable, plus a check that
no property name loses occurrences across the trip, since a parser that
dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
2026-08-26 14:32:38 -04:00
connor 52cd5a961d Add the RFC 5545 domain model
VEvent and friends, transcribed from the previous calendar-models crate
and tightened so that the states which caused its timezone and recurrence
bugs cannot be represented.

The substantive changes from v1:

CalendarDateTime is an enum over the four forms RFC 5545 actually admits
(date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose
Option<String> zone plus an all_day flag that could all disagree. A zoned
value carries an IANA identifier, never a UTC offset -- an offset cannot
tell standard time from daylight time, which is why recurring events
drifted an hour across DST.

EventEnd is an enum, because DTEND and DURATION are mutually exclusive.
Priority validates 0-9 on construction and on deserialisation. EditScope
replaces dispatch on strings like "this_and_future", which appeared 53
times and turned typos into silent fallthrough.

CalendarObject models a CalDAV resource as it really is: one UID, one
master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list,
which made overrides look like duplicates and motivated ~500 lines of
title-matching heuristics that silently discarded events.

Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and
Period: defined but never used.

28 tests cover serde round-trips, the exact JSON shape (the model is the
wire format, so changing it should be deliberate), duration fallbacks,
validation boundaries and master/override separation.

uuid needs an explicit entropy source on wasm; without it the frontend
cannot compile the shared model. Verified that the default feature set
pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest.
2026-08-26 12:29:59 -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