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