You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a small, opt-in extension point that lets a module or plugin transform selected entity fields on the way into Postgres (on create/update) and on the way back out (when the repository serializes a read). One registration point, two hooks: onWrite and onRead, keyed by entity and field.
This is the seam you need for field-level encryption, tokenization, format-preserving masking, and PII redaction. None of those has a clean home in Medusa today. It stays vendor-neutral on purpose: the framework ships the hook, not any particular crypto or provider.
Motivation
A commerce database holds the data a breach is actually about: customer names, phone numbers, addresses, and everything that joins to them. More and more teams want some of those columns to be ciphertext at rest (or tokenized, or redacted for certain roles), while the app keeps working with plaintext in-request.
A few concrete cases:
Field-level encryption against an external KMS, an HSM, or a threshold-crypto network, so a stolen database or a leaked backup is unreadable and the app process never holds a standing key.
Tokenization of PAN-adjacent or PII fields for PCI/GDPR scope reduction, with detokenization gated per request.
Role-gated redaction, where a field returns plaintext for a staff member who holds a role and a masked value for one who does not.
All three are the same shape. Transform a value before it is persisted, and transform it back (or not) when it is read, based on request context.
The problem today
There is no extension point for this. To do it now you have to patch the framework's data layer, specifically MikroOrmBaseRepository in @medusajs/utils:
seal or transform inbound data inside create and update, before manager.create / manager.assign;
transform outbound records inside serialize, which is the one place every module service (and remoteQuery through it) funnels reads through.
That works, but it means forking @medusajs/utils, and a fork:
can't ship as a plugin (plugins can't patch core),
is brittle across upgrades, since the repository internals move, and
makes every adopter maintain a fork of the framework instead of running npm i on a package.
We built this exact integration as a proof of concept, and the change is small: a handful of lines in create/update/serialize, plus a per-request reader identity. The only thing stopping it from shipping as a plugin is that the seam isn't exposed.
Proposal
A registry of field transformers, consulted by the base repository. A transformer targets one or more entity.field pairs and implements up to two async hooks:
exportinterfaceFieldTransformer{// Which entity fields this transformer owns, e.g. { Customer: ["first_name", "phone"] }.fields: Record<string/* entity name */,string[]/* field names */>// Called before a create/update payload is persisted. Return the value to store.onWrite?(ctx: FieldHookContext): Promise<unknown>|unknown// Called after a record is loaded/serialized, before it is returned. Return the value to expose.onRead?(ctx: FieldHookContext): Promise<unknown>|unknown}exportinterfaceFieldHookContext{entity: string// "Customer"field: string// "first_name"value: unknown// current value (plaintext on write, stored value on read)record: Record<string,unknown>// the row/payload, for sibling-field accessrequestContext?: unknown// whatever the app threaded for this request (see below)}
Registration is opt-in and lives at the app or plugin level (a fieldTransformers array in config, or container.register). When nothing is registered the code path is a no-op, so existing apps are byte-for-byte unaffected.
Where it hooks
Write: in MikroOrmBaseRepository#create and #update, for each field a transformer owns, value = await transformer.onWrite(ctx) before the entity is created or assigned.
Read: in MikroOrmBaseRepository#serialize, after mikroOrmSerializer, for each field a transformer owns, value = await transformer.onRead(ctx).
serialize is the right read seam because every generated module service method (retrieve/list/listAndCount) serializes through this.baseRepository_.serialize, and remoteQuery / query.graph reaches the data through those service methods. So a single hook there covers the module API, the admin API, and the store API without touching each route. One thing we learned the hard way: hooking only the per-entity repository's serialize missed remoteQuery. Moving the hook into the base serialize that baseRepository_ uses covered every read.
Batching
onRead/onWrite are called per field, but a transformer that talks to a network (a KMS, an HSM, a cohort) wants to batch. Two ways to handle it, and I'd lean toward the second:
keep the per-field signature and let transformers batch internally with a microtask or dataloader; or
add a batched form the base repository prefers when present, onReadMany(ctxs: FieldHookContext[]) / onWriteMany(...), so a whole page of records is one round trip.
Our POC batches a page of records into a single network call, so a list endpoint costs one round trip instead of N. Exposing the *Many form makes that natural.
Request context (the one real design question)
Read transforms are usually request-scoped: open this field, as the staff member this request authenticated, if they hold the role. The repository has no request context of its own. Two options:
Thread it through the existing Context that repository methods already accept, adding an optional requestContext / transformContext field the HTTP layer populates. This is the cleaner, more explicit one.
Or expose a framework-level AsyncLocalStorage that the auth middleware sets and transformers read. This is what our POC does, since we couldn't add to Context without patching.
I'd prefer the first if the team is open to a field on Context. It avoids AsyncLocalStorage and keeps the data flow explicit.
Example plugin (one implementation, not part of core)
A field-encryption plugin registers something like:
{fields: {Customer: ["first_name","last_name","phone","company_name"]},asynconWriteMany(ctxs){/* encrypt each value via the configured backend */},asynconReadMany(ctxs){// decrypt, gated on the request's verified actor holding a role;// with no context / not authorized, return the stored (encrypted) value unchanged.},}
The backend is the plugin's business: AWS KMS, Vault Transit, an HSM, a threshold-crypto network, in-process AES with envelope keys. Core stays vendor-neutral, and this RFC ships only the hook.
Non-goals
No opinion on crypto, key management, or providers.
No querying on transformed columns. A transformed column is opaque to WHERE/ORDER BY, the same as it is for any field-level-encryption scheme. Lookup keys stay in the clear by the app's choice of which fields to register. This RFC does not try to make sealed columns searchable.
No migration tooling for existing rows. A plugin or app can do a one-time backfill through the same hook.
Backward compatibility
Fully additive and opt-in. With no transformers registered, create/update/serialize behave exactly as they do today, behind a single if (transformers.length) guard. No schema changes.
Open questions
Registration surface: a fieldTransformers config array, a container registration, or both?
Per-field vs batched hooks (onRead vs onReadMany). Support both, prefer batched?
Request context: an optional field on the existing Context, or a framework AsyncLocalStorage?
Should link/graph resolution expose the same seam, or is routing all reads through baseRepository_.serialize enough? In our testing it was enough.
Prior art / reference
We implemented this end to end against a fork of @medusajs/medusa, sealing customer and address PII with an external threshold-crypto backend, gated by a role, opened only in-request. The whole change is small and lives entirely in the three base-repository methods plus one auth-middleware line. Happy to open a draft PR that adds the hook (no vendor code, just the seam)
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Add a small, opt-in extension point that lets a module or plugin transform selected entity fields on the way into Postgres (on
create/update) and on the way back out (when the repository serializes a read). One registration point, two hooks:onWriteandonRead, keyed by entity and field.This is the seam you need for field-level encryption, tokenization, format-preserving masking, and PII redaction. None of those has a clean home in Medusa today. It stays vendor-neutral on purpose: the framework ships the hook, not any particular crypto or provider.
Motivation
A commerce database holds the data a breach is actually about: customer names, phone numbers, addresses, and everything that joins to them. More and more teams want some of those columns to be ciphertext at rest (or tokenized, or redacted for certain roles), while the app keeps working with plaintext in-request.
A few concrete cases:
All three are the same shape. Transform a value before it is persisted, and transform it back (or not) when it is read, based on request context.
The problem today
There is no extension point for this. To do it now you have to patch the framework's data layer, specifically
MikroOrmBaseRepositoryin@medusajs/utils:datainsidecreateandupdate, beforemanager.create/manager.assign;serialize, which is the one place every module service (andremoteQuerythrough it) funnels reads through.That works, but it means forking
@medusajs/utils, and a fork:npm ion a package.We built this exact integration as a proof of concept, and the change is small: a handful of lines in
create/update/serialize, plus a per-request reader identity. The only thing stopping it from shipping as a plugin is that the seam isn't exposed.Proposal
A registry of field transformers, consulted by the base repository. A transformer targets one or more
entity.fieldpairs and implements up to two async hooks:Registration is opt-in and lives at the app or plugin level (a
fieldTransformersarray in config, orcontainer.register). When nothing is registered the code path is a no-op, so existing apps are byte-for-byte unaffected.Where it hooks
MikroOrmBaseRepository#createand#update, for each field a transformer owns,value = await transformer.onWrite(ctx)before the entity is created or assigned.MikroOrmBaseRepository#serialize, aftermikroOrmSerializer, for each field a transformer owns,value = await transformer.onRead(ctx).serializeis the right read seam because every generated module service method (retrieve/list/listAndCount) serializes throughthis.baseRepository_.serialize, andremoteQuery/query.graphreaches the data through those service methods. So a single hook there covers the module API, the admin API, and the store API without touching each route. One thing we learned the hard way: hooking only the per-entity repository'sserializemissedremoteQuery. Moving the hook into the baseserializethatbaseRepository_uses covered every read.Batching
onRead/onWriteare called per field, but a transformer that talks to a network (a KMS, an HSM, a cohort) wants to batch. Two ways to handle it, and I'd lean toward the second:onReadMany(ctxs: FieldHookContext[])/onWriteMany(...), so a whole page of records is one round trip.Our POC batches a page of records into a single network call, so a list endpoint costs one round trip instead of N. Exposing the
*Manyform makes that natural.Request context (the one real design question)
Read transforms are usually request-scoped: open this field, as the staff member this request authenticated, if they hold the role. The repository has no request context of its own. Two options:
Contextthat repository methods already accept, adding an optionalrequestContext/transformContextfield the HTTP layer populates. This is the cleaner, more explicit one.AsyncLocalStoragethat the auth middleware sets and transformers read. This is what our POC does, since we couldn't add toContextwithout patching.I'd prefer the first if the team is open to a field on
Context. It avoidsAsyncLocalStorageand keeps the data flow explicit.Example plugin (one implementation, not part of core)
A field-encryption plugin registers something like:
The backend is the plugin's business: AWS KMS, Vault Transit, an HSM, a threshold-crypto network, in-process AES with envelope keys. Core stays vendor-neutral, and this RFC ships only the hook.
Non-goals
WHERE/ORDER BY, the same as it is for any field-level-encryption scheme. Lookup keys stay in the clear by the app's choice of which fields to register. This RFC does not try to make sealed columns searchable.Backward compatibility
Fully additive and opt-in. With no transformers registered,
create/update/serializebehave exactly as they do today, behind a singleif (transformers.length)guard. No schema changes.Open questions
fieldTransformersconfig array, a container registration, or both?onReadvsonReadMany). Support both, prefer batched?Context, or a frameworkAsyncLocalStorage?baseRepository_.serializeenough? In our testing it was enough.Prior art / reference
We implemented this end to end against a fork of
@medusajs/medusa, sealing customer and address PII with an external threshold-crypto backend, gated by a role, opened only in-request. The whole change is small and lives entirely in the three base-repository methods plus one auth-middleware line. Happy to open a draft PR that adds the hook (no vendor code, just the seam)All reactions