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