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 /// `true` makes this own event calendar the one that receives
656 /// invitations.
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub is_default: Option<bool>,
659}
660
661/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
662#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
663pub struct PimInstance {
664 pub collection_id: i64,
665 /// The object's resource name in the collection.
666 pub name: String,
667 pub uid: String,
668 /// The original start of a recurring instance (RFC 3339 UTC); `None`
669 /// for an object that does not recur.
670 pub recurrence_id: Option<String>,
671 /// RFC 3339 UTC. An all-day instance starts at midnight in `tz`.
672 pub start: String,
673 pub end: String,
674 pub all_day: bool,
675 /// `VEVENT`, `VTODO` or `VJOURNAL`.
676 pub component: String,
677 pub summary: Option<String>,
678 pub location: Option<String>,
679 /// `TENTATIVE`, `CONFIRMED`, `CANCELLED`, ...
680 pub status: Option<String>,
681 pub transparent: bool,
682 pub has_attendees: bool,
683 /// The calendar owner's PARTSTAT when they are an attendee.
684 pub partstat: Option<String>,
685 /// The organizer's name, else address.
686 pub organizer: Option<String>,
687}
688
689#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
690pub struct PimInstances {
691 pub instances: Vec<PimInstance>,
692 /// Not every instance fits: the range held too many.
693 pub truncated: bool,
694}
695
696#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
697pub struct PimPerson {
698 pub name: Option<String>,
699 /// The calendar user address, e.g. `mailto:...`.
700 pub address: String,
701}
702
703#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
704pub struct PimAttendee {
705 #[serde(flatten)]
706 pub person: PimPerson,
707 pub partstat: Option<String>,
708 pub role: Option<String>,
709 /// The calendar owner.
710 pub is_owner: bool,
711}
712
713#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
714pub struct PimEventDetail {
715 pub collection_id: i64,
716 pub name: String,
717 pub uid: String,
718 pub component: String,
719 pub summary: Option<String>,
720 pub description: Option<String>,
721 pub location: Option<String>,
722 pub url: Option<String>,
723 pub status: Option<String>,
724 pub transparent: bool,
725 pub all_day: bool,
726 /// The instance's times as in [`PimInstance`]. `None` without DTSTART.
727 pub start: Option<String>,
728 pub end: Option<String>,
729 pub categories: Vec<String>,
730 /// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
731 pub rrule: Option<String>,
732 pub organizer: Option<PimPerson>,
733 pub attendees: Vec<PimAttendee>,
734 /// The instance has a component of its own (RECURRENCE-ID).
735 #[serde(default)]
736 pub is_override: bool,
737 /// The owner's PARTSTAT on the series' master, when they attend it.
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub series_partstat: Option<String>,
740 /// The signed-in user may change the object with a client.
741 pub can_edit: bool,
742 /// The calendar owner is an attendee and the signed-in user may answer
743 /// for them.
744 pub can_reply: bool,
745}
746
747#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
748pub struct PimLabeled {
749 /// `work`, `home`, a client's own label, ...
750 pub label: Option<String>,
751 pub value: String,
752}
753
754/// One entry of `GET {PIM_CONTACTS}`.
755#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
756pub struct PimContact {
757 pub collection_id: i64,
758 pub name: String,
759 pub full_name: String,
760 pub org: Option<String>,
761 pub email: Option<String>,
762 pub phone: Option<String>,
763 pub has_photo: bool,
764 pub is_group: bool,
765}
766
767#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
768pub struct PimContactDetail {
769 pub collection_id: i64,
770 pub name: String,
771 pub uid: Option<String>,
772 pub full_name: String,
773 pub org: Option<String>,
774 pub title: Option<String>,
775 pub emails: Vec<PimLabeled>,
776 pub phones: Vec<PimLabeled>,
777 /// One address per entry, its parts on separate lines.
778 pub addresses: Vec<PimLabeled>,
779 pub urls: Vec<PimLabeled>,
780 /// `1980-03-15`, or `--03-15` without a year.
781 pub birthday: Option<String>,
782 pub anniversary: Option<String>,
783 pub note: Option<String>,
784 pub is_group: bool,
785 /// A group's members, by name where the address book knows them.
786 pub members: Vec<String>,
787 /// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
788 pub photo_url: Option<String>,
789 pub can_edit: bool,
790}
791
792/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
793#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
794#[serde(tag = "type", rename_all = "lowercase")]
795pub enum PimObjectDetail {
796 Event(PimEventDetail),
797 Contact(PimContactDetail),
798}
799
800/// One entry of `GET {PIM_INVITATIONS}`: a series, or one instance of it
801/// when that instance was invited on its own.
802#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
803pub struct PimInvitation {
804 pub collection_id: i64,
805 pub name: String,
806 pub uid: String,
807 /// `None`: the whole series.
808 pub recurrence_id: Option<String>,
809 pub summary: Option<String>,
810 pub location: Option<String>,
811 pub organizer: Option<PimPerson>,
812 /// The next start, RFC 3339 UTC.
813 pub start: String,
814 pub end: String,
815 pub all_day: bool,
816 pub recurring: bool,
817 /// The series' RRULE when the invitation is for the whole series.
818 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub rrule: Option<String>,
820}
821
822/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
823/// it into their copy and tells the organizer, as a client would.
824#[derive(Serialize, Deserialize, Clone, Debug)]
825pub struct PimReply {
826 pub collection_id: i64,
827 pub name: String,
828 /// Answer one instance only. As in [`PimInstance::recurrence_id`].
829 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub recurrence_id: Option<String>,
831 /// `ACCEPTED`, `TENTATIVE` or `DECLINED`.
832 pub partstat: String,
833 /// The IANA zone of the request that listed the instance.
834 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub tz: Option<String>,
836}
837
838/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
839#[derive(Serialize, Deserialize, Clone, Debug)]
840pub struct PimShareInfo {
841 pub user_id: i64,
842 pub user_name: String,
843 pub mode: PimShareMode,
844}
845
846/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
847/// change the mode of an existing loan.
848#[derive(Serialize, Deserialize)]
849pub struct CreatePimShare {
850 pub user: String,
851 pub mode: PimShareMode,
852}
853
854/// One entry of `GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
855#[derive(Serialize, Deserialize, Clone, Debug)]
856pub struct PimLinkInfo {
857 pub id: i64,
858 /// `{FEED}/{token}` with the extension.
859 pub path: String,
860 pub busy_only: bool,
861 pub created_at: String,
862 pub expires_at: Option<String>,
863 pub has_password: bool,
864}
865
866/// One feed with its collection and owner: `GET {ADMIN_PIM_LINKS}`, and the
867/// own ones in `GET {PIM_SHARES}`.
868#[derive(Serialize, Deserialize, Clone, Debug)]
869pub struct AdminPimLink {
870 #[serde(flatten)]
871 pub link: PimLinkInfo,
872 pub collection_id: i64,
873 pub collection_name: String,
874 pub kind: PimCollectionKind,
875 pub owner_id: i64,
876 pub owner_name: String,
877 /// Whether the owner can still sign in. A disabled owner's feeds answer 404.
878 pub owner_active: bool,
879}
880
881/// One loan of an own collection: `GET {PIM_SHARES}`.
882#[derive(Serialize, Deserialize, Clone, Debug)]
883pub struct PimLend {
884 pub collection_id: i64,
885 pub collection_name: String,
886 pub kind: PimCollectionKind,
887 #[serde(flatten)]
888 pub share: PimShareInfo,
889}
890
891/// `GET {PIM_SHARES}`.
892#[derive(Serialize, Deserialize, Clone, Debug)]
893pub struct PimOwnShares {
894 pub links: Vec<AdminPimLink>,
895 pub lends: Vec<PimLend>,
896}
897
898/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
899#[derive(Serialize, Deserialize, Default)]
900pub struct CreatePimLink {
901 /// Calendars only: events without their details.
902 #[serde(default)]
903 pub busy_only: bool,
904 /// Absolute expiry as RFC 3339; absent = never.
905 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub expires_at: Option<String>,
907 /// Asked for with HTTP Basic; the user name is ignored.
908 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub password: Option<String>,
910}
911
912/// The answer to an import.
913#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
914pub struct PimImportResult {
915 pub created: usize,
916 pub updated: usize,
917 /// All skipped objects, also those beyond `skipped`.
918 pub skipped_total: usize,
919 /// The first skipped objects.
920 pub skipped: Vec<PimSkipped>,
921}
922
923/// The answer to `POST {PIM_IMPORT_NEW}`.
924#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
925pub struct PimImportNew {
926 /// `None` when nothing could be imported: then no collection was made.
927 pub collection: Option<PimCollectionInfo>,
928 #[serde(flatten)]
929 pub result: PimImportResult,
930}
931
932/// An account a collection can be lent to.
933#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
934pub struct PimShareCandidate {
935 pub name: String,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub display_name: Option<String>,
938}
939
940#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
941pub struct PimSkipped {
942 pub uid: Option<String>,
943 /// The precondition a PUT of the object would fail, e.g.
944 /// `valid-calendar-object-resource`.
945 pub reason: String,
946}
947
948#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
949#[serde(rename_all = "lowercase")]
950pub enum RoomKind {
951 Room,
952 Resource,
953}
954
955/// A room or resource: `GET {ADMIN_ROOMS}`.
956#[derive(Serialize, Deserialize, Clone, Debug)]
957pub struct RoomInfo {
958 pub id: i64,
959 /// The URL segment. Fixed, since it is also the scheduling address.
960 pub name: String,
961 pub display_name: String,
962 pub kind: RoomKind,
963 /// The principal URL.
964 pub url: String,
965}
966
967/// `POST {ADMIN_ROOMS}`.
968#[derive(Serialize, Deserialize)]
969pub struct CreateRoom {
970 pub name: String,
971 /// Defaults to `name`.
972 pub display_name: Option<String>,
973 pub kind: RoomKind,
974}
975
976/// `PUT {ADMIN_ROOMS}/{id}`.
977#[derive(Serialize, Deserialize)]
978pub struct UpdateRoom {
979 pub display_name: String,
980}
981
982/// `POST ...?action=exists` body: upload targets relative to the request
983/// directory (may contain subfolders, like upload part names).
984#[derive(Serialize, Deserialize)]
985pub struct ExistsReq {
986 pub paths: Vec<String>,
987}
988
989/// One existing upload target.
990#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
991pub struct Existing {
992 pub path: String,
993 pub is_dir: bool,
994}
995
996/// `POST ...?action=exists` response: the subset of the requested paths
997/// that exist, in request order.
998#[derive(Serialize, Deserialize)]
999pub struct ExistsResp {
1000 pub existing: Vec<Existing>,
1001}
1002
1003/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
1004#[derive(Serialize, Deserialize)]
1005pub struct SaveResp {
1006 pub mtime: i64,
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 /// The serde spellings are the database values too, so they are pinned.
1014 #[test]
1015 fn mode_wire_format_is_rw_ro() {
1016 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
1017 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
1018 for m in [Mode::Rw, Mode::Ro] {
1019 let s = serde_json::to_string(&m).unwrap();
1020 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
1021 // `as_str`/`from_wire` must agree with serde.
1022 assert_eq!(s, format!("\"{}\"", m.as_str()));
1023 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
1024 }
1025 assert_eq!(Mode::from_wire("both"), None);
1026 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
1027 assert!(Mode::Rw.is_writable());
1028 assert!(!Mode::Ro.is_writable());
1029 }
1030
1031 #[test]
1032 fn pim_share_mode_wire_format() {
1033 for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
1034 let s = serde_json::to_string(&m).unwrap();
1035 assert_eq!(s, format!("\"{}\"", m.as_str()));
1036 assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
1037 assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
1038 }
1039 assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
1040 }
1041
1042 #[test]
1043 fn op_wire_format() {
1044 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
1045 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
1046 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
1047 for op in [Op::Rename, Op::Move, Op::Copy] {
1048 let s = serde_json::to_string(&op).unwrap();
1049 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
1050 }
1051 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
1052 }
1053
1054 #[test]
1055 fn mutation_round_trip_skips_absent_fields() {
1056 let m = Mutation {
1057 op: Op::Move,
1058 new_name: None,
1059 dst_root_id: Some(3),
1060 dst: Some("docs".into()),
1061 overwrite: true,
1062 };
1063 let s = serde_json::to_string(&m).unwrap();
1064 assert!(!s.contains("new_name"));
1065 let back: Mutation = serde_json::from_str(&s).unwrap();
1066 assert_eq!(back.dst_root_id, Some(3));
1067 assert_eq!(back.op, Op::Move);
1068 }
1069
1070 #[test]
1071 fn mutation_defaults_missing_fields() {
1072 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
1073 assert!(!m.overwrite);
1074 assert_eq!(m.dst, None);
1075 }
1076
1077 #[test]
1078 fn root_defaults_mode_to_rw() {
1079 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
1080 assert_eq!(r.mode, Mode::Rw);
1081 }
1082
1083 #[test]
1084 fn me_round_trip() {
1085 let me = Me {
1086 first_boot: false,
1087 user: Some(UserInfo {
1088 id: 1,
1089 name: "admin".into(),
1090 is_admin: true,
1091 single_click_open: false,
1092 thumbnails: true,
1093 language: None,
1094 week_start: 1,
1095 default_root_id: None,
1096 auth_mode: AuthMode::Either,
1097 has_password: true,
1098 }),
1099 roots: vec![RootInfo {
1100 id: 1,
1101 name: "root".into(),
1102 path: ".".into(),
1103 mode: Mode::Rw,
1104 }],
1105 allow_writable_shares: false,
1106 thumbnails_available: true,
1107 public_url: None,
1108 };
1109 let s = serde_json::to_string(&me).unwrap();
1110 let back: Me = serde_json::from_str(&s).unwrap();
1111 assert_eq!(back.roots.len(), 1);
1112 }
1113}
1114
1115// ---------------------------------------------------------------------------
1116// Sign-in methods: password, passkeys, and how they combine
1117// ---------------------------------------------------------------------------
1118
1119/// What an account needs to sign in.
1120///
1121/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
1122/// passkey-only account, which is still two factors when the authenticator
1123/// does user verification (the server always asks for it).
1124#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1125#[serde(rename_all = "lowercase")]
1126pub enum AuthMode {
1127 /// Password *or* passkey. Either one alone signs the user in.
1128 #[default]
1129 Either,
1130 /// Password *and* passkey. Both legs must pass, in either order.
1131 Both,
1132}
1133
1134impl AuthMode {
1135 pub fn as_str(self) -> &'static str {
1136 match self {
1137 AuthMode::Either => "either",
1138 AuthMode::Both => "both",
1139 }
1140 }
1141
1142 pub fn from_wire(s: &str) -> Option<Self> {
1143 match s {
1144 "either" => Some(AuthMode::Either),
1145 "both" => Some(AuthMode::Both),
1146 _ => None,
1147 }
1148 }
1149}
1150
1151/// One registered passkey, as shown in profile settings. Never carries key
1152/// material.
1153#[derive(Serialize, Deserialize, Clone)]
1154pub struct PasskeyInfo {
1155 pub id: i64,
1156 /// User-chosen label ("YubiKey", "Work laptop").
1157 pub name: String,
1158 /// RFC 3339 UTC.
1159 pub created_at: String,
1160 pub last_used_at: Option<String>,
1161 /// Whether the browser reported this credential as discoverable, so it
1162 /// can sign in without the account name. `None` when the browser did not
1163 /// say — the `credProps` extension is optional and unsigned, so absence
1164 /// means "unknown", never "no".
1165 pub discoverable: Option<bool>,
1166}
1167
1168/// One app password, as shown in profile settings.
1169///
1170/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
1171/// which exists only in [`NewAppPassword`].
1172#[derive(Serialize, Deserialize, Clone)]
1173pub struct AppPasswordInfo {
1174 pub id: i64,
1175 /// User-chosen label ("Laptop mount", "phone").
1176 pub name: String,
1177 /// RFC 3339 UTC.
1178 pub created_at: String,
1179 /// Only tracked to the hour.
1180 pub last_used_at: Option<String>,
1181}
1182
1183/// `POST {AUTH_APP_PASSWORDS}`.
1184#[derive(Serialize, Deserialize)]
1185pub struct CreateAppPassword {
1186 pub name: String,
1187}
1188
1189/// The answer to `POST {AUTH_APP_PASSWORDS}`.
1190///
1191/// The only time `secret` is readable. The server keeps a hash of it.
1192#[derive(Serialize, Deserialize)]
1193pub struct NewAppPassword {
1194 #[serde(flatten)]
1195 pub info: AppPasswordInfo,
1196 pub secret: String,
1197}
1198
1199/// `POST {AUTH_PASSWORD}` — set or change the password.
1200///
1201/// No current password to confirm: a passkey-only account has none to give.
1202/// The session is the gate, and the server drops the account's other
1203/// sessions on every change.
1204#[derive(Serialize, Deserialize)]
1205pub struct ChangePassword {
1206 pub new_password: String,
1207}
1208
1209/// `PUT {AUTH_MODE}`.
1210#[derive(Serialize, Deserialize)]
1211pub struct SetAuthMode {
1212 pub mode: AuthMode,
1213}
1214
1215/// A WebAuthn challenge on its way to the browser.
1216///
1217/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
1218/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
1219/// nested object. Neither side has to re-parse it: the server serializes the
1220/// `webauthn-rs` type straight into it, and the client hands it to
1221/// `JSON.parse` in the browser shim.
1222#[derive(Serialize, Deserialize)]
1223pub struct PasskeyChallenge {
1224 /// Opaque handle for the server-side ceremony state. Echoed back on
1225 /// finish. Not a credential, and useless on its own.
1226 pub state_id: String,
1227 pub options: String,
1228}
1229
1230/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
1231#[derive(Serialize, Deserialize)]
1232pub struct PasskeyRegisterFinish {
1233 pub state_id: String,
1234 /// Label for the new passkey.
1235 pub name: String,
1236 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
1237 pub credential: String,
1238}
1239
1240/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
1241#[derive(Serialize, Deserialize)]
1242pub struct PasskeyLoginBegin {
1243 /// Account name, when the user typed one. Without it the server issues a
1244 /// discoverable challenge, which only finds passkeys the authenticator
1245 /// stores itself.
1246 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub name: Option<String>,
1248 /// Ask for a conditional-mediation (autofill) challenge instead of a
1249 /// modal one.
1250 #[serde(default)]
1251 pub conditional: bool,
1252}
1253
1254/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
1255#[derive(Serialize, Deserialize)]
1256pub struct PasskeyLoginFinish {
1257 pub state_id: String,
1258 pub credential: String,
1259}
1260
1261/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
1262#[derive(Serialize, Deserialize)]
1263pub struct LoginReq {
1264 /// Omitted only when `state_id` names a half-finished sign-in, which
1265 /// already knows who the user is.
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 pub name: Option<String>,
1268 pub password: String,
1269 /// Handle from a passkey leg that still needs a password (an
1270 /// [`AuthMode::Both`] account signing in passkey-first).
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub state_id: Option<String>,
1273}
1274
1275/// The answer to either sign-in leg.
1276///
1277/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
1278/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
1279/// and which one appears depends only on which leg the user started with.
1280#[derive(Serialize, Deserialize, Default)]
1281pub struct LoginResp {
1282 /// True when the session cookie is set and the user is in.
1283 pub ok: bool,
1284 /// Present when this leg passed but a passkey is still required.
1285 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 pub passkey_challenge: Option<PasskeyChallenge>,
1287 /// Present when this leg passed but the password is still required.
1288 /// Carries the account name, so the form can show whose password it
1289 /// wants, and the handle to send back with it.
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub password_required: Option<PasswordStep>,
1292}
1293
1294#[derive(Serialize, Deserialize, Clone)]
1295pub struct PasswordStep {
1296 pub name: String,
1297 pub state_id: String,
1298}
1299