Google requires OAuth for calendar access; app passwords stopped working for CalDAV, CardDAV and IMAP in March 2025, so there is no simpler path to offer. - Authorisation code flow over a loopback redirect, which is what Google supports for desktop clients now the copy-paste flow is gone, with PKCE so an intercepted code is useless without the verifier. Only the refresh token is persisted, 0600, in the state directory. - An expired grant is reported as itself: a consent screen still in Testing has its refresh tokens expired after 7 days, and "run calcalist google login" is more use than Google's bare invalid_grant. - doctor reports whether each Google endpoint is still authorised, since an installation that worked last week can stop with nothing having changed here. A webcal URL may now come from a command instead of the config. Google's secret iCal address grants read access to a whole calendar to anyone holding it, so writing it into a file described as portable and secret-free was a contradiction. Fixed a serious defect in the first draft of this module: random_token used fs::read on /dev/urandom, which reads to end of file. /dev/urandom has no end, so it allocated until the machine ran out of memory — it took the editor down with it. It now reads exactly 32 bytes, and a randomness failure is fatal rather than falling back to the clock, since a guessable state or PKCE verifier defeats the point of having them. Verified end to end against a live Posteo CalDAV calendar: pimsync validated the generated config against the real server, 58 events from a public feed were mirrored and pushed, and a second run was a no-op. 97 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67 lines
3.7 KiB
Markdown
67 lines
3.7 KiB
Markdown
# Roadmap
|
|
|
|
Milestones from the implementation plan. See SPECS.md for the architecture and the
|
|
sync semantics these items implement.
|
|
|
|
## M0 — skeleton (done)
|
|
|
|
- [x] Initialise jj colocated with git; `.gitignore` written before the first build
|
|
- [x] `devbox.json` pinning Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0, radicale 3.7.8
|
|
- [x] Configuration model with referential validation, reporting every problem in one pass
|
|
- [x] `calcalist doctor` — pimsync presence and version series, state directory, configuration
|
|
- [x] Define the full CLI surface; unimplemented commands exit 2 rather than pretend
|
|
- [x] SPECS.md: Rust naming conventions, `devbox run check` gate, recorded sync semantics
|
|
|
|
## M1 — bidirectional sync
|
|
|
|
Core modules:
|
|
|
|
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
|
|
resolved target endpoint id **and** backend type
|
|
- [x] `vdir.rs` — read and write vdir directories
|
|
- [x] `ical.rs` — surgical line-level `.ics` editing (UID rewrite, property injection),
|
|
respecting RFC 5545 folding; no parse-and-reserialize
|
|
- [x] `provenance.rs` — deterministic `blake3(aggregate_id, source_id, source_uid)` UIDs
|
|
- [x] `mirror.rs` — the to-aggregate and to-source transforms (added; not in the
|
|
original plan, which folded these into `reconcile`)
|
|
- [x] `reconcile.rs` — the aggregation engine; pure, no I/O
|
|
- [x] `sync.rs` — one cycle over the local vdirs, applying what `reconcile` decides
|
|
- [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
|
|
drive one-shot `pimsync sync` bracketing the reconcile step
|
|
- [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's
|
|
parser does not always match its documentation
|
|
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
|
|
- [ ] `google/api.rs`, `google/convert.rs`
|
|
- [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code)
|
|
|
|
Safety-critical behaviour:
|
|
|
|
- [ ] **`events.import` gate — do this first.** Import an attendee-bearing event whose
|
|
guests are on a mail sink we control and confirm no mail is emitted; repeat for
|
|
update and delete under `sendUpdates=none`. The Google attendee path depends on
|
|
it. Fallback if it fails: the same demotion transform used for CalDAV.
|
|
- [x] `sync` refuses to run on aggregate target drift, before reconciliation
|
|
- [ ] `aggregate retarget` — flush unrouted creations against the old target, then
|
|
re-materialise; keep old orphans by default
|
|
- [x] Mass-deletion guard (`max_delete_fraction`), overridable with `--force`, with an
|
|
absolute floor so deleting a couple of events is never refused
|
|
- [x] Echo suppression: derived UIDs are never re-ingested as source events
|
|
|
|
Tests:
|
|
|
|
- [x] `reconcile` table-driven cases: create/update/delete each direction, both-sides-changed,
|
|
routing, echo suppression, mass-delete abort
|
|
- [x] `ical` round-trip fixtures: recurring with overrides, all-day, TZID, unknown `X-` props
|
|
- [ ] Integration against Radicale plus a `file://` WebCal fixture; assert idempotence
|
|
- [x] Safety (unit level): no live `ATTENDEE`/`ORGANIZER` on a CalDAV-targeted mirror,
|
|
`VALARM` intact, `PARTSTAT: DECLINED` maps to `TRANSP: TRANSPARENT`, bulk deletion aborts
|
|
- [ ] Safety (integration): the same against a real Radicale instance with an SMTP sink,
|
|
proving no mail is emitted
|
|
- [ ] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source
|
|
event (verified by hand end to end; still needs an automated test)
|
|
|
|
## M2 — interface and packaging
|
|
|
|
- [ ] axum configuration UI, bound to 127.0.0.1
|
|
- [ ] OAuth loopback redirect handler
|
|
- [ ] systemd user units: `calcalist.service` (oneshot) and `calcalist.timer`
|