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
A small, opt-in extension point in twenty-orm that lets a feature module transform selected object fields on the way into Postgres (persist) and on the way back out (read), keyed by object and field. One place to register, two directions: a persist transform and a read transform.
Twenty already has half of this. The common result getters pipeline post-processes every record on the way out. What's missing is a symmetric persist-time transform, and a way for a module to register into both without editing WorkspaceRepository and the result-getters service by hand.
Why
Some fields in a Twenty workspace are the sensitive part: a person's name, phone, email, the company they work at. A growing number of teams want those columns to be ciphertext at rest (or tokenized, or redacted for people without a role), while the app keeps working with plaintext in-request. The shapes that need this:
Field-level encryption against a KMS, an HSM, or a threshold-crypto network, so a stolen database or a leaked backup is unreadable and the server never holds a standing key.
Tokenization of PII for compliance scope reduction, detokenized per request.
Role-gated redaction, where a field reads back as plaintext for someone who holds a role and masked for someone who doesn't.
All three are the same operation: transform a value before it's persisted, and transform it back (or not) when it's read.
What it takes today
There is no seam, so you patch twenty-server in two places:
Persist: call a seal function inside WorkspaceRepository, at each site that writes records (insert, save/upsert, update). In our fork that's three call sites in workspace-repository.ts.
Read: call an open function inside common-result-getters.service.ts, which is the one pipeline every API read already flows through.
That works, and the read side is a clean single seam because the result-getters service already touches every record. But it means forking twenty-server rather than shipping a module, and it's brittle: the repository's write paths and the result-getters signature move between versions, and every adopter carries the same patch.
Proposal
A registry of field transformers, consulted by twenty-orm. A transformer names the object fields it owns and implements up to two async hooks:
exportinterfaceFieldTransformer{// Object fields this transformer owns, keyed by object nameSingular.// e.g. { person: ["name", "phone", "emails"], company: ["name"] }fields: Record<string,string[]>// Called before records are persisted (insert / save / update). Return the value to store.onPersist?(ctx: FieldHookContext): Promise<unknown>|unknown// Called as records are read back, in the result-getters pipeline. Return the value to expose.onRead?(ctx: FieldHookContext): Promise<unknown>|unknown}exportinterfaceFieldHookContext{objectNameSingular: string// "person"field: string// "phone"value: unknown// plaintext on persist, stored value on readrecord: Record<string,unknown>// the whole record, for sibling-field accessworkspaceId: stringrequestContext?: unknown// whatever the app threaded for this request (see below)}
Registration is opt-in and module-level. When nothing is registered, both paths are a no-op, so a stock Twenty is byte-for-byte unchanged behind a single if (transformers.length) guard.
Where it hooks
Persist: in WorkspaceRepository, at the point records are about to be written, run onPersist for each owned field on each record before the insert/update statement is built. Today those are the three sites a fork has to touch; the ask is to run them from one shared place inside the repository so a module doesn't edit each one.
Read: register onRead into the existing common result getters pipeline. This is already the universal read seam in Twenty, which is why the read side is the easy half. It just isn't open for a module to register into.
Batching
Transformers that talk to a network (a KMS, an HSM, a cohort) want to batch a page of records into one round trip rather than one call per field. Worth supporting a batched form the pipeline prefers when present, onReadMany(ctxs) / onPersistMany(ctxs), so a list query is one round trip instead of N. Our implementation batches this way.
Request context (the one real design question)
Read transforms are usually request-scoped: open this field as the user this request authenticated, if they hold the role. twenty-orm and the result getters don't carry request identity of their own. Options:
Thread it through the workspace/data-source context Twenty already builds per request, exposing an optional requestContext the transformer reads. Cleaner and explicit.
Or a server-level AsyncLocalStorage the auth layer sets and the transformer reads. This is what our fork does, because we couldn't add to the request context without patching.
The first is preferable if you're open to a field on the per-request context.
Example module (one implementation, not part of core)
A field-encryption module registers:
{fields: {person: ["name","phone","emails"],company: ["name"]},asynconPersistMany(ctxs){/* encrypt each value via the configured backend */},asynconReadMany(ctxs){// decrypt, gated on the request's verified user holding a role;// with no context or no authorization, return the stored (encrypted) value unchanged.},}
The backend is the module's business: a KMS, Vault, an HSM, a threshold-crypto network, in-process AES with envelope keys. Core stays vendor-neutral and ships only the hook. (For reference, the read side can also be done in the browser: return the stored ciphertext and open it client-side. That's a variant of the same seam, not a different one.)
Non-goals
No opinion on crypto, key management, or providers.
No querying on transformed columns. A transformed column is opaque to filters and sorts, the same as it is for any field-level-encryption scheme. Which fields to register is the app's call, and lookup keys stay in the clear by that choice. This does not try to make sealed columns searchable.
No migration tooling for existing rows. A module can backfill through the same hook.
Backward compatibility
Fully additive and opt-in. With no transformers registered, persist and read behave exactly as they do today, behind one guard. No schema changes, no change to the GraphQL surface.
Open questions
Registration surface: a module-level registry, dependency injection into twenty-orm, or both?
Per-field vs batched hooks. Support both, prefer batched?
Request context: a field on the per-request workspace context, or a server AsyncLocalStorage?
Persist side: is one shared call point inside WorkspaceRepository enough to cover insert, save/upsert and update, or do you want the hook expressed per path?
Prior art / reference
We built this end to end against a fork of twenty-server, sealing person and company PII with an external threshold-crypto backend, gated by a role, opened per request (server-side through the result getters, and in one mode client-side in the browser). The whole change is small and lives in the WorkspaceRepository persist paths plus the common result getters. Happy to open a draft PR that adds just the seam, no vendor code.
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.
What I'm asking for
A small, opt-in extension point in
twenty-ormthat lets a feature module transform selected object fields on the way into Postgres (persist) and on the way back out (read), keyed by object and field. One place to register, two directions: a persist transform and a read transform.Twenty already has half of this. The common result getters pipeline post-processes every record on the way out. What's missing is a symmetric persist-time transform, and a way for a module to register into both without editing
WorkspaceRepositoryand the result-getters service by hand.Why
Some fields in a Twenty workspace are the sensitive part: a person's name, phone, email, the company they work at. A growing number of teams want those columns to be ciphertext at rest (or tokenized, or redacted for people without a role), while the app keeps working with plaintext in-request. The shapes that need this:
All three are the same operation: transform a value before it's persisted, and transform it back (or not) when it's read.
What it takes today
There is no seam, so you patch
twenty-serverin two places:WorkspaceRepository, at each site that writes records (insert,save/upsert,update). In our fork that's three call sites inworkspace-repository.ts.common-result-getters.service.ts, which is the one pipeline every API read already flows through.That works, and the read side is a clean single seam because the result-getters service already touches every record. But it means forking
twenty-serverrather than shipping a module, and it's brittle: the repository's write paths and the result-getters signature move between versions, and every adopter carries the same patch.Proposal
A registry of field transformers, consulted by
twenty-orm. A transformer names the object fields it owns and implements up to two async hooks:Registration is opt-in and module-level. When nothing is registered, both paths are a no-op, so a stock Twenty is byte-for-byte unchanged behind a single
if (transformers.length)guard.Where it hooks
WorkspaceRepository, at the point records are about to be written, runonPersistfor each owned field on each record before the insert/update statement is built. Today those are the three sites a fork has to touch; the ask is to run them from one shared place inside the repository so a module doesn't edit each one.onReadinto the existing common result getters pipeline. This is already the universal read seam in Twenty, which is why the read side is the easy half. It just isn't open for a module to register into.Batching
Transformers that talk to a network (a KMS, an HSM, a cohort) want to batch a page of records into one round trip rather than one call per field. Worth supporting a batched form the pipeline prefers when present,
onReadMany(ctxs)/onPersistMany(ctxs), so a list query is one round trip instead of N. Our implementation batches this way.Request context (the one real design question)
Read transforms are usually request-scoped: open this field as the user this request authenticated, if they hold the role.
twenty-ormand the result getters don't carry request identity of their own. Options:requestContextthe transformer reads. Cleaner and explicit.AsyncLocalStoragethe auth layer sets and the transformer reads. This is what our fork does, because we couldn't add to the request context without patching.The first is preferable if you're open to a field on the per-request context.
Example module (one implementation, not part of core)
A field-encryption module registers:
The backend is the module's business: a KMS, Vault, an HSM, a threshold-crypto network, in-process AES with envelope keys. Core stays vendor-neutral and ships only the hook. (For reference, the read side can also be done in the browser: return the stored ciphertext and open it client-side. That's a variant of the same seam, not a different one.)
Non-goals
Backward compatibility
Fully additive and opt-in. With no transformers registered, persist and read behave exactly as they do today, behind one guard. No schema changes, no change to the GraphQL surface.
Open questions
twenty-orm, or both?AsyncLocalStorage?WorkspaceRepositoryenough to cover insert, save/upsert and update, or do you want the hook expressed per path?Prior art / reference
We built this end to end against a fork of
twenty-server, sealing person and company PII with an external threshold-crypto backend, gated by a role, opened per request (server-side through the result getters, and in one mode client-side in the browser). The whole change is small and lives in theWorkspaceRepositorypersist paths plus the common result getters. Happy to open a draft PR that adds just the seam, no vendor code.All reactions