7.1 Layout#
Every module carries its documentation. The package's README.md is the
admin-facing guide — what the module does, how to set it up, what it writes
and what uninstalling keeps — and docs/{locale}.md adds translations
(docs/cs.md, docs/en.md; the store-locale spelling docs/cs_CZ.md is
accepted too). The console shows it on the module page's Documentation
tab and in the catalog before install, in the reader's console language,
falling back to README.md. Markdown only (GitHub-flavoured), ≤ 256 KiB per
file, no subfolders under docs/; relative links and images are not
resolved in v1 (absolute https: only). Upload rows: docs_missing
(error for every target — neither README.md nor a docs/*.md),
docs_invalid (a docs/ file not named {locale}.md, or not UTF-8),
docs_size, docs_empty (warn — a README under ~200 characters). The
starter's module-pack runs the same rows before upload. Served by
POST /api/module/docs (§8.6).
One more file may sit in docs/: docs/elements.md, the element guide —
the storefront contract for building elements on the module (the runtime
API, the /m/{id}/* routes with their shapes, data attributes, events,
translation keys, one minimal example). It is not a language file (the
{locale}.md rule takes two letters), is served by the same route with
kind: 'elements', and is what the agents read through readModuleGuide.
A kit that declares a shared script (global.js, or the legacy runtime)
without it is docs_elements_missing (warn).
{id}-{version}.zip
├── manifest.json # ModuleManifest (§2) — the only required file
├── module.wasm # hosted/platform only (later phase); forbidden for connected and none
├── src/ # optional TS/JS source; deploy: managed → the function source (src/package.json + its `main`); never in a none package
├── ui/ # optional static admin pages: .html .js .css .svg .png .woff2 .json only (later phase); never in a none package
├── kit/{kitId}/ # optional element kit(s) in the templates/ format (template.json + elements); required for none
│ ├── global.css # optional global stylesheet, named by template.json "global.css" (≤ 128 KB) → elements/{id}_runtime css
│ └── global.js # optional shared storefront script, "global.js" (or the legacy "runtime": "runtime.js"; ≤ 128 KB) → elements/{id}_runtime js
├── templates/{id}.{locale}.hbs # optional e-mail templates seeded into data/{locale}/templates/{id}; never in a none package
├── translations/{locale}.json # optional {{t}} keys merged into data/{locale}/translations/{group} — never overwriting
├── schemas/*.json # the collection schemas the manifest references; never in a none package
├── indexes.json # deprecated, do not ship — declare `collection.keys` in the manifest
├── README.md # REQUIRED: the module's documentation (or docs/{locale}.md)
├── docs/{locale}.md # optional localized documentation: docs/cs.md, docs/en.md, docs/cs_CZ.md
├── docs/elements.md # optional element guide — expected when a kit ships a global.js / runtime (docs_elements_missing)
└── CHANGELOG.md # required for a platform-catalog packageAn elements module (hosting: 'none', §2.1) ships the subset
manifest.json, README.md / docs/, CHANGELOG.md, kit/ and
translations/ — module.wasm, src/, ui/, schemas/ and templates/
are static_file errors (§7.3).
README.md, docs/ and LICENSE are allowed at the root; nothing else is. A
connected module's package is usually manifest.json alone — the code
lives on the module's own host and the package is the contract. When it
carries storefront assets, kit/, templates/ and translations/ are the
only trees the install reads back (§8.2 step 5c): a translations file is
{group: {leaf: "text"}} (a top-level object = one {{t}} group document,
nested objects = deeper dotted keys; a top-level "a.b": "text" splits at
the first dot, a bare top-level string lands in the module's own {id}
group) — the echo package (examples/module-echo/package/) is the smallest
complete example.
A deploy: 'managed' package carries its code: src/ is zipped as-is
(the src/ prefix stripped) and deployed as a Cloud Functions gen2 HTTP
function (§8.7). src/package.json is required and its main must export
the handler as module (the functions framework's FUNCTION_TARGET) —
an Express app from expressModuleRouter works as it is. Cloud Build runs
npm install against the public registry, so a dependency that is not
published there (drop-core-server is not) must be bundled into the
source; src/ files are capped at 1 MiB each (src_size) and
node_modules/ is refused (src_node_modules). The worked example is
npm run pack:managed in examples/module-echo/ (esbuild bundles the echo +
the module router into one ~22 KB index.js, express stays a real
dependency).
7.2 npm run module:pack#
In the vendor starter (scripts/vendor-core/, handed out through
docs/VENDOR.md): builds {id}-{version}.zip from a module folder and runs
the stage-1 rows it can run locally — validateManifest, kit_lint, the
hash chain (packageHashes) — so a developer sees stage 1 before uploading.
The zip is what the console's Upload package takes; there is no manifest
paste, no manifest_url, no CLI or MCP upload. The loop is edit → pack →
upload (below).
Publishing#
Three doors, one pipeline (§7.3 stages 1–3):
| Who | Target | Lands as | Then |
|---|---|---|---|
A platform admin (master / administrator), for a client |
organization |
published at once |
installable in every store of that organization |
A client developer (the developer platform claim, a member of the store) |
organization only |
in_review |
a platform admin approves it in the review queue (/platform/modules) → published; rejected → the notes ride the version |
| A platform admin | platform (the catalog) |
in_review |
the review queue; visibility: 'all' needs a second person (two-person rule) |
The developer door is the console proxy's (admin/server/api-proxy.mjs):
it admits /system/module/upload for a developer claim that is a member of
the upload's store, refuses a platform target, and stamps
x-drop-submission: review + the verified e-mail (submitted_by) — the
core parks an organization version in_review only on that header, which
the proxy strips from every client request. Store owners without the claim
cannot upload.
7.3 What the check runs — stages 1–2#
validatePackage(zip, ctx) (server, module_package.ts) is pure: bytes in,
report out. The manifest part is validateManifest in core-lib, so the
console pre-flight (fflate + validateManifest in the browser, before the
bytes leave) and module:pack emit the same row ids. Every check is one
report row {id, level: 'error' | 'warn' | 'info', message, detail?}; any
error row → 422 {report} and nothing is written anywhere. Row ids
are the contract between the four runners; the ids below are the ones the
plan fixes — the rest are named by validateManifest / validatePackage
when they land and this table follows.
| Stage · check | Row id | Level | Meaning |
|---|---|---|---|
| 1 · archive | (archive rows) | error | ≤ 25 MB, ≤ 500 entries; no .., leading / or backslash in a path; no symlinks; no entry outside the allowed top-level set of §7.1 |
| 1 · manifest — id / shape | manifest_invalid: {field} |
error | parses; MODULE_ID_PATTERN; tags shape; semver strictly greater than every existing version of the key; vendor; name / description free of HTML; config.fields keys unique |
| 1 · manifest — category | manifest_invalid: category |
error | category ∈ MODULE_CATEGORIES and, for a later version, equal to the registry record's |
| 1 · manifest — hosting fixed | hosting_immutable (detail: {previous, hosting}) |
error | a later version whose hosting differs from the previous version's (any change, incl. to or from none) — update would answer every install 409 hosting_changed (§8.4); a new hosting is a new module id |
| 1 · manifest — hosting by target | hosting_target, endpoint_invalid |
error | target: 'platform' requires hosting ∈ {hosted, platform, none} — or connected with an endpoint on a MODULE_PLATFORM_ENDPOINTS origin (a deploy: 'managed' package never qualifies); target: 'organization' allows all four; endpoint required iff connected and passes assertPublicEndpoint. A platform-operated origin in an organization package is endpoint_invalid / admin_page_url with reason platform_only |
| 1 · manifest — grants | (manifest row) | error | access ⊆ MODULE_MAX_ACCESS minus the deny list (§5.1); scope leaves valid for the store entity roots |
| 1 · manifest — collections | (manifest row) | error | every collection named in events[] / data.collections[] has a schema in schemas/ |
| 1 · manifest — reserved records name | collection_reserved |
error | a records collection named state, outbox or logs (the install doc's reserved subcollections, §5.5), or with a key outside [a-z][a-z0-9_-]{0,40} |
| 1 · manifest — content collection prefix | collection_prefix |
error | a content collection not named {id}_{name} (or the bare {id}) — the shared data/{locale}/ namespace stays free of module-taken common names |
| 1 · manifest — entity writes | manifest_admin_entity_write |
warn | access grants admin.create / admin.add / admin.update together with data.entity_fields — a reminder that the core refuses create / add / delete on those entities at runtime (§5.4); admin.delete is never in MODULE_MAX_ACCESS |
| 1 · manifest — callbacks | (manifest row) | error | callbacks ⊆ MODULE_CALLBACKS |
| 1 · manifest — admin pages | manifest_invalid: admin.pages, admin_page_url |
error | page shape: capability ∈ the mount capabilities, roster a boolean; url on the endpoint origin (connected) or inside ui/ (package) |
| 1 · manifest — uninstall point | routes_invalid (detail.field: 'uninstall') |
error | routes.uninstall not a boolean, or true on a non-connected module |
| 1 · manifest — booking policy | callbacks_policy_required |
error | callbacks_policy.order_validate must be explicit when category === 'booking' |
1 · manifest — provides |
provider_kind, provider_category, provider_callbacks, provider_services, provider_points_required |
error | the carrier rules of §2.4 |
1 · manifest — provides gate |
provider_unsupported |
error | refused when the platform build does not support the kind |
| 1 · manifest — egress hosts | egress_host |
error | hostnames only — no scheme, port or IP literal; none of the platform's own (*.inmake.dev, *.inbs.dev, *.googleapis.com, *.firebaseio.com, *.run.app, *.web.app, *.firebaseapp.com, *.cloudfunctions.net — the platform-endpoint allowlist never applies to egress) and never the GCE metadata server (metadata.google.internal, metadata, 169.254.169.254) |
| 1 · manifest — egress wildcard | egress_wildcard |
info | one wildcard form is allowed: a single leading * label followed by ≥ 2 literal labels (*.api.mailchimp.com yes; *, *.com, api.*.com, *mailchimp.com no), and the expansion must not match a denied host. The runner's glob also matches multi-label expansions (a.b.api.mailchimp.com) — the info row records that residual for the reviewer |
| 1 · manifest — limits | (manifest row) | warn | a limits value clamped to the tier profile |
| 1 · manifest — public secret | (manifest row) | error | public: true on a secret field |
1 · module.wasm |
wasm_unexpected |
error | present in a connected package — nobody would run it. (For hosted / platform: required, ≤ 4 MiB, compiles, exports call — the runner's one entry point, the point rides x-drop-point (wasm_export_missing) — and imports only the Extism namespaces extism:host/env / extism:host/user: a WASI import is error wasm_import, another namespace warn wasm_import) |
1 · ui/ |
(ui rows) | error / warn | extension allowlist, ≤ 4 MiB per file, ≤ 16 MiB total; the secret regex (sk_live_|sk_test_|whsec_|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|AIza[0-9A-Za-z_-]{35}|ya29\.) is an error; an external <script src> / @import / fetch('https://…') literal is a warn |
1 · kit/ |
kit_lint |
error | the element-kit checks of core-lib utils/kit_lint.ts (the format: templates/README.md); every element id starts {moduleId}- |
1 · kit/ — runtime |
kit_runtime_missing, kit_runtime_size |
error | template.json.runtime must name a .js file at the kit root (KIT_RUNTIME_PATTERN, no folders) that the kit carries; ≤ KIT_RUNTIME_MAX_BYTES = 128 KB (same kit_lint) |
1 · kit/ — global |
kit_global_missing, kit_global_size, kit_runtime_size, kit_runtime_conflict |
error | template.json.global = {css?, js?}, each a file at the kit root the kit carries (.css / .js); global.css ≤ KIT_GLOBAL_CSS_MAX_BYTES = 128 KB, global.js ≤ KIT_RUNTIME_MAX_BYTES; global.js and runtime are one slot — declaring both is kit_runtime_conflict (same kit_lint, module kits only). Errors at upload; the mount (install / update / restamp) tolerates these rows on a version published before them — it logs a warning and mounts without the offending part (no global.css; no global.js, the legacy runtime instead) — while every other kit row still refuses the mount |
1 · kit/ — assets |
kit_assets_ignored |
warn | template.json.assets or an assets/ folder — nothing there is mounted; reference external URLs or the store's design system from global.css |
| 1 · manifest — elements kits | kits_required |
error | hosting: 'none' only: kits missing, empty, not a string array, or naming a kit more than once (detail.duplicates) |
1 · kit/ — elements package |
kits_missing |
error | hosting: 'none' only: a manifest.kits entry with no kit/{id}/ in the package (a code module's mount skips such a kit with a warning instead) |
| 1 · elements package — files | static_file (detail.path) |
error | hosting: 'none' only: the package carries module.wasm, src/, ui/, schemas/ or templates/ — nothing would run, serve or read them |
1 · docs/ — element guide |
docs_elements_missing |
warn | a kit declares a shared script (global.js, or the legacy runtime) but the package has no docs/elements.md — the guide others build elements from (§7.1); global.css alone needs no guide |
| 1 · manifest — console pages | admin_pages_missing |
warn | the module has a console side (routes.admin or a records collection) but declares no admin.pages[] — every such module gives the store at least one page: it is what the Data page lists first for the module and what a manager pins to the Home menu (Pin to Home on the page, the module's Pages card and the Data row) |
1 · templates/, translations/ |
(template rows) | error | Handlebars compiles (a stub {{t}} registered), locale ids xx_YY, translation values are strings |
1 · schemas/, indexes.json |
(schema rows), indexes_deprecated |
error / warn | SchemaEntity shape (the same guard writeSchema runs); a collection.keys map is validated with core-lib's validateKeys (≤ 8 keys, 1–4 fields, names ^[a-z][a-z0-9_]{0,31}$, no id / _k — error); data.indexes / indexes.json is warn indexes_deprecated — keys replace composites |
1 · CHANGELOG.md |
(changelog row) | error / info | required for the platform target with a heading for manifest.version; info when absent from an organization package |
| 1 · hashes | — | — | sha256 per file, wasm_hash, package_hash = sha256 over the sorted "{path}\0{sha256}\n" lines — computed server-side from the received bytes; a hash in the request is ignored |
| 2 · policy scan | (policy rows) | warn | the secret regex over every text file; a high-entropy token (≥ 40 base64 chars in a string literal in .js / .json); binaries only where allowed; src/ without node_modules/ and no file above 1 MiB; a literal hostname in src/ (or the wasm's data segments) missing from egress is a warn ("names a host it may not call"), an egress host that appears nowhere is info |
| 2 · kit egress | kit_egress |
warn | a host literal in any kit/**/*.js (element scripts and the runtime — code that runs first-party in the shopper's browser) that the manifest's egress does not cover; XML namespace URIs (www.w3.org) are ignored. Shown to the reviewer |
Stage 3 — the dry run: hosted / platform packages are
instantiated on the dedicated module-runner-check service (never a
service that carries live install tokens) with a placeholder token minted
for the check (access: {}, scope: {}, revoked afterwards) and
egress: []; install {dry_run: true} and verify run under a 10 s wall
clock, then one synthetic envelope per other declared point. The rows:
stage3_instantiate (the module does not instantiate), install_trap,
install_fuel (> 50 % of the clamped install budget), verify_trap,
platform_path_denied (a platform_fetch path outside the route tree the
install token will carry — access ∩ the ceiling plus the report-back routes
a provides kind earns, installAccessFor in module_rules.ts; an
invoicer's module/invoice/pending passes, a manifest's own access.module
never does),
egress_attempted (a host not in egress) — all error;
synthetic_trap (warn); runner_unavailable (error — no check service
in the env, or it did not answer: retry the upload). The report's
measured block carries instantiate_ms, fuel / time per call and
platform_paths_attempted. The objects of a version that fails stage 3 are
deleted again (no record was written). A connected package has no stage
3 — its endpoint is proven at install (§4.1) — and its report carries
stage_reached: 2; neither has an elements package (hosting: 'none':
there is no code to run), whose upload and re-check never reach the runner
(Registry.upload / Registry.check dry-run hosted / platform only).
Stage 4 — review: a platform-catalog version is in_review
after stage 3 and a platform admin approves or rejects it on
/platform/modules (visibility: 'all' needs a second person — the
reviewer must not be the submitter, 403 two_person_rule). An organization
package is published at once.
7.4 The report#
interface ModuleCheckReport {
ok: boolean; // no 'error' rows
at: number;
stage_reached: 1 | 2 | 3; // 2 for a connected or an elements (none) package
elements_count?: number; // the listed kits' element count (sum of template.json order[] lengths)
checks: Array<{id: string; level: 'error' | 'warn' | 'info'; message: string; detail?: Entity}>;
measured?: {…}; // the dry run's numbers (later phase)
hashes: {package: string; wasm?: string;
files?: Record<string, string>; // in the upload RESPONSE only
files_count?: number}; // what the registry record keeps
diff?: {against: string; manifest: string[]; grants_widened: boolean; egress_added: string[];
schema_fields_removed: string[]; files_changed: string[]; files_added: string[]; files_removed: string[]};
}diff compares with the previous version of the same registry key — its
highest version of any state, against that version's own manifest snapshot
(every registry version record carries its manifest, immutable like the
bytes). The report is stored on the registry version record without
hashes.files (the per-file map is in the upload response only, shown once
by the console, and in the .hashes.json object beside the package — the next
upload's file-diff base; the record keeps hashes.files_count), copied onto
ModuleInstall.report at install / update, and rendered by the console's
ModuleCheckReportCard (rows grouped by level, the diff with the old → new
grant trees). elements_count is set whenever the manifest lists kits — the
sum of the listed kits' template.json order[] lengths (an unlisted kit
does not count); it survives the summary, so the catalog's
versions[].report carries it for the Elements chip.
7.5 Where packages live#
Objects are written create-only to the env's core bucket
(modules/platform/{id}/{version}/… or modules/org/{org}/{id}/{version}/…)
plus one registry record modules/{key}. manifest.json is
written first, so a second upload of the same version fails on the first
write with 409 version_exists and nothing is overwritten; a .hashes.json
sidecar (the per-file map, the next upload's file-diff base) is written
beside it. A published version's bytes never change — a bad one is yanked,
not patched. Every version record on the registry carries its own manifest
snapshot and its report summary, so the catalog, the diff base and the
unpublish snap-back never read the bucket. The install / update does read
it — once: it lists the version's prefix and downloads only the named kits'
trees, the named templates' .hbs files and every translations/*.json
(never src/, ui/, schemas/), then writes the mounts onto the store's
own cloud (§8.2).