feat(contract): /v1/calendars joins the tool contract, one allowlist for both doors

The calendar table gets the same contract treatment as every other
data domain: a named route constant, three allowlist rows (GET, POST,
DELETE /v1/calendars/{name}), mock-router arms that enforce the
door's loudest rules — a named calendar, an ics with a URL — and
conformance cases (add, the invalid refused, listed, removed) that
run against the mock and the real daemon alike, so the two cannot
drift. The claw's auth middleware reads the same list, which is how
the rows land there with no claw-side code.

The spec names the doors and the pattern they establish: domains the
daemon reconfigures at runtime live in tables behind their routes,
validation runs at the door, and claw.yaml carries only what boot
needs.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:58:09 +02:00
1 parent 818079d1f4
commit aa9a68ab6d
5 files changed
+94 -17

No files matched your search

+5 -16
View File
@@ -237,12 +237,8 @@ pub fn router(state: ClawState) -> Router {
.route("/bootstrap", get(bootstrap_status))
.route("/watches", get(watches_list).post(watches_add))
.route("/watches/{forge}/{*repo}", delete(watches_remove))
.route(
"/calendars",
get(calendars_list)
.post(calendars_add)
.delete(calendars_remove),
)
.route("/calendars", get(calendars_list).post(calendars_add))
.route("/calendars/{name}", delete(calendars_remove))
.route("/accounts", get(accounts_list).post(accounts_add))
.route("/accounts/{id}", delete(accounts_remove))
.route("/consents", get(consents_list))
@@ -658,17 +654,10 @@ async fn calendars_add(
}
}
/// `DELETE /v1/calendars?name=…`: forget a calendar; the reminders
/// `DELETE /v1/calendars/{name}`: forget a calendar; the reminders
/// source stops polling it on the next tick.
async fn calendars_remove(
State(state): State<ClawState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response {
let Some(name) = params
.get("name")
.map(|n| n.trim())
.filter(|n| !n.is_empty())
else {
async fn calendars_remove(State(state): State<ClawState>, Path(name): Path<String>) -> Response {
let Some(name) = Some(name.trim()).filter(|n| !n.is_empty()) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "name is required"})),
+1 -1
View File
@@ -955,7 +955,7 @@ async fn calendars_round_trip_over_their_door() {
// Removal is a delete on the name; the next pass simply has one
// fewer feed.
let res = client
.delete(format!("{base}/v1/calendars?name=team"))
.delete(format!("{base}/v1/calendars/team"))
.send()
.await
.expect("the delete answers");