Commit 0dcb7fe
feat(desktop): bounded google calendar event contract and API walk (T12, slice one) (#21)
* feat(desktop): fallible compare-and-set mutation on the secret store
`mutate_blob` runs a read-modify-write under the interprocess advisory lock
with a fresh read inside it, but its mutation cannot refuse. A caller that
needs "write this only if the stored state still says X" therefore had to
check and then write, which loses the race the check exists for.
Add `mutate_checked`, the same operation with a fallible mutation: an `Err`
leaves the durable blob and the cache exactly as they were and is returned to
the caller, so a predicate can be evaluated on the freshly-read durable state
inside the lock. `mutate_blob` now delegates to it, so there is one
implementation of the locking, reading and copy-on-write rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
* feat(desktop): google calendar authorization contract (T12, slice one)
The credential half of T12, implementing the accepted memo
`docs/plans/2026-09-04-calendar-authorization.md`. Nothing here is wired to a
command, the webview or the sidebar, and no token reaches either: the module
is the contract, the views and the render cache are slice two.
* `redact` — the wrapper every credential is held in. It renders as a fixed
marker and has no serializer, so a struct holding one cannot be serialized
into a UI payload at all; persistence goes through an explicit wire form
that is private to `binding`.
* `oauth` — PKCE S256, a CSPRNG `state` checked with a constant-time compare,
a CSPRNG `nonce` checked inside the ID token, the bounded callback parser,
and the exchange conditions (refresh token, ID token, every requested
scope) short of which no binding is written. Signature verification is a
trait with no implementation here, so no path can produce validated claims
without one being supplied (decisions 1 and 2).
* `loopback` — the callback listener, bounded on request bytes, connections
accepted, per-read time and total wait (decision 1).
* `binding` — the stored envelope and five transition-specific
compare-and-set predicates, each evaluated on the freshly-read durable
state inside the store's lock. Disconnect clears the binding and opens the
journal entry in one commit, so no prefix of it leaves a grant unreachable
and unrevoked (decisions 2, 5 and 6). The envelope key sits outside the
`mcp:` namespace the launcher resolves, which is decision 9's denial seam.
* `revocation` — the journal: only HTTP 200 confirms, an entry clears only
when the purge and the revocation both confirm, failures back off, and the
seven-day ceiling converges on the terminal `revocation_unconfirmed` state
rather than retrying forever (decision 5).
* `failure` — the three error matrices and their four states, each with an
explicit default that fails closed (decision 8).
* `interval` — what a batch proves, half-open and never inferred (T12a
decision 13).
Every predicate has a test that fails when the predicate is deleted, and the
concurrent disconnect is a barrier-held race over the shipped transition code.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
* feat(desktop): bounded google calendar event contract and API walk
The data half of T12 slice one, implementing
`docs/plans/2026-09-04-calendar-view-design.md` where it decides the model and
the wire behaviour. Still nothing wired to a command or the webview.
* `dto` — the only way API-sourced text enters the fork. Every string is
capped here, at the boundary, with one `truncated` flag per field because a
truncated field is read-only; the caps also hold a row inside T11 decision
6's 256 KiB bound. An all-day value stays a date in the calendar's zone,
never an instant. Editability is `accessRole` narrowed by event type and
organizer, and an unrecognized role or type is read-only (decisions 1 and 2).
* `client` — the bounded `events.list` walk. Pages, bytes read off the socket,
events accumulated and wall-clock time are each capped, a page cut at the
byte budget is discarded whole, and the batch carries the interval it proved
plus the classified failure that stopped it. The three mutations fence with
`If-Match`, carry a client-generated id so a lost create is replayable, and
treat a 404 as ambiguous until the calendar is probed (decisions 5, 11, 13).
* The shipped transport pins HTTPS, refuses redirects — a followed redirect
would replay the bearer token at another host — and never reads proxy
settings from the process environment, which a managed agent at operator
trust can write.
* `mock_server` — a keep-alive mock Google Calendar with two principals on one
shared calendar, an ACL-loss switch, paging, `If-Match`, a duplicate-id
create and a redirect probe, so the transport, the walk, the caps, the
classification and the parser under test are all the shipped ones.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
* fix(desktop): bind the calendar containments with removal tests
One consolidated fix round on T12 slice one. Every guard the review found
undertested now has a test that fails when the guard is deleted, and two
guards that bounded the wrong thing were corrected.
Transport containment (client.rs). `TransportConfig::base_url` is private
and the origin is pinned by parsing, not prefix-matching: a shipped
configuration reaches `https://www.googleapis.com/calendar/v3/` and nothing
else, so `www.googleapis.com.evil.test`, a non-default port, another API
path, an embedded credential and a query on the base are all refused. The
`no_proxy()` containment now has a differential test: with every proxy
variable pointing at a sentinel listener, a test-only configuration that
reads the environment routes there, and the shipped one reaches the
loopback mock directly with the sentinel untouched. Deleting `no_proxy()`
fails it.
Loopback listener (loopback.rs). The wait's deadline is carried into
`read_callback`, so each read waits for the shorter of the idle bound and
the wait's remainder. Before, the only bound inside a connection was the
per-read timeout, which resets on every byte: one local process could hold
the flow for hours without ever sending CRLFCRLF. `ListenerError::TimedOut`
now has two tests, one with no connection and one with a stalled one;
without the fix the second runs 30 s against a 250 ms budget.
Compare-and-set seam (secret_store.rs, binding.rs). The candidate builder
inside `mutate_checked` is extracted as `checked_candidate` and tested
without a keychain, so turning `f(&mut next)?` into a discarded result fails
a test on every platform. `KeychainEnvelopes` commits through a
`CheckedBlob` seam, and every envelope test now drives that shipped commit
over an in-memory blob rather than a reimplementation: swapping the
`Refused` and `Store` arms fails seven tests. A backend failure and an
unreadable stored envelope each have their own test.
Bounds that bounded the wrong quantity. The walk applies the event cap to a
page before adding it, so it can no longer return `max_events + page - 1`
events, and the proven interval ends at the last kept start rather than the
page's. An over-long `recurringEventId` is dropped rather than truncated
into a different id.
Also bound: the `events.list` query Google is asked for (`singleEvents`,
`orderBy=startTime`, `timeMin`, `timeMax`, `maxResults`), the id, etag,
zone and page-token caps, the id-token byte cap, and T11 decision 9 driven
through the shipped `McpSecretLookup::resolve` with a working control.
The mock server no longer `dup`s its socket: a failing `try_clone` near the
descriptor limit closed a connection silently, which a client sees as a
reset mid-request.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
* test(desktop): record the calendar events URLs in the egress inventory
The events-URL tripwire in `egress_guard_tests.rs` scans every `.rs` file
under `desktop/src-tauri/src` for `/events` URL construction and fails until
each site is paired with an inventory row. The Google Calendar slice adds
five such sites — `client.rs:606` (`events_path`), `mock_server.rs:287` and
three fixtures in `client_tests.rs` — so `cargo test --lib` (the lane behind
`just desktop-tauri-test` and CI's Tauri Rust job) failed
`events_url_inventory_is_fully_guarded`.
Answer the question the tripwire asks rather than silence it. The Calendar
REST collection is not a relay egress boundary: the request goes to
`www.googleapis.com` with a Google bearer token and carries no Nostr event,
so `assert_no_key_backup` — whose stated scope in `egress_guard.rs` is
relay-bound egress of NIP-49 backup material — has nothing to check there.
Record that as three rows with zero expected guard calls and the reasoning
written beside them, in the file's existing style.
The rows still fence the files: a second `/events` site in `client.rs`, or a
fourth in `client_tests.rs`, trips the scan and asks again. The existing
mutation tests (`inventory_scan_catches_new_site_in_allowlisted_file`,
`inventory_scan_catches_removed_guard_call`,
`inventory_scan_catches_new_unlisted_file`) already prove the pairing fails
when a row or a guard call is removed, so no new mutation test is added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
---------
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>1 parent c7f0397 commit 0dcb7fe
18 files changed
Lines changed: 6375 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
294 | 304 | | |
295 | 305 | | |
296 | 306 | | |
| |||
0 commit comments