@mh-gg/base-plugins 0.1.1-alpha.20260613T085325975Z
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/README.md +247 -0
- package/package.json +48 -0
- package/src/approvals/index.cjs +65 -0
- package/src/attachments/index.cjs +75 -0
- package/src/calendar/index.cjs +48 -0
- package/src/checklists/index.cjs +58 -0
- package/src/comments/index.cjs +15 -0
- package/src/comments/plugin.cjs +66 -0
- package/src/comments/reducer.cjs +81 -0
- package/src/comments/schemas.cjs +60 -0
- package/src/comments/state.cjs +17 -0
- package/src/comments/threads.cjs +44 -0
- package/src/comments/views.cjs +27 -0
- package/src/composer/capabilities.cjs +19 -0
- package/src/composer/compose.cjs +37 -0
- package/src/composer/index.cjs +15 -0
- package/src/composer/operations.cjs +42 -0
- package/src/composer/registry.cjs +155 -0
- package/src/composer/selection.cjs +39 -0
- package/src/composer/suite.cjs +32 -0
- package/src/crdt/client.mjs +207 -0
- package/src/crdt/index.cjs +258 -0
- package/src/embeds/index.cjs +90 -0
- package/src/files/index.cjs +133 -0
- package/src/index.cjs +19 -0
- package/src/labels/index.cjs +46 -0
- package/src/location-pins/index.cjs +142 -0
- package/src/markdown/documents/index.cjs +128 -0
- package/src/markdown/index.cjs +8 -0
- package/src/markdown/parser/index.cjs +127 -0
- package/src/markdown/providers/audio.cjs +77 -0
- package/src/markdown/providers/cloud.cjs +72 -0
- package/src/markdown/providers/developer.cjs +45 -0
- package/src/markdown/providers/direct.cjs +49 -0
- package/src/markdown/providers/games.cjs +26 -0
- package/src/markdown/providers/images.cjs +88 -0
- package/src/markdown/providers/index.cjs +97 -0
- package/src/markdown/providers/maps.cjs +24 -0
- package/src/markdown/providers/productivity.cjs +30 -0
- package/src/markdown/providers/res-inspired.cjs +11 -0
- package/src/markdown/providers/social.cjs +33 -0
- package/src/markdown/providers/video.cjs +139 -0
- package/src/markdown/resolve.cjs +87 -0
- package/src/media-rooms/index.cjs +244 -0
- package/src/presence/index.cjs +193 -0
- package/src/reactions/index.cjs +47 -0
- package/src/screen-share/index.cjs +84 -0
- package/src/shared/constants.cjs +87 -0
- package/src/shared/embed.cjs +82 -0
- package/src/shared/index.cjs +20 -0
- package/src/shared/roles.cjs +5 -0
- package/src/shared/scopes.cjs +15 -0
- package/src/shared/url.cjs +32 -0
- package/src/shared/validation.cjs +31 -0
- package/test/composable-plugins.test.cjs +170 -0
- package/test/crdt-plugin.test.cjs +168 -0
- package/test/embed-autodetect-providers.test.cjs +138 -0
- package/test/markdown-media-workflow-plugins.test.cjs +201 -0
- package/test/markdown-parser-edge-cases.test.cjs +86 -0
- package/test/plugin-structure.test.cjs +69 -0
- package/test/shared-plugin-edges.test.cjs +207 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { canonicalJson } from "@mh-gg/event";
|
|
2
|
+
import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";
|
|
3
|
+
import * as Y from "yjs";
|
|
4
|
+
|
|
5
|
+
export const CRDT_DOCUMENT_CHANGE_KIND = "crdt.document.change";
|
|
6
|
+
export const CRDT_CURSOR_UPDATE_KIND = "crdt.cursor.update";
|
|
7
|
+
export const MATTERHORN_CRDT_NOSTR_KIND = 24313;
|
|
8
|
+
|
|
9
|
+
const DOCUMENT_UPDATE_FORMAT = "matterhorn.shared-doc.update.v1";
|
|
10
|
+
const CURSOR_FORMAT = "matterhorn.shared-doc.cursor.v1";
|
|
11
|
+
const REMOTE_ORIGIN = "matterhorn-crdt-remote";
|
|
12
|
+
|
|
13
|
+
function requiredString(value, name) {
|
|
14
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`);
|
|
15
|
+
return value.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requireFunction(value, name) {
|
|
19
|
+
if (typeof value !== "function") throw new Error(`${name} is required`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bytesToBase64(bytes) {
|
|
24
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
25
|
+
let binary = "";
|
|
26
|
+
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
|
|
27
|
+
return btoa(binary);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function base64ToBytes(value) {
|
|
31
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(value, "base64"));
|
|
32
|
+
const binary = atob(value);
|
|
33
|
+
const bytes = new Uint8Array(binary.length);
|
|
34
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hexToBytes(value) {
|
|
39
|
+
if (value instanceof Uint8Array) return value;
|
|
40
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value)) throw new Error("privateKey must be a 32-byte hex string or Uint8Array");
|
|
41
|
+
const bytes = new Uint8Array(32);
|
|
42
|
+
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tagValue(tags, name) {
|
|
47
|
+
const tag = tags.find((item) => Array.isArray(item) && item[0] === name);
|
|
48
|
+
return typeof tag?.[1] === "string" ? tag[1] : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function encryptedPayload(value, kind) {
|
|
52
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("encryptRoomPayload must return an encrypted payload object");
|
|
53
|
+
if (value.encrypted !== true || value.alg !== "A256GCM") throw new Error("encryptRoomPayload returned an invalid encrypted payload");
|
|
54
|
+
if (value.kind !== undefined && value.kind !== kind) throw new Error("encryptRoomPayload returned the wrong kind");
|
|
55
|
+
if (typeof value.iv !== "string" || typeof value.data !== "string") throw new Error("encrypted payload is missing iv or data");
|
|
56
|
+
return { ...value, kind };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function changeFromUpdate(update) {
|
|
60
|
+
return { format: DOCUMENT_UPDATE_FORMAT, updateBase64: bytesToBase64(update) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateFromChange(change) {
|
|
64
|
+
if (!change || change.format !== DOCUMENT_UPDATE_FORMAT || typeof change.updateBase64 !== "string") throw new Error("Invalid shared document change");
|
|
65
|
+
return base64ToBytes(change.updateBase64);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createSharedTextDocument(options = {}) {
|
|
69
|
+
const doc = options.doc || new Y.Doc();
|
|
70
|
+
const text = doc.getText(options.textName || "body");
|
|
71
|
+
const listeners = new Set();
|
|
72
|
+
const pendingUpdates = [];
|
|
73
|
+
let applyingRemote = false;
|
|
74
|
+
|
|
75
|
+
function emit() {
|
|
76
|
+
const snapshot = api.getSnapshot();
|
|
77
|
+
for (const listener of [...listeners]) listener(snapshot);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
doc.on("update", (update) => {
|
|
81
|
+
if (!applyingRemote) pendingUpdates.push(new Uint8Array(update));
|
|
82
|
+
emit();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const api = {
|
|
86
|
+
getText() { return text.toString(); },
|
|
87
|
+
getSnapshot() { return { text: text.toString(), pendingUpdateCount: pendingUpdates.length }; },
|
|
88
|
+
replaceText(value) {
|
|
89
|
+
const next = String(value ?? "");
|
|
90
|
+
doc.transact(() => {
|
|
91
|
+
text.delete(0, text.length);
|
|
92
|
+
if (next) text.insert(0, next);
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
insertText(index, value) {
|
|
96
|
+
const position = Math.max(0, Math.min(Number(index) || 0, text.length));
|
|
97
|
+
text.insert(position, String(value ?? ""));
|
|
98
|
+
},
|
|
99
|
+
deleteText(index, length) {
|
|
100
|
+
const position = Math.max(0, Math.min(Number(index) || 0, text.length));
|
|
101
|
+
const count = Math.max(0, Math.min(Number(length) || 0, text.length - position));
|
|
102
|
+
if (count > 0) text.delete(position, count);
|
|
103
|
+
},
|
|
104
|
+
subscribe(listener) {
|
|
105
|
+
listeners.add(listener);
|
|
106
|
+
listener(api.getSnapshot());
|
|
107
|
+
return () => listeners.delete(listener);
|
|
108
|
+
},
|
|
109
|
+
takePendingUpdate() {
|
|
110
|
+
if (!pendingUpdates.length) return null;
|
|
111
|
+
const update = pendingUpdates.length === 1 ? pendingUpdates[0] : Y.mergeUpdates(pendingUpdates);
|
|
112
|
+
pendingUpdates.length = 0;
|
|
113
|
+
return update;
|
|
114
|
+
},
|
|
115
|
+
createChange() {
|
|
116
|
+
const update = api.takePendingUpdate();
|
|
117
|
+
return update ? changeFromUpdate(update) : null;
|
|
118
|
+
},
|
|
119
|
+
applyChange(change) {
|
|
120
|
+
const update = updateFromChange(change);
|
|
121
|
+
applyingRemote = true;
|
|
122
|
+
try {
|
|
123
|
+
Y.applyUpdate(doc, update, REMOTE_ORIGIN);
|
|
124
|
+
} finally {
|
|
125
|
+
applyingRemote = false;
|
|
126
|
+
}
|
|
127
|
+
emit();
|
|
128
|
+
},
|
|
129
|
+
encodeState() { return bytesToBase64(Y.encodeStateAsUpdate(doc)); },
|
|
130
|
+
applyEncodedState(value) { api.applyChange(changeFromUpdate(base64ToBytes(value))); }
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return api;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createCrdtEventKey() {
|
|
137
|
+
return generateSecretKey();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function createEncryptedCrdtEvent(input) {
|
|
141
|
+
const roomName = requiredString(input.roomName || input.roomId, "roomName");
|
|
142
|
+
const documentId = requiredString(input.documentId, "documentId");
|
|
143
|
+
const kind = requiredString(input.kind, "kind");
|
|
144
|
+
const encryptRoomPayload = requireFunction(input.encryptRoomPayload, "encryptRoomPayload");
|
|
145
|
+
const privateKey = input.privateKey ? hexToBytes(input.privateKey) : generateSecretKey();
|
|
146
|
+
const value = { documentId, ...(input.value || {}) };
|
|
147
|
+
const payload = encryptedPayload(await encryptRoomPayload(kind, value), kind);
|
|
148
|
+
const createdAt = Number.isInteger(input.createdAt) ? input.createdAt : Date.now();
|
|
149
|
+
const event = finalizeEvent({
|
|
150
|
+
kind: MATTERHORN_CRDT_NOSTR_KIND,
|
|
151
|
+
created_at: Math.floor(createdAt / 1000),
|
|
152
|
+
tags: [
|
|
153
|
+
["protocol", "matterhorn-sdk"],
|
|
154
|
+
["version", "1"],
|
|
155
|
+
["d", roomName],
|
|
156
|
+
["crdt-doc", documentId],
|
|
157
|
+
["crdt-kind", kind],
|
|
158
|
+
["created-at-ms", String(createdAt)]
|
|
159
|
+
],
|
|
160
|
+
content: canonicalJson({ payload })
|
|
161
|
+
}, privateKey);
|
|
162
|
+
if (!verifyEvent(event)) throw new Error("CRDT event signature did not verify");
|
|
163
|
+
return event;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function encryptedCrdtPayload(event, expectedKind) {
|
|
167
|
+
if (!event || typeof event !== "object") throw new Error("CRDT event is required");
|
|
168
|
+
if (!verifyEvent(event)) throw new Error("CRDT event signature did not verify");
|
|
169
|
+
if (tagValue(event.tags || [], "crdt-kind") !== expectedKind) throw new Error("CRDT event kind is invalid");
|
|
170
|
+
const content = JSON.parse(event.content || "{}");
|
|
171
|
+
return encryptedPayload(content.payload, expectedKind);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function decryptCrdtEvent(event, expectedKind, decryptRoomPayload) {
|
|
175
|
+
const decrypt = requireFunction(decryptRoomPayload, "decryptRoomPayload");
|
|
176
|
+
return decrypt(expectedKind, encryptedCrdtPayload(event, expectedKind));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function createDocumentChangeEvent(input) {
|
|
180
|
+
const document = input.document || input.sharedDocument;
|
|
181
|
+
const change = input.change || document?.createChange?.();
|
|
182
|
+
if (!change) return null;
|
|
183
|
+
return createEncryptedCrdtEvent({ ...input, kind: CRDT_DOCUMENT_CHANGE_KIND, value: change });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function applyDocumentChangeEvent(input) {
|
|
187
|
+
const document = input.document || input.sharedDocument;
|
|
188
|
+
if (!document?.applyChange) throw new Error("document.applyChange is required");
|
|
189
|
+
const change = await decryptCrdtEvent(input.event, CRDT_DOCUMENT_CHANGE_KIND, input.decryptRoomPayload);
|
|
190
|
+
document.applyChange(change);
|
|
191
|
+
return change;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function createCursorEvent(input) {
|
|
195
|
+
const cursor = input.cursor && typeof input.cursor === "object" ? input.cursor : {};
|
|
196
|
+
return createEncryptedCrdtEvent({ ...input, kind: CRDT_CURSOR_UPDATE_KIND, value: { format: CURSOR_FORMAT, cursor } });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function readCursorEvent(input) {
|
|
200
|
+
const value = await decryptCrdtEvent(input.event, CRDT_CURSOR_UPDATE_KIND, input.decryptRoomPayload);
|
|
201
|
+
if (value.format !== CURSOR_FORMAT || !value.cursor || typeof value.cursor !== "object") throw new Error("Invalid cursor event");
|
|
202
|
+
return value.cursor;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function publicKeyForCrdtEventKey(privateKey) {
|
|
206
|
+
return getPublicKey(hexToBytes(privateKey));
|
|
207
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const { validateEvent, verifyEvent } = require("nostr-tools/pure");
|
|
2
|
+
const {
|
|
3
|
+
allow,
|
|
4
|
+
activity,
|
|
5
|
+
actorName,
|
|
6
|
+
array,
|
|
7
|
+
clone,
|
|
8
|
+
createOperationSchemaDescriptor,
|
|
9
|
+
defineHostPlugin,
|
|
10
|
+
deny,
|
|
11
|
+
object,
|
|
12
|
+
readonlyState,
|
|
13
|
+
string,
|
|
14
|
+
memberOrBetter,
|
|
15
|
+
BASE_PLUGIN_VERSION,
|
|
16
|
+
CRDT_PLUGIN_ID,
|
|
17
|
+
MAX_CRDT_EVENT_BYTES,
|
|
18
|
+
MAX_CRDT_UPDATES_PER_DOCUMENT
|
|
19
|
+
} = require("../shared/index.cjs");
|
|
20
|
+
|
|
21
|
+
const CRDT_DOCUMENT_CHANGE_TYPE = "crdt.document.change";
|
|
22
|
+
const CRDT_CURSOR_UPDATE_TYPE = "crdt.cursor.update";
|
|
23
|
+
const CRDT_CURSOR_CLEAR_TYPE = "crdt.cursor.clear";
|
|
24
|
+
const MATTERHORN_CRDT_NOSTR_KIND = 24313;
|
|
25
|
+
const MAX_CRDT_ACTIVITY = 250;
|
|
26
|
+
const RESERVED_IDS = new Set(["__proto__", "prototype", "constructor"]);
|
|
27
|
+
|
|
28
|
+
function crdtId(value, name) {
|
|
29
|
+
const id = string(value, name, 200);
|
|
30
|
+
if (RESERVED_IDS.has(id)) throw new Error(`${name} is reserved`);
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tagValue(tags, name) {
|
|
35
|
+
const tag = tags.find((item) => Array.isArray(item) && item[0] === name);
|
|
36
|
+
return typeof tag?.[1] === "string" ? tag[1] : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseEventContent(event, expectedKind) {
|
|
40
|
+
let content;
|
|
41
|
+
try {
|
|
42
|
+
content = JSON.parse(event.content || "{}");
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error("CRDT event content must be JSON");
|
|
45
|
+
}
|
|
46
|
+
const payload = object(content.payload, "event.content.payload");
|
|
47
|
+
if (payload.encrypted !== true) throw new Error("CRDT event payload must be encrypted");
|
|
48
|
+
if (payload.alg !== "A256GCM") throw new Error("CRDT event payload algorithm is invalid");
|
|
49
|
+
if (payload.kind !== expectedKind) throw new Error("CRDT event payload kind is invalid");
|
|
50
|
+
string(payload.iv, "event.content.payload.iv", 2000);
|
|
51
|
+
string(payload.data, "event.content.payload.data", MAX_CRDT_EVENT_BYTES);
|
|
52
|
+
if (payload.epochId !== undefined) string(payload.epochId, "event.content.payload.epochId", 200);
|
|
53
|
+
return payload;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function eventPayload(value) {
|
|
57
|
+
const event = object(value, "CRDT event");
|
|
58
|
+
if (event.kind !== MATTERHORN_CRDT_NOSTR_KIND) throw new Error("CRDT event kind is invalid");
|
|
59
|
+
if (!Number.isInteger(event.created_at)) throw new Error("CRDT event created_at must be an integer");
|
|
60
|
+
string(event.id, "event.id", 160);
|
|
61
|
+
string(event.pubkey, "event.pubkey", 160);
|
|
62
|
+
string(event.sig, "event.sig", 240);
|
|
63
|
+
string(event.content, "event.content", MAX_CRDT_EVENT_BYTES);
|
|
64
|
+
event.tags = array(event.tags, "event.tags").map((tag, index) => array(tag, `event.tags[${index}]`).map((part, partIndex) => string(part, `event.tags[${index}][${partIndex}]`, 500)));
|
|
65
|
+
const json = JSON.stringify(event);
|
|
66
|
+
if (Buffer.byteLength(json, "utf8") > MAX_CRDT_EVENT_BYTES) throw new Error("CRDT event is too large");
|
|
67
|
+
if (!validateEvent(event) || !verifyEvent(event)) throw new Error("CRDT event signature did not verify");
|
|
68
|
+
return event;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateRoomEvent(roomId, documentId, event, expectedKind) {
|
|
72
|
+
if (tagValue(event.tags, "protocol") !== "matterhorn-sdk") throw new Error("CRDT event protocol is invalid");
|
|
73
|
+
if (tagValue(event.tags, "d") !== roomId) throw new Error("CRDT event room does not match operation room");
|
|
74
|
+
if (tagValue(event.tags, "crdt-doc") !== documentId) throw new Error("CRDT event document does not match operation document");
|
|
75
|
+
if (tagValue(event.tags, "crdt-kind") !== expectedKind) throw new Error("CRDT event kind tag is invalid");
|
|
76
|
+
parseEventContent(event, expectedKind);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function changePayload(payload) {
|
|
80
|
+
const value = object(payload, "crdt.document.change payload");
|
|
81
|
+
return { documentId: crdtId(value.documentId, "documentId"), event: eventPayload(value.event) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cursorPayload(payload) {
|
|
85
|
+
const value = object(payload, "crdt.cursor.update payload");
|
|
86
|
+
return { documentId: crdtId(value.documentId, "documentId"), event: eventPayload(value.event) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clearCursorPayload(payload) {
|
|
90
|
+
const value = object(payload, "crdt.cursor.clear payload");
|
|
91
|
+
return { documentId: crdtId(value.documentId, "documentId") };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseCrdtState(state) {
|
|
95
|
+
const value = object(state, "CRDT state");
|
|
96
|
+
object(value.documents, "documents");
|
|
97
|
+
object(value.cursors, "cursors");
|
|
98
|
+
array(value.activity, "activity");
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function emptyDocument(documentId, op) {
|
|
103
|
+
return {
|
|
104
|
+
id: documentId,
|
|
105
|
+
documentId,
|
|
106
|
+
createdAt: op.createdAt,
|
|
107
|
+
createdBy: op.actor.memberId,
|
|
108
|
+
updatedAt: op.createdAt,
|
|
109
|
+
updatedBy: op.actor.memberId,
|
|
110
|
+
changeCount: 0,
|
|
111
|
+
changes: {},
|
|
112
|
+
changeOrder: []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendActivity(state, op, message, extra) {
|
|
117
|
+
return activity(state, op, message, extra).slice(-MAX_CRDT_ACTIVITY);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cursorKey(actor) {
|
|
121
|
+
return `${encodeURIComponent(actor.memberId || "member")}:${encodeURIComponent(actor.deviceId || "device")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function appendChange(state, op) {
|
|
125
|
+
const documentId = op.payload.documentId;
|
|
126
|
+
const document = state.documents[documentId] || emptyDocument(documentId, op);
|
|
127
|
+
const eventId = op.payload.event.id;
|
|
128
|
+
if (document.changes[eventId]) return state;
|
|
129
|
+
if (document.changeOrder.length >= MAX_CRDT_UPDATES_PER_DOCUMENT) throw new Error("CRDT document update limit reached");
|
|
130
|
+
const change = {
|
|
131
|
+
id: eventId,
|
|
132
|
+
eventId,
|
|
133
|
+
event: clone(op.payload.event),
|
|
134
|
+
authorId: op.actor.memberId,
|
|
135
|
+
authorName: actorName(op.actor),
|
|
136
|
+
createdAt: op.createdAt
|
|
137
|
+
};
|
|
138
|
+
const nextDocument = {
|
|
139
|
+
...document,
|
|
140
|
+
updatedAt: op.createdAt,
|
|
141
|
+
updatedBy: op.actor.memberId,
|
|
142
|
+
changeCount: document.changeCount + 1,
|
|
143
|
+
changes: { ...document.changes, [eventId]: change },
|
|
144
|
+
changeOrder: [...document.changeOrder, eventId]
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
...state,
|
|
148
|
+
documents: { ...state.documents, [documentId]: nextDocument },
|
|
149
|
+
activity: appendActivity(state, op, `${actorName(op.actor)} updated a shared document`, { documentId, eventId })
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function updateCursor(state, op) {
|
|
154
|
+
const documentId = op.payload.documentId;
|
|
155
|
+
const byDocument = state.cursors[documentId] || {};
|
|
156
|
+
const key = cursorKey(op.actor);
|
|
157
|
+
const cursor = {
|
|
158
|
+
id: key,
|
|
159
|
+
memberId: op.actor.memberId,
|
|
160
|
+
deviceId: op.actor.deviceId || null,
|
|
161
|
+
actorName: actorName(op.actor),
|
|
162
|
+
eventId: op.payload.event.id,
|
|
163
|
+
event: clone(op.payload.event),
|
|
164
|
+
updatedAt: op.createdAt
|
|
165
|
+
};
|
|
166
|
+
return { ...state, cursors: { ...state.cursors, [documentId]: { ...byDocument, [key]: cursor } } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function clearCursor(state, op) {
|
|
170
|
+
const documentId = op.payload.documentId;
|
|
171
|
+
const byDocument = { ...(state.cursors[documentId] || {}) };
|
|
172
|
+
delete byDocument[cursorKey(op.actor)];
|
|
173
|
+
return { ...state, cursors: { ...state.cursors, [documentId]: byDocument } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const nostrEventPayloadSchema = {
|
|
177
|
+
type: "object",
|
|
178
|
+
required: {
|
|
179
|
+
kind: { type: "number" },
|
|
180
|
+
created_at: { type: "number" },
|
|
181
|
+
tags: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
182
|
+
content: { type: "string" },
|
|
183
|
+
pubkey: { type: "string" },
|
|
184
|
+
id: { type: "string" },
|
|
185
|
+
sig: { type: "string" }
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const crdtOperationSchemaDescriptor = {
|
|
190
|
+
[CRDT_DOCUMENT_CHANGE_TYPE]: {
|
|
191
|
+
required: { documentId: { type: "string" }, event: nostrEventPayloadSchema },
|
|
192
|
+
authorize: { roles: ["member"] }
|
|
193
|
+
},
|
|
194
|
+
[CRDT_CURSOR_UPDATE_TYPE]: {
|
|
195
|
+
required: { documentId: { type: "string" }, event: nostrEventPayloadSchema },
|
|
196
|
+
authorize: { roles: ["member"] }
|
|
197
|
+
},
|
|
198
|
+
[CRDT_CURSOR_CLEAR_TYPE]: {
|
|
199
|
+
required: { documentId: { type: "string" } },
|
|
200
|
+
authorize: { roles: ["member"] }
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const crdtHostPlugin = defineHostPlugin({
|
|
205
|
+
id: CRDT_PLUGIN_ID,
|
|
206
|
+
version: BASE_PLUGIN_VERSION,
|
|
207
|
+
meta: { name: "Encrypted Client CRDT Events" },
|
|
208
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["crdt.shared-docs", "crdt.cursors", "crdt.encrypted-events"] },
|
|
209
|
+
stateSchemaDescriptor: { plugin: CRDT_PLUGIN_ID, shape: ["documents", "cursors", "activity"] },
|
|
210
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(CRDT_PLUGIN_ID, BASE_PLUGIN_VERSION, crdtOperationSchemaDescriptor),
|
|
211
|
+
schemas: {
|
|
212
|
+
state: { parse: parseCrdtState },
|
|
213
|
+
operations: {
|
|
214
|
+
[CRDT_DOCUMENT_CHANGE_TYPE]: { parse: changePayload },
|
|
215
|
+
[CRDT_CURSOR_UPDATE_TYPE]: { parse: cursorPayload },
|
|
216
|
+
[CRDT_CURSOR_CLEAR_TYPE]: { parse: clearCursorPayload }
|
|
217
|
+
},
|
|
218
|
+
publicView: { parse: readonlyState },
|
|
219
|
+
queries: { documentChanges: { parse: readonlyState }, documentCursors: { parse: readonlyState } }
|
|
220
|
+
},
|
|
221
|
+
async createInitialState() { return { documents: {}, cursors: {}, activity: [] }; },
|
|
222
|
+
authorize(_ctx, op) {
|
|
223
|
+
return [CRDT_DOCUMENT_CHANGE_TYPE, CRDT_CURSOR_UPDATE_TYPE, CRDT_CURSOR_CLEAR_TYPE].includes(op.type) && memberOrBetter(op.actor) ? allow() : deny("Members only");
|
|
224
|
+
},
|
|
225
|
+
async reduce(ctx, state, op) {
|
|
226
|
+
if (op.type === CRDT_DOCUMENT_CHANGE_TYPE) {
|
|
227
|
+
validateRoomEvent(ctx.room.id, op.payload.documentId, op.payload.event, CRDT_DOCUMENT_CHANGE_TYPE);
|
|
228
|
+
return appendChange(state, op);
|
|
229
|
+
}
|
|
230
|
+
if (op.type === CRDT_CURSOR_UPDATE_TYPE) {
|
|
231
|
+
validateRoomEvent(ctx.room.id, op.payload.documentId, op.payload.event, CRDT_CURSOR_UPDATE_TYPE);
|
|
232
|
+
return updateCursor(state, op);
|
|
233
|
+
}
|
|
234
|
+
if (op.type === CRDT_CURSOR_CLEAR_TYPE) return clearCursor(state, op);
|
|
235
|
+
return state;
|
|
236
|
+
},
|
|
237
|
+
getPublicView(_ctx, state) { return readonlyState(state); },
|
|
238
|
+
queries: {
|
|
239
|
+
documentChanges(_ctx, state, input = {}) {
|
|
240
|
+
const documentId = crdtId(object(input, "documentChanges input").documentId, "documentId");
|
|
241
|
+
const document = state.documents[documentId];
|
|
242
|
+
return document ? document.changeOrder.map((id) => document.changes[id]) : [];
|
|
243
|
+
},
|
|
244
|
+
documentCursors(_ctx, state, input = {}) {
|
|
245
|
+
const documentId = crdtId(object(input, "documentCursors input").documentId, "documentId");
|
|
246
|
+
return Object.values(state.cursors[documentId] || {}).sort((a, b) => a.actorName.localeCompare(b.actorName));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
CRDT_CURSOR_CLEAR_TYPE,
|
|
253
|
+
CRDT_CURSOR_UPDATE_TYPE,
|
|
254
|
+
CRDT_DOCUMENT_CHANGE_TYPE,
|
|
255
|
+
CRDT_PLUGIN_ID,
|
|
256
|
+
MATTERHORN_CRDT_NOSTR_KIND,
|
|
257
|
+
crdtHostPlugin
|
|
258
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const {
|
|
2
|
+
allow,
|
|
3
|
+
activity,
|
|
4
|
+
actorName,
|
|
5
|
+
array,
|
|
6
|
+
createOperationSchemaDescriptor,
|
|
7
|
+
defineHostPlugin,
|
|
8
|
+
deny,
|
|
9
|
+
entityId,
|
|
10
|
+
normalizeUrl,
|
|
11
|
+
object,
|
|
12
|
+
optionalString,
|
|
13
|
+
readonlyState,
|
|
14
|
+
scopeKey,
|
|
15
|
+
scopePayload,
|
|
16
|
+
string,
|
|
17
|
+
memberOrBetter,
|
|
18
|
+
moderatorOrBetter,
|
|
19
|
+
EMBEDS_PLUGIN_ID,
|
|
20
|
+
BASE_PLUGIN_VERSION
|
|
21
|
+
} = require("../shared/index.cjs");
|
|
22
|
+
const { EMBED_PROVIDER_CAPABILITIES } = require("../markdown/providers/index.cjs");
|
|
23
|
+
const { resolveEmbed } = require("../markdown/resolve.cjs");
|
|
24
|
+
|
|
25
|
+
function parseEmbedsState(state) {
|
|
26
|
+
const value = object(state, "embeds state");
|
|
27
|
+
object(value.embeds, "embeds");
|
|
28
|
+
array(value.activity, "activity");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function embedAddPayload(payload) {
|
|
32
|
+
const value = object(payload, "embed.add payload");
|
|
33
|
+
return { ...scopePayload(value, "embed scope"), url: normalizeUrl(value.url), title: optionalString(value.title, "title", 180), provider: optionalString(value.provider, "provider", 80), note: optionalString(value.note, "note", 500) };
|
|
34
|
+
}
|
|
35
|
+
function embedRemovePayload(payload) { return { embedId: string(object(payload, "embed.remove payload").embedId, "embedId") }; }
|
|
36
|
+
const embedsStateSchemaDescriptor = { plugin: EMBEDS_PLUGIN_ID, shape: ["embeds", "activity"] };
|
|
37
|
+
const embedsOperationSchemaDescriptor = { "embed.add": { required: ["scopeType", "scopeId", "url"], optional: ["title", "provider", "note"], authorize: { roles: ["member"] } }, "embed.remove": { required: ["embedId"], authorize: { roles: ["member"] } } };
|
|
38
|
+
const embedsHostPlugin = defineHostPlugin({
|
|
39
|
+
id: EMBEDS_PLUGIN_ID,
|
|
40
|
+
version: BASE_PLUGIN_VERSION,
|
|
41
|
+
meta: { name: "Reusable Rich Embeds" },
|
|
42
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["content.embeds", ...EMBED_PROVIDER_CAPABILITIES] },
|
|
43
|
+
stateSchemaDescriptor: embedsStateSchemaDescriptor,
|
|
44
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(EMBEDS_PLUGIN_ID, BASE_PLUGIN_VERSION, embedsOperationSchemaDescriptor),
|
|
45
|
+
schemas: {
|
|
46
|
+
state: { parse: parseEmbedsState },
|
|
47
|
+
operations: { "embed.add": { parse: embedAddPayload }, "embed.remove": { parse: embedRemovePayload } },
|
|
48
|
+
publicView: { parse: readonlyState },
|
|
49
|
+
queries: { embedsForScope: { parse: readonlyState }, embedsByProvider: { parse: readonlyState } }
|
|
50
|
+
},
|
|
51
|
+
async createInitialState() { return { embeds: {}, activity: [] }; },
|
|
52
|
+
authorize(_ctx, op) {
|
|
53
|
+
if (op.type === "embed.add") return memberOrBetter(op.actor) ? allow() : deny("Members only");
|
|
54
|
+
if (op.type === "embed.remove") return memberOrBetter(op.actor) ? allow() : deny("Members only");
|
|
55
|
+
return deny("Unsupported embed operation");
|
|
56
|
+
},
|
|
57
|
+
async reduce(_ctx, state, op) {
|
|
58
|
+
if (op.type === "embed.add") {
|
|
59
|
+
const resolved = resolveEmbed(op.payload.url, { provider: op.payload.provider, title: op.payload.title });
|
|
60
|
+
const embed = { id: entityId("embed", op), ...resolved, scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId), note: op.payload.note, addedBy: op.actor.memberId, addedByName: actorName(op.actor), addedAt: op.createdAt };
|
|
61
|
+
return { ...state, embeds: { ...state.embeds, [embed.id]: embed }, activity: activity(state, op, `${actorName(op.actor)} embedded ${embed.title}`, { embedId: embed.id, provider: embed.provider }) };
|
|
62
|
+
}
|
|
63
|
+
if (op.type === "embed.remove") {
|
|
64
|
+
const embed = state.embeds[op.payload.embedId];
|
|
65
|
+
if (!embed || embed.removedAt) throw new Error(`Embed ${op.payload.embedId} not found`);
|
|
66
|
+
if (!moderatorOrBetter(op.actor) && embed.addedBy !== op.actor.memberId) throw new Error("Only embed authors or moderators can remove embeds");
|
|
67
|
+
return { ...state, embeds: { ...state.embeds, [embed.id]: { ...embed, removedAt: op.createdAt, removedBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} removed ${embed.title}`) };
|
|
68
|
+
}
|
|
69
|
+
return state;
|
|
70
|
+
},
|
|
71
|
+
getPublicView(_ctx, state) { return { ...state, embeds: Object.fromEntries(Object.entries(state.embeds).filter(([, embed]) => !embed.removedAt)) }; },
|
|
72
|
+
queries: {
|
|
73
|
+
embedsForScope(_ctx, state, input = {}) {
|
|
74
|
+
const scoped = scopePayload(input, "embedsForScope input");
|
|
75
|
+
return Object.values(state.embeds).filter((embed) => !embed.removedAt && embed.scopeType === scoped.scopeType && embed.scopeId === scoped.scopeId).sort((a, b) => a.addedAt - b.addedAt);
|
|
76
|
+
},
|
|
77
|
+
embedsByProvider(_ctx, state, input = {}) {
|
|
78
|
+
const provider = string(input.provider, "provider", 80);
|
|
79
|
+
return Object.values(state.embeds).filter((embed) => !embed.removedAt && embed.provider === provider).sort((a, b) => b.addedAt - a.addedAt);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
methods: { resolve(_ctx, input = {}) { return resolveEmbed(object(input, "resolve input").url, input); } }
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
EMBEDS_PLUGIN_ID,
|
|
87
|
+
embedsHostPlugin,
|
|
88
|
+
embedsOperationSchemaDescriptor,
|
|
89
|
+
embedsStateSchemaDescriptor
|
|
90
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const {
|
|
2
|
+
allow,
|
|
3
|
+
activity,
|
|
4
|
+
actorName,
|
|
5
|
+
array,
|
|
6
|
+
createOperationSchemaDescriptor,
|
|
7
|
+
defineHostPlugin,
|
|
8
|
+
deny,
|
|
9
|
+
entityId,
|
|
10
|
+
object,
|
|
11
|
+
readonlyState,
|
|
12
|
+
scopeKey,
|
|
13
|
+
scopePayload,
|
|
14
|
+
string,
|
|
15
|
+
memberOrBetter,
|
|
16
|
+
moderatorOrBetter,
|
|
17
|
+
BASE_PLUGIN_VERSION,
|
|
18
|
+
FILES_PLUGIN_ID,
|
|
19
|
+
MAX_FILE_EVENT_BYTES,
|
|
20
|
+
MAX_FILES
|
|
21
|
+
} = require("../shared/index.cjs");
|
|
22
|
+
const { nostrEventToPartyEvent, validatePartyEvent } = require("@mh-gg/event");
|
|
23
|
+
|
|
24
|
+
function parseFilesState(state) {
|
|
25
|
+
const value = object(state, "files state");
|
|
26
|
+
object(value.files, "files");
|
|
27
|
+
array(value.activity, "activity");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function eventPayload(value) {
|
|
32
|
+
const event = object(value, "file event");
|
|
33
|
+
string(event.id, "event.id", 160);
|
|
34
|
+
string(event.pubkey, "event.pubkey", 160);
|
|
35
|
+
string(event.sig, "event.sig", 240);
|
|
36
|
+
string(event.content, "event.content", MAX_FILE_EVENT_BYTES);
|
|
37
|
+
array(event.tags, "event.tags");
|
|
38
|
+
return event;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fileUploadPayload(payload) {
|
|
42
|
+
const value = object(payload, "file.upload payload");
|
|
43
|
+
return {
|
|
44
|
+
...scopePayload(value, "file scope"),
|
|
45
|
+
event: eventPayload(value.event)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fileRemovePayload(payload) {
|
|
50
|
+
return { fileId: string(object(payload, "file.remove payload").fileId, "fileId") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateUploadEvent(roomId, op) {
|
|
54
|
+
const partyEvent = nostrEventToPartyEvent(op.payload.event);
|
|
55
|
+
const result = validatePartyEvent(partyEvent, { now: op.createdAt, maxEventBytes: MAX_FILE_EVENT_BYTES });
|
|
56
|
+
if (!result.ok) throw new Error(result.message || "Invalid file upload event");
|
|
57
|
+
if (partyEvent.kind !== "file.upload") throw new Error("File upload event kind is invalid");
|
|
58
|
+
if (partyEvent.partyId !== roomId) throw new Error("File upload event room does not match operation room");
|
|
59
|
+
return partyEvent;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nostrEventPayloadSchema = {
|
|
63
|
+
type: "object",
|
|
64
|
+
required: {
|
|
65
|
+
kind: { type: "number" },
|
|
66
|
+
created_at: { type: "number" },
|
|
67
|
+
tags: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
68
|
+
content: { type: "string" },
|
|
69
|
+
pubkey: { type: "string" },
|
|
70
|
+
id: { type: "string" },
|
|
71
|
+
sig: { type: "string" }
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const filesHostPlugin = defineHostPlugin({
|
|
76
|
+
id: FILES_PLUGIN_ID,
|
|
77
|
+
version: BASE_PLUGIN_VERSION,
|
|
78
|
+
meta: { name: "Encrypted Nostr File Uploads" },
|
|
79
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["content.files", "file.uploads", "file.nostr-events"] },
|
|
80
|
+
stateSchemaDescriptor: { plugin: FILES_PLUGIN_ID, shape: ["files", "activity"] },
|
|
81
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(FILES_PLUGIN_ID, BASE_PLUGIN_VERSION, {
|
|
82
|
+
"file.upload": {
|
|
83
|
+
required: { scopeType: { type: "string" }, scopeId: { type: "string" }, event: nostrEventPayloadSchema },
|
|
84
|
+
authorize: { roles: ["member"] }
|
|
85
|
+
},
|
|
86
|
+
"file.remove": { required: { fileId: { type: "string" } }, authorize: { roles: ["member"] } }
|
|
87
|
+
}),
|
|
88
|
+
schemas: {
|
|
89
|
+
state: { parse: parseFilesState },
|
|
90
|
+
operations: { "file.upload": { parse: fileUploadPayload }, "file.remove": { parse: fileRemovePayload } },
|
|
91
|
+
publicView: { parse: readonlyState },
|
|
92
|
+
queries: { filesForScope: { parse: readonlyState } }
|
|
93
|
+
},
|
|
94
|
+
async createInitialState() { return { files: {}, activity: [] }; },
|
|
95
|
+
authorize(_ctx, op) { return ["file.upload", "file.remove"].includes(op.type) ? (memberOrBetter(op.actor) ? allow() : deny("Members only")) : deny("Unsupported file operation"); },
|
|
96
|
+
async reduce(ctx, state, op) {
|
|
97
|
+
if (op.type === "file.upload") {
|
|
98
|
+
if (Object.keys(state.files).filter((id) => !state.files[id].removedAt).length >= MAX_FILES) throw new Error("File limit reached");
|
|
99
|
+
const partyEvent = validateUploadEvent(ctx.room.id, op);
|
|
100
|
+
const file = {
|
|
101
|
+
id: entityId("file", op),
|
|
102
|
+
scopeType: op.payload.scopeType,
|
|
103
|
+
scopeId: op.payload.scopeId,
|
|
104
|
+
scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId),
|
|
105
|
+
eventId: partyEvent.id,
|
|
106
|
+
event: op.payload.event,
|
|
107
|
+
uploadedBy: op.actor.memberId,
|
|
108
|
+
uploadedByName: actorName(op.actor),
|
|
109
|
+
uploadedAt: op.createdAt
|
|
110
|
+
};
|
|
111
|
+
return { ...state, files: { ...state.files, [file.id]: file }, activity: activity(state, op, `${actorName(op.actor)} uploaded an encrypted file`, { fileId: file.id, eventId: file.eventId }) };
|
|
112
|
+
}
|
|
113
|
+
if (op.type === "file.remove") {
|
|
114
|
+
const file = state.files[op.payload.fileId];
|
|
115
|
+
if (!file || file.removedAt) throw new Error(`File ${op.payload.fileId} not found`);
|
|
116
|
+
if (!moderatorOrBetter(op.actor) && file.uploadedBy !== op.actor.memberId) throw new Error("Only file uploaders or moderators can remove files");
|
|
117
|
+
return { ...state, files: { ...state.files, [file.id]: { ...file, removedAt: op.createdAt, removedBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} removed an encrypted file`) };
|
|
118
|
+
}
|
|
119
|
+
return state;
|
|
120
|
+
},
|
|
121
|
+
getPublicView(_ctx, state) { return { ...state, files: Object.fromEntries(Object.entries(state.files).filter(([, file]) => !file.removedAt)) }; },
|
|
122
|
+
queries: {
|
|
123
|
+
filesForScope(_ctx, state, input = {}) {
|
|
124
|
+
const scoped = scopePayload(input, "filesForScope input");
|
|
125
|
+
return Object.values(state.files).filter((file) => !file.removedAt && file.scopeType === scoped.scopeType && file.scopeId === scoped.scopeId).sort((a, b) => a.uploadedAt - b.uploadedAt);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
FILES_PLUGIN_ID,
|
|
132
|
+
filesHostPlugin
|
|
133
|
+
};
|