Modules
Contributed modules, module revisions, and which modules have been run against a real site.
Module Revisions
A module that lives on your machine and not on a git host is uploaded through /modify. Every
upload is a revision, and the site keeps the last five per package (REVISION_RETENTION in
src/ops/module-rev.ts). Each /modify request takes the owner token.
A revision is a manifest of files addressed by the hash of their contents. A second upload sends only the files that changed. A file the site already holds costs nothing to keep, and dropping a revision deletes only the file contents that no surviving revision names.
An upload is three steps: plan, send the blobs, then commit. The table lists every route.
| Action | Route | Method | What it does |
|---|---|---|---|
| Plan | ?action=plan&package=X |
POST | splits declared files into what the site has and what it needs |
| Blobs | ?action=blobs&package=X |
POST | stores the missing file bytes, verified against the hash sent with each |
| Commit | ?action=commit&package=X |
POST | records a revision from a manifest and makes it live |
| Revisions | ?action=revisions&package=X |
GET | the stored revisions, newest first, and which one is active |
| Manifest | ?action=manifest&package=X&rev=Y |
GET | one revision's file list; rev takes active by default |
| Activate | ?action=activate&package=X&rev=Y |
POST | makes a stored revision live again, with no bytes on the wire |
| Rollback | ?action=activate&package=X&rev=previous |
POST | activates the revision before the active one |
| Status | ?action=status |
GET | what is live per package, with its size and age |
| Drop | ?action=drop&package=X&rev=Y |
POST | deletes a revision and the blobs nothing else names |
plan answers the have and want split and the change an apply would make: counts over every
declared file, the removed paths by name, and the rows an apply would charge. A file whose new
bytes have not been sent yet counts as modified and not as a removal, because the site does not
hold those bytes yet.
Commit and activate use the same apply, verify and restore path that a git pull takes. The files are
written in one transaction, and the kernel boots with every enabled module's PHP loaded. A boot that
fails restores the previous file set. A module whose .module no longer parses is rolled back, and
the reply names the parse error. A refused commit or activation returns 409. A refused commit stays
stored, and the revision it replaced stays active. A path another package already owns is reported
as a conflict and not taken (applyRevision() in src/do/modify.ts).
flowchart TD
U[Plan and send the blobs] --> C[Commit records a revision]
C --> A[Apply the files in one transaction]
AC[Activate a stored revision] --> A
RB[Rollback activates the previous revision] --> AC
A --> B[Boot the kernel and load modules]
B -->|boot passes| OK[Revision is active, five kept]
B -->|boot refuses| FL[Previous files restored, prior revision serves]
DR[Drop removes a revision and unreferenced blobs]File contents are verified against the hash they were sent under before they are stored, so a manifest cannot come to name content the site never received. Stored blobs are checked against their hash again before a revision is applied.
Bytes are bounded twice: by MAX_BODY_BYTES per request, and by a 2,199,995-byte cap per file,
since each file is one stored row. A client batches to stay under both.
A commit body may carry autoload, one object or an array, for vendor packages a build delivers:
{"name":"acme/widget","version":"1.4.0","mount":"vendor/acme/widget","autoload":{"psr-4":{...}}}.
The mount must sit under vendor/ or libraries/. The site registers each map with the class loader
before it boots the kernel, and puts the previous row back if the boot refuses. /install does not
fetch a package that is already registered.
A revision may also carry paths under core/, which shadow the packed files at the next boot. A
changed core/**/*.yml drops cache_container and cache_discovery first, so the verifying boot
rebuilds them.
A requirement on a virtual package such as psr/log-implementation is met by the shipped lock's
provide and replace entries, and is not fetched.
Contrib Modules
Installing a module works on a deployed site, with the module present in core.extension afterwards
on 6 of 6 attempts. The cache is cold for one visit while the pages the install invalidated are
re-rendered.
Every contrib module has one of three states: verified, untested or blocked. A module is verified
only when the gate enabled it against a real site and asserted an observable the module owns.
Nothing reaches that state by inspection. A blocked row says which capability is missing.
moduleTable() in src/ops/module-table.ts is the census the lists below are rendered from.
Contrib is a development dependency. The shipped pack carries four modules (admin_toolbar,
ctools, pathauto, token). The rest are verified against a test build and installed by a site
that asks for them, so a new site stays small.
Installing One
Three owner-token routes, in the order a site uses them. Each takes Authorization: Bearer <owner token>, the token /firstrun mints when the site is claimed.
| Route | What it does |
|---|---|
/installable?module=X |
resolves the package and names any conflict with the shipped lock |
/install?module=X |
fetches it and writes its files; runs the check first unless force=1 |
/enable?module=X |
installs it through Drupal's own module installer |
drupal/* resolves against packages.drupal.org/8, and everything else against
repo.packagist.org. Resolution is registry first. It covers a composer graph's replace, stability
flags, branch aliases, branch archives, metapackages and virtual packages. Composer plugins are
skipped. Installing does not enable a module. The two are separate steps, because a package that
lands is not yet a module Drupal knows about.
A module you wrote yourself arrives through a git remote or through /modify. A git pull fetches
one commit at depth one and boots the kernel against the new tree, rolling back if it fatals. See
Git Remotes and Module Revisions. The same /enable step
applies afterwards.
Verified
access_unpublished, address, admin_toolbar, backup_migrate, better_exposed_filters, captcha, cas, coffee,
colorbox, config_ignore, config_update, contact_storage, crop, csv_serialization, ctools, devel,
diff, easy_breadcrumb, editor_advanced_link, editoria11y, embed, entity, entity_browser,
entity_clone, entity_embed, entity_reference_revisions, externalauth, facets,
features, field_group, filefield_sources, focal_point, gin, gin_toolbar,
google_analytics, google_tag, honeypot, imageapi_optimize, imageapi_optimize_binaries,
imce, inline_entity_form, jquery_ui, jquery_ui_autocomplete, jquery_ui_datepicker,
jquery_ui_menu, jquery_ui_slider, jquery_ui_touch_punch, json_field, key,
layout_builder_restrictions, libraries, link_attributes, linkit, mailsystem,
media_library_form_element, memcache, menu_block, metatag, metatag_search_gov,
migrate_plus, migrate_source_csv, migrate_tools, module_filter, openid_connect,
paragraphs, password_policy, pathauto, purge, queue_ui, rabbit_hole, recaptcha, redirect, redis,
samlauth, scheduler, schema_metatag,
search_api, search_api_solr, seckit, simple_sitemap, simplesamlphp_auth, smtp, stage_file_proxy, strata, svg_image,
token, twig_tweak, usfedgov_google_analytics, uswds_base, video_embed_field,
views_bulk_operations, views_data_export, webform, xmlsitemap.
uswds_base is a theme and installs through theme_installer and not the module installer.
strata (Packagist earth-app/strata) is verified for what the run asserted: it enables, its
services, config and routes appear, and its own cipher seals and opens a frame under a key the module
created, refuses tampered frames, and matches a frame sealed by native libsodium. Its
strata_sodium status row reads Available, because extension_loaded('sodium') is true here: the
sodium stand-in covers the whole ext-sodium API and the conformance gate holds it to native
libsodium.
Strata against an S3-compatible store was run on wrangler dev with SeaweedFS in a container, the
same API Cloudflare R2 answers. The requests go through the parked fetch, and a loopback endpoint
needs OUTBOUND_GUARD=0, which exists for rigs. A seal stored its frames in the bucket and a
restore drill passed. Deleting a stored object made the drill fail with frame.missing, flipping a
byte inside a frame made it fail with frame.aead_fail, and restoring the object cleared both. Eight
of strata's 21 tripwire classes were tripped and cleared this way; the other 13 were not exercised.
On a corpus of 32 prose nodes (75,383 bytes), the codec call ratio was 2.44 for zstd level 3 and 3.06
for brotli level 11 (n=36 calls), and the whole seal stored 1.52 to 1.81 times fewer bytes than it
sealed, one run per arm. Treat the ratios as one corpus, not a spread.
Strata's scheduled work does not run in full. From cron, capture, reconcile, flush and keyspace
discovery run against the store, and the chain then ends silently inside the dictionary training
stage, so compaction, the scheduled restore drill and telemetry do not run. The cause is a parked
call inside a generator: CommitLog::walk() yields commits and reads the store inside its body, and
the park resumes the chain but the request ends without output. Fixing that is a change to the
park extension in phasm. Until then, run a drill by hand and read the engine status and the
watchdog rows, because strata records a stage as started before it runs.
Strata's codecs are zstd and brotli through ext-zstd and ext-brotli, which this runtime does
not load. Both are shimmed over node:zlib: zstd_compress(), zstd_uncompress(), the _dict
pairs and brotli_compress(), brotli_uncompress() with their constants. extension_loaded() stays
false for both, since the streaming API is not shimmed. In a clone with strata enabled, the registry
lists zstd and brotli as available.
Gap against native: ext-brotli 0.21.0 takes a preset dictionary as the fourth argument of
brotli_compress() and the second of brotli_uncompress(), and a dictionary frame decodes only
with that dictionary. The shim refuses a dictionary with a warning, because node:zlib takes none.
Strata's brotli codec reports dictionary support unconditionally, so a dictionary write fails on this
runtime. Measured 2026-11-02 against the local PHP 8.5.7 with ext-brotli 0.21.0.
The sodium stand-in is complete except for password hashing. crypto_pwhash and crypto_pwhash_scryptsalsa208sha256 refuse work above a fixed cap, so a caller asking for more gets a SodiumException with the message internal error, the message libsodium gives for a failed allocation. The cap is 64 MiB of memory (PWHASH_MEMORY_CAP in src/site/shims/sodium-lib.ts), 4 Argon2 passes over 64 MiB (PWHASH_WORK_CAP), and scrypt N * r * p up to 2^21 (SCRYPT_WORK_CAP). The isolate has about 20 MiB spare on a large site and a Thunder-sized budget, so a 64 MiB arena is the most a single hash may take. Constants above the cap are refused: MEMLIMIT_MODERATE (256 MiB) and MEMLIMIT_SENSITIVE (1 GiB), the moderate and sensitive OPSLIMIT values, and scrypt sensitive (N of 2^20 at r 8 is 1 GiB). crypto_pwhash_str_verify and crypto_pwhash_scryptsalsa208sha256_str_verify return false past the cap rather than throwing. The interactive profiles fit: Argon2 at 64 MiB with 2 passes, and scrypt interactive at 16 MiB.
simplesamlphp_auth needs phpmailer 7 through SimpleSAMLphp 2.5, and smtp needs phpmailer 6, so the
two cannot share a composer lock. The contrib lane installs it in its own phase after smtp has
been removed. It is verified for a real login and logout against Keycloak: e2e/saml.spec.ts drives
an SP-initiated login, the ACS, the account and its authmap row against the NameID, a cookie-less
request staying anonymous afterwards, and Drupal's logout ending both the Drupal session and the
provider's. An altered assertion, a replayed assertion and an empty ACS call each leave the visitor
anonymous. The AuthnRequest is signed with the SP key, and the client refuses one whose signature has
been removed. Assertions are encrypted, with RSA-OAEP key transport under a SHA-1 digest, the only
transport SimpleSAMLphp's legacy decrypt path can unwrap. An IdP that sends RSA-OAEP with a SHA-256
digest, which is Keycloak's default, is not covered: that path cannot decrypt it even on native PHP.
A site needs one line of SimpleSAMLphp configuration, because none of SimpleSAMLphp's own stores can
run here (no PDO driver, no Predis, no memcache) and the module refuses phpsession:
'store.type' => 'Drupal\drupflare\SimpleSaml\DatabaseStore',It keeps sessions, AuthnRequest state and assertion ids in the site database. A site also supplies
SimpleSAMLphp's own configuration directory, loaded with Configuration::setConfigDir() from a small
module of its own, and its identity provider's metadata. drupflare supplies the rest: the
/simplesaml/module.php endpoints, a switch for SimpleSAMLphp's exit and its raw Location
header, an explicit session save in place of the header_register_callback that never fires here,
and its SAML2 container. SimpleSAMLphp's never-returning redirect methods raise a TypeError that
Drupal logs on every login start. The run has not happened on a deployed worker.
Blocked
Nothing. openid_connect, redis and smtp were here.
smtp sends over the host transport from its own smtp.settings, and nothing in a render waits for
delivery. redis and openid_connect both need an answer inside the request that asked. The PHP
call is suspended, the Worker performs the socket read or the HTTP exchange, and the same call
resumes with the answer. For openid_connect that is the authorization-code POST and the userinfo
GET, so a login completes through the module's own client. For redis, the Durable Object's own
SQLite is still the faster cache backend and the recommended one.
access_unpublished, contact_storage, editoria11y, password_policy and seckit are verified
for a behaviour each, read through the real serve path. A tokened URL shows an unpublished node and
the next visitor without the token does not. An anonymous contact form POST leaves one stored
message. An editor's page carries the checker and a forged report is refused. A 12-character policy
rejects a short password with the module's own message. The module sends a CSP, HSTS and referrer
header and refuses a foreign Origin. The access_unpublished run found a runtime defect, now
fixed in RequestResetter: its token getter kept the last visitor's token. Mail delivery, the
browser checker, user-form validation and the CSP report endpoint were not asserted.
Untested
search_gov_results_api renders results from an API call made while its form is building. That call
completes inside the render that makes it, so a search answers on the first request. No run has
exercised it against a live Search.gov key.