lib.rs
⎇
Raw
1//! The HTTP wire contract of filebrowser-ng 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/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
58pub const ADMIN_USERS: &str = "/api/admin/users";
59/// Admin view of every share on the server: `{ADMIN_SHARES}` and
60/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
61pub const ADMIN_SHARES: &str = "/api/admin/shares";
62pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
63/// Pseudo root id every signed-in admin has on the files API: the whole
64/// server root, read-only (the admin folder picker browses it). Real root
65/// ids are positive database ids. Not listed in `/me`.
66pub const ADMIN_ROOT: i64 = -1;
67
68// ---------------------------------------------------------------------------
69// Query params
70// ---------------------------------------------------------------------------
71
72/// `?action=...` on file URLs; without it the route lists the directory.
73pub const P_ACTION: &str = "action";
74pub const ACTION_DOWNLOAD: &str = "download";
75pub const ACTION_PREVIEW: &str = "preview";
76pub const ACTION_CONTENT: &str = "content";
77pub const ACTION_THUMB: &str = "thumb";
78/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
79/// POST route also carries uploads and mutations.
80pub const ACTION_MKDIR: &str = "mkdir";
81/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
82/// like `mkdir`, for the same reason.
83pub const ACTION_CREATE_FILE: &str = "create-file";
84/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
85/// pre-check for an upload: which of the given targets already exist.
86pub const ACTION_EXISTS: &str = "exists";
87/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
88pub const P_FORMAT: &str = "format";
89/// `?share=<token>` — authenticate file calls with a public share token.
90pub const P_SHARE: &str = "share";
91/// Search query text (`GET {SEARCH}`).
92pub const P_Q: &str = "q";
93/// Which index to search: `name`, `content` or `both`.
94pub const P_SCOPE: &str = "scope";
95/// Root id to search; omitted = the caller's first root.
96pub const P_ROOT: &str = "root";
97/// Folder inside the root to start a search in (relative to the root);
98/// omitted or empty = the whole root.
99pub const P_PATH: &str = "path";
100/// `?overwrite=true|1` on mutations and uploads.
101pub const P_OVERWRITE: &str = "overwrite";
102
103// ---------------------------------------------------------------------------
104// Wire enums
105// ---------------------------------------------------------------------------
106
107/// Access mode of a root or a share.
108///
109/// The serde names are also the values stored in the SQLite `mode` columns,
110/// so renaming a variant would break existing databases. The round-trip test
111/// below pins them.
112#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum Mode {
115 Rw,
116 Ro,
117}
118
119impl Mode {
120 /// The only question callers ask: may this root be written to?
121 pub fn is_writable(self) -> bool {
122 matches!(self, Mode::Rw)
123 }
124
125 /// The wire/database spelling, for `<select>` values and SQL params.
126 pub fn as_str(self) -> &'static str {
127 match self {
128 Mode::Rw => "rw",
129 Mode::Ro => "ro",
130 }
131 }
132
133 /// Parse the wire spelling. `None` for anything else.
134 pub fn from_wire(s: &str) -> Option<Self> {
135 match s {
136 "rw" => Some(Mode::Rw),
137 "ro" => Some(Mode::Ro),
138 _ => None,
139 }
140 }
141}
142
143/// Which mutation [`Mutation`] asks for.
144#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
145#[serde(rename_all = "lowercase")]
146pub enum Op {
147 Rename,
148 Move,
149 Copy,
150}
151
152// ---------------------------------------------------------------------------
153// Request bodies (client → server)
154// ---------------------------------------------------------------------------
155
156#[derive(Serialize, Deserialize)]
157pub struct Credentials {
158 pub name: String,
159 pub password: String,
160}
161
162/// Rename / move / copy (one body for all file mutations).
163#[derive(Serialize, Deserialize)]
164pub struct Mutation {
165 pub op: Op,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub new_name: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub dst_root_id: Option<i64>,
170 /// Destination directory, relative to `dst_root_id`.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub dst: Option<String>,
173 #[serde(default)]
174 pub overwrite: bool,
175}
176
177/// A user folder: path relative to the server root + access mode.
178#[derive(Serialize, Deserialize)]
179pub struct Root {
180 /// Path relative to the server root; "." means the whole root.
181 pub path: String,
182 #[serde(default = "default_rw")]
183 pub mode: Mode,
184}
185
186fn default_rw() -> Mode {
187 Mode::Rw
188}
189
190#[derive(Serialize, Deserialize)]
191pub struct CreateUser {
192 pub name: String,
193 pub password: String,
194 #[serde(default)]
195 pub is_admin: bool,
196 #[serde(default)]
197 pub roots: Vec<Root>,
198}
199
200#[derive(Serialize, Deserialize)]
201pub struct UpdateUser {
202 /// Setting one is also the recovery path for a locked-out account: it
203 /// deletes every passkey and puts the account back on
204 /// [`AuthMode::Either`], leaving the new password as the one way in.
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub password: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub is_admin: Option<bool>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub active: Option<bool>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub roots: Option<Vec<Root>>,
213}
214
215/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
216#[derive(Serialize, Deserialize, Clone)]
217pub struct Settings {
218 pub allow_writable_shares: bool,
219 /// Folders left out of every search, as paths relative to the server
220 /// root. A path covers everything beneath it.
221 #[serde(default)]
222 pub search_excludes: Vec<String>,
223}
224
225#[derive(Serialize, Deserialize)]
226pub struct CreateShare {
227 pub root_id: i64,
228 /// Item path relative to the root ("" or "." for the root itself).
229 pub path: String,
230 #[serde(default)]
231 pub writable: bool,
232 /// Absolute expiry as RFC 3339; absent = never.
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub expires_at: Option<String>,
235 /// Password the visitor must enter before the share opens; absent = none.
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub password: Option<String>,
238}
239
240/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
241#[derive(Serialize, Deserialize)]
242pub struct UnlockShare {
243 pub password: String,
244}
245
246// ---------------------------------------------------------------------------
247// Responses (server → client)
248// ---------------------------------------------------------------------------
249
250/// What a listing entry actually is, decided by the server from the file's
251/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
252/// not from its name. Drives the icon and the preview the client offers.
253///
254/// Deliberately coarse: this answers "which viewer opens this", not "what
255/// exact format is it". Syntax highlighting still keys off the extension,
256/// because `.h` is C or C++ and no amount of sniffing decides that.
257#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
258#[serde(rename_all = "lowercase")]
259pub enum FileKind {
260 Dir,
261 Image,
262 Video,
263 Audio,
264 Pdf,
265 Archive,
266 /// Anything that decodes as text: source code, markup, config, plain text.
267 Text,
268 /// Recognized-but-not-viewable, or undecodable bytes.
269 Binary,
270}
271
272#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
273pub struct Entry {
274 pub name: String,
275 pub is_dir: bool,
276 pub size: u64,
277 /// RFC 3339 UTC modification time.
278 pub mtime: String,
279 /// Content-sniffed kind (see [`FileKind`]).
280 pub kind: FileKind,
281}
282
283#[derive(Serialize, Deserialize)]
284pub struct FilesResp {
285 pub entries: Vec<Entry>,
286 /// True when the folder had more entries than `MAX_LIST_ENTRIES`.
287 /// `entries` then holds only the first `MAX_LIST_ENTRIES` after sorting.
288 #[serde(default)]
289 pub truncated: bool,
290}
291
292/// Cap on entries in one directory listing.
293pub const MAX_LIST_ENTRIES: usize = 10_000;
294
295/// One streamed search result. Each is sent as one SSE event
296/// (`data: <json>`), in the order found; the `done` event always ends the
297/// stream.
298#[derive(Serialize, Deserialize, Clone)]
299#[serde(tag = "type", rename_all = "snake_case")]
300pub enum SearchEvent {
301 /// A file or folder whose name matched (scope `name`/`both`).
302 File {
303 root_id: i64,
304 /// Path relative to the root, `/`-separated.
305 path: String,
306 size: u64,
307 is_dir: bool,
308 /// Sniffed the same way as a directory listing's, so the client can
309 /// pick an icon and a viewer without a second guess at the name.
310 kind: FileKind,
311 },
312 /// One matching line (scope `content`/`both`). `path` is relative to the
313 /// root; `text` is the matched line, truncated to a fixed length.
314 Match {
315 root_id: i64,
316 path: String,
317 line: u64,
318 text: String,
319 },
320 /// Stream finished. `stopped` is true when the client aborted before the
321 /// search completed.
322 Done {
323 stopped: bool,
324 files: usize,
325 matches: usize,
326 /// Files examined (walked) before the stream ended.
327 scanned: usize,
328 /// Files skipped for content search (over the size cap).
329 skipped: usize,
330 elapsed_ms: u64,
331 },
332}
333
334#[derive(Serialize, Deserialize, Clone, PartialEq)]
335pub struct UserInfo {
336 pub id: i64,
337 pub name: String,
338 pub is_admin: bool,
339 /// Profile setting: single click opens entries (off = click selects,
340 /// double click opens).
341 pub single_click_open: bool,
342 /// Profile setting: show image and video thumbnails in the grid.
343 pub thumbnails: bool,
344 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
345 /// browser.
346 pub language: Option<String>,
347 /// Profile setting: the root the UI opens on page load and on the home
348 /// link. Always one of `Me::roots` (the server drops a stale id), or
349 /// None for the root picker.
350 #[serde(default)]
351 pub default_root_id: Option<i64>,
352 /// What this account needs to sign in.
353 #[serde(default)]
354 pub auth_mode: AuthMode,
355 /// Whether a password is set at all. False means passkeys only.
356 #[serde(default = "yes")]
357 pub has_password: bool,
358}
359
360fn yes() -> bool {
361 true
362}
363
364#[derive(Serialize, Deserialize, Clone)]
365pub struct RootInfo {
366 pub id: i64,
367 pub name: String,
368 pub path: String,
369 pub mode: Mode,
370}
371
372/// GET `{AUTH_ME}`.
373#[derive(Serialize, Deserialize, Clone)]
374pub struct Me {
375 /// True while no users exist yet (first-boot setup).
376 pub first_boot: bool,
377 /// None on first boot.
378 pub user: Option<UserInfo>,
379 pub roots: Vec<RootInfo>,
380 pub allow_writable_shares: bool,
381 /// Whether the server can make thumbnails at all (`--cache` is set).
382 /// The profile setting is only offered when this is true.
383 pub thumbnails_available: bool,
384 /// `--public-url`, if set. The UI builds share links from it instead of
385 /// the page origin.
386 pub public_url: Option<String>,
387}
388
389/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
390#[derive(Serialize, Deserialize, Clone)]
391pub struct ShareInfo {
392 pub id: i64,
393 pub token: String,
394 /// Display name (file/folder name, or the root's name for ".").
395 pub name: String,
396 pub is_file: bool,
397 pub writable: bool,
398 /// Path relative to the server root.
399 pub target: String,
400 /// RFC 3339 UTC creation time.
401 pub created_at: String,
402 /// RFC 3339 UTC expiry; None = never.
403 pub expires_at: Option<String>,
404 /// Synthetic root id to use in file API calls.
405 pub root_id: i64,
406 /// The file's kind for file shares (None for folder shares, and when
407 /// not sniffed — the public resolve endpoint fills it in).
408 pub kind: Option<FileKind>,
409 /// Whether the share asks for a password. Never the password itself.
410 #[serde(default)]
411 pub has_password: bool,
412}
413
414/// One share plus who owns it: GET `{ADMIN_SHARES}`.
415///
416/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
417/// Kept separate from [`ShareInfo`] because the public resolve route answers
418/// with a `ShareInfo` to anonymous visitors.
419#[derive(Serialize, Deserialize, Clone)]
420pub struct AdminShare {
421 #[serde(flatten)]
422 pub share: ShareInfo,
423 pub creator_id: i64,
424 pub creator_name: String,
425 /// Whether the creator's account can still sign in. Deactivating an account
426 /// does not revoke its shares, so `false` marks a live link its owner can no
427 /// longer manage.
428 pub creator_active: bool,
429}
430
431/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
432#[derive(Serialize, Deserialize, Clone)]
433pub struct AdminUser {
434 pub id: i64,
435 pub name: String,
436 pub is_admin: bool,
437 pub active: bool,
438 pub roots: Vec<RootInfo>,
439}
440
441/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
442/// the acknowledgement, so the body is the empty object.
443#[derive(Serialize, Deserialize)]
444pub struct OkResp {}
445
446/// DELETE `{FILES}/...`: whether the removed item was a folder (the client
447/// reports "folder deleted" vs "file deleted").
448#[derive(Serialize, Deserialize)]
449pub struct DeleteResp {
450 pub is_dir: bool,
451}
452
453/// `POST ...?action=exists` body: upload targets relative to the request
454/// directory (may contain subfolders, like upload part names).
455#[derive(Serialize, Deserialize)]
456pub struct ExistsReq {
457 pub paths: Vec<String>,
458}
459
460/// One existing upload target.
461#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
462pub struct Existing {
463 pub path: String,
464 pub is_dir: bool,
465}
466
467/// `POST ...?action=exists` response: the subset of the requested paths
468/// that exist, in request order.
469#[derive(Serialize, Deserialize)]
470pub struct ExistsResp {
471 pub existing: Vec<Existing>,
472}
473
474/// Upload success: how many files were written.
475#[derive(Serialize, Deserialize)]
476pub struct UploadResp {
477 pub uploaded: usize,
478}
479
480/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
481#[derive(Serialize, Deserialize)]
482pub struct SaveResp {
483 pub mtime: i64,
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 /// The serde spellings are the database values too, so they are pinned.
491 #[test]
492 fn mode_wire_format_is_rw_ro() {
493 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
494 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
495 for m in [Mode::Rw, Mode::Ro] {
496 let s = serde_json::to_string(&m).unwrap();
497 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
498 // `as_str`/`from_wire` must agree with serde.
499 assert_eq!(s, format!("\"{}\"", m.as_str()));
500 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
501 }
502 assert_eq!(Mode::from_wire("both"), None);
503 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
504 assert!(Mode::Rw.is_writable());
505 assert!(!Mode::Ro.is_writable());
506 }
507
508 #[test]
509 fn op_wire_format() {
510 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
511 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
512 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
513 for op in [Op::Rename, Op::Move, Op::Copy] {
514 let s = serde_json::to_string(&op).unwrap();
515 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
516 }
517 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
518 }
519
520 #[test]
521 fn mutation_round_trip_skips_absent_fields() {
522 let m = Mutation {
523 op: Op::Move,
524 new_name: None,
525 dst_root_id: Some(3),
526 dst: Some("docs".into()),
527 overwrite: true,
528 };
529 let s = serde_json::to_string(&m).unwrap();
530 assert!(!s.contains("new_name"));
531 let back: Mutation = serde_json::from_str(&s).unwrap();
532 assert_eq!(back.dst_root_id, Some(3));
533 assert_eq!(back.op, Op::Move);
534 }
535
536 #[test]
537 fn mutation_defaults_missing_fields() {
538 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
539 assert!(!m.overwrite);
540 assert_eq!(m.dst, None);
541 }
542
543 #[test]
544 fn root_defaults_mode_to_rw() {
545 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
546 assert_eq!(r.mode, Mode::Rw);
547 }
548
549 #[test]
550 fn me_round_trip() {
551 let me = Me {
552 first_boot: false,
553 user: Some(UserInfo {
554 id: 1,
555 name: "admin".into(),
556 is_admin: true,
557 single_click_open: false,
558 thumbnails: true,
559 language: None,
560 default_root_id: None,
561 auth_mode: AuthMode::Either,
562 has_password: true,
563 }),
564 roots: vec![RootInfo {
565 id: 1,
566 name: "root".into(),
567 path: ".".into(),
568 mode: Mode::Rw,
569 }],
570 allow_writable_shares: false,
571 thumbnails_available: true,
572 public_url: None,
573 };
574 let s = serde_json::to_string(&me).unwrap();
575 let back: Me = serde_json::from_str(&s).unwrap();
576 assert_eq!(back.roots.len(), 1);
577 }
578}
579
580// ---------------------------------------------------------------------------
581// Sign-in methods: password, passkeys, and how they combine
582// ---------------------------------------------------------------------------
583
584/// What an account needs to sign in.
585///
586/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
587/// passkey-only account, which is still two factors when the authenticator
588/// does user verification (the server always asks for it).
589#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
590#[serde(rename_all = "lowercase")]
591pub enum AuthMode {
592 /// Password *or* passkey. Either one alone signs the user in.
593 #[default]
594 Either,
595 /// Password *and* passkey. Both legs must pass, in either order.
596 Both,
597}
598
599impl AuthMode {
600 pub fn as_str(self) -> &'static str {
601 match self {
602 AuthMode::Either => "either",
603 AuthMode::Both => "both",
604 }
605 }
606
607 pub fn from_wire(s: &str) -> Option<Self> {
608 match s {
609 "either" => Some(AuthMode::Either),
610 "both" => Some(AuthMode::Both),
611 _ => None,
612 }
613 }
614}
615
616/// One registered passkey, as shown in profile settings. Never carries key
617/// material.
618#[derive(Serialize, Deserialize, Clone)]
619pub struct PasskeyInfo {
620 pub id: i64,
621 /// User-chosen label ("YubiKey", "Work laptop").
622 pub name: String,
623 /// RFC 3339 UTC.
624 pub created_at: String,
625 pub last_used_at: Option<String>,
626 /// Whether the browser reported this credential as discoverable, so it
627 /// can sign in without the account name. `None` when the browser did not
628 /// say — the `credProps` extension is optional and unsigned, so absence
629 /// means "unknown", never "no".
630 pub discoverable: Option<bool>,
631}
632
633/// One app password, as shown in profile settings.
634///
635/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
636/// which exists only in [`NewAppPassword`].
637#[derive(Serialize, Deserialize, Clone)]
638pub struct AppPasswordInfo {
639 pub id: i64,
640 /// User-chosen label ("Laptop mount", "phone").
641 pub name: String,
642 /// RFC 3339 UTC.
643 pub created_at: String,
644 /// Only tracked to the hour.
645 pub last_used_at: Option<String>,
646}
647
648/// `POST {AUTH_APP_PASSWORDS}`.
649#[derive(Serialize, Deserialize)]
650pub struct CreateAppPassword {
651 pub name: String,
652}
653
654/// The answer to `POST {AUTH_APP_PASSWORDS}`.
655///
656/// The only time `secret` is readable. The server keeps a hash of it.
657#[derive(Serialize, Deserialize)]
658pub struct NewAppPassword {
659 #[serde(flatten)]
660 pub info: AppPasswordInfo,
661 pub secret: String,
662}
663
664/// `POST {AUTH_PASSWORD}` — set or change the password.
665///
666/// No current password to confirm: a passkey-only account has none to give.
667/// The session is the gate, and the server drops the account's other
668/// sessions on every change.
669#[derive(Serialize, Deserialize)]
670pub struct ChangePassword {
671 pub new_password: String,
672}
673
674/// `PUT {AUTH_MODE}`.
675#[derive(Serialize, Deserialize)]
676pub struct SetAuthMode {
677 pub mode: AuthMode,
678}
679
680/// A WebAuthn challenge on its way to the browser.
681///
682/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
683/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
684/// nested object. Neither side has to re-parse it: the server serializes the
685/// `webauthn-rs` type straight into it, and the client hands it to
686/// `JSON.parse` in the browser shim.
687#[derive(Serialize, Deserialize)]
688pub struct PasskeyChallenge {
689 /// Opaque handle for the server-side ceremony state. Echoed back on
690 /// finish. Not a credential, and useless on its own.
691 pub state_id: String,
692 pub options: String,
693}
694
695/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
696#[derive(Serialize, Deserialize)]
697pub struct PasskeyRegisterFinish {
698 pub state_id: String,
699 /// Label for the new passkey.
700 pub name: String,
701 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
702 pub credential: String,
703}
704
705/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
706#[derive(Serialize, Deserialize, Default)]
707pub struct PasskeyLoginBegin {
708 /// Account name, when the user typed one. Without it the server issues a
709 /// discoverable challenge, which only finds passkeys the authenticator
710 /// stores itself.
711 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub name: Option<String>,
713 /// Ask for a conditional-mediation (autofill) challenge instead of a
714 /// modal one.
715 #[serde(default)]
716 pub conditional: bool,
717}
718
719/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
720#[derive(Serialize, Deserialize)]
721pub struct PasskeyLoginFinish {
722 pub state_id: String,
723 pub credential: String,
724}
725
726/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
727#[derive(Serialize, Deserialize)]
728pub struct LoginReq {
729 /// Omitted only when `state_id` names a half-finished sign-in, which
730 /// already knows who the user is.
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub name: Option<String>,
733 pub password: String,
734 /// Handle from a passkey leg that still needs a password (an
735 /// [`AuthMode::Both`] account signing in passkey-first).
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub state_id: Option<String>,
738}
739
740/// The answer to either sign-in leg.
741///
742/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
743/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
744/// and which one appears depends only on which leg the user started with.
745#[derive(Serialize, Deserialize, Default)]
746pub struct LoginResp {
747 /// True when the session cookie is set and the user is in.
748 pub ok: bool,
749 /// Present when this leg passed but a passkey is still required.
750 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub passkey_challenge: Option<PasskeyChallenge>,
752 /// Present when this leg passed but the password is still required.
753 /// Carries the account name, so the form can show whose password it
754 /// wants, and the handle to send back with it.
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub password_required: Option<PasswordStep>,
757}
758
759#[derive(Serialize, Deserialize, Clone)]
760pub struct PasswordStep {
761 pub name: String,
762 pub state_id: String,
763}
764