lib.rs
⎇
Raw
1//! The HTTP wire contract of dovenest in one place.
2//!
3//! Both the server (axum) and the web frontend (wasm `fetch`) import these
4//! endpoint paths, query params and serde types, so the two sides cannot
5//! drift apart. Serde only — no axum, no wasm dependencies.
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// Endpoint paths (single source of truth for the route table and the client)
11// ---------------------------------------------------------------------------
12
13pub const AUTH_LOGIN: &str = "/api/auth/login";
14pub const AUTH_LOGOUT: &str = "/api/auth/logout";
15pub const AUTH_ME: &str = "/api/auth/me";
16pub const AUTH_SETUP: &str = "/api/auth/setup";
17/// Change or set the signed-in user's password (`POST`), or remove it
18/// (`DELETE`, passkey-only accounts).
19pub const AUTH_PASSWORD: &str = "/api/auth/password";
20/// `PUT` the signed-in user's sign-in requirement ([`AuthMode`]).
21pub const AUTH_MODE: &str = "/api/auth/mode";
22/// The signed-in user's passkeys: `GET {AUTH_PASSKEYS}` lists them,
23/// `DELETE {AUTH_PASSKEYS}/{id}` removes one.
24pub const AUTH_PASSKEYS: &str = "/api/auth/passkeys";
25/// Start registering a new passkey (`POST`). Finished at
26/// `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
27pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
28/// Start a passkey sign-in (`POST`, no session needed). Finished at
29/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
30pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
31/// The signed-in user's WebDAV app passwords: `GET {AUTH_APP_PASSWORDS}`
32/// lists them, `POST` creates one, `DELETE {AUTH_APP_PASSWORDS}/{id}` revokes
33/// one.
34pub const AUTH_APP_PASSWORDS: &str = "/api/auth/app-passwords";
35/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
36/// begin path plus this suffix.
37pub const FINISH_SUFFIX: &str = "/finish";
38/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
39pub const FILES: &str = "/api/files";
40/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
41pub const SHARES: &str = "/api/shares";
42/// Public share resolve (no login): `{SHARE}/{token}`.
43pub const SHARE: &str = "/api/share";
44/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
45pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
46/// `GET /api/search` — name and/or content search, streamed as SSE.
47pub const SEARCH: &str = "/api/search";
48
49/// WebDAV mount of the signed-in user's roots: `{DAV}` and `{DAV}/{path...}`.
50pub const DAV: &str = "/dav";
51/// WebDAV mount of one public share: `{DAV_SHARE}/{token}/{path...}`.
52///
53/// A separate top-level path, not a segment under [`DAV`]: there, the first
54/// segment is a root's display name, which a reserved word could collide with.
55pub const DAV_SHARE: &str = "/dav-share";
56
57/// CalDAV and CardDAV: principals, calendars and address books.
58///
59/// Not under [`DAV`] for the same reason as [`DAV_SHARE`].
60pub const PIM: &str = "/pim";
61/// RFC 6764 discovery. Both redirect to [`PIM`].
62pub const WELL_KNOWN_CALDAV: &str = "/.well-known/caldav";
63pub const WELL_KNOWN_CARDDAV: &str = "/.well-known/carddav";
64
65/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
66pub const ADMIN_USERS: &str = "/api/admin/users";
67/// Admin view of every share on the server: `{ADMIN_SHARES}` and
68/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
69pub const ADMIN_SHARES: &str = "/api/admin/shares";
70pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
71/// Admin management of rooms and resources: `{ADMIN_ROOMS}` and
72/// `{ADMIN_ROOMS}/{id}`.
73pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
74/// Admin view of every public calendar and address book feed:
75/// `{ADMIN_PIM_LINKS}` and `{ADMIN_PIM_LINKS}/{id}`.
76pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
77/// The signed-in user's calendars and address books, own and lent to them
78/// (`GET`), and a new one (`POST`). `PUT` and `DELETE` on `{PIM_COLLECTIONS}/{id}`
79/// change or delete an own one; `DELETE` on a lent one ends the loan.
80/// `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends (`POST`)
81/// an own one; `DELETE` on `.../{user_id}` below it ends a loan.
82pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
83pub const SHARES_SUFFIX: &str = "/shares";
84/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
85/// collection (`GET`, `POST`); `DELETE` on `.../{link_id}` below it.
86pub const LINKS_SUFFIX: &str = "/links";
87/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body.
88pub const IMPORT_SUFFIX: &str = "/import";
89/// `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: the collection as one file.
90pub const EXPORT_SUFFIX: &str = "/export";
91/// `GET`: the system address book as one file. It has no collection id.
92pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
93/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
94/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
95/// a WebP thumbnail.
96pub const OBJECTS_SUFFIX: &str = "/objects";
97pub const PHOTO_SUFFIX: &str = "/photo";
98/// `GET`: the instances of the readable calendars in a time range, as
99/// [`PimInstances`]. Params `from`, `to` (RFC 3339), `tz`, `collections`.
100pub const PIM_INSTANCES: &str = "/api/pim/instances";
101/// `GET`: the contacts of the readable address books, as [`PimContact`]s.
102/// Params `q`, `collections`.
103pub const PIM_CONTACTS: &str = "/api/pim/contacts";
104/// `GET`: the invitations the signed-in user has not answered, as
105/// [`PimInvitation`]s. `POST` a [`PimReply`] to answer one.
106pub const PIM_INVITATIONS: &str = "/api/pim/invitations";
107/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
108/// `.vcf`. The extension is optional.
109pub const FEED: &str = "/feed";
110/// Pseudo root id every signed-in admin has on the files API: the whole
111/// server root, read-only (the admin folder picker browses it). Real root
112/// ids are positive database ids. Not listed in `/me`.
113pub const ADMIN_ROOT: i64 = -1;
114
115// ---------------------------------------------------------------------------
116// Query params
117// ---------------------------------------------------------------------------
118
119/// `?action=...` on file URLs; without it the route lists the directory.
120pub const P_ACTION: &str = "action";
121pub const ACTION_DOWNLOAD: &str = "download";
122pub const ACTION_PREVIEW: &str = "preview";
123pub const ACTION_CONTENT: &str = "content";
124pub const ACTION_THUMB: &str = "thumb";
125/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
126/// POST route also carries uploads and mutations.
127pub const ACTION_MKDIR: &str = "mkdir";
128/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
129/// like `mkdir`, for the same reason.
130pub const ACTION_CREATE_FILE: &str = "create-file";
131/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
132/// pre-check for an upload: which of the given targets already exist.
133pub const ACTION_EXISTS: &str = "exists";
134/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
135pub const P_FORMAT: &str = "format";
136/// `?share=<token>` — authenticate file calls with a public share token.
137pub const P_SHARE: &str = "share";
138/// Search query text (`GET {SEARCH}`).
139pub const P_Q: &str = "q";
140/// Which index to search: `name`, `content` or `both`.
141pub const P_SCOPE: &str = "scope";
142/// Root id to search; omitted = the caller's first root.
143pub const P_ROOT: &str = "root";
144/// Folder inside the root to start a search in (relative to the root);
145/// omitted or empty = the whole root.
146pub const P_PATH: &str = "path";
147/// `?overwrite=true|1` on mutations and uploads.
148pub const P_OVERWRITE: &str = "overwrite";
149/// Listing order ([`SortKey`]); omitted = by name.
150pub const P_SORT: &str = "sort";
151/// `?desc=true` reverses the listing order. Folders still come first.
152pub const P_DESC: &str = "desc";
153/// First listing entry to return, in the sorted order.
154pub const P_OFFSET: &str = "offset";
155/// Listing entries to return, capped at [`MAX_LIST_ENTRIES`]; omitted = the cap.
156pub const P_LIMIT: &str = "limit";
157/// `?dirs=true` lists only the subfolders (the folder picker).
158pub const P_DIRS: &str = "dirs";
159/// `?around=name` returns the page that holds this entry, instead of the
160/// one at the offset. A missing name falls back to the offset.
161pub const P_AROUND: &str = "around";
162/// [`PIM_INSTANCES`]: the range, RFC 3339.
163pub const P_FROM: &str = "from";
164pub const P_TO: &str = "to";
165/// [`PIM_INSTANCES`], [`PIM_PREVIEW`], object detail: the IANA zone that
166/// all-day and floating times are read in. Default UTC.
167pub const P_TZ: &str = "tz";
168/// [`PIM_INSTANCES`], [`PIM_CONTACTS`]: comma-separated collection ids.
169/// Default all readable ones.
170pub const P_COLLECTIONS: &str = "collections";
171/// Object detail: the instance of a series, as in [`PimInstance`].
172pub const P_RECURRENCE_ID: &str = "recurrence_id";
173
174// ---------------------------------------------------------------------------
175// Wire enums
176// ---------------------------------------------------------------------------
177
178/// Access mode of a root or a share.
179///
180/// The serde names are also the values stored in the SQLite `mode` columns,
181/// so renaming a variant would break existing databases. The round-trip test
182/// below pins them.
183#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
184#[serde(rename_all = "lowercase")]
185pub enum Mode {
186 Rw,
187 Ro,
188}
189
190impl Mode {
191 /// The only question callers ask: may this root be written to?
192 pub fn is_writable(self) -> bool {
193 matches!(self, Mode::Rw)
194 }
195
196 /// The wire/database spelling, for `<select>` values and SQL params.
197 pub fn as_str(self) -> &'static str {
198 match self {
199 Mode::Rw => "rw",
200 Mode::Ro => "ro",
201 }
202 }
203
204 /// Parse the wire spelling. `None` for anything else.
205 pub fn from_wire(s: &str) -> Option<Self> {
206 match s {
207 "rw" => Some(Mode::Rw),
208 "ro" => Some(Mode::Ro),
209 _ => None,
210 }
211 }
212}
213
214/// Which mutation [`Mutation`] asks for.
215#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
216#[serde(rename_all = "lowercase")]
217pub enum Op {
218 Rename,
219 Move,
220 Copy,
221}
222
223/// What a listing is ordered by ([`P_SORT`]). Folders always sort before
224/// files, so a size or date order does not scatter them through the listing.
225#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
226#[serde(rename_all = "lowercase")]
227pub enum SortKey {
228 #[default]
229 Name,
230 Size,
231 Modified,
232}
233
234impl SortKey {
235 pub fn as_str(self) -> &'static str {
236 match self {
237 SortKey::Name => "name",
238 SortKey::Size => "size",
239 SortKey::Modified => "modified",
240 }
241 }
242
243 pub fn parse(s: &str) -> Option<Self> {
244 match s {
245 "name" => Some(SortKey::Name),
246 "size" => Some(SortKey::Size),
247 "modified" => Some(SortKey::Modified),
248 _ => None,
249 }
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Request bodies (client → server)
255// ---------------------------------------------------------------------------
256
257#[derive(Serialize, Deserialize)]
258pub struct Credentials {
259 pub name: String,
260 pub password: String,
261}
262
263/// Rename / move / copy (one body for all file mutations).
264#[derive(Serialize, Deserialize)]
265pub struct Mutation {
266 pub op: Op,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub new_name: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub dst_root_id: Option<i64>,
271 /// Destination directory, relative to `dst_root_id`.
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub dst: Option<String>,
274 #[serde(default)]
275 pub overwrite: bool,
276}
277
278/// A user folder: path relative to the server root + access mode.
279#[derive(Serialize, Deserialize)]
280pub struct Root {
281 /// Path relative to the server root; "." means the whole root.
282 pub path: String,
283 #[serde(default = "default_rw")]
284 pub mode: Mode,
285}
286
287fn default_rw() -> Mode {
288 Mode::Rw
289}
290
291#[derive(Serialize, Deserialize)]
292pub struct CreateUser {
293 pub name: String,
294 pub password: String,
295 #[serde(default)]
296 pub is_admin: bool,
297 #[serde(default)]
298 pub roots: Vec<Root>,
299}
300
301#[derive(Serialize, Deserialize)]
302pub struct UpdateUser {
303 /// Setting one is also the recovery path for a locked-out account: it
304 /// deletes every passkey and puts the account back on
305 /// [`AuthMode::Either`], leaving the new password as the one way in.
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub password: Option<String>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub is_admin: Option<bool>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub active: Option<bool>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub roots: Option<Vec<Root>>,
314}
315
316/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
317#[derive(Serialize, Deserialize, Clone)]
318pub struct Settings {
319 pub allow_writable_shares: bool,
320 /// Folders left out of every search, as paths relative to the server
321 /// root. A path covers everything beneath it.
322 #[serde(default)]
323 pub search_excludes: Vec<String>,
324}
325
326#[derive(Serialize, Deserialize)]
327pub struct CreateShare {
328 pub root_id: i64,
329 /// Item path relative to the root ("" or "." for the root itself).
330 pub path: String,
331 #[serde(default)]
332 pub writable: bool,
333 /// Absolute expiry as RFC 3339; absent = never.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub expires_at: Option<String>,
336 /// Password the visitor must enter before the share opens; absent = none.
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub password: Option<String>,
339}
340
341/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
342#[derive(Serialize, Deserialize)]
343pub struct UnlockShare {
344 pub password: String,
345}
346
347// ---------------------------------------------------------------------------
348// Responses (server → client)
349// ---------------------------------------------------------------------------
350
351/// What a listing entry actually is, decided by the server from the file's
352/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
353/// not from its name. Drives the icon and the preview the client offers.
354///
355/// Deliberately coarse: this answers "which viewer opens this", not "what
356/// exact format is it". Syntax highlighting still keys off the extension,
357/// because `.h` is C or C++ and no amount of sniffing decides that.
358#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum FileKind {
361 Dir,
362 Image,
363 Video,
364 Audio,
365 Pdf,
366 Archive,
367 /// Anything that decodes as text: source code, markup, config, plain text.
368 Text,
369 /// Recognized-but-not-viewable, or undecodable bytes.
370 Binary,
371}
372
373#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
374pub struct Entry {
375 pub name: String,
376 pub is_dir: bool,
377 pub size: u64,
378 /// RFC 3339 UTC modification time.
379 pub mtime: String,
380 /// Content-sniffed kind (see [`FileKind`]).
381 pub kind: FileKind,
382}
383
384/// One page of a folder listing.
385#[derive(Serialize, Deserialize)]
386pub struct FilesResp {
387 pub entries: Vec<Entry>,
388 /// Entries in the whole folder, across all pages.
389 pub total: usize,
390 /// Position of `entries[0]` in the sorted folder. An offset past the end
391 /// comes back as the start of the last page.
392 pub offset: usize,
393}
394
395/// Cap on entries in one listing page.
396pub const MAX_LIST_ENTRIES: usize = 10_000;
397
398/// One streamed search result. Each is sent as one SSE event
399/// (`data: <json>`), in the order found; the `done` event always ends the
400/// stream.
401#[derive(Serialize, Deserialize, Clone)]
402#[serde(tag = "type", rename_all = "snake_case")]
403pub enum SearchEvent {
404 /// A file or folder whose name matched (scope `name`/`both`).
405 File {
406 root_id: i64,
407 /// Path relative to the root, `/`-separated.
408 path: String,
409 size: u64,
410 is_dir: bool,
411 /// Sniffed the same way as a directory listing's, so the client can
412 /// pick an icon and a viewer without a second guess at the name.
413 kind: FileKind,
414 },
415 /// One matching line (scope `content`/`both`). `path` is relative to the
416 /// root; `text` is the matched line, truncated to a fixed length.
417 Match {
418 root_id: i64,
419 path: String,
420 line: u64,
421 text: String,
422 },
423 /// Stream finished. `stopped` is true when the client aborted before the
424 /// search completed.
425 Done {
426 stopped: bool,
427 /// Files examined (walked) before the stream ended.
428 scanned: usize,
429 /// Files skipped for content search (over the size cap).
430 skipped: usize,
431 elapsed_ms: u64,
432 },
433}
434
435#[derive(Serialize, Deserialize, Clone, PartialEq)]
436pub struct UserInfo {
437 pub id: i64,
438 pub name: String,
439 pub is_admin: bool,
440 /// Profile setting: single click opens entries (off = click selects,
441 /// double click opens).
442 pub single_click_open: bool,
443 /// Profile setting: show image and video thumbnails in the grid.
444 pub thumbnails: bool,
445 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
446 /// browser.
447 pub language: Option<String>,
448 /// Profile setting: the first day of the week in the calendar, 0 for
449 /// Sunday through 6 for Saturday.
450 pub week_start: u8,
451 /// Profile setting: the root the UI opens on page load and on the home
452 /// link. Always one of `Me::roots` (the server drops a stale id), or
453 /// None for the root picker.
454 pub default_root_id: Option<i64>,
455 /// What this account needs to sign in.
456 pub auth_mode: AuthMode,
457 /// Whether a password is set at all. False means passkeys only.
458 pub has_password: bool,
459}
460
461#[derive(Serialize, Deserialize, Clone)]
462pub struct RootInfo {
463 pub id: i64,
464 pub name: String,
465 pub path: String,
466 pub mode: Mode,
467}
468
469/// GET `{AUTH_ME}`.
470#[derive(Serialize, Deserialize, Clone)]
471pub struct Me {
472 /// True while no users exist yet (first-boot setup).
473 pub first_boot: bool,
474 /// None on first boot.
475 pub user: Option<UserInfo>,
476 pub roots: Vec<RootInfo>,
477 pub allow_writable_shares: bool,
478 /// Whether the server can make thumbnails at all (`--cache` is set).
479 /// The profile setting is only offered when this is true.
480 pub thumbnails_available: bool,
481 /// `--public-url`, if set. The UI builds share links from it instead of
482 /// the page origin.
483 pub public_url: Option<String>,
484}
485
486/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
487#[derive(Serialize, Deserialize, Clone)]
488pub struct ShareInfo {
489 /// Also the share's synthetic root id in file API calls.
490 pub id: i64,
491 pub token: String,
492 /// Display name (file/folder name, or the root's name for ".").
493 pub name: String,
494 pub is_file: bool,
495 pub writable: bool,
496 /// Path relative to the server root.
497 pub target: String,
498 /// RFC 3339 UTC creation time.
499 pub created_at: String,
500 /// RFC 3339 UTC expiry; None = never.
501 pub expires_at: Option<String>,
502 /// The file's kind for file shares (None for folder shares, and when
503 /// not sniffed — the public resolve endpoint fills it in).
504 pub kind: Option<FileKind>,
505 /// Whether the share asks for a password. Never the password itself.
506 pub has_password: bool,
507}
508
509/// One share plus who owns it: GET `{ADMIN_SHARES}`.
510///
511/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
512/// Kept separate from [`ShareInfo`] because the public resolve route answers
513/// with a `ShareInfo` to anonymous visitors.
514#[derive(Serialize, Deserialize, Clone)]
515pub struct AdminShare {
516 #[serde(flatten)]
517 pub share: ShareInfo,
518 pub creator_id: i64,
519 pub creator_name: String,
520 /// Whether the creator's account can still sign in. Deactivating an account
521 /// does not revoke its shares, so `false` marks a live link its owner can no
522 /// longer manage.
523 pub creator_active: bool,
524}
525
526/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
527#[derive(Serialize, Deserialize, Clone)]
528pub struct AdminUser {
529 pub id: i64,
530 pub name: String,
531 pub is_admin: bool,
532 pub active: bool,
533 pub roots: Vec<RootInfo>,
534}
535
536/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
537/// the acknowledgement, so the body is the empty object.
538#[derive(Serialize, Deserialize)]
539pub struct OkResp {}
540
541#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
542#[serde(rename_all = "lowercase")]
543pub enum PimCollectionKind {
544 Calendar,
545 Addressbook,
546}
547
548/// How a calendar or address book is lent. The serde names are also the
549/// values stored in `pim_shares.mode`.
550#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
551pub enum PimShareMode {
552 #[serde(rename = "ro")]
553 Ro,
554 /// Change members, but send no scheduling messages as the owner.
555 #[serde(rename = "rw")]
556 Rw,
557 /// Also invite and answer as the owner, named in SENT-BY.
558 #[serde(rename = "rw+schedule")]
559 RwSchedule,
560}
561
562impl PimShareMode {
563 pub fn as_str(self) -> &'static str {
564 match self {
565 PimShareMode::Ro => "ro",
566 PimShareMode::Rw => "rw",
567 PimShareMode::RwSchedule => "rw+schedule",
568 }
569 }
570
571 pub fn from_wire(s: &str) -> Option<Self> {
572 match s {
573 "ro" => Some(PimShareMode::Ro),
574 "rw" => Some(PimShareMode::Rw),
575 "rw+schedule" => Some(PimShareMode::RwSchedule),
576 _ => None,
577 }
578 }
579}
580
581/// One entry of `GET {PIM_COLLECTIONS}`.
582#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
583pub struct PimCollectionInfo {
584 /// `0` for the system address book and `-1` for the birthday calendar,
585 /// which the server generates.
586 pub id: i64,
587 pub kind: PimCollectionKind,
588 pub name: String,
589 /// The CalDAV or CardDAV URL, as seen by the signed-in user.
590 pub url: String,
591 pub owner: String,
592 /// `None` for an own collection, the loan's mode for a lent one.
593 pub mode: Option<PimShareMode>,
594 /// Generated by the server, so read-only.
595 #[serde(default)]
596 pub generated: bool,
597 #[serde(default)]
598 pub color: Option<String>,
599 #[serde(default)]
600 pub description: Option<String>,
601 /// Calendars: the component types it takes, e.g. `VEVENT`.
602 #[serde(default)]
603 pub components: Vec<String>,
604 /// Calendars: adds no busy time to scheduling.
605 #[serde(default)]
606 pub transparent: bool,
607 /// The calendar that receives invitations. It cannot be deleted.
608 #[serde(default)]
609 pub is_default: bool,
610 /// Own collections: how many accounts it is lent to.
611 #[serde(default)]
612 pub shares: usize,
613 /// Own collections: how many public feeds it has.
614 #[serde(default)]
615 pub links: usize,
616}
617
618/// `POST {PIM_COLLECTIONS}`.
619#[derive(Serialize, Deserialize, Clone, Debug)]
620pub struct CreatePimCollection {
621 pub kind: PimCollectionKind,
622 pub name: String,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub color: Option<String>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub description: Option<String>,
627 /// Calendars: `VEVENT`, `VTODO`, `VJOURNAL`. Empty takes all three.
628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
629 pub components: Vec<String>,
630}
631
632/// `PUT {PIM_COLLECTIONS}/{id}`: absent fields stay; an empty `color` or
633/// `description` removes it.
634#[derive(Serialize, Deserialize, Clone, Debug, Default)]
635pub struct UpdatePimCollection {
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub name: Option<String>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub color: Option<String>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub description: Option<String>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub transparent: Option<bool>,
644}
645
646/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
647#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
648pub struct PimInstance {
649 pub collection_id: i64,
650 /// The object's resource name in the collection.
651 pub name: String,
652 pub uid: String,
653 /// The original start of a recurring instance (RFC 3339 UTC); `None`
654 /// for an object that does not recur.
655 pub recurrence_id: Option<String>,
656 /// RFC 3339 UTC. An all-day instance starts at midnight in `tz`.
657 pub start: String,
658 pub end: String,
659 pub all_day: bool,
660 /// `VEVENT`, `VTODO` or `VJOURNAL`.
661 pub component: String,
662 pub summary: Option<String>,
663 pub location: Option<String>,
664 /// `TENTATIVE`, `CONFIRMED`, `CANCELLED`, ...
665 pub status: Option<String>,
666 pub transparent: bool,
667 pub has_attendees: bool,
668 /// The calendar owner's PARTSTAT when they are an attendee.
669 pub partstat: Option<String>,
670 /// The organizer's name, else address.
671 pub organizer: Option<String>,
672}
673
674#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
675pub struct PimInstances {
676 pub instances: Vec<PimInstance>,
677 /// Not every instance fits: the range held too many.
678 pub truncated: bool,
679}
680
681#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
682pub struct PimPerson {
683 pub name: Option<String>,
684 /// The calendar user address, e.g. `mailto:...`.
685 pub address: String,
686}
687
688#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
689pub struct PimAttendee {
690 #[serde(flatten)]
691 pub person: PimPerson,
692 pub partstat: Option<String>,
693 pub role: Option<String>,
694 /// The calendar owner.
695 pub is_owner: bool,
696}
697
698#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
699pub struct PimEventDetail {
700 pub collection_id: i64,
701 pub name: String,
702 pub uid: String,
703 pub component: String,
704 pub summary: Option<String>,
705 pub description: Option<String>,
706 pub location: Option<String>,
707 pub url: Option<String>,
708 pub status: Option<String>,
709 pub transparent: bool,
710 pub all_day: bool,
711 pub categories: Vec<String>,
712 /// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
713 pub rrule: Option<String>,
714 pub organizer: Option<PimPerson>,
715 pub attendees: Vec<PimAttendee>,
716 /// The signed-in user may change the object with a client.
717 pub can_edit: bool,
718 /// The calendar owner is an attendee and the signed-in user may answer
719 /// for them.
720 pub can_reply: bool,
721}
722
723#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
724pub struct PimLabeled {
725 /// `work`, `home`, a client's own label, ...
726 pub label: Option<String>,
727 pub value: String,
728}
729
730/// One entry of `GET {PIM_CONTACTS}`.
731#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
732pub struct PimContact {
733 pub collection_id: i64,
734 pub name: String,
735 pub full_name: String,
736 pub org: Option<String>,
737 pub email: Option<String>,
738 pub phone: Option<String>,
739 pub has_photo: bool,
740 pub is_group: bool,
741}
742
743#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
744pub struct PimContactDetail {
745 pub collection_id: i64,
746 pub name: String,
747 pub uid: Option<String>,
748 pub full_name: String,
749 pub org: Option<String>,
750 pub title: Option<String>,
751 pub emails: Vec<PimLabeled>,
752 pub phones: Vec<PimLabeled>,
753 /// One address per entry, its parts on separate lines.
754 pub addresses: Vec<PimLabeled>,
755 pub urls: Vec<PimLabeled>,
756 /// `1980-03-15`, or `--03-15` without a year.
757 pub birthday: Option<String>,
758 pub anniversary: Option<String>,
759 pub note: Option<String>,
760 pub is_group: bool,
761 /// A group's members, by name where the address book knows them.
762 pub members: Vec<String>,
763 /// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
764 pub photo_url: Option<String>,
765 pub can_edit: bool,
766}
767
768/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
769#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
770#[serde(tag = "type", rename_all = "lowercase")]
771pub enum PimObjectDetail {
772 Event(PimEventDetail),
773 Contact(PimContactDetail),
774}
775
776/// One entry of `GET {PIM_INVITATIONS}`: a series, or one instance of it
777/// when that instance was invited on its own.
778#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
779pub struct PimInvitation {
780 pub collection_id: i64,
781 pub name: String,
782 pub uid: String,
783 /// `None`: the whole series.
784 pub recurrence_id: Option<String>,
785 pub summary: Option<String>,
786 pub location: Option<String>,
787 pub organizer: Option<PimPerson>,
788 /// The next start, RFC 3339 UTC.
789 pub start: String,
790 pub end: String,
791 pub all_day: bool,
792 pub recurring: bool,
793}
794
795/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
796/// it into their copy and tells the organizer, as a client would.
797#[derive(Serialize, Deserialize, Clone, Debug)]
798pub struct PimReply {
799 pub collection_id: i64,
800 pub name: String,
801 /// Answer one instance only. As in [`PimInstance::recurrence_id`].
802 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub recurrence_id: Option<String>,
804 /// `ACCEPTED`, `TENTATIVE` or `DECLINED`.
805 pub partstat: String,
806 /// The IANA zone of the request that listed the instance.
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub tz: Option<String>,
809}
810
811/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
812#[derive(Serialize, Deserialize, Clone, Debug)]
813pub struct PimShareInfo {
814 pub user_id: i64,
815 pub user_name: String,
816 pub mode: PimShareMode,
817}
818
819/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
820/// change the mode of an existing loan.
821#[derive(Serialize, Deserialize)]
822pub struct CreatePimShare {
823 pub user: String,
824 pub mode: PimShareMode,
825}
826
827/// One entry of `GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
828#[derive(Serialize, Deserialize, Clone, Debug)]
829pub struct PimLinkInfo {
830 pub id: i64,
831 /// `{FEED}/{token}` with the extension.
832 pub path: String,
833 pub busy_only: bool,
834 pub created_at: String,
835 pub expires_at: Option<String>,
836 pub has_password: bool,
837}
838
839/// One feed with its collection and owner: GET `{ADMIN_PIM_LINKS}`.
840#[derive(Serialize, Deserialize, Clone, Debug)]
841pub struct AdminPimLink {
842 #[serde(flatten)]
843 pub link: PimLinkInfo,
844 pub collection_id: i64,
845 pub collection_name: String,
846 pub kind: PimCollectionKind,
847 pub owner_id: i64,
848 pub owner_name: String,
849 /// Whether the owner can still sign in. A disabled owner's feeds stay live.
850 pub owner_active: bool,
851}
852
853/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
854#[derive(Serialize, Deserialize, Default)]
855pub struct CreatePimLink {
856 /// Calendars only: events without their details.
857 #[serde(default)]
858 pub busy_only: bool,
859 /// Absolute expiry as RFC 3339; absent = never.
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub expires_at: Option<String>,
862 /// Asked for with HTTP Basic; the user name is ignored.
863 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub password: Option<String>,
865}
866
867/// The answer to an import.
868#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
869pub struct PimImportResult {
870 pub created: usize,
871 pub updated: usize,
872 /// All skipped objects, also those beyond `skipped`.
873 pub skipped_total: usize,
874 /// The first skipped objects.
875 pub skipped: Vec<PimSkipped>,
876}
877
878#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
879pub struct PimSkipped {
880 pub uid: Option<String>,
881 /// The precondition a PUT of the object would fail, e.g.
882 /// `valid-calendar-object-resource`.
883 pub reason: String,
884}
885
886#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
887#[serde(rename_all = "lowercase")]
888pub enum RoomKind {
889 Room,
890 Resource,
891}
892
893/// A room or resource: `GET {ADMIN_ROOMS}`.
894#[derive(Serialize, Deserialize, Clone, Debug)]
895pub struct RoomInfo {
896 pub id: i64,
897 /// The URL segment. Fixed, since it is also the scheduling address.
898 pub name: String,
899 pub display_name: String,
900 pub kind: RoomKind,
901 /// The principal URL.
902 pub url: String,
903}
904
905/// `POST {ADMIN_ROOMS}`.
906#[derive(Serialize, Deserialize)]
907pub struct CreateRoom {
908 pub name: String,
909 /// Defaults to `name`.
910 pub display_name: Option<String>,
911 pub kind: RoomKind,
912}
913
914/// `PUT {ADMIN_ROOMS}/{id}`.
915#[derive(Serialize, Deserialize)]
916pub struct UpdateRoom {
917 pub display_name: String,
918}
919
920/// `POST ...?action=exists` body: upload targets relative to the request
921/// directory (may contain subfolders, like upload part names).
922#[derive(Serialize, Deserialize)]
923pub struct ExistsReq {
924 pub paths: Vec<String>,
925}
926
927/// One existing upload target.
928#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
929pub struct Existing {
930 pub path: String,
931 pub is_dir: bool,
932}
933
934/// `POST ...?action=exists` response: the subset of the requested paths
935/// that exist, in request order.
936#[derive(Serialize, Deserialize)]
937pub struct ExistsResp {
938 pub existing: Vec<Existing>,
939}
940
941/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
942#[derive(Serialize, Deserialize)]
943pub struct SaveResp {
944 pub mtime: i64,
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950
951 /// The serde spellings are the database values too, so they are pinned.
952 #[test]
953 fn mode_wire_format_is_rw_ro() {
954 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
955 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
956 for m in [Mode::Rw, Mode::Ro] {
957 let s = serde_json::to_string(&m).unwrap();
958 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
959 // `as_str`/`from_wire` must agree with serde.
960 assert_eq!(s, format!("\"{}\"", m.as_str()));
961 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
962 }
963 assert_eq!(Mode::from_wire("both"), None);
964 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
965 assert!(Mode::Rw.is_writable());
966 assert!(!Mode::Ro.is_writable());
967 }
968
969 #[test]
970 fn pim_share_mode_wire_format() {
971 for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
972 let s = serde_json::to_string(&m).unwrap();
973 assert_eq!(s, format!("\"{}\"", m.as_str()));
974 assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
975 assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
976 }
977 assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
978 }
979
980 #[test]
981 fn op_wire_format() {
982 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
983 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
984 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
985 for op in [Op::Rename, Op::Move, Op::Copy] {
986 let s = serde_json::to_string(&op).unwrap();
987 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
988 }
989 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
990 }
991
992 #[test]
993 fn mutation_round_trip_skips_absent_fields() {
994 let m = Mutation {
995 op: Op::Move,
996 new_name: None,
997 dst_root_id: Some(3),
998 dst: Some("docs".into()),
999 overwrite: true,
1000 };
1001 let s = serde_json::to_string(&m).unwrap();
1002 assert!(!s.contains("new_name"));
1003 let back: Mutation = serde_json::from_str(&s).unwrap();
1004 assert_eq!(back.dst_root_id, Some(3));
1005 assert_eq!(back.op, Op::Move);
1006 }
1007
1008 #[test]
1009 fn mutation_defaults_missing_fields() {
1010 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
1011 assert!(!m.overwrite);
1012 assert_eq!(m.dst, None);
1013 }
1014
1015 #[test]
1016 fn root_defaults_mode_to_rw() {
1017 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
1018 assert_eq!(r.mode, Mode::Rw);
1019 }
1020
1021 #[test]
1022 fn me_round_trip() {
1023 let me = Me {
1024 first_boot: false,
1025 user: Some(UserInfo {
1026 id: 1,
1027 name: "admin".into(),
1028 is_admin: true,
1029 single_click_open: false,
1030 thumbnails: true,
1031 language: None,
1032 week_start: 1,
1033 default_root_id: None,
1034 auth_mode: AuthMode::Either,
1035 has_password: true,
1036 }),
1037 roots: vec![RootInfo {
1038 id: 1,
1039 name: "root".into(),
1040 path: ".".into(),
1041 mode: Mode::Rw,
1042 }],
1043 allow_writable_shares: false,
1044 thumbnails_available: true,
1045 public_url: None,
1046 };
1047 let s = serde_json::to_string(&me).unwrap();
1048 let back: Me = serde_json::from_str(&s).unwrap();
1049 assert_eq!(back.roots.len(), 1);
1050 }
1051}
1052
1053// ---------------------------------------------------------------------------
1054// Sign-in methods: password, passkeys, and how they combine
1055// ---------------------------------------------------------------------------
1056
1057/// What an account needs to sign in.
1058///
1059/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
1060/// passkey-only account, which is still two factors when the authenticator
1061/// does user verification (the server always asks for it).
1062#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1063#[serde(rename_all = "lowercase")]
1064pub enum AuthMode {
1065 /// Password *or* passkey. Either one alone signs the user in.
1066 #[default]
1067 Either,
1068 /// Password *and* passkey. Both legs must pass, in either order.
1069 Both,
1070}
1071
1072impl AuthMode {
1073 pub fn as_str(self) -> &'static str {
1074 match self {
1075 AuthMode::Either => "either",
1076 AuthMode::Both => "both",
1077 }
1078 }
1079
1080 pub fn from_wire(s: &str) -> Option<Self> {
1081 match s {
1082 "either" => Some(AuthMode::Either),
1083 "both" => Some(AuthMode::Both),
1084 _ => None,
1085 }
1086 }
1087}
1088
1089/// One registered passkey, as shown in profile settings. Never carries key
1090/// material.
1091#[derive(Serialize, Deserialize, Clone)]
1092pub struct PasskeyInfo {
1093 pub id: i64,
1094 /// User-chosen label ("YubiKey", "Work laptop").
1095 pub name: String,
1096 /// RFC 3339 UTC.
1097 pub created_at: String,
1098 pub last_used_at: Option<String>,
1099 /// Whether the browser reported this credential as discoverable, so it
1100 /// can sign in without the account name. `None` when the browser did not
1101 /// say — the `credProps` extension is optional and unsigned, so absence
1102 /// means "unknown", never "no".
1103 pub discoverable: Option<bool>,
1104}
1105
1106/// One app password, as shown in profile settings.
1107///
1108/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
1109/// which exists only in [`NewAppPassword`].
1110#[derive(Serialize, Deserialize, Clone)]
1111pub struct AppPasswordInfo {
1112 pub id: i64,
1113 /// User-chosen label ("Laptop mount", "phone").
1114 pub name: String,
1115 /// RFC 3339 UTC.
1116 pub created_at: String,
1117 /// Only tracked to the hour.
1118 pub last_used_at: Option<String>,
1119}
1120
1121/// `POST {AUTH_APP_PASSWORDS}`.
1122#[derive(Serialize, Deserialize)]
1123pub struct CreateAppPassword {
1124 pub name: String,
1125}
1126
1127/// The answer to `POST {AUTH_APP_PASSWORDS}`.
1128///
1129/// The only time `secret` is readable. The server keeps a hash of it.
1130#[derive(Serialize, Deserialize)]
1131pub struct NewAppPassword {
1132 #[serde(flatten)]
1133 pub info: AppPasswordInfo,
1134 pub secret: String,
1135}
1136
1137/// `POST {AUTH_PASSWORD}` — set or change the password.
1138///
1139/// No current password to confirm: a passkey-only account has none to give.
1140/// The session is the gate, and the server drops the account's other
1141/// sessions on every change.
1142#[derive(Serialize, Deserialize)]
1143pub struct ChangePassword {
1144 pub new_password: String,
1145}
1146
1147/// `PUT {AUTH_MODE}`.
1148#[derive(Serialize, Deserialize)]
1149pub struct SetAuthMode {
1150 pub mode: AuthMode,
1151}
1152
1153/// A WebAuthn challenge on its way to the browser.
1154///
1155/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
1156/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
1157/// nested object. Neither side has to re-parse it: the server serializes the
1158/// `webauthn-rs` type straight into it, and the client hands it to
1159/// `JSON.parse` in the browser shim.
1160#[derive(Serialize, Deserialize)]
1161pub struct PasskeyChallenge {
1162 /// Opaque handle for the server-side ceremony state. Echoed back on
1163 /// finish. Not a credential, and useless on its own.
1164 pub state_id: String,
1165 pub options: String,
1166}
1167
1168/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
1169#[derive(Serialize, Deserialize)]
1170pub struct PasskeyRegisterFinish {
1171 pub state_id: String,
1172 /// Label for the new passkey.
1173 pub name: String,
1174 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
1175 pub credential: String,
1176}
1177
1178/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
1179#[derive(Serialize, Deserialize)]
1180pub struct PasskeyLoginBegin {
1181 /// Account name, when the user typed one. Without it the server issues a
1182 /// discoverable challenge, which only finds passkeys the authenticator
1183 /// stores itself.
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1185 pub name: Option<String>,
1186 /// Ask for a conditional-mediation (autofill) challenge instead of a
1187 /// modal one.
1188 #[serde(default)]
1189 pub conditional: bool,
1190}
1191
1192/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
1193#[derive(Serialize, Deserialize)]
1194pub struct PasskeyLoginFinish {
1195 pub state_id: String,
1196 pub credential: String,
1197}
1198
1199/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
1200#[derive(Serialize, Deserialize)]
1201pub struct LoginReq {
1202 /// Omitted only when `state_id` names a half-finished sign-in, which
1203 /// already knows who the user is.
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub name: Option<String>,
1206 pub password: String,
1207 /// Handle from a passkey leg that still needs a password (an
1208 /// [`AuthMode::Both`] account signing in passkey-first).
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub state_id: Option<String>,
1211}
1212
1213/// The answer to either sign-in leg.
1214///
1215/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
1216/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
1217/// and which one appears depends only on which leg the user started with.
1218#[derive(Serialize, Deserialize, Default)]
1219pub struct LoginResp {
1220 /// True when the session cookie is set and the user is in.
1221 pub ok: bool,
1222 /// Present when this leg passed but a passkey is still required.
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub passkey_challenge: Option<PasskeyChallenge>,
1225 /// Present when this leg passed but the password is still required.
1226 /// Carries the account name, so the form can show whose password it
1227 /// wants, and the handle to send back with it.
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub password_required: Option<PasswordStep>,
1230}
1231
1232#[derive(Serialize, Deserialize, Clone)]
1233pub struct PasswordStep {
1234 pub name: String,
1235 pub state_id: String,
1236}
1237