Skip to main content

laterite_admin/
routes.rs

1//! Routes a module mounts itself: admin screens, and public endpoints.
2//!
3//! A [`Resource`](crate::Resource) covers list and form screens as data. A
4//! [`Screen`] is the substrate underneath: any routes a module wants, mounted
5//! inside the admin with the same session, permission, CSRF and error handling a
6//! resource gets. Reach for it only when descriptors cannot express the screen.
7//!
8//! A [`PublicRoute`] is the same idea outside the admin, for the endpoints that
9//! have to live at a literal path: `/robots.txt`, a sitemap, a feed, a webhook.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use axum::Router;
15use laterite_auth::{AuditEntry, AuthService, AuthenticatedUser};
16use laterite_core::{Db, Events, Registry, Text};
17
18use crate::form::{FormField, FormHandle, Forms, ValueForm};
19use crate::list::ImportLink;
20
21/// What a contributed route is given at boot.
22///
23/// Deliberately narrow: the admin's own router state stays private, so its
24/// composition can change without breaking a screen. Fields are read through
25/// accessors, so this can gain context after 1.0; `new` cannot, and extra context
26/// would arrive as a builder.
27#[derive(Clone)]
28pub struct RouteCtx {
29    db: Db,
30    auth: AuthService,
31    parameters: laterite_core::parameters::Parameters,
32    cache: laterite_core::Cache,
33    queue: laterite_core::Queue,
34    schedule: laterite_core::Schedule,
35    mail: laterite_core::Mail,
36    upload_extensions: Arc<Vec<String>>,
37    field_types: Arc<crate::field::FieldRegistry>,
38    overrides: Arc<dyn crate::field::OverrideResolver>,
39    crypt: Arc<laterite_core::Crypt>,
40    storage_dir: Arc<str>,
41    base_path: Arc<str>,
42    admin_path: Arc<str>,
43    base_url: Arc<str>,
44    plugin_defined: Arc<Registry>,
45    events: Events,
46    forms: Forms,
47}
48
49impl RouteCtx {
50    #[allow(clippy::too_many_arguments)]
51    pub(crate) fn new(
52        db: Db,
53        auth: AuthService,
54        parameters: laterite_core::parameters::Parameters,
55        queue: laterite_core::Queue,
56        schedule: laterite_core::Schedule,
57        mail: laterite_core::Mail,
58        upload_extensions: Arc<Vec<String>>,
59        field_types: Arc<crate::field::FieldRegistry>,
60        overrides: Arc<dyn crate::field::OverrideResolver>,
61        crypt: Arc<laterite_core::Crypt>,
62        storage_dir: &str,
63        base_path: &str,
64        admin_path: &str,
65        base_url: &str,
66        plugin_defined: Arc<Registry>,
67        events: Events,
68        forms: Forms,
69    ) -> Self {
70        Self {
71            db,
72            auth,
73            cache: parameters.cache().clone(),
74            parameters,
75            queue,
76            schedule,
77            mail,
78            upload_extensions,
79            field_types,
80            overrides,
81            crypt,
82            storage_dir: Arc::from(storage_dir.trim_end_matches('/')),
83            base_path: Arc::from(base_path),
84            admin_path: Arc::from(admin_path),
85            base_url: Arc::from(base_url),
86            plugin_defined,
87            events,
88            forms,
89        }
90    }
91
92    /// Appends one audit entry for a mutation `user` just performed through a
93    /// module's own screen. A failed write is logged and never fails the
94    /// operation it records.
95    pub async fn audit(
96        &self,
97        user: &AuthenticatedUser,
98        action: &str,
99        target_type: Option<&str>,
100        target_id: Option<&str>,
101        target_label: Option<&str>,
102        detail: Option<&str>,
103    ) {
104        let actor = laterite_core::Actor::user(user.user.id, user.user.username.clone());
105        let mut entry = AuditEntry::new(&actor, action);
106        if let (Some(kind), Some(id)) = (target_type, target_id) {
107            entry = entry.target(kind, id, target_label);
108        }
109        if let Some(detail) = detail {
110            entry = entry.detail(detail);
111        }
112        if let Err(e) = self.auth.record_audit(entry).await {
113            tracing::error!(action, error = %e, "failed to write audit log entry");
114        }
115    }
116
117    /// A form over a JSON object from `fields`, rendered and collected through
118    /// the registered field types; `scope` names what an override is scoped to.
119    /// Refused, naming the field, when a type is unregistered or an option
120    /// malformed: the fields may come from data edited at runtime.
121    pub fn value_form(&self, fields: &[FormField], scope: &str) -> Result<ValueForm, Text> {
122        ValueForm::new(
123            fields,
124            self.field_types.clone(),
125            self.overrides.clone(),
126            scope,
127        )
128    }
129
130    /// The operator a request's session cookie belongs to, when it carries a
131    /// live one: how a public route tells an operator from a visitor.
132    pub async fn operator(&self, headers: &axum::http::HeaderMap) -> Option<AuthenticatedUser> {
133        let token = headers
134            .get_all(axum::http::header::COOKIE)
135            .iter()
136            .filter_map(|v| v.to_str().ok())
137            .flat_map(|line| line.split(';'))
138            .filter_map(|pair| pair.trim().split_once('='))
139            .find(|(name, _)| *name == crate::SESSION_COOKIE)
140            .map(|(_, value)| value.trim().to_string())?;
141        self.auth.verify_session(&token).await.ok()
142    }
143
144    /// Counts a URL the site could not serve in the request log, as the panel
145    /// does for its own.
146    pub async fn record_unserved(&self, method: &str, path: &str, status: u16) {
147        crate::requestlog::record(&self.db, method, path, status).await;
148    }
149
150    /// The file extensions an upload may carry (`backend.upload_extensions`,
151    /// else [`laterite_media::sanitize::DEFAULT_EXTENSIONS`]), lowercased. A
152    /// screen that takes files checks a name with
153    /// [`laterite_media::sanitize::extension_allowed`] before storing it.
154    pub fn upload_extensions(&self) -> &[String] {
155        &self.upload_extensions
156    }
157
158    /// The deployment's storage directory (`backend.storage`), where a module
159    /// keeps files when the application registered no disk for it.
160    pub fn storage_dir(&self) -> &str {
161        &self.storage_dir
162    }
163
164    /// Signs `message` under the application key for the `url` purpose: a
165    /// token in a link the route mints (a private file's URL). See
166    /// [`laterite_core::crypt`]; for a token with an expiry or a sealed value,
167    /// use [`crypt`](Self::crypt).
168    pub fn sign(&self, message: &[u8]) -> String {
169        self.crypt.sign("url", message)
170    }
171
172    /// Whether `signature` is [`sign`](Self::sign) of `message`, compared in
173    /// constant time.
174    pub fn verify_signature(&self, message: &[u8], signature: &str) -> bool {
175        self.crypt.verify("url", message, signature)
176    }
177
178    /// The application's encryption and signing, under `app.key`.
179    pub fn crypt(&self) -> &laterite_core::Crypt {
180        &self.crypt
181    }
182
183    pub fn db(&self) -> &Db {
184        &self.db
185    }
186
187    /// The parameters store, read through the cache. See
188    /// [`laterite_core::parameters`].
189    pub fn parameters(&self) -> &laterite_core::parameters::Parameters {
190        &self.parameters
191    }
192
193    /// The application's cache. See [`laterite_core::cache`].
194    pub fn cache(&self) -> &laterite_core::Cache {
195        &self.cache
196    }
197
198    /// The application's queue, to push a job. See [`laterite_core::queue`].
199    pub fn queue(&self) -> &laterite_core::Queue {
200        &self.queue
201    }
202
203    /// The application's schedule. See [`laterite_core::schedule`].
204    pub fn schedule(&self) -> &laterite_core::Schedule {
205        &self.schedule
206    }
207
208    /// The mail service, to send a message. See [`laterite_core::mail`].
209    pub fn mail(&self) -> &laterite_core::Mail {
210        &self.mail
211    }
212
213    /// The settings store, writing through the cache. See
214    /// [`crate::settings::Settings`].
215    pub fn settings(&self) -> crate::settings::Settings {
216        crate::settings::Settings::new(self.db.clone(), self.cache.clone())
217    }
218
219    /// A resource's form, to write its records from code: an importer, a sync,
220    /// anything that saves what an operator could have typed.
221    ///
222    /// `path` is the resource's path as its descriptor declares it, relative to
223    /// the admin root (`/products`). `None` when no resource mounts there or the
224    /// one that does has no form. The handle is `Clone` and owns what it needs,
225    /// so a detached task may keep it.
226    pub fn form(&self, path: &str) -> Option<FormHandle> {
227        self.forms
228            .get(path)
229            .map(|form| FormHandle::new(self.db.clone(), self.cache.clone(), form.clone()))
230    }
231
232    /// The application's event bus, for a route that announces a fact:
233    /// `ctx.events().emit(&Published { article_id }).await`. Clone it into the
234    /// route's state.
235    pub fn events(&self) -> &Events {
236        &self.events
237    }
238
239    /// The site's own origin, without a trailing slash: the configured
240    /// `app.url`, falling back to the bind address when none is declared.
241    ///
242    /// For the absolute URLs a route has to emit rather than link: a `<loc>` in a
243    /// sitemap, a canonical URL, an entry in a feed. The request's `Host` header
244    /// is not a substitute, because behind a proxy it is whatever the proxy sent.
245    pub fn base_url(&self) -> &str {
246        &self.base_url
247    }
248
249    /// The contributions of type `T` that the framework itself does not consume.
250    ///
251    /// This is how one module reads another's contributions. A module declaring
252    /// its own kind of extension point defines a type, other modules contribute
253    /// it from their `register`, and the route reads them here: the framework
254    /// never learns the type, and registration order does not matter because
255    /// everything is collected before any route runs.
256    pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
257        self.plugin_defined.items::<T>()
258    }
259
260    /// Where this screen actually mounted, after the module namespace, any base
261    /// it declared, the deployment's overrides and the admin mount.
262    pub fn base_path(&self) -> &str {
263        &self.base_path
264    }
265
266    /// Where the admin panel is mounted, after `backend.path`. Ask rather than
267    /// assuming `/admin`: an operator may have moved it, and a module building a
268    /// link into the panel, or excluding it from a sitemap, needs the real one.
269    pub fn admin_path(&self) -> &str {
270        &self.admin_path
271    }
272
273    /// An absolute admin URL under this screen. Build every self-link this way:
274    /// a deployment can move the screen, so a hardcoded path would break.
275    pub fn url(&self, relative: &str) -> String {
276        join(&self.base_path, relative)
277    }
278
279    /// A context built by hand, for testing a route without booting the framework.
280    ///
281    /// `mount` takes a `RouteCtx`, and the framework's own constructor is private,
282    /// so without this a plugin could only exercise its routes through a full boot.
283    /// With it, a route is an ordinary `tower` service:
284    ///
285    /// ```ignore
286    /// let ctx = RouteCtx::builder(db).admin_path("/backoffice").build();
287    /// let response = MyRoute.mount(&ctx).oneshot(request).await?;
288    /// ```
289    ///
290    /// This is also how the context grows after 1.0: a new field gets a builder
291    /// method and a default, which `new` could not have offered.
292    pub fn builder(db: Db) -> RouteCtxBuilder {
293        RouteCtxBuilder {
294            db,
295            auth: None,
296            base_path: "/".to_string(),
297            admin_path: "/admin".to_string(),
298            base_url: "http://localhost".to_string(),
299            plugin_defined: None,
300            events: None,
301            cache: None,
302            queue: None,
303            schedule: None,
304            mail: None,
305        }
306    }
307}
308
309/// Builds a [`RouteCtx`]. Every field has a default, so a test names only what it
310/// asserts on.
311pub struct RouteCtxBuilder {
312    db: Db,
313    auth: Option<AuthService>,
314    base_path: String,
315    admin_path: String,
316    base_url: String,
317    plugin_defined: Option<Arc<Registry>>,
318    events: Option<Events>,
319    cache: Option<laterite_core::Cache>,
320    queue: Option<laterite_core::Queue>,
321    schedule: Option<laterite_core::Schedule>,
322    mail: Option<laterite_core::Mail>,
323}
324
325impl RouteCtxBuilder {
326    /// The mail service `RouteCtx::mail` hands out. Defaults to one keeping
327    /// messages in memory.
328    pub fn mail(mut self, mail: laterite_core::Mail) -> Self {
329        self.mail = Some(mail);
330        self
331    }
332
333    /// The schedule `RouteCtx::schedule` hands out. Defaults to one with no
334    /// entry.
335    pub fn schedule(mut self, schedule: laterite_core::Schedule) -> Self {
336        self.schedule = Some(schedule);
337        self
338    }
339
340    /// The queue `RouteCtx::queue` hands out. Defaults to an inline queue
341    /// knowing no job.
342    pub fn queue(mut self, queue: laterite_core::Queue) -> Self {
343        self.queue = Some(queue);
344        self
345    }
346
347    /// The cache `RouteCtx::cache` hands out. Defaults to one process's own.
348    pub fn cache(mut self, cache: laterite_core::Cache) -> Self {
349        self.cache = Some(cache);
350        self
351    }
352
353    /// Where the screen mounted. Defaults to `/`.
354    pub fn base_path(mut self, path: impl Into<String>) -> Self {
355        self.base_path = path.into();
356        self
357    }
358
359    /// Where the panel mounted. Defaults to `/admin`, which is what a route
360    /// excluding the panel should *not* be allowed to assume: set it to something
361    /// else in a test that checks the exclusion is read rather than hardcoded.
362    pub fn admin_path(mut self, path: impl Into<String>) -> Self {
363        self.admin_path = path.into();
364        self
365    }
366
367    /// The site's origin. Defaults to `http://localhost`.
368    pub fn base_url(mut self, url: impl Into<String>) -> Self {
369        self.base_url = url.into();
370        self
371    }
372
373    /// The contributions `RouteCtx::contributions` will read. Defaults to empty.
374    pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
375        self.plugin_defined = Some(registry);
376        self
377    }
378
379    /// The bus `RouteCtx::events` will hand out. Defaults to one nothing
380    /// listens on; pass one built with a listener to assert on what a route
381    /// announces.
382    pub fn events(mut self, events: Events) -> Self {
383        self.events = Some(events);
384        self
385    }
386
387    /// The auth service [`RouteCtx::audit`] writes through. Without one, a
388    /// default service over the same database.
389    pub fn auth(mut self, auth: AuthService) -> Self {
390        self.auth = Some(auth);
391        self
392    }
393
394    pub fn build(self) -> RouteCtx {
395        let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
396        let auth = self.auth.unwrap_or_else(|| {
397            AuthService::new(self.db.clone(), laterite_auth::AuthConfig::default())
398        });
399        let cache = self.cache.unwrap_or_else(laterite_core::Cache::local);
400        let parameters =
401            laterite_core::parameters::Parameters::with_cache(self.db.clone(), cache.clone());
402        let queue = self.queue.unwrap_or_else(|| {
403            laterite_core::Queue::inline(
404                Vec::new(),
405                laterite_core::queue::Services {
406                    db: self.db.clone(),
407                    events: events.clone(),
408                    cache: cache.clone(),
409                    parameters: parameters.clone(),
410                },
411            )
412        });
413        let schedule = self.schedule.unwrap_or_else(|| {
414            laterite_core::Schedule::new(
415                Vec::new(),
416                laterite_core::schedule::Services {
417                    db: self.db.clone(),
418                    events: events.clone(),
419                    cache: cache.clone(),
420                    parameters: parameters.clone(),
421                    queue: queue.clone(),
422                },
423            )
424            .expect("no entry, nothing to refuse")
425        });
426        let mail = self.mail.unwrap_or_else(|| {
427            laterite_core::Mail::new(
428                Arc::new(laterite_core::mail::StubMailer::new()),
429                laterite_core::mail::Sender::from_config(
430                    &laterite_core::config::MailConfig::default(),
431                )
432                .expect("the default sender parses"),
433                laterite_core::mail::Templates::new(self.db.clone(), cache.clone()),
434                queue.clone(),
435                laterite_core::mail::drivers([]),
436                self.db.clone(),
437            )
438        });
439        RouteCtx::new(
440            self.db.clone(),
441            auth,
442            parameters,
443            queue,
444            schedule,
445            mail,
446            Arc::new(crate::upload_extensions(&[])),
447            Arc::new(crate::field::builtin_registry()),
448            Arc::new(crate::field::NoOverrides),
449            Arc::new(laterite_core::Crypt::ephemeral()),
450            "storage",
451            &self.base_path,
452            &self.admin_path,
453            &self.base_url,
454            self.plugin_defined
455                .unwrap_or_else(|| Arc::new(Registry::new())),
456            events,
457            Arc::new(HashMap::new()),
458        )
459    }
460}
461
462fn join(base: &str, relative: &str) -> String {
463    let base = base.trim_end_matches('/');
464    let relative = relative.trim_start_matches('/');
465    if relative.is_empty() {
466        base.to_string()
467    } else {
468        format!("{base}/{relative}")
469    }
470}
471
472/// A module's own admin screen.
473pub trait Screen: Send + Sync + 'static {
474    /// The routes this screen serves, relative to its own base. Handlers capture
475    /// what they need from `ctx`, so the router carries no framework state.
476    fn mount(&self, ctx: &RouteCtx) -> Router;
477}
478
479/// A registered screen: where it mounts, what it is called, and who may reach it.
480///
481/// Built with [`ScreenReg::new`] plus its builder methods. Non-exhaustive so the
482/// framework can learn something new about a screen after 1.0 without a major bump:
483/// anything it needs to know belongs here rather than as another trait method, which
484/// is also what keeps the code half replaceable at the plugin boundary.
485#[non_exhaustive]
486pub struct ScreenReg {
487    /// Path relative to the module's admin base, starting with a slash.
488    pub base_path: String,
489    /// Menu label, localized at render. `None` mounts it without a nav entry.
490    pub nav_label: Option<Text>,
491    /// Where the entry sits: under a section, with an icon, at a position.
492    pub menu: crate::nav::Placement,
493    /// The permission every route under this screen requires. Required, because
494    /// an unguarded screen exposes whatever it serves to any signed-in operator.
495    pub permission: String,
496    pub screen: Arc<dyn Screen>,
497}
498
499impl ScreenReg {
500    pub fn new(
501        base_path: impl Into<String>,
502        permission: impl Into<String>,
503        screen: Arc<dyn Screen>,
504    ) -> Self {
505        Self {
506            base_path: base_path.into(),
507            nav_label: None,
508            menu: crate::nav::Placement::default(),
509            permission: permission.into(),
510            screen,
511        }
512    }
513
514    /// Shows this screen in the menu under `label`.
515    pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
516        self.nav_label = Some(label.into());
517        self
518    }
519
520    /// Where the menu entry sits. See [`Placement`](crate::nav::Placement).
521    pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
522        self.menu = placement;
523        self
524    }
525}
526
527/// A module's own route outside the admin.
528///
529/// Public by definition: no permission gate and no admin chrome, though the
530/// styled error pages still apply. A route that accepts a POST declares its own
531/// stance on CSRF, because the admin's blanket protection assumes a session.
532pub trait PublicRoute: Send + Sync + 'static {
533    /// The routes this endpoint serves, relative to its declared path.
534    fn mount(&self, ctx: &RouteCtx) -> Router;
535}
536
537/// A registered public route.
538///
539/// The path is literal and never namespaced: `/robots.txt` has to be exactly
540/// that. Collisions are therefore likelier than in the admin, so two claims on
541/// one path, or a claim on the admin mount, abort the boot.
542/// Built with [`PublicRouteReg::new`]. Non-exhaustive for the same reason as
543/// [`ScreenReg`].
544#[non_exhaustive]
545pub struct PublicRouteReg {
546    pub path: String,
547    pub route: Arc<dyn PublicRoute>,
548}
549
550impl PublicRouteReg {
551    pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
552        Self {
553            path: path.into(),
554            route,
555        }
556    }
557
558    /// The site itself: the route every URL reaches that no admin route and no
559    /// other public route serves, in place of the panel's own 404. One module
560    /// may register it; a second aborts the boot as a clash.
561    pub fn site(route: Arc<dyn PublicRoute>) -> Self {
562        Self::new("/", route)
563    }
564
565    /// Whether this registration is the site (see [`PublicRouteReg::site`]).
566    pub fn is_site(&self) -> bool {
567        self.path == "/"
568    }
569}
570
571/// A feature a module mounts under every resource whose descriptor declares
572/// its key: the routes behind `import: true`, say.
573///
574/// The admin owns the key and the button; the module owns the routes. A
575/// descriptor declaring a key no module serves aborts boot naming it, so the
576/// admin never depends on the module.
577pub trait ResourceFeature: Send + Sync + 'static {
578    /// The routes, relative to `{resource}/{key}`, which is `ctx.base_path()`.
579    fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
580}
581
582/// A registered feature, keyed by the descriptor key it serves. Mounted under
583/// every resource declaring that key, behind the key's permission or, when the
584/// key names none, the resource's.
585#[non_exhaustive]
586pub struct ResourceFeatureReg {
587    pub key: String,
588    pub feature: Arc<dyn ResourceFeature>,
589}
590
591impl ResourceFeatureReg {
592    pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
593        Self {
594            key: key.into(),
595            feature,
596        }
597    }
598}
599
600/// The resource a feature mounts under, as the feature sees it.
601#[derive(Clone)]
602#[non_exhaustive]
603pub struct ResourceInfo {
604    /// The resource's path as its descriptor declares it, relative to the
605    /// admin root (`/products`).
606    pub path: String,
607    /// Where its list mounted (`/admin/products`).
608    pub base_path: String,
609    /// The resource's menu label.
610    pub label: Text,
611    /// The permission the feature's routes are gated by.
612    pub permission: String,
613    /// The resource's form, to write its records through.
614    pub form: FormHandle,
615}
616
617impl ResourceInfo {
618    pub(crate) fn new(
619        path: String,
620        base_path: String,
621        label: Text,
622        permission: String,
623        form: FormHandle,
624    ) -> Self {
625        Self {
626            path,
627            base_path,
628            label,
629            permission,
630            form,
631        }
632    }
633}
634
635/// The link a list shows to a feature mounted under it.
636pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
637    ImportLink {
638        href: format!("{base_path}/{key}"),
639        permission: permission.to_string(),
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn a_self_link_builds_from_where_the_screen_actually_mounted() {
649        let base = "/admin/rainmill/location/import";
650        assert_eq!(
651            join(base, "/step2"),
652            "/admin/rainmill/location/import/step2"
653        );
654        assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
655        assert_eq!(join(base, ""), "/admin/rainmill/location/import");
656    }
657
658    #[test]
659    fn a_moved_screen_moves_its_links_with_it() {
660        assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
661    }
662
663    #[test]
664    fn a_registration_defaults_to_no_menu_entry() {
665        struct Noop;
666        impl Screen for Noop {
667            fn mount(&self, _ctx: &RouteCtx) -> Router {
668                Router::new()
669            }
670        }
671        let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
672        assert!(reg.nav_label.is_none());
673        assert!(reg.in_menu("Import").nav_label.is_some());
674    }
675}
676
677/// Bytes from lowercase or uppercase hex; `None` on an odd length or a stray
678/// character.
679pub(crate) fn unhex(hex: &str) -> Option<Vec<u8>> {
680    if !hex.len().is_multiple_of(2) || !hex.is_ascii() {
681        return None;
682    }
683    (0..hex.len())
684        .step_by(2)
685        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
686        .collect()
687}
688
689#[cfg(test)]
690mod signing_tests {
691    use super::*;
692
693    #[tokio::test]
694    async fn a_route_signs_for_the_url_purpose_under_its_own_key() {
695        let (db, _guard) = laterite_core::testing::connect_test(&[]).await;
696        let ctx = RouteCtx::builder(db.clone()).build();
697        let sig = ctx.sign(b"media:abc:99");
698        assert!(ctx.verify_signature(b"media:abc:99", &sig));
699        assert!(!ctx.verify_signature(b"media:abc:98", &sig));
700        assert!(!ctx.crypt().verify("token", b"media:abc:99", &sig));
701        let other = RouteCtx::builder(db).build();
702        assert!(!other.verify_signature(b"media:abc:99", &sig));
703    }
704}