@geonosis/integrations
v3.0.0
Published
One integration seam: a prefixed id, an optional settings schema, one registry that refuses a duplicate, a wrong prefix or a missing required id — and settings fields derived from the schema rather than hand-mapped.
Maintainers
Readme
@geonosis/integrations
One integration seam: a prefixed id, an optional settings schema, and a registry that refuses a duplicate id, an id outside its prefix, or a missing required id — at build time, once, instead of on every lookup for the life of the process.
Zero runtime dependencies. It never imports zod.
pnpm add @geonosis/integrationsWhy
A Medusa storefront arrived at the same seam three times, with no shared code between them:
AbstractCrmProvider + buildCrmProviderRegistry (cp_*), AbstractSearchProvider +
buildSearchProviderRegistry (sp_*), and AbstractMessagingChannelProvider +
buildMessagingProviderRegistry (mp_*). Midday is the same story one stage later: five
incompatible plugin seams for one concept, a UnifiedApp manifest with 25 optional fields and
value: any, a settings UI hand-mapped inside a dashboard component, and 8 of 35 integration
directories reachable from nothing.
The measured gain is smaller and sharper than "one shape for all of them". A Medusa storefront declares
export const CRM_PROVIDER_PREFIX = 'cp_' // modules/crm/types.ts:145
export const SEARCH_PROVIDER_PREFIX = 'sp_' // modules/search/types.ts:135and references neither anywhere else in the repo. Both registries are a
Record<string, Provider> built from an object literal, so:
- a key that ignores the prefix is accepted;
- a second entry under an id already taken silently replaces the first, and the one that disappears is whichever was written higher up;
- "the configured default must be registered" is checked inside
getActiveProvider()— on every call, forever — instead of once, when the registry is built.
Two dead constants and three invariants stated in comments and held by nothing. This package holds them.
The boundary
Stated here because a Workers + D1 app closed this package not applicable and gave the reason as "the
registry only models a user-installable integration" — which was a fact about a boundary nobody had
written down. Every line below is measured in
docs/ports-events-inventory-2026-08-30.md,
which read all three a Medusa storefront seams and a Workers + D1 app's app catalogue before a line of this was written.
One registry, many doors. A door is an Integration<Id, Instance>: a prefixed id, a typed
instance, and at most two probes. A registry is a set of doors behind one prefix, validated once
when it is built. Id is the literal so crm.get('cp_attio') is checked rather than hoped, and
Instance is one type for the whole registry — that is the abstract provider a seam is already
written against, not a union of nine shapes.
What a door owns
| owned | why |
|---|---|
| its id, inside the registry's prefix | the prefix constants existed in a Medusa storefront and were referenced by nothing; here they are enforced |
| its instance — whatever the consumer's own contract is | upsertContact, search, parseInbound are the consumer's nine methods and this package never sees them |
| health() and isConfigured() | the two probes AbstractCrmProvider and AbstractSearchProvider both declare. AbstractMessagingChannelProvider declares neither, so both are optional |
| a settingsSchema, structurally { safeParse } | so a settings form is derived. Never imported, never a dependency |
What a door may not own
| not owned | measured |
|---|---|
| a manifest — name, category, description, logo | zero occurrences across all three a Medusa storefront seams; Midday's has 25 optional fields and value: any. A catalogue is presentation, and it stays in the repo whose UI shows it |
| an install lifecycle — installUrl, onCallback, onUninstall | zero occurrences in either consumer. A Medusa storefront's integrations are credential-configured; a Workers + D1 app's have no server half. The slot waits for a consumer that holds one |
| storage — where settings, tokens or install state live | a registry that read a store could not be built at module scope, which is the one moment its three invariants can be checked |
| the lookup key — a door is found by its id and by nothing else | buildMessagingProviderRegistry is keyed by CommunicationChannel and total with holes; see below |
| resolution order or a default beyond requiredId | "the configured default must be registered" is answered once, when the registry is built, not on every getActiveProvider() call forever |
The doors, in full. defineIntegration builds one and isIntegration recognises one — the
registry uses the second to refuse an entry that never went through the first, and it is exported
because a consumer building its own list needs the same answer. createIntegrationRegistry takes
a RegistryInput and hands back an IntegrationRegistry; defineIntegration takes an
IntegrationInput and hands back an Integration. settingsFieldsOf turns a SchemaLike into
SettingsFields. That is the whole surface, and src/boundary.test.ts fails by name if a door is
added without a line here.
The one shape outside the boundary is the channel-keyed registry —
Record<CommunicationChannel, Provider | undefined>, total with holes so a new channel breaks the
build. It is a second registry shape, not a worse version of this one, and it is spelled out at the
end of this file.
API
defineIntegration({ id, instance?, settingsSchema?, health?, isConfigured? })
import { defineIntegration } from '@geonosis/integrations'
import { z } from 'zod'
export const attio = defineIntegration({
id: 'cp_attio',
settingsSchema: z.object({
apiKey: z.string().describe('The Attio API key'),
baseUrl: z.string().optional(),
}),
health: () => client.ping(),
isConfigured: () => apiKey !== undefined,
})Refuses an empty or non-string id, a settingsSchema with no safeParse, and a health or
isConfigured that is not callable.
health and isConfigured are the two probes AbstractCrmProvider and AbstractSearchProvider
both declare. Everything a provider does beyond them — upsertContact, search, parseInbound —
is the consumer's own contract and stays there; this package holds the seam, not the work.
The posture on instances (decided 2026-08-30)
The kit validates the registry AND may hold the instance. A repo that keeps its instances elsewhere passes nothing, declares nothing, and compiles exactly as before.
instance is optional and typed. It exists because a Medusa storefront measured its getCrmProvider('cp_attio')
against this package and found 15 parity checks, 10 divergences, and every one of the ten was this
one missing slot: their seam hands back a provider with nine methods on it, and a registry that
validates entries and then makes the caller go and find the object somewhere else is a registry of
names. Names were never the problem. The three build-time invariants — prefix, duplicate, required —
are unchanged; this is additive.
const attio = defineIntegration({ id: 'cp_attio', instance: attioClient })
const crm = createIntegrationRegistry({ prefix: 'cp_', integrations: [attio, hubspot] })
crm.get('cp_attio').instance.upsertContact(…) // typed, all nine methodsThe type parameter is inferred from the value, so nothing has to be written twice. It defaults to
unknown: with no instance, entry.instance is undefined and its type is unknown, which is
what this package actually knows about it. Every entry in one registry shares one Instance type —
that is the abstract provider a seam is already written against.
createIntegrationRegistry({ prefix, integrations, requiredId? })
const crm = createIntegrationRegistry({
prefix: 'cp_',
integrations: [attio, hubspot],
requiredId: 'cp_attio',
})
crm.get('cp_attio') // throws, naming what IS registered, if absent
crm.has('cp_hubspot') // boolean, no throw
crm.list() // declaration order
crm.required() // the `requiredId` integration
crm.prefix // 'cp_'Refuses, when built: an empty prefix; an entry that never went through defineIntegration; an id
outside the prefix; a duplicate id; a requiredId outside the prefix or absent from the list.
requiredId is a Medusa storefront's DEFAULT_CRM_PROVIDER_ID / DEFAULT_SEARCH_PROVIDER_ID plus the
NOT_FOUND its getActiveProvider() throws — moved to the one moment the question can be answered
once. (Plan 022 called this option manualImplementationRequired; it is named for the consumer
construct it extracts.)
settingsFieldsOf(integration)
A JSON description of the settings schema's fields, so a settings form is derived rather than hand-mapped:
settingsFieldsOf(attio)
// [ { name: 'apiKey', type: 'string', required: true, description: 'The Attio API key' },
// { name: 'baseUrl', type: 'string', required: false } ]optional, default and prefault make a field not required; nullable and readonly are peeled
without changing that; an enum carries its choices as options. An integration with no schema, or
a schema that is not an object of fields, has no fields. A schema this reader cannot see into
throws rather than returning an empty list — a blank settings page with no explanation is the silent
zero this kit refuses everywhere else.
The one shape this package does not replace
A Medusa storefront's third seam is not this shape and is deliberately out of scope:
buildMessagingProviderRegistry(): Record<CommunicationChannel, AbstractMessagingChannelProvider | undefined>It is keyed by CommunicationChannel, not by a prefixed id — the mp_* ids live in a separate
MESSAGING_PROVIDER_IDS map the registry never uses — and it is total with holes: sms,
telegram and webchat are present and undefined, so a new channel breaks the build until
someone answers for it. Its own test asserts exactly that:
expect(reg.sms).toBeUndefined() // providers.test.ts:157A registry that refuses a non-integration destroys the totality that test protects. That is a second registry shape — keyed and total over a declared union — and replacing it with this one would be a downgrade. It is the only registry test either consumer has, which is why it is quoted here rather than summarised.
Migration — a Medusa storefront, five lines
-export function buildCrmProviderRegistry(opts: CrmProviderOptions = {}): Record<string, AbstractCrmProvider> {
- return { [DEFAULT_CRM_PROVIDER_ID]: new AttioCrmProviderService(...), [CRM_PROVIDER_IDS.hubspot]: new HubspotCrmProviderService(...) }
-}
+export const buildCrmProviderRegistry = (opts: CrmProviderOptions = {}) =>
+ createIntegrationRegistry({
+ prefix: CRM_PROVIDER_PREFIX,
+ requiredId: DEFAULT_CRM_PROVIDER_ID,
+ integrations: [attioIntegration(opts), hubspotIntegration(opts)],
+ })getActiveProvider() becomes registry.required(), and its NOT_FOUND throw is deleted — the
registry already refused to exist. CRM_PROVIDER_PREFIX stops being a dead export.
Migration — a client-only catalogue, five lines
The shape the idiom is generalised from is a hardcoded array of 12 AppDefinitions
({ category, description, icon, id, name }) with client-only installedIds and no server half.
Its first real integration is where the seam is worth having:
-const DEFAULT_APPS: AppDefinition[] = [ { id: 'slack', name: 'Slack', ... }, ... ]
+export const apps = createIntegrationRegistry({
+ prefix: 'app_',
+ integrations: [slack, gmail, outlook],
+})The presentation fields (category, description, icon, name) stay in a Workers + D1 app: they are a
UI catalogue, and this package deliberately carries no manifest. See below.
Equivalence status (D-027)
| Piece | Status |
|---|---|
| createIntegrationRegistry — prefix, duplicates, requiredId | extracted — three consumer copies, the invariants they state and do not hold |
| defineIntegration — id, health, isConfigured | extracted — the fields two of the three abstractions declare |
| settingsSchema + settingsFieldsOf | from Midday's failure, no consumer fixture yet — neither consumer holds a settings schema today; the field exists because value: any plus a hand-mapped dashboard component is where its absence leads |
| manifest (name, category, description, logo) | not here — Midday's is 25 optional fields, and neither consumer's provider carries one |
| install lifecycle (installUrl, onCallback, onUninstall) | not here — zero occurrences in either consumer. A Medusa storefront's integrations are credential-configured; a Workers + D1 app's have no server half. It waits for a consumer that holds one |
| the channel-keyed registry | not replaced — see above |
Adopted by: nobody yet. This is a published package with contract tests over both consumers' measured shapes, not a migration that has happened.
zod, and why the guard is structural
settingsSchema is typed structurally — { safeParse } — and checked the same way. This package
never imports zod, and has no runtime dependencies at all. The declared peer is ^4.0.0, optional.
The reason, measured rather than assumed:
| | a Medusa storefront plugins | a Medusa storefront root | a Workers + D1 app |
|---|---|---|---|
| import specifier | @medusajs/framework/zod | zod | zod (catalog) |
| resolved version | 4.2.0 (via @medusajs/deps) | 4.4.3 | 4.5.4 |
Three module instances of one major, across two consumers, with genuinely different constructors and prototypes.
The obvious conclusion from that — "so an instanceof z.ZodType guard would refuse a schema built
by another copy" — is wrong for zod 4, and this package's tests are where that was found out. Zod 4 puts
a Symbol.hasInstance on its classes that answers by an internal trait rather than by the prototype
chain, so instanceof bridges copies of zod 4 fine. It does not bridge zod 3, whose schemas are
plain classes — and a Medusa storefront's own root package declares its zod peer as ^3.0.0 || ^4.0.0.
So the structural guard is still the right one, for the reasons that survived the measurement: it
asks what a schema does, which every copy and every major answers identically; and it needs no
import, which keeps this package out of the version disagreement entirely. settingsFieldsOf reads
zod 4 internals and says so loudly when handed something else.
src/realms.test.ts asserts all of it against three real copies of zod (4.5, 4.2 and 3.25) in one
process, starting with the assertion that they are genuinely different modules — without which the
rest would pass and prove nothing.
Consumable from CommonJS
A Medusa backend is module: Node16 CommonJS by upstream requirement, not by preference. This
package ships both builds and a require condition carrying its own .d.cts, so a static import
type-checks and require() works:
import { defineIntegration } from '@geonosis/integrations' // ESM, module: Node16 or ESNext
const { defineIntegration } = require('@geonosis/integrations') // CommonJSAn ESM-only package is a wall for such a backend, and no amount of parity gets over it: a Medusa storefront
measured @geonosis/testbed at 15/15 identical and still could not adopt it, because a static
import is TS1479 and await import() cannot serve the synchronous describe() registration its 107
suites are built on.
Licence
Apache-2.0
