@scopmx/markora-server
v1.3.0
Published
Backend for the Markora markup editor: Express routes and service functions on the host's MongoDB connection.
Readme
@scopmx/markora-server
The backend for the Markora markup editor (@scopmx/markora-client): an Express
router and a few service functions. They run on your MongoDB connection, your
auth, and your file storage.
Each drawing keeps one main copy of its markup (workingSnapshot) plus one
record per mark and per comment, the Bluebeam model. Saves are guarded by a
revision number, so two people never silently overwrite each other.
@scopmx/markora-client merges their work in the browser.
Install
npm install @scopmx/markora-serverPeer dependencies: express ^4.17, mongoose ^8. The package never loads its
own Mongoose; it uses the instance that owns the connection you pass.
Mount
import { createMarkoraServer } from "@scopmx/markora-server";
const markora = createMarkoraServer({
connection: (req) => req.user.conn, // your MongoDB connection for this request
user: (req) => ({ id: String(req.user._id), name: req.user.name }),
files: {
copy: (url) => copyFile(url), // copy a file you own → new URL
remove: (url) => deleteFile(url), // delete a file you own
isOwn: (url) => isOwnS3Url(url), // true for URLs you may later delete
},
// Optional: use your own collections and foreign key.
models: { drawing: "jobDefect", version: "jobDefectVersion", activity: "jobDefectActivity",
markup: "jobDefectMarkup", comment: "jobDefectComment", presence: "jobDefectPresence",
tool: "jobDefectTool" },
foreignKey: "defectId",
copyToChildren: ["jobId"],
// Optional: turns GET /search on. A filter on the foreign key naming the drawings THIS person may search.
searchScope: async (req) => ({ defectId: { $in: await defectIdsFor(req.user) } }),
// Optional: what THIS person may do with THIS drawing: "edit", "comment" or "view" (default: everyone edits).
access: async (req, drawing) => (await canEditDefect(req.user, drawing.foreignKey) ? "edit" : "view"),
});
app.use("/api/markora", requireLogin, markora.router);If a model with one of those names is registered on the connection, it is used as-is, so your hooks run. Otherwise the package registers its own schema.
A tool model (default markoraTool) holds each person's tool sets, one document per
person. A model you register yourself for it must declare ownerId as a unique
String index ({ ownerId: { type: String, required: true, unique: true } }); without
the unique index, two first saves at once can create two documents for one person.
Revisions. A new file for the same drawing is kept next to the old one: the drawing gains an
append-only sources array ({ n, sourceType, sourceFileUrl, backgroundUrl, pdfPageCount, name, uploadedBy, at }),
and every version carries a sourceRef of the file it was made on. The package writes both even when your own
drawing schema is strict; declare sources: [Mixed] on it as well if other code of yours reads the drawing through
Mongoose documents. Nothing is migrated: a drawing with no sources reads as one source,
and remove deletes the files of every source.
Access. With the access option the package enforces what the host decides, on every route of one drawing
(not on /tools and /search). The hook gets the request and { id, foreignKey } (the drawing's own record id)
once per request, after the drawing is found and before anything is written:
| Answer | Reads and presence | Saves, versions, restore, background, revisions, HTML state, activity |
|---|---|---|
| "edit" | yes | yes |
| "comment" | yes | a save only if nothing but the comments differs from the stored drawing (every mark in order, the measure state, page turns, PDF imports and the canvas size must be equal) and the comments pass the author rule; activity only for entries whose action starts with comment; everything else 403 comment_only |
| "view" | yes | 403 read_only |
A refusal writes nothing and does not bump the revision. A save on a stale revision is still a 409, so a commenter's
editor can merge. GET /drawings/:id returns access next to the drawing ("edit" when you set no hook), and the
editor takes the stricter of that and its own mode prop. Throw a MarkoraError from the hook to refuse a whole
request with your own code; any other failure, or an answer that is not one of the three, is a 500 (never an open
door). Guests are ordinary users: build user(req) from the share token and answer "comment" or "view" for the one
drawing it names. commentOnlyChange(current, incoming) is exported for hosts that want the same check.
Your own create, duplicate and delete routes call:
| Call | When |
|---|---|
| markora.service.attach(req, id, { sourceType, sourceFileUrl, backgroundUrl, pdfPageCount?, pdfCurrentPage?, htmlLayoutViewport?, snapshot?, summary? }) | right after you create the record |
| markora.service.duplicate(req, sourceId, targetId, { copyAnnotations, summary? }) | after you create the copy's record |
| markora.service.remove(req, id) | before you delete the record |
| markora.service.reindex(req, id) | to rebuild mark and comment records |
Routes
Success: { data }. Failure: { error: { code, message } }.
| Route | Purpose |
|---|---|
| GET /drawings/:id | the record with workingSnapshot, revision, editors, and access (edit, comment or view) next to it |
| PUT /drawings/:id/state | autosave { baseRevision, snapshot } |
| POST /drawings/:id/versions | manual save { baseRevision, snapshot, label? }, or { source: "conflict-copy", snapshot, label } |
| GET /drawings/:id/versions | history, newest first, without snapshots |
| POST /versions/:versionId/restore | { createCheckpoint?, currentSnapshot? }; brings the version's file back when the drawing still has it; sourceUnknown: true when it cannot (a version from before revisions) |
| GET / POST /drawings/:id/activity | the History feed ({ entries }, 200 per call) |
| PUT /drawings/:id/background | new image, PDF page, HTML page or 3D model |
| POST /drawings/:id/revisions | upload a new file for the drawing { sourceType: "image" \| "pdf", sourceFileUrl, backgroundUrl, pdfPageCount?, pdfCurrentPage?, name }; answers { revision, drawing }; deletes nothing |
| GET /drawings/:id/revisions | { revisions: [{ n, name, uploadedBy, at, pdfPageCount?, current }] }, oldest first |
| PUT /drawings/:id/html-state | HTML screen routes file |
| POST /drawings/:id/presence | check in; returns who else is here and who changed it since since |
| GET /search?q=&limit= | words people typed (a mark's text, the subject and author name they gave it, comments and their authors) across the drawings searchScope names: { hits: [{ foreignKey, kind: "mark" \| "comment", markupId, commentId?, page, subject?, snippet, author?, status?, at }] }, newest first. q 2 to 100 characters (regex characters are plain characters), limit 50 by default and 100 at most. No searchScope, or one returning null, is a 403 search_disabled: the package never decides who sees what |
| GET / PUT /tools | the signed-in person's Tool Chest { sets } (null = never saved); 400 over 50 sets, 200 tools, 256 KB a tool or 4 MB in all, and over a style or template key starting with $ or containing . |
Error codes: bad_request 400, foreign_file 400 (a revision's file is not one this backend owns), search_disabled 403, read_only 403, comment_only 403, comment_forbidden 403, not_found 404,
revision_conflict 409, background_changed 409, snapshot_too_large 413,
internal 500.
Only a comment's author can change or remove it (comment_forbidden).
Removing a mark removes its thread.
Tests
The database tests need a disposable MongoDB. They are skipped without one:
mongod --dbpath ./.mongo --port 27099
MARKORA_TEST_MONGO_URL=mongodb://127.0.0.1:27099 npm test