@mosaicast/plugin-sdk 0.15.0 → 0.16.0
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 +136 -11
- package/dist/index.d.ts +324 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +50 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +143 -5
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -85,7 +85,9 @@ This is the part plugin authors most often guess wrong, so it is stated plainly.
|
|
|
85
85
|
|
|
86
86
|
```text
|
|
87
87
|
GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
88
|
-
→ one JSON doc;
|
|
88
|
+
→ one JSON doc; 204 (no body) if the key is not set — 404 means a wrong address
|
|
89
|
+
GET /api/plugins/{id}/data/{scopeType}?ids=a,b&keys=x,y (since 0.16.0)
|
|
90
|
+
→ { a: { x: … }, b: {} } — misses absent; ≤ 100 ids and ≤ 100 keys per request
|
|
89
91
|
GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
|
|
90
92
|
→ { items: [{ key, value }], page, size, totalElements, totalPages }
|
|
91
93
|
PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
|
|
@@ -109,7 +111,7 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
|
109
111
|
### The typed client — `ctx.docs` (since 0.9.0)
|
|
110
112
|
|
|
111
113
|
`ctx.api` is the raw surface and stays available. `ctx.docs` is the same endpoints with the path building,
|
|
112
|
-
the key validation and the
|
|
114
|
+
the key validation and the absence handling done for you — which removes a whole class of bug, since every doc
|
|
113
115
|
access above was string concatenation the plugin had to get right four segments at a time:
|
|
114
116
|
|
|
115
117
|
```ts
|
|
@@ -122,8 +124,22 @@ await ctx.docs.remove(ctx.scope, 'draft'); // any Scope addre
|
|
|
122
124
|
- **`'self'` is `data/user/me`** and `'site'` is `data/site/main` — the two singletons. Making the
|
|
123
125
|
per-user partition the *shortest* thing to write is deliberate: it is the most security-relevant
|
|
124
126
|
convention in the contract, and a convention only sticks when it is also the easy path.
|
|
125
|
-
- **`get` resolves `null`
|
|
126
|
-
failure. The same is true of `ctx.api.getOrNull(path)
|
|
127
|
+
- **`get` resolves `null` when the key is not set** (the host's 204), because a key nothing has written
|
|
128
|
+
yet is a normal state rather than a failure. The same is true of `ctx.api.getOrNull(path)`; a raw
|
|
129
|
+
`ctx.api.get` resolves `undefined` there.
|
|
130
|
+
- **`getMany(type, ids, keys)` reads a page of cards in one request** (since 0.16.0), answering
|
|
131
|
+
`{ id: { key: value } }` with misses simply absent. Over 100 ids or keys is split and merged for you.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const docs = await ctx.docs.getMany<Highlight>('episode', ctx.episodes.slice(0, 20), ['highlight']);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **The client remembers for you (guaranteed since 0.16.0)**, per plugin and signed-in identity, for the
|
|
138
|
+
life of the page: identical `get`s in flight share one request; a miss is remembered (from `getMany`
|
|
139
|
+
too); your own `put`/`remove` forget the address they touched; hits are never cached; errors are never
|
|
140
|
+
remembered. So **delete any cache of misses you wrote yourself** — it is redundant — and keep a cache of
|
|
141
|
+
hits, if at all, no longer than a render: it hides writes made in other sessions. Before this, 98% of
|
|
142
|
+
one measured session's plugin requests were "not set", with one key asked 178 times.
|
|
127
143
|
- **A malformed key throws at the call site**, with `DOC_KEY_PATTERN` in the message, instead of costing a
|
|
128
144
|
400 round-trip whose body you then have to read.
|
|
129
145
|
- Everything else is unchanged and still the host's: both access floors, `backendOwned`, the 400 on an
|
|
@@ -163,16 +179,23 @@ boundary, so it does not share a constructor with anything in your plugin.
|
|
|
163
179
|
- The partition is **flat** — one per user, not one per user and entity — so the entity goes in the key: `mark:<episodeSlug>:cell`.
|
|
164
180
|
- `readableBy` does not apply to it. No floor makes someone else's partition readable.
|
|
165
181
|
|
|
166
|
-
**A backend has no calling user**, so every `DocStore` method throws `UnsupportedOperationException` for a `USER` scope — reads included, since resolving "me" without a caller would have to pick someone. Aggregate instead:
|
|
182
|
+
**A backend has no calling user**, so every `DocStore` method throws `UnsupportedOperationException` for a `USER` scope — reads included, since resolving "me" without a caller would have to pick someone. Aggregate instead — **after declaring it** (since 0.16.0):
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
"data": { "writableBy": "fan", "readableBy": "anonymous", "readsAllUsers": true }
|
|
186
|
+
```
|
|
167
187
|
|
|
168
188
|
```java
|
|
169
189
|
// Backend-only, read-only, and no HTTP surface: no visitor's request can reach another's data.
|
|
170
|
-
|
|
190
|
+
// null without data.readsAllUsers — the one read that crosses an ownership boundary is declared.
|
|
191
|
+
List<OwnedDocEntry> marks = ctx.allUsers().query("mark:");
|
|
171
192
|
// record OwnedDocEntry(UUID userId, String key, JsonNode value) — the owner is host-resolved, never
|
|
172
193
|
// a value the browser supplied, which is what makes a leaderboard built from it true.
|
|
173
194
|
```
|
|
174
195
|
|
|
175
|
-
Write the aggregate back to an entity scope (`…/data/episode/s2e04/leaderboard`) and let the component read it there. In tests, `InMemoryDocStore.asUser(uuid)` stands in for the host resolving `me`, so you can seed what a frontend would have written and
|
|
196
|
+
Write the aggregate back to an entity scope (`…/data/episode/s2e04/leaderboard`) and let the component read it there. In tests, `InMemoryDocStore.asUser(uuid)` stands in for the host resolving `me`, so you can seed what a frontend would have written; `FakePluginContext.withReadsAllUsers()` stands in for the declaration (off by default, so `ctx.allUsers()` is `null` as it is for an undeclared plugin), and `store.acrossUsers()` reads the partitions directly for your assertions.
|
|
197
|
+
|
|
198
|
+
**Why `readsAllUsers` is declared.** Every other doc-store read is a plugin's own shared scopes or the caller's own partition. This one returns every account's documents with their owners' UUIDs — "can enumerate everyone who ever used me" — which an operator should be able to read off a manifest before installing, exactly as with `identity` and `notifications`. Until 0.16.0 it was `ctx.store().queryAcrossUsers(prefix)` and every plugin had it without asking.
|
|
176
199
|
|
|
177
200
|
**The two ends see one store.** The doc a backend writes with `ctx.store().put(scope, key, value)` is exactly what the frontend reads at `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`.
|
|
178
201
|
|
|
@@ -327,7 +350,38 @@ const one = await ctx.feeds.display('kraken'); // null when absent or not visi
|
|
|
327
350
|
reason to read it live. Cache per render, never per install.
|
|
328
351
|
- `displayMany` **clamps** at 200 slugs rather than erroring, so check what came back.
|
|
329
352
|
|
|
353
|
+
- **`description` is untrusted third-party HTML** — whatever the podcast host put in the feed, unsanitized.
|
|
354
|
+
Never assign it to `innerHTML` as it stands. Show `descriptionText` (plain text, since 0.16.0) for a card
|
|
355
|
+
or a teaser, or run it through `ctx.sanitize` (below). A Java backend putting show notes into an
|
|
356
|
+
`OgMeta`, a `SearchHit` or a notification uses `descriptionText()` too.
|
|
357
|
+
|
|
330
358
|
Test it with `makeMockFeeds().withDisplay(slug, snapshot)`, mirroring the Java `FakeFeedAccess.withDisplay`.
|
|
359
|
+
The double derives `descriptionText` from `description` when a fixture leaves it out.
|
|
360
|
+
|
|
361
|
+
## Rendering HTML you did not write — `ctx.sanitize` (since 0.16.0)
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
notes.innerHTML = ctx.sanitize(snap.description); // show notes from the feed
|
|
365
|
+
page.innerHTML = ctx.sanitize(marked.parse(markdown)); // a podcaster's Markdown, after rendering
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`ctx.sanitize` applies **the host's own policy** — the one the shell applies to feed HTML, exported as
|
|
369
|
+
`FEED_HTML_POLICY`. It keeps prose, links, lists, tables and images; drops `<style>`, `<script>`,
|
|
370
|
+
`<iframe>`, forms, and every `style`, `srcset` and event-handler attribute; drops `javascript:`/`data:`
|
|
371
|
+
links; and sends every external link to a new tab with `rel="noopener noreferrer nofollow ugc"`, so
|
|
372
|
+
following one does not stop the player.
|
|
373
|
+
|
|
374
|
+
**Why not `DOMPurify.sanitize(html)`?** Its defaults stop scripts but allow `<style>` and `style=`. The
|
|
375
|
+
plugin contract needs `style-src 'unsafe-inline'`, and `img-src` stays open to any `https:` host for
|
|
376
|
+
artwork, so a stylesheet in someone else's HTML becomes a full-viewport click-jacking overlay or an
|
|
377
|
+
attribute-selector that leaks form values one character at a time. The wiki plugin shipped exactly that.
|
|
378
|
+
With `ctx.sanitize` a plugin cannot end up with a weaker policy than the host by writing less code. If you
|
|
379
|
+
really must sanitize where `ctx` does not reach, pass `FEED_HTML_POLICY`'s lists to your sanitizer rather
|
|
380
|
+
than its defaults.
|
|
381
|
+
|
|
382
|
+
In tests, `makeMockCtx()` puts `sanitizeLikeHost` on `ctx.sanitize`: the same policy walked over a parsed
|
|
383
|
+
tree, so a component test sees the same removals. It needs a DOM (`// @vitest-environment jsdom`) and is a
|
|
384
|
+
test double, never a boundary — ship `ctx.sanitize`.
|
|
331
385
|
|
|
332
386
|
## Site-wide tags — `ctx.tags` / `ctx.tags()` (since 0.9.0)
|
|
333
387
|
|
|
@@ -371,7 +425,7 @@ Test with `makeMockTags({ writesEpisodes })` / `FakeTags`, both of which refuse
|
|
|
371
425
|
|
|
372
426
|
## Who the UUIDs are — `ctx.users` / `ctx.users()` (since 0.13.0)
|
|
373
427
|
|
|
374
|
-
A backend calling `
|
|
428
|
+
A backend calling `ctx.allUsers().query(...)` gets `OwnedDocEntry(userId, …)` — UUIDs and a document. A leaderboard
|
|
375
429
|
built from that had ids and no way to draw a person, and both workarounds were bad: show raw UUIDs, or copy
|
|
376
430
|
display names into the plugin's own store. This is the lookup that fixes it. It is deliberately a lookup
|
|
377
431
|
rather than a wider `ctx.user`: the host still resolves access, and what you learn about somebody else stays
|
|
@@ -448,7 +502,7 @@ if (told.length < participants.length) prune(participants, told);
|
|
|
448
502
|
```
|
|
449
503
|
|
|
450
504
|
- **Only users you already hold `user`-scope data for.** Host-enforced against the same partitions
|
|
451
|
-
`
|
|
505
|
+
`allUsers().query(...)` reads — whether or not you declared `readsAllUsers` — participants have rows, and no plugin can reach a user who never touched it.
|
|
452
506
|
- **`send` answers who actually got it.** An ineligible or erased recipient is left out rather than
|
|
453
507
|
failing the call, so partial sends are normal and the return value is the only way to see one. A plugin
|
|
454
508
|
that ignores it and works from a stale participant list notifies nobody while looking perfectly healthy.
|
|
@@ -603,6 +657,34 @@ ceilings and the allow-list**: a component that has only ever met an accepting d
|
|
|
603
657
|
refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
|
|
604
658
|
(`rejectContent`) to exercise that path.
|
|
605
659
|
|
|
660
|
+
## Theme tokens — `--mc-*` (`--mc-accent-text` since 0.16.0)
|
|
661
|
+
|
|
662
|
+
`defineMosaicastElement` writes `ctx.theme` onto your `:host` as custom properties; the host also sets the
|
|
663
|
+
same names on `:root`, so they inherit into any shadow root.
|
|
664
|
+
|
|
665
|
+
| Token | CSS | Use it for |
|
|
666
|
+
|---|---|---|
|
|
667
|
+
| `bg` / `surface` | `--mc-bg` / `--mc-surface` | page and raised backgrounds |
|
|
668
|
+
| `text` / `textMuted` | `--mc-text` / `--mc-text-muted` | body and secondary text |
|
|
669
|
+
| `accent` | `--mc-accent` | **fills only** — buttons, badges, bars |
|
|
670
|
+
| `accentContrast` | `--mc-accent-contrast` | text and icons *on* an `--mc-accent` fill |
|
|
671
|
+
| `accentText` | `--mc-accent-text` | **text, links and focus rings** in the accent colour |
|
|
672
|
+
| `accent2` | `--mc-accent-2` | an optional secondary accent |
|
|
673
|
+
| `border` | `--mc-border` | dividers and outlines |
|
|
674
|
+
|
|
675
|
+
**Never colour text with `--mc-accent`.** It is the admin's seed, unchecked against the page: a pale seed
|
|
676
|
+
such as `#FFF176` measured 1.12:1 as link text. `--mc-accent-text` is the same accent clamped to WCAG AA
|
|
677
|
+
(4.5:1) against both `--mc-bg` and `--mc-surface`:
|
|
678
|
+
|
|
679
|
+
```css
|
|
680
|
+
a, .link { color: var(--mc-accent-text); }
|
|
681
|
+
:focus-visible { outline: 2px solid var(--mc-accent-text); }
|
|
682
|
+
button.primary { background: var(--mc-accent); color: var(--mc-accent-contrast); }
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
`ctx.theme.accentText` is optional — a host older than 0.16.0 does not send it — and the SDK only writes
|
|
686
|
+
it onto `:host` when present, so the value inherited from `:root` is never shadowed by an empty one.
|
|
687
|
+
|
|
606
688
|
## Host icons — `iconCss` / `iconMask` (since 0.9.0)
|
|
607
689
|
|
|
608
690
|
The host publishes its icon set as `--mc-icon-*` custom properties, which inherit through the shadow
|
|
@@ -786,6 +868,26 @@ shows them `ingestIntervalSeconds` and nothing else, with no room to say what a
|
|
|
786
868
|
it is in. Both take a locale map or a plain string; the key stays visible beside the label, because the key
|
|
787
869
|
is what your own docs name. `options` declares a closed set, rendered as a select and refused outside it.
|
|
788
870
|
|
|
871
|
+
**Config values have bounds (since 0.16.0).** `min`/`max`/`step` on a `number` field, `minLength`/`maxLength`
|
|
872
|
+
on a `string` one. Without them any number an operator can type is legal — including the `0` that switches
|
|
873
|
+
a scheduled task off. The host **refuses** an out-of-range write with a 400 naming the bound (never clamps),
|
|
874
|
+
the form renders the bounds as input constraints, a `default` outside its own bounds is refused at load, and
|
|
875
|
+
a value stored before a bound existed that now breaks it counts as unset. So what `PluginConfig.get` returns
|
|
876
|
+
satisfies the declaration — put the rule in the manifest, not a clamp in code:
|
|
877
|
+
|
|
878
|
+
```json
|
|
879
|
+
"ingestIntervalSeconds": { "type": "number", "default": 60, "min": 10, "max": 3600, "step": 1 }
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
There is no `pattern` yet: Java and JavaScript regex dialects differ, and one field's regex would be two
|
|
883
|
+
rules free to disagree.
|
|
884
|
+
|
|
885
|
+
**`frontend.entry` has a grammar (since 0.16.0).** A relative path under the plugin's own `assets/`: one or
|
|
886
|
+
more `[A-Za-z0-9._-]` segments joined by `/`, no leading slash, no `.` or `..` segment, no query and no
|
|
887
|
+
fragment. The host builds `/plugins/<id>/assets/<entry>` from it and **rejects the plugin at load** when it
|
|
888
|
+
does not match, rather than loading a URL you did not write. `FRONTEND_ENTRY_PATTERN` is the same rule, so a
|
|
889
|
+
test can find out before the host does.
|
|
890
|
+
|
|
789
891
|
**Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
|
|
790
892
|
`ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
|
|
791
893
|
the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
|
|
@@ -1002,8 +1104,8 @@ per-service detail exists so the notice can name who stores what for how long; w
|
|
|
1002
1104
|
the category. So if two plugins each declare an `analytics` service with different providers, the visitor
|
|
1003
1105
|
sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
|
|
1004
1106
|
provider and refuse the other. If you need your own services to be refusable independently, declare them
|
|
1005
|
-
under **different categories** (a plugin-declared category is fine
|
|
1006
|
-
|
|
1107
|
+
under **different categories** (a plugin-declared category is fine — label it, see below). That is the only
|
|
1108
|
+
lever the contract gives you.
|
|
1007
1109
|
|
|
1008
1110
|
**`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
|
|
1009
1111
|
it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
|
|
@@ -1040,6 +1142,29 @@ where plugin authors look, so the required shape is documented here. **From `0.4
|
|
|
1040
1142
|
}
|
|
1041
1143
|
```
|
|
1042
1144
|
|
|
1145
|
+
**Label a category you introduce (since 0.16.0).** The category is what the visitor consents to, so it is
|
|
1146
|
+
the one string that cannot fall back to a developer key — a bare `social` between two explained core
|
|
1147
|
+
categories reads as a bug, not a choice. Give it a name and a one-line hint, as a locale map or a plain
|
|
1148
|
+
string:
|
|
1149
|
+
|
|
1150
|
+
```json
|
|
1151
|
+
"consent": {
|
|
1152
|
+
"services": [{ "id": "mastodon", "category": "social", "…": "…" }],
|
|
1153
|
+
"categoryLabels": {
|
|
1154
|
+
"social": {
|
|
1155
|
+
"label": { "en": "Social media", "de": "Soziale Medien" },
|
|
1156
|
+
"hint": { "en": "Posts embedded from social networks." }
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
Declaring a category outside `necessary`/`functional`/`analytics` **obliges you to label it**: an
|
|
1163
|
+
unlabelled one still loads but is shown wrapped in a generic phrase. A label for a **core** category, or for
|
|
1164
|
+
a category none of your services declares, is refused at load. If two plugins label the same category the
|
|
1165
|
+
host picks one, deterministically — the decision is shared, so only one label can be shown. (`categoryLabels`
|
|
1166
|
+
is not the pre-0.4 `categories` array, which stays rejected.)
|
|
1167
|
+
|
|
1043
1168
|
| Field | Meaning |
|
|
1044
1169
|
|---|---|
|
|
1045
1170
|
| `id` | Stable identifier for this service within your plugin. |
|