Skip to main content

apiplant_server/
admin.rs

1//! The admin dashboard: its manifest, and baking a static copy of it.
2//!
3//! The dashboard is embedded in the binary and [served live](crate::run) for
4//! every app, so the common case generates nothing. [`build`] writes the same
5//! files out as a plain directory (`index.html`, `app.js`, `app.css` and a
6//! manifest) for **hosting it somewhere other than the API** — a CDN, a bucket,
7//! a different origin entirely. That copy is never read back by the server:
8//! the running dashboard always describes the running app. Everything it needs
9//! to know about the app — which resources exist, what to call them, which
10//! fields to show, who may see what — is resolved *here*, at build time, and
11//! written into `apiplant-admin.json`. The shipped JavaScript is the same for
12//! every app.
13//!
14//! Two things are kept firmly apart, and it matters:
15//!
16//! * `[permissions]` / a function's `permission` decide what the **API**
17//!   allows. They are enforced by the server on every request.
18//! * `[admin]` decides what an **operator is shown**. It is presentation, and
19//!   this generator treats it as such — hiding a resource here does not protect
20//!   it, and the manifest never carries anything a signed-in caller could not
21//!   already read from the API.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use anyhow::{anyhow, bail, Context, Result};
28use apiplant_abi::{FunctionAccess, HttpMethod};
29use apiplant_core::schema::{
30    is_auth_resource, relation_name, titleize, Access, ContentFormat, Field, FieldType, OnDelete,
31    Resource, Widget,
32};
33use apiplant_core::{Agent, App};
34use serde::Serialize;
35use serde_json::Value;
36
37use crate::auth_routes::VERIFIED_AT_FIELD;
38use crate::functions::FunctionRegistry;
39
40/// Name of the manifest file the dashboard fetches on load.
41pub const MANIFEST_FILE: &str = "apiplant-admin.json";
42
43#[derive(Debug, Clone)]
44pub struct Options {
45    pub api: String,
46    pub out: Option<PathBuf>,
47}
48
49#[derive(Debug, Serialize)]
50struct AdminManifest {
51    title: String,
52    app_name: String,
53    /// URL of the app's own mark, when it configured one.
54    logo: Option<String>,
55    api_base_url: String,
56    docs_url: Option<String>,
57    /// Present only when the dashboard may call the app's AI endpoint to help
58    /// fill text fields.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    ai_assistance: Option<AdminAiAssistanceManifest>,
61    auth: AuthManifest,
62    resources: Vec<ResourceManifest>,
63    functions: Vec<FunctionManifest>,
64    agents: Vec<AgentManifest>,
65    /// Present only in an app that takes money, so the dashboard shows its
66    /// billing screens exactly where the `/billing` routes are mounted and the
67    /// `billing_*` resources exist.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    billing: Option<BillingManifest>,
70}
71
72/// What the dashboard needs to render billing.
73#[derive(Debug, Serialize)]
74struct BillingManifest {
75    /// `stripe`.
76    provider: String,
77    /// Safe to put in a page: it is designed to be.
78    publishable_key: String,
79    /// The currency amounts are quoted in, for formatting a price list.
80    currency: String,
81    /// Whether the amounts shown are before tax — which is what a price list
82    /// has to say out loud, because "€10" meaning "€12 at the till" is the
83    /// single most complained-about thing in software pricing.
84    automatic_tax: bool,
85    /// Whether checkout asks the buyer for a VAT/GST number.
86    tax_id_collection: bool,
87    /// Whether deliveries can be verified. False means checkouts complete and
88    /// nothing is ever recorded — worth saying on screen rather than leaving
89    /// an operator to notice an empty table.
90    webhooks_configured: bool,
91}
92
93#[derive(Debug, Serialize)]
94struct AdminAiAssistanceManifest {
95    prompt_placeholder: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    system: Option<String>,
98}
99
100#[derive(Debug, Serialize)]
101struct AuthManifest {
102    /// The field a person logs in with, and a label to put above the box.
103    identity_field: String,
104    identity_label: String,
105    allow_registration: bool,
106    /// Whether this deployment can send email at all. The three flags below are
107    /// each already `false` without it — this one exists so an interface can
108    /// explain *why* a button is missing ("no email provider is configured")
109    /// rather than silently omitting it.
110    email_enabled: bool,
111    /// Whether a new account must confirm its address before it can sign in, so
112    /// the register form can say what will happen instead of waiting for a
113    /// login to be refused.
114    require_email_verification: bool,
115    /// Whether the team screen may invite somebody who has no account yet.
116    invitations_enabled: bool,
117    /// Whether to offer "forgot your password?".
118    password_reset_enabled: bool,
119    /// Extra fields the register form should collect, so nobody has to type
120    /// JSON to create an account.
121    signup_fields: Vec<FieldManifest>,
122    /// Fields the account screen lets someone edit about themselves.
123    profile_fields: Vec<FieldManifest>,
124    /// Roles seen anywhere in the app's permissions, so role pickers can offer
125    /// real choices instead of a free-text box.
126    known_roles: Vec<String>,
127    /// The third-party sign-ins this deployment offers, in the order they
128    /// should be drawn: `{ provider, label, start_url }` each.
129    ///
130    /// Empty when `[oauth]` names nothing, which is what keeps a sign-in
131    /// screen from offering a button that would land on a 404 — the same
132    /// reason the three email flags above exist.
133    oauth_providers: Vec<OAuthProviderManifest>,
134}
135
136#[derive(Debug, Serialize)]
137struct OAuthProviderManifest {
138    provider: String,
139    /// What the button says.
140    label: String,
141    /// Where the button goes, **relative to `api_base_url`** — like every other
142    /// path in this manifest, so a console pointed at a remote API builds the
143    /// URL the same way it builds all the others.
144    start_url: String,
145    /// False for a provider that releases no address, so a screen can say why
146    /// an account created through it has no email on file.
147    provides_email: bool,
148    /// A logo the app supplied for a provider apiplant does not draw itself —
149    /// `[oauth.<name>] icon`, usually a file in `public/`. Empty for the four
150    /// it does draw, and for one nobody gave an image for.
151    icon: String,
152}
153
154#[derive(Debug, Serialize)]
155struct ResourceManifest {
156    name: String,
157    /// Singular human label ("Purchase order").
158    label: String,
159    /// Collection human label ("Purchase orders").
160    plural: String,
161    /// Sidebar grouping, or `None` for the ungrouped tail.
162    group: Option<String>,
163    order: i64,
164    builtin: bool,
165    /// One of the auth/tenancy resources the dashboard manages with a dedicated
166    /// screen rather than a generic table.
167    auth_resource: bool,
168    /// Whether it belongs in the resource navigation at all.
169    visible: bool,
170    /// Organisation roles that may see it; empty means "anyone who can list it".
171    roles: Vec<String>,
172    scope: &'static str,
173    owner_field: String,
174    /// Field whose value names a record in tables, pickers and headings.
175    display_field: Option<String>,
176    /// Field the list search box filters on.
177    search_field: Option<String>,
178    /// Every field `?search=` looks in — what the dashboard's search box
179    /// actually covers, which may be more than one column.
180    search_fields: Vec<String>,
181    /// Columns for the list table, in order.
182    columns: Vec<String>,
183    fields: Vec<FieldManifest>,
184    /// `belongs_to` edges out of this resource.
185    relations: Vec<RelationManifest>,
186    /// `has_many` edges into it — the record screen lists these inline.
187    children: Vec<ChildManifest>,
188    permissions: ActionPermissionsManifest,
189}
190
191#[derive(Debug, Serialize)]
192struct ActionPermissionsManifest {
193    list: ActionPermissionManifest,
194    read: ActionPermissionManifest,
195    create: ActionPermissionManifest,
196    update: ActionPermissionManifest,
197    delete: ActionPermissionManifest,
198}
199
200#[derive(Debug, Serialize)]
201struct ActionPermissionManifest {
202    value: String,
203    /// The role name when `value` is `role:<name>`, so the UI needn't re-parse.
204    role: Option<String>,
205    note: String,
206    requires_org: bool,
207}
208
209#[derive(Debug, Serialize)]
210struct FieldManifest {
211    name: String,
212    label: String,
213    #[serde(rename = "type")]
214    ty: &'static str,
215    /// The input to render; `auto` lets the interface choose from `type`.
216    widget: &'static str,
217    help: Option<String>,
218    placeholder: Option<String>,
219    /// What the text is: `plain`, `markdown` or `html`. Presentation only —
220    /// the dashboard highlights and previews the markup.
221    format: &'static str,
222    options: Vec<FieldOption>,
223    required: bool,
224    unique: bool,
225    /// Stripped from API responses entirely (a password hash, say).
226    hidden: bool,
227    /// Present in the API but deliberately not shown in the dashboard.
228    admin_visible: bool,
229    readonly: bool,
230    max_length: Option<u32>,
231    references: Option<String>,
232    relation: Option<String>,
233    on_delete: Option<&'static str>,
234    default_value: Option<Value>,
235    /// Whether the dashboard may submit this field on create/update.
236    writable: bool,
237}
238
239#[derive(Debug, Serialize)]
240struct FieldOption {
241    value: String,
242    label: String,
243}
244
245#[derive(Debug, Serialize)]
246struct RelationManifest {
247    field: String,
248    relation: String,
249    target: String,
250    /// Human label for the link ("Customer").
251    label: String,
252    required: bool,
253}
254
255/// A resource that points *at* this one — rendered as a related list on the
256/// record screen, which is what turns a table of foreign keys into something a
257/// non-technical operator can actually navigate.
258#[derive(Debug, Serialize)]
259struct ChildManifest {
260    resource: String,
261    /// The child's field that points here.
262    field: String,
263    label: String,
264}
265
266#[derive(Debug, Serialize)]
267struct FunctionManifest {
268    name: String,
269    label: String,
270    description: String,
271    group: Option<String>,
272    order: i64,
273    method: &'static str,
274    /// Effective access policy, in the shared `[permissions]` grammar.
275    permission: String,
276    role: Option<String>,
277    permission_note: String,
278    requires_org: bool,
279    /// Whether it belongs in the dashboard's action list.
280    visible: bool,
281    roles: Vec<String>,
282    /// Text for the confirmation step, or `None` to run without one.
283    confirm: Option<String>,
284    run_label: String,
285    /// JSON Schema for the request body; the dashboard renders a form from it.
286    input_schema: Option<Value>,
287    output_schema: Option<Value>,
288}
289
290#[derive(Debug, Serialize)]
291struct AgentManifest {
292    name: String,
293    label: String,
294    description: String,
295    scope: &'static str,
296    storage: bool,
297    reasoning_enabled: bool,
298    thread_resource: Option<String>,
299    message_resource: Option<String>,
300    chat: ActionPermissionManifest,
301    history: ActionPermissionManifest,
302    delete_history: ActionPermissionManifest,
303}
304
305/// The `admin { … }` block of a function manifest, as carried over the ABI.
306#[derive(Debug, Default, serde::Deserialize)]
307#[serde(default)]
308struct FunctionAdmin {
309    visible: Option<bool>,
310    roles: Vec<String>,
311    label: Option<String>,
312    group: Option<String>,
313    description: Option<String>,
314    confirm: Option<String>,
315    run_label: Option<String>,
316    order: Option<i64>,
317}
318
319pub fn build(app_dir: &Path, options: Options) -> Result<PathBuf> {
320    let app = App::load(app_dir)?;
321    let api_base_url = normalize_api_base(
322        &options.api,
323        &app.config.server.base_path,
324        app.tls.is_some(),
325    )?;
326    let output_dir = options.out.unwrap_or_else(|| app_dir.join("admin"));
327    let registry = FunctionRegistry::load(&app);
328    // A baked copy has to make the same three claims the running server would,
329    // so the mailer is built here too — for its verdict, not to send anything.
330    let email_enabled = apiplant_email::Mailer::from_config(&app.config.email)
331        .map(|mailer| mailer.is_some())
332        .unwrap_or(false);
333    let manifest = build_manifest(&app, &registry, api_base_url.clone(), email_enabled)?;
334
335    fs::create_dir_all(&output_dir)
336        .with_context(|| format!("failed to create {}", output_dir.display()))?;
337
338    for (relative, _) in apiplant_assets::ADMIN {
339        let path = output_dir.join(relative);
340        if let Some(parent) = path.parent() {
341            fs::create_dir_all(parent)
342                .with_context(|| format!("failed to create {}", parent.display()))?;
343        }
344        let bytes = asset(relative).expect("listed asset");
345        write_bytes(path, &bytes)?;
346    }
347    write_json(output_dir.join(MANIFEST_FILE), &manifest)?;
348
349    Ok(output_dir)
350}
351
352/// One file of the embedded dashboard, ready to serve or write out.
353///
354/// The stylesheet is rewritten on the way past: Vite emits absolute
355/// `url(/head.png)` references, and the dashboard is never at the site root —
356/// it is under `/admin/`, or in a directory someone hosts wherever they like.
357pub fn asset(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
358    use std::borrow::Cow;
359
360    let bytes = apiplant_assets::find(apiplant_assets::ADMIN, path)?;
361    if path.trim_matches('/') == "app.css" {
362        let css = String::from_utf8_lossy(bytes)
363            .replace("url(/head.png)", "url(./head.png)")
364            .replace("url(/head-inverted.png)", "url(./head-inverted.png)");
365        return Some(Cow::Owned(css.into_bytes()));
366    }
367    Some(Cow::Borrowed(bytes))
368}
369
370/// The manifest for an app already loaded by the server, as JSON.
371///
372/// `api_base_url` is the prefix the dashboard puts in front of every request;
373/// served from the app's own origin that is just the API's `base_path`.
374pub fn manifest_json(
375    app: &App,
376    functions: &FunctionRegistry,
377    api_base_url: String,
378    email_enabled: bool,
379) -> Result<String> {
380    let manifest = build_manifest(app, functions, api_base_url, email_enabled)?;
381    Ok(serde_json::to_string(&manifest)?)
382}
383
384fn build_manifest(
385    app: &App,
386    functions: &FunctionRegistry,
387    api_base_url: String,
388    email_enabled: bool,
389) -> Result<AdminManifest> {
390    let app_name = app.display_name();
391    let user = app.resources.get("user");
392    let identity_field = user
393        .and_then(|resource| resource.auth.as_ref())
394        .map(|auth| auth.identity_field.clone())
395        .unwrap_or_else(|| "email".to_string());
396    let password_field = user
397        .and_then(|resource| resource.auth.as_ref())
398        .map(|auth| auth.password_field.clone())
399        .unwrap_or_else(|| "password_hash".to_string());
400    let docs_url = if app.config.docs.enabled {
401        Some(format!("{}{}", api_base_url, app.config.docs.path))
402    } else {
403        None
404    };
405
406    // Functions bound to a resource's lifecycle are machinery, not operator
407    // actions; they never appear as something to "run" even when their
408    // permission would allow it.
409    let hook_functions = app
410        .resources
411        .values()
412        .flat_map(|resource| {
413            resource
414                .hooks
415                .iter()
416                .map(|(_, function)| function.to_string())
417        })
418        .collect::<BTreeSet<_>>();
419
420    // Reverse index: which resources point at each resource, so a record screen
421    // can offer its related lists.
422    let mut children: BTreeMap<String, Vec<ChildManifest>> = BTreeMap::new();
423    for child in app.resources.values() {
424        let references = child.references();
425        for reference in &references {
426            // A tenancy column is plumbing — every org-scoped row has one, and
427            // "this organization → all its products" is not a relationship
428            // anyone wants to browse from the organisation screen.
429            if reference.field == "organization_id" {
430                continue;
431            }
432            if !app.resources.contains_key(&reference.target) {
433                continue;
434            }
435            // When a child points at the same parent twice — an order's billing
436            // *and* shipping address — the resource name alone names both
437            // lists, so the relation has to disambiguate them.
438            let ambiguous = references
439                .iter()
440                .filter(|other| other.target == reference.target)
441                .count()
442                > 1;
443            let label = if ambiguous {
444                format!(
445                    "{} ({})",
446                    child.admin_plural(),
447                    titleize(&reference.relation).to_lowercase()
448                )
449            } else {
450                child.admin_plural()
451            };
452            children
453                .entry(reference.target.clone())
454                .or_default()
455                .push(ChildManifest {
456                    resource: child.meta.name.clone(),
457                    field: reference.field.clone(),
458                    label,
459                });
460        }
461    }
462
463    let resources = app
464        .resources
465        .values()
466        .map(|resource| {
467            resource_manifest(
468                resource,
469                &password_field,
470                children
471                    .remove(resource.meta.name.as_str())
472                    .unwrap_or_default(),
473            )
474        })
475        .collect::<Vec<_>>();
476
477    let mut loaded_functions = functions
478        .iter()
479        .filter(|entry| !hook_functions.contains(entry.manifest.name.as_str()))
480        .map(|entry| function_manifest(&entry.manifest))
481        // A private function has no endpoint at all, so there is nothing for an
482        // operator to run and nothing to show.
483        .filter(|manifest| manifest.permission != "private")
484        .collect::<Vec<_>>();
485    loaded_functions.sort_by(|left, right| {
486        left.group
487            .cmp(&right.group)
488            .then(left.order.cmp(&right.order))
489            .then(left.label.cmp(&right.label))
490    });
491
492    let mut agents = app
493        .agents
494        .values()
495        .map(|agent| agent_manifest(app, agent))
496        .collect::<Vec<_>>();
497    agents.sort_by(|left, right| {
498        left.label
499            .cmp(&right.label)
500            .then(left.name.cmp(&right.name))
501    });
502
503    let signup_fields = user
504        .map(|resource| {
505            resource
506                .fields
507                .iter()
508                .filter(|(name, field)| {
509                    // The identity and password have their own inputs on the
510                    // form. Of the rest, a field is asked for when the model
511                    // says so — `[fields.<name>.admin] signup`, which is how an
512                    // app adds `name` and `surname` to the form without making
513                    // them mandatory — and otherwise when it is `required`,
514                    // since leaving one of those out simply fails the signup.
515                    *name != &identity_field
516                        && *name != &password_field
517                        && field.admin.in_signup(field)
518                        && !field.hidden
519                        && field.admin.visible
520                        && name.as_str() != "organization_id"
521                        && name.as_str() != VERIFIED_AT_FIELD
522                })
523                .map(|(name, field)| field_manifest(name, field, resource))
524                .collect::<Vec<_>>()
525        })
526        .unwrap_or_default();
527
528    let profile_fields = user
529        .map(|resource| {
530            resource
531                .fields
532                .iter()
533                .filter(|(name, field)| {
534                    !field.hidden && field.admin.visible && *name != &password_field
535                })
536                .map(|(name, field)| field_manifest(name, field, resource))
537                .collect::<Vec<_>>()
538        })
539        .unwrap_or_default();
540
541    Ok(AdminManifest {
542        title: format!("{app_name} admin"),
543        app_name,
544        logo: app.config.admin.logo.clone(),
545        api_base_url,
546        docs_url,
547        ai_assistance: admin_ai_assistance_manifest(app),
548        auth: AuthManifest {
549            identity_label: titleize(&identity_field),
550            identity_field,
551            allow_registration: app.config.auth.allow_registration,
552            email_enabled,
553            require_email_verification: app.config.auth.requires_email_verification(email_enabled),
554            invitations_enabled: app.config.auth.invitations_enabled(email_enabled),
555            password_reset_enabled: app.config.auth.password_reset_enabled(email_enabled),
556            signup_fields,
557            profile_fields,
558            known_roles: known_roles(app, functions),
559            oauth_providers: oauth_providers_manifest(app),
560        },
561        resources,
562        functions: loaded_functions,
563        agents,
564        billing: billing_manifest(app),
565    })
566}
567
568/// The sign-in buttons, built from config alone.
569///
570/// Deliberately not built from `apiplant_oauth::Providers`: the manifest is
571/// generated by `apiplant admin` as well as by the running server, and a static
572/// dashboard built on a laptop should describe the same buttons as the
573/// deployment it is built for. Config is what both have.
574fn oauth_providers_manifest(app: &App) -> Vec<OAuthProviderManifest> {
575    app.config
576        .oauth
577        .active_providers()
578        .into_iter()
579        .map(|provider| {
580            let configured = app.config.oauth.providers.get(provider);
581            let builtin = apiplant_oauth::BUILTIN.iter().find(|b| b.key == provider);
582            let label = configured
583                .map(|c| c.label.trim())
584                .filter(|label| !label.is_empty())
585                .map(str::to_string)
586                .or_else(|| builtin.map(|b| b.label.to_string()))
587                .unwrap_or_else(|| titleize(provider));
588            OAuthProviderManifest {
589                start_url: format!("/auth/oauth/{provider}/start"),
590                provides_email: builtin.map(|b| b.provides_email).unwrap_or(true),
591                icon: configured
592                    .map(|c| c.icon.trim())
593                    .unwrap_or_default()
594                    .to_string(),
595                provider: provider.to_string(),
596                label,
597            }
598        })
599        .collect()
600}
601
602fn admin_ai_assistance_manifest(app: &App) -> Option<AdminAiAssistanceManifest> {
603    let assistance = &app.config.admin.ai_assistance;
604    (app.config.ai.enabled() && assistance.enabled).then(|| AdminAiAssistanceManifest {
605        prompt_placeholder: assistance.prompt_placeholder.trim().to_string(),
606        system: (!assistance.system.trim().is_empty())
607            .then(|| assistance.system.trim().to_string()),
608    })
609}
610
611/// Every role named anywhere in the app — resource permissions, function
612/// permissions, `[admin] roles` — so the team screen can offer a dropdown
613/// rather than asking someone to remember how "admin" is spelled.
614/// The billing block, for an app that takes money.
615///
616/// Read from `[payments]` rather than from a built [`Payments`] client,
617/// because this is also called by `apiplant admin` — which generates a
618/// dashboard from a directory, offline, with no keys to connect anything
619/// with. The two agree: a configured provider that cannot be built fails the
620/// boot, so there is no app where one says yes and the other no.
621///
622/// [`Payments`]: apiplant_payments::Payments
623fn billing_manifest(app: &App) -> Option<BillingManifest> {
624    let payments = &app.config.payments;
625    payments.enabled().then(|| BillingManifest {
626        provider: payments.provider.trim().to_ascii_lowercase(),
627        publishable_key: payments.publishable_key.trim().to_string(),
628        currency: payments.default_currency(),
629        automatic_tax: payments.automatic_tax,
630        tax_id_collection: payments.collects_tax_ids(),
631        webhooks_configured: payments.webhooks_enabled(),
632    })
633}
634
635fn known_roles(app: &App, functions: &FunctionRegistry) -> Vec<String> {
636    let mut roles: BTreeSet<String> = BTreeSet::new();
637    // `member` is the role the built-in membership defaults describe, and every
638    // app has one whether or not a permission names it.
639    roles.insert("member".to_string());
640    roles.insert("admin".to_string());
641
642    for resource in app.resources.values() {
643        for access in [
644            &resource.permissions.list,
645            &resource.permissions.read,
646            &resource.permissions.create,
647            &resource.permissions.update,
648            &resource.permissions.delete,
649        ] {
650            if let Access::Role(role) = access {
651                roles.insert(role.clone());
652            }
653        }
654        roles.extend(resource.admin.roles.iter().cloned());
655    }
656    for entry in functions.iter() {
657        if let FunctionAccess::Role(role) = entry.manifest.access() {
658            roles.insert(role);
659        }
660        roles.extend(parse_function_admin(&entry.manifest).roles);
661    }
662    for agent in app.agents.values() {
663        for access in [
664            &agent.permissions.chat,
665            &agent.permissions.history,
666            &agent.permissions.delete_history,
667        ] {
668            if let Access::Role(role) = access {
669                roles.insert(role.clone());
670            }
671        }
672    }
673    roles.into_iter().collect()
674}
675
676fn agent_manifest(app: &App, agent: &Agent) -> AgentManifest {
677    let org_scoped = agent.meta.scope == apiplant_core::Scope::Organization;
678    AgentManifest {
679        name: agent.meta.name.clone(),
680        label: agent.label(),
681        description: agent.meta.description.clone(),
682        scope: if org_scoped { "organization" } else { "global" },
683        storage: agent.meta.storage.enabled,
684        reasoning_enabled: agent.merged_ai_config(&app.config.ai).reasoning,
685        thread_resource: agent
686            .meta
687            .storage
688            .enabled
689            .then(|| agent.thread_resource_name()),
690        message_resource: agent
691            .meta
692            .storage
693            .enabled
694            .then(|| agent.message_resource_name()),
695        chat: permission_manifest(&agent.permissions.chat, org_scoped),
696        history: permission_manifest(&agent.permissions.history, org_scoped),
697        delete_history: permission_manifest(&agent.permissions.delete_history, org_scoped),
698    }
699}
700
701fn resource_manifest(
702    resource: &Resource,
703    password_field: &str,
704    children: Vec<ChildManifest>,
705) -> ResourceManifest {
706    let fields = resource
707        .fields
708        .iter()
709        .map(|(name, field)| field_manifest(name, field, resource))
710        .collect::<Vec<_>>();
711    let relations = resource
712        .references()
713        .into_iter()
714        .filter(|reference| reference.field != "organization_id")
715        .map(|reference| RelationManifest {
716            label: titleize(&reference.relation),
717            field: reference.field,
718            relation: reference.relation,
719            target: reference.target,
720            required: reference.required,
721        })
722        .collect::<Vec<_>>();
723    let org_scoped = resource.is_org_scoped();
724
725    ResourceManifest {
726        label: resource.admin_label(),
727        plural: resource.admin_plural(),
728        group: resource.admin.group.clone(),
729        order: resource.admin.order,
730        builtin: is_builtin_resource(&resource.meta.name),
731        auth_resource: is_auth_resource(&resource.meta.name),
732        visible: resource.admin.is_visible(&resource.meta.name),
733        roles: resource.admin.roles.clone(),
734        scope: if org_scoped { "organization" } else { "global" },
735        owner_field: resource.meta.owner_field.clone(),
736        display_field: resource.admin_display_field(),
737        search_field: resource.admin_search_field(),
738        search_fields: resource.admin_search_fields(),
739        columns: resource
740            .admin_columns()
741            .into_iter()
742            // A password column would never render usefully and, on a resource
743            // that names one, is exactly the thing not to put in a table.
744            .filter(|column| column != password_field || resource.meta.name != "user")
745            .collect(),
746        permissions: ActionPermissionsManifest {
747            list: permission_manifest(&resource.permissions.list, org_scoped),
748            read: permission_manifest(&resource.permissions.read, org_scoped),
749            create: permission_manifest(&resource.permissions.create, org_scoped),
750            update: permission_manifest(&resource.permissions.update, org_scoped),
751            delete: permission_manifest(&resource.permissions.delete, org_scoped),
752        },
753        name: resource.meta.name.clone(),
754        fields,
755        relations,
756        children,
757    }
758}
759
760fn is_builtin_resource(name: &str) -> bool {
761    is_auth_resource(name)
762}
763
764fn field_manifest(name: &str, field: &Field, resource: &Resource) -> FieldManifest {
765    let references = field.references.clone();
766    let relation = references.as_ref().map(|_| relation_name(name).to_string());
767    // The framework stamps the owner and the tenant itself; offering either as
768    // an input invites someone to fill in a value the server will overwrite.
769    let stamped = name == resource.meta.owner_field || name == "organization_id";
770
771    FieldManifest {
772        label: field
773            .admin
774            .label
775            .clone()
776            .unwrap_or_else(|| titleize(name))
777            .to_string(),
778        ty: field_type_name(field.ty),
779        widget: resolve_widget(field),
780        help: field.admin.help.clone(),
781        placeholder: field.admin.placeholder.clone(),
782        format: field.admin.format.as_str(),
783        options: field
784            .admin
785            .options
786            .iter()
787            .map(|option| match option.split_once('|') {
788                Some((value, label)) => FieldOption {
789                    value: value.to_string(),
790                    label: label.to_string(),
791                },
792                None => FieldOption {
793                    value: option.clone(),
794                    label: titleize(option),
795                },
796            })
797            .collect(),
798        required: field.required,
799        unique: field.unique,
800        hidden: field.hidden,
801        admin_visible: field.admin.visible && !field.hidden,
802        readonly: field.admin.readonly,
803        max_length: field.max_length,
804        references,
805        relation,
806        on_delete: field.on_delete.map(on_delete_name),
807        default_value: field.default.clone(),
808        writable: !field.hidden && !field.admin.readonly && !stamped,
809        name: name.to_string(),
810    }
811}
812
813/// Resolve `widget = "auto"` against the field's type, so the interface always
814/// receives a concrete instruction and never has to duplicate this mapping.
815fn resolve_widget(field: &Field) -> &'static str {
816    if field.admin.widget != Widget::Auto {
817        return field.admin.widget.as_str();
818    }
819    if !field.admin.options.is_empty() {
820        return "select";
821    }
822    // Markup needs room and a preview beside it, whatever the column type.
823    if field.admin.format != ContentFormat::Plain {
824        return "textarea";
825    }
826    match field.ty {
827        FieldType::Text => "textarea",
828        FieldType::Boolean => "switch",
829        FieldType::Json => "json",
830        FieldType::Timestamp => "date_time",
831        FieldType::Reference => "reference",
832        FieldType::Integer | FieldType::BigInt | FieldType::Float => "number",
833        FieldType::Uuid => "text",
834        FieldType::String => "text",
835    }
836}
837
838fn permission_manifest(access: &Access, org_scoped: bool) -> ActionPermissionManifest {
839    ActionPermissionManifest {
840        value: access_value(access),
841        role: match access {
842            Access::Role(role) => Some(role.clone()),
843            _ => None,
844        },
845        note: access_note(access, org_scoped),
846        requires_org: org_scoped || matches!(access, Access::Role(_) | Access::Member),
847    }
848}
849
850fn parse_function_admin(manifest: &apiplant_abi::FunctionManifest) -> FunctionAdmin {
851    if manifest.admin.is_empty() {
852        return FunctionAdmin::default();
853    }
854    serde_json::from_str(manifest.admin.as_str()).unwrap_or_default()
855}
856
857fn function_manifest(manifest: &apiplant_abi::FunctionManifest) -> FunctionManifest {
858    let access = manifest.access();
859    let admin = parse_function_admin(manifest);
860    let name = manifest.name.to_string();
861    let label = admin.label.unwrap_or_else(|| titleize(&name));
862
863    FunctionManifest {
864        label: label.clone(),
865        description: admin
866            .description
867            .unwrap_or_else(|| manifest.description.to_string()),
868        group: admin.group,
869        order: admin.order.unwrap_or(0),
870        method: method_name(manifest.method),
871        permission: access.as_string(),
872        role: match &access {
873            FunctionAccess::Role(role) => Some(role.clone()),
874            _ => None,
875        },
876        permission_note: function_access_note(&access),
877        requires_org: matches!(access, FunctionAccess::Role(_) | FunctionAccess::Member),
878        visible: admin.visible.unwrap_or(true),
879        roles: admin.roles,
880        confirm: admin.confirm,
881        run_label: admin.run_label.unwrap_or(label),
882        input_schema: parse_schema(manifest.input_schema.as_str()),
883        output_schema: parse_schema(manifest.output_schema.as_str()),
884        name,
885    }
886}
887
888/// A manifest's schemas are optional and may be malformed (they come from
889/// another language's library). An unreadable one simply means "no form", not a
890/// failed build.
891fn parse_schema(raw: &str) -> Option<Value> {
892    if raw.trim().is_empty() {
893        return None;
894    }
895    serde_json::from_str(raw).ok()
896}
897
898fn access_value(access: &Access) -> String {
899    match access {
900        Access::Public => "public".to_string(),
901        Access::Authenticated => "authenticated".to_string(),
902        Access::Member => "member".to_string(),
903        Access::Owner => "owner".to_string(),
904        Access::Role(role) => format!("role:{role}"),
905        Access::Private => "private".to_string(),
906    }
907}
908
909fn access_note(access: &Access, org_scoped: bool) -> String {
910    if org_scoped {
911        return match access {
912            Access::Private => "Not available.".to_string(),
913            Access::Owner => "Limited to records you created.".to_string(),
914            Access::Role(role) => format!("Needs the {role} role."),
915            _ => "Available to everyone in this organization.".to_string(),
916        };
917    }
918
919    match access {
920        Access::Public => "Available to anyone.".to_string(),
921        Access::Authenticated | Access::Member => "Available once you sign in.".to_string(),
922        Access::Owner => "Limited to records you created.".to_string(),
923        Access::Role(role) => format!("Needs the {role} role."),
924        Access::Private => "Not available.".to_string(),
925    }
926}
927
928fn function_access_note(access: &FunctionAccess) -> String {
929    match access {
930        FunctionAccess::Public => "Anyone can run this.".to_string(),
931        FunctionAccess::Authenticated => "Available once you sign in.".to_string(),
932        FunctionAccess::Member => "Available to everyone in this organization.".to_string(),
933        FunctionAccess::Role(role) => format!("Needs the {role} role."),
934        FunctionAccess::Private => "Not available.".to_string(),
935    }
936}
937
938fn field_type_name(ty: FieldType) -> &'static str {
939    match ty {
940        FieldType::String => "string",
941        FieldType::Text => "text",
942        FieldType::Integer => "integer",
943        FieldType::BigInt => "big_int",
944        FieldType::Float => "float",
945        FieldType::Boolean => "boolean",
946        FieldType::Uuid => "uuid",
947        FieldType::Timestamp => "timestamp",
948        FieldType::Json => "json",
949        FieldType::Reference => "reference",
950    }
951}
952
953fn on_delete_name(on_delete: OnDelete) -> &'static str {
954    match on_delete {
955        OnDelete::Restrict => "restrict",
956        OnDelete::SetNull => "set_null",
957        OnDelete::Cascade => "cascade",
958        OnDelete::NoAction => "no_action",
959    }
960}
961
962fn method_name(method: HttpMethod) -> &'static str {
963    match method {
964        HttpMethod::Get => "GET",
965        HttpMethod::Post => "POST",
966        HttpMethod::Put => "PUT",
967        HttpMethod::Delete => "DELETE",
968    }
969}
970
971fn normalize_api_base(raw: &str, base_path: &str, prefer_https: bool) -> Result<String> {
972    let trimmed = raw.trim();
973    if trimmed.is_empty() {
974        bail!("--api requires a domain or full API URL");
975    }
976
977    let mut url = if trimmed.contains("://") {
978        trimmed.to_string()
979    } else {
980        format!(
981            "{}://{}",
982            if prefer_https { "https" } else { "http" },
983            trimmed
984        )
985    };
986
987    if !url.starts_with("http://") && !url.starts_with("https://") {
988        bail!("--api must resolve to an http:// or https:// URL");
989    }
990
991    let scheme_end = url
992        .find("://")
993        .map(|index| index + 3)
994        .ok_or_else(|| anyhow!("invalid API URL"))?;
995
996    match url[scheme_end..].find('/') {
997        None => {
998            if !base_path.is_empty() {
999                url.push_str(base_path);
1000            }
1001        }
1002        Some(relative_start) => {
1003            let path_start = scheme_end + relative_start;
1004            let path = &url[path_start..];
1005            if path == "/" {
1006                url.truncate(path_start);
1007                if !base_path.is_empty() {
1008                    url.push_str(base_path);
1009                }
1010            } else {
1011                while url.ends_with('/') {
1012                    url.pop();
1013                }
1014            }
1015        }
1016    }
1017
1018    while url.ends_with('/') {
1019        url.pop();
1020    }
1021
1022    Ok(url)
1023}
1024
1025fn write_bytes(path: PathBuf, bytes: &[u8]) -> Result<()> {
1026    fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
1027}
1028
1029fn write_json(path: PathBuf, manifest: &AdminManifest) -> Result<()> {
1030    let bytes = serde_json::to_vec_pretty(manifest)?;
1031    fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use std::time::{SystemTime, UNIX_EPOCH};
1038
1039    fn temp_dir(label: &str) -> PathBuf {
1040        let mut dir = std::env::temp_dir();
1041        let stamp = SystemTime::now()
1042            .duration_since(UNIX_EPOCH)
1043            .unwrap()
1044            .as_nanos();
1045        dir.push(format!(
1046            "apiplant-admin-{label}-{}-{stamp}",
1047            std::process::id()
1048        ));
1049        fs::create_dir_all(&dir).unwrap();
1050        dir
1051    }
1052
1053    fn build_manifest_for(models: &[(&str, &str)]) -> Value {
1054        build_manifest_with_config(
1055            "[server]\nbase_path = \"/api\"\n\n[auth]\nallow_registration = true\n",
1056            models,
1057        )
1058    }
1059
1060    fn build_manifest_with_config(main_toml: &str, models: &[(&str, &str)]) -> Value {
1061        let app_dir = temp_dir("app");
1062        let out_dir = temp_dir("out");
1063        fs::create_dir_all(app_dir.join("models")).unwrap();
1064        fs::write(app_dir.join("main.toml"), main_toml).unwrap();
1065        for (name, src) in models {
1066            fs::write(app_dir.join(format!("models/{name}.toml")), src).unwrap();
1067        }
1068
1069        build(
1070            &app_dir,
1071            Options {
1072                api: "https://example.com".to_string(),
1073                out: Some(out_dir.clone()),
1074            },
1075        )
1076        .unwrap();
1077
1078        let manifest: Value =
1079            serde_json::from_slice(&fs::read(out_dir.join("apiplant-admin.json")).unwrap())
1080                .unwrap();
1081        fs::remove_dir_all(app_dir).unwrap();
1082        fs::remove_dir_all(out_dir).unwrap();
1083        manifest
1084    }
1085
1086    fn resource<'a>(manifest: &'a Value, name: &str) -> &'a Value {
1087        manifest["resources"]
1088            .as_array()
1089            .unwrap()
1090            .iter()
1091            .find(|resource| resource["name"] == name)
1092            .unwrap_or_else(|| panic!("no `{name}` in manifest"))
1093    }
1094
1095    /// The header an operator reads is the app's to choose; the directory it
1096    /// happens to live in is only the fallback.
1097    #[test]
1098    fn app_name_comes_from_config_and_falls_back_to_the_directory() {
1099        let named = build_manifest_with_config(
1100            "[app]\nname = \"Acme Logistics\"\n\n[server]\nbase_path = \"/api\"\n",
1101            &[],
1102        );
1103        assert_eq!(named["app_name"], "Acme Logistics");
1104        assert_eq!(named["title"], "Acme Logistics admin");
1105
1106        // A blank name is not a name: it would render as a header with nothing
1107        // in it, so it falls back like an absent one.
1108        let blank = build_manifest_with_config(
1109            "[app]\nname = \"   \"\n\n[server]\nbase_path = \"/api\"\n",
1110            &[],
1111        );
1112        assert!(blank["app_name"]
1113            .as_str()
1114            .unwrap()
1115            .starts_with("apiplant-admin-app-"));
1116
1117        let unnamed = build_manifest_for(&[]);
1118        assert!(unnamed["app_name"]
1119            .as_str()
1120            .unwrap()
1121            .starts_with("apiplant-admin-app-"));
1122    }
1123
1124    #[test]
1125    fn api_base_uses_app_base_path_when_only_a_domain_is_given() {
1126        assert_eq!(
1127            normalize_api_base("admin.example.com", "/api", true).unwrap(),
1128            "https://admin.example.com/api"
1129        );
1130        assert_eq!(
1131            normalize_api_base("127.0.0.1:8099", "", false).unwrap(),
1132            "http://127.0.0.1:8099"
1133        );
1134    }
1135
1136    #[test]
1137    fn explicit_api_paths_are_preserved() {
1138        assert_eq!(
1139            normalize_api_base("https://example.com/custom/", "/api", true).unwrap(),
1140            "https://example.com/custom"
1141        );
1142        assert_eq!(
1143            normalize_api_base("https://example.com/", "/api", true).unwrap(),
1144            "https://example.com/api"
1145        );
1146    }
1147
1148    #[test]
1149    fn build_writes_static_admin_files_and_manifest() {
1150        let app_dir = temp_dir("files");
1151        let out_dir = temp_dir("files-out");
1152        fs::create_dir_all(app_dir.join("models")).unwrap();
1153        fs::write(
1154            app_dir.join("main.toml"),
1155            "[server]\nbase_path = \"/api\"\n",
1156        )
1157        .unwrap();
1158
1159        let written = build(
1160            &app_dir,
1161            Options {
1162                api: "https://example.com".to_string(),
1163                out: Some(out_dir.clone()),
1164            },
1165        )
1166        .unwrap();
1167
1168        assert_eq!(written, out_dir);
1169        for file in [
1170            "index.html",
1171            "app.js",
1172            "app.css",
1173            "head.png",
1174            "head-inverted.png",
1175            "apiplant-admin.json",
1176        ] {
1177            assert!(out_dir.join(file).exists(), "{file} was not written");
1178        }
1179
1180        fs::remove_dir_all(app_dir).unwrap();
1181        fs::remove_dir_all(out_dir).unwrap();
1182    }
1183
1184    #[test]
1185    fn auth_resources_are_hidden_from_the_resource_navigation_by_default() {
1186        let manifest = build_manifest_for(&[(
1187            "post",
1188            "[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n",
1189        )]);
1190
1191        for name in ["user", "organization", "membership", "api_key"] {
1192            let auth = resource(&manifest, name);
1193            assert_eq!(auth["visible"], false, "{name} should be hidden");
1194            assert_eq!(auth["auth_resource"], true);
1195        }
1196        assert_eq!(resource(&manifest, "post")["visible"], true);
1197        assert_eq!(resource(&manifest, "post")["auth_resource"], false);
1198    }
1199
1200    #[test]
1201    fn admin_section_overrides_labels_columns_and_role_visibility() {
1202        let manifest = build_manifest_for(&[(
1203            "product",
1204            r#"
1205[resource]
1206name = "product"
1207
1208[admin]
1209visible = true
1210roles = ["manager"]
1211label = "Item"
1212plural = "Catalogue items"
1213group = "Catalogue"
1214order = 3
1215display_field = "title"
1216columns = ["title", "status"]
1217
1218[fields.title]
1219type = "string"
1220required = true
1221
1222[fields.status]
1223type = "string"
1224default = "draft"
1225
1226[fields.status.admin]
1227label = "Lifecycle"
1228widget = "select"
1229options = ["draft", "active|Live"]
1230help = "Only live items are sold."
1231
1232[fields.internal_note]
1233type = "text"
1234
1235[fields.internal_note.admin]
1236visible = false
1237"#,
1238        )]);
1239
1240        let product = resource(&manifest, "product");
1241        assert_eq!(product["label"], "Item");
1242        assert_eq!(product["plural"], "Catalogue items");
1243        assert_eq!(product["group"], "Catalogue");
1244        assert_eq!(product["order"], 3);
1245        assert_eq!(product["roles"][0], "manager");
1246        assert_eq!(product["display_field"], "title");
1247        assert_eq!(product["columns"][0], "title");
1248        assert_eq!(product["columns"][1], "status");
1249
1250        let field = |name: &str| {
1251            product["fields"]
1252                .as_array()
1253                .unwrap()
1254                .iter()
1255                .find(|field| field["name"] == name)
1256                .unwrap()
1257        };
1258        let status = field("status");
1259        assert_eq!(status["label"], "Lifecycle");
1260        assert_eq!(status["widget"], "select");
1261        assert_eq!(status["help"], "Only live items are sold.");
1262        assert_eq!(status["options"][0]["value"], "draft");
1263        assert_eq!(status["options"][0]["label"], "Draft");
1264        // `value|Label` splits into an explicit caption.
1265        assert_eq!(status["options"][1]["value"], "active");
1266        assert_eq!(status["options"][1]["label"], "Live");
1267
1268        // Hidden in the dashboard, still part of the API.
1269        assert_eq!(field("internal_note")["admin_visible"], false);
1270        assert_eq!(field("internal_note")["hidden"], false);
1271
1272        // The injected tenancy column is never an input.
1273        assert_eq!(field("organization_id")["writable"], false);
1274        assert_eq!(field("organization_id")["admin_visible"], false);
1275    }
1276
1277    #[test]
1278    fn content_format_reaches_the_manifest_and_forces_a_textarea() {
1279        let manifest = build_manifest_for(&[(
1280            "article",
1281            r#"
1282[resource]
1283name = "article"
1284
1285[fields.body]
1286type = "text"
1287
1288[fields.body.admin]
1289format = "markdown"
1290
1291[fields.summary]
1292type = "string"
1293
1294[fields.summary.admin]
1295format = "html"
1296
1297[fields.slug]
1298type = "string"
1299"#,
1300        )]);
1301
1302        let article = resource(&manifest, "article");
1303        let field = |name: &str| {
1304            article["fields"]
1305                .as_array()
1306                .unwrap()
1307                .iter()
1308                .find(|field| field["name"] == name)
1309                .unwrap()
1310                .clone()
1311        };
1312
1313        assert_eq!(field("body")["format"], "markdown");
1314        assert_eq!(field("body")["widget"], "textarea");
1315        // Markup needs the room even when the column is a plain string.
1316        assert_eq!(field("summary")["format"], "html");
1317        assert_eq!(field("summary")["widget"], "textarea");
1318        assert_eq!(field("slug")["format"], "plain");
1319        assert_eq!(field("slug")["widget"], "text");
1320    }
1321
1322    #[test]
1323    fn admin_ai_assistance_appears_only_when_both_admin_and_ai_are_configured() {
1324        let enabled = build_manifest_with_config(
1325            r#"
1326[server]
1327base_path = "/api"
1328
1329[ai]
1330provider = "openai"
1331api_key = "test"
1332
1333[admin.ai_assistance]
1334enabled = true
1335system = "Return only the field value."
1336prompt_placeholder = "Prompt AI to fill this field"
1337"#,
1338            &[],
1339        );
1340        assert_eq!(
1341            enabled["ai_assistance"]["prompt_placeholder"],
1342            "Prompt AI to fill this field"
1343        );
1344        assert_eq!(
1345            enabled["ai_assistance"]["system"],
1346            "Return only the field value."
1347        );
1348
1349        let no_ai = build_manifest_with_config(
1350            r#"
1351[server]
1352base_path = "/api"
1353
1354[admin.ai_assistance]
1355enabled = true
1356"#,
1357            &[],
1358        );
1359        assert!(no_ai["ai_assistance"].is_null());
1360
1361        let no_admin = build_manifest_with_config(
1362            r#"
1363[server]
1364base_path = "/api"
1365
1366[ai]
1367provider = "openai"
1368api_key = "test"
1369"#,
1370            &[],
1371        );
1372        assert!(no_admin["ai_assistance"].is_null());
1373    }
1374
1375    #[test]
1376    fn labels_and_columns_are_inferred_when_admin_says_nothing() {
1377        let manifest = build_manifest_for(&[(
1378            "purchase_order",
1379            r#"
1380[resource]
1381name = "purchase_order"
1382
1383[fields.name]
1384type = "string"
1385
1386[fields.notes]
1387type = "text"
1388
1389[fields.settings]
1390type = "json"
1391"#,
1392        )]);
1393
1394        let purchase_order = resource(&manifest, "purchase_order");
1395        assert_eq!(purchase_order["label"], "Purchase order");
1396        assert_eq!(purchase_order["plural"], "Purchase orders");
1397        assert_eq!(purchase_order["display_field"], "name");
1398        assert_eq!(purchase_order["search_field"], "name");
1399
1400        // `text` and `json` never read well in a table cell, so they are left
1401        // out of the inferred column set.
1402        let columns: Vec<&str> = purchase_order["columns"]
1403            .as_array()
1404            .unwrap()
1405            .iter()
1406            .map(|column| column.as_str().unwrap())
1407            .collect();
1408        assert_eq!(columns, vec!["name"]);
1409    }
1410
1411    #[test]
1412    fn related_lists_are_derived_from_incoming_references() {
1413        let manifest = build_manifest_for(&[
1414            (
1415                "order",
1416                "[resource]\nname = \"order\"\n\n[fields.number]\ntype = \"string\"\n",
1417            ),
1418            (
1419                "order_line",
1420                r#"
1421[resource]
1422name = "order_line"
1423
1424[fields.order_id]
1425type = "reference"
1426references = "order"
1427required = true
1428
1429[fields.quantity]
1430type = "integer"
1431"#,
1432            ),
1433        ]);
1434
1435        let order = resource(&manifest, "order");
1436        let children = order["children"].as_array().unwrap();
1437        assert_eq!(children.len(), 1);
1438        assert_eq!(children[0]["resource"], "order_line");
1439        assert_eq!(children[0]["field"], "order_id");
1440        assert_eq!(children[0]["label"], "Order lines");
1441
1442        // …and the child knows which way its own reference points.
1443        let line = resource(&manifest, "order_line");
1444        let relation = line["relations"]
1445            .as_array()
1446            .unwrap()
1447            .iter()
1448            .find(|relation| relation["field"] == "order_id")
1449            .unwrap();
1450        assert_eq!(relation["target"], "order");
1451        assert_eq!(relation["label"], "Order");
1452        assert_eq!(relation["required"], true);
1453    }
1454
1455    #[test]
1456    fn known_roles_collect_every_role_the_app_names() {
1457        let manifest = build_manifest_for(&[(
1458            "product",
1459            r#"
1460[resource]
1461name = "product"
1462
1463[permissions]
1464create = "role:buyer"
1465delete = "role:auditor"
1466
1467[fields.name]
1468type = "string"
1469"#,
1470        )]);
1471
1472        let roles: Vec<&str> = manifest["auth"]["known_roles"]
1473            .as_array()
1474            .unwrap()
1475            .iter()
1476            .map(|role| role.as_str().unwrap())
1477            .collect();
1478        assert!(roles.contains(&"buyer"));
1479        assert!(roles.contains(&"auditor"));
1480        // The two roles every app has, whether or not a permission names them.
1481        assert!(roles.contains(&"admin"));
1482        assert!(roles.contains(&"member"));
1483    }
1484
1485    #[test]
1486    fn signup_collects_required_profile_fields_so_nobody_types_json() {
1487        let manifest = build_manifest_for(&[(
1488            "user",
1489            r#"
1490[resource]
1491name = "user"
1492scope = "global"
1493
1494[auth]
1495identity_field = "email"
1496password_field = "password_hash"
1497
1498[fields.email]
1499type = "string"
1500required = true
1501unique = true
1502
1503[fields.password_hash]
1504type = "string"
1505hidden = true
1506
1507[fields.full_name]
1508type = "string"
1509required = true
1510
1511[fields.nickname]
1512type = "string"
1513"#,
1514        )]);
1515
1516        let signup: Vec<&str> = manifest["auth"]["signup_fields"]
1517            .as_array()
1518            .unwrap()
1519            .iter()
1520            .map(|field| field["name"].as_str().unwrap())
1521            .collect();
1522        // Required extras only: the identity and password have their own inputs,
1523        // and an optional field would just be noise on a sign-up form.
1524        assert_eq!(signup, vec!["full_name"]);
1525        assert_eq!(manifest["auth"]["identity_label"], "Email");
1526
1527        // The account screen offers everything editable, including optional
1528        // fields — but never the password hash.
1529        let profile: Vec<&str> = manifest["auth"]["profile_fields"]
1530            .as_array()
1531            .unwrap()
1532            .iter()
1533            .map(|field| field["name"].as_str().unwrap())
1534            .collect();
1535        assert!(profile.contains(&"nickname"));
1536        assert!(!profile.contains(&"password_hash"));
1537    }
1538}