@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
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Matterhorn reusable example plugins
|
|
2
|
+
|
|
3
|
+
`@mh-gg/base-plugins` contains reusable Matterhorn backend plugins for common collaboration, content, media, and workflow features.
|
|
4
|
+
|
|
5
|
+
The package intentionally lives under `examples/` rather than `packages/` because these are product-facing PoC plugins, not frozen Matterhorn core APIs yet.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Source layout
|
|
9
|
+
|
|
10
|
+
The package is intentionally organized by composable capability. There is no root `plugins.cjs`, `workflow.cjs`, or `markdown.cjs` catch-all implementation file. Each reusable backend owns a folder with an `index.cjs` barrel, while markdown and embed providers are further split by concern:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
packages/base-plugins/src/
|
|
14
|
+
comments/
|
|
15
|
+
presence/
|
|
16
|
+
media-rooms/
|
|
17
|
+
screen-share/
|
|
18
|
+
markdown/
|
|
19
|
+
parser/
|
|
20
|
+
documents/
|
|
21
|
+
providers/
|
|
22
|
+
video.cjs
|
|
23
|
+
audio.cjs
|
|
24
|
+
cloud.cjs
|
|
25
|
+
images.cjs
|
|
26
|
+
social.cjs
|
|
27
|
+
developer.cjs
|
|
28
|
+
maps.cjs
|
|
29
|
+
productivity.cjs
|
|
30
|
+
games.cjs
|
|
31
|
+
direct.cjs
|
|
32
|
+
embeds/
|
|
33
|
+
attachments/
|
|
34
|
+
files/
|
|
35
|
+
reactions/
|
|
36
|
+
labels/
|
|
37
|
+
approvals/
|
|
38
|
+
checklists/
|
|
39
|
+
calendar/
|
|
40
|
+
composer/
|
|
41
|
+
shared/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`src/index.cjs` is only a public barrel. New reusable capabilities should be added as a folder, registered in `composer/`, and covered by focused backend tests. The structure test fails if the old generic catch-all files return or if a source file grows beyond the composability budget.
|
|
45
|
+
|
|
46
|
+
The package also exposes subpath imports for focused consumption, for example `@mh-gg/base-plugins/comments`, `@mh-gg/base-plugins/markdown`, `@mh-gg/base-plugins/embeds`, and `@mh-gg/base-plugins/composer`. Apps can still use the root barrel when they want the full example toolkit.
|
|
47
|
+
|
|
48
|
+
## Brainstormed plugin families now implemented
|
|
49
|
+
|
|
50
|
+
| Selection key | Plugin | Provides | Reuse target |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| `comments` | `com.matterhorn.examples.plugins.comments` | Threaded comments, edit/delete, reactions, resolve/reopen | Kanban cards, wiki pages, polls, expenses, deals, event discussion |
|
|
53
|
+
| `presence` | `com.matterhorn.examples.plugins.presence` | Member status/activity/location | Chat, CRM, live planning, support dashboards |
|
|
54
|
+
| `mediaRooms` | `com.matterhorn.examples.plugins.media-rooms` | Voice/video/stage room state and membership | Discord-like rooms, event ops calls, project standups |
|
|
55
|
+
| `screenShare` | `com.matterhorn.examples.plugins.screen-share` | Screen-share session state | Chat demos, event run-of-show, design reviews |
|
|
56
|
+
| `markdown` | `com.matterhorn.examples.plugins.markdown` | Markdown document storage, sanitized AST, parser extension API, inline/directive embeds | Wiki pages, event briefs, kanban specs, CRM notes |
|
|
57
|
+
| `embeds` | `com.matterhorn.examples.plugins.embeds` | Shareable media/link cards for YouTube, Spotify, Dropbox, Google Drive, Google Photos, generic HTTPS links | Chat channels, wiki pages, event planning, docs |
|
|
58
|
+
| `attachments` | `com.matterhorn.examples.plugins.attachments` | External cloud-file attachment records backed by the embed resolver | Dropbox/Drive/Photos links attached to any app scope |
|
|
59
|
+
| `files` | `com.matterhorn.examples.plugins.files` | Encrypted Nostr file upload events scoped to app objects | Wiki pages, docs, rooms that need key-gated local files |
|
|
60
|
+
| `reactions` | `com.matterhorn.examples.plugins.reactions` | Scoped emoji reactions independent of comments/messages | Cards, pages, polls, events, announcements |
|
|
61
|
+
| `labels` | `com.matterhorn.examples.plugins.labels` | Reusable labels/tags and per-scope assignments | Kanban, CRM, wiki, budget categories |
|
|
62
|
+
| `approvals` | `com.matterhorn.examples.plugins.approvals` | Reusable review/approval requests and decisions | Expenses, event runbooks, wiki docs, CRM deal review |
|
|
63
|
+
| `checklists` | `com.matterhorn.examples.plugins.checklists` | Scoped checklists and item completion | Event tasks, kanban cards, launch plans, CRM onboarding |
|
|
64
|
+
| `calendar` | `com.matterhorn.examples.plugins.calendar` | Scoped scheduled events/timeline entries | Event schedule, project milestones, CRM follow-ups |
|
|
65
|
+
|
|
66
|
+
## Markdown parser and markdown plugins
|
|
67
|
+
|
|
68
|
+
The markdown parser is self-contained and returns a sanitized AST instead of HTML. Frontends render nodes/components themselves, which keeps embedded media policy explicit.
|
|
69
|
+
|
|
70
|
+
Supported core syntax:
|
|
71
|
+
|
|
72
|
+
- headings
|
|
73
|
+
- paragraphs
|
|
74
|
+
- unordered lists
|
|
75
|
+
- blockquotes
|
|
76
|
+
- fenced code blocks
|
|
77
|
+
- markdown links
|
|
78
|
+
- bare links
|
|
79
|
+
- media directives
|
|
80
|
+
|
|
81
|
+
Media directive syntax:
|
|
82
|
+
|
|
83
|
+
```md
|
|
84
|
+
::youtube[Architecture walkthrough](https://youtu.be/dQw4w9WgXcQ)
|
|
85
|
+
::spotify[Launch playlist](https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)
|
|
86
|
+
::drive[Spec](https://drive.google.com/file/d/abc123/view)
|
|
87
|
+
::dropbox[Runbook](https://www.dropbox.com/s/example/runbook.pdf?dl=0)
|
|
88
|
+
::gphotos[Album](https://photos.app.goo.gl/example)
|
|
89
|
+
::embed[Related link](https://example.com/story)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Custom markdown plugins implement:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const { defineMarkdownPlugin, parseMarkdown } = require("@mh-gg/base-plugins");
|
|
96
|
+
|
|
97
|
+
const figmaPlugin = defineMarkdownPlugin({
|
|
98
|
+
id: "markdown.embed.figma",
|
|
99
|
+
provider: "figma",
|
|
100
|
+
aliases: ["figma"],
|
|
101
|
+
matchUrl(url) { return new URL(url).hostname === "www.figma.com"; },
|
|
102
|
+
toEmbed(url, ctx) {
|
|
103
|
+
return {
|
|
104
|
+
provider: "figma",
|
|
105
|
+
kind: "design",
|
|
106
|
+
title: ctx.title || "Figma file",
|
|
107
|
+
url,
|
|
108
|
+
externalUrl: url,
|
|
109
|
+
renderMode: "card",
|
|
110
|
+
metadata: {}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const ast = parseMarkdown("::figma[Mockup](https://www.figma.com/file/demo)", {
|
|
116
|
+
plugins: [figmaPlugin]
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
## RES-inspired embeddable auto-detection
|
|
122
|
+
|
|
123
|
+
Matterhorn now ships a broad, declarative provider registry inspired by the public Reddit Enhancement Suite media host registry. The implementation is Matterhorn-native and intentionally stores structured embed metadata rather than HTML. RES's list is used as a checklist for coverage: video, audio, image, code, document, map, social, gaming, meme, and direct-file providers.
|
|
124
|
+
|
|
125
|
+
Auto-detection works in three places:
|
|
126
|
+
|
|
127
|
+
1. `parseMarkdown(markdown)` resolves markdown links, bare links, and `::provider[title](url)` directives into `parsed.embeds`.
|
|
128
|
+
2. `autoDetectEmbeds(text)` scans arbitrary chat/comment text and returns deduped embed records, with `includeGeneric: false` available for rich-only detection.
|
|
129
|
+
3. `resolveEmbed(url)` detects one URL and returns a provider-specific record.
|
|
130
|
+
|
|
131
|
+
The default provider list currently covers YouTube, Spotify, Google Drive, Google Photos, Dropbox, Vimeo, SoundCloud, Bandcamp, Apple Music, Twitch, Dailymotion, Streamable, Streamja, Coub, Imgur, Giphy, Tenor, Redgifs, Gfycat, Gyazo, Flickr, 500px, DeviantArt, Photobucket, Pixiv, Tumblr, Twitter/X media, Instagram, Facebook video, TikTok, Twitter/X posts, Bluesky, Reddit media/gallery/post/poll links, Imgflip, CodePen, JSFiddle, GitHub, Gist, Pastebin, Hastebin, Wikipedia, Google Maps, OpenStreetMap, Ride with GPS, OneDrive, Figma, Notion, Loom, Canva, Miro, Excalidraw, Steam, XboxDVR/GameDVR, xkcd, Strawpoll, PeerTube, Clyp, GetYarn, direct images, direct videos, direct audio, direct PDFs, and generic HTTPS cards.
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const { autoDetectEmbeds, parseMarkdown, resolveEmbed } = require("@mh-gg/base-plugins");
|
|
135
|
+
|
|
136
|
+
const embeds = autoDetectEmbeds(
|
|
137
|
+
"Watch https://vimeo.com/123456789 and review https://www.figma.com/file/abc123/demo",
|
|
138
|
+
{ includeGeneric: false }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const ast = parseMarkdown("::twitch[Live room](https://www.twitch.tv/matterhorn)");
|
|
142
|
+
const embed = resolveEmbed("https://codepen.io/user/pen/abc123");
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Composition API
|
|
146
|
+
|
|
147
|
+
Use the suite API rather than hand-building plugin arrays. It gives every example deterministic ordering, duplicate removal, manifest-friendly plugin entries, and capability summaries.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const { HostPluginRuntime } = require("@mh-gg/host-runtime");
|
|
151
|
+
const { kanbanHostPlugin } = require("@mh-gg/example-kanban");
|
|
152
|
+
const { createCollaborationPluginSuite, composeHostPlugins } = require("@mh-gg/base-plugins");
|
|
153
|
+
|
|
154
|
+
const collaboration = createCollaborationPluginSuite(["comments", "markdown", "embeds", "checklists"]);
|
|
155
|
+
|
|
156
|
+
const runtime = new HostPluginRuntime({
|
|
157
|
+
room,
|
|
158
|
+
plugins: composeHostPlugins(kanbanHostPlugin, collaboration),
|
|
159
|
+
authenticateActor
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The app-specific plugin owns domain state. Reusable plugins own cross-cutting collaboration features. For example, a Kanban app does not need to implement card comments itself; it can dispatch `comments.add` with `{ scopeType: "kanban-card", scopeId: cardId, body }`.
|
|
164
|
+
|
|
165
|
+
### Presets
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
createCollaborationPluginSuite("comments"); // comments only
|
|
169
|
+
createCollaborationPluginSuite("collaboration"); // comments + presence
|
|
170
|
+
createCollaborationPluginSuite("media"); // media rooms + screen share
|
|
171
|
+
createCollaborationPluginSuite("content"); // markdown + embeds + files
|
|
172
|
+
createCollaborationPluginSuite("docs"); // markdown + embeds + files + comments + labels
|
|
173
|
+
createCollaborationPluginSuite("workflow"); // labels + approvals + checklists + calendar
|
|
174
|
+
createCollaborationPluginSuite("project"); // comments + presence + workflow helpers
|
|
175
|
+
createCollaborationPluginSuite("community"); // chat/community primitives: comments, presence, media, screen share, markdown, embeds, reactions
|
|
176
|
+
createCollaborationPluginSuite("all"); // every reusable plugin in canonical order
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Aliases such as `voice`, `video`, `screen`, `discussion`, `youtube`, `spotify`, `dropbox`, `drive`, `docs`, `workflow`, and each plugin ID are accepted, but the returned suite is always normalized into canonical order.
|
|
180
|
+
|
|
181
|
+
## Operation examples
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
client.dispatch({
|
|
185
|
+
pluginId: "com.matterhorn.examples.plugins.markdown",
|
|
186
|
+
type: "markdown.document.upsert",
|
|
187
|
+
payload: {
|
|
188
|
+
scopeType: "wiki-page",
|
|
189
|
+
scopeId: "page_123",
|
|
190
|
+
title: "Launch brief",
|
|
191
|
+
markdown: "# Launch\n\n::youtube[Demo](https://youtu.be/dQw4w9WgXcQ)"
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
client.dispatch({
|
|
196
|
+
pluginId: "com.matterhorn.examples.plugins.embeds",
|
|
197
|
+
type: "embed.add",
|
|
198
|
+
payload: {
|
|
199
|
+
scopeType: "channel",
|
|
200
|
+
scopeId: "general",
|
|
201
|
+
url: "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC",
|
|
202
|
+
title: "Launch song"
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
client.dispatch({
|
|
207
|
+
pluginId: "com.matterhorn.examples.plugins.files",
|
|
208
|
+
type: "file.upload",
|
|
209
|
+
payload: {
|
|
210
|
+
scopeType: "wiki-page",
|
|
211
|
+
scopeId: "page_123",
|
|
212
|
+
event: encryptedNostrFileUploadEvent
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
client.dispatch({
|
|
217
|
+
pluginId: "com.matterhorn.examples.plugins.approvals",
|
|
218
|
+
type: "approval.request",
|
|
219
|
+
payload: {
|
|
220
|
+
scopeType: "budget-expense",
|
|
221
|
+
scopeId: "expense_123",
|
|
222
|
+
title: "Approve venue deposit",
|
|
223
|
+
requiredRole: "admin"
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Tests
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
pnpm --dir packages/base-plugins run test
|
|
232
|
+
pnpm run test:examples
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The tests prove that suites normalize composition, reject conflicts, expose capabilities, run together in one Matterhorn host runtime, parse markdown with extension-driven media plugins, resolve YouTube/Spotify/Dropbox/Google Drive/Google Photos links, and let apps reuse backend reducers without copying code.
|
|
236
|
+
|
|
237
|
+
## Schema-first composition
|
|
238
|
+
|
|
239
|
+
Reusable plugins also publish JSON-safe micro-plugin schemas and JSON Schema contracts via:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
const { listMicroPluginSchemas, createAppCompositionSchema, loadAppCompositionSchemaFile } = require("@mh-gg/schema");
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Apps should compose shared capabilities with `app.json` + `composition.json` instead of importing reusable host plugin implementations directly. `composition.json` may use local `$imports` for `model.json`, `actions.json`, and other fragments. The app builder resolves that JSON through the trusted local registry only at build/install time. This keeps common implementation code inside the micro-plugin package and keeps app packages focused on domain behavior and frontend code.
|
|
246
|
+
|
|
247
|
+
See `docs/SCHEMA_COMPOSITION.md` and `docs/JSON_SCHEMA_COMPOSITION.md` for the schema format, import rules, and test lanes.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/base-plugins",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Reusable Matterhorn base plugins for comments, presence, media rooms, markdown, embeds, attachments, encrypted files, reactions, labels, approvals, checklists, and calendar state.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs",
|
|
9
|
+
"./comments": "./src/comments/index.cjs",
|
|
10
|
+
"./presence": "./src/presence/index.cjs",
|
|
11
|
+
"./media-rooms": "./src/media-rooms/index.cjs",
|
|
12
|
+
"./screen-share": "./src/screen-share/index.cjs",
|
|
13
|
+
"./markdown": "./src/markdown/index.cjs",
|
|
14
|
+
"./markdown/parser": "./src/markdown/parser/index.cjs",
|
|
15
|
+
"./markdown/resolve": "./src/markdown/resolve.cjs",
|
|
16
|
+
"./embeds": "./src/embeds/index.cjs",
|
|
17
|
+
"./attachments": "./src/attachments/index.cjs",
|
|
18
|
+
"./files": "./src/files/index.cjs",
|
|
19
|
+
"./reactions": "./src/reactions/index.cjs",
|
|
20
|
+
"./labels": "./src/labels/index.cjs",
|
|
21
|
+
"./approvals": "./src/approvals/index.cjs",
|
|
22
|
+
"./checklists": "./src/checklists/index.cjs",
|
|
23
|
+
"./calendar": "./src/calendar/index.cjs",
|
|
24
|
+
"./location-pins": "./src/location-pins/index.cjs",
|
|
25
|
+
"./crdt": "./src/crdt/index.cjs",
|
|
26
|
+
"./crdt/client": "./src/crdt/client.mjs",
|
|
27
|
+
"./composer": "./src/composer/index.cjs",
|
|
28
|
+
"./composer/registry": "./src/composer/registry.cjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"nostr-tools": "^2.23.5",
|
|
32
|
+
"yjs": "^13.6.27",
|
|
33
|
+
"@mh-gg/base": "^0.1.1-alpha.20260613T085325975Z",
|
|
34
|
+
"@mh-gg/event": "^0.1.1-alpha.20260613T085325975Z",
|
|
35
|
+
"@mh-gg/host-runtime": "^0.1.1-alpha.20260613T085325975Z",
|
|
36
|
+
"@mh-gg/protocol": "^0.1.1-alpha.20260613T085325975Z"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.12"
|
|
40
|
+
},
|
|
41
|
+
"matterhorn-sdk": {
|
|
42
|
+
"plugins": "./src/index.cjs"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test test/*.test.cjs",
|
|
46
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=70 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { APPROVALS_PLUGIN_ID, APPROVALS_PLUGIN_KEY } = require("../shared/constants.cjs");
|
|
2
|
+
const {
|
|
3
|
+
allow,
|
|
4
|
+
activity,
|
|
5
|
+
actorName,
|
|
6
|
+
array,
|
|
7
|
+
createOperationSchemaDescriptor,
|
|
8
|
+
defineHostPlugin,
|
|
9
|
+
deny,
|
|
10
|
+
entityId,
|
|
11
|
+
enumValue,
|
|
12
|
+
memberOrBetter,
|
|
13
|
+
moderatorOrBetter,
|
|
14
|
+
object,
|
|
15
|
+
optionalString,
|
|
16
|
+
readonlyState,
|
|
17
|
+
scopeKey,
|
|
18
|
+
scopePayload,
|
|
19
|
+
string,
|
|
20
|
+
BASE_PLUGIN_VERSION
|
|
21
|
+
} = require("../shared/index.cjs");
|
|
22
|
+
|
|
23
|
+
const { adminOrOwner } = require("../shared/index.cjs");
|
|
24
|
+
const adminOrBetter = adminOrOwner;
|
|
25
|
+
function parseAssignees(value) { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("assigneeIds must be an array"); return value.map((entry, index) => string(entry, `assigneeIds[${index}]`, 120)); }
|
|
26
|
+
|
|
27
|
+
function approvalRequestPayload(payload) { const value = object(payload, "approval.request payload"); return { ...scopePayload(value), title: string(value.title, "title", 160), description: optionalString(value.description, "description", 1000), requiredRole: enumValue(value.requiredRole || "moderator", "requiredRole", ["moderator", "admin", "owner"]), assigneeIds: parseAssignees(value.assigneeIds) }; }
|
|
28
|
+
function approvalDecisionPayload(payload) { const value = object(payload, "approval.decide payload"); return { requestId: string(value.requestId, "requestId"), decision: enumValue(value.decision, "decision", ["approved", "rejected", "changes-requested"]), note: optionalString(value.note, "note", 1000) }; }
|
|
29
|
+
const approvalsHostPlugin = defineHostPlugin({
|
|
30
|
+
id: APPROVALS_PLUGIN_ID,
|
|
31
|
+
version: BASE_PLUGIN_VERSION,
|
|
32
|
+
meta: { name: "Reusable Approval Requests" },
|
|
33
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["workflow.approvals", "workflow.review"] },
|
|
34
|
+
stateSchemaDescriptor: { plugin: APPROVALS_PLUGIN_ID, shape: ["requests", "activity"] },
|
|
35
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(APPROVALS_PLUGIN_ID, BASE_PLUGIN_VERSION, {
|
|
36
|
+
"approval.request": {
|
|
37
|
+
required: { scopeType: { type: "string" }, scopeId: { type: "string" }, title: { type: "string" } },
|
|
38
|
+
optional: {
|
|
39
|
+
description: { type: "string" },
|
|
40
|
+
requiredRole: { type: "enum", values: ["moderator", "admin", "owner"] },
|
|
41
|
+
assigneeIds: { type: "array", items: { type: "string" } }
|
|
42
|
+
},
|
|
43
|
+
authorize: { roles: ["member"] }
|
|
44
|
+
},
|
|
45
|
+
"approval.decide": {
|
|
46
|
+
required: { requestId: { type: "string" }, decision: { type: "enum", values: ["approved", "rejected", "changes-requested"] } },
|
|
47
|
+
optional: { note: { type: "string" } },
|
|
48
|
+
authorize: { roles: ["moderator"] }
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
schemas: { state: { parse(value) { const state = object(value, "approvals state"); object(state.requests, "requests"); array(state.activity, "activity"); return state; } }, operations: { "approval.request": { parse: approvalRequestPayload }, "approval.decide": { parse: approvalDecisionPayload } }, publicView: { parse: readonlyState }, queries: { openApprovals: { parse: readonlyState }, approvalsForScope: { parse: readonlyState } } },
|
|
52
|
+
async createInitialState() { return { requests: {}, activity: [] }; },
|
|
53
|
+
authorize(_ctx, op) { if (op.type === "approval.request") return memberOrBetter(op.actor) ? allow() : deny("Members only"); if (op.type === "approval.decide") return moderatorOrBetter(op.actor) ? allow() : deny("Moderators only"); return deny("Unsupported approval operation"); },
|
|
54
|
+
async reduce(_ctx, state, op) {
|
|
55
|
+
if (op.type === "approval.request") { const request = { id: entityId("approval", op), ...op.payload, requestedBy: op.actor.memberId, requestedByName: actorName(op.actor), requestedAt: op.createdAt, status: "open", decisions: [] }; return { ...state, requests: { ...state.requests, [request.id]: request }, activity: activity(state, op, `${actorName(op.actor)} requested approval: ${request.title}`) }; }
|
|
56
|
+
const request = state.requests[op.payload.requestId]; if (!request) throw new Error(`Approval request ${op.payload.requestId} not found`);
|
|
57
|
+
if (request.requiredRole === "admin" && !adminOrBetter(op.actor)) throw new Error("Admins only");
|
|
58
|
+
const decision = { decision: op.payload.decision, note: op.payload.note, decidedBy: op.actor.memberId, decidedByName: actorName(op.actor), decidedAt: op.createdAt };
|
|
59
|
+
return { ...state, requests: { ...state.requests, [request.id]: { ...request, status: op.payload.decision, decisions: [...request.decisions, decision], closedAt: op.createdAt } }, activity: activity(state, op, `${actorName(op.actor)} ${op.payload.decision} ${request.title}`) };
|
|
60
|
+
},
|
|
61
|
+
getPublicView(_ctx, state) { return state; },
|
|
62
|
+
queries: { openApprovals(_ctx, state) { return Object.values(state.requests).filter((request) => request.status === "open").sort((a, b) => a.requestedAt - b.requestedAt); }, approvalsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "approvalsForScope input"); return Object.values(state.requests).filter((request) => request.scopeType === scoped.scopeType && request.scopeId === scoped.scopeId).sort((a, b) => a.requestedAt - b.requestedAt); } }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
module.exports = { APPROVALS_PLUGIN_ID, APPROVALS_PLUGIN_KEY, approvalsHostPlugin };
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
ATTACHMENTS_PLUGIN_ID,
|
|
20
|
+
BASE_PLUGIN_VERSION,
|
|
21
|
+
MAX_ATTACHMENTS
|
|
22
|
+
} = require("../shared/index.cjs");
|
|
23
|
+
const { resolveEmbed } = require("../markdown/resolve.cjs");
|
|
24
|
+
|
|
25
|
+
function parseAttachmentsState(state) {
|
|
26
|
+
const value = object(state, "attachments state");
|
|
27
|
+
object(value.attachments, "attachments");
|
|
28
|
+
array(value.activity, "activity");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function attachmentAddPayload(payload) {
|
|
32
|
+
const value = object(payload, "attachment.add payload");
|
|
33
|
+
return { ...scopePayload(value, "attachment scope"), url: normalizeUrl(value.url), title: string(value.title, "title", 180), mimeType: optionalString(value.mimeType, "mimeType", 120), sizeBytes: value.sizeBytes === undefined ? null : Number(value.sizeBytes), provider: optionalString(value.provider, "provider", 80), note: optionalString(value.note, "note", 500) };
|
|
34
|
+
}
|
|
35
|
+
function attachmentRemovePayload(payload) { return { attachmentId: string(object(payload, "attachment.remove payload").attachmentId, "attachmentId") }; }
|
|
36
|
+
const attachmentsHostPlugin = defineHostPlugin({
|
|
37
|
+
id: ATTACHMENTS_PLUGIN_ID,
|
|
38
|
+
version: BASE_PLUGIN_VERSION,
|
|
39
|
+
meta: { name: "Reusable External Attachments" },
|
|
40
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["content.attachments", "attachment.external-links", "attachment.cloud-media"] },
|
|
41
|
+
stateSchemaDescriptor: { plugin: ATTACHMENTS_PLUGIN_ID, shape: ["attachments", "activity"] },
|
|
42
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(ATTACHMENTS_PLUGIN_ID, BASE_PLUGIN_VERSION, {
|
|
43
|
+
"attachment.add": {
|
|
44
|
+
required: { scopeType: { type: "string" }, scopeId: { type: "string" }, url: { type: "string" }, title: { type: "string" } },
|
|
45
|
+
optional: { mimeType: { type: "string" }, sizeBytes: { type: "number" }, provider: { type: "string" }, note: { type: "string" } },
|
|
46
|
+
authorize: { roles: ["member"] }
|
|
47
|
+
},
|
|
48
|
+
"attachment.remove": { required: { attachmentId: { type: "string" } }, authorize: { roles: ["member"] } }
|
|
49
|
+
}),
|
|
50
|
+
schemas: { state: { parse: parseAttachmentsState }, operations: { "attachment.add": { parse: attachmentAddPayload }, "attachment.remove": { parse: attachmentRemovePayload } }, publicView: { parse: readonlyState }, queries: { attachmentsForScope: { parse: readonlyState } } },
|
|
51
|
+
async createInitialState() { return { attachments: {}, activity: [] }; },
|
|
52
|
+
authorize(_ctx, op) { return ["attachment.add", "attachment.remove"].includes(op.type) ? (memberOrBetter(op.actor) ? allow() : deny("Members only")) : deny("Unsupported attachment operation"); },
|
|
53
|
+
async reduce(_ctx, state, op) {
|
|
54
|
+
if (op.type === "attachment.add") {
|
|
55
|
+
if (Object.keys(state.attachments).filter((id) => !state.attachments[id].removedAt).length >= MAX_ATTACHMENTS) throw new Error("Attachment limit reached");
|
|
56
|
+
const resolved = resolveEmbed(op.payload.url, { provider: op.payload.provider, title: op.payload.title });
|
|
57
|
+
const attachment = { id: entityId("att", op), scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId), title: op.payload.title, url: op.payload.url, provider: op.payload.provider || resolved.provider, kind: resolved.kind, renderMode: resolved.renderMode, embed: resolved, mimeType: op.payload.mimeType, sizeBytes: op.payload.sizeBytes, note: op.payload.note, addedBy: op.actor.memberId, addedByName: actorName(op.actor), addedAt: op.createdAt };
|
|
58
|
+
return { ...state, attachments: { ...state.attachments, [attachment.id]: attachment }, activity: activity(state, op, `${actorName(op.actor)} attached ${attachment.title}`) };
|
|
59
|
+
}
|
|
60
|
+
if (op.type === "attachment.remove") {
|
|
61
|
+
const attachment = state.attachments[op.payload.attachmentId];
|
|
62
|
+
if (!attachment || attachment.removedAt) throw new Error(`Attachment ${op.payload.attachmentId} not found`);
|
|
63
|
+
if (!moderatorOrBetter(op.actor) && attachment.addedBy !== op.actor.memberId) throw new Error("Only attachment authors or moderators can remove attachments");
|
|
64
|
+
return { ...state, attachments: { ...state.attachments, [attachment.id]: { ...attachment, removedAt: op.createdAt, removedBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} removed ${attachment.title}`) };
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
},
|
|
68
|
+
getPublicView(_ctx, state) { return { ...state, attachments: Object.fromEntries(Object.entries(state.attachments).filter(([, attachment]) => !attachment.removedAt)) }; },
|
|
69
|
+
queries: { attachmentsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "attachmentsForScope input"); return Object.values(state.attachments).filter((attachment) => !attachment.removedAt && attachment.scopeType === scoped.scopeType && attachment.scopeId === scoped.scopeId).sort((a, b) => a.addedAt - b.addedAt); } }
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
ATTACHMENTS_PLUGIN_ID,
|
|
74
|
+
attachmentsHostPlugin
|
|
75
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { CALENDAR_PLUGIN_ID, CALENDAR_PLUGIN_KEY } = require("../shared/constants.cjs");
|
|
2
|
+
const {
|
|
3
|
+
allow,
|
|
4
|
+
activity,
|
|
5
|
+
actorName,
|
|
6
|
+
array,
|
|
7
|
+
createOperationSchemaDescriptor,
|
|
8
|
+
defineHostPlugin,
|
|
9
|
+
deny,
|
|
10
|
+
entityId,
|
|
11
|
+
memberOrBetter,
|
|
12
|
+
moderatorOrBetter,
|
|
13
|
+
object,
|
|
14
|
+
optionalString,
|
|
15
|
+
readonlyState,
|
|
16
|
+
scopeKey,
|
|
17
|
+
scopePayload,
|
|
18
|
+
string,
|
|
19
|
+
BASE_PLUGIN_VERSION
|
|
20
|
+
} = require("../shared/index.cjs");
|
|
21
|
+
|
|
22
|
+
const { number } = require("../shared/index.cjs");
|
|
23
|
+
|
|
24
|
+
function calendarEventPayload(payload) { const value = object(payload, "calendar.event payload"); return { ...scopePayload(value), title: string(value.title, "title", 180), startsAt: number(value.startsAt, "startsAt", { min: 0 }), endsAt: value.endsAt === undefined ? null : number(value.endsAt, "endsAt", { min: 0 }), location: optionalString(value.location, "location", 240), description: optionalString(value.description, "description", 1000) }; }
|
|
25
|
+
function calendarIdPayload(payload) { return { eventId: string(object(payload, "calendar id payload").eventId, "eventId") }; }
|
|
26
|
+
const calendarHostPlugin = defineHostPlugin({
|
|
27
|
+
id: CALENDAR_PLUGIN_ID,
|
|
28
|
+
version: BASE_PLUGIN_VERSION,
|
|
29
|
+
meta: { name: "Reusable Calendar Timeline" },
|
|
30
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["calendar.events", "timeline.schedule"] },
|
|
31
|
+
stateSchemaDescriptor: { plugin: CALENDAR_PLUGIN_ID, shape: ["events", "activity"] },
|
|
32
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(CALENDAR_PLUGIN_ID, BASE_PLUGIN_VERSION, {
|
|
33
|
+
"calendar.event.create": {
|
|
34
|
+
required: { scopeType: { type: "string" }, scopeId: { type: "string" }, title: { type: "string" }, startsAt: { type: "number" } },
|
|
35
|
+
optional: { endsAt: { type: "number" }, location: { type: "string" }, description: { type: "string" } },
|
|
36
|
+
authorize: { roles: ["member"] }
|
|
37
|
+
},
|
|
38
|
+
"calendar.event.cancel": { required: { eventId: { type: "string" } }, authorize: { roles: ["moderator"] } }
|
|
39
|
+
}),
|
|
40
|
+
schemas: { state: { parse(value) { const state = object(value, "calendar state"); object(state.events, "events"); array(state.activity, "activity"); return state; } }, operations: { "calendar.event.create": { parse: calendarEventPayload }, "calendar.event.cancel": { parse: calendarIdPayload } }, publicView: { parse: readonlyState }, queries: { upcomingEvents: { parse: readonlyState }, eventsForScope: { parse: readonlyState } } },
|
|
41
|
+
async createInitialState() { return { events: {}, activity: [] }; },
|
|
42
|
+
authorize(_ctx, op) { if (op.type === "calendar.event.create") return memberOrBetter(op.actor) ? allow() : deny("Members only"); if (op.type === "calendar.event.cancel") return moderatorOrBetter(op.actor) ? allow() : deny("Moderators only"); return deny("Unsupported calendar operation"); },
|
|
43
|
+
async reduce(_ctx, state, op) { if (op.type === "calendar.event.create") { const event = { id: entityId("cal", op), ...op.payload, createdBy: op.actor.memberId, createdByName: actorName(op.actor), createdAt: op.createdAt }; return { ...state, events: { ...state.events, [event.id]: event }, activity: activity(state, op, `${actorName(op.actor)} scheduled ${event.title}`) }; } const event = state.events[op.payload.eventId]; if (!event || event.cancelledAt) throw new Error(`Calendar event ${op.payload.eventId} not found`); return { ...state, events: { ...state.events, [event.id]: { ...event, cancelledAt: op.createdAt, cancelledBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} cancelled ${event.title}`) }; },
|
|
44
|
+
getPublicView(_ctx, state) { return { ...state, events: Object.fromEntries(Object.entries(state.events).filter(([, event]) => !event.cancelledAt)) }; },
|
|
45
|
+
queries: { upcomingEvents(_ctx, state, input = {}) { const now = Number(input.now || 0); return Object.values(state.events).filter((event) => !event.cancelledAt && event.startsAt >= now).sort((a, b) => a.startsAt - b.startsAt); }, eventsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "eventsForScope input"); return Object.values(state.events).filter((event) => !event.cancelledAt && event.scopeType === scoped.scopeType && event.scopeId === scoped.scopeId).sort((a, b) => a.startsAt - b.startsAt); } }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
module.exports = { CALENDAR_PLUGIN_ID, CALENDAR_PLUGIN_KEY, calendarHostPlugin };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const { CHECKLISTS_PLUGIN_ID, CHECKLISTS_PLUGIN_KEY } = require("../shared/constants.cjs");
|
|
2
|
+
const {
|
|
3
|
+
allow,
|
|
4
|
+
activity,
|
|
5
|
+
actorName,
|
|
6
|
+
array,
|
|
7
|
+
createOperationSchemaDescriptor,
|
|
8
|
+
defineHostPlugin,
|
|
9
|
+
deny,
|
|
10
|
+
entityId,
|
|
11
|
+
enumValue,
|
|
12
|
+
memberOrBetter,
|
|
13
|
+
moderatorOrBetter,
|
|
14
|
+
object,
|
|
15
|
+
optionalString,
|
|
16
|
+
readonlyState,
|
|
17
|
+
scopeKey,
|
|
18
|
+
scopePayload,
|
|
19
|
+
string,
|
|
20
|
+
BASE_PLUGIN_VERSION
|
|
21
|
+
} = require("../shared/index.cjs");
|
|
22
|
+
|
|
23
|
+
const { boolean } = require("../shared/index.cjs");
|
|
24
|
+
const bool = boolean;
|
|
25
|
+
|
|
26
|
+
function checklistCreatePayload(payload) { const value = object(payload, "checklist.create payload"); return { ...scopePayload(value), title: string(value.title, "title", 160), items: (value.items || []).map((item, index) => string(item, `items[${index}]`, 240)) }; }
|
|
27
|
+
function checklistItemPayload(payload) { const value = object(payload, "checklist.item payload"); return { checklistId: string(value.checklistId, "checklistId"), text: string(value.text, "text", 240) }; }
|
|
28
|
+
function checklistTogglePayload(payload) { const value = object(payload, "checklist.toggle payload"); return { checklistId: string(value.checklistId, "checklistId"), itemId: string(value.itemId, "itemId"), completed: value.completed === undefined ? true : bool(value.completed) }; }
|
|
29
|
+
const checklistsHostPlugin = defineHostPlugin({
|
|
30
|
+
id: CHECKLISTS_PLUGIN_ID,
|
|
31
|
+
version: BASE_PLUGIN_VERSION,
|
|
32
|
+
meta: { name: "Reusable Checklists" },
|
|
33
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["workflow.checklists", "task.check-items"] },
|
|
34
|
+
stateSchemaDescriptor: { plugin: CHECKLISTS_PLUGIN_ID, shape: ["checklists", "activity"] },
|
|
35
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(CHECKLISTS_PLUGIN_ID, BASE_PLUGIN_VERSION, {
|
|
36
|
+
"checklist.create": {
|
|
37
|
+
required: { scopeType: { type: "string" }, scopeId: { type: "string" }, title: { type: "string" } },
|
|
38
|
+
optional: { items: { type: "array", items: { type: "string" } } },
|
|
39
|
+
authorize: { roles: ["member"] }
|
|
40
|
+
},
|
|
41
|
+
"checklist.item.add": { required: { checklistId: { type: "string" }, text: { type: "string" } }, authorize: { roles: ["member"] } },
|
|
42
|
+
"checklist.item.toggle": { required: { checklistId: { type: "string" }, itemId: { type: "string" } }, optional: { completed: { type: "boolean" } }, authorize: { roles: ["member"] } }
|
|
43
|
+
}),
|
|
44
|
+
schemas: { state: { parse(value) { const state = object(value, "checklists state"); object(state.checklists, "checklists"); array(state.activity, "activity"); return state; } }, operations: { "checklist.create": { parse: checklistCreatePayload }, "checklist.item.add": { parse: checklistItemPayload }, "checklist.item.toggle": { parse: checklistTogglePayload } }, publicView: { parse: readonlyState }, queries: { checklistsForScope: { parse: readonlyState } } },
|
|
45
|
+
async createInitialState() { return { checklists: {}, activity: [] }; },
|
|
46
|
+
authorize(_ctx, op) { return ["checklist.create", "checklist.item.add", "checklist.item.toggle"].includes(op.type) ? (memberOrBetter(op.actor) ? allow() : deny("Members only")) : deny("Unsupported checklist operation"); },
|
|
47
|
+
async reduce(_ctx, state, op) {
|
|
48
|
+
if (op.type === "checklist.create") { const checklistId = entityId("checklist", op); const checklist = { id: checklistId, scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId), title: op.payload.title, items: op.payload.items.map((text, index) => ({ id: `${checklistId}_item_${index + 1}`, text, completed: false })), createdBy: op.actor.memberId, createdAt: op.createdAt }; return { ...state, checklists: { ...state.checklists, [checklist.id]: checklist }, activity: activity(state, op, `${actorName(op.actor)} created checklist ${checklist.title}`) }; }
|
|
49
|
+
const checklist = state.checklists[op.payload.checklistId]; if (!checklist) throw new Error(`Checklist ${op.payload.checklistId} not found`);
|
|
50
|
+
if (op.type === "checklist.item.add") { const item = { id: `${op.payload.checklistId}_item_${checklist.items.length + 1}_${op.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`, text: op.payload.text, completed: false }; return { ...state, checklists: { ...state.checklists, [checklist.id]: { ...checklist, items: [...checklist.items, item], updatedAt: op.createdAt } } }; }
|
|
51
|
+
const item = checklist.items.find((candidate) => candidate.id === op.payload.itemId); if (!item) throw new Error(`Checklist item ${op.payload.itemId} not found`);
|
|
52
|
+
return { ...state, checklists: { ...state.checklists, [checklist.id]: { ...checklist, items: checklist.items.map((candidate) => candidate.id === item.id ? { ...candidate, completed: op.payload.completed, completedBy: op.payload.completed ? op.actor.memberId : undefined, completedAt: op.payload.completed ? op.createdAt : undefined } : candidate), updatedAt: op.createdAt } } };
|
|
53
|
+
},
|
|
54
|
+
getPublicView(_ctx, state) { return state; },
|
|
55
|
+
queries: { checklistsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "checklistsForScope input"); return Object.values(state.checklists).filter((checklist) => checklist.scopeType === scoped.scopeType && checklist.scopeId === scoped.scopeId); } }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
module.exports = { CHECKLISTS_PLUGIN_ID, CHECKLISTS_PLUGIN_KEY, checklistsHostPlugin };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { COMMENTS_PLUGIN_ID } = require("../shared/index.cjs");
|
|
2
|
+
const schemas = require("./schemas.cjs");
|
|
3
|
+
const state = require("./state.cjs");
|
|
4
|
+
const { commentsHostPlugin } = require("./plugin.cjs");
|
|
5
|
+
const threads = require("./threads.cjs");
|
|
6
|
+
const views = require("./views.cjs");
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
COMMENTS_PLUGIN_ID,
|
|
10
|
+
...schemas,
|
|
11
|
+
...state,
|
|
12
|
+
...threads,
|
|
13
|
+
...views,
|
|
14
|
+
commentsHostPlugin
|
|
15
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const { allow, createOperationSchemaDescriptor,
|
|
2
|
+
defineHostPlugin, deny, memberOrBetter, moderatorOrBetter, readonlyState, BASE_PLUGIN_VERSION, COMMENTS_PLUGIN_ID } = require("../shared/index.cjs");
|
|
3
|
+
const {
|
|
4
|
+
commentsAddPayload,
|
|
5
|
+
commentsDeletePayload,
|
|
6
|
+
commentsEditPayload,
|
|
7
|
+
commentsOperationSchemaDescriptor,
|
|
8
|
+
commentsReactPayload,
|
|
9
|
+
commentsResolvePayload,
|
|
10
|
+
commentsThreadPayload
|
|
11
|
+
} = require("./schemas.cjs");
|
|
12
|
+
const { commentsStateSchemaDescriptor, createInitialCommentsState, parseCommentsState } = require("./state.cjs");
|
|
13
|
+
const { reduceCommentsState } = require("./reducer.cjs");
|
|
14
|
+
const { commentsForScope, countForScope, publicCommentsView, unresolvedThreads } = require("./views.cjs");
|
|
15
|
+
|
|
16
|
+
function commentScope(state, payload = {}) {
|
|
17
|
+
if (payload.scopeType && payload.scopeId) return { scopeType: payload.scopeType, scopeId: payload.scopeId };
|
|
18
|
+
const thread = state.threads?.[payload.threadId] || state.threads?.[state.comments?.[payload.commentId]?.threadId];
|
|
19
|
+
return thread ? { scopeType: thread.scopeType, scopeId: thread.scopeId } : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireScopeEdit(ctx, op) {
|
|
23
|
+
const scope = commentScope(ctx.pluginState || {}, op.payload || {});
|
|
24
|
+
if (!scope) return deny("Comment scope is required");
|
|
25
|
+
return ctx.access.canEdit(scope.scopeType, scope.scopeId) ? allow() : deny("Scoped edit access required");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authorizeCommentsOperation(ctx, op) {
|
|
29
|
+
if (["comments.thread.create", "comments.add", "comments.react"].includes(op.type)) return memberOrBetter(op.actor) ? requireScopeEdit(ctx, op) : deny("Members only");
|
|
30
|
+
if (["comments.edit", "comments.delete"].includes(op.type)) return memberOrBetter(op.actor) ? requireScopeEdit(ctx, op) : deny("Members only");
|
|
31
|
+
if (op.type === "comments.resolve") {
|
|
32
|
+
if (!moderatorOrBetter(op.actor)) return deny("Moderators only");
|
|
33
|
+
return requireScopeEdit(ctx, op);
|
|
34
|
+
}
|
|
35
|
+
return deny("Unsupported comments operation");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commentsHostPlugin = defineHostPlugin({
|
|
39
|
+
id: COMMENTS_PLUGIN_ID,
|
|
40
|
+
version: BASE_PLUGIN_VERSION,
|
|
41
|
+
meta: { name: "Reusable Comments" },
|
|
42
|
+
capabilities: { requires: ["room.state", "room.roles"], provides: ["comments.threaded", "comments.reactions", "comments.attachable"] },
|
|
43
|
+
stateSchemaDescriptor: commentsStateSchemaDescriptor,
|
|
44
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor(COMMENTS_PLUGIN_ID, BASE_PLUGIN_VERSION, commentsOperationSchemaDescriptor),
|
|
45
|
+
schemas: {
|
|
46
|
+
state: { parse: parseCommentsState },
|
|
47
|
+
operations: {
|
|
48
|
+
"comments.thread.create": { parse: commentsThreadPayload },
|
|
49
|
+
"comments.add": { parse: commentsAddPayload },
|
|
50
|
+
"comments.edit": { parse: commentsEditPayload },
|
|
51
|
+
"comments.react": { parse: commentsReactPayload },
|
|
52
|
+
"comments.delete": { parse: commentsDeletePayload },
|
|
53
|
+
"comments.resolve": { parse: commentsResolvePayload }
|
|
54
|
+
},
|
|
55
|
+
publicView: { parse: readonlyState },
|
|
56
|
+
queries: { commentsForScope: { parse: readonlyState }, unresolvedThreads: { parse: readonlyState } }
|
|
57
|
+
},
|
|
58
|
+
async createInitialState() { return createInitialCommentsState(); },
|
|
59
|
+
authorize: authorizeCommentsOperation,
|
|
60
|
+
reduce: reduceCommentsState,
|
|
61
|
+
getPublicView: publicCommentsView,
|
|
62
|
+
queries: { commentsForScope, unresolvedThreads },
|
|
63
|
+
methods: { countForScope }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
module.exports = { authorizeCommentsOperation, commentsHostPlugin };
|