inmake DOCS
Docs / Modules / Identity and access

Identity and access

The install token, module secrets, config on every envelope, fields on core entities, private state and records, atomic commits.

5.1 The install token#

Install mints one store token per install (role: service, note MODULE - {id}, metadata: {module: id, version}, revealable: false — no readable copy exists anywhere) with

  • access = intersectAccess(manifest.access, MODULE_MAX_ACCESS) — the ceiling withholds admin.delete altogether (a module deletes through /admin/commit {op: 'delete'} under the per-path fence, §5.6), and never admits secure.access, system, gen / generative (a module never spends a wallet), auth, promo, or the module control-plane group (the one exception — the provider events routes module.shipment.events, module.invoice.{events, pending}, module.payment.events, module.order.events — is granted by the install handler per declared provides kind, §2.4, never from manifest.access: a manifest's own access.module is stripped before the intersect). admin.create / admin.add on orders / products / customers are refused at runtime (§5.4) — a manifest pairing them with data.entity_fields gets the manifest_admin_entity_write warning at check, not an error;
  • scope = manifest.scope, validated as a ScopeData tree and against the deny list below;
  • customer unset — a module never spends a wallet; every paid route answers 400.

The raw token goes into custody (the platform's secret store under the system parent, main project, unreachable to any token) and to the module in handshake stage token; token_id lands on the install doc. The module uses it exactly like any store token: DropCore.start({store, access_token, target}) and the admin / public / secure facades. Store scoping and the path scope are enforced by the core on every call.

Hard deny list (validateManifest, on top of MODULE_MAX_ACCESS):

  • scope may not grant setup beyond what setup/modules derives (setup/hooks, setup/smtp, setup/payments, setup/analytics are never module-readable), nor members, nor $store write, nor anything under $promo (promo/incore is the platform's wallet store).
  • access may not name secure (except secure.secret), system, mailing.send with platformFallback, or admin.upload unless manifest.data lists a records collection (files a module writes land under files/modules/{id}/**).

/secure/access refuses op: 'update' / 'delete' on a token whose note starts with MODULE - (409 managed_token); managed tokens change only through install / update / reroll / uninstall.

Tier gate (the tier catalog decides). The store's resolved tier (Tier.resolve: the effective plan from store/incore/customers/{sid} — subscription ⊕ platform grant — folded with the catalog store/incore/setup/limits and the store's overrides.{sid}, ../plans/console/tier-limits-gating.md r5) answers two questions, in this order:

  1. features.modules — none / connected / hosted (hosted admits connected). The committed default: tier_0 none, tier_1 connected, tier_2/tier_3 hosted. Not admitted → 403 {error: 'tier_required', plan, hosting, feature} — before any count, before any handshake.
  2. plans.*.modules_max — the per-store install cap (null = unlimited; default 10 on every plan that may install). count >= cap → 403 {error: 'modules_limit', plan, limit, count}. The count is the store's code-module installs — elements modules are not in it.

An elements module (hosting: 'none') answers neither question: every features.modules value admits it (hostingAllowedByFeature, so the catalog marks it admitted on every plan) and it counts against its own cap, plans.*.element_modules_max (absent = the committed default, null = unlimited — the default on every tier), over the store's hosting == 'none' installs only → 402 {error: 'element_modules_limit', plan, limit, count}.

A platform-owned package published with visibility: 'platform' skips step 1 and never meets a cap below the committed default; the count still binds. master is not exempt — the console is master, so the gate is real. The gate is always hard: the catalog's enforce switch governs the metered dimensions (Limits.guard), not this feature gate. A catalog edit (a row, or a store override such as overrides.{sid}.modules: 'hosted') takes effect on the next request — the storeWritten dispatcher bumps the tier cache.

Per call, too. The gateway re-checks features.modules on every call (Gateway.assertTier): a store whose plan lapses below the install's hosting stops calling it (403 tier_required, a platform-side refusal like module_disabled — callbacks take their fallback). The count cap is an install-time rule only; a downgrade never uninstalls. The install doc's tier_exempt stamp means this needs no registry read; a tier read failure fails open here (logged) and closed at install.

In the catalog. POST /api/module/catalog items carry admitted (false = listed, not installable on this plan) and visibility; the console shows Needs a higher plan instead of Install.

5.2 Secrets — module_{id}_* only#

A module's own credentials are what its secret config fields hold. The console writes them write-only through POST /api/module/configure {id, config, secrets} → the store's ordinary secret store as module_{id}_{field}; the response carries has_secrets: {[field]: true}, never values. The module reads them itself with its token through POST /secure/secret when the manifest grants secure.secret — and that grant is fenced: when the caller is a module token, key must match ^module_{id}_, key: '*' is refused (403 module_secret_scope) and a list reply is filtered to that prefix. Without this one rule the deny list's own exception would hand a module every credential the store holds (Stripe, GA4, SMTP). Secrets are never on the envelope and never sent to verify; the platform's custody of the install token sits in a different, unreachable parent.

5.3 What rides every envelope: config#

The install's non-secret config map is on every envelope body (§3.2) — a module never reads its own install doc per call, and a configure in the console reaches the module on its next call. Public fields (public: true) are additionally exposed to the storefront as the derived doc store/{sid}/setup/modules = {public: true, [moduleId]: {publicConfig}}, read through POST /public/setup {name: 'modules'}, so element JS reads widget keys without a round trip.

5.4 Fields on core entities: {entity}.modules.{id}#

orders.modules.{id}, products.modules.{id}, customers.modules.{id} are the module's own annotation space on a core entity (the "app-owned metafield" idea). Enforced in crud.update: when the caller is a module token, every top-level key of the update must be a dotted path modules.{moduleId}.… (or exactly modules.{moduleId}, or timestamp) and the path must be store/{sid}/{orders|products|customers}/{id} for an entity the manifest lists in data.entity_fields; otherwise 403 Module tokens may only write modules.{id} (dotted field paths). Write dotted keys, never a nested object: {"modules.echo.erp_id": "A1"} is accepted, {"modules": {"echo": {"erp_id": "A1"}}} is refused — Firestore's update() patches a dotted path in place but replaces a top-level map wholesale, so the nested form would wipe every other module's field on the same document. create / add / delete on those three collections are refused for module tokens outright. The one exception is module-owned products. A commit create of a type: 'service' product whose id falls under one of the manifest's prefix: scope grants on products ({"products": {"prefix:reservation-": true}}) is allowed with name / code / status / availability / tags / categories / modules.{id}, and the core stamps owner_module. That module may then update those keys (not type) on the products it created. A product the merchant made, even one inside the prefix, stays at modules.{id}. This lets a booking module create and price the products it sells (functions/docs/api.md). The order form renders modules.* under a collapsed, read-only Modules section. A module never writes order.shipping — a carrier's data reaches the core through the callbacks.

5.5 Private state and records#

  • State — store/{sid}/modules/{id}/state/{key}: a KV the module owns (cursors, ERP mappings, watermarks), reachable through /admin/read|update|create|delete with the install token whose scope has modules: {[id]: true}. Writes here fire no events. Keys starting with _ are the platform's — _schedule (the schedule point's bookkeeping) and _cache_{sha256} (the response cache above) live here too; a module reads them but must not write them, and every other key is its own.
  • Records — store/{sid}/modules/{id}/{collection}/{docId}: a records collection is a direct subcollection of the install doc (six segments; state, outbox and logs are reserved names — a records collection named like one is refused at check, collection_reserved, and a write there fires nothing). Transactional, unlocalized rows the module writes through /admin/update or /admin/commit (declared as data.collections[].kind: 'records'; the subtree appears on the first write). Writes fire the module_records event (§6). uninstall {purge} removes every subcollection present under the install doc, declared or not. The console's generic records view lists them (/store/:sid/modules/:id/records/:collection).
  • Content — data.collections[].kind: 'content': ordinary localized collections (data/{locale}/{collection}) registered at install with DataCollection.module = id and a schema. They share the store's data and schema namespaces with the store's own collections and every other module's, so each is named with the module prefix, {id}_{name} (or the bare {id}; e.g. reservations_resources, reservations_services) — a module never "blocks" a common name like services. Refused at check otherwise, collection_prefix. They are listed and edited in the console's collection view, read by the storefront's /content. A module token reaches them only where scope grants the collection explicitly.
  • Files — under files/modules/{id}/**, and only with a records collection declared (the admin.upload rule above).

5.6 Atomic writes: POST /admin/commit#

POST /api/admin/commit
{writes: [{path, op: 'create' | 'set' | 'update' | 'delete', value?,
           precondition?: {exists?: boolean; update_time?: string /* ISO 8601 */}}]}   // ≤ 100 writes
→ 200 {ok: true, results: [{path, update_time}]}
→ 409 {error: 'precondition_failed', index, code: 'ALREADY_EXISTS' | 'FAILED_PRECONDITION'}
→ 403 (any path outside store/{sid}/… or denied by scope)

One Firestore batch, all-or-nothing: create fails when the doc exists; set with {exists: false} is a create; update / delete with update_time require the doc's last update time to match (a compare-and-set over a value read with POST /api/admin/batch {…, meta: true}, which returns _meta: {[path]: {update_time}} beside the docs). Every path must sit under the request's store and pass scope for write. Any token may use it; it exists for modules that sync against an external system and must not half-apply. SDK: admin.data.commit(writes).