1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
//! `POST /enrollment/node` + `DELETE /enrollment/node/{node_id}` — the
//! server-mediated, user-session-authenticated writer (and de-enroller) for
//! the LAN-pair enrollment row (R593-F9, W268 §"The binding: enrollment is
//! an ownership row").
//!
//! ## Why this route exists (not another `POST /ownership` caller)
//!
//! `POST /ownership` (see [`crate::ownership`]) requires an MCP token bearing
//! [`Scope::OwnershipWrite`], and that scope is grantable to `Service`
//! principals only ([`cheers_core::validate_grant`], composition rule (4)) —
//! by construction, no `User` principal can ever hold a token that would let
//! it call that route. R593-F5's original (reverted) design worked around
//! this by shipping a *static* `ownership:write` service-principal secret
//! baked into the LAN-pair accepter (an end-user phone/Mac) so the device
//! could mint its own token locally. Adversarial review (2026-07-02) found
//! that secret, combined with `POST /ownership` not checking the body's
//! `principal_id` against the token's `sub` and accepting an unrestricted
//! `resource_kind` string, let a single extracted secret forge *arbitrary*
//! ownership rows — a full ledger compromise, not scoped node-enrollment.
//!
//! This route is the safe replacement: **the end-user device authenticates
//! with the session bearer it already legitimately holds from its own login**
//! (passkey / OIDC / magic-link — the same [`Claims`]-shaped token
//! [`crate::me`] verifies), and the server performs the ownership write
//! itself, under its own fixed internal service identity
//! ([`ENROLLMENT_GRANTED_BY`]). Three structural properties close the F5/F9
//! gaps:
//!
//! 1. **No client secret at all.** The bearer is the user's own short-TTL,
//! revocable session token (`SessionPolicy::DEFAULT_ACCESS_TTL_SECONDS`,
//! minutes-scale) — not a static, distributed, ledger-wide credential.
//! Extracting it from one device compromises that one session until it
//! expires or is revoked, not the whole ownership table.
//! 2. **`principal_id` is never caller-supplied.** It is derived entirely
//! from the verified session's `claims.sub` — there is no body field to
//! mismatch against a token subject, because there is no such field.
//! 3. **`resource_kind` / `relationship` are hardcoded**
//! ([`NODE_RESOURCE_KIND`] / [`OWNS_RELATIONSHIP`], verbatim with F4's
//! fleet-path and F5's seam constants) — the only caller-supplied datum is
//! the `node_id` hex string itself. A caller cannot request any other
//! resource kind or relationship through this route.
//!
//! `granted_by` on the inserted row is [`ENROLLMENT_GRANTED_BY`] (a fixed
//! `svc:` principal identifying "the enrollment route wrote this," not any
//! externally-presentable credential: the service writes in-process under
//! its own identity, D4). The row's holder is the authenticated user, so
//! `OwnershipStore::revoke_by_principal`'s holder cascade sweeps it when the
//! account goes away; `on_behalf_of` is the same user, as attribution only.
//!
//! Idempotent create, mirroring [`crate::ownership::create`]: repairing the
//! same device converges on one live row (200), not a stack of duplicates.
//!
//! ## Eviction parity (W268 Q6)
//!
//! Re-enrolling a node under a **different** user revokes the previous
//! owner's live row first (see [`enroll_node`]) — a device that changed
//! hands cannot leave two simultaneously-live owner bindings, which would
//! otherwise keep satisfying R593-F6's token binding for the original owner.
//! `DELETE /enrollment/node/{node_id}` ([`deenroll_node`]) is the explicit
//! retire-a-device half, scoped to the caller's own row.
//!
//! ## Wiring
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use axum::Router;
//! # use cheers_axum::enrollment::{router, EnrollmentState};
//! # use cheers_server::{EdgeVerifier, OwnershipStore};
//! # async fn run<V, Rd, O>(edge: Arc<EdgeVerifier<V, Rd>>, store: Arc<O>)
//! # -> Result<(), Box<dyn std::error::Error>>
//! # where
//! # V: cheers_core::TokenVerifier + Send + Sync + 'static,
//! # Rd: cheers_server::RevocationReader + Send + Sync + 'static,
//! # O: OwnershipStore + 'static,
//! # {
//! let state = Arc::new(EnrollmentState { edge, store });
//! let app: Router = Router::new().nest("/api", router(state));
//! # Ok(()) }
//! ```
use Arc;
use Json;
use Router;
use ;
use ;
use ;
use Deserialize;
use ;
use ;
use crateRouteError;
use crateauthenticate;
/// Canonical `resource_kind` for node-enrollment ownership rows — verbatim
/// with `cheers::lan_pair::enroll::NODE_RESOURCE_KIND` (R593-F5) and yubaba's
/// fleet-path `cheers_client::NODE_RESOURCE_KIND` (R593-F4). All three
/// enrollment writers must land the same literal so the `owns[].node` claim
/// vocabulary can't fork.
pub const NODE_RESOURCE_KIND: &str = "node";
/// Relationship string for enrollment rows — verbatim with F4/F5's constant.
pub const OWNS_RELATIONSHIP: &str = "owns";
/// Fixed internal service principal this route writes `granted_by` as.
///
/// Not a credential — never presented on the wire, never verified as a
/// bearer. It attributes the write to the enrollment service; the actual authorization
/// decision is "did the caller present a valid, unexpired, unrevoked user
/// session bearer" (checked by [`authenticate`] before this constant is ever
/// touched).
pub const ENROLLMENT_GRANTED_BY: &str = "cheers-enrollment";
/// JSON body for `POST /enrollment/node`.
///
/// Deliberately minimal: `node_id` is the ONLY caller-supplied datum. There
/// is no `principal_id` field (derived from the authenticated session) and
/// no `resource_kind` field (hardcoded to [`NODE_RESOURCE_KIND`]) — see the
/// module doc for why that's the load-bearing difference from `POST
/// /ownership`.
/// State bundle held by the `/enrollment/node` handler.
///
/// `edge` verifies the caller's own **session** bearer ([`Claims`], not an
/// MCP token) — the same [`EdgeVerifier`] [`crate::me`] holds. `store` is
/// the product-supplied [`OwnershipStore`] this route writes to directly,
/// with no MCP scope check in between (there is no bearer-presented
/// capability to check — the server's own write authority is implicit in
/// running this handler at all).
/// Build a router mounting `POST /enrollment/node` +
/// `DELETE /enrollment/node/{node_id}`. The product nests it under whatever
/// base path it chose (`/api`, …).
/// `POST /enrollment/node` — record `user:<authenticated sub> owns
/// node:<node_id>`. Authenticates the caller's session bearer (never an MCP
/// token), then writes idempotently: an identical live row returns `200`,
/// a fresh one `201` (mirrors `POST /ownership`'s idempotency contract so a
/// re-pair of the same device converges on one row).
///
/// ## Eviction parity on ownership change (W268 Q6, R593-F9)
///
/// Before writing, any *live* enrollment row binding this same node to a
/// **different** principal is revoked — "the device changed hands":
/// re-pairing under user B must not leave user A's stale row alive, because
/// that stale row would keep satisfying R593-F6's token binding for the
/// original owner after they gave the device away. Last completed
/// (user-authenticated, physically-present LAN-pair) ceremony wins — the
/// same trust model as the pairing itself. The sweep uses
/// [`OwnershipStore::list_for_resource`], added for exactly this.
pub async
/// `DELETE /enrollment/node/{node_id}` — de-enrollment: revoke the
/// **caller's own** live enrollment row for `node_id`. `204 No Content` on
/// success, `404 Not Found` when the caller holds no live row for that node
/// (someone else's row for the same node is invisible here — same
/// no-existence-oracle stance as the ownership route's per-writer scoping).
///
/// This is the explicit half of the Q6 eviction story: the automatic sweep
/// in [`enroll_node`] covers "device re-paired by its new owner"; this
/// route covers "owner retires a device" without waiting for anyone else to
/// re-pair it.
pub async