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 = trueis refused: writepages = "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 againstmodules/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). Alang/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, andmedia = "manage"(not"read"). Ask for one only when the module needs it; people see the sentence before it runs. gansey mod addshows what a module asks for and asks before installing it: scripts add--yes, and without a terminal the command refuses.- An assistant's
install_modulerefuses a package that asks for a trusted permission, and so doesPOST /api/modules/installgiven a package or an address: set it aside withPOST /api/modules/fetchand 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 aremembers.*andoptimisation.*, and a module that namedaccountsorperformancenames 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 inreplaces, even if it was never on the site. Whether the hub signed a module is kept with its record, and a module's ownsourceno 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 moduleprovides = ["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(wasconsent.banner), its hookcookies.choices(wasconsent.choices) and its browser eventcookies.changed(wasconsent.changed): core's names sit under core's owncookiesprefix, socookiesis no longer a module id, andconsent.*is the Consent module's alone. A banner saysprovides = ["cookies.banner"]; a script listens withgansey.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.paymentand Shop Pro'sshop-pro.subscription.change,shop-pro.subscriptions.resendandshop-pro.subscription.reportarepayments.checkouts,payments.checkout.start,payments.checkout.confirm,payments.refund,payments.report,payments.subscription.change,payments.subscriptions.resendandpayments.subscription.report;shop.delivery.options,.points,.shipand.statusaredelivery.options,delivery.points,delivery.shipanddelivery.status. Payloads are unchanged and now in the roles reference. A role's call goes back to the module that asked: pass it asctx.hooks.call(name, payload, { module })(see Roles). A filler listening on the old names is never asked (gansey mod checkwarns), andpaymentsanddeliveryare no longer free as module ids. - A module's own role may not hold a hook in core's catalogue, and
ctx.events.emitrefuses core's event names. consent_banner = trueis a real, trusted permission, for the one module that fillscookies.bannerand registers withctx.consent.banner. Other modules ask core (ctx.consent.allowed), not the banner module's own hooks (see Consent categories).- Listening to
user.*events andemail.sentneedsread_users(orwrite_users, orauth). ctx.env(name)readsGANSEY_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?)andctx.http.post(url, body, options?): headers go in the options, andgethas no second argument. Changectx.http.get(url, null, headers, { timeout: 15 })toctx.http.get(url, { headers: headers, timeout: 15 }), andctx.http.post(url, body, headers, opts)toctx.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 whenhttpnames its host ("127.0.0.1");"*"no longer reaches it. ctx.email.send'sheadersand anemail.sendinglistener's answer refuse theResent-,Content-,DKIM-,ARC-,Authentication-Results,Disposition-Notification-To,Return-Receipt-ToandErrors-Tokinds.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_parsewith 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)andfloat(text)answer the number, notOk(number); text that is not a number still answersErr. Drop the.unwrap()after them, or keep?, which now passes a plain value."3".to_int()is unchanged.format_dateandparse_datetake 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_datereadsYYYY-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'svisibility, and changing or deleting a live entry or a live page's settings answerErr; 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 apublish.requestedrefusal or hold keeps the entry a draft, and theErrsays why. ctx.pages.list()takes{ status }and lists every page of that status, pages not for everyone included, each with all its settings,published,visibilityandvisible_roles.ctx.content.entriesand.relatedgive each entry itsvisibilityandvisible_rolesand include entries not for everyone: a module that shows them to visitors leaves those out itself.ctx.site.exportleaves other modules' definition rows out, andctx.site.importrefuses row files and a seed'smeta, unless the module holdsmodule_data.ctx.users.set_fieldsanswers only the values the module may fill;ctx.users.updaterefuses to rename a staff account.ctx.tasks.start'susermust 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):
insertandupsertanswerErronce the module's tables reach 1 GB, new files once its stored files reach 2 GB,ctx.queue.pushonce 10,000 jobs wait, andctx.tasks.startonce 20 tasks are open; raise them under[limits]if the module needs more.ctx.db.findwith alimitover 1,000 (or the module's[limits] rows) answersErrnaming 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 withctx.db.each, page withoffsetorctx.db.page, or raiserows(at most 10,000). - An OAuth provider's
authorize_urlmust behttpson one of the module'shttphosts, as its token and revoke addresses were. - Assistant tools: arguments are held to the
minimum,maximum,minLengthandmaxLengththe tool declares, and a bound that is not a number is refused atctx.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.htmlhad 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 inpage.html. - A
page.saving,block.saving,part.savingortheme.savinganswer that adds or changes custom code or CSS is left out unless the module holdscode = "write". - A
requestlistener no longer hearsauthorizationorproxy-authorization. accounts.profile_fieldsis 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.uploadingrefusal); 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/…needsedit_pages(upload_mediafor media).- (M18d)
GET /api/site/codeanswersconsent_banner(the name of the module chosen forcookies.banner, or null) in place ofconsent_modules: held snippets are run by the consent banner, not by every module withcode = "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_historyin 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_settingsorrun_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:assistantsin[permissions]andproonctx.tools.registerare 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.registertakesdestructive,idempotentandopen_world; a changing tool is still destructive unless it says not. A tool's request carriesreq.assistant.areasand.tools, andreq.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 markedmanage_site.ctx.roles.save_staffworks only in a changing tool markedmanage_roles. New:ctx.tools.guide,ctx.tools.prompt(offered to every assistant), theassistants.overviewhook (any module with it incallsmay ask it),ctx.modules.list()'sassistant_areas, the trustedmanage_moduleswithctx.modules.enableand the rest, the trustedstaff_roleswithctx.roles.save_staff,ctx.modules.has's{ signed: true }andsignedonctx.modules.list(),ctx.site.sync, andsave_type'sfor_site(see Tools for assistants). gansey mod new <id>makes the module in a folder of the current directory (--dirto choose), with a health check, a command, a test and editor support files; Modules → Create organsey 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 guesseslocalfrom 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'slocalis false (dev sites too) and the first run asks for its setup code. Test-site features should read the newctx.site.local_install, not the site's address: a live site behind a proxy with no public address also hasctx.site.urlon127.0.0.1. Developers who relied on the guess setGANSEY_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 withgansey_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:writeandcode:writeact as View), and no custom code in a page sent withpages: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 owncallswithout 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 sentprivate, no-cachewithnoindex(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.