8.1 One door, four verbs#
Every module — any tier, organization-private or platform catalog — is a
package that a signed-in user uploads through the console:
/store/:sid/modules/upload for the store's organization,
/platform/modules → Upload to catalog for the platform.
| Verb | What it means | Route (group) | Who | Result |
|---|---|---|---|---|
| Upload | bytes → a checked, immutable version record in the registry; nothing touches any store | POST /system/module/upload (systemRoute, multipart, master-only at the core) |
a platform admin, for either target — clients hand over the zip for now | {key, version, state, report} or 422 {report}; 409 version_exists; 400 store_unassigned |
| Check | the automated pipeline (stages 1–3) and, for the platform catalog, a human review | inside upload; re-run with POST /system/module/check (systemRoute, later phase) |
automatic; platform admins review | ModuleCheckReport + the version state |
| Install (= mount) | bind one published version to one store: token + custody, hook entries, collections, kits / templates / translations, public config |
POST /api/module/install {key, version?, accept_grants: true} (moduleRoute) |
store owner / master |
ModuleInstall with enabled: true |
| Update | move an install to another published version — a diffed re-mount (rollback is the same verb with an older version) |
POST /api/module/update {id, version, accept_grants?} (moduleRoute); POST /system/module/auto-update for update: 'auto' (master, by hand since 2026-10-24) |
store owner / master; the auto-update pass |
the install's version, manifest snapshot and report replaced |
Two words that stay two: a package is checked (the report); an
install is verified (the module's own verify point, verified_at).
Upload targets: target: 'organization' — the owner is re-derived from
store/{sid}.organization (never from the request), the record is
{org}~{id} with visibility: {organizations: [org]} and the version is
published at once, to every store of that organization; target: 'platform' — the record is {id}, visibility 'platform' (default) or
'all', the version goes in_review (stage 4, §7.3). A 'platform' package
is listed and installable for master only: in the console, platform admins
see it in the catalog of every project, store owners never do. 'all' lists
it for every store. The visibility is set when the key is created, and later
versions keep it. Console-side both
upload buttons are gated on platform admin, so the core's 403 Master role required is never the first thing a user sees.
8.2 Install — what mounting does#
POST /api/module/install {key, version?, accept_grants: true}: key is the
registry key the console listed; version defaults to the highest
published (409 version_not_published otherwise); the version must be
visible to the store (else 403); a store already holding a module of that
id from another key gets 409 id_installed. The steps, all-or-nothing by
unwind (three projects are involved, so it is a sequence, not a
transaction), and for connected nothing is stored before the handshake
succeeds:
- Read the version record from the registry — manifest, hashes, limits, report. Never the request.
- Grants. The console shows
access ∩ MODULE_MAX_ACCESSandscoperead-only; the handler requiresaccept_grants: trueand recordsgrants_accepted: {version, by, at}. - Keys and endpoint (
connected): handshake stagekeys(§4.1). - Token and custody: mint, custody, handshake stage
token. - Mount points, in order: (a)
setup/hooksentries; (b)contentcollections + schemas (recordsneed nothing); (c) kits — the element mount of §8.2a: each element →elements/{moduleId}-{elementId}withmodule+origin: {module, version, hash}, written only where the doc is absent or the module's own and pristine (a customized one is skipped, a doc the module does not own is a reported conflict), then thesetup/elementsorder/categoriesappended, then the runtime docelements/{moduleId}_runtime; the outcome lands on the install doc aselements(ModuleElementsReport). The kits'globalcss / js (and the legacyruntimescript) are mounted — into the runtime doc (css+js, §8.2a), served ahead of the element css / js; a kit'sassetsare not mounted. Templates (templates/{id}.{locale}.hbs→data/{locale}/templates/{id}, only where absent), translations (translations/{locale}.json→data/{locale}/translations/{group}, merged — an existing value always wins) follow; (d) the derivedsetup/modulespublic doc; (e) the install docstore/{sid}/modules/{id}— written last, so the gateway never sees a half-mounted module; (f) the install cache bump; then the registry'sinstallsentry. - Verify when
config.verifyis set; the card opens on therequiredfields still empty.
The tier gate runs before step 3: the plan's features.modules must
admit the hosting (403 tier_required) and the store's code-module installs
must be under limits.modules_max (403 modules_limit). Elements modules
are counted apart (two count() reads — every install, and the
hosting == 'none' ones): a library never takes a code-module slot and vice
versa.
An elements module (hosting: 'none', §2.1) installs on every plan —
no modules feature needed — and counts against limits.element_modules_max
(null = unlimited, the committed default on every tier; a full cap is
402 element_modules_limit {plan, limit, count}). Its install is steps 1
and 5c and the install doc only: accept_grants is not required (a passed
true is harmless), there is no runner check, no handshake, no token, no
custody, no setup/hooks entries, no collections and no setup/modules;
the install doc carries no token_id, hook_id or grants_accepted. A
failed mount unwinds only what it wrote — the module's elements and the
install doc — and never touches admin-eur3 or setup/hooks.
What a mounted module is attached by, and what each verb does to it:
| Attachment point | Mounted by | enabled: false |
uninstall |
|---|---|---|---|
| identity — install token + custody | step 4 | kept (calls refused as module_disabled) |
token deleted, custody deleted |
events in — setup/hooks entries module_{id}* |
step 5a | kept, the notifier skips disabled installs | entries removed |
storefront — /m/{id}/** |
nothing to write: the tenant server forwards any /m/{id} whose install is enabled |
404 module_disabled |
404 |
storefront — elements + the runtime doc (origin.module === id, elements/{id}_runtime) |
step 5c | kept; the runtime doc carries disabled: true → every element with module: id renders nothing (§8.3) |
deleted on keep and purge, with their setup/elements order entries; the store's own derived / custom elements stay and render nothing |
| admin — mounts, pages, widgets, menu items | rendered from the manifest snapshot on the install doc | hidden, menu items inert | gone |
| data — collections, schemas, templates, translations | step 5b–5c | untouched | keep (default) leaves all of it; purge removes the content collections; templates and translations are data — never removed |
schedule — schedule.next_at |
step 5e | skipped by the scheduler | gone with the doc |
8.2a Module-owned elements#
What a kit mounts is not a one-off import: the elements stay tied to the
module (the pure rules are
core-lib utils/element_module.ts, the I/O store_modules/module_elements.ts).
ElementEntity carries module ("this element needs module X's runtime
and backend"), origin: {module, version, hash}, forked, and — on the
runtime doc only — runtime / disabled. One classifier,
elementModuleState(doc):
| Kind | Marker | Store may edit | On module update | On uninstall (keep or purge) |
|---|---|---|---|---|
| pristine | origin: {module, version, hash} + module; body hash = origin.hash (a missing hash reads as pristine) |
yes | overwritten | deleted |
| customized | same; body hash ≠ origin.hash |
yes | skipped — kept as edited, origin.version stays; Reset to module version |
deleted |
| runtime | runtime: true; id {moduleId}_runtime |
no (409 module_owned) |
always overwritten | deleted |
| derived (a Duplicate, or a detach) | module + forked: {element, version}, no origin |
yes | untouched | kept, renders nothing |
| custom (built new on the module) | module, no origin |
yes | untouched | kept, renders nothing |
- The hash is
elementBodyHash— a sync, browser-safe FNV over the canonicalfields+html+css+js(sorted keys,\r\n→\n).name/description/category/sampleare outside it. "Customized" is computed, never stored, so every writer (editor, agent, MCP, raw API) is covered. It detects change; it is not a security control. - The mount is a plan —
planElementMount(existing, produced, origin)→{write, skip, conflict, detach, remove}(module_assets.ts, pure;mountElementsexecutes it): absent and pristine → written (full replace); customized → skipped; a doc at a kit id that the module does not own (a store element, another module's) → conflict, left alone; an owned element the version no longer ships → removed when pristine, detached into a derived element when customized (originstripped,forkedstamped). The result isinstall.elements={version, mounted, skipped, conflicts, detached, runtime}. - The runtime doc
elements/{moduleId}_runtime={runtime: true, module, origin, js, css?}is written for every install with a kit orroutes.web(hosting: 'none'included — it always has kits;hasStorefront),js= the kits'global.js(or legacyruntime) files joined with\n;\n(empty when none declares one),css= the kits'global.csssheets joined with\n— absent when blank. The renderer emits the css before the element css and the js before the element js, on the pages that place an element of the module only (the console's Elements tab counts both inruntime.bytes). It is never insetup/elements.order, never store-writable, and doubles as the storefront's view of the module's state:disabled: truecloses the render gate (§8.3) — a disabled module's global css / js is not emitted either. - The write fence —
guardElementWrite, applied by crud (fenceElementWrite) to create / update / delete / add / commit onstore/{sid}/elements/*, for every caller includingmaster: a write or delete of a runtime doc, a create at a*_runtimeid and a delete of an owned element are409 {error: 'module_owned', element, reason}; client-suppliedruntime/disabledand the module form oforiginare dropped (the legacy{template, version}form passes — elements imported by the retired system kits carry it); an owned doc keepsorigin+modulewhatever the payload says; a new or changedmodulemust name a module whose runtime doc exists on the store (400 module_not_installed) — an unchanged one passes, so a custom element of an uninstalled module stays editable. A refusal refuses a whole/admin/commit. - No embedding. An element with
modulecannot be embedded into a page template — an embedded copy would get neither the runtime nor the gate. - Verbs (§8.6):
POST /api/module/elements(the status the console's Elements tab reads),POST /api/module/element/reset(re-mount one element from the installed package;forcereplaces a store-authored element at a conflicting id, never another module's —409 element_conflict). (POST /system/module/restamp, the migration that stamped installs mounted before element ownership existed, ran on both envs and was deleted on 2026-10-27.)
8.3 Configure, verify, enable, reroll#
POST /api/module/configure {id, config?, secrets?}— non-secret values onto the install doc, secrets write-only asmodule_{id}_{key};setup/modulesrewritten;{install}withhas_secrets.POST /api/module/verify {id}— theverifyenvelope;{ok, message?, install}; stampsverified_at.POST /api/module/enable {id, enabled, retry?, dry_run?}— flipsenabled;retry: trueclearshealth;409 version_yankedwhen enabling a yanked version. A disable is never refused but answerswarnings: [{code, module, orders?, count?}]— what fails closed once a provider module stops answering (shipments_open,payments_open,order_processor_active,invoice_provider_active,provider_pending);dry_run: trueanswers{install, warnings}without writing (the console's confirm pre-flight). A disabled or unverified provider is refused at call time (module_disabled) and hidden from the storefront's offering (§2.4). It also stamps (or clears)disabled: trueon the runtime doc (setRuntimeDisabled; the yank sweep does the same) — the render gate: that element write fires theelementshook, the fleet drops its rendered HTML at once, and every placement of an element withmodule: idrenders nothing until the module is enabled again.setup/modulesis not involved. A tier refusal is not a disable — placements stay, the backend refuses.POST /api/module/reroll {id}— new token + keys, re-handshake (§4.1).
An elements module (hosting: 'none') has no config, verify point or
identity: configure, verify and reroll (and POST /api/module/call)
answer 409 {error: 'not_applicable', hosting: 'none', module}, logs an
empty list; enable works as above and is its only switch.
All four are manager verbs (master | owner in-handler; an editor member
token is refused). The status chip on the module page reads not
configured / unverified / verified {date} / failing / disabled.
8.4 Update — a diffed re-mount#
| Package | Who uploads the new version | How installs move |
|---|---|---|
| platform catalog (later phase) | a platform admin → review → published |
update_available is stamped on every install of the key; update: 'auto' installs (the platform default) move on the next scheduler tick — unless the report says grants_widened, then grants_pending is set and a manager accepts on the card; manual installs show Update {version} with the notes and the diff |
| organization package | a platform admin on the client's behalf, from a store of that organization → published at once |
the upload page offers Update {store} to {version} right away; the organization's other stores show Update available to their managers; organization installs are always update: 'manual' |
connected code |
the client deploys their own code; the package is the contract | unchanged manifest → nothing to do (x-drop-module-version equals the installed version); changed manifest → upload the next package version and update; a drift (the endpoint reports a version the registry does not have) shows Upload the {v} package on the card |
POST /api/module/update {id, version, accept_grants?} — version must be
published for the install's key (an older one is a rollback;
yanked → 409 version_yanked). The re-mount is computed from the diff of
the installed manifest snapshot and the new manifest:
- Provider kinds. A new manifest that drops a
provideskind the store has selected (setup/invoice.provider,setup/order.processor, an enabledsetup/delivery.methods[].carrierorsetup/payments.methods[].servicenaming the module) is409 provider_kind_removed {kind, module}— switch the store off it first; a dropped kind nothing selects passes. - Grants. Widened (a leaf of the new
access/scopetree the old tree does not already grant) →accept_grants: truerequired (409 grants_pendingotherwise); the scheduler never accepts. The token's trees are rewritten in place — the raw token does not change, no re-handshake. - Endpoint (
connected). Changed → a reroll against the new endpoint; the old token is revoked only after the new handshake succeeds. - Hook entries re-upserted by id; entries for removed events deleted.
- Collections. New → registered; a changed schema → written through
the schema guard (documents are not migrated — the report's
diff.schema_fields_removedis awarnand the card shows those fields before the button); removed from the manifest → kept (an update never deletes data). - Kits re-mounted by the plan of §8.2a (this replaced the original
"overwrite same-id" rule): pristine elements are
overwritten and move to the new
origin.version; customized elements are skipped and keep the version they were based on; a customized element the new version drops is detached into a derived element, a pristine one is deleted; the runtime doc is always rewritten (keepingdisabledon a disabled install).install.elementsrecords the outcome and the update card says customized elements will be kept; templates only where still absent (an edited or already-seeded record is untouched); translations merged — a value the store changed survives. - Custody params from the new version record.
- Install doc —
version,manifestsnapshot,report,update_availablecleared,updated_by / at;setup/modulesrewritten;verifyruns again.
An update never touches config, secrets, state/, the records
collections, outbox/ or health (a failing module stays failing until a
good call).
An update keeps the hosting (409 hosting_changed otherwise — an elements
module never quietly gains a token). The check runs before anything else
(before the runner-availability check), and the refusal stamps
update_blocked {version, error: 'hosting_changed', from, to, at} on the
install: the auto-update sweep skips that version from then on (as it does
a version parked on grants_pending) and reports it under blocked, never
as a failure; a newer update_available is tried again, and the next
successful update clears the stamp. Since the upload refuses such a version
(hosting_immutable, §7.3) the stamp only catches versions published
before that row existed. For an elements module it is step 5
and the install doc only: no provider check, no grants (never
grants_pending), no custody (so never module_custody_missing), no token
rewrite, hooks, collections, setup/modules or token_id; the auto-update
sweep moves it like any other install.
8.5 Uninstall, version states, housekeeping#
POST /api/module/uninstall {id, purge?}: (a provider module first passes the
removal guard — 409 shipments_open {module, orders} (a carrier parcel in
shipping / delivery), payments_open {module, orders} (a payment attempt
still counter / requires_capture on a confirmed / ready / active
order), order_processor_active / invoice_provider_active (the store
still selects it), provider_pending {module, kind, orders} (provider calls
not yet resolved); on a clean pass the delivery / payment methods naming it
are disabled, never deleted, and an invoicer's answer carries
unpaid_invoices; store deletion forces past it) → disable → hook entries → token →
the module's uninstall {purge} call (connected with routes.uninstall,
one best-effort call, never blocking; store deletion sends purge: true) →
custody → secrets → the module's own elements and its runtime doc deleted
(purgeElements — origin.module === id, their setup/elements order
entries pruned; on keep and on purge: they are code of a backend that is
gone) → (purge: the content-collection cascade — per doc across
locales → custom schemas → the setup/data entry last — plus a recursive
delete of modules/{id} and the modules.{id} field removed from
entities) → the install doc last. The store's derived / custom elements
(module without origin) are store data — kept, still editable, and with
the runtime doc gone they render nothing under the strict gate. Seeded e-mail templates
and merged translation keys are store data on both paths: never removed.
manifest.uninstall fixes the choice; when absent the console asks keep
data / purge. A store delete runs the same for every install before the
store subtree goes. An elements module's uninstall is the element purge, the
registry's installs entry and the install doc — no hook entries, token,
custody, notice or secrets to remove, and keep and purge do the same (it has
no data of its own). That short path needs a clean install doc: one that
says none yet carries a token_id or hook_id runs the full path above.
Version states (registry, per version): checked → in_review → published | rejected → unpublished | yanked; organization packages go checked → published in one write. POST /system/module/unpublish {key, version, store?} (systemRoute, platform admins; the handler checks the key's owner
against the store's organization, 403 not_owner): no new installs, running
installs untouched, latest snaps back, auto installs follow latest.
POST /system/module/yank {key, version, reason} (master only — the
console proxy refuses an administrator) is the security pull: every install on that version flips to
enabled: false, health: {state: 'disabled', last_error: 'yanked: …'} and
the card offers Update to the highest remaining published version.
Garbage collection rides the outbox sweep: rejected versions after 30 days;
unpublished versions with zero installs after 30 days (objects deleted with
the record); yanked records kept for audit. An organization package whose
last install is gone stays in the registry, re-installable, until its owner
unpublishes it.
8.6 Every route, with its group#
Control plane — one store's install (moduleRoute = /api/module, the
generic /api/:r auth middleware; in-handler master | owner gate on
every mutating route; module is a root key in the access tree, so an editor
member token reads catalog / list / logs / deploy / docs / elements and nothing else):
| Route | Body | Purpose |
|---|---|---|
POST /api/module/catalog |
{category?, q?} |
{items: ModuleCatalogEntry[]} visible to the store — platform entries (visibility ∩ tier) + the store's organization's, and for a master caller (the console's platform admins) also the visibility: 'platform' entries — published versions plus installed ones |
POST /api/module/list |
{} |
{items: ModuleInstall[]} with health, has_secrets, update_available, drift, grants_pending |
POST /api/module/install |
{key, version?, accept_grants: true} |
§8.2; a managed module answers 202 {status: 'deploying', module, version, deploy} (§8.7) |
POST /api/module/update |
{id, version, accept_grants?} |
§8.4; managed → 202 while the new version's function builds (§8.7) |
POST /api/module/docs |
{id? | key?, version?, locale?, kind?} |
the module's documentation as markdown: docs/{locale}.md for the reader's language (cs, en, cs_CZ), else README.md — {markdown, locale, path, version, key, languages}; by installed id (its version) or by registry key (+ version, default the latest published) before install, behind the install's visibility rule (403 not_visible); 404 docs_missing for a version published before the rule. kind: 'elements' answers the element guide docs/elements.md instead (one file, no language pick; 404 docs_missing when the package has none). Any member with module.docs (§7.1) An installed version the registry no longer lists (its record was removed, or the version left it) is answered with the last documentation still readable — the installed version's own package objects, else the newest served version the record carries (ModuleDocs.readDetached; the answer's version says which, and the console notes a difference); 404 docs_missing when none. |
POST /api/module/elements |
{id} |
§8.2a — {module, version, enabled, elements: [{id, name, state: pristine | customized | conflict | missing, version}], dependents: [{id, name, kind: derived | custom, forked}], runtime: {present, disabled, bytes}, stamped}; two queries, no bucket read (stamped: false = mounted before the rules and not restamped). Any member with module.elements |
POST /api/module/element/reset |
{id, element, force?} |
§8.2a — re-mounts ONE kit element from the installed version's package → {element, status}; force replaces a store-authored element at that id; 404 element_not_in_package, 409 element_conflict {owner} (another module's element is never replaced), 409 version_not_found. Manager |
POST /api/module/deploy/status |
{id} |
a managed module's deploy: {deploy: ModuleDeployState | null, function?: {state, url, version} | null, error?} (§8.7) |
POST /api/module/configure |
{id, config?, secrets?} |
§8.3 |
POST /api/module/verify |
{id} |
§8.3 |
POST /api/module/enable |
{id, enabled, retry?, dry_run?} |
§8.3 |
POST /api/module/reroll |
{id} |
§8.3 |
POST /api/module/uninstall |
{id, purge?} |
§8.5 |
POST /api/module/call |
{id, path, body?, entity?} |
the admin leg with the member block; the module's answer relayed at the top level (its status, its body) |
POST /api/module/logs |
{id, limit?} |
{outbox: [pending + dead], health, logs: [runner rows]} |
POST /api/module/shipment/events |
{order, events: TrackingEvent[]} |
a module token's route (a carrier provider's push tracking, granted module.shipment.events) → {ok: true, applied, status, changed}; the invoicer, payment and order-system routes are below |
POST /api/module/payment/events |
{events: [{order, key, state: PaymentState}]} |
a module token's route (a payment provider's channel back, granted module.payment.events) → {ok: true, applied, skipped: [{index, reason}]} — §2.4 |
POST /api/module/invoice/events |
{events: InvoiceEvent[] ≤ 50} |
an invoicer provider's token (granted module.invoice.events) → {ok: true, applied, skipped: [{index, reason}]} — issued for its own pending key, paid / unpaid / cancelled / restored on its own documents (§2.4) |
POST /api/module/invoice/pending |
{limit? ≤ 20} |
an invoicer provider's token (granted module.invoice.pending) → {pending: [{order, period, key, state, attempts, at, request}]} |
POST /api/admin/commit |
{writes} |
§5.6; any token, under scope (adminRoute) |
POST /api/admin/batch |
gains meta?: boolean |
§5.6 (adminRoute) |
Storefront (publicRoute): POST /api/public/module {module, path, method, query, body, locale, ip, origin, referer, identity?}
on the serving token — the web leg, {status, headers, body}.
Ingress — no store token, in-handler auth, on hookRoute (/hook/**,
beside the Stripe legs):
| Route | Auth | Purpose |
|---|---|---|
POST /hook/module/{sid}/{id}/event |
access-seed with module_key (sent by the platform's own outbox) |
the hook target for hosted / platform installs — relayed into the runner as the event point with the delivery's headers + body; a non-2xx makes the outbox retry |
GET|POST /hook/module/{sid}/{id}/admin/{path} |
authorization: Bearer <5-minute session JWT> (custody session_key, aud = the module, sid = the path's store); CORS * |
the admin leg for package ui/ pages (a connected page talks to its own origin) — forwarded as the admin point with the token's member block |
GET|POST /hook/module/{sid}/{id}/inbound/{path}[/{url_key}] |
per routes.inbound[]: access-seed with inbound_key (label inbound/{path}, 5 min), or the url_key segment = hex(HMAC-SHA256(inbound_key, 'url:' + path)), wrong → 404 |
a third party's webhook without exposing the module's origin; forwarded with point: 'inbound' |
POST /hook/module/{sid}/{id}/log {lines} |
access-seed with module_key, label log |
asynchronous runner log rows (7-day TTL); a call's own log lines ride its answer and are written by the gateway |
System (systemRoute, roles: [master] at the core; through the console
proxy every platform admin, yank master alone):
| Route | Purpose |
|---|---|
POST /system/module/upload (multipart zip, store, target, notes?, visibility?) |
§8.1 |
POST /system/module/unpublish {key, version, store?} |
§8.5 |
POST /system/module/outbox/sweep {store?} |
one on-demand run of the outbox sweep (§6.2) — every store, or the one named — instead of waiting for the 5-minute schedule; what the test harness calls |
POST /system/module/registry/delete {key, force?} |
removes a registry record and nothing else (409 installs_exist while installs reference it unless force); goes through the registry cache, which a raw /admin/delete of the doc would not — warm instances would keep serving the stale record |
POST /system/module/session {store, module, member} |
signs the session JWT for iframe pages (called by the console server) |
POST /system/module/check {key, version} |
re-runs stages 1–3 on the STORED objects of a version, no registry write |
POST /system/module/review {key, version, verdict, notes?, actor?} |
approve → published (latest moves, update_available stamped); reject → rejected with the notes; 403 two_person_rule for a visibility: 'all' version reviewed by its submitter (the console proxy stamps actor = the verified e-mail) |
POST /system/module/queue {} |
the review queue — every platform-owned in_review version with its record |
POST /system/module/yank {key, version, reason} |
the security pull: yanked, every install on it disabled with yanked: {reason}, latest snaps back; master only at the proxy |
POST /system/module/ui {owner, id, version, path, any?} |
one ui/** object for the console's /module-ui host (published / unpublished only unless any — the platform-admin sandbox) |
POST /system/module/deploy/create {store, key, version?, force?} |
deploy a managed version without installing it — the pre-deploy / retry lever; 202 {deploy}, 200 when already ready at that version (unless force); 400 not_managed, 409 version_not_published | deploy_unavailable (§8.7) |
POST /system/module/deploy/update {store, id} |
rebuild the installed version (force); the identity rides into the new revision from custody |
POST /system/module/deploy/delete {store, id} |
remove a function no install uses (409 installed — uninstall removes an installed one; 404 not_deployed) |
POST /system/module/deploy/status {store, id} |
the same answer as /api/module/deploy |
SDK: a top-level modules facade on DropCoreInstance (sdk-js/src/modules/)
with one method per /api/module/* route (deploy(id), elements(id) and
resetElement({id, element, force?}) included; docs takes kind; install /
update return ModuleInstallResult | ModuleDeployingResult, told apart by
isModuleDeploying) and no upload (/system/**
is console-only — systemUpload() in admin/src/api/sdk.ts carries the
multipart); public.module for the storefront leg;
admin.data.commit(writes). Server side: verifyModuleEnvelope,
expressModuleRouter, moduleIdentityFromEnv (§8.7), and
drop-core-server/lib/module/session_token (Node-only, off the package index).
8.7 Managed deploys (deploy: 'managed')#
The platform deploys a connected module's code into the store's space
cloud (the organization's own Firebase project, docs/space-cloud-ops.md)
and installs against it — no host of the client's, no endpoint in the
manifest. One HTTP function per install: mod-{id}-{sid} (a sid that
would pass 63 characters folds to a hash), because every install carries its
own identity and may pin its own version, and two stores of one organization
share a project.
The gate. The store must be bound to an organization whose custody is
ready, holds an uploaded service-account key (variant K) and whose probe
reports capabilities.shim_deploy === 'ok' (cloudfunctions.functions.create
actAs— the same right the shim deploy needs). Otherwise every verb answers409 deploy_unavailable {reason}withreasonone ofnot_bound,cloud_not_ready,credential,shim_deploy,key_revoked; the self-service path (deploy: 'self') stays open.
Install and update deploy first. install runs its checks (visibility,
version, grants, already_installed, tier) and then, instead of the
handshake, queues a deploy and answers 202 {status: 'deploying', module, version, deploy}; nothing else is written. The deploy runs on the shim
queue (shimDeployTask, tasks with target: 'module'): start zips src/
from the version's objects in the core bucket, uploads it and creates or
patches the function (Node 22, 256 MiB, 60 s, 0–10 instances, ingressSettings: ALLOW_ALL); poll looks at the operation every 30 s … 5 min (≤ 60 polls ≈
4 h, then failed); once the function is ACTIVE it grants
roles/run.invoker to allUsers on its Cloud Run service — the gateway
calls it with no Google identity, the envelope is the auth, exactly as for any
connected endpoint — marks the deploy ready with the URL and runs the
request's then: the install (handshake, custody, mounts, the install
doc with deployment: {project_id, function, region, url, version}) or the
update. A second install while the deploy runs is the same 202 (it only
refreshes then); a deploy already ready at that version is used directly,
synchronously. update to a new version redeploys the same function with the
new source first; the endpoint does not change, so the token is rewritten in
place (no reroll). The gateway, the hook entries and reroll read the
endpoint as install.deployment.url ?? manifest.endpoint.
The state doc store/{sid}/modules/{id}/state/_deploy (ModuleDeployState,
core-lib; a reserved state/ doc, so its writes fire nothing and never churn
the gateway cache — and it exists before the install doc):
{state: 'queued' | 'deploying' | 'ready' | 'failed' | 'deleting', key, version, project_id, region, function, url?, operation?, identity?: 'sealed' | 'pending' | 'failed', then?: {op: 'install' | 'update', by, role?}, started_at, updated_at, polls, error?}. A build failure, a failed invoker
grant or a failed then lands in error (the install page shows it with
Try again, which re-sends the verb — a failed deploy is re-queued). Every
task is idempotent against this doc: a duplicate start or a late poll
re-reads it and does nothing.
The identity survives a cold start. The function scales to zero, and the
keys the handshake delivers live in the handler's memory. So after every
handshake (install, reroll) the platform seals the identity into the
function's env — DROP_MODULE_IDENTITY = {store, module, version, module_key, session_key, inbound_key, token}, a serviceConfig PATCH (a new
revision, no rebuild) — and every redeploy carries it from custody. The
module seeds its key state from it with moduleIdentityFromEnv()
(drop-core-server) before expressModuleRouter runs. An env var, not
Secret Manager: a space-cloud key typically lacks secretmanager.secrets.create
(the shim takes the same fallback for DROP_SHIM_KEY), and it is the
organization's own project — the identity is theirs to read. A failed seal is
identity: 'failed' on the state doc (the warm instance keeps serving; the
next deploy/update carries it).
Uninstall runs the normal sequence (§8.5) and then enqueues a delete
task with the install's deployment — the function goes, then the state doc
(a state doc already re-queued by a reinstall stays). A self-service module's
function is the client's and is never touched. A function left behind by an
install that never completed is removed with POST /system/module/deploy/delete.
8.8 Hosted and platform modules — the runner#
A hosted / platform module is a Wasm package the platform runs on
its module runner (module-runner/, a Rust Extism host on Cloud Run in
drop-core-modules; the wire contract is module-runner/CONTRACT.md).
What differs from connected:
- The package ships
module.wasm(≤ 4 MiB) exporting one function,call: input = the JSON envelope{headers, body}(the point isheaders['x-drop-point']), output ={status, headers?, body?}. Host functions (extism:host/user):platform_fetch(path, body)(the core API with the install token —/api/admin/,/api/public/,/api/process/,/api/secure/secret,/api/mailing/send, plus the provider report-back routes/api/module/shipment/events,/api/module/payment/events,/api/module/order/eventsand/api/module/invoice/(events,pending) only — the token's grants still decide which of those answer — ≤ 20 / 40 per call),kv_get/kv_set(state/{key}),log,now. Outbound HTTP is Extism's own, fenced byegress(allowed_hosts). No WASI. - Install mints the token + keys straight into custody — there is no
endpoint to prove, so no install handshake. Custody also carries the version
record's
object+wasm_hash; every call passes the bundle by hash and the runner verifies it (bundle_hash_mismatch→ 502). An update is live on the next call. - Limits —
manifest.limitsclamped by the profile:hostedfuel ≤ 20 M, 256 pages (16 MiB),http_per_call≤ 20,calls_per_day≤ 20 000 (the gateway's meterstate/_calls_{yyyymmdd}, 429 past it), ≤ 5 egress hosts;platformfuel ≤ 100 M, 1024 pages, 40, no daily cap, ≤ 20 hosts. The timeout is the point's own budget. A trap maps to the runner's status:fuel_exhausted/timeout504,memory/trap502 — the gateway's health counters see it like any failure. - Events go to the platform's own ingress
/hook/module/{sid}/{id}/event, which relays them into the runner. - Logs — each call's log lines are written to
logs/(7-day TTL) and show on the module's Logs tab (/api/module/logs→logs). - Updates — platform installs default to
update: 'auto': the scheduler moves one install per store per tick toupdate_available(updated_by: 'auto'); a version whose grants widened stops atgrants_pendinguntil a manager accepts on the card.
Where the runner lives: MODULE_RUNNER_URL_PROD / _DEMO / _CHECK in
the functions env (functions/.env.{project}), picked by env; the API
function's runtime service account holds roles/run.invoker on the
matching service and calls it with a Google ID token.