@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.
Files changed (61) hide show
  1. package/README.md +247 -0
  2. package/package.json +48 -0
  3. package/src/approvals/index.cjs +65 -0
  4. package/src/attachments/index.cjs +75 -0
  5. package/src/calendar/index.cjs +48 -0
  6. package/src/checklists/index.cjs +58 -0
  7. package/src/comments/index.cjs +15 -0
  8. package/src/comments/plugin.cjs +66 -0
  9. package/src/comments/reducer.cjs +81 -0
  10. package/src/comments/schemas.cjs +60 -0
  11. package/src/comments/state.cjs +17 -0
  12. package/src/comments/threads.cjs +44 -0
  13. package/src/comments/views.cjs +27 -0
  14. package/src/composer/capabilities.cjs +19 -0
  15. package/src/composer/compose.cjs +37 -0
  16. package/src/composer/index.cjs +15 -0
  17. package/src/composer/operations.cjs +42 -0
  18. package/src/composer/registry.cjs +155 -0
  19. package/src/composer/selection.cjs +39 -0
  20. package/src/composer/suite.cjs +32 -0
  21. package/src/crdt/client.mjs +207 -0
  22. package/src/crdt/index.cjs +258 -0
  23. package/src/embeds/index.cjs +90 -0
  24. package/src/files/index.cjs +133 -0
  25. package/src/index.cjs +19 -0
  26. package/src/labels/index.cjs +46 -0
  27. package/src/location-pins/index.cjs +142 -0
  28. package/src/markdown/documents/index.cjs +128 -0
  29. package/src/markdown/index.cjs +8 -0
  30. package/src/markdown/parser/index.cjs +127 -0
  31. package/src/markdown/providers/audio.cjs +77 -0
  32. package/src/markdown/providers/cloud.cjs +72 -0
  33. package/src/markdown/providers/developer.cjs +45 -0
  34. package/src/markdown/providers/direct.cjs +49 -0
  35. package/src/markdown/providers/games.cjs +26 -0
  36. package/src/markdown/providers/images.cjs +88 -0
  37. package/src/markdown/providers/index.cjs +97 -0
  38. package/src/markdown/providers/maps.cjs +24 -0
  39. package/src/markdown/providers/productivity.cjs +30 -0
  40. package/src/markdown/providers/res-inspired.cjs +11 -0
  41. package/src/markdown/providers/social.cjs +33 -0
  42. package/src/markdown/providers/video.cjs +139 -0
  43. package/src/markdown/resolve.cjs +87 -0
  44. package/src/media-rooms/index.cjs +244 -0
  45. package/src/presence/index.cjs +193 -0
  46. package/src/reactions/index.cjs +47 -0
  47. package/src/screen-share/index.cjs +84 -0
  48. package/src/shared/constants.cjs +87 -0
  49. package/src/shared/embed.cjs +82 -0
  50. package/src/shared/index.cjs +20 -0
  51. package/src/shared/roles.cjs +5 -0
  52. package/src/shared/scopes.cjs +15 -0
  53. package/src/shared/url.cjs +32 -0
  54. package/src/shared/validation.cjs +31 -0
  55. package/test/composable-plugins.test.cjs +170 -0
  56. package/test/crdt-plugin.test.cjs +168 -0
  57. package/test/embed-autodetect-providers.test.cjs +138 -0
  58. package/test/markdown-media-workflow-plugins.test.cjs +201 -0
  59. package/test/markdown-parser-edge-cases.test.cjs +86 -0
  60. package/test/plugin-structure.test.cjs +69 -0
  61. package/test/shared-plugin-edges.test.cjs +207 -0
@@ -0,0 +1,82 @@
1
+ const { optionalString } = require("./validation.cjs");
2
+ const { hostEndsWith, hostIs, normalizeUrl } = require("./url.cjs");
3
+
4
+ function optionalTitle(value, fallback = null) {
5
+ return optionalString(value, "title", 180) || fallback;
6
+ }
7
+
8
+ function defineMarkdownPlugin(plugin) {
9
+ if (!plugin || typeof plugin !== "object") throw new Error("Markdown plugin is required");
10
+ if (!plugin.id || !plugin.provider) throw new Error("Markdown plugin id and provider are required");
11
+ if (typeof plugin.matchUrl !== "function") throw new Error(`${plugin.id} matchUrl is required`);
12
+ if (typeof plugin.toEmbed !== "function") throw new Error(`${plugin.id} toEmbed is required`);
13
+ return Object.freeze({ ...plugin });
14
+ }
15
+
16
+ function createEmbedRecord({ provider, kind = "link", title, url, embedUrl, thumbnailUrl, externalUrl, renderMode = "card", metadata = {} }) {
17
+ const cleanUrl = normalizeUrl(url);
18
+ return Object.freeze({
19
+ provider,
20
+ kind,
21
+ title: optionalTitle(title, provider),
22
+ url: cleanUrl,
23
+ externalUrl: externalUrl ? normalizeUrl(externalUrl) : cleanUrl,
24
+ ...(embedUrl ? { embedUrl: normalizeUrl(embedUrl) } : {}),
25
+ ...(thumbnailUrl ? { thumbnailUrl: normalizeUrl(thumbnailUrl) } : {}),
26
+ renderMode,
27
+ metadata: Object.freeze({ ...metadata })
28
+ });
29
+ }
30
+
31
+ function mediaKindFromPath(pathname) {
32
+ const lower = pathname.toLowerCase();
33
+ if (/\.(?:png|jpe?g|webp|gif|avif|svg)(?:$|[?#])/.test(lower)) return "image";
34
+ if (/\.(?:mp4|webm|mov|m4v)(?:$|[?#])/.test(lower)) return "video";
35
+ if (/\.(?:mp3|wav|ogg|m4a|flac)(?:$|[?#])/.test(lower)) return "audio";
36
+ if (/\.(?:pdf)(?:$|[?#])/.test(lower)) return "document";
37
+ return "file";
38
+ }
39
+
40
+ function directRenderMode(kind) {
41
+ if (kind === "image") return "image";
42
+ if (kind === "video") return "video";
43
+ if (kind === "audio") return "audio";
44
+ if (kind === "document") return "file-preview";
45
+ return "card";
46
+ }
47
+
48
+ function simpleProvider({ id, provider, aliases = [], hosts = [], hostSuffixes = [], kind = "link", title, renderMode = "card", metadata = {}, matchPath, embedUrl, thumbnailUrl, externalUrl }) {
49
+ return defineMarkdownPlugin({
50
+ id,
51
+ provider,
52
+ aliases,
53
+ matchUrl(url) {
54
+ const hostMatches = hosts.length ? hostIs(url, hosts) : false;
55
+ const suffixMatches = hostSuffixes.length ? hostEndsWith(url, hostSuffixes) : false;
56
+ if (!hostMatches && !suffixMatches) return false;
57
+ if (typeof matchPath === "function") return Boolean(matchPath(new URL(url)));
58
+ return true;
59
+ },
60
+ toEmbed(url, ctx = {}) {
61
+ const parsed = new URL(url);
62
+ const resolvedKind = typeof kind === "function" ? kind(parsed) : kind;
63
+ const resolvedEmbedUrl = typeof embedUrl === "function" ? embedUrl(parsed) : embedUrl;
64
+ const resolvedThumbnailUrl = typeof thumbnailUrl === "function" ? thumbnailUrl(parsed) : thumbnailUrl;
65
+ const resolvedExternalUrl = typeof externalUrl === "function" ? externalUrl(parsed) : externalUrl;
66
+ const resolvedMetadata = typeof metadata === "function" ? metadata(parsed) : metadata;
67
+ return createEmbedRecord({
68
+ provider,
69
+ kind: resolvedKind,
70
+ title: ctx.title || (typeof title === "function" ? title(parsed) : title) || provider,
71
+ url,
72
+ ...(resolvedEmbedUrl ? { embedUrl: resolvedEmbedUrl } : {}),
73
+ ...(resolvedThumbnailUrl ? { thumbnailUrl: resolvedThumbnailUrl } : {}),
74
+ ...(resolvedExternalUrl ? { externalUrl: resolvedExternalUrl } : {}),
75
+ renderMode: typeof renderMode === "function" ? renderMode(parsed, resolvedKind) : renderMode,
76
+ metadata: { host: parsed.hostname.toLowerCase(), ...resolvedMetadata }
77
+ });
78
+ }
79
+ });
80
+ }
81
+
82
+ module.exports = { createEmbedRecord, defineMarkdownPlugin, directRenderMode, mediaKindFromPath, optionalTitle, simpleProvider };
@@ -0,0 +1,20 @@
1
+ const { allow, createOperationSchemaDescriptor, defineHostPlugin, deny } = require("@mh-gg/host-runtime");
2
+ const constants = require("./constants.cjs");
3
+ const embed = require("./embed.cjs");
4
+ const roles = require("./roles.cjs");
5
+ const scopes = require("./scopes.cjs");
6
+ const url = require("./url.cjs");
7
+ const validation = require("./validation.cjs");
8
+
9
+ module.exports = {
10
+ ...constants,
11
+ ...embed,
12
+ ...roles,
13
+ ...scopes,
14
+ ...url,
15
+ ...validation,
16
+ allow,
17
+ createOperationSchemaDescriptor,
18
+ defineHostPlugin,
19
+ deny
20
+ };
@@ -0,0 +1,5 @@
1
+ const { roleAtLeast } = require("./validation.cjs");
2
+ function memberOrBetter(actor) { return roleAtLeast(actor, ["owner", "admin", "moderator", "member"]); }
3
+ function moderatorOrBetter(actor) { return roleAtLeast(actor, ["owner", "admin", "moderator"]); }
4
+ function adminOrOwner(actor) { return roleAtLeast(actor, ["owner", "admin"]); }
5
+ module.exports = { adminOrOwner, memberOrBetter, moderatorOrBetter, roleAtLeast };
@@ -0,0 +1,15 @@
1
+ const { object, string } = require("./validation.cjs");
2
+
3
+ function scopePayload(payload, name = "scope") {
4
+ const value = object(payload, name);
5
+ return {
6
+ scopeType: string(value.scopeType, "scopeType", 80),
7
+ scopeId: string(value.scopeId, "scopeId", 180)
8
+ };
9
+ }
10
+
11
+ function scopeKey(scopeType, scopeId) {
12
+ return `${encodeURIComponent(scopeType)}:${encodeURIComponent(scopeId)}`;
13
+ }
14
+
15
+ module.exports = { scopeKey, scopePayload };
@@ -0,0 +1,32 @@
1
+ const { string } = require("./validation.cjs");
2
+ const { MAX_EMBED_URL_BYTES } = require("./constants.cjs");
3
+
4
+ function normalizeUrl(raw, { allowHttpLocalhost = true } = {}) {
5
+ const value = string(raw, "url", MAX_EMBED_URL_BYTES);
6
+ let parsed;
7
+ try { parsed = new URL(value); } catch { throw new Error("url must be a valid URL"); }
8
+ if (parsed.protocol !== "https:") {
9
+ const local = parsed.protocol === "http:" && allowHttpLocalhost && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
10
+ if (!local) throw new Error("url must use https unless it is localhost");
11
+ }
12
+ parsed.hash = "";
13
+ return parsed.toString();
14
+ }
15
+
16
+ function safeHostname(url) {
17
+ try { return new URL(url).hostname.toLowerCase().replace(/^www\./, ""); } catch { return ""; }
18
+ }
19
+ function hostOf(url) { return safeHostname(url); }
20
+ function hostIs(url, hosts) { return hosts.includes(hostOf(url)); }
21
+ function hostEndsWith(url, suffixes) { const host = hostOf(url); return suffixes.some((suffix) => host === suffix || host.endsWith(`.${suffix}`)); }
22
+ function pathParts(url) { return new URL(url).pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part)); }
23
+ function cleanId(value, max = 120) { return String(value || "").replace(/[^A-Za-z0-9._~-]/g, "").slice(0, max); }
24
+ function firstMatch(patterns, value) {
25
+ for (const pattern of patterns) {
26
+ const match = value.match(pattern);
27
+ if (match) return match;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ module.exports = { cleanId, firstMatch, hostEndsWith, hostIs, hostOf, normalizeUrl, pathParts, safeHostname };
@@ -0,0 +1,31 @@
1
+ const { isSnowflakeId, prefixedSnowflakeId, snowflakeIdForOperation } = require("@mh-gg/protocol");
2
+
3
+ function clone(value) { return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); }
4
+ function object(value, name) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`); return value; }
5
+ function array(value, name) { if (!Array.isArray(value)) throw new Error(`${name} must be an array`); return value; }
6
+ function string(value, name, max = 1000) { if (typeof value !== "string") throw new Error(`${name} must be a string`); const trimmed = value.trim(); if (!trimmed) throw new Error(`${name} must be a non-empty string`); if (trimmed.length > max) throw new Error(`${name} is too long`); return trimmed; }
7
+ function optionalString(value, name, max = 1000) { if (value === undefined || value === null || value === "") return null; return string(String(value), name, max); }
8
+ function number(value, name, options = {}) { const parsed = Number(value); if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number`); if (options.min !== undefined && parsed < options.min) throw new Error(`${name} must be >= ${options.min}`); if (options.max !== undefined && parsed > options.max) throw new Error(`${name} must be <= ${options.max}`); return parsed; }
9
+ function enumValue(value, name, allowed) { if (!allowed.includes(value)) throw new Error(`${name} must be one of ${allowed.join(", ")}`); return value; }
10
+ function boolean(value) { return Boolean(value); }
11
+ function roleAtLeast(actor, roles) { return actor && roles.includes(actor.role); }
12
+ function displayText(value, max) {
13
+ if (typeof value !== "string") return "";
14
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
15
+ return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
16
+ }
17
+ function actorName(actor) { return displayText(actor?.displayName, 80) || displayText(actor?.memberId, 120) || "Unknown"; }
18
+ function sanitizeId(value) { return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "_"); }
19
+ function entityId(prefix, op) {
20
+ if (op && typeof op === "object") {
21
+ const ledgerId = op.ledgerId || op.snowflakeId;
22
+ if (ledgerId && isSnowflakeId(ledgerId)) return prefixedSnowflakeId(prefix, ledgerId);
23
+ return prefixedSnowflakeId(prefix, snowflakeIdForOperation(op));
24
+ }
25
+ if (isSnowflakeId(op)) return prefixedSnowflakeId(prefix, op);
26
+ return `${prefix}_${sanitizeId(op)}`;
27
+ }
28
+ function activity(state, op, message, extra = {}) { return [...(state.activity || []), { id: entityId("activity", op), actorId: op.actor.memberId, actorName: actorName(op.actor), operationId: op.id, ledgerId: op.ledgerId, message, createdAt: op.createdAt, ...extra }]; }
29
+ function readonlyState(state) { return clone(state); }
30
+ function parseMaybeJsonList(value, name) { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error(`${name} must be an array`); return value.map((entry, index) => string(entry, `${name}[${index}]`, 80)); }
31
+ module.exports = { activity, actorName, array, boolean, clone, entityId, enumValue, number, object, optionalString, parseMaybeJsonList, readonlyState, roleAtLeast, string };
@@ -0,0 +1,170 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const { manifestHash } = require("@mh-gg/base");
4
+ const { createMemoryOperationLog, createMemoryRoomStore, HostPluginRuntime } = require("@mh-gg/host-runtime");
5
+ const { buildExampleMatterhornApp, createExampleActor, createExampleOperationFactory } = require("../../../examples/_shared/matterhornExample/index.cjs");
6
+ const {
7
+ COMMENTS_PLUGIN_ID,
8
+ MEDIA_ROOMS_PLUGIN_ID,
9
+ PRESENCE_PLUGIN_ID,
10
+ SCREEN_SHARE_PLUGIN_ID,
11
+ MARKDOWN_PLUGIN_ID,
12
+ EMBEDS_PLUGIN_ID,
13
+ ATTACHMENTS_PLUGIN_ID,
14
+ FILES_PLUGIN_ID,
15
+ REACTIONS_PLUGIN_ID,
16
+ LABELS_PLUGIN_ID,
17
+ LOCATION_PINS_PLUGIN_ID,
18
+ APPROVALS_PLUGIN_ID,
19
+ CHECKLISTS_PLUGIN_ID,
20
+ CALENDAR_PLUGIN_ID,
21
+ appCapabilitySet,
22
+ commentsHostPlugin,
23
+ composeHostPluginEntries,
24
+ createCollaborationPluginSuite,
25
+ getReusablePluginEntry,
26
+ mediaRoomsHostPlugin,
27
+ presenceHostPlugin,
28
+ screenShareHostPlugin
29
+ } = require("../src/index.cjs");
30
+
31
+ const fullSuite = createCollaborationPluginSuite("all");
32
+
33
+ const built = buildExampleMatterhornApp({
34
+ slug: "composable",
35
+ packageName: "@mh-gg/example-composable-test",
36
+ appId: "com.matterhorn.examples.composable-test",
37
+ name: "Composable Test",
38
+ version: "1.0.0",
39
+ hostPlugins: [fullSuite],
40
+ appCapabilities: { required: ["room.state", "room.roles", "comments.threaded", "media.voice", "media.video", "media.screen-share"], optional: [] },
41
+ hostCapabilities: { required: ["room.state", "room.roles"], optional: ["media.sfu"] }
42
+ });
43
+
44
+ function opFactory(roomId = "room_composable") {
45
+ return createExampleOperationFactory({ appPack: built.appPack, hostPlugin: commentsHostPlugin, roomId, actor: createExampleActor({ memberId: "admin", role: "admin", displayName: "Admin" }), startTime: 1000 });
46
+ }
47
+
48
+ async function runtime(roomId = "room_composable") {
49
+ const rt = new HostPluginRuntime({
50
+ room: { id: roomId, appPack: { id: built.appPack.id, version: built.appPack.version, hash: manifestHash(built.appPack), protocolHash: built.appPack.compatibility.appProtocolHash } },
51
+ plugins: fullSuite.plugins,
52
+ store: createMemoryRoomStore(),
53
+ operationLog: createMemoryOperationLog(),
54
+ authenticateActor: async (auth, actor) => {
55
+ assert.equal(auth.signature, "sig");
56
+ return actor;
57
+ }
58
+ });
59
+ await rt.start();
60
+ return rt;
61
+ }
62
+
63
+ async function rejected(promise, pattern) {
64
+ const result = await promise;
65
+ assert.equal(result.ok, false);
66
+ assert.match(result.reason, pattern);
67
+ }
68
+
69
+ test("reusable plugin package exposes composable host plugins", () => {
70
+ assert.deepEqual(fullSuite.pluginIds, [
71
+ COMMENTS_PLUGIN_ID,
72
+ PRESENCE_PLUGIN_ID,
73
+ MEDIA_ROOMS_PLUGIN_ID,
74
+ SCREEN_SHARE_PLUGIN_ID,
75
+ MARKDOWN_PLUGIN_ID,
76
+ EMBEDS_PLUGIN_ID,
77
+ ATTACHMENTS_PLUGIN_ID,
78
+ FILES_PLUGIN_ID,
79
+ REACTIONS_PLUGIN_ID,
80
+ LABELS_PLUGIN_ID,
81
+ APPROVALS_PLUGIN_ID,
82
+ CHECKLISTS_PLUGIN_ID,
83
+ CALENDAR_PLUGIN_ID,
84
+ LOCATION_PINS_PLUGIN_ID
85
+ ]);
86
+ assert.deepEqual(built.hostPack.plugins.map((plugin) => plugin.id), fullSuite.pluginIds);
87
+ assert.deepEqual(built.playerPack.supports[0].pluginIds, built.hostPluginDescriptors.map((plugin) => plugin.id));
88
+ });
89
+
90
+ test("comments plugin can attach threads to arbitrary app scopes", async () => {
91
+ const rt = await runtime();
92
+ const op = opFactory();
93
+ await rt.handleOperation(op("comments.thread.create", { scopeType: "card", scopeId: "card_1", title: "Card discussion" }, { id: "c_1", pluginId: COMMENTS_PLUGIN_ID }));
94
+ await rt.handleOperation(op("comments.add", { scopeType: "card", scopeId: "card_1", body: "Looks good" }, { id: "c_2", pluginId: COMMENTS_PLUGIN_ID, actor: createExampleActor({ memberId: "mina", role: "member", displayName: "Mina" }) }));
95
+ const state = await rt.getState();
96
+ const comment = Object.values(state.plugins[COMMENTS_PLUGIN_ID].comments)[0];
97
+ await rt.handleOperation(op("comments.react", { commentId: comment.id, emoji: "✅" }, { id: "c_3", pluginId: COMMENTS_PLUGIN_ID, actor: createExampleActor({ memberId: "lee", role: "member", displayName: "Lee" }) }));
98
+ const scoped = await rt.query(COMMENTS_PLUGIN_ID, "commentsForScope", { scopeType: "card", scopeId: "card_1" }, createExampleActor({ memberId: "mina", role: "member" }));
99
+ assert.equal(scoped.threads[0].title, "Card discussion");
100
+ assert.equal(scoped.comments[0].body, "Looks good");
101
+ assert.deepEqual(scoped.comments[0].reactions["✅"], ["lee"]);
102
+ });
103
+
104
+ test("comments permissions protect edits and deletes", async () => {
105
+ const rt = await runtime();
106
+ const op = opFactory();
107
+ await rt.handleOperation(op("comments.add", { scopeType: "wiki-page", scopeId: "page_1", body: "draft note" }, { id: "p_1", pluginId: COMMENTS_PLUGIN_ID, actor: createExampleActor({ memberId: "mina", role: "member" }) }));
108
+ const state = await rt.getState();
109
+ const commentId = Object.keys(state.plugins[COMMENTS_PLUGIN_ID].comments)[0];
110
+ await rejected(rt.handleOperation(op("comments.edit", { commentId, body: "hijack" }, { id: "p_2", pluginId: COMMENTS_PLUGIN_ID, actor: createExampleActor({ memberId: "guest", role: "guest" }) })), /Members only|requires member/);
111
+ await rejected(rt.handleOperation(op("comments.delete", { commentId }, { id: "p_3", pluginId: COMMENTS_PLUGIN_ID, actor: createExampleActor({ memberId: "lee", role: "member" }) })), /Only authors or moderators/);
112
+ });
113
+
114
+ test("presence plugin tracks reusable user state", async () => {
115
+ const rt = await runtime();
116
+ const op = opFactory();
117
+ await rt.handleOperation(op("presence.update", { status: "online", activity: "reviewing budget" }, { id: "presence_1", pluginId: PRESENCE_PLUGIN_ID, actor: createExampleActor({ memberId: "mina", role: "member", displayName: "Mina" }) }));
118
+ await rt.handleOperation(op("presence.update", { status: "busy", activity: "screen sharing" }, { id: "presence_2", pluginId: PRESENCE_PLUGIN_ID, actor: createExampleActor({ memberId: "lee", role: "member", displayName: "Lee" }) }));
119
+ const online = await rt.query(PRESENCE_PLUGIN_ID, "onlineMembers", {}, createExampleActor({ memberId: "admin", role: "admin" }));
120
+ assert.deepEqual(online.map((member) => `${member.name}:${member.status}`), ["Lee:busy", "Mina:online"]);
121
+ });
122
+
123
+ test("media rooms and screen-share plugins are independent but compose around the same scope", async () => {
124
+ const rt = await runtime();
125
+ const op = opFactory();
126
+ await rt.handleOperation(op("media.room.create", { name: "Design review", allowsVideo: true, scopeType: "kanban-card", scopeId: "card_42" }, { id: "media_1", pluginId: MEDIA_ROOMS_PLUGIN_ID }));
127
+ const roomId = Object.keys((await rt.getState()).plugins[MEDIA_ROOMS_PLUGIN_ID].rooms)[0];
128
+ await rt.handleOperation(op("media.room.join", { roomId, media: { audio: true, video: true } }, { id: "media_2", pluginId: MEDIA_ROOMS_PLUGIN_ID, actor: createExampleActor({ memberId: "mina", role: "member", displayName: "Mina" }) }));
129
+ await rt.handleOperation(op("screenshare.start", { roomId, scopeType: "kanban-card", scopeId: "card_42", title: "Card walkthrough" }, { id: "share_1", pluginId: SCREEN_SHARE_PLUGIN_ID, actor: createExampleActor({ memberId: "mina", role: "member", displayName: "Mina" }) }));
130
+ const rooms = await rt.query(MEDIA_ROOMS_PLUGIN_ID, "roomDirectory", {}, createExampleActor({ memberId: "admin", role: "admin" }));
131
+ const shares = await rt.query(SCREEN_SHARE_PLUGIN_ID, "activeShares", { scopeType: "kanban-card", scopeId: "card_42" }, createExampleActor({ memberId: "admin", role: "admin" }));
132
+ assert.equal(rooms[0].participantCount, 1);
133
+ assert.equal(shares[0].title, "Card walkthrough");
134
+ await rejected(rt.handleOperation(op("screenshare.stop", { shareId: shares[0].id }, { id: "share_2", pluginId: SCREEN_SHARE_PLUGIN_ID, actor: createExampleActor({ memberId: "sam", role: "member" }) })), /Only presenter or moderators/);
135
+ });
136
+
137
+
138
+ test("collaboration plugin suites normalize ordering, capabilities, and plugin entries", () => {
139
+ const suite = createCollaborationPluginSuite(["screenShare", "comments", "mediaRooms"]);
140
+ assert.deepEqual(suite.selection, ["comments", "mediaRooms", "screenShare"]);
141
+ assert.deepEqual(suite.pluginIds, [COMMENTS_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID, SCREEN_SHARE_PLUGIN_ID]);
142
+ assert.equal(suite.hasCapability("media.voice"), true);
143
+ assert.equal(suite.hasCapability("media.screen-share"), true);
144
+
145
+ const commentsOnly = createCollaborationPluginSuite("comments");
146
+ assert.deepEqual(commentsOnly.pluginIds, [COMMENTS_PLUGIN_ID]);
147
+ assert.deepEqual(commentsOnly.capabilities.provides, ["comments.attachable", "comments.reactions", "comments.threaded"]);
148
+
149
+ const deduped = composeHostPluginEntries(commentsOnly, getReusablePluginEntry("comments"));
150
+ assert.equal(deduped.length, 1);
151
+ assert.equal(deduped[0].plugin.id, COMMENTS_PLUGIN_ID);
152
+ });
153
+
154
+ test("collaboration app capability sets include selected suite capabilities", () => {
155
+ const suite = createCollaborationPluginSuite(["mediaRooms", "screenShare"]);
156
+ const capabilities = appCapabilitySet({ suites: [suite], optional: ["relay.event-cache"] });
157
+ assert.deepEqual(capabilities.required, ["relay.route", "room.roles", "room.state"]);
158
+ assert.equal(capabilities.optional.includes("media.voice"), true);
159
+ assert.equal(capabilities.optional.includes("media.video"), true);
160
+ assert.equal(capabilities.optional.includes("media.screen-share"), true);
161
+ assert.equal(capabilities.optional.includes("relay.event-cache"), true);
162
+ });
163
+
164
+ test("collaboration composer rejects unknown selections and conflicting versions", () => {
165
+ assert.throws(() => createCollaborationPluginSuite(["missing-plugin"]), /Unknown reusable plugin selection/);
166
+ assert.throws(() => composeHostPluginEntries(
167
+ getReusablePluginEntry("comments"),
168
+ { plugin: { ...commentsHostPlugin, version: "9.9.9" }, packageName: "test", exportName: "commentsHostPlugin" }
169
+ ), /Conflicting versions for plugin/);
170
+ });
@@ -0,0 +1,168 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const { buildExampleMatterhornApp, createExampleActor, createExampleOperationFactory, createExampleRuntime } = require("../../../examples/_shared/matterhornExample/index.cjs");
4
+ const {
5
+ CRDT_CURSOR_CLEAR_TYPE,
6
+ CRDT_CURSOR_UPDATE_TYPE,
7
+ CRDT_DOCUMENT_CHANGE_TYPE,
8
+ CRDT_PLUGIN_ID,
9
+ MATTERHORN_CRDT_NOSTR_KIND,
10
+ crdtHostPlugin,
11
+ createCollaborationPluginSuite,
12
+ getReusablePlugin
13
+ } = require("../src/index.cjs");
14
+ const { finalizeEvent } = require("nostr-tools/pure");
15
+
16
+ const roomId = "room_crdt_test";
17
+ const documentId = "doc_main";
18
+ const actorA = createExampleActor({ memberId: "alice", deviceId: "dev_alice", role: "member", displayName: "Alice" });
19
+ const actorB = createExampleActor({ memberId: "bob", deviceId: "dev_bob", role: "member", displayName: "Bob" });
20
+
21
+ const built = buildExampleMatterhornApp({
22
+ slug: "crdt-plugin",
23
+ packageName: "@mh-gg/base-plugins",
24
+ appId: "com.matterhorn.tests.crdt-plugin",
25
+ name: "CRDT Plugin Test",
26
+ version: "1.0.0",
27
+ hostPlugin: crdtHostPlugin
28
+ });
29
+
30
+ function fakeEncrypt(kind, value) {
31
+ return {
32
+ encrypted: true,
33
+ alg: "A256GCM",
34
+ kind,
35
+ iv: "test-iv",
36
+ data: Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
37
+ };
38
+ }
39
+
40
+ function fakeDecrypt(kind, payload) {
41
+ assert.equal(payload.kind, kind);
42
+ return JSON.parse(Buffer.from(payload.data, "base64url").toString("utf8"));
43
+ }
44
+
45
+ function opFactory(actor = actorA) {
46
+ return createExampleOperationFactory({ appPack: built.appPack, hostPlugin: crdtHostPlugin, roomId, actor, startTime: 1700000000000 });
47
+ }
48
+
49
+ function pluginState(state) {
50
+ return state.plugins[CRDT_PLUGIN_ID];
51
+ }
52
+
53
+ test("CRDT plugin is explicit and does not replace the default reusable suite", () => {
54
+ const fullSuite = createCollaborationPluginSuite("all");
55
+ assert.equal(fullSuite.pluginIds.includes(CRDT_PLUGIN_ID), false);
56
+ assert.equal(getReusablePlugin("crdt").id, CRDT_PLUGIN_ID);
57
+
58
+ const crdtSuite = createCollaborationPluginSuite(["crdt"]);
59
+ assert.deepEqual(crdtSuite.selection, ["crdt"]);
60
+ assert.deepEqual(crdtSuite.pluginIds, [CRDT_PLUGIN_ID]);
61
+ });
62
+
63
+ test("CRDT host plugin stores opaque encrypted events and clients resolve document changes", async () => {
64
+ const {
65
+ applyDocumentChangeEvent,
66
+ createCrdtEventKey,
67
+ createDocumentChangeEvent,
68
+ createSharedTextDocument
69
+ } = await import("../src/crdt/client.mjs");
70
+ const runtime = createExampleRuntime({ appPack: built.appPack, hostPlugin: crdtHostPlugin, roomId });
71
+ const makeA = opFactory(actorA);
72
+ const keyA = createCrdtEventKey();
73
+ const keyB = createCrdtEventKey();
74
+
75
+ const left = createSharedTextDocument();
76
+ const right = createSharedTextDocument();
77
+ left.replaceText("Hello from Alice");
78
+ const firstEvent = await createDocumentChangeEvent({
79
+ document: left,
80
+ roomName: roomId,
81
+ documentId,
82
+ privateKey: keyA,
83
+ createdAt: 1700000001000,
84
+ encryptRoomPayload: fakeEncrypt
85
+ });
86
+
87
+ const firstResult = await runtime.handleOperation(makeA(CRDT_DOCUMENT_CHANGE_TYPE, { documentId, event: firstEvent }, { id: "crdt_1" }));
88
+ assert.equal(firstResult.ok, true);
89
+ const afterFirst = pluginState(await runtime.getState());
90
+ assert.equal(JSON.stringify(afterFirst).includes("Hello from Alice"), false);
91
+
92
+ const changes = await runtime.query(CRDT_PLUGIN_ID, "documentChanges", { documentId }, actorB);
93
+ assert.equal(changes.length, 1);
94
+ await applyDocumentChangeEvent({ document: right, event: changes[0].event, decryptRoomPayload: fakeDecrypt });
95
+ assert.equal(right.getText(), "Hello from Alice");
96
+
97
+ right.replaceText("Hello from Bob");
98
+ const secondEvent = await createDocumentChangeEvent({
99
+ document: right,
100
+ roomName: roomId,
101
+ documentId,
102
+ privateKey: keyB,
103
+ createdAt: 1700000002000,
104
+ encryptRoomPayload: fakeEncrypt
105
+ });
106
+ const makeB = opFactory(actorB);
107
+ const secondResult = await runtime.handleOperation(makeB(CRDT_DOCUMENT_CHANGE_TYPE, { documentId, event: secondEvent }, { id: "crdt_2" }));
108
+ assert.equal(secondResult.ok, true);
109
+
110
+ await applyDocumentChangeEvent({ document: left, event: secondEvent, decryptRoomPayload: fakeDecrypt });
111
+ assert.equal(left.getText(), right.getText());
112
+ assert.equal(left.getText(), "Hello from Bob");
113
+ });
114
+
115
+ test("CRDT cursor updates stay encrypted until a client decrypts them", async () => {
116
+ const { createCrdtEventKey, createCursorEvent, readCursorEvent } = await import("../src/crdt/client.mjs");
117
+ const runtime = createExampleRuntime({ appPack: built.appPack, hostPlugin: crdtHostPlugin, roomId });
118
+ const event = await createCursorEvent({
119
+ roomName: roomId,
120
+ documentId,
121
+ privateKey: createCrdtEventKey(),
122
+ createdAt: 1700000003000,
123
+ encryptRoomPayload: fakeEncrypt,
124
+ cursor: { anchor: 2, head: 5, color: "oklch(58% 0.16 28)" }
125
+ });
126
+
127
+ const result = await runtime.handleOperation(opFactory(actorA)(CRDT_CURSOR_UPDATE_TYPE, { documentId, event }, { id: "cursor_1" }));
128
+ assert.equal(result.ok, true);
129
+ const state = pluginState(await runtime.getState());
130
+ assert.equal(JSON.stringify(state).includes("anchor"), false);
131
+
132
+ const cursors = await runtime.query(CRDT_PLUGIN_ID, "documentCursors", { documentId }, actorB);
133
+ assert.equal(cursors.length, 1);
134
+ const cursor = await readCursorEvent({ event: cursors[0].event, decryptRoomPayload: fakeDecrypt });
135
+ assert.deepEqual(cursor, { anchor: 2, head: 5, color: "oklch(58% 0.16 28)" });
136
+
137
+ const cleared = await runtime.handleOperation(opFactory(actorA)(CRDT_CURSOR_CLEAR_TYPE, { documentId }, { id: "cursor_clear_1" }));
138
+ assert.equal(cleared.ok, true);
139
+ assert.deepEqual(await runtime.query(CRDT_PLUGIN_ID, "documentCursors", { documentId }, actorB), []);
140
+ });
141
+
142
+ test("CRDT host rejects signed events with invalid encrypted content", async () => {
143
+ const { createCrdtEventKey } = await import("../src/crdt/client.mjs");
144
+ const runtime = createExampleRuntime({ appPack: built.appPack, hostPlugin: crdtHostPlugin, roomId });
145
+ const event = finalizeEvent({
146
+ kind: MATTERHORN_CRDT_NOSTR_KIND,
147
+ created_at: 1700000010,
148
+ tags: [
149
+ ["protocol", "matterhorn-sdk"],
150
+ ["version", "1"],
151
+ ["d", roomId],
152
+ ["crdt-doc", documentId],
153
+ ["crdt-kind", CRDT_DOCUMENT_CHANGE_TYPE],
154
+ ["created-at-ms", "1700000010000"]
155
+ ],
156
+ content: "not-json"
157
+ }, createCrdtEventKey());
158
+
159
+ const rejected = await runtime.handleOperation(opFactory(actorA)(CRDT_DOCUMENT_CHANGE_TYPE, { documentId, event }, { id: "crdt_bad_content" }));
160
+ assert.equal(rejected.ok, false);
161
+ assert.match(rejected.reason, /content must be JSON/);
162
+ });
163
+
164
+ test("CRDT reducer leaves unsupported direct operation types unchanged", async () => {
165
+ const state = await crdtHostPlugin.createInitialState({ room: { id: roomId } });
166
+ const next = await crdtHostPlugin.reduce({ room: { id: roomId } }, state, { type: "unknown", payload: {}, actor: actorA, createdAt: 1 });
167
+ assert.equal(next, state);
168
+ });