Modules

Upgrading to 1.10

Core 1.10 widens what modules can do, and changes some things a 1.9 module relies on. Core 1.10 is the only core offered from its release, and older module versions are withdrawn with it, so nothing below keeps the old way working: change the module and release it for 1.10. A module for 1.10 says gansey = ">=1.10.0" (">=1.10.0-beta.1" while trying it on a pre-release, see The manifest).

The changes are in the order a module meets them: reading its manifest, being installed, starting, the calls it makes, the hooks it hears, what its screens and callers see, and the site around it. gansey mod check <folder> and gansey mod test <folder> --core 1.10.0 find most of them.

Reading the manifest

  • pages = true is refused: write pages = "read" (or "write", see Writing the site).
  • A key under [permissions] core has no words for is refused (1.9 ignored it): check the spelling against modules/reference/permissions.md.
  • A module's id may not be one of the words core's hooks start with (media, page, publish, search, module, user, email, entry, settings, panel, oauth, task, data, payments, delivery, …), nor one of the site's token scopes (definitions, pages, content, structure, design, site, code): rename such a module.
  • [limits] is new, and a value over core's ceiling is refused (see Limits). A lang/ file that does not read, or is not named by a language code, stops the module loading (see Translations).

Being installed

  • More permissions are trusted, shown first and marked at install: publish, mail, intercept, write_users, consent_banner, module_data, and media = "manage" (not "read"). Ask for one only when the module needs it; people see the sentence before it runs.
  • gansey mod add shows what a module asks for and asks before installing it: scripts add --yes, and without a terminal the command refuses.
  • An assistant's install_module refuses a package that asks for a trusted permission, and so does POST /api/modules/install given a package or an address: set it aside with POST /api/modules/fetch and install it with the token, as the admin does.
  • A module may change its id with replaces = "<old id>" (see A module that replaces another): installing it moves the old module's data to it and removes the old module. The official Accounts and Accounts Pro are now Members (members, members-pro), and Performance and Performance Pro are Optimisation (optimisation, optimisation-pro): their hooks are members.* and optimisation.*, and a module that named accounts or performance names the new id. Visitors' addresses and wording (/account, "Your account") are unchanged.
  • (M18d) A module that replaces another takes it over only in the load that follows its own install (the Modules screen, gansey mod add, a pin): one copied into the modules folder by hand, or reloaded, doesn't load while the old module is still there with its data. The old id is refused beside a module that names it in replaces, even if it was never on the site. Whether the hub signed a module is kept with its record, and a module's own source no longer decides who may take it over (see A module that replaces another).

Starting

  • Roles (see Roles). A payment module says provides = ["payments.checkout"] and a delivery module provides = ["delivery.carrier"]; their hooks reach only modules that fill the role and that the site has chosen, and only modules naming the role ("role:…" in [requires] or [optional]) may ask them. Naming one of the roles core asks itself (mail.transport, search.engine, cookies.banner, media.source) gives a module no right to ask their hooks. It may list who fills one (ctx.providers.list), so a module can tell whether a consent banner runs (M18d).
  • (M18d) The consent banner's role is cookies.banner (was consent.banner), its hook cookies.choices (was consent.choices) and its browser event cookies.changed (was consent.changed): core's names sit under core's own cookies prefix, so cookies is no longer a module id, and consent.* is the Consent module's alone. A banner says provides = ["cookies.banner"]; a script listens with gansey.on("cookies.changed", fn); a module naming the role writes "role:cookies.banner". ctx.consent.* is unchanged. A site's choice of banner is made again (the one banner is chosen by default).
  • The payment and delivery hooks are named after their role (RH): shop.checkouts, shop.checkout.start, shop.checkout.confirm, shop.refund, shop.payment and Shop Pro's shop-pro.subscription.change, shop-pro.subscriptions.resend and shop-pro.subscription.report are payments.checkouts, payments.checkout.start, payments.checkout.confirm, payments.refund, payments.report, payments.subscription.change, payments.subscriptions.resend and payments.subscription.report; shop.delivery.options, .points, .ship and .status are delivery.options, delivery.points, delivery.ship and delivery.status. Payloads are unchanged and now in the roles reference. A role's call goes back to the module that asked: pass it as ctx.hooks.call(name, payload, { module }) (see Roles). A filler listening on the old names is never asked (gansey mod check warns), and payments and delivery are no longer free as module ids.
  • A module's own role may not hold a hook in core's catalogue, and ctx.events.emit refuses core's event names.
  • consent_banner = true is a real, trusted permission, for the one module that fills cookies.banner and registers with ctx.consent.banner. Other modules ask core (ctx.consent.allowed), not the banner module's own hooks (see Consent categories).
  • Listening to user.* events and email.sent needs read_users (or write_users, or auth).
  • ctx.env(name) reads GANSEY_MODULE_<ID>__<NAME>, two underscores before the name (1.9 read one). Hosts that set such variables rename them.

The calls it makes

  • ctx.http.get(url, options?) and ctx.http.post(url, body, options?): headers go in the options, and get has no second argument. Change ctx.http.get(url, null, headers, { timeout: 15 }) to ctx.http.get(url, { headers: headers, timeout: 15 }), and ctx.http.post(url, body, headers, opts) to ctx.http.post(url, body, opts.merge({ headers: headers })). The old forms answer an error saying so.
  • An OAuth provider on this machine (token, revoke, ctx.oauth.client_token) is reached only on a local install and only when http names its host ("127.0.0.1"); "*" no longer reaches it.
  • ctx.email.send's headers and an email.sending listener's answer refuse the Resent-, Content-, DKIM-, ARC-, Authentication-Results, Disposition-Notification-To, Return-Receipt-To and Errors-To kinds.
  • csv_stringify(…, { escape_formulas: true }) also escapes text that starts with spaces or a line break and then a formula sign, and the full-width signs; csv_parse with the option reads them back.
  • Once a redirect leaves the first origin, only harmless headers go on (accept…, content-type, content-language, user-agent, cache-control, if-none-match, if-modified-since, range): a key in a header of the service's own naming is not sent to the new host.
  • int(text) and float(text) answer the number, not Ok(number); text that is not a number still answers Err. Drop the .unwrap() after them, or keep ?, which now passes a plain value. "3".to_int() is unchanged.
  • format_date and parse_date take a time zone (UTC when left out, as before) and RFC 3339 strings; an unknown % code stops the call rather than failing the whole request; parse_date reads YYYY-MM-DD HH:MM.
  • Publishing needs the trusted publish = true (see Writing the site and Saving and publishing). Without it everything a module writes is a draft: ctx.content.publish/unpublish, status: "published", an entry's visibility, and changing or deleting a live entry or a live page's settings answer Err; so does changing a content type the site owns, an existing taxonomy, or an existing term. With it, publishing goes the admin's way: a missing required field, a design check or a publish.requested refusal or hold keeps the entry a draft, and the Err says why.
  • ctx.pages.list() takes { status } and lists every page of that status, pages not for everyone included, each with all its settings, published, visibility and visible_roles. ctx.content.entries and .related give each entry its visibility and visible_roles and include entries not for everyone: a module that shows them to visitors leaves those out itself.
  • ctx.site.export leaves other modules' definition rows out, and ctx.site.import refuses row files and a seed's meta, unless the module holds module_data.
  • ctx.users.set_fields answers only the values the module may fill; ctx.users.update refuses to rename a staff account.
  • ctx.tasks.start's user must be an account on the site, and its payload 1 MB at most (keep larger data in a stored file and pass its id).
  • Limits (see Limits): insert and upsert answer Err once the module's tables reach 1 GB, new files once its stored files reach 2 GB, ctx.queue.push once 10,000 jobs wait, and ctx.tasks.start once 20 tasks are open; raise them under [limits] if the module needs more. ctx.db.find with a limit over 1,000 (or the module's [limits] rows) answers Err naming the cap; until 1.10 it was cut to 1,000 without a word, so a module asking for more silently missed rows. Walk every row with ctx.db.each, page with offset or ctx.db.page, or raise rows (at most 10,000).
  • An OAuth provider's authorize_url must be https on one of the module's http hosts, as its token and revoke addresses were.
  • Assistant tools: arguments are held to the minimum, maximum, minLength and maxLength the tool declares, and a bound that is not a number is refused at ctx.tools.register.
  • media = "manage" also covers importing, changing and deleting library items and folders and the host pages load media from; media = "read" also reads an item's bytes (ctx.media.read).

The hooks it hears

  • Listeners run in priority order (default 100, ties by module id), not by module id alone. A filter or gate listener on a visitor's path has 250 ms (page.html had 10 s), elsewhere 10 s, then it is skipped; one that runs over three times in a minute is left out for the next minute. Do heavy work ahead of time, not in page.html.
  • A page.saving, block.saving, part.saving or theme.saving answer that adds or changes custom code or CSS is left out unless the module holds code = "write".
  • A request listener no longer hears authorization or proxy-authorization.
  • accounts.profile_fields is gone: profile fields are core's (see People: capabilities, profile fields and accounts), and Accounts keys staff-made values by their full key.
  • A gate that matters more than the site carrying on (a review, a virus scanner) may listen with { fail: "closed" } (see Saving and publishing).

What screens and callers see

  • Saving a module's settings answers 422 with fields (was 400) for a value that does not fit its type.
  • Publish routes may answer 422 (a module refused) or 409 (waiting to publish), and media uploads and replacements 422 (a media.uploading refusal); an upload may be kept under another name or with alt text a module chose.
  • A screen on a page section is as tall as its content and the admin page scrolls: drop height: 100vh. Links to admin addresses in a screen move the admin rather than opening a tab.
  • GET /api/record-meta/… needs edit_pages (upload_media for media).
  • (M18d) GET /api/site/code answers consent_banner (the name of the module chosen for cookies.banner, or null) in place of consent_modules: held snippets are run by the consent banner, not by every module with code = "write".
  • (M18d) Saving a page drops builder values that would land in an id, a style or a class but that the renderer would leave off (an anchor that is not an id, a length with ; or quotes, an unknown hover inside, a background address with whitespace or quotes). A list draws 500 items at most, and a large library picture as its marker gives way to the built-in icon on a long list.
  • A tool's description over 2,000 characters, or a guide's or prompt's title over 200, is refused when registered; two modules' tools or prompts with one assistant name are both left out; a tool in a module's own area marked with one of core's capabilities needs the area that lends it. Removing a module ends what assistants were given in its areas.

The site around it

  • Restoring a backup drops every module's tables before loading it, and backups hold the modules' stored files.
  • Sync says when a replaced module row or theme file keeps no history (replaced_without_history in its outcome): a backup has the earlier version.
  • Assistants: Structure and Code are view-only for every assistant, and core has no tool that changes them, nor save_role, module_settings or run_site_files_sync. A site on 1.9 whose assistants changed those loses that through core's tools; MCP Pro offers it again as tools of its own, in areas each person gives. A connection that held Change on Structure or Code keeps View. There is no unlock: assistants in [permissions] and pro on ctx.tools.register are refused; a module that sells tools with another module registers them only while that one runs (ctx.modules.has, with it under [optional]). Modules may move their tools into areas of their own ([[assistant_areas]], whose ids may not be core's), which people then give separately: a tool that moves is no longer reached through the core area it sat in. An area given by one of core's capabilities lends it to the module's tools there. ctx.tools.register takes destructive, idempotent and open_world; a changing tool is still destructive unless it says not. A tool's request carries req.assistant.areas and .tools, and req.person.capabilities (the person's own, not narrowed). A changing tool's core capability must come from an area given at Change: View on Structure no longer opens a changing tool marked manage_site. ctx.roles.save_staff works only in a changing tool marked manage_roles. New: ctx.tools.guide, ctx.tools.prompt (offered to every assistant), the assistants.overview hook (any module with it in calls may ask it), ctx.modules.list()'s assistant_areas, the trusted manage_modules with ctx.modules.enable and the rest, the trusted staff_roles with ctx.roles.save_staff, ctx.modules.has's { signed: true } and signed on ctx.modules.list(), ctx.site.sync, and save_type's for_site (see Tools for assistants).
  • gansey mod new <id> makes the module in a folder of the current directory (--dir to choose), with a health check, a command, a test and editor support files; Modules → Create or gansey mod add <folder> puts it on a site.
  • A local install has to be said (CA): GANSEY_ENV=local, or a public address on this machine set on purpose. A site that only guesses local from a loopback bind keeps error detail and stays unindexed, but its modules no longer call this machine, core's webhooks no longer reach private addresses, ctx.auth.link's local is false (dev sites too) and the first run asks for its setup code. Test-site features should read the new ctx.site.local_install, not the site's address: a live site behind a proxy with no public address also has ctx.site.url on 127.0.0.1. Developers who relied on the guess set GANSEY_ENV=local. (M18d) A program built on core that skips the server's start-up (an extension binary, a harness) is not a local install unless it says so with gansey_api::env::set_local_install; the module runtime follows the same answer.
  • API tokens made in the admin are limited like assistants (CA): Structure and Code view only at /mcp (structure:write and code:write act as View), and no custom code in a page sent with pages:draft, on any site. A deploy that pushes pages holding custom code uses the site folder (Sync) instead. Tokens for a module's own scopes (ctx.tokens.scope) are unchanged.
  • A role's calls (payments.report, delivery.status) are made only by a running module the site chose for the role (CA): a module that lists one in its own calls without filling the role, or fills it but is switched off for it, gets nothing back.
  • Removing a module removes what it does (CA): pages, entries, sections and widgets kept to a role a module keeps (ctx.roles) open to everyone while that module is switched off or removed, and close again when it runs; people keep the role. A module that needs its content closed whatever happens should not rely on its roles alone. (M18d) While open that way, a page or entry is sent private, no-cache with noindex (its Markdown copy too) and left out of the sitemap; search answers only what is open to everyone now, and what is kept to a module's role is indexed again at start and whenever modules load or go. A module being updated or reinstalled is not off meanwhile.

New, nothing to change

sources, fields, provided_types, meta, intercept, session, builder, capabilities, profile_fields, oauth, [data], module_data and [limits] in the manifest; the media.source role and ctx.media.source; ctx.email.transport for the mail.transport role; ctx.cache.vary, guest sessions, consent categories, module API tokens and browser events; ctx.t and lang/; lifecycle hooks (module.installed, module.upgraded, module.disabled, module.removing), health checks and commands; { name, tz } for ctx.jobs.cron and .daily; tel fields in ctx.ui.form. Each is in its section above.