Build a multi-module solution on one data folder

Best practice Recommended
A Docly solution with a public site, an admin application and a customer or member area is one workspace with one data folder, published once per module, where each module is a publish template placed next to the data folder. The data folder holds the data and what every module shares; each template holds what only its module does. Where a file lives decides which URLs can reach it, who can call it and how often it runs, and each module's kind of caller (anonymous, JWT or Docly login) decides what the platform checks for you and what your code must check itself.
Applies to: PublishingPublish templatesWorkspaces

What you'll see

You are building a solution with more than one audience: a public website, an admin application for the people who run it, and perhaps a logged-in area for customers or members. The tempting layouts are one folder per audience, each with its own copy of the data, or one folder in which every page, endpoint and scheduled job lives side by side and each script works out who the caller is.

The first layout splits the data, so the modules drift apart. The second puts admin endpoints on the public site. Both also get three things wrong that nothing warns you about: scheduled jobs that run several times, endpoints reachable from a module that was never meant to have them, and schemas the other modules cannot find.

What's actually happening

The layout. Every multi-module solution on the platform uses the same shape: one workspace, one data folder, and one publish template per module, all as siblings:

Example (workspace)/                 schema: Docly Workspace
├── .claude/                         agent instructions, see below
├── Example.no/                      THE DATA FOLDER, schema: Folder, published once per module
│   ├── #/                           shared by EVERY module, never served statically
│   │   ├── Schemas/                 every schema the data uses
│   │   ├── Libs/                    shared libraries (Auth.js, Config.js)
│   │   ├── Components/              shared web components
│   │   ├── Root/                    shared assets (style.css, favicon)
│   │   ├── Config.docly             configuration and secrets
│   │   └── site.json                headers, CSP
│   ├── Customers/                   the data itself
│   └── Users/
├── Example (public module)/         schema: Publish template, published at /
│   ├── Root/                        public pages
│   └── API/                         public endpoints (forms, Turnstile)
├── Example (admin module)/          schema: Publish template, published at /admin, login required
│   ├── Root/  Folder/  API/  Libs/
│   ├── Services/  Services.json     scheduled jobs, in exactly ONE module
│   └── Master.hash
└── Example (customer module)/       schema: Publish template, published at /customer
    ├── Root/  API/
    └── Master.hash

The data folder is published once per module, and each publication names its own template. Reference solutions with this layout: Dinkalibrering (welcome and admin), Sikkerhetsanalyse.no (public, admin and customer) and NCMT (public, member and admin). SmartSupport ships the same split as a package: an admin app and a public app.

How a request is resolved. Every #/… path resolves first in the data folder's own #, then in the publication's template, where the #/ prefix is dropped: #/API/x.js becomes the template's API/x.js. The data folder always wins. Among several publications of the same folder, the longest matching URL path wins, and matching ignores case, so /ADMIN/ reaches the admin module just as /admin/ does.

That one rule has four consequences, and each of them is easy to get wrong:

  1. What the data folder's # holds, every module has. An endpoint in Example.no/#/API/ answers at /API/x, /admin/API/x and /customer/API/x, including on the public publication, which has no login. A display template placed there (#/<Schema>.hash, #/Folder/Index.hash) makes that data render in every module, public ones included.
  2. What a template holds, only its own module has. Admin endpoints in the admin template do not exist on the public site at all. The template is the boundary between modules.
  3. Scheduled jobs run once per publication. The scheduler reads #/Services.json for each publication separately. If you put it in the data folder's #, the job runs once for every module. A template that is used by two publications runs its jobs twice. Put Services.json and Services/ in exactly one template, which by convention is the admin module.
  4. Schemas are found by walking up through the workspace, never through the template. Schema lookup ignores the publish template completely. A schema in a template's Schemas/ is found only because the template happens to sit in the same workspace, and only by name. Two copies of the same name in different modules break the binding. Keep every schema in the data folder's #/Schemas/, and refer to it by path: "#/Schemas/Customer".

What protects the data by default. A schema-bound document in the data folder is never served as raw JSON. It renders only if a display template exists for its schema; otherwise the request returns 404. Attachments follow the same check. So /Customers/<id> on the public module returns 404, and that is by design. The protection lasts only as long as nobody adds a display template in a place every module can see. Rule 1 is that place.

What "login required" on a publication means, and what it does not. A publication with AuthMode set to require login sends anonymous visitors to Docly's login, and it issues a session only to users who have visit access to the data folder or to something below it. A Docly account with no share on the solution gets no session. So far, so good. But that check is about access to the solution, not about role:

  • Files that come from the template (pages, Root/, Folder/, API/) are open to every user who holds a session. There is no further check.
  • Files in the data folder also require visit access to that particular file.

In a solution where customers or members sign in with a Visit share, as in the two-layer model in Ship secure user access fast, every one of them holds a session that the admin module accepts. The platform therefore cannot tell a customer from an administrator, so every endpoint in the admin template must make that distinction itself.

request.user is set on public publications too. The session cookie applies to the whole host (Path=/). Once someone has signed in at /admin, request.user holds their e-mail address in the public and customer modules as well. A check such as if (!request.user) return docly.denyAccess(); therefore lets through every user who has any share in the solution, on every module, whatever their role.

Three kinds of caller. Every module serves one of three kinds of caller, and they differ in exactly the ways that matter for access control:

AnonymousJWT (the module's own login)Docly login
PublicationPublicPublic, the module signs users in itself with docly.writeJwt()Login required (AuthMode)
Identity in codenonerequest.Jwt, the payload you wrote. It is set in API calls only, never in pagesrequest.user, the e-mail address
Cookie scope–This module's path only (/customer), signed with a secret that belongs to this publicationThe whole host (Path=/), shared by every module
What the platform checksnothingthe signature, and that the token was issued today (server time)that the user has a share on the data folder or something below it
What your code must checkeverythingthat the user still exists and is still activethe role

Four facts follow from the table, and none of them is visible from inside one module:

  • A JWT belongs to one module. A token issued at /customer is never sent to /admin or /, and it would not validate there anyway, because each publication has its own secret. So an endpoint in the data folder's #/API/ sees request.Jwt only when it is called under the path of the module that issued the token.
  • A JWT is valid on the day it was issued, and nothing revokes it sooner. writeJwt writes the issue date (server time) into the signed header. The platform rejects any token that is not from today, so at midnight request.Jwt becomes null and the user has to sign in again. A token without a date, or with a date that has been altered, is rejected as well. The cookie is a browser-session cookie. Until midnight, however, a token copied out of a browser keeps working, and deleting or deactivating the user does not revoke it. That is why your code must look the user up on every call.
  • A Docly session is one session for the whole host. Signing in at /admin sets request.user in every module, and docly.logOut() ends it in every module. docly.deleteJwt(), on the other hand, ends only the JWT of the module it runs in.
  • Two modules that both require Docly login cannot be separated by shares. Both publish the same data folder, and the platform's only check is "has a share on that folder". A share that lets a customer into the customer area also lets them through the login of the admin module. Only the role check in your code tells the two apart.

docly.denyAccess() does not stop the script. It sets the status to 401 and returns. Code that follows it still runs, and whatever it writes or returns still happens. Always write return docly.denyAccess();, or throw straight after it.

What to do

1. Place each file by who may reach it.

WhatWhereWhy
Data documentsExample.no/<Folder>/One copy of the data, managed in the Docly admin UI
SchemasExample.no/#/Schemas/Schema lookup never searches templates
Config, secrets, site.jsonExample.no/#/Shared by all modules, never served
Shared libraries, components, assetsExample.no/#/Libs, #/Components, #/RootOne copy for every module
Pages of one module<Module>/Root/, <Module>/Folder/Exist only under that module's URL
Endpoints of one module<Module>/API/Not reachable from any other module
Display templates for dataThe module that shows it, never the data folder's #In # they render on the public site too
Scheduled jobsServices.json + Services/ in ONE templateThey run once per publication

2. Gate every endpoint by role, not by being signed in. Put the check in one shared library and call it on the first line of each endpoint:

// Example.no/#/Libs/Auth.js
function deny() { docly.denyAccess(); throw "Access denied"; }   // denyAccess() does not stop execution

export function requireAdmin() {
    if (!request.user) deny();
    let shares = docly.getFolderShares("/").Shares;
    let ok = shares.some(s => s.User && s.User.toLowerCase() === request.user.toLowerCase() && s.AllowAdmin);
    if (!ok) deny();
    return request.user;
}

// Example (admin module)/API/customers/list.js
import { requireAdmin } from "#/Libs/Auth.js";
export default () => {
    requireAdmin();
    return docly.getFiles("Customers");
}

For finer rights than "admin or not", such as which customers a user may see, keep a user registry and check it the same way; see Ship secure user access fast.

2b. Customer and member modules on a JWT: look the user up on every call. These modules usually run on a public publication with their own login, typically a magic link followed by writeJwt. The platform expires the token at midnight on the day it was issued. You do not need an expiry in the payload, and one set later than that has no effect. What the platform cannot know is whether the user still has access, so check that on every call:

// at sign-in
docly.writeJwt({ email: email });

// Example.no/#/Libs/Auth.js
export function requireCustomer() {
    let j = request.Jwt;                                          // null once the token is not from today
    if (!j || !j.email) deny();
    let customer = docly.getFile("#/Customers/" + j.email);     // still exists?
    if (!customer || !customer.Active) deny();                    // still active?
    return customer;
}

Need a session shorter than the rest of the day, for example one hour? Then add your own expiry to the payload and check it here as well. It can only make the session shorter than the platform's, never longer.

Accept exactly the caller kinds the endpoint is meant for. If an endpoint accepts request.Jwt || request.user, every user with any share in the solution passes the second half, whatever their role. Make sure that is what you intend.

3. Keep the data folder's #/API/ for endpoints that are genuinely public. Anything there answers on the public site without a login. If an endpoint belongs to one module, move it into that module's template. If it is meant to be public, write down why in a comment on its first line, so that the next reviewer does not have to guess.

4. Put per-user output in an API, never in a page. Pages on a public publication are cached and served to the next visitor. A page that renders the signed-in member's name will show it to everybody. See Hash files are cached.

5. Never create a # folder inside a template. The template root already is #; see Do not create a # folder inside a publish template.

6. Describe the layout for the next agent. Put a .claude/CLAUDE.md on the workspace root that lists every module: the folder, its URL, whether it requires login, and a modul-*.md file per module. .claude paths return 404 on every published site. See Set up AI agent instruction files for a workspace.

Verify after publishing, from a private window, where you are not signed in:

curl -s -o /dev/null -w "%{http_code}\n" https://example.no/Customers/          # 404
curl -s -o /dev/null -w "%{http_code}\n" https://example.no/admin/API/customers/list  # login page, not 200
curl -s -o /dev/null -w "%{http_code}\n" https://example.no/API/<each endpoint>   # only the intended ones answer

Then sign in as a user with the lowest role that has a share, such as a customer with only a Visit share, and repeat the admin calls. They must be refused. For a JWT module, sign in, deactivate the user, and call an endpoint again: it must be refused without the user signing out.