My agentic slop goes here. Not intended for anyone else!
1(** Secure API key storage for Eio applications using XDG directories
2
3 This library provides secure storage and retrieval of API keys and credentials
4 for Eio-based applications. It integrates with the XDG Base Directory
5 Specification via the xdge library to store credentials in a consistent,
6 platform-appropriate location.
7
8 {b Key Features:}
9
10 - Store API keys in XDG-compliant directories with proper permissions
11 - Support multiple profiles per service (production, staging, development)
12 - TOML-based storage format for readability and flexibility
13 - Cmdliner integration for easy command-line usage
14 - Designed for future Secret Service API integration
15
16 {b Security Model:}
17
18 Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/]
19 with strict filesystem permissions (0o600 - owner read/write only). This follows
20 common practice for CLI tools and provides reasonable security for single-user
21 systems.
22
23 The design supports future integration with system keychains via the
24 freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC)
25 without breaking the API.
26
27 {b Storage Structure:}
28
29 Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE
30 is the name of the service (e.g., "immiche", "karakeepe"). Each service file
31 contains one or more named profiles:
32
33 {v
34 [default]
35 api_key = "abc123..."
36 base_url = "https://api.example.com"
37
38 [production]
39 api_key = "xyz789..."
40 base_url = "https://api.prod.example.com"
41 v}
42
43 {b Example Usage:}
44
45 {[
46 open Cmdliner
47
48 let main (xdg, _) profile =
49 Eio_main.run @@ fun env ->
50
51 let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
52 let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in
53
54 (* Use credentials to create API client *)
55 let client = Immiche.create ~sw ~env ~base_url ~api_key () in
56 (* ... *)
57
58 let () =
59 let xdg_term = Xdge.Cmd.term "myapp" env#fs () in
60 let key_term = Keyeio.Cmd.term
61 ~app_name:"myapp"
62 ~service:"immiche"
63 env#fs () in
64
65 let cmd = Cmd.v (Cmd.info "myapp")
66 Term.(const main $ xdg_term $ key_term) in
67 exit (Cmd.eval cmd)
68 ]}
69
70 @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification
71 @see <https://specifications.freedesktop.org/secret-service-spec/latest/> Secret Service API *)
72
73(** The main Keyeio context type containing directory paths and configuration.
74
75 A value of type [t] represents the keyeio configuration for a specific
76 application, including the keys directory path and storage backend. *)
77type t
78
79(** {1 Exceptions} *)
80
81(** Exception raised when a required key is not found in a profile. *)
82exception Key_not_found of string
83
84(** Exception raised when a profile is not found in a service. *)
85exception Profile_not_found of string
86
87(** Exception raised when attempting to access invalid TOML structure. *)
88exception Invalid_key_file of string
89
90(** {1 Profile} *)
91
92(** A profile represents a set of credentials for one service configuration.
93
94 Profiles allow you to store multiple sets of credentials for the same
95 service. For example, you might have "default", "production", and "staging"
96 profiles for an API service, each with different API keys and endpoints.
97
98 Each profile contains arbitrary key-value pairs stored as strings. Common
99 keys include "api_key", "base_url", "email", etc., but applications are
100 free to store any configuration needed. *)
101module Profile : sig
102 (** The type of a credential profile. *)
103 type t
104
105 (** [service t] returns the service name this profile belongs to.
106
107 @return The service name (e.g., "immiche", "karakeepe") *)
108 val service : t -> string
109
110 (** [name t] returns the profile name.
111
112 @return The profile name (e.g., "default", "production", "staging") *)
113 val name : t -> string
114
115 (** [get t ~key] retrieves a value from the profile.
116
117 @param t The profile to query
118 @param key The key to look up
119 @return [Some value] if the key exists, [None] otherwise
120
121 {b Example:}
122 {[
123 match Profile.get profile ~key:"api_key" with
124 | Some key -> Printf.printf "API key: %s\n" key
125 | None -> Printf.printf "No API key found\n"
126 ]} *)
127 val get : t -> key:string -> string option
128
129 (** [get_required t ~key] retrieves a value that must exist.
130
131 @param t The profile to query
132 @param key The key to look up
133 @return The value associated with the key
134 @raise Key_not_found if the key does not exist
135
136 {b Example:}
137 {[
138 let api_key = Profile.get_required profile ~key:"api_key" in
139 (* Use api_key, knowing it exists *)
140 ]} *)
141 val get_required : t -> key:string -> string
142
143 (** [keys t] returns all keys available in this profile.
144
145 @param t The profile to query
146 @return A list of all key names in the profile
147
148 {b Example:}
149 {[
150 let available = Profile.keys profile in
151 List.iter (fun k -> Printf.printf "Available key: %s\n" k) available
152 ]} *)
153 val keys : t -> string list
154
155 (** [empty] creates an empty profile with no data.
156
157 This is useful when a command doesn't need API keys but must
158 conform to an interface that expects a profile parameter.
159
160 @return An empty profile with empty service name, empty profile name, and no data *)
161 val empty : t
162
163 (** [to_toml t] converts the profile to a TOML table representation.
164
165 Returns a TOML table containing all key-value pairs in the profile.
166
167 @param t The profile to convert
168 @return A TOML table representation *)
169 val to_toml : t -> Toml.Types.table
170
171 (** [pp ppf t] pretty prints a profile for debugging.
172
173 Displays the service name, profile name, and all key-value pairs.
174 Sensitive values (keys containing "key", "token", "password") are
175 masked for security.
176
177 @param ppf The formatter to print to
178 @param t The profile to print *)
179 val pp : Format.formatter -> t -> unit
180end
181
182(** {1 Service} *)
183
184(** A service represents all profiles for a given service.
185
186 Services group together multiple profiles for the same API or service.
187 For example, an "immiche" service might contain "default", "production",
188 and "staging" profiles, each with their own credentials.
189
190 Services are loaded from TOML files in the keys directory, with one file
191 per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *)
192module Service : sig
193 (** The type of a service containing multiple profiles. *)
194 type t
195
196 (** [name t] returns the service name.
197
198 @return The service name (e.g., "immiche", "karakeepe") *)
199 val name : t -> string
200
201 (** [profile_names t] returns all available profile names for this service.
202
203 @param t The service to query
204 @return A list of profile names (e.g., ["default"; "production"; "staging"])
205
206 {b Example:}
207 {[
208 let profiles = Service.profile_names service in
209 Printf.printf "Available profiles: %s\n" (String.concat ", " profiles)
210 ]} *)
211 val profile_names : t -> string list
212
213 (** [get_profile t name] retrieves a specific profile by name.
214
215 @param t The service to query
216 @param name The profile name to retrieve
217 @return [Some profile] if found, [None] otherwise
218
219 {b Example:}
220 {[
221 match Service.get_profile service "production" with
222 | Some prof -> (* Use production profile *)
223 | None -> failwith "Production profile not configured"
224 ]} *)
225 val get_profile : t -> string -> Profile.t option
226
227 (** [default_profile t] retrieves the "default" profile if it exists.
228
229 This is a convenience function equivalent to [get_profile t "default"].
230
231 @param t The service to query
232 @return [Some profile] if a "default" profile exists, [None] otherwise *)
233 val default_profile : t -> Profile.t option
234
235 (** [pp ppf t] pretty prints a service and all its profiles.
236
237 @param ppf The formatter to print to
238 @param t The service to print *)
239 val pp : Format.formatter -> t -> unit
240end
241
242(** {1 Construction} *)
243
244(** [create xdg] creates a Keyeio context from an Xdge context.
245
246 The keys are stored in a "keys" subdirectory of the XDG config directory.
247 For example, if the application is "myapp" and [XDG_CONFIG_HOME] is
248 [~/.config], keys will be stored in [~/.config/myapp/keys/].
249
250 The keys directory is created with permissions 0o700 if it doesn't exist.
251
252 @param xdg The Xdge context providing XDG directory paths
253 @return A new Keyeio context
254
255 {b Example:}
256 {[
257 let xdg = Xdge.create env#fs "myapp" in
258 let keyeio = Keyeio.create xdg in
259 (* Now you can load services and profiles *)
260 ]} *)
261val create : Xdge.t -> t
262
263(** {1 Creating Credentials} *)
264
265(** [create_default_keyfile t ~service ~profile ~data] creates a new credential file.
266
267 Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with
268 the provided profile and key-value pairs. If the file already exists,
269 it will be loaded and the new profile will be added or updated.
270
271 @param t The Keyeio context
272 @param service The service name (e.g., "karakeepe")
273 @param profile The profile name (default: "default")
274 @param data Key-value pairs to store in the profile
275 @return [Ok ()] on success, [Error (`Msg msg)] on failure
276
277 {b Example:}
278 {[
279 let data = [
280 ("api_key", "ak1_example_key");
281 ("base_url", "https://api.example.com")
282 ] in
283 match Keyeio.create_default_keyfile keyeio ~service:"karakeepe"
284 ~profile:"default" ~data with
285 | Ok () -> Printf.printf "Key file created successfully\n"
286 | Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg
287 ]}
288
289 {b Security:} The file is created with permissions 0o600 (owner read/write only). *)
290val create_default_keyfile :
291 t ->
292 service:string ->
293 profile:string ->
294 data:(string * string) list ->
295 (unit, [> `Msg of string ]) result
296
297(** {1 Loading Credentials} *)
298
299(** [load_service t ~service] loads all profiles for a given service.
300
301 Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and
302 parses all profiles contained within. The file must contain TOML tables
303 where each section name is a profile name containing credential key-value pairs.
304
305 @param t The Keyeio context
306 @param service The service name to load (e.g., "immiche", "karakeepe")
307 @return [Ok service] on success, [Error (`Msg msg)] on failure
308
309 {b Example:}
310 {[
311 match Keyeio.load_service keyeio ~service:"immiche" with
312 | Ok svc ->
313 begin match Service.default_profile svc with
314 | Some prof -> (* Use default profile *)
315 | None -> failwith "No default profile"
316 end
317 | Error (`Msg msg) ->
318 Printf.eprintf "Failed to load service: %s\n" msg
319 ]}
320
321 {b Error Conditions:}
322 - Service file does not exist
323 - Service file has incorrect permissions (not 0o600)
324 - Service file contains invalid TOML
325 - Service file does not contain proper TOML tables *)
326val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result
327
328(** [list_services t] returns all available service names.
329
330 Scans the keys directory for all [*.toml] files and returns their
331 base names (without the .toml extension). This allows applications
332 to discover what services have stored credentials.
333
334 @param t The Keyeio context
335 @return [Ok services] with a list of service names, or [Error (`Msg msg)] on failure
336
337 {b Example:}
338 {[
339 match Keyeio.list_services keyeio with
340 | Ok services ->
341 Printf.printf "Available services: %s\n"
342 (String.concat ", " services)
343 | Error (`Msg msg) ->
344 Printf.eprintf "Failed to list services: %s\n" msg
345 ]}
346
347 {b Note:} Only files with [.toml] extension are considered. Files
348 with incorrect permissions are silently skipped. *)
349val list_services : t -> (string list, [> `Msg of string ]) result
350
351(** {1 Pretty Printing} *)
352
353(** [pp ppf t] pretty prints the Keyeio configuration.
354
355 Shows the keys directory path and basic configuration information.
356 Does not display actual credentials for security.
357
358 @param ppf The formatter to print to
359 @param t The Keyeio context to print *)
360val pp : Format.formatter -> t -> unit
361
362(** {1 Cmdliner Integration} *)
363
364module Cmd : sig
365 (** The type of the outer Keyeio context *)
366 type keyeio_t = t
367
368 (** Cmdliner integration for API key and credential management.
369
370 This module provides seamless integration with the Cmdliner library,
371 allowing applications to easily add credential loading to their
372 command-line interfaces. The integration follows the same patterns
373 as Xdge.Cmd for consistency.
374
375 {b Features:}
376 - Automatic command-line flag generation for profile selection
377 - Optional direct file path override
378 - Clear error messages for missing or invalid credentials
379 - Composable with other Cmdliner terms *)
380
381 (** [term ~app_name ~fs ~service ()] creates a Cmdliner term for loading credentials.
382
383 This function generates a Cmdliner term that handles credential loading
384 for a specific service. It automatically creates appropriate command-line
385 flags and handles loading the requested profile.
386
387 @param app_name The application name (used for XDG paths)
388 @param fs The Eio filesystem providing filesystem access
389 @param service The service name to load credentials for (e.g., "immiche")
390 @param profile Default profile name to use (default: "default")
391 @param key_file Add [--key-file] override flag (default: [true])
392
393 {b Generated Command-line Flags:}
394 - [--profile NAME]: Select which profile to use (default: "default")
395 - [--key-file PATH]: Override with direct TOML file path (if [key_file=true])
396
397 {b Flag Precedence:}
398 + [--key-file PATH] - highest priority (if enabled)
399 + [--profile NAME]
400 + Default profile ("default")
401
402 {b Example - Basic usage:}
403 {[
404 open Cmdliner
405
406 let main profile =
407 let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
408 (* Use api_key *)
409
410 let () =
411 let key_term = Keyeio.Cmd.term
412 ~app_name:"myapp"
413 ~service:"immiche"
414 env#fs () in
415
416 let cmd = Cmd.v (Cmd.info "myapp")
417 Term.(const main $ key_term) in
418 exit (Cmd.eval cmd)
419 ]}
420
421 {b Example - Custom profile default:}
422 {[
423 let key_term = Keyeio.Cmd.term
424 ~app_name:"myapp"
425 ~service:"immiche"
426 ~profile:"production" (* Use production by default *)
427 env#fs () in
428 ]}
429
430 {b Example - Without key-file override:}
431 {[
432 let key_term = Keyeio.Cmd.term
433 ~app_name:"myapp"
434 ~service:"immiche"
435 ~key_file:false (* Only --profile flag, no --key-file *)
436 env#fs () in
437 ]}
438
439 {b Error Handling:}
440
441 The term will fail with a clear error message if:
442 - The service file does not exist
443 - The requested profile is not found
444 - The TOML file is invalid
445 - File permissions are incorrect *)
446 val term :
447 app_name:string ->
448 fs:Eio.Fs.dir_ty Eio.Path.t ->
449 service:string ->
450 ?profile:string ->
451 ?key_file:bool ->
452 unit ->
453 Profile.t Cmdliner.Term.t
454
455 (** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles.
456
457 This function generates a Cmdliner term that handles interactive creation
458 of credential files. It prompts for required values, allows optional overrides
459 via command-line flags, and creates the TOML file with proper permissions.
460
461 @param app_name The application name (used for XDG paths)
462 @param fs The Eio filesystem providing filesystem access
463 @param service The service name to create credentials for (e.g., "karakeepe")
464 @param default_data Default key-value pairs with optional prompts
465 @param profile Default profile name to create (default: "default")
466
467 {b Generated Command-line Flags:}
468 - [--profile NAME]: Profile name to create (default: "default")
469 - One flag per key in default_data (e.g., [--api-key], [--base-url])
470
471 {b Example - Basic usage with prompts:}
472 {[
473 open Cmdliner
474
475 let create_cmd env =
476 let default_data = [
477 ("api_key", None); (* Will prompt if not provided *)
478 ("base_url", Some "https://hoard.recoil.org") (* Has default *)
479 ] in
480
481 let create_term = Keyeio.Cmd.create_term
482 ~app_name:"karakeepe"
483 ~fs:env#fs
484 ~service:"karakeepe"
485 ~default_data
486 () in
487
488 Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials")
489 create_term
490 ]}
491
492 {b Behavior:}
493 - If a value is provided via CLI flag, use it
494 - If a value has a default in default_data, use it
495 - Otherwise, prompt interactively for the value
496 - Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml]
497 - Set file permissions to 0o600 for security
498 - Return 0 on success, 1 on failure *)
499 val create_term :
500 app_name:string ->
501 fs:Eio.Fs.dir_ty Eio.Path.t ->
502 service:string ->
503 default_data:(string * string option) list ->
504 ?profile:string ->
505 unit ->
506 int Cmdliner.Term.t
507
508 (** [env_docs ~app_name ~service ()] generates documentation for environment variables.
509
510 Returns a formatted string documenting relevant environment variables
511 that affect key storage location. This is useful for generating man
512 pages or help text.
513
514 @param app_name The application name
515 @param service The service name
516 @return A formatted documentation string
517
518 {b Included Information:}
519 - How XDG_CONFIG_HOME affects key storage location
520 - Application-specific overrides
521 - File location examples
522
523 {b Example:}
524 {[
525 let env_section = env_docs ~app_name:"myapp" ~service:"immiche" ()
526 ]} *)
527 val env_docs : app_name:string -> service:string -> unit -> string
528end