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