@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.
Files changed (40) hide show
  1. package/dist/core/checkpointPolicy.d.ts +25 -0
  2. package/dist/core/checkpointPolicy.js +45 -0
  3. package/dist/core/index.d.ts +2 -0
  4. package/dist/core/index.js +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +1 -0
  7. package/dist/server.d.ts +2 -0
  8. package/dist/server.js +1 -0
  9. package/dist/transport/backend.d.ts +29 -0
  10. package/dist/transport/backend.js +93 -0
  11. package/dist/transport/trpcClient.d.ts +29 -8
  12. package/dist/trpc/client.d.ts +1 -1
  13. package/dist/trpc/server.d.ts +2 -0
  14. package/dist/trpc/server.js +1 -0
  15. package/dist/trpc.d.ts +1 -1
  16. package/docs/.vitepress/cache/deps/@theme_index.js +275 -0
  17. package/docs/.vitepress/cache/deps/@theme_index.js.map +7 -0
  18. package/docs/.vitepress/cache/deps/_metadata.json +40 -0
  19. package/docs/.vitepress/cache/deps/chunk-PM3I3KHC.js +9719 -0
  20. package/docs/.vitepress/cache/deps/chunk-PM3I3KHC.js.map +7 -0
  21. package/docs/.vitepress/cache/deps/chunk-VSHFF4ZG.js +13018 -0
  22. package/docs/.vitepress/cache/deps/chunk-VSHFF4ZG.js.map +7 -0
  23. package/docs/.vitepress/cache/deps/package.json +3 -0
  24. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4505 -0
  25. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
  26. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +583 -0
  27. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
  28. package/docs/.vitepress/cache/deps/vue.js +347 -0
  29. package/docs/.vitepress/cache/deps/vue.js.map +7 -0
  30. package/docs/.vitepress/config.ts +11 -0
  31. package/docs/api.md +153 -18
  32. package/docs/architecture.md +24 -9
  33. package/docs/collaboration-persistence.md +147 -0
  34. package/docs/index.md +139 -65
  35. package/docs/permissions.md +139 -0
  36. package/docs/rest.md +98 -0
  37. package/docs/shadcn.md +6 -3
  38. package/docs/styling.md +11 -10
  39. package/docs/use-cases.md +148 -0
  40. 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, version history, conflict handling, and
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
- version history, and no collaboration. You own the `value` and `onChange` state.
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-version views, or any readonly markdown
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 version browsing and restore
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
- createMdKitTrpcAdapter,
88
- createMdKitTrpcClient,
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
- () => createMdKitTrpcClient({ url: `${apiUrl}/trpc` }),
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 modal shells above are intentionally plain. Put `VersionHistoryPanel` and
163
- `MdKitConflictPanel` inside your own dialog, drawer, side panel, or editor
164
- replacement view. If your app uses shadcn/ui, see [Shadcn Plugin](./shadcn.md)
165
- for the source-installed workflow component.
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
- ### Backend With Fastify And tRPC
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
- The backend only needs a store object. Replace `createYourDocumentStore()` with
170
- Postgres, MongoDB, Redis, files, or any other durable storage.
187
+ ### Backend With tRPC
171
188
 
172
189
  ```ts
173
- import cors from "@fastify/cors";
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 { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
178
- import Fastify from "fastify";
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 app = Fastify();
182
- const store = createYourDocumentStore();
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 }) => store.readCollaborationState(documentName),
212
+ fetch: ({ documentName }) => mdkit.readCollaborationState(documentName),
188
213
  store: ({ documentName, state }) =>
189
- store.writeCollaborationState(documentName, state),
214
+ mdkit.writeCollaborationState(documentName, state),
190
215
  }),
191
216
  ],
192
217
  });
193
218
 
194
- await app.register(cors, { origin: true });
195
- await app.register(websocket);
219
+ const appRouter = t.router({
220
+ mdkit: createMdKitTrpcRouter(mdkit),
221
+ // otherRouters: ...
222
+ });
196
223
 
197
- await app.register(fastifyTRPCPlugin, {
198
- prefix: "/trpc",
199
- trpcOptions: {
200
- router: createMdKitTrpcRouter(store),
201
- },
224
+ export type AppRouter = typeof appRouter;
225
+
226
+ const server = createHTTPServer({
227
+ basePath: "/trpc",
228
+ router: appRouter,
202
229
  });
203
230
 
204
- app.get("/collaboration", { websocket: true }, (socket, request) => {
205
- collaboration.handleConnection(socket, request.raw, {});
231
+ const collaborationSockets = new WebSocketServer({ noServer: true });
232
+
233
+ collaborationSockets.on("connection", (socket, request) => {
234
+ collaboration.handleConnection(socket, request, {});
206
235
  });
207
236
 
208
- await app.listen({ port: Number(process.env.PORT ?? 4312) });
209
- ```
237
+ server.on("upgrade", (request, socket, head) => {
238
+ if (!request.url?.startsWith("/collaboration")) {
239
+ socket.destroy();
240
+ return;
241
+ }
210
242
 
211
- The store object implements `MdKitTransportStore`: current document reads and
212
- writes, version list/read/restore, and optional collaboration state storage. See
213
- [API Reference](./api.md#transport-helpers).
243
+ collaborationSockets.handleUpgrade(request, socket, head, (websocket) => {
244
+ collaborationSockets.emit("connection", websocket, request);
245
+ });
246
+ });
214
247
 
215
- ### REST Compatibility
248
+ server.listen(Number(process.env.PORT ?? 4312));
249
+ ```
216
250
 
217
- If you want REST instead of tRPC, use the REST frontend adapter:
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
- ```tsx
220
- import { createMdKitRestAdapter } from "@mp-lb/mdkit";
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
- const adapter = createMdKitRestAdapter({
223
- baseUrl: "https://api.example.com/mdkit",
224
- });
225
- ```
259
+ ### REST
226
260
 
227
- On Fastify, register the matching REST endpoints:
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
- ```ts
230
- import { registerMdKitFastify } from "@mp-lb/mdkit/fastify";
266
+ See [REST Backend](./rest.md) for the backend route shape and the small
267
+ frontend transport changes.
231
268
 
232
- await registerMdKitFastify(app, {
233
- prefix: "/mdkit",
234
- store,
235
- });
236
- ```
269
+ ### Checkpoints
237
270
 
238
- The mdkit testbench uses this split deliberately: `Connected (panels)` uses the
239
- REST adapter, while `Connected (shadcn)` uses the tRPC adapter.
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, version history, and collaboration can stay separate. Storage stores
246
- the current markdown snapshot. Version history stores markdown snapshots.
247
- Hocuspocus stores live Yjs collaboration state. They only need glue if your
248
- product wants collaborative edits to automatically become saved markdown
249
- versions.
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.