ba90a30e41d40a210d0617ee847652b5fae0ce25
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |