@mp-lb/mdkit 0.2.3-main.20.1 → 0.2.3-main.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/checkpointPolicy.d.ts +25 -0
- package/dist/core/checkpointPolicy.js +45 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/dist/transport/backend.d.ts +29 -0
- package/dist/transport/backend.js +93 -0
- package/dist/transport/trpcClient.d.ts +29 -8
- package/dist/trpc/client.d.ts +1 -1
- package/dist/trpc/server.d.ts +2 -0
- package/dist/trpc/server.js +1 -0
- package/dist/trpc.d.ts +1 -1
- package/docs/.vitepress/cache/deps/@theme_index.js +275 -0
- package/docs/.vitepress/cache/deps/@theme_index.js.map +7 -0
- package/docs/.vitepress/cache/deps/_metadata.json +40 -0
- package/docs/.vitepress/cache/deps/chunk-PM3I3KHC.js +9719 -0
- package/docs/.vitepress/cache/deps/chunk-PM3I3KHC.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-VSHFF4ZG.js +13018 -0
- package/docs/.vitepress/cache/deps/chunk-VSHFF4ZG.js.map +7 -0
- package/docs/.vitepress/cache/deps/package.json +3 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4505 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +583 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
- package/docs/.vitepress/cache/deps/vue.js +347 -0
- package/docs/.vitepress/cache/deps/vue.js.map +7 -0
- package/docs/.vitepress/config.ts +11 -0
- package/docs/api.md +153 -18
- package/docs/architecture.md +24 -9
- package/docs/collaboration-persistence.md +147 -0
- package/docs/index.md +139 -65
- package/docs/permissions.md +139 -0
- package/docs/rest.md +98 -0
- package/docs/shadcn.md +6 -3
- package/docs/styling.md +11 -10
- package/docs/use-cases.md +148 -0
- package/package.json +6 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Collaboration Persistence
|
|
2
|
+
|
|
3
|
+
`useMdKitCollaboration` connects `MdKitEditor` to Hocuspocus/Yjs. Mdkit handles
|
|
4
|
+
the editor integration and markdown/Yjs conversion helpers, but your application
|
|
5
|
+
owns where collaboration state is stored.
|
|
6
|
+
|
|
7
|
+
## What Needs To Be Stored
|
|
8
|
+
|
|
9
|
+
Hocuspocus collaboration state is Yjs binary state for one collaborative
|
|
10
|
+
document. Store it as bytes in your database or object storage. Good fits
|
|
11
|
+
include Postgres `bytea`, MongoDB `Binary`, S3/blob storage, or another durable
|
|
12
|
+
store your backend already uses.
|
|
13
|
+
|
|
14
|
+
You can keep this state in memory for a single-process app. That is simple and
|
|
15
|
+
can be acceptable for small deployments, but it has important limits:
|
|
16
|
+
|
|
17
|
+
- a server restart loses open collaboration state unless it was flushed
|
|
18
|
+
elsewhere
|
|
19
|
+
- multiple backend instances do not share memory
|
|
20
|
+
- collaborators can split across different instances unless your deployment
|
|
21
|
+
adds sticky routing, shared persistence, or Hocuspocus infrastructure for
|
|
22
|
+
multi-instance collaboration
|
|
23
|
+
|
|
24
|
+
## Document Names Are Stable IDs
|
|
25
|
+
|
|
26
|
+
Hocuspocus calls the collaboration key `documentName`. Treat that value as a
|
|
27
|
+
stable unique document identifier, not as a mutable display name. It can be a
|
|
28
|
+
document UUID, file id, or deterministic tenant/document key such as
|
|
29
|
+
`tenantId:documentId`.
|
|
30
|
+
|
|
31
|
+
On the client, mdkit passes `documentId` to Hocuspocus as the provider `name` by
|
|
32
|
+
default. If your collaboration key needs tenancy or another namespace, derive it
|
|
33
|
+
with `resolveRoomName`:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
const collaboration = useMdKitCollaboration({
|
|
37
|
+
collaborator: { id: user.id, name: user.name },
|
|
38
|
+
documentId,
|
|
39
|
+
endpoint: `${apiUrl.replace(/^http/, "ws")}/collaboration`,
|
|
40
|
+
getToken,
|
|
41
|
+
resolveRoomName: (documentId) => `${tenantId}:${documentId}`,
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The same resolved value is what Hocuspocus passes to its persistence callbacks
|
|
46
|
+
as `documentName`.
|
|
47
|
+
|
|
48
|
+
## Database Extension
|
|
49
|
+
|
|
50
|
+
The usual integration is Hocuspocus' generic `Database` extension. Put your DB
|
|
51
|
+
reads and writes inside `fetch` and `store`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { Database } from "@hocuspocus/extension-database";
|
|
55
|
+
import { Server } from "@hocuspocus/server";
|
|
56
|
+
import { CheckpointPolicy } from "@mp-lb/mdkit/core";
|
|
57
|
+
import {
|
|
58
|
+
createMdKitBackend,
|
|
59
|
+
type MdKitBackendStore,
|
|
60
|
+
} from "@mp-lb/mdkit/server";
|
|
61
|
+
|
|
62
|
+
const mdkit = createMdKitBackend({
|
|
63
|
+
store: createYourDocumentStore(),
|
|
64
|
+
checkpointPolicy: CheckpointPolicy.smart(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const collaboration = Server.configure({
|
|
68
|
+
extensions: [
|
|
69
|
+
new Database({
|
|
70
|
+
fetch: async ({ documentName }) => {
|
|
71
|
+
// Hocuspocus calls this documentName, but it should be a stable id.
|
|
72
|
+
return mdkit.readCollaborationState?.(documentName) ?? null;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
store: async ({ documentName, state }) => {
|
|
76
|
+
await mdkit.writeCollaborationState?.(documentName, state);
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Your document store can back those mdkit methods with any database:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const createYourDocumentStore = (): MdKitBackendStore => ({
|
|
87
|
+
// Current markdown snapshot.
|
|
88
|
+
readDocument: (documentId) => db.documents.readCurrent(documentId),
|
|
89
|
+
writeDocument: (input) => db.documents.writeCurrent(input),
|
|
90
|
+
|
|
91
|
+
// Optional checkpoint history.
|
|
92
|
+
createCheckpoint: (input) => db.checkpoints.create(input),
|
|
93
|
+
getLatestCheckpoint: (documentId) => db.checkpoints.latest(documentId),
|
|
94
|
+
listDocumentVersions: (documentId) => db.checkpoints.list(documentId),
|
|
95
|
+
readDocumentVersion: (input) => db.checkpoints.read(input),
|
|
96
|
+
|
|
97
|
+
// Durable Yjs collaboration state.
|
|
98
|
+
readCollaborationState: async (documentName) => {
|
|
99
|
+
const row = await db.collaborationState.findByDocumentName(documentName);
|
|
100
|
+
return row?.yjsState ?? null;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
writeCollaborationState: async (documentName, state) => {
|
|
104
|
+
await db.collaborationState.upsert({
|
|
105
|
+
documentName,
|
|
106
|
+
yjsState: state,
|
|
107
|
+
updatedAt: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Store the `state` bytes exactly as Hocuspocus gives them to you, and return the
|
|
114
|
+
same bytes from `fetch`. Do not convert the Yjs state to JSON and rebuild it on
|
|
115
|
+
every connection; that creates new Yjs identities and can duplicate content when
|
|
116
|
+
clients reconnect.
|
|
117
|
+
|
|
118
|
+
## Seeding From Markdown
|
|
119
|
+
|
|
120
|
+
The simplest flow is:
|
|
121
|
+
|
|
122
|
+
1. Current markdown lives in your normal document table.
|
|
123
|
+
2. Hocuspocus asks for Yjs state in `fetch`.
|
|
124
|
+
3. If persisted Yjs state exists, return it.
|
|
125
|
+
4. If no persisted Yjs state exists, seed the collaboration room from the
|
|
126
|
+
current markdown snapshot.
|
|
127
|
+
5. After that first seed, persist and reuse the Yjs state.
|
|
128
|
+
|
|
129
|
+
Mdkit exposes Yjs helpers for that seed/reset work:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { yjs } from "@mp-lb/mdkit";
|
|
133
|
+
|
|
134
|
+
const state = existingState ?? yjs.markdownToMdKitYjs(currentMarkdown);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If your app supports restore, import, or external full-document replacement,
|
|
138
|
+
replace or reset the persisted collaboration state deliberately so the next
|
|
139
|
+
collaboration session starts from the restored canonical markdown.
|
|
140
|
+
|
|
141
|
+
## Hocuspocus Docs
|
|
142
|
+
|
|
143
|
+
For the Hocuspocus side of the setup, see:
|
|
144
|
+
|
|
145
|
+
- [Hocuspocus Database extension](https://tiptap.dev/docs/hocuspocus/server/extensions/database)
|
|
146
|
+
- [Hocuspocus persistence guide](https://tiptap.dev/docs/hocuspocus/guides/persistence)
|
|
147
|
+
- [Hocuspocus Redis extension](https://tiptap.dev/docs/hocuspocus/server/extensions/redis)
|
package/docs/index.md
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Markdown Editor Kit is a React package for teams that need more than a bare
|
|
4
4
|
markdown editor widget. It starts with a controlled markdown editor that behaves
|
|
5
|
-
like a textarea, then adds autosave,
|
|
5
|
+
like a textarea, then adds autosave, checkpoint history, conflict handling, and
|
|
6
6
|
collaboration through adapters.
|
|
7
7
|
|
|
8
|
+
Use the pieces you need. `MdKitEditor` works without any backend, storage,
|
|
9
|
+
checkpoint history, or collaboration. Persistence, checkpoint history, and
|
|
10
|
+
collaboration are optional layers that can be added independently.
|
|
11
|
+
|
|
8
12
|
```bash
|
|
9
13
|
pnpm add @mp-lb/mdkit
|
|
10
14
|
```
|
|
@@ -22,7 +26,8 @@ custom panel styles.
|
|
|
22
26
|
## Basic Editor
|
|
23
27
|
|
|
24
28
|
`MdKitEditor` is the textarea-like entry point. It has no persistence, no
|
|
25
|
-
|
|
29
|
+
checkpoint history, and no collaboration. You own the `value` and `onChange`
|
|
30
|
+
state.
|
|
26
31
|
|
|
27
32
|
```tsx
|
|
28
33
|
import { useState } from "react";
|
|
@@ -31,7 +36,6 @@ import "@mp-lb/mdkit/styles.css";
|
|
|
31
36
|
|
|
32
37
|
export function MarkdownEditorExample() {
|
|
33
38
|
const [markdown, setMarkdown] = useState("# Hello markdown");
|
|
34
|
-
|
|
35
39
|
return <MdKitEditor value={markdown} onChange={setMarkdown} />;
|
|
36
40
|
}
|
|
37
41
|
```
|
|
@@ -53,15 +57,15 @@ export function MarkdownPreview({ markdown }: { markdown: string }) {
|
|
|
53
57
|
}
|
|
54
58
|
```
|
|
55
59
|
|
|
56
|
-
Use this for document previews, restored-
|
|
57
|
-
surface that should visually match the editor.
|
|
60
|
+
Use this for document previews, restored-checkpoint views, or any readonly
|
|
61
|
+
markdown surface that should visually match the editor.
|
|
58
62
|
|
|
59
63
|
## Connected Editor
|
|
60
64
|
|
|
61
65
|
The connected workflow combines:
|
|
62
66
|
|
|
63
67
|
- `useMdKitDocument` for loading, autosave, dirty state, and conflict detection
|
|
64
|
-
- `useMdKitDocumentVersions` for
|
|
68
|
+
- `useMdKitDocumentVersions` for checkpoint browsing and restore
|
|
65
69
|
- `useMdKitCollaboration` for Hocuspocus/Yjs collaboration
|
|
66
70
|
- `MdKitDocumentToolbar`, `VersionHistoryPanel`, and `MdKitConflictPanel` for
|
|
67
71
|
a complete base-panel UI
|
|
@@ -83,10 +87,9 @@ import {
|
|
|
83
87
|
useMdKitDocumentVersions,
|
|
84
88
|
type MdKitDocumentVersionDetail,
|
|
85
89
|
} from "@mp-lb/mdkit";
|
|
86
|
-
import {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} from "@mp-lb/mdkit/trpc/client";
|
|
90
|
+
import { createMdKitTrpcAdapter } from "@mp-lb/mdkit/trpc/client";
|
|
91
|
+
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
|
|
92
|
+
import type { AppRouter } from "./server";
|
|
90
93
|
|
|
91
94
|
const documentId = "docs/example.md";
|
|
92
95
|
|
|
@@ -99,13 +102,20 @@ export function ConnectedMarkdownEditor({
|
|
|
99
102
|
const [conflictOpen, setConflictOpen] = useState(false);
|
|
100
103
|
|
|
101
104
|
const trpc = useMemo(
|
|
102
|
-
() =>
|
|
105
|
+
() =>
|
|
106
|
+
createTRPCProxyClient<AppRouter>({
|
|
107
|
+
links: [httpBatchLink({ url: `${apiUrl}/trpc` })],
|
|
108
|
+
}),
|
|
103
109
|
[apiUrl],
|
|
104
110
|
);
|
|
105
|
-
const adapter = useMemo(() => createMdKitTrpcAdapter({ client: trpc }), [trpc]);
|
|
106
111
|
|
|
112
|
+
const adapter = useMemo(
|
|
113
|
+
() => createMdKitTrpcAdapter({ client: trpc.mdkit }),
|
|
114
|
+
[trpc],
|
|
115
|
+
);
|
|
107
116
|
const document = useMdKitDocument({ adapter, documentId });
|
|
108
117
|
const versions = useMdKitDocumentVersions({ adapter, documentId });
|
|
118
|
+
|
|
109
119
|
const collaboration = useMdKitCollaboration({
|
|
110
120
|
collaborator: { id: "user-1", name: "Ada" },
|
|
111
121
|
documentId,
|
|
@@ -113,10 +123,11 @@ export function ConnectedMarkdownEditor({
|
|
|
113
123
|
});
|
|
114
124
|
|
|
115
125
|
const restoreVersion = async (version: MdKitDocumentVersionDetail) => {
|
|
116
|
-
await trpc.restoreDocumentVersion.mutate({
|
|
126
|
+
await trpc.mdkit.restoreDocumentVersion.mutate({
|
|
117
127
|
documentId,
|
|
118
128
|
versionId: version.id,
|
|
119
129
|
});
|
|
130
|
+
|
|
120
131
|
await document.resync();
|
|
121
132
|
await versions.refresh();
|
|
122
133
|
};
|
|
@@ -159,94 +170,119 @@ export function ConnectedMarkdownEditor({
|
|
|
159
170
|
}
|
|
160
171
|
```
|
|
161
172
|
|
|
162
|
-
The
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
The base panels are starter UI; if they do not fit your product, keep the hooks
|
|
174
|
+
and build your own workflow components.
|
|
175
|
+
|
|
176
|
+
### Backend Store Contract
|
|
166
177
|
|
|
167
|
-
|
|
178
|
+
The backend starts with a store object. Replace `createYourDocumentStore()` with
|
|
179
|
+
Postgres, MongoDB, Redis, files, or any other durable storage. Your store
|
|
180
|
+
implements database primitives: read/write the current document, create/read
|
|
181
|
+
checkpoints, restore a checkpoint, and optionally persist collaboration state.
|
|
182
|
+
The mdkit backend helper applies checkpoint policy and turns those primitives
|
|
183
|
+
into tRPC or REST procedures. Application-owned metadata, auth, permissions,
|
|
184
|
+
tenancy, and durable Yjs storage stay in your code; see
|
|
185
|
+
[Permissions](./permissions.md).
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
Postgres, MongoDB, Redis, files, or any other durable storage.
|
|
187
|
+
### Backend With tRPC
|
|
171
188
|
|
|
172
189
|
```ts
|
|
173
|
-
import
|
|
174
|
-
import websocket from "@fastify/websocket";
|
|
190
|
+
import { createHTTPServer } from "@trpc/server/adapters/standalone";
|
|
175
191
|
import { Database } from "@hocuspocus/extension-database";
|
|
176
192
|
import { Server } from "@hocuspocus/server";
|
|
177
|
-
import {
|
|
178
|
-
import
|
|
193
|
+
import { WebSocketServer } from "ws";
|
|
194
|
+
import { CheckpointPolicy } from "@mp-lb/mdkit/core";
|
|
195
|
+
import { t } from "./trpc";
|
|
196
|
+
import {
|
|
197
|
+
createMdKitBackend,
|
|
198
|
+
type MdKitBackendStore,
|
|
199
|
+
} from "@mp-lb/mdkit/server";
|
|
179
200
|
import { createMdKitTrpcRouter } from "@mp-lb/mdkit/trpc/server";
|
|
180
201
|
|
|
181
|
-
const
|
|
182
|
-
|
|
202
|
+
const store: MdKitBackendStore = createYourDocumentStore();
|
|
203
|
+
|
|
204
|
+
const mdkit = createMdKitBackend({
|
|
205
|
+
store,
|
|
206
|
+
checkpointPolicy: CheckpointPolicy.smart(),
|
|
207
|
+
});
|
|
183
208
|
|
|
184
209
|
const collaboration = Server.configure({
|
|
185
210
|
extensions: [
|
|
186
211
|
new Database({
|
|
187
|
-
fetch: ({ documentName }) =>
|
|
212
|
+
fetch: ({ documentName }) => mdkit.readCollaborationState(documentName),
|
|
188
213
|
store: ({ documentName, state }) =>
|
|
189
|
-
|
|
214
|
+
mdkit.writeCollaborationState(documentName, state),
|
|
190
215
|
}),
|
|
191
216
|
],
|
|
192
217
|
});
|
|
193
218
|
|
|
194
|
-
|
|
195
|
-
|
|
219
|
+
const appRouter = t.router({
|
|
220
|
+
mdkit: createMdKitTrpcRouter(mdkit),
|
|
221
|
+
// otherRouters: ...
|
|
222
|
+
});
|
|
196
223
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
224
|
+
export type AppRouter = typeof appRouter;
|
|
225
|
+
|
|
226
|
+
const server = createHTTPServer({
|
|
227
|
+
basePath: "/trpc",
|
|
228
|
+
router: appRouter,
|
|
202
229
|
});
|
|
203
230
|
|
|
204
|
-
|
|
205
|
-
|
|
231
|
+
const collaborationSockets = new WebSocketServer({ noServer: true });
|
|
232
|
+
|
|
233
|
+
collaborationSockets.on("connection", (socket, request) => {
|
|
234
|
+
collaboration.handleConnection(socket, request, {});
|
|
206
235
|
});
|
|
207
236
|
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
server.on("upgrade", (request, socket, head) => {
|
|
238
|
+
if (!request.url?.startsWith("/collaboration")) {
|
|
239
|
+
socket.destroy();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
210
242
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
collaborationSockets.handleUpgrade(request, socket, head, (websocket) => {
|
|
244
|
+
collaborationSockets.emit("connection", websocket, request);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
214
247
|
|
|
215
|
-
|
|
248
|
+
server.listen(Number(process.env.PORT ?? 4312));
|
|
249
|
+
```
|
|
216
250
|
|
|
217
|
-
|
|
251
|
+
**Important:** if you enable collaboration, decide where Hocuspocus Yjs state is
|
|
252
|
+
stored; in-memory state is simple but does not scale across backend instances.
|
|
253
|
+
See [Collaboration Persistence](./collaboration-persistence.md).
|
|
218
254
|
|
|
219
|
-
|
|
220
|
-
|
|
255
|
+
If the frontend runs on a different origin, put this server behind your app's
|
|
256
|
+
dev proxy or add CORS handling around the tRPC handler. See
|
|
257
|
+
[API Reference](./api.md#transport-helpers) for the full backend helper API.
|
|
221
258
|
|
|
222
|
-
|
|
223
|
-
baseUrl: "https://api.example.com/mdkit",
|
|
224
|
-
});
|
|
225
|
-
```
|
|
259
|
+
### REST
|
|
226
260
|
|
|
227
|
-
|
|
261
|
+
The quick start uses tRPC because it gives the connected editor a complete typed
|
|
262
|
+
backend surface. REST is supported, but it is more verbose: the frontend adapter
|
|
263
|
+
expects matching REST endpoints, and restore currently needs one explicit
|
|
264
|
+
request from your UI.
|
|
228
265
|
|
|
229
|
-
|
|
230
|
-
|
|
266
|
+
See [REST Backend](./rest.md) for the backend route shape and the small
|
|
267
|
+
frontend transport changes.
|
|
231
268
|
|
|
232
|
-
|
|
233
|
-
prefix: "/mdkit",
|
|
234
|
-
store,
|
|
235
|
-
});
|
|
236
|
-
```
|
|
269
|
+
### Checkpoints
|
|
237
270
|
|
|
238
|
-
|
|
239
|
-
|
|
271
|
+
Checkpoint policies decide when saved content becomes history. The backend
|
|
272
|
+
example uses `CheckpointPolicy.smart()`, which applies mdkit's default
|
|
273
|
+
autosave-friendly policy. See [Checkpoint Policy](./api.md#checkpoint-policy)
|
|
274
|
+
for `never`, `always`, `smart`, and custom policy functions.
|
|
240
275
|
|
|
241
276
|
## Questions
|
|
242
277
|
|
|
243
278
|
### Do the backends need to know about each other?
|
|
244
279
|
|
|
245
|
-
Storage,
|
|
246
|
-
the current markdown snapshot.
|
|
247
|
-
Hocuspocus
|
|
248
|
-
|
|
249
|
-
|
|
280
|
+
Storage, checkpoint history, and collaboration can stay separate. Storage stores
|
|
281
|
+
the current markdown snapshot. Checkpoint history stores immutable markdown
|
|
282
|
+
snapshots. Hocuspocus hosts live Yjs collaboration state, and your application
|
|
283
|
+
chooses whether and where to persist that state. These pieces need glue when
|
|
284
|
+
your product wants collaborative edits to become canonical markdown or
|
|
285
|
+
checkpoints.
|
|
250
286
|
|
|
251
287
|
### Does mdkit require tRPC or these exact REST endpoints?
|
|
252
288
|
|
|
@@ -254,6 +290,44 @@ No. The frontend hooks only need an `MdKitDocumentAdapter`. You can use REST,
|
|
|
254
290
|
tRPC, GraphQL, server actions, IndexedDB, Rails, Go, or anything else as long as
|
|
255
291
|
your adapter returns the documented shapes.
|
|
256
292
|
|
|
293
|
+
### How do store callbacks access user or request context?
|
|
294
|
+
|
|
295
|
+
In the tRPC quick-start path, `createMdKitTrpcRouter(mdkit)` is the simple
|
|
296
|
+
version: create one mdkit backend around a store object, then mount the router.
|
|
297
|
+
Use that when the store can enforce permissions and write metadata without
|
|
298
|
+
per-request React editor context.
|
|
299
|
+
|
|
300
|
+
If your store methods need the authenticated user, organization, request id,
|
|
301
|
+
logger, or other trusted request state, create the store at your application
|
|
302
|
+
request boundary and close over that context:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
const createStoreForRequest = (ctx: AppContext): MdKitBackendStore => ({
|
|
306
|
+
readDocument: (documentId) =>
|
|
307
|
+
files.readCurrentMarkdown({ documentId, user: ctx.user }),
|
|
308
|
+
|
|
309
|
+
writeDocument: (input) =>
|
|
310
|
+
files.writeCurrentMarkdown({
|
|
311
|
+
...input,
|
|
312
|
+
actorId: ctx.user.id,
|
|
313
|
+
}),
|
|
314
|
+
|
|
315
|
+
createCheckpoint: (input) =>
|
|
316
|
+
files.createMarkdownCheckpoint({
|
|
317
|
+
...input,
|
|
318
|
+
actorId: ctx.user.id,
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
With tRPC, that means writing the mdkit procedures in your app router when you
|
|
324
|
+
need request-scoped behavior, and calling `createMdKitBackend({
|
|
325
|
+
store: createStoreForRequest(ctx), checkpointPolicy })` inside those
|
|
326
|
+
procedures. With REST, use the same pattern inside your route handlers after
|
|
327
|
+
you have authenticated the request. Mdkit does not need to understand your auth
|
|
328
|
+
model; it only needs store callbacks that can read, write, and checkpoint the
|
|
329
|
+
document.
|
|
330
|
+
|
|
257
331
|
### Do I have to use the base panels?
|
|
258
332
|
|
|
259
333
|
No. The base panels are the fastest way to get a complete workflow working in
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Permissions
|
|
2
|
+
|
|
3
|
+
MDKit should make connected editing secure without owning your application's
|
|
4
|
+
auth system.
|
|
5
|
+
|
|
6
|
+
The application owns authentication, user identity, tenancy, document ownership,
|
|
7
|
+
roles, teams, audit logging, and permission policy. MDKit owns where permission
|
|
8
|
+
checks happen in the document, checkpoint, restore, and collaboration
|
|
9
|
+
lifecycle.
|
|
10
|
+
|
|
11
|
+
## Permission Contract
|
|
12
|
+
|
|
13
|
+
The proposed permission contract is one `can` function:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type MdKitPermissionAction = "read" | "write" | "restore";
|
|
17
|
+
|
|
18
|
+
type MdKitPermissions<User = unknown, Document = unknown, Context = unknown> = {
|
|
19
|
+
can(
|
|
20
|
+
action: MdKitPermissionAction,
|
|
21
|
+
user: User,
|
|
22
|
+
document: Document,
|
|
23
|
+
context: Context,
|
|
24
|
+
): Promise<boolean> | boolean;
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This is intentionally small. Products that do not need a separate restore
|
|
29
|
+
permission can implement `restore` as equivalent to `write`.
|
|
30
|
+
|
|
31
|
+
## Action Semantics
|
|
32
|
+
|
|
33
|
+
`read` allows reading the current document and checkpoint history.
|
|
34
|
+
|
|
35
|
+
`write` allows changing the current document, creating checkpoints, joining a
|
|
36
|
+
collaboration room, and writing/snapshotting collaboration state. Collaboration
|
|
37
|
+
requires `write`; MDKit does not need read-only collaboration in the initial
|
|
38
|
+
permission model.
|
|
39
|
+
|
|
40
|
+
`restore` allows restoring a checkpoint into the current document and resetting
|
|
41
|
+
collaboration state as part of that restore.
|
|
42
|
+
|
|
43
|
+
Permissions should be mandatory for the opinionated backend helper. A package
|
|
44
|
+
can expose an explicit allow-all implementation for local development and
|
|
45
|
+
applications that intentionally handle permissions outside MDKit:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
export const mdKitDisablePermissions: MdKitPermissions = {
|
|
49
|
+
can: () => true,
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Disabling permission checks should be visible in code. It should not be the
|
|
54
|
+
accidental default.
|
|
55
|
+
|
|
56
|
+
## Collaboration Security
|
|
57
|
+
|
|
58
|
+
Collaboration room names are not secrets. The server must authorize every room
|
|
59
|
+
join.
|
|
60
|
+
|
|
61
|
+
Joining a collaboration room is both read access and write access to the live
|
|
62
|
+
Yjs document. Once connected, a client can receive live state and send Yjs
|
|
63
|
+
updates unless the server rejects or constrains that connection. Storage
|
|
64
|
+
callbacks alone are not enough because active Yjs state can be read or changed
|
|
65
|
+
before a persistence callback runs.
|
|
66
|
+
|
|
67
|
+
The join rule is:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
can("write", user, document, context)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If permission fails, the WebSocket must be rejected before the client receives
|
|
74
|
+
document state or sends accepted Yjs updates.
|
|
75
|
+
|
|
76
|
+
## Enforcement Levels
|
|
77
|
+
|
|
78
|
+
Applications should be able to choose how strictly collaboration permissions are
|
|
79
|
+
enforced after a session joins.
|
|
80
|
+
|
|
81
|
+
### Join Only
|
|
82
|
+
|
|
83
|
+
Check `can("write")` when the user joins the collaboration room. This is simple
|
|
84
|
+
and common. Tradeoff: if permissions are revoked mid-session, the active socket
|
|
85
|
+
may keep editing until it disconnects, is closed, or is forced to reconnect.
|
|
86
|
+
WebSocket ping timeouts are liveness checks, not permission expiry.
|
|
87
|
+
|
|
88
|
+
### Per-Message Permission Check
|
|
89
|
+
|
|
90
|
+
Check `can("write")` in Hocuspocus `beforeHandleMessage` before every incoming
|
|
91
|
+
Yjs message is applied. This is closest to checking permissions before every
|
|
92
|
+
HTTP write request. It can read from the database directly, but it may be too
|
|
93
|
+
expensive for high-volume collaboration.
|
|
94
|
+
|
|
95
|
+
### Per-Message Revocation Check
|
|
96
|
+
|
|
97
|
+
Run full permission policy on join and permission-change operations, then check
|
|
98
|
+
a cheap revocation source per packet: an in-memory set, cache entry, permission
|
|
99
|
+
generation, or short-lived authorization record.
|
|
100
|
+
|
|
101
|
+
This is the practical compromise for stricter apps. The revocation source must
|
|
102
|
+
have the ordering guarantee the product needs. If a revocation request returns
|
|
103
|
+
success before every collaboration server can observe the revoked state, the
|
|
104
|
+
system has eventual revocation rather than strict post-response write
|
|
105
|
+
prevention.
|
|
106
|
+
|
|
107
|
+
## Hocuspocus Hooks
|
|
108
|
+
|
|
109
|
+
Hocuspocus exposes the server hooks MDKit needs:
|
|
110
|
+
|
|
111
|
+
- `onConnect` runs when a client first asks to connect to a document
|
|
112
|
+
- `onAuthenticate` runs when authentication is enabled and receives the provider
|
|
113
|
+
token
|
|
114
|
+
- `beforeHandleMessage` runs before an incoming collaboration message is applied
|
|
115
|
+
to the Yjs document
|
|
116
|
+
|
|
117
|
+
`useMdKitCollaboration` already has a token hook. That token is not the
|
|
118
|
+
permission policy; it is how the server identifies the user and request context
|
|
119
|
+
before calling `can`.
|
|
120
|
+
|
|
121
|
+
## Backend Helper Responsibilities
|
|
122
|
+
|
|
123
|
+
An opinionated MDKit backend helper should call permissions at every server-side
|
|
124
|
+
entry point:
|
|
125
|
+
|
|
126
|
+
| Operation | Required Action |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| Read current document | `read` |
|
|
129
|
+
| Write current document | `write` |
|
|
130
|
+
| List checkpoints | `read` |
|
|
131
|
+
| Read checkpoint | `read` |
|
|
132
|
+
| Create checkpoint | `write` |
|
|
133
|
+
| Restore checkpoint | `restore` |
|
|
134
|
+
| Join collaboration | `write` |
|
|
135
|
+
| Persist collaboration state from an authorized room | `write` |
|
|
136
|
+
| Reset collaboration state during restore | `restore` |
|
|
137
|
+
|
|
138
|
+
Frontend UI checks are convenience. Server-side checks are the security
|
|
139
|
+
boundary.
|