@mosaicast/plugin-sdk 0.14.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 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,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 `queryAcrossUsers` gets `OwnedDocEntry(userId, …)` — UUIDs and a document. A leaderboard
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
- `queryAcrossUsers` reads — participants have rows, and no plugin can reach a user who never touched it.
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
@@ -766,9 +848,46 @@ export default defineManifest({
766
848
  identity: { resolvesUsers: true },
767
849
  notifications: { sends: true, perUserPerDay: 5 },
768
850
  external: { kinds: ['translation'], usedBy: 'podcaster' },
851
+ config: {
852
+ ingestIntervalSeconds: {
853
+ type: 'number', default: 60, editableBy: 'podcaster',
854
+ label: { en: 'Ingest interval', de: 'Abrufintervall' }, // locale map or a plain string
855
+ description: { en: 'Seconds between two ingest runs.' },
856
+ },
857
+ matchMode: {
858
+ type: 'string', default: 'fuzzy',
859
+ options: [{ value: 'fuzzy', label: 'Fuzzy' }, { value: 'exact', label: 'Exact' }],
860
+ },
861
+ },
769
862
  });
770
863
  ```
771
864
 
865
+ **Config fields say what they are (since 0.15.0).** Plugins may not build their own config UI (§7.2), so
866
+ the generic admin form is the only thing an operator ever sees — and without `label` and `description` it
867
+ shows them `ingestIntervalSeconds` and nothing else, with no room to say what a sane value is or what unit
868
+ it is in. Both take a locale map or a plain string; the key stays visible beside the label, because the key
869
+ is what your own docs name. `options` declares a closed set, rendered as a select and refused outside it.
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
+
772
891
  **Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
773
892
  `ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
774
893
  the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
@@ -884,6 +1003,58 @@ var sitemap = new SitemapProviderHarness("wiki", new WikiSitemap(store)).collect
884
1003
  assertTrue(sitemap.problems().isEmpty()); // outside the namespace, hand-written ?lang=, split groups
885
1004
  ```
886
1005
 
1006
+ ## Scheduled work — `ctx.onSchedule(...)` (a live period since 0.15.0)
1007
+
1008
+ ```java
1009
+ // Fixed forever — fine for work whose cadence is a constant.
1010
+ ctx.onSchedule(Duration.ofMinutes(15), this::ingest);
1011
+
1012
+ // Re-read before every tick — use this whenever the period comes from config.
1013
+ ctx.onSchedule(() -> Duration.ofSeconds(ctx.config().get("ingestIntervalSeconds", Integer.class, 60)),
1014
+ this::ingest);
1015
+ ```
1016
+
1017
+ The `Duration` overload takes its period **once**, during `register()`, and the host holds it for the life
1018
+ of the process. So a plugin whose tick rate is configurable — which the manifest actively invites —
1019
+ accepted a new value, stored it, reported success, and went on running at the old cadence until a restart.
1020
+ Nothing in the admin form said so.
1021
+
1022
+ Hand over the *reading* of the period instead and the host consults your supplier before each fire,
1023
+ rescheduling when the answer changes: a saved setting takes effect within one old period. The supplier runs
1024
+ on a scheduler thread, so read config or a field and nothing more. Returning `null`, returning a
1025
+ non-positive `Duration`, or throwing leaves the task on the last period that was valid — logged, never
1026
+ silently dropped. Only the value at registration is strict: it must be positive. The host may clamp very
1027
+ short periods to a floor it owns.
1028
+
1029
+ `FakePluginContext` makes the difference assertable: `scheduledPeriods()` re-reads every registered
1030
+ supplier on demand, so a test changes `MapPluginConfig` and sees the new period — a plugin that captured a
1031
+ `Duration` at `register()` keeps reporting the old one and fails the assertion.
1032
+
1033
+ ## Surviving a new `ctx` — `MosaicastHandle` (since 0.15.0)
1034
+
1035
+ ```ts
1036
+ defineMosaicastElement({
1037
+ tag: 'bingo-card',
1038
+ render: ({ ctx, root }) => {
1039
+ const app = mountMyFramework(root, ctx);
1040
+ return { update: (next) => app.setCtx(next), destroy: () => app.unmount() };
1041
+ },
1042
+ });
1043
+ ```
1044
+
1045
+ Returning a cleanup callback (or nothing) keeps the original behaviour: every `ctx` assignment runs cleanup,
1046
+ clears `root` and calls `render` again. That is right for a static card and expensive for anything else,
1047
+ because `ctx` changes far more often than "a consent choice or a language switch" — a host that rebuilds
1048
+ its context object on each of its own renders can reassign it several times a second during playback, and
1049
+ each rebuild loses component state, in-flight requests, scroll position and open dialogs, then re-runs
1050
+ every effect behind them.
1051
+
1052
+ Return a `MosaicastHandle` with an `update` and the SDK stops tearing down: it refreshes the `--mc-*` theme
1053
+ variables, calls `update(next)` and leaves `root` alone. `destroy` then runs only when the element actually
1054
+ disconnects — and if the element is moved in the DOM (a disconnect plus a connect), the SDK renders it
1055
+ again rather than leaving it dead. Re-assigning the **same** context object is ignored either way, so a
1056
+ churning host costs you nothing without your having to compare contexts yourself.
1057
+
887
1058
  ## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
888
1059
 
889
1060
  ```java
@@ -933,8 +1104,8 @@ per-service detail exists so the notice can name who stores what for how long; w
933
1104
  the category. So if two plugins each declare an `analytics` service with different providers, the visitor
934
1105
  sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
935
1106
  provider and refuse the other. If you need your own services to be refusable independently, declare them
936
- under **different categories** (a plugin-declared category is fine; the shell just has no translated label
937
- for it). That is the only lever the contract gives you.
1107
+ under **different categories** (a plugin-declared category is fine — label it, see below). That is the only
1108
+ lever the contract gives you.
938
1109
 
939
1110
  **`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
940
1111
  it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
@@ -971,6 +1142,29 @@ where plugin authors look, so the required shape is documented here. **From `0.4
971
1142
  }
972
1143
  ```
973
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
+
974
1168
  | Field | Meaning |
975
1169
  |---|---|
976
1170
  | `id` | Stable identifier for this service within your plugin. |
@@ -1016,6 +1210,10 @@ var ctx = new FakePluginContext(); // in-memory store, config, fee
1016
1210
  myPlugin.register(ctx); // exercise the backend
1017
1211
  assertEquals(Optional.of("world"),
1018
1212
  ctx.store().get(Scope.site(), "hello", String.class));
1213
+
1214
+ ctx.runScheduled(); // tick again (since 0.15.0)
1215
+ config.with("ingestIntervalSeconds", 10); // and prove the period follows config
1216
+ assertEquals(List.of(Duration.ofSeconds(10)), ctx.scheduledPeriods());
1019
1217
  ```
1020
1218
 
1021
1219
  **TypeScript** (`@mosaicast/plugin-sdk/testing`, jsdom):