inmake DOCS
Docs / Modules / Lifecycle

Lifecycle

Upload → check → install → configure → update → uninstall, every route with its group, managed deploys, the runner for hosted and platform modules.

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:

  1. Read the version record from the registry — manifest, hashes, limits, report. Never the request.
  2. Grants. The console shows access ∩ MODULE_MAX_ACCESS and scope read-only; the handler requires accept_grants: true and records grants_accepted: {version, by, at}.
  3. Keys and endpoint (connected): handshake stage keys (§4.1).
  4. Token and custody: mint, custody, handshake stage token.
  5. Mount points, in order: (a) setup/hooks entries; (b) content collections + schemas (records need nothing); (c) kits — the element mount of §8.2a: each element → elements/{moduleId}-{elementId} with module + 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 the setup/elements order / categories appended, then the runtime doc elements/{moduleId}_runtime; the outcome lands on the install doc as elements (ModuleElementsReport). The kits' global css / js (and the legacy runtime script) are mounted — into the runtime doc (css + js, §8.2a), served ahead of the element css / js; a kit's assets are 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 derived setup/modules public doc; (e) the install doc store/{sid}/modules/{id} — written last, so the gateway never sees a half-mounted module; (f) the install cache bump; then the registry's installs entry.
  6. Verify when config.verify is set; the card opens on the required fields 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 canonical fields + html + css + js (sorted keys, \r\n → \n). name / description / category / sample are 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; mountElements executes 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 (origin stripped, forked stamped). The result is install.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 or routes.web (hosting: 'none' included — it always has kits; hasStorefront), js = the kits' global.js (or legacy runtime) files joined with \n;\n (empty when none declares one), css = the kits' global.css sheets 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 in runtime.bytes). It is never in setup/elements.order, never store-writable, and doubles as the storefront's view of the module's state: disabled: true closes 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 on store/{sid}/elements/*, for every caller including master: a write or delete of a runtime doc, a create at a *_runtime id and a delete of an owned element are 409 {error: 'module_owned', element, reason}; client-supplied runtime / disabled and the module form of origin are dropped (the legacy {template, version} form passes — elements imported by the retired system kits carry it); an owned doc keeps origin + module whatever the payload says; a new or changed module must 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 module cannot 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; force replaces 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 as module_{id}_{key}; setup/modules rewritten; {install} with has_secrets.
  • POST /api/module/verify {id} — the verify envelope; {ok, message?, install}; stamps verified_at.
  • POST /api/module/enable {id, enabled, retry?, dry_run?} — flips enabled; retry: true clears health; 409 version_yanked when enabling a yanked version. A disable is never refused but answers warnings: [{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: true answers {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: true on the runtime doc (setRuntimeDisabled; the yank sweep does the same) — the render gate: that element write fires the elements hook, the fleet drops its rendered HTML at once, and every placement of an element with module: id renders nothing until the module is enabled again. setup/modules is 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:

  1. Provider kinds. A new manifest that drops a provides kind the store has selected (setup/invoice.provider, setup/order.processor, an enabled setup/delivery.methods[].carrier or setup/payments.methods[].service naming the module) is 409 provider_kind_removed {kind, module} — switch the store off it first; a dropped kind nothing selects passes.
  2. Grants. Widened (a leaf of the new access / scope tree the old tree does not already grant) → accept_grants: true required (409 grants_pending otherwise); the scheduler never accepts. The token's trees are rewritten in place — the raw token does not change, no re-handshake.
  3. Endpoint (connected). Changed → a reroll against the new endpoint; the old token is revoked only after the new handshake succeeds.
  4. Hook entries re-upserted by id; entries for removed events deleted.
  5. Collections. New → registered; a changed schema → written through the schema guard (documents are not migrated — the report's diff.schema_fields_removed is a warn and the card shows those fields before the button); removed from the manifest → kept (an update never deletes data).
  6. 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 (keeping disabled on a disabled install). install.elements records 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.
  7. Custody params from the new version record.
  8. Install doc — version, manifest snapshot, report, update_available cleared, updated_by / at; setup/modules rewritten; verify runs 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 answers 409 deploy_unavailable {reason} with reason one of not_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 is headers['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/events and /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 by egress (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.limits clamped by the profile: hosted fuel ≤ 20 M, 256 pages (16 MiB), http_per_call ≤ 20, calls_per_day ≤ 20 000 (the gateway's meter state/_calls_{yyyymmdd}, 429 past it), ≤ 5 egress hosts; platform fuel ≤ 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 / timeout 504, memory / trap 502 — 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 to update_available (updated_by: 'auto'); a version whose grants widened stops at grants_pending until 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.