@mosaicast/plugin-sdk 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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; 404 if absent
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 404 handling done for you — which removes a whole class of bug, since every doc
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` on 404**, because a key nothing has written yet is a normal state rather than a
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
- List<OwnedDocEntry> marks = ctx.store().queryAcrossUsers("mark:");
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 then assert on `queryAcrossUsers`.
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,44 @@ 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. Since 0.16.1 it keeps `start` on a resumed numbered list and `align`
373
+ on table cells, so ordinary Markdown survives it. The allow-list is the **whole** list: no `data-*`, no
374
+ `aria-*`, no `class` — so a plugin's own `data-*` markers cannot be forged by an author. The one exception to
375
+ the URL rule is a `data:` image in `<img src>`, which cannot run script and is kept.
376
+
377
+ **Why not `DOMPurify.sanitize(html)`?** Its defaults stop scripts but allow `<style>` and `style=`. The
378
+ plugin contract needs `style-src 'unsafe-inline'`, and `img-src` stays open to any `https:` host for
379
+ artwork, so a stylesheet in someone else's HTML becomes a full-viewport click-jacking overlay or an
380
+ attribute-selector that leaks form values one character at a time. The wiki plugin shipped exactly that.
381
+ With `ctx.sanitize` a plugin cannot end up with a weaker policy than the host by writing less code. If you
382
+ really must sanitize where `ctx` does not reach, pass `FEED_HTML_POLICY`'s lists to your sanitizer rather
383
+ than its defaults — and with DOMPurify also `ALLOW_DATA_ATTR: false`, `ALLOW_ARIA_ATTR: false` and
384
+ `ADD_URI_SAFE_ATTR` for every allowed attribute but `href`/`src`; without them the lists are not the policy.
385
+
386
+ In tests, `makeMockCtx()` puts `sanitizeLikeHost` on `ctx.sanitize`: the same policy walked over a parsed
387
+ tree, so a component test sees the same removals. Since 0.16.1 it matches the host exactly on
388
+ core's parity samples — including which removed elements take their text with them — because a double
389
+ stricter than production is as misleading as a looser one. It needs a DOM (`// @vitest-environment jsdom`)
390
+ and is a test double, never a boundary — ship `ctx.sanitize`.
331
391
 
332
392
  ## Site-wide tags — `ctx.tags` / `ctx.tags()` (since 0.9.0)
333
393
 
@@ -371,7 +431,7 @@ Test with `makeMockTags({ writesEpisodes })` / `FakeTags`, both of which refuse
371
431
 
372
432
  ## Who the UUIDs are — `ctx.users` / `ctx.users()` (since 0.13.0)
373
433
 
374
- A backend calling `queryAcrossUsers` gets `OwnedDocEntry(userId, …)` — UUIDs and a document. A leaderboard
434
+ A backend calling `ctx.allUsers().query(...)` gets `OwnedDocEntry(userId, …)` — UUIDs and a document. A leaderboard
375
435
  built from that had ids and no way to draw a person, and both workarounds were bad: show raw UUIDs, or copy
376
436
  display names into the plugin's own store. This is the lookup that fixes it. It is deliberately a lookup
377
437
  rather than a wider `ctx.user`: the host still resolves access, and what you learn about somebody else stays
@@ -448,7 +508,7 @@ if (told.length < participants.length) prune(participants, told);
448
508
  ```
449
509
 
450
510
  - **Only users you already hold `user`-scope data for.** Host-enforced against the same partitions
451
- `queryAcrossUsers` reads — participants have rows, and no plugin can reach a user who never touched it.
511
+ `allUsers().query(...)` reads — whether or not you declared `readsAllUsers` — participants have rows, and no plugin can reach a user who never touched it.
452
512
  - **`send` answers who actually got it.** An ineligible or erased recipient is left out rather than
453
513
  failing the call, so partial sends are normal and the return value is the only way to see one. A plugin
454
514
  that ignores it and works from a stale participant list notifies nobody while looking perfectly healthy.
@@ -603,6 +663,34 @@ ceilings and the allow-list**: a component that has only ever met an accepting d
603
663
  refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
604
664
  (`rejectContent`) to exercise that path.
605
665
 
666
+ ## Theme tokens — `--mc-*` (`--mc-accent-text` since 0.16.0)
667
+
668
+ `defineMosaicastElement` writes `ctx.theme` onto your `:host` as custom properties; the host also sets the
669
+ same names on `:root`, so they inherit into any shadow root.
670
+
671
+ | Token | CSS | Use it for |
672
+ |---|---|---|
673
+ | `bg` / `surface` | `--mc-bg` / `--mc-surface` | page and raised backgrounds |
674
+ | `text` / `textMuted` | `--mc-text` / `--mc-text-muted` | body and secondary text |
675
+ | `accent` | `--mc-accent` | **fills only** — buttons, badges, bars |
676
+ | `accentContrast` | `--mc-accent-contrast` | text and icons *on* an `--mc-accent` fill |
677
+ | `accentText` | `--mc-accent-text` | **text, links and focus rings** in the accent colour |
678
+ | `accent2` | `--mc-accent-2` | an optional secondary accent |
679
+ | `border` | `--mc-border` | dividers and outlines |
680
+
681
+ **Never colour text with `--mc-accent`.** It is the admin's seed, unchecked against the page: a pale seed
682
+ such as `#FFF176` measured 1.12:1 as link text. `--mc-accent-text` is the same accent clamped to WCAG AA
683
+ (4.5:1) against both `--mc-bg` and `--mc-surface`:
684
+
685
+ ```css
686
+ a, .link { color: var(--mc-accent-text); }
687
+ :focus-visible { outline: 2px solid var(--mc-accent-text); }
688
+ button.primary { background: var(--mc-accent); color: var(--mc-accent-contrast); }
689
+ ```
690
+
691
+ `ctx.theme.accentText` is optional — a host older than 0.16.0 does not send it — and the SDK only writes
692
+ it onto `:host` when present, so the value inherited from `:root` is never shadowed by an empty one.
693
+
606
694
  ## Host icons — `iconCss` / `iconMask` (since 0.9.0)
607
695
 
608
696
  The host publishes its icon set as `--mc-icon-*` custom properties, which inherit through the shadow
@@ -786,6 +874,26 @@ shows them `ingestIntervalSeconds` and nothing else, with no room to say what a
786
874
  it is in. Both take a locale map or a plain string; the key stays visible beside the label, because the key
787
875
  is what your own docs name. `options` declares a closed set, rendered as a select and refused outside it.
788
876
 
877
+ **Config values have bounds (since 0.16.0).** `min`/`max`/`step` on a `number` field, `minLength`/`maxLength`
878
+ on a `string` one. Without them any number an operator can type is legal — including the `0` that switches
879
+ a scheduled task off. The host **refuses** an out-of-range write with a 400 naming the bound (never clamps),
880
+ the form renders the bounds as input constraints, a `default` outside its own bounds is refused at load, and
881
+ a value stored before a bound existed that now breaks it counts as unset. So what `PluginConfig.get` returns
882
+ satisfies the declaration — put the rule in the manifest, not a clamp in code:
883
+
884
+ ```json
885
+ "ingestIntervalSeconds": { "type": "number", "default": 60, "min": 10, "max": 3600, "step": 1 }
886
+ ```
887
+
888
+ There is no `pattern` yet: Java and JavaScript regex dialects differ, and one field's regex would be two
889
+ rules free to disagree.
890
+
891
+ **`frontend.entry` has a grammar (since 0.16.0).** A relative path under the plugin's own `assets/`: one or
892
+ more `[A-Za-z0-9._-]` segments joined by `/`, no leading slash, no `.` or `..` segment, no query and no
893
+ fragment. The host builds `/plugins/<id>/assets/<entry>` from it and **rejects the plugin at load** when it
894
+ does not match, rather than loading a URL you did not write. `FRONTEND_ENTRY_PATTERN` is the same rule, so a
895
+ test can find out before the host does.
896
+
789
897
  **Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
790
898
  `ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
791
899
  the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
@@ -1002,8 +1110,8 @@ per-service detail exists so the notice can name who stores what for how long; w
1002
1110
  the category. So if two plugins each declare an `analytics` service with different providers, the visitor
1003
1111
  sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
1004
1112
  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; the shell just has no translated label
1006
- for it). That is the only lever the contract gives you.
1113
+ under **different categories** (a plugin-declared category is fine — label it, see below). That is the only
1114
+ lever the contract gives you.
1007
1115
 
1008
1116
  **`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
1009
1117
  it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
@@ -1040,6 +1148,29 @@ where plugin authors look, so the required shape is documented here. **From `0.4
1040
1148
  }
1041
1149
  ```
1042
1150
 
1151
+ **Label a category you introduce (since 0.16.0).** The category is what the visitor consents to, so it is
1152
+ the one string that cannot fall back to a developer key — a bare `social` between two explained core
1153
+ categories reads as a bug, not a choice. Give it a name and a one-line hint, as a locale map or a plain
1154
+ string:
1155
+
1156
+ ```json
1157
+ "consent": {
1158
+ "services": [{ "id": "mastodon", "category": "social", "…": "…" }],
1159
+ "categoryLabels": {
1160
+ "social": {
1161
+ "label": { "en": "Social media", "de": "Soziale Medien" },
1162
+ "hint": { "en": "Posts embedded from social networks." }
1163
+ }
1164
+ }
1165
+ }
1166
+ ```
1167
+
1168
+ Declaring a category outside `necessary`/`functional`/`analytics` **obliges you to label it**: an
1169
+ unlabelled one still loads but is shown wrapped in a generic phrase. A label for a **core** category, or for
1170
+ a category none of your services declares, is refused at load. If two plugins label the same category the
1171
+ host picks one, deterministically — the decision is shared, so only one label can be shown. (`categoryLabels`
1172
+ is not the pre-0.4 `categories` array, which stays rejected.)
1173
+
1043
1174
  | Field | Meaning |
1044
1175
  |---|---|
1045
1176
  | `id` | Stable identifier for this service within your plugin. |