- TypeScript 95.8%
- Shell 2.6%
- PowerShell 1%
- JavaScript 0.6%
|
All checks were successful
Build and push Docker image / docker (push) Successful in 1m18s
Now that CI publishes an image, a deployment needs neither repository nor
a toolchain. The quick start is two curls, three seds and a chown:
curl compose.example.yml -> compose.yml
curl .env.example -> .env
docker compose up -d
compose.example.yml pulls git.derg.cz/ulysia/docmost-freenterprise:main.
The old build-from-source version moves to compose.dev.yml, unchanged
apart from its header; the two differ in exactly one service, so there is
no second copy of the sidecar or database config to keep in sync.
Building from source becomes a section at the end of INSTALL.md rather
than the main path. It still needs both clones, the wrapper Dockerfile
and the patch stack, and all of that is still documented — it just is not
what most people should read first.
Also written down: `main` is a moving tag, so `docker compose pull` is
what advances a deployment, and every build publishes a :<short-sha> tag
to pin to instead. Each image carries the upstream Docmost commit as a
label, with the docker inspect line to read it, because the version that
matters is in neither repository alone — a bundle SHA says nothing about
which upstream it was built against.
Noted that a private package or repository makes both the curls and the
image pull need credentials, since that is the first thing that will bite
someone following this on a fresh host.
|
||
|---|---|---|
| .github/workflows | ||
| api-key | ||
| attachments-ee | ||
| audit | ||
| base | ||
| docker | ||
| document-import | ||
| docx-export | ||
| group-role | ||
| licence | ||
| lockfile | ||
| maintenance | ||
| mfa | ||
| openapi | ||
| page-permission | ||
| page-verification | ||
| patches | ||
| pdf-export | ||
| personal-space | ||
| scim | ||
| scripts | ||
| shared | ||
| sso | ||
| template | ||
| typesense | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| compose.dev.yml | ||
| compose.example.yml | ||
| DEPLOYMENT.md | ||
| di-check.ts | ||
| docker-compose.ee.yml | ||
| ee.module.ts | ||
| INSTALL.md | ||
| ISSUES.md | ||
| README.md | ||
| SETUP-DEV.md | ||
| TODO.md | ||
| upstream.pin | ||
| VERIFICATION.md | ||
Docmost Freenterprise — EE bundle
A self-hosted replacement for Docmost's private ee submodule
(github.com/docmost/ee), which is closed-source and unavailable.
This directory is its own git repository
(ssh://git@git.derg.cz/ulysia/docmost-freenterprise.git), checked out into
the parent Docmost fork at apps/server/src/ee/ and gitignored there.
Why this works at all
Docmost's OSS core is built to load enterprise code that it doesn't ship. Two mechanisms:
1. Scattered require() hooks. Core calls into EE at specific points,
each wrapped in try/catch:
// apps/server/src/core/auth/strategies/jwt.strategy.ts
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
return ApiKeyService.validateApiKey(payload);
Each hook has a documented fallback when the module is absent — silent no-op,
thrown error, or a "requires a valid enterprise license" message. Every
module in this bundle documents its own hook contract in its header comment.
Read those first; they cite exact file:line call sites in core.
2. The root aggregator. app.module.ts does
require('./ee/ee.module')?.EeModule and pushes it into the root imports.
Anything registered under EeModule becomes resolvable to every
ModuleRef.get(..., { strict: false }) hook above, and any controller it
mounts gets routed.
The key insight
Core already ships the schema, and usually the client, for every EE
feature. All 39 tables have migrations; there is no gap between the schema
Kysely knows about and what migrations create. So almost nothing here needed a
new migration — user_mfa, api_keys, audit, auth_providers,
scim_tokens, page_verifications, base_* all already existed.
The client is frequently complete too. When implementing a feature, the API
contract is usually already sitting in apps/client/src/ee/<feature>/ —
reverse-engineer it from there rather than inventing routes. Several
non-obvious route names (/docx-export, /search-attachments,
/pages/verification-info) are fixed by client code we don't control.
Status
| Feature | State | Notes |
|---|---|---|
| SSO — OIDC | implemented | Authentik-targeted. Local-account merge via SSO_ACCOUNT_MERGE; group-claim sync via SSO_OIDC_GROUP_SYNC. SAML/Google/LDAP are stubs. |
| MFA | implemented | TOTP + backup codes, via otpauth. |
| API keys | implemented | JWT-based; no separate secret stored. |
| Audit logging | implemented | Real IAuditService over the audit table. |
| Licence | implemented | Always licensed; edition shown as "Freenterprise". |
| PDF export | implemented | Gotenberg renders the real page in Chromium. |
| PDF import | implemented | Tika text extraction. |
| DOCX export | implemented | Reuses the OSS prosemirror-docx serializer. |
| DOCX import | implemented | mammoth; images become real attachments. |
| Attachment search | implemented | Tika indexing + /search-attachments. |
| SCIM 2.0 | implemented | Users + Groups, deprovisioning, group sync. |
| OpenAPI docs | implemented | Swagger UI at /docs for all 223 routes — see openapi/. |
| Group-driven roles | implemented | A group can grant member/admin/owner to its members — see group-role/. |
| Page verification | implemented | Both expiring and qms workflows. |
| Bases / Kanban | implemented | All six stages — see base/base.module.ts. |
| Typesense | stub | Deliberately deferred — see below. |
| Confluence import | dropped | Deleted; not needed. |
Bases, in six stages
All implemented, in this order — each left the feature more usable than the last, and the risky parts came after the basics worked:
- CRUD — 23 endpoints. A base is a page with
is_base = true, so it inherits the page tree, space membership and page-level permissions. - Realtime —
BaseWsService, 14 outboundbase:*events. Broadcasts include the originating client, which suppresses its own echo byrequestId. - Query engine (
base/engine/) — compiles the client'sFilterNodeinto SQL over thebase_cell_*helpers. 20 operators, timezone-aware relative date presets. - Formulas — values are materialised into
cells, which is what lets stage 3 filter and sort them. Source is recompiled server-side; the client's AST and dependency list are discarded. - Async type conversion — staged via
pending_type/pending_token, applied by a worker, committed with abase_schema_versionbump. - CSV export.
BaseProcessor is the consumer for BASE_QUEUE, which core registers but
never drains.
Deliberately not done
- Typesense — Postgres FTS here is already competent (weighted tsvector,
unaccent,pg_trgm, GIN). Typesense would add a second stateful service plus an indexing pipeline and query-time ACL filtering, for typo tolerance and scale we don't currently need. LeavingSEARCH_DRIVERunset is the safe default; setting it totypesensewithout the module hard-fails search. - SAML / Google / LDAP — stubs under
sso/strategies/. Schema and client forms exist if ever needed.
SSO local-account merge
SSO_ACCOUNT_MERGE controls whether an SSO login adopts an existing local
account instead of creating a duplicate:
| Value | Behaviour |
|---|---|
email |
match on email (default — what the bundle always did) |
email-or-username |
also match preferred_username against users.name |
off |
never adopt; a colliding email is a clear error |
Env var rather than a UI toggle because the client's IAuthProvider has no
such field and adding one would mean editing client code we don't control.
Guards, since this is an account-takeover surface:
- Email merge requires
email_verified: true. A missing claim counts as unverified — treating absence as good enough would trust any IdP that simply omits it. Authentik sends it. SetSSO_MERGE_REQUIRE_VERIFIED_EMAIL=falseto relax this to "not explicitly false" for a trusted IdP that genuinely doesn't. - Username merge requires exactly one local match, since
users.namecarries no unique constraint. Ambiguous matches are logged and skipped. - Disabled accounts are never adopted, and every merge is audit-logged with which field matched.
Note email-or-username is inherently the weaker mode: it links on a claim
the user may control at the IdP, matched against a non-unique display name.
Prefer plain email unless you specifically need it.
Conventions
- Never edit core files. Every line added to the parent repo is future
rebase conflict surface. New env vars go in
shared/ee-env.tsreadingprocess.env, not into core'sEnvironmentService. (Vars core already defines —GOTENBERG_URL,APP_URL— still go throughEnvironmentService.) - Match core's conventions rather than inventing parallel ones: same
tsquery/ts_rank/f_unaccentsearch construction, same cursor pagination shape, same CASL ability checks, samecatch (err: any)style. - Reuse core services —
SignupService,PageAccessService,SessionService,TokenService, the repos. Don't reimplement permissions. - Most core modules are
@Global()(Database, Environment, Storage, Queue, Casl, PageAccess), so sub-modules usually need to import onlyTokenModuleorAuthModule.
Documentation
| INSTALL.md | Deploying with Docker, start to finish. The documented path. |
| SETUP-DEV.md | Running from source, for working on the code. |
| DEPLOYMENT.md | The reference behind both — upstream pin, the two-image story, storage, CI. |
| .github/workflows/build.yml | Builds and pushes the image on every push to main. |
| patches/README.md | What each client patch fixes, and how to regenerate one. |
| VERIFICATION.md | What has and hasn't actually been tested. |
| ISSUES.md | Bugs found in live testing. |
| TODO.md | Wanted, not built. |
Templates live here too: compose.example.yml (pulls the published image), compose.dev.yml (builds from source) and .env.example.
Installing
CI publishes the image, so a deployment needs neither the source nor a toolchain — two files and a directory:
.
|-- compose.yml <- from compose.example.yml
|-- .env <- from .env.example
`-- data/ <- attachments, owned by uid 1000
INSTALL.md opens with a single paste-able block: edit
APP_URL, run it, and you have both files with generated secrets and a
correctly owned storage directory. Then docker compose up -d.
The image is git.derg.cz/ulysia/docmost-freenterprise:main, rebuilt on every
push here. main moves; every build also publishes a :<short-sha> tag to pin
to, and labels each image with the upstream Docmost commit it was built
against — the version that matters is in neither repository alone.
Building it yourself instead needs both clones and the patch machinery; that is
the Building from source section of INSTALL.md, with compose.dev.yml.
One thing worth knowing before the first start: data/ must exist and be owned
by uid 1000. The container runs as node, and Docker creates a missing
bind-mount source as root — which fails every upload with a message that says
nothing useful.
openapi
Swagger UI at /docs, the document at /docs-json, covering all 223
routes the instance serves — 98 from this bundle, 125 from core. API_DOCS=false
turns it off.
Mounted outside /api deliberately: that prefix is guarded by a preHandler
hook in main.ts which 404s any request without a resolved workspace, and
/docs sidesteps it without editing the exclusion list. The SPA's *
catch-all does not shadow it — Fastify prefers an explicit route.
Schemas come from the @nestjs/swagger CLI plugin (patch 0010), which
reads TypeScript types, class-validator decorators and JSDoc at build time.
That is the whole reason this approach was viable: request bodies for core's
125 routes are described without annotating a single core file. @IsUUID()
becomes format: uuid, @IsIn([...]) becomes an enum, a doc comment becomes
the description.
Two things the generated document would get wrong, and how they are fixed:
- Responses are enveloped. Core's
TransformHttpResponseInterceptorwraps every payload in{ data, success, status }, so a naive spec is wrong for almost every route.applyResponseEnveloperewrites each 2xx schema to match. Handlers that write the reply themselves — SCIM, exports, attachments, health — are excluded by path prefix, since whether a handler takes@Res()is a source fact not visible at runtime. - Response shapes are not inferred. Nest cannot see a handler's return
type. Rather than emit a lie, operations get the envelope with an
undeclared
data. Annotating an EE handler with@ApiOkResponse({ type })improves it, and the envelope wrapper preserves what you declare.
The @nestjs/swagger dependency is the price. It is not in upstream's
package.json, so the lockfile moves too — and that ships as a whole file
(lockfile/pnpm-lock.yaml, substituted by the wrapper Dockerfile) rather than
as a patch hunk, because a diff against a generated, alphabetically ordered
lockfile breaks on nearly every upstream dependency bump. scripts/gen-lockfile.sh
regenerates it. It also drags in @scarf/scarf, whose postinstall script pnpm
11 refuses to run unapproved — which fails the Docker build until
pnpm-workspace.yaml says '@scarf/scarf': false. That is patch 0010 too.
To get the document as a file — for a code generator, or to diff what a change
did to the surface — scripts/gen-openapi.ts builds the same application and
writes it out. It needs a real environment, so run it in the container:
docker compose exec -w /app/apps/server docmost node dist/ee/scripts/gen-openapi.js > openapi.json
page-permission
Per-page access control: restrict a page, then grant users or groups
reader/writer. Restrictions inherit down the page tree.
Enforcement is core's, not ours. PagePermissionRepo already does the
recursive ancestor walk (canUserEditPage, getUserPageAccessLevel,
filterAccessiblePageIds), and core consumes it from PageAccessService,
favourites, labels, notifications and comment mentions. The tables
(page_access, page_permissions) ship with core too. Only the management
endpoints the client calls were missing — which is why /pages/permission-info
404'd while the licence advertised the feature.
This module is therefore a thin, guarded layer over the repo. Two guards worth knowing:
- Restricting grants the actor writer access. Otherwise the page ends up restricted with zero permission rows and the traversal check locks everyone out, including whoever just clicked the button.
- The last writer cannot be removed or demoted. A restricted page with only readers is unrecoverable through the UI — nobody can manage its permissions to add a writer back. The check runs inside a transaction and rolls back, rather than predicting the outcome up front.
Caching: canUserEditPage memoises per (userId, pageId) for 5s
(PERMISSION_CACHE_TTL_MS) and nothing in core invalidates it, so a change
can take that long to take effect. Invalidating properly would mean
enumerating every affected user across every descendant page.
template
Page templates: create one, edit it in a dedicated editor, then spawn pages from it. Templates are either global (workspace-wide) or scoped to a space.
Core owns the storage again — the templates table and TemplateRepo ship
with core, tsv is maintained by a database trigger off title +
text_content, and the allowMemberTemplates workspace setting is already
wired through workspace.service. Only the six endpoints were missing, which
is why the Templates button sat greyed out.
Access rules are taken from what the client already gates on, so the two agree (client gating is cosmetic; the service enforces):
| action | rule |
|---|---|
| view / use | global templates are workspace-wide; space ones need membership |
| create | workspace admin, or any member when allowMemberTemplates is on |
| make global | admin only |
| edit / delete | admin only |
/templates/use goes through PageService.create with the stored prosemirror
JSON, so the result is an ordinary collaborative page — correct slugId, tree
position and ydoc — rather than a special-cased copy. The target space is
independent of where the template lives, so a global template can seed a page
in any space the user belongs to.
personal-space
One private space per person, created by them. The "Allow personal spaces"
workspace toggle gates it (settings.spaces.allowPersonal).
Core owns everything underneath: spaces.is_personal with a partial unique
index on creator_id, so the database enforces one per person;
SpaceRepo.findPersonalSpace; and SpaceService.createSpace(..., {isPersonal}),
which creates the space, adds the creator as ADMIN and audits it — already
recording isPersonal in the audit changes. The toggle itself was already
licence-gated in workspace.service.
Only /personal-space/{info,create} were missing, which is why the toggle
claimed it needed an upgraded tier.
Two guards worth knowing:
- Duplicate check before insert. The unique index would reject a second personal space anyway, but as a constraint violation — a 500 carrying a Postgres message. Checking first returns something the caller can act on.
- Slug collisions are expected here.
SpaceService.createthrows on a duplicate slug rather than disambiguating, and personal space names collide readily ("Alex's space" twice). The service retries with a short suffix and falls back to the user id rather than looping.
group-role
A group can carry a workspace role, and everyone in it gets that role. The point is that admin rights follow Authentik group membership, so onboarding and offboarding happen in one place instead of two.
| mark | effect |
|---|---|
| Not assigned (default) | the group has no opinion; members' roles stay manual |
| Member | every member is held at member level |
| Admin | every member is a workspace admin |
| Owner | every member is promoted to owner — see below |
Set it on the group's detail page. Deliberately available on SCIM-synced groups, unlike everything else there: this mark is ours, not the IdP's, so no sync overwrites it. Marking the IdP's admins group as Admin is the whole point of the feature.
The receipt
Applying a grant also writes users.group_role. That column is what makes
withdrawal possible — without it, "this admin just lost their last admin
group" and "this admin was promoted by hand and is in no group at all" are
indistinguishable, and the first must be demoted while the second must not be
touched. It is also what greys out the role menu in the members table.
Highest grant wins, so someone in both a member group and an admin group is an admin; any other rule would depend on the order groups happen to be visited. Losing the last grant demotes to MEMBER rather than restoring whatever the person was before, because restoring would mean storing that too.
Owners are never managed
An Owner group promotes, and that is all it does: the promotion clears the receipt, so the user leaves group management for good and going back down is a manual act. Owner is the role that cannot be locked out and the one that repairs everything else, so no automatic process gets to take it away — not a group being unmarked, not an emptied claim, not an IdP that stopped answering.
The same reasoning runs through the rest of the guards:
- Only an owner can mark a group Owner, or change a group already marked
that way. Core refuses to let an admin act on the owner role directly
(
isAdminActingOnOwner), so they must not be able to do it sideways. - The default group cannot be marked at all — it contains everyone, so a grant on it would promote the entire workspace.
- The members table still offers Owner to owners, for a group-managed user, and the server allows exactly that one manual change. It is the escape hatch: whatever the IdP does, there is always a route to a role it cannot veto.
users.group_role has a CHECK admitting only member/admin, so "owner" is
not a state that table can even be in.
When it recomputes
Group membership is the input, so every path that changes it re-derives:
| trigger | where |
|---|---|
| the mark itself changes | POST /groups/role (ours) |
| SCIM membership sync, group delete | scim/services/scim-group.service.ts (ours) |
| OIDC group claim, on every login | sso/services/sso.service.ts (ours) |
| a member is added or removed by hand | core group-user.service.ts (patch 0009) |
| a group is deleted | core group.service.ts (patch 0009) |
The core triggers go through the usual require() + ModuleRef hook, so
without this bundle they are no-ops and roles stay entirely manual — upstream's
behaviour. All of them skip unmarked groups, which is nearly all groups.
Recomputes never throw at their caller. Each one is a side effect of something else the user asked for, and failing to re-derive a role must not fail a login.
OIDC group sync
Turn on "Group sync" on the OIDC provider and the groups claim is
reconciled into Docmost groups on every login, not just the first.
Groups are matched by name and created on demand, so a group SCIM has not
provisioned yet still works — the first person to log in carrying it brings it
into existence. Created groups are marked is_external, the same mark SCIM
uses, so patch 0008 locks them in the UI.
Requesting the claim is what makes this possible: the authorization request
adds the groups scope when group sync is on, and if the ID token still lacks
it we fall back to the userinfo endpoint. Both only happen when group sync is
on, so a provider without it costs no extra round trip and cannot be broken by
an IdP that does not know the scope.
Removal is governed by SSO_OIDC_GROUP_SYNC:
| value | behaviour |
|---|---|
add (default) |
join groups named in the claim, never leave any |
reconcile |
also leave IdP-managed groups the claim no longer names |
add is the default because it cannot fight SCIM. SCIM reconciles a group to
exactly the members the IdP sent, while an OIDC claim is per-user and may
legitimately omit groups SCIM manages — under strict reconcile each side would
keep undoing the other on alternating login and sync. Use reconcile when
OIDC is your only group source.
Either way only is_external groups are ever left, and the default group is
never touched: a manual assignment must not be undone by a login. Failures are
logged and swallowed — group sync is a convenience, and a transient database
error during it must not become a failed login.