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