@mosaicast/plugin-sdk 0.8.0 → 0.9.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
@@ -33,7 +33,9 @@ The contract version is a **single SemVer anchor** mirrored in four places that
33
33
  **How the host matches it:** core compares `major.minor` **exactly**. A plugin declaring `0.3.x` is
34
34
  rejected the moment the host runs `0.7.0` — with the reason shown in the admin log viewer. While the SDK
35
35
  is pre-1.0 a breaking change is therefore a **minor** bump (`0.6.0` → `0.7.0`), not a major one; from
36
- `1.0.0` on, normal SemVer applies and breaking means major.
36
+ `1.0.0` on, normal SemVer applies and breaking means major. **The patch floats** — a plugin declaring
37
+ `0.9.0` loads on a `0.9.1` host, which is what makes a purely additive release (a new optional extension
38
+ point, a test double) cheap: it rejects nothing already installed.
37
39
 
38
40
  ## Build & test
39
41
  ```bash
@@ -49,8 +51,8 @@ npm ci && npm run build # TypeScript: src → dist (.js + .d.ts)
49
51
  - Released: from **GitHub Packages** (see below).
50
52
  ```kotlin
51
53
  dependencies {
52
- compileOnly("dev.mosaicast:plugin-api:0.7.0") // contract, provided by the host
53
- testImplementation("dev.mosaicast:plugin-testkit:0.7.0") // test doubles only
54
+ compileOnly("dev.mosaicast:plugin-api:0.9.1") // contract, provided by the host
55
+ testImplementation("dev.mosaicast:plugin-testkit:0.9.1") // test doubles only
54
56
  }
55
57
  ```
56
58
  Sources + Javadoc JARs give IDE hover docs automatically.
@@ -104,6 +106,52 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
104
106
  - The list is **paginated** (core's standard `PagedResponse` envelope) and **carries keys** (`DocEntry`), because neither end can address a doc without one.
105
107
  - `delete` is **idempotent**: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
106
108
 
109
+ ### The typed client — `ctx.docs` (since 0.9.0)
110
+
111
+ `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
113
+ access above was string concatenation the plugin had to get right four segments at a time:
114
+
115
+ ```ts
116
+ await ctx.docs.put('self', `mark:${ctx.scope.id}`, marks); // data/user/me/mark:<slug>
117
+ const board = await ctx.docs.get<Board>('site', 'leaderboard'); // null when absent, not a rejection
118
+ const page = await ctx.docs.list<Marks>('self', { prefix: 'mark:' });
119
+ await ctx.docs.remove(ctx.scope, 'draft'); // any Scope addresses its own partition
120
+ ```
121
+
122
+ - **`'self'` is `data/user/me`** and `'site'` is `data/site/main` — the two singletons. Making the
123
+ per-user partition the *shortest* thing to write is deliberate: it is the most security-relevant
124
+ 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
+ - **A malformed key throws at the call site**, with `DOC_KEY_PATTERN` in the message, instead of costing a
128
+ 400 round-trip whose body you then have to read.
129
+ - Everything else is unchanged and still the host's: both access floors, `backendOwned`, the 400 on an
130
+ unknown scope, the 401 on an anonymous `user` request.
131
+
132
+ ### Typed failures — `PluginApiError` (since 0.9.0)
133
+
134
+ Every `ctx.api` method rejects with an error carrying the **status** and the RFC 7807 body. Before 0.9.0 the
135
+ rejection was untyped, so the only way to survive a 404 was `catch(() => undefined)` — which also swallowed
136
+ the 500, the 403 and the network failure, and showed the visitor an empty widget for all four:
137
+
138
+ ```ts
139
+ import { isPluginApiError } from '@mosaicast/plugin-sdk';
140
+
141
+ try {
142
+ await ctx.docs.put('site', 'stats', computed);
143
+ } catch (e) {
144
+ if (isPluginApiError(e) && e.status === 403) {
145
+ ctx.log('warn', e.problem?.detail ?? 'refused'); // backendOwned, or the write floor
146
+ return;
147
+ }
148
+ throw e; // a real failure — surface it
149
+ }
150
+ ```
151
+
152
+ Use `isPluginApiError`, not `instanceof`: the error is constructed by the host and crosses a bundle
153
+ boundary, so it does not share a constructor with anything in your plugin.
154
+
107
155
  ### Per-user data — the `user` scope (since 0.5.0)
108
156
 
109
157
  **Per-user data belongs in the `user` scope, never in the key.** A key is client-supplied, so the convention this README used to suggest — `mark:<userId>:cell` under an episode scope — was an access-control decision the host could not enforce: any caller past the plugin's read floor could address someone else's key directly, and scope ids are public slugs, so nothing had to be guessed.
@@ -252,6 +300,75 @@ const n = await ctx.schema.count('page');
252
300
  Test it with `makeMockSchema({ page: [...] })` from `/testing`, which records every query. Its `search` is
253
301
  a substring match with the same caveat as `FakeSchemaStore`'s.
254
302
 
303
+ ## Episode snapshots — `ctx.feeds` (since 0.9.0)
304
+
305
+ The Java contract could read an episode's display snapshot and the frontend could not, so a plugin that
306
+ wanted to draw an episode card copied the host's own data into its doc store and kept it fresh on a
307
+ schedule — a backend, a scheduled ingest, a `backendOwned` key and a copy that is stale between runs, for
308
+ fields the host already has.
309
+
310
+ ```ts
311
+ const cards = await ctx.feeds.displayMany(ctx.episodes.slice(0, 20)); // one request, not N
312
+ for (const slug of ctx.episodes) {
313
+ const snap = cards[slug];
314
+ if (!snap) continue; // filtered out for this visitor — normal, not an error
315
+ render(slug, snap.title, resolveArtwork(snap), i18n.duration(snap.duration ?? 0));
316
+ }
317
+
318
+ const one = await ctx.feeds.display('kraken'); // null when absent or not visible
319
+ ```
320
+
321
+ - **The host filters, the plugin consumes.** A `WITHDRAWN` or tier-gated episode is **absent** rather than
322
+ redacted, and this cannot enumerate episodes `ctx.episodes` did not already give you.
323
+ - **No `readableBy` gate of its own** — it returns host data the same visitor can read from
324
+ `/api/episodes/*` anyway. It exists so a plugin need not know that URL shape, the same argument
325
+ `ctx.links` makes.
326
+ - **Not authoritative.** The snapshot is overwritten on every feed refetch — that is the feature, and the
327
+ reason to read it live. Cache per render, never per install.
328
+ - `displayMany` **clamps** at 200 slugs rather than erroring, so check what came back.
329
+
330
+ Test it with `makeMockFeeds().withDisplay(slug, snapshot)`, mirroring the Java `FakeFeedAccess.withDisplay`.
331
+
332
+ ## Site-wide tags — `ctx.tags` / `ctx.tags()` (since 0.9.0)
333
+
334
+ Tags existed in core only as a feed-derived filter axis over episodes: no vocabulary, no plugin surface. So
335
+ every plugin that wanted tags grew a private free-text column, and a wiki's `lore` and an episode's `lore`
336
+ were unrelated strings that could not be suggested, linked or counted together.
337
+
338
+ Opt in from the manifest — reading and tagging your own subjects is one thing, tagging **episodes** is
339
+ another:
340
+
341
+ ```json
342
+ "tags": { "readsVocabulary": true, "writesEpisodes": false }
343
+ ```
344
+
345
+ `ctx.tags` (TS) and `ctx.tags()` (Java) are **`null`** without it, the same shape as `schema` and `blobs`.
346
+
347
+ ```ts
348
+ const tags = ctx.tags;
349
+ if (!tags) return;
350
+
351
+ for (const t of await tags.all()) suggest(t.label, t.tag); // the site's real vocabulary
352
+ await tags.tagSubject(`page:${slug}`, 'Maritime Lore'); // your own namespace
353
+ const also = await tags.episodesWith('maritime lore'); // what else is about this
354
+ a.href = ctx.links.feed('main', { tag: 'maritime lore' }); // and it links to the feed view
355
+ ```
356
+
357
+ - **`tag` is the canonical key** (trim, collapse whitespace, casefold), applied on every path into the
358
+ vocabulary including feed ingest; **`label` is presentation**, kept from first use. Send any spelling,
359
+ store and compare on the key.
360
+ - **Tagging an episode is a capability**, not a convenience: it changes the shell's filter options *and*
361
+ what core recommends beside that episode. Hence the second flag.
362
+ - **What no plugin may do:** delete a tag from the vocabulary (it is shared), rename one (a vocabulary-wide
363
+ edit, and admin's), or remove another writer's assignment — the feed's included. Every plugin write
364
+ carries `source = plugin:<id>`, which makes the last one enforceable rather than merely discouraged.
365
+ - A tag stops existing when nothing carries it.
366
+
367
+ `subjectKey` is opaque and yours to invent — the same namespacing `ctx.schema` has for tables. Use the same
368
+ key a `SearchProvider` hit resolves to, so a tag and a search result name one object.
369
+
370
+ Test with `makeMockTags({ writesEpisodes })` / `FakeTags`, both of which refuse what the host refuses.
371
+
255
372
  ## Deep links & navigation — `ctx.route` (navigate since 0.7.0)
256
373
 
257
374
  A plugin declaring a `page` slot owns the URL subtree `/p/<pluginId>/*`. The host hands it the subpath
@@ -279,7 +396,37 @@ ctx.route.navigate('index', { replace: true }); // no back-button step (ta
279
396
  - **Do not reach past this handle.** `history.pushState` plus a synthetic `popstate` happens to work
280
397
  against the host's current router and is not part of the contract.
281
398
  - Pair it with `ShareMetadataProvider` (OpenGraph per subpath) and `SitemapProvider` on the backend so the
282
- URLs you navigate to also preview and index properly.
399
+ URLs you navigate to also preview and index properly — and with `PageRouteProvider` (since 0.9.1) so the
400
+ ones that do *not* exist answer 404 instead of a 200 with a not-found view in it.
401
+
402
+ ### Reading the query, and matching a route (since 0.9.0)
403
+
404
+ `navigate` always accepted a `?query`, and until 0.9.0 there was no supported way to read one back —
405
+ `location.search` is exactly the "do not reach past this handle" the rule above forbids. Filters, sort
406
+ order and pagination are the obvious shareable-link state:
407
+
408
+ ```ts
409
+ const sort = ctx.route.query.get('sort') ?? 'newest'; // host-parsed URLSearchParams
410
+ ctx.route.navigate(`moments?sort=${next}`, { replace: true });
411
+ ```
412
+
413
+ `matchRoute` replaces the prefix matcher every page plugin hand-rolls — and the hand-rolled one is usually
414
+ a `startsWith`, which matches `moments-archive` too:
415
+
416
+ ```ts
417
+ import { matchRoute } from '@mosaicast/plugin-sdk';
418
+
419
+ const match = matchRoute(ctx.route.path, ['', 'moments', 'highlight/:slug'] as const);
420
+ switch (match?.pattern) {
421
+ case 'highlight/:slug': return renderDetail(match.params.slug);
422
+ case 'moments': return renderMoments();
423
+ case '': return renderIndex();
424
+ default: return renderNotFound(); // null — nothing matched
425
+ }
426
+ ```
427
+
428
+ The whole path must be consumed, `:param` captures one non-empty segment (decoded), surrounding slashes and
429
+ any `?query`/`#hash` are ignored, and the **first** matching pattern wins.
283
430
 
284
431
  ### Linking to core pages — `ctx.links` (since 0.8.0)
285
432
 
@@ -348,6 +495,149 @@ ceilings and the allow-list**: a component that has only ever met an accepting d
348
495
  refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
349
496
  (`rejectContent`) to exercise that path.
350
497
 
498
+ ## Host icons — `iconCss` / `iconMask` (since 0.9.0)
499
+
500
+ The host publishes its icon set as `--mc-icon-*` custom properties, which inherit through the shadow
501
+ boundary — so a plugin built against **this** SDK picks up an icon the day core publishes it, with no SDK
502
+ release and no manifest bump. That is why the icon set is deliberately *not* on `ctx`, and why these
503
+ helpers take a plain `string` rather than a closed union: either would pin the set to an SDK version.
504
+
505
+ What they own is the mechanics, which are subtle enough to get wrong three ways:
506
+
507
+ ```ts
508
+ import { iconCss } from '@mosaicast/plugin-sdk';
509
+
510
+ const style = document.createElement('style');
511
+ style.textContent = iconCss(['star', 'clock']); // put it in YOUR shadow root
512
+ root.append(style);
513
+ root.insertAdjacentHTML('beforeend', '<i class="mc-icon mc-icon-star" aria-hidden="true"></i>');
514
+ ```
515
+
516
+ - **Mask, never `background-image`.** `background: currentColor` behind a mask is what makes the icon
517
+ re-theme with the text beside it.
518
+ - **Every reference needs a blank-image fallback.** An unresolved `var()` invalidates the declaration, so
519
+ `mask-image` falls back to its initial `none` — leaving an unmasked element painting `currentColor`
520
+ across its whole box. **A missing icon renders as a solid square, not as nothing**, and `mask-image: none`
521
+ is the obvious fallback and the one that causes it. `iconMask` emits a real blank SVG instead.
522
+ - **A bundled `.css` file lands in the host document and cannot reach any shadow root.** It is the first
523
+ thing everyone tries and it silently does nothing.
524
+
525
+ Don't declare into `--mc-*` yourself — that is the host's namespace.
526
+
527
+ ## Formatting — `createPluginI18n` (plurals & units since 0.9.0)
528
+
529
+ `t(key, params)` interpolates; the rest formats against `ctx.locale`:
530
+
531
+ ```ts
532
+ const i18n = createPluginI18n(catalogs, ctx.locale);
533
+
534
+ i18n.plural('moments', n); // catalog keys: moments.one / moments.other / moments.few …
535
+ i18n.n(1234.5); // 1.234,5 in de
536
+ i18n.date(snapshot.publishedAt!); // takes the ISO instants the contract hands over
537
+ i18n.duration(snapshot.duration!); // 'PT1H2M3S' → 1:02:03; also takes plain seconds
538
+ i18n.bytes(quota.usedBytes); // decimal units, locale separator — 5,2 MB in de
539
+ ```
540
+
541
+ - **`plural` uses `Intl.PluralRules`.** A catalog could not express "1 highlight" / "5 highlights" at all,
542
+ so plugins shipped an English-shaped `if (n === 1)` — wrong in Polish, Russian and Arabic. Only the
543
+ `.other` form is required; a locale needing one you did not write falls back to it. `count` is
544
+ interpolated for you.
545
+ - **`duration` and `bytes` are contract-adjacent**: `DisplaySnapshot.duration` *is* an ISO-8601 string and
546
+ `BlobQuota` *is* three raw byte counts. The SDK produces both, so it may as well render them — and the
547
+ hand-rolled byte formatter hardcodes `.` as the decimal separator, which is simply wrong in `de`.
548
+
549
+ ## The manifest, typed — `defineManifest` (since 0.9.0)
550
+
551
+ ```ts
552
+ import { defineManifest, PLATFORM_API_VERSION } from '@mosaicast/plugin-sdk';
553
+
554
+ export default defineManifest({
555
+ id: 'sample', version: '1.0.0', platformApi: PLATFORM_API_VERSION, name: 'Sample',
556
+ frontend: { entry: 'sample.es.js', elements: ['sample-card'] },
557
+ slots: [{ scope: 'episode', element: 'sample-card', placement: 'main', visibleTo: 'anonymous' }],
558
+ nav: [{ path: '', label: 'Sample', icon: 'star' }],
559
+ data: { writableBy: 'podcaster', readableBy: 'anonymous' },
560
+ });
561
+ ```
562
+
563
+ **Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
564
+ `ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
565
+ the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
566
+
567
+ What it buys you: emit `plugin.json` from a build step and the manifest stops being a second, unchecked
568
+ copy of what your code already knows. `nav[]` and a plugin's own in-page tab bar are the same list of
569
+ entrances declared twice, and a renamed path otherwise becomes a menu entry leading to an empty view with
570
+ nothing to catch it. Note where the two *legitimately* diverge: `path`, `icon` and `role` are pinned
571
+ between them, but the menu **label cannot be translated** — core has no plugin catalogs — while your
572
+ in-page tab can.
573
+
574
+ ## Optional extension points — `SearchProvider`, `UserDataHandler`, `PageRouteProvider`
575
+
576
+ All three are `ExtensionPoint`s a plugin MAY implement alongside `PluginBackend`, like `SitemapProvider`. No
577
+ manifest declaration: not implementing one is how a plugin says it has nothing to contribute.
578
+
579
+ **`SearchProvider`** puts plugin content into the site's search, instead of each plugin growing a private
580
+ search box a visitor has no way to discover:
581
+
582
+ ```java
583
+ public List<SearchHit> search(String query, Role role, int limit) {
584
+ return pages.matching(query, role).stream()
585
+ .map(p -> new SearchHit("glossary/" + p.slug(), p.title(), p.excerpt(), p.rank()))
586
+ .toList();
587
+ }
588
+ ```
589
+
590
+ `subpath` resolves to `/p/<pluginId>/<subpath>`, exactly as `ShareMetadataProvider.metaFor` works in
591
+ reverse, so a hit is a real deep-linkable URL and the host keeps the URL shape. Results are **grouped by
592
+ source**, not merged — your `score` and Postgres `ts_rank` are not on one scale — and a slow provider costs
593
+ its own section, not the visitor's query.
594
+
595
+ **Access is your job here, unusually.** Everywhere else the host resolves access; it cannot for objects it
596
+ has no model of. A provider returning a draft page to an anonymous visitor is a leak the host will not
597
+ catch, so `role` is `null` for anonymous and `SearchProviderHarness` calls you once per role:
598
+
599
+ ```java
600
+ var results = new SearchProviderHarness(new WikiSearch(store)).search("kraken");
601
+ assertFalse(results.leakedToAnonymous("draft/lighthouse"));
602
+ ```
603
+
604
+ **`UserDataHandler`** is what §12's promise needs to be keepable — core cannot pseudonymise a plugin's
605
+ contributions, cannot find them, and cannot know that pseudonymising is right rather than deleting:
606
+
607
+ ```java
608
+ public void eraseUser(String userId) { revisions.anonymise(userId); } // or a hard delete — your call
609
+ ```
610
+
611
+ The `USER` scope is host-owned, so core drops `data/user/<id>/…` itself. Implement this for the hard half:
612
+ **schema columns and blobs**, where identity lives in a column a plugin chose and the host never learned
613
+ which one is a person. Handlers run *before* the account row goes, and **must be idempotent** — a failed
614
+ deletion is retried. `UserDataHandlerHarness.eraseTwice(userId)` is that test, and it is the one authors
615
+ skip: the second call is the one that throws, during a retry, when the alternative is a half-done deletion.
616
+
617
+ **`PageRouteProvider`** (since 0.9.1) is how a plugin's unknown subpaths become real 404s. Declare a `page`
618
+ slot and *every* subpath under `/p/<id>/` answers `200` — a page never written, a mistyped slug, the URL of
619
+ a page deleted last year — each rendering your not-found view inside a `200 OK`. §6.6 rules that soft-404
620
+ out for core's own routes, and the host cannot fix it alone: only the plugin knows whether a subpath is a
621
+ thing.
622
+
623
+ ```java
624
+ public boolean hasRoute(String subpath) {
625
+ return subpath.isEmpty() || subpath.startsWith("_search/") || pages.exists(slugOf(subpath));
626
+ }
627
+ ```
628
+
629
+ **Not `ShareMetadataProvider`** — the tempting shortcut, and wrong: `_search/<term>` and `_admin` return an
630
+ empty `metaFor` *on purpose*, so reading "no share metadata" as "no page" would 404 working routes. Absent
631
+ means today's behaviour, the **root is a route** (`subpath` is empty there), it runs on a request so keep it
632
+ a lookup, and a provider that throws is logged and skipped and the route answers `200` — a broken plugin
633
+ must not turn a working page into a 404. It decides the status line only; the shell still renders the
634
+ not-found view. `PageRouteProviderHarness` asks about a handful of subpaths at once, root included:
635
+
636
+ ```java
637
+ var routes = new PageRouteProviderHarness(new WikiRoutes(store)).check("glossary/kraken", "glossary/tpyo");
638
+ assertEquals(List.of("glossary/tpyo"), routes.notFound());
639
+ ```
640
+
351
641
  ## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
352
642
 
353
643
  ```java
@@ -493,6 +783,35 @@ expect(el.shadowRoot!.querySelector('.card')).not.toBeNull();
493
783
  // el.ctx.api is a MockApiClient: assert el.ctx.api.calls after interactions
494
784
  ```
495
785
 
786
+ **Awaiting a component's fetches** (`flushMockApi`, since 0.9.0). The mock resolves its promise before the
787
+ component's `.then(setState)` runs, so `await Promise.resolve()` covers one hop and not two — which is why
788
+ the symptom is an assertion that fails *only sometimes*, depending on how many hops the component happens
789
+ to have:
790
+
791
+ ```ts
792
+ mount(ctx);
793
+ await flushMockApi(ctx.api); // waits for the calls, then drains the hops behind them
794
+ expect(root.querySelector('.title')?.textContent).toBe('The Kraken');
795
+ ```
796
+
797
+ **Driving a failure.** `apiError(status, problem)` as a canned response makes the client reject with the
798
+ host's error shape, so a component's 404 branch and its 500 branch are separately testable:
799
+
800
+ ```ts
801
+ const ctx = makeMockCtx({ apiResponses: { 'get data/site/main/stats': apiError(403, { detail: 'backendOwned' }) } });
802
+ ```
803
+
804
+ **The doubles refuse what the host refuses.** `makeMockTags()` throws on `tagEpisode` unless you pass
805
+ `{ writesEpisodes: true }`, and its `untagEpisode` leaves a `withFeedTag` assignment alone. `makeMockBlobs`
806
+ enforces the ceilings and the allow-list. A component that has only ever met a permissive double meets its
807
+ first refusal in front of a podcaster.
808
+
809
+ **The extension-point harnesses** (Java): `SearchProviderHarness` calls a provider once per role including
810
+ anonymous; `UserDataHandlerHarness.eraseTwice(userId)` proves erasure survives the host's retry;
811
+ `PageRouteProviderHarness.check(...)` answers which subpaths 404, always probing the plugin root. All three
812
+ are shown under
813
+ [Optional extension points](#optional-extension-points--searchprovider-userdatahandler-pagerouteprovider).
814
+
496
815
  ## Contributing
497
816
  Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). In short: `git commit -s` (DCO, required), SPDX header in new files, add tests.
498
817