No description
  • TypeScript 95.8%
  • Shell 2.6%
  • PowerShell 1%
  • JavaScript 0.6%
Find a file
ulysia c287aefd26
All checks were successful
Build and push Docker image / docker (push) Successful in 1m18s
docs: install by pulling the published image
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.
2026-08-18 15:28:00 +02:00
.github/workflows ci: move to .github/workflows and ubuntu-latest, matching what works 2026-08-18 15:06:23 +02:00
api-key fix(api-key): re-export the provider class so core can resolve it 2026-08-13 08:03:05 +02:00
attachments-ee fix(attachments): correct spaces.logo alias and raw IN predicate 2026-08-11 20:40:41 +02:00
audit fix(bases): write jsonb columns as objects, not JSON strings 2026-08-13 08:51:01 +02:00
base feat(sidebar): create a base or board from the + menu 2026-08-13 16:06:06 +02:00
docker fix(docker): CRLF broke the build on Windows hosts 2026-08-18 14:30:52 +02:00
document-import fix(docx-import): inject AttachmentRepo instead of AttachmentService 2026-08-12 20:51:42 +02:00
docx-export feat(docx): implement DOCX import via mammoth and DOCX export 2026-08-11 20:15:02 +02:00
group-role feat(group-role): groups can grant workspace roles 2026-08-14 08:26:41 +02:00
licence feat(personal-space): implement the API; fix the + button I broke 2026-08-13 17:14:31 +02:00
lockfile feat(openapi): Swagger UI and an OpenAPI document for all 223 routes 2026-08-18 12:20:28 +02:00
maintenance fix(bases): repair mixed-key damage and heal on ingress 2026-08-13 09:26:50 +02:00
mfa feat: EE module bundle for self-hosted Docmost 2026-08-11 18:51:45 +02:00
openapi feat(openapi): Swagger UI and an OpenAPI document for all 223 routes 2026-08-18 12:20:28 +02:00
page-permission feat(page-permission): implement the granular page permission API 2026-08-13 13:55:59 +02:00
page-verification fix(bases): write jsonb columns as objects, not JSON strings 2026-08-13 08:51:01 +02:00
patches fix(docker): CRLF broke the build on Windows hosts 2026-08-18 14:30:52 +02:00
pdf-export fix(bases): write jsonb columns as objects, not JSON strings 2026-08-13 08:51:01 +02:00
personal-space fix(scim): set is_default on group create; drop casts that hid it 2026-08-13 17:42:24 +02:00
scim feat(group-role): groups can grant workspace roles 2026-08-14 08:26:41 +02:00
scripts fix(docker): CRLF broke the build on Windows hosts 2026-08-18 14:30:52 +02:00
shared feat(sso): implement OIDC group claim sync, creating groups on demand 2026-08-14 06:52:36 +02:00
sso feat(group-role): groups can grant workspace roles 2026-08-14 08:26:41 +02:00
template fix(scim): set is_default on group create; drop casts that hid it 2026-08-13 17:42:24 +02:00
typesense fix(typesense): delete the unimplemented stub so "not bundled" is true 2026-08-13 14:22:23 +02:00
.env.example docs: Docker install guide, compose and env templates 2026-08-18 08:53:31 +02:00
.gitattributes fix(docker): CRLF broke the build on Windows hosts 2026-08-18 14:30:52 +02:00
.gitignore build: generate the Docker wrapper instead of shipping a copy 2026-08-13 11:42:53 +02:00
compose.dev.yml docs: install by pulling the published image 2026-08-18 15:28:00 +02:00
compose.example.yml docs: install by pulling the published image 2026-08-18 15:28:00 +02:00
DEPLOYMENT.md docs: install by pulling the published image 2026-08-18 15:28:00 +02:00
di-check.ts fix(typesense): delete the unimplemented stub so "not bundled" is true 2026-08-13 14:22:23 +02:00
docker-compose.ee.yml feat(pdf): implement PDF export via Gotenberg and PDF import via Tika 2026-08-11 19:02:20 +02:00
ee.module.ts feat(group-role): groups can grant workspace roles 2026-08-14 08:26:41 +02:00
INSTALL.md docs: install by pulling the published image 2026-08-18 15:28:00 +02:00
ISSUES.md feat(bases): apply configured property defaults 2026-08-13 12:57:00 +02:00
README.md docs: install by pulling the published image 2026-08-18 15:28:00 +02:00
SETUP-DEV.md ci: move to .github/workflows and ubuntu-latest, matching what works 2026-08-18 15:06:23 +02:00
TODO.md feat(group-role): groups can grant workspace roles 2026-08-14 08:26:41 +02:00
upstream.pin ci: Forgejo workflow building and pushing the image on main 2026-08-18 15:00:57 +02:00
VERIFICATION.md fix(di-check): build a real app, and stop reporting failure as success 2026-08-13 14:06:53 +02:00

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:

  1. CRUD — 23 endpoints. A base is a page with is_base = true, so it inherits the page tree, space membership and page-level permissions.
  2. RealtimeBaseWsService, 14 outbound base:* events. Broadcasts include the originating client, which suppresses its own echo by requestId.
  3. Query engine (base/engine/) — compiles the client's FilterNode into SQL over the base_cell_* helpers. 20 operators, timezone-aware relative date presets.
  4. 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.
  5. Async type conversion — staged via pending_type/pending_token, applied by a worker, committed with a base_schema_version bump.
  6. 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. Leaving SEARCH_DRIVER unset is the safe default; setting it to typesense without 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. Set SSO_MERGE_REQUIRE_VERIFIED_EMAIL=false to relax this to "not explicitly false" for a trusted IdP that genuinely doesn't.
  • Username merge requires exactly one local match, since users.name carries 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.ts reading process.env, not into core's EnvironmentService. (Vars core already defines — GOTENBERG_URL, APP_URL — still go through EnvironmentService.)
  • Match core's conventions rather than inventing parallel ones: same tsquery/ts_rank/f_unaccent search construction, same cursor pagination shape, same CASL ability checks, same catch (err: any) style.
  • Reuse core servicesSignupService, 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 only TokenModule or AuthModule.

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 TransformHttpResponseInterceptor wraps every payload in { data, success, status }, so a naive spec is wrong for almost every route. applyResponseEnvelope rewrites 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.create throws 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.