Every platform → module call is POST {endpoint}{path} with the same
headers and the same body shape, whatever the point and whatever the tier.
The module verifies it before doing anything else.
3.1 Headers#
| Header | Value | On |
|---|---|---|
access-seed |
<ts>.<hex HMAC-SHA256("<ts>.<label>", module_key)> — generateSeed(); there is no access_seed twin (underscored headers are dropped by nginx) |
every call |
x-drop-module |
the module id | every call |
x-drop-point |
web | admin | event | inbound | callback:{name} | schedule | install | verify |
every call |
x-drop-store |
the store id (sid) |
every call |
x-drop-env |
prod | demo |
every call |
x-drop-request |
a uuid v4 — the dedupe key; a retried event carries the same value | every call |
x-drop-updated |
the doc's Firestore updateTime in ms — the receiver's out-of-order guard |
events only |
x-drop-event, x-drop-locale, x-drop-collection, x-drop-form, x-drop-id, x-drop-schema, x-drop-version, x-drop-storage-path, x-drop-deleted |
the existing hook delivery context (docs/hooks-and-caching.md) |
events only |
content-type |
application/json |
every call |
3.2 Body#
{module, store, env, request, point, config,
locale?, identity?: {customer}, member?: {uid, email, role, capabilities},
entity?: {kind, id}, url_tokens?: {[path]: string}, input}config— the install's non-secret config map, on every point, so a module never reads its own install doc per call. Secrets never ride the envelope; the module reads those itself (§5).input— the point's payload (§4).identity— the storefront customer, onwebcalls that carried anidentity-token.member— the console user behind anadmincall.entity— the order / product / customer an admin mount was placed on.url_tokens— rides onlyinstall,verifyandadmin: theurl_keyof each inbound route so a module can register its own webhook URL.
3.3 Labels#
The seed is bound to a per-call label — verifying the signature verifies which call it is:
| Point | Label |
|---|---|
install |
install |
verify |
verify |
web |
web/{path} |
admin |
admin/{path} |
callback:{name} |
callback/{name} |
schedule |
schedule |
inbound |
inbound/{path} (signed with inbound_key, not module_key) |
event |
the labels hooks already use — {locale}/{collection}/{id} for data, orders/{id}, invoices/{id}, forms/{form}/{id}, files/{storage_path} — plus modules/{id}/{collection}/{docId} for module_records |
3.4 Verifying#
verifyModuleEnvelope(headers, key) in core-lib
utils/module_envelope.ts, re-exported by drop-core-sdk and
drop-core-server like every core-lib util:
verifyModuleEnvelope(headers, moduleKey)
→ {ok: true, point, label, request}
| {ok: false, reason: VerifySeedReason | 'missing_point' | 'missing_request'}Replay tolerance is 5 minutes (maxAgeMs: 5 * 60_000) with 60 s of
future skew. A connected module on Express is ten lines with
expressModuleRouter(handlers, {moduleKey, sessionKey}) from
drop-core-server (src/module/express_module.ts, express lazy-required),
which verifies every envelope, dispatches on point, computes the install
x-drop-ack itself, dedupes x-drop-request (a 1,000-entry LRU) and stamps
x-drop-module-version on every response:
import express from 'express';
import {expressModuleRouter} from 'drop-core-server';
const app = express();
app.use(expressModuleRouter({
install: {onKeys: keys => store.save(keys), onToken: t => store.save(t)}, // the two-step handshake (§4.1)
verify: async ({config}) => ({ok: !!config.account}),
event: async ({input}) => { await sync(input); },
web: async ({input}) => ({status: 200, headers: {'content-type': 'application/json'}, body: '{}'}),
}, {moduleKey: () => store.get('module_key'), sessionKey: () => store.get('session_key')}));
app.listen(8080);(Illustrative — the handler object is typed ModuleHandlers = {install?, verify?, event?, schedule?, web?, admin?, callbacks?: Partial<Record<ModuleCallbackName, Handler>>}
in the starter; the same object feeds the Extism entry for the hosted
tier.) A module that is not on Express calls verifyModuleEnvelope itself
and answers 403 on {ok: false}.
3.5 What the gateway does around the call#
- Loads the install (cached, invalidated by every control-plane write);
enabled: false→ the caller gets404 {error: 'module_disabled'}and the module is not called. - Loads custody (the install token + the three keys, in the platform's secret store — never the install doc).
- Signs with
module_keyand the point's label. - Delivers:
connected→fetch(endpoint + path)withAbortSignal.timeout(ms)after the SSRF check (https:only, a public hostname, port 443 or none, none of the platform's hosts;redirect: 'manual'— a 3xx is a failure, a module endpoint answers directly);hosted/platform→ the runner (§8.8): the same envelope, the bundle by custodyobject+wasm_hash, the install token, the clamped limits and the declared egress; the hosted profile'scalls_per_dayis metered first (429 past the cap). - Caps the response by
content-lengthand a capped stream read: 64 KiB for callbacks / events / admin, 1 MiB forweb(callback:shipment.label,callback:shipment.manifestandcallback:invoice.pdf: 4 MiB — the bytes come back inline). - Records a
drop.statrow (module_call; storefront hits aremodule_web). - Health: a failure (network error, timeout, 5xx, bad JSON, size cap)
increments
install.health.points[point].failures; success resets it. At 20 consecutive failures on a pointinstall.health = {state: 'failing', point, since, last_error}and the point short-circuits to its fallback (§4) until Retry in the console (POST /api/module/enable {id, retry: true}) or a successfulschedulecall clears it.
x-drop-module-version. Every module response may carry it (the Express
router does). A value ≠ the installed manifest.version sets
ModuleInstall.drift = {version, seen_at} and the console says the endpoint
runs {v}; upload the {v} package to update. The platform never re-reads a
manifest from an endpoint — the package is the contract.