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