A point is one kind of call the platform makes into a module. A module
implements the points its manifest declares; the gateway never calls a point
the manifest does not name. Every point receives the envelope (§3);
input is what differs.
| Point | When | input |
Expected response | Timeout | Fallback |
|---|---|---|---|---|---|
install |
at install, reroll, and an update that changes endpoint — twice (§4.1) |
{stage: 'keys', module_key, inbound_key, nonce, store, module, version} then {stage: 'token', token, session_key, config} |
stage keys: 200 + header x-drop-ack; stage token: 200 |
the gateway's per-point deadline | none — a failure unwinds the install (422 handshake_failed) |
verify |
after install / update / configure when config.verify is set; the console's Verify |
{config} (secrets not included — the module reads them itself) |
{ok: boolean, message?} |
the gateway's per-point deadline | verified_at stays unset; the status chip says unverified |
uninstall |
POST /api/module/uninstall (and store deletion), after the install token is revoked and before custody is deleted — only when the manifest says routes.uninstall: true (connected only) |
{purge: boolean} — the merchant's choice; store deletion always sends true |
2xx (body ignored) |
10 s | none: one call, no retry, no outbox; a failure is logged and the uninstall goes on. The install token is already revoked — the module must not need it |
event |
a store write matched a hook entry (§6); retried by the outbox | the doc (masked by events_mask) or a {id} tombstone with x-drop-deleted: true |
2xx (body ignored) |
10 s (the hook delivery timeout) | the outbox retries; dead after five attempts |
schedule |
every schedule.every, one in flight per install |
{} |
2xx |
60 s | the next tick; state/_schedule (last_at / last_ms / last_ok / last_error) and health record the result. Ticks run only by hand since 2026-10-24 (/system/module/schedule/tick) |
web |
a storefront request to /m/{id}/{path} on the store origin |
{method, path, query, body, ip, origin, referer} (+ identity on the envelope) |
{status, headers: {'content-type', 'cache-control'?}, body: string} — only those two headers are re-emitted, cache-control defaults to no-store, never set-cookie |
the gateway's per-point deadline | the core's error body passes through to the storefront; a routes.guarded path without the guard → 428 guard_required, the module is not called (below) |
admin |
a console user submits a declarative form / action, opens a card, or a package ui/ page calls the admin leg |
{path, body, entity?} (+ member on the envelope) |
forms {ok, message?, values?}; actions {ok, pending?}; cards {rows: [{label, value, tone?}], updated?} |
the gateway's per-point deadline | a toast / the health chip on the card |
inbound |
a third party hits /hook/module/{sid}/{id}/inbound/{path} |
{path, method, query, headers, json?, form?, raw?} — GET or POST; a JSON body arrives parsed as json only (the raw bytes are not kept), a form body as form + raw, anything else as raw; headers filtered to the route's allowlist |
2xx |
the gateway's per-point deadline | the third party sees the module's status |
callback:order.validate |
Order.prepare, once, before the order write — every install that lists it, in install order |
{init: OrderInitEntity, order: <prepared>, customer?} |
{ok: true} | {ok: false, errors: [{field?, code, message}]} → 400 {error: 'module_validation', errors} to the buyer |
3 s | open (default): the order proceeds; closed: 503 module_unavailable {module} |
callback:shipping.rates |
the delivery planner | address, items, currency, locale | {methods: [...]} merged after the store's own |
10 s | the module's methods are omitted (cached 12 h per sha256(address, items, currency)) |
callback:shipping.points |
the pickup-point picker | PointsQuery |
{points: PickupPoint[]} |
10 s | empty list (cached 10 min) |
callback:shipment.createShipment / label / track / cancel / manifest |
the carrier registry's module: provider |
ShipmentRequest (with the idempotency key) / ShipmentRef (+ LabelOptions) |
ShipmentResult / LabelResult ({format, content_base64}, bytes inline) / TrackingEvent[] / void |
20 s | an error on the order card |
callback:invoice.issue / send / pdf / cancel |
an invoicer provider |
the issue request / the invoice ref | the provider's document (invoice.pdf: bytes inline, 4 MiB cap) |
20 s | error — the caller records the failure; never cached |
callback:payment.create / status / capture / cancel |
a payment provider (Part G) |
the payment request / ref | the gateway's state (payment.status may complete an approved payment) |
20 s | error; never cached |
callback:order.push / cancel |
an orders provider (Part O) |
the order push / ref | the processor's acceptance | 20 s | error; never cached |
Guarded paths (routes.guarded). A guarded storefront path is a public
form endpoint of the module, so the tenant server runs the same FormGuard
chain POST /form/:locale/:form runs (honeypot, signed min-fill time,
per-IP rate limit) before the request reaches the module:
GET /m/{id}/{guarded path}answers the guard fields —{fields: {email_2: '', form_ts: '…'}},no-store— exactly likeGET /form/…; the module is not called. The page folds the fields into the JSON it POSTs (the guard's form key ismodule:{id}:/{path}).POST|PUT|DELETErunsverify(): a failure is403 {error: 'Forbidden', reason}and the module never sees it; on success the guard's fields are stripped from the body and the request is forwarded withguard: 'passed'.- The core is the authority, fail-closed.
POST /api/public/moduleanswers a guarded path that lacksguard: 'passed'with the envelope{status: 428, body: {error: 'guard_required'}}and does not call the module. Every envelope of a module with guarded paths carriesguarded: string[]; the tenant server caches the list per store + module (5 min) and guards before forwarding from then on — an unknown or stale list costs one extra round trip, never an unguarded call. No extra read: the list comes from the install the gateway already loads. - The Studio bridge has no guard chain (like
/form): a write the Studio allows goes aspassed(the signed-in member's own), a GET gets an empty field set. - Whoever holds a store token can send
guard: 'passed'themselves — the guard fences anonymous visitors, not token holders (the same line/public/formdraws). - Matching is
isGuardedPathin core-lib — one implementation for the core, the tenant server and the bridge.
Elements on the storefront leg. The UI that calls /m/{id}/** is an
element with module: '{id}' — one the kit shipped, or one the store built
(§8.2a). A page that places such an element loads the module's shared
runtime (the kit's runtime file, served from the runtime doc at
/store/{sid}/js/element/{id}_runtime.js) once, after the element-library
layer and before the element scripts, so element code calls the runtime
(window.Drop{Module}) instead of re-implementing the guard dance. The
same doc is the render gate: while the module is disabled its elements
render nothing on the published page (the backend would answer 404 module_disabled anyway — the gate is UX, not a control). The contract an
element author builds against — runtime API, routes, events, translation
keys — is the package's docs/elements.md (§7.1).
Timeouts not fixed above are the gateway's own constants
(module_gateway.ts), all applied with AbortSignal.timeout. Errors a
callback raises reach the caller as RequestErrors, never bare 500s.
Fallbacks and the response cache. The Fallback column is what the
platform does when a point fails, times out, answers a non-2xx or answers
something that is not the shape above: the two shipping.* points are
substituted (an empty method list, an empty point list) and the store never
sees an error, order.validate follows the manifest's policy, shipment.*
surfaces the failure on the order card, and the rest have no fallback at all
(the handshake unwinds, event goes to the outbox, schedule waits for the
next tick). The two points with a cache TTL are answered from
store/{sid}/modules/{id}/state/_cache_{sha256} while the row lives —
shipping.rates for 12 h keyed on sha256(address, items, currency) (the
locale is deliberately not part of the key), shipping.points for 10 min
keyed on the whole query. Only a real 2xx is ever cached; a fallback
never is, so a module that recovers is called again on the next request.
Every other point is uncached. Expired rows are dropped by the outbox sweep
(POST /system/module/outbox/sweep, run by hand since 2026-10-24); an expired
row is never served in the meantime.
Scheduled work. A manifest with schedule: {every} (5m / 15m / 1h /
6h / 1d) is called on its schedule point by a platform sweep. Since
2026-10-24 that sweep has no cron — moduleScheduler was removed and a
master runs a tick by hand (POST /system/module/schedule/tick); a module
that needs timed work runs it itself (Reservations moves to a dedicated
project). Cadences are at least that long, never exact: the tick
picks up every install whose next moment has passed, calls at most 50 across
the whole fleet per tick, and leaves the rest for the next one. The
bookkeeping is the module's own state/_schedule document — next_at,
last_at, last_ms, last_ok, last_error and, while a call is in
flight, running_since. That claim is what guarantees one call in flight
per install: a module slower than its own cadence is never called twice
(a claim older than 10 minutes counts as a crashed tick and is retaken), and
next_at is recomputed after every attempt, so a failing module falls
back to the next tick rather than stalling or hot-looping. The result also
lands on health like every other point — and a successful schedule call
is what clears a failing install.
order.validate's policy is deliberate: open is the default — a
connector that annotates an order must never be able to stop the store
selling, and the breaker (20 consecutive failures → health.failing → the
point short-circuits to its fallback) bounds the damage of a slow module. A
module that is authoritative over whether an order may exist declares
callbacks_policy.order_validate: 'closed' itself; a booking module must
declare the key explicitly, and the catalog card and the install grant screen
show the policy so a store owner sees before installing that this module
can refuse a checkout.
4.1 The install handshake — two stages, x-drop-ack#
A connected module has no dry run, so its endpoint is proven by a two-step
handshake that never sends the install token to an unproven receiver:
assertPublicEndpoint(endpoint)—https:only, a hostname (no IP literal), DNS resolving to public unicast addresses only, none of the platform's hosts, port 443 or none. Checked at upload, checked again here. (The one exception is the local test loop: a loopbackhttp://127.0.0.1endpoint is admitted only by a function running under the Firebase emulator —allowLoopback, §11 — never by a deployed one.)- Stage
keys.POST {endpoint}/install, the envelope signed by a freshly generatedmodule_key, body{stage: 'keys', module_key, inbound_key, nonce, store, module, version}. The module persists the keys and answers200with headerx-drop-ack: hex(HMAC-SHA256(module_key, nonce)). The gateway compares withtimingSafeEqual; no ack or a wrong one → the keys are discarded,422 handshake_failed {status, body_head}, nothing stored. Nothing sensitive has left:module_keyverifies the platform's envelopes andinbound_keyauthenticates the module's own inbound leg — both inert without the token. - Stage
token. The install token is minted and sent as{stage: 'token', token, session_key, config}. The module persists the token (it is the credential for every API call it makes) andsession_key(verifies console session tokens for iframe pages, §4.2). A non-200revokes the token and unwinds — no custody, no hook entry, no install doc. verifyruns immediately after whenconfig.verifyis set.
reroll (the console's Reroll keys, POST /api/module/reroll {id})
repeats stages 2–3 with fresh keys and a fresh token; so does an update whose
manifest changes endpoint — the old token is revoked only after the new
handshake succeeds, so a failed update leaves the old endpoint installed and
working. expressModuleRouter implements both stages: the install handler
receives onKeys(keys) and onToken(token) and the router computes
x-drop-ack itself.
4.2 Admin pages and the session token#
A connected module can put its own web pages inside the console:
manifest.admin.pages[] — {id, label, icon?, url, capability?, roster?}, at most eight, id
matching [a-z0-9-]{1,32} and unique, url an https: URL on the
module's endpoint origin (same host; anything else is the check row
admin_page_url). Each page is a console route
/store/:sid/modules/{id}/pages/{page} (the Pages & widgets card on the
module page links them; a member needs the page's capability — one of
commerce / content / community / web / develop, absent = develop) that
renders the URL in a cross-origin iframe with
sandbox="allow-scripts allow-forms allow-same-origin" — the page runs on
your origin with your cookies; the console is opaque to it.
Package ui/ pages (hosted / platform): url is a path
inside the package's ui/. The console serves it from its own host at
/module-ui/{env}/{platform|org/{org}}/{id}/{version}/{path} (immutable —
the version is in the path; a non-published version only to a platform
admin, the review sandbox) in a frame with sandbox="allow-scripts allow-forms" — an opaque origin: no cookies, no storage. The token
message carries one more field, admin: the base of the admin leg
/hook/module/{sid}/{id}/admin/{path} the page calls with
authorization: Bearer {token} (CORS open; the core verifies the token
against custody session_key and forwards as the admin point). Nothing
under ui/ may hold a secret — the upload's secret scan refuses one.
The page never gets a store token. It gets a session token: a 5-minute
HS256 JWT the platform signs with the install's session_key (the key your
install handler received at {stage: 'token'}), minted per signed-in
console user and delivered by postMessage — never in the URL, never in a
cookie of ours.
header {alg: 'HS256', typ: 'JWT'}
payload {iss: 'inmake', aud: '{moduleId}', sub: '{uid}', sid, env: 'prod' | 'demo',
email, role, capabilities: {commerce | content | community | generative | web | develop: 'read' | 'write'},
roster?: [{uid, email, name?, role}], // only for a `roster: true` page
iat, exp: iat + 300, jti}role is the member's console role (owner / admin / editor, or a
platform role master / administrator / developer); capabilities is
what that member may do in this store — a manager carries every capability
at write. Treat both as the platform's statement of who is in the frame,
not as your own authorization model.
Page capability. The console sends the page id with every
mint (POST /system/module/session {store, module, member, page}), and the
core gates it against the installed manifest snapshot: an unknown page
is 404 page_not_found; a member without read or write on the page's
capability (absent = develop) is 403 capability_required {page, capability} — managers (owner / admin) and the platform roles pass. A
mint without page (widgets, cards) is not gated there.
Roster. A page that declares roster: true gets one more
claim: roster: [{uid, email, name?, role}] — the store's members, built by
the console server (the roster lives in the console's own database; a module
never gets a members scope, §5.1). The core signs it in only when the
installed manifest has that page with roster: true — otherwise a roster
in the request is ignored — sanitized (an entry without a valid uid / e-mail /
role is dropped, duplicate uids collapse, names ≤ 120 chars) and capped at
MODULE_SESSION_ROSTER_MAX = 200 entries. It is typed as
ModuleSessionRosterEntry[] on ModuleSessionPayload.roster, and
expressModuleRouter hands it to an /admin/* Bearer handler as
call.roster. Use it for assignee pickers and @mentions; it is as fresh as
the token (≤ 5 minutes).
The protocol (host = the console, frame = your page):
| Direction | Message | When |
|---|---|---|
| host → frame | {type: 'drop:token', token, exp, locale, theme} |
on the frame's load and after every re-mint; exp in seconds since the epoch; locale / theme (light | dark) let the page match the console (a package ui/ page also gets admin, its Bearer base) |
| frame → host | {type: 'drop:refresh'} |
ask for a fresh token before exp (the host also re-mints ~30 s before exp on its own) |
| frame → host | {type: 'drop:size', height} |
the content height in px — the host sizes the frame to it (clamped 320–4000) |
| host → frame | {type: 'drop:viewport', height, inset: {top, right}} |
pages only (not widgets): after the frame's load, after it is sized, and on every resize of the console viewport or of the console's floating bar (throttled, re-sent only when it changes) — the content height in px the frame can take without the console page scrolling (viewport minus the frame's top offset and the space the layout keeps below it; at least 320). Answer drop:size with it to fill the screen exactly; ignore it to keep auto-height. inset is the strip of the frame's top-right corner the console's floating bar covers (px from the frame's top / right edge; {0, 0} when nothing overlaps) — keep the controls of your top row out of it (pad the row's right side by inset.right). A module page is drawn full-bleed and edge to edge with no console head (no crumbs, title, Pin to Home or Reload — those live on the module's own page): the page is the whole content area |
| host → frame | {type: 'drop:route', path} |
pages only: right after the frame document's first drop:token, when the console URL carries ?at=<path> — navigate there (deep link) |
| frame → host | {type: 'drop:route', path} |
pages only: your page navigated — the host mirrors path into the console URL as ?at= (router.replace: no history entry, no reload, no new token), so a reload or a shared link reopens the page there |
A path is / followed by up to 512 characters of [A-Za-z0-9/_\-.~] —
no query, fragment or %-escapes; anything else (from the console URL or
from your page) is ignored. A console link to another at on the same page
is forwarded as one more drop:route without reloading the frame; the
host never echoes back the path your page just reported. Pages that never
read drop:viewport / drop:route keep working unchanged.
The host posts only to your page's origin (postMessage(msg, origin)) and
accepts a message only when e.source is its own frame and e.origin
is that origin. In the page, listen on window and reply to e.source with
e.origin as the target — never '*':
let session = null;
window.addEventListener('message', (e) => {
if (e.data?.type !== 'drop:token') return;
session = {token: e.data.token, exp: e.data.exp, host: e.source, origin: e.origin};
// ask again a minute before it expires
setTimeout(() => session.host.postMessage({type: 'drop:refresh'}, session.origin), (e.data.exp - 60) * 1000 - Date.now());
});
// call your own backend with it
fetch('/api/orders', {headers: {authorization: `Bearer ${session.token}`}});
// optional: fill the console viewport, and keep deep links in sync
window.addEventListener('message', (e) => {
if (!session || e.source !== session.host) return;
if (e.data?.type === 'drop:viewport') session.host.postMessage({type: 'drop:size', height: e.data.height}, session.origin);
if (e.data?.type === 'drop:route') router.push(e.data.path); // your own router
});
router.afterEach((to) => session?.host.postMessage({type: 'drop:route', path: to.path}, session.origin));Verifying on your backend — the token proves the member and the store to you; verify it with the same implementation the platform signs with:
import {verifyModuleSessionToken} from 'drop-core-server/lib/module/session_token';
const verified = verifyModuleSessionToken(bearer, sessionKey, {aud: 'my-module'});
if (!verified.ok) return res.status(401).json({error: verified.reason}); // bad_format | bad_alg | bad_signature | expired | not_yet_valid | bad_issuer | bad_audience
const {sid, sub: uid, email, role, capabilities, env} = verified.payload;Always pass aud (your module id): a token minted for another module on the
same store is signed with a different key and fails anyway, but the audience
check is what makes that a contract rather than an accident. The verifier
allows 60 s of clock skew and never throws. expressModuleRouter does this
for you on its admin leg when it is given sessionKey (a Bearer on
/admin/* is verified there before the handler runs; the envelope path
stays for the platform's own calls). The token's lifetime is five minutes on
purpose — a page that stays open re-asks; a token that leaked is worth
little.
4.3 Declarative mounts — forms, actions, cards, widgets#
Most modules need a field, a button and a status panel inside the console,
not a page of their own. manifest.admin mounts those declaratively: the
console renders them from the manifest and the module only answers calls.
Every form, action and card is one call: POST /api/module/call {id, path, body?, entity?} → the gateway's admin point at your /admin/{path} (§4,
§3 for the envelope), carrying the caller as member ({uid, email, role, capabilities}). path is one or more [a-z0-9_-] segments, at most 64
characters — the same grammar the manifest is validated against, so a path
the manifest declares is a path the gateway accepts. Because these ride the
admin leg, a module that declares any of the three must also declare
routes.admin: true (check row admin_routes_required); the iframe kinds
(pages, widgets) load from your own origin instead and need nothing.
Ids match [a-z0-9-]{1,32} and are unique within their kind, and a kind
holds at most MODULE_ADMIN_MOUNTS_MAX = 12 entries (pages: 8).
| Kind | Declares | Renders | The module answers |
|---|---|---|---|
forms[] |
{id, label, help?, fields: ModuleFieldSpec[], submit: {path, label}, load?, placement, capability?} |
a card at placement; on an entity detail the call also carries entity: {kind, id} |
submit: {ok, message?, values?} (a toast; values re-fills the form) · load: {values} |
actions[] |
{id, label, icon?, path, entity?, confirm?, capability?} |
a button — module-level when it names no entity, otherwise on that entity's detail toolbar and its listing row menu; confirm (a locale map) becomes the confirmation text |
{ok, message?, pending?} — pending: true shows the in-progress state and leaves the next card refresh to report |
cards[] |
{id, label, path, placement, refresh?} |
a read-only key/value panel at placement, cached refresh seconds (default 60, floored at 15) |
{rows: [{label, value, tone?}], updated?} — tone (ok / warn / error / info) colours the chip |
widgets[] |
{id, label, url, size} |
a dashboard widget: your page in the §4.2 iframe, size ∈ tile | half | full |
nothing — it is a page, with the §4.2 session-token handshake |
Placements. A form renders at module (its own module page), setup
(Settings), or the detail page of an orders / invoices / products /
customers document. A card takes the same list plus dashboard (the
overview widget kind) and without setup. An action has no placement: its
entity (orders / products / customers) decides. Anything outside
those lists is a manifest_invalid row with detail.field: 'admin.{kind}'.
Who may use them. capability (one of commerce, content,
community, web, develop; absent = develop) is the member capability a
form's submit and an action's button require at write. The console greys
them out for a member without it, and the same rule is re-applied server-side
on the call — the gate is not the UI's.
Fields in a form are ModuleFieldSpecs, exactly as in config.fields
(§2.2) and validated by the same rules: a known kind, a locale label, no
public on a secret, and options / options_from mutually exclusive
with a select needing one of them. The difference is where the values go:
config values are stored by the platform and ride every envelope, a form's
values are handed to you on the call and are yours to store.
A minimal pair — a card and the action that refreshes it:
"routes": {"admin": true},
"admin": {
"cards": [{"id": "sync", "label": {"en": "Sync status"}, "path": "sync-status", "placement": "module", "refresh": 60}],
"actions": [{"id": "resync", "label": {"en": "Re-sync"}, "path": "resync", "confirm": {"en": "Re-sync every order?"}, "capability": "commerce"}]
}const handlers: ModuleHandlers = {
admin: {
'sync-status': () => ({rows: [{label: 'Last run', value: '2 min ago', tone: 'ok'}], updated: Date.now()}),
resync: (call) => ({ok: true, pending: true, message: `queued by ${call.member?.email}`}),
},
};The runnable version of all four kinds is the echo fixture
(examples/module-echo/): its manifest declares the ping action, the
sync card, a settings form with an options_from select and a board
widget, and src/app.ts answers each one.