@mosaicast/plugin-sdk 0.7.1 → 0.9.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 +357 -0
- package/dist/index.d.ts +994 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +341 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +222 -9
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +499 -8
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,52 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
|
104
104
|
- The list is **paginated** (core's standard `PagedResponse` envelope) and **carries keys** (`DocEntry`), because neither end can address a doc without one.
|
|
105
105
|
- `delete` is **idempotent**: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
|
|
106
106
|
|
|
107
|
+
### The typed client — `ctx.docs` (since 0.9.0)
|
|
108
|
+
|
|
109
|
+
`ctx.api` is the raw surface and stays available. `ctx.docs` is the same endpoints with the path building,
|
|
110
|
+
the key validation and the 404 handling done for you — which removes a whole class of bug, since every doc
|
|
111
|
+
access above was string concatenation the plugin had to get right four segments at a time:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await ctx.docs.put('self', `mark:${ctx.scope.id}`, marks); // data/user/me/mark:<slug>
|
|
115
|
+
const board = await ctx.docs.get<Board>('site', 'leaderboard'); // null when absent, not a rejection
|
|
116
|
+
const page = await ctx.docs.list<Marks>('self', { prefix: 'mark:' });
|
|
117
|
+
await ctx.docs.remove(ctx.scope, 'draft'); // any Scope addresses its own partition
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **`'self'` is `data/user/me`** and `'site'` is `data/site/main` — the two singletons. Making the
|
|
121
|
+
per-user partition the *shortest* thing to write is deliberate: it is the most security-relevant
|
|
122
|
+
convention in the contract, and a convention only sticks when it is also the easy path.
|
|
123
|
+
- **`get` resolves `null` on 404**, because a key nothing has written yet is a normal state rather than a
|
|
124
|
+
failure. The same is true of `ctx.api.getOrNull(path)`.
|
|
125
|
+
- **A malformed key throws at the call site**, with `DOC_KEY_PATTERN` in the message, instead of costing a
|
|
126
|
+
400 round-trip whose body you then have to read.
|
|
127
|
+
- Everything else is unchanged and still the host's: both access floors, `backendOwned`, the 400 on an
|
|
128
|
+
unknown scope, the 401 on an anonymous `user` request.
|
|
129
|
+
|
|
130
|
+
### Typed failures — `PluginApiError` (since 0.9.0)
|
|
131
|
+
|
|
132
|
+
Every `ctx.api` method rejects with an error carrying the **status** and the RFC 7807 body. Before 0.9.0 the
|
|
133
|
+
rejection was untyped, so the only way to survive a 404 was `catch(() => undefined)` — which also swallowed
|
|
134
|
+
the 500, the 403 and the network failure, and showed the visitor an empty widget for all four:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { isPluginApiError } from '@mosaicast/plugin-sdk';
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await ctx.docs.put('site', 'stats', computed);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (isPluginApiError(e) && e.status === 403) {
|
|
143
|
+
ctx.log('warn', e.problem?.detail ?? 'refused'); // backendOwned, or the write floor
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw e; // a real failure — surface it
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Use `isPluginApiError`, not `instanceof`: the error is constructed by the host and crosses a bundle
|
|
151
|
+
boundary, so it does not share a constructor with anything in your plugin.
|
|
152
|
+
|
|
107
153
|
### Per-user data — the `user` scope (since 0.5.0)
|
|
108
154
|
|
|
109
155
|
**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 +298,75 @@ const n = await ctx.schema.count('page');
|
|
|
252
298
|
Test it with `makeMockSchema({ page: [...] })` from `/testing`, which records every query. Its `search` is
|
|
253
299
|
a substring match with the same caveat as `FakeSchemaStore`'s.
|
|
254
300
|
|
|
301
|
+
## Episode snapshots — `ctx.feeds` (since 0.9.0)
|
|
302
|
+
|
|
303
|
+
The Java contract could read an episode's display snapshot and the frontend could not, so a plugin that
|
|
304
|
+
wanted to draw an episode card copied the host's own data into its doc store and kept it fresh on a
|
|
305
|
+
schedule — a backend, a scheduled ingest, a `backendOwned` key and a copy that is stale between runs, for
|
|
306
|
+
fields the host already has.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const cards = await ctx.feeds.displayMany(ctx.episodes.slice(0, 20)); // one request, not N
|
|
310
|
+
for (const slug of ctx.episodes) {
|
|
311
|
+
const snap = cards[slug];
|
|
312
|
+
if (!snap) continue; // filtered out for this visitor — normal, not an error
|
|
313
|
+
render(slug, snap.title, resolveArtwork(snap), i18n.duration(snap.duration ?? 0));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const one = await ctx.feeds.display('kraken'); // null when absent or not visible
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- **The host filters, the plugin consumes.** A `WITHDRAWN` or tier-gated episode is **absent** rather than
|
|
320
|
+
redacted, and this cannot enumerate episodes `ctx.episodes` did not already give you.
|
|
321
|
+
- **No `readableBy` gate of its own** — it returns host data the same visitor can read from
|
|
322
|
+
`/api/episodes/*` anyway. It exists so a plugin need not know that URL shape, the same argument
|
|
323
|
+
`ctx.links` makes.
|
|
324
|
+
- **Not authoritative.** The snapshot is overwritten on every feed refetch — that is the feature, and the
|
|
325
|
+
reason to read it live. Cache per render, never per install.
|
|
326
|
+
- `displayMany` **clamps** at 200 slugs rather than erroring, so check what came back.
|
|
327
|
+
|
|
328
|
+
Test it with `makeMockFeeds().withDisplay(slug, snapshot)`, mirroring the Java `FakeFeedAccess.withDisplay`.
|
|
329
|
+
|
|
330
|
+
## Site-wide tags — `ctx.tags` / `ctx.tags()` (since 0.9.0)
|
|
331
|
+
|
|
332
|
+
Tags existed in core only as a feed-derived filter axis over episodes: no vocabulary, no plugin surface. So
|
|
333
|
+
every plugin that wanted tags grew a private free-text column, and a wiki's `lore` and an episode's `lore`
|
|
334
|
+
were unrelated strings that could not be suggested, linked or counted together.
|
|
335
|
+
|
|
336
|
+
Opt in from the manifest — reading and tagging your own subjects is one thing, tagging **episodes** is
|
|
337
|
+
another:
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
"tags": { "readsVocabulary": true, "writesEpisodes": false }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`ctx.tags` (TS) and `ctx.tags()` (Java) are **`null`** without it, the same shape as `schema` and `blobs`.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
const tags = ctx.tags;
|
|
347
|
+
if (!tags) return;
|
|
348
|
+
|
|
349
|
+
for (const t of await tags.all()) suggest(t.label, t.tag); // the site's real vocabulary
|
|
350
|
+
await tags.tagSubject(`page:${slug}`, 'Maritime Lore'); // your own namespace
|
|
351
|
+
const also = await tags.episodesWith('maritime lore'); // what else is about this
|
|
352
|
+
a.href = ctx.links.feed('main', { tag: 'maritime lore' }); // and it links to the feed view
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- **`tag` is the canonical key** (trim, collapse whitespace, casefold), applied on every path into the
|
|
356
|
+
vocabulary including feed ingest; **`label` is presentation**, kept from first use. Send any spelling,
|
|
357
|
+
store and compare on the key.
|
|
358
|
+
- **Tagging an episode is a capability**, not a convenience: it changes the shell's filter options *and*
|
|
359
|
+
what core recommends beside that episode. Hence the second flag.
|
|
360
|
+
- **What no plugin may do:** delete a tag from the vocabulary (it is shared), rename one (a vocabulary-wide
|
|
361
|
+
edit, and admin's), or remove another writer's assignment — the feed's included. Every plugin write
|
|
362
|
+
carries `source = plugin:<id>`, which makes the last one enforceable rather than merely discouraged.
|
|
363
|
+
- A tag stops existing when nothing carries it.
|
|
364
|
+
|
|
365
|
+
`subjectKey` is opaque and yours to invent — the same namespacing `ctx.schema` has for tables. Use the same
|
|
366
|
+
key a `SearchProvider` hit resolves to, so a tag and a search result name one object.
|
|
367
|
+
|
|
368
|
+
Test with `makeMockTags({ writesEpisodes })` / `FakeTags`, both of which refuse what the host refuses.
|
|
369
|
+
|
|
255
370
|
## Deep links & navigation — `ctx.route` (navigate since 0.7.0)
|
|
256
371
|
|
|
257
372
|
A plugin declaring a `page` slot owns the URL subtree `/p/<pluginId>/*`. The host hands it the subpath
|
|
@@ -281,6 +396,221 @@ ctx.route.navigate('index', { replace: true }); // no back-button step (ta
|
|
|
281
396
|
- Pair it with `ShareMetadataProvider` (OpenGraph per subpath) and `SitemapProvider` on the backend so the
|
|
282
397
|
URLs you navigate to also preview and index properly.
|
|
283
398
|
|
|
399
|
+
### Reading the query, and matching a route (since 0.9.0)
|
|
400
|
+
|
|
401
|
+
`navigate` always accepted a `?query`, and until 0.9.0 there was no supported way to read one back —
|
|
402
|
+
`location.search` is exactly the "do not reach past this handle" the rule above forbids. Filters, sort
|
|
403
|
+
order and pagination are the obvious shareable-link state:
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
const sort = ctx.route.query.get('sort') ?? 'newest'; // host-parsed URLSearchParams
|
|
407
|
+
ctx.route.navigate(`moments?sort=${next}`, { replace: true });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`matchRoute` replaces the prefix matcher every page plugin hand-rolls — and the hand-rolled one is usually
|
|
411
|
+
a `startsWith`, which matches `moments-archive` too:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { matchRoute } from '@mosaicast/plugin-sdk';
|
|
415
|
+
|
|
416
|
+
const match = matchRoute(ctx.route.path, ['', 'moments', 'highlight/:slug'] as const);
|
|
417
|
+
switch (match?.pattern) {
|
|
418
|
+
case 'highlight/:slug': return renderDetail(match.params.slug);
|
|
419
|
+
case 'moments': return renderMoments();
|
|
420
|
+
case '': return renderIndex();
|
|
421
|
+
default: return renderNotFound(); // null — nothing matched
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
The whole path must be consumed, `:param` captures one non-empty segment (decoded), surrounding slashes and
|
|
426
|
+
any `?query`/`#hash` are ignored, and the **first** matching pattern wins.
|
|
427
|
+
|
|
428
|
+
### Linking to core pages — `ctx.links` (since 0.8.0)
|
|
429
|
+
|
|
430
|
+
`navigate` deliberately cannot name a core route. Producing a *link* to one is a different thing — the
|
|
431
|
+
visitor still clicks — and `ctx.links` gives you the host's URL shapes instead of a hardcoded string that
|
|
432
|
+
breaks when a route changes:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
a.href = ctx.links.episode('kraken', { t: 724 }); // /episodes/kraken?t=724
|
|
436
|
+
a.href = ctx.links.feed('main', { season: '2' }); // /feeds/main?season=2
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`?t=` is the host's timestamp deep link: it seeks the player to that second, and beats the listener's
|
|
440
|
+
stored position for that navigation without overwriting it. Frontend only — this is where links get
|
|
441
|
+
rendered. Strings, not navigation: put them in a real `href`.
|
|
442
|
+
|
|
443
|
+
## File storage — `ctx.blobs` (since 0.8.0)
|
|
444
|
+
|
|
445
|
+
A plugin that has to accept a file — a wiki's diagrams, a show-notes image — rather than link to somebody
|
|
446
|
+
else's host. **Declare it or you do not get it**, and what you declare is what an operator sees you asking
|
|
447
|
+
for:
|
|
448
|
+
|
|
449
|
+
```json
|
|
450
|
+
"blobs": { "maxFileBytes": 5242880, "quotaBytes": 268435456,
|
|
451
|
+
"mimeTypes": ["image/png", "image/jpeg", "image/webp"] }
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`ctx.blobs` is `null` without it, exactly like `ctx.schema`. From a Web Component:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
const blobs = ctx.blobs;
|
|
458
|
+
if (!blobs) return;
|
|
459
|
+
|
|
460
|
+
const stored = await blobs.upload(file); // a File from <input type="file">
|
|
461
|
+
img.src = blobs.urlFor(stored.ref);
|
|
462
|
+
await ctx.api.put('data/site/main/logo', { ref: stored.ref });
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
and from a backend, for what only a backend can do — fetching on a schedule, deleting what a document no
|
|
466
|
+
longer points at:
|
|
467
|
+
|
|
468
|
+
```java
|
|
469
|
+
try (InputStream in = Files.newInputStream(path)) {
|
|
470
|
+
BlobInfo stored = ctx.blobs().put("architecture.png", "image/png", in);
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- **Store the `ref`, never the URL.** The URL is derived and the host may change how; the ref is the
|
|
475
|
+
identity.
|
|
476
|
+
- **Writes are the point here**, unlike `ctx.schema`. A file has no relational invariant for plugin code to
|
|
477
|
+
enforce, so `data.writableBy` plus a quota is the whole authorization story and a client may upload
|
|
478
|
+
directly. Reads follow `data.readableBy`.
|
|
479
|
+
- **Uploads are refused for reasons you can predict**: size against your effective ceiling, declared type
|
|
480
|
+
against the effective allow-list, then the *actual* type read from the leading bytes. A file whose content
|
|
481
|
+
contradicts its extension is refused, and SVG is never accepted — it is a script container wearing an
|
|
482
|
+
image's file extension. Call `quota()` to warn someone *before* they pick a 200 MB file.
|
|
483
|
+
- **The operator's numbers win.** They cap both ceilings and intersect the type list with the install's own,
|
|
484
|
+
so you may be granted less than you asked for; `quota()` is the only honest source.
|
|
485
|
+
- **Nothing collects orphans.** A file outlives the document that named it, and only your plugin knows
|
|
486
|
+
which those are.
|
|
487
|
+
- Blobs are served same-origin under `/api/plugins/<id>/blob/<ref>`, so rendering one needs no CSP host and
|
|
488
|
+
makes no consent decision — which an external image URL cannot say.
|
|
489
|
+
|
|
490
|
+
Test it with `makeMockBlobs()` from `/testing` and `InMemoryPluginBlobs` in the Java kit. Both **enforce the
|
|
491
|
+
ceilings and the allow-list**: a component that has only ever met an accepting double meets its first
|
|
492
|
+
refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
|
|
493
|
+
(`rejectContent`) to exercise that path.
|
|
494
|
+
|
|
495
|
+
## Host icons — `iconCss` / `iconMask` (since 0.9.0)
|
|
496
|
+
|
|
497
|
+
The host publishes its icon set as `--mc-icon-*` custom properties, which inherit through the shadow
|
|
498
|
+
boundary — so a plugin built against **this** SDK picks up an icon the day core publishes it, with no SDK
|
|
499
|
+
release and no manifest bump. That is why the icon set is deliberately *not* on `ctx`, and why these
|
|
500
|
+
helpers take a plain `string` rather than a closed union: either would pin the set to an SDK version.
|
|
501
|
+
|
|
502
|
+
What they own is the mechanics, which are subtle enough to get wrong three ways:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { iconCss } from '@mosaicast/plugin-sdk';
|
|
506
|
+
|
|
507
|
+
const style = document.createElement('style');
|
|
508
|
+
style.textContent = iconCss(['star', 'clock']); // put it in YOUR shadow root
|
|
509
|
+
root.append(style);
|
|
510
|
+
root.insertAdjacentHTML('beforeend', '<i class="mc-icon mc-icon-star" aria-hidden="true"></i>');
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
- **Mask, never `background-image`.** `background: currentColor` behind a mask is what makes the icon
|
|
514
|
+
re-theme with the text beside it.
|
|
515
|
+
- **Every reference needs a blank-image fallback.** An unresolved `var()` invalidates the declaration, so
|
|
516
|
+
`mask-image` falls back to its initial `none` — leaving an unmasked element painting `currentColor`
|
|
517
|
+
across its whole box. **A missing icon renders as a solid square, not as nothing**, and `mask-image: none`
|
|
518
|
+
is the obvious fallback and the one that causes it. `iconMask` emits a real blank SVG instead.
|
|
519
|
+
- **A bundled `.css` file lands in the host document and cannot reach any shadow root.** It is the first
|
|
520
|
+
thing everyone tries and it silently does nothing.
|
|
521
|
+
|
|
522
|
+
Don't declare into `--mc-*` yourself — that is the host's namespace.
|
|
523
|
+
|
|
524
|
+
## Formatting — `createPluginI18n` (plurals & units since 0.9.0)
|
|
525
|
+
|
|
526
|
+
`t(key, params)` interpolates; the rest formats against `ctx.locale`:
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
const i18n = createPluginI18n(catalogs, ctx.locale);
|
|
530
|
+
|
|
531
|
+
i18n.plural('moments', n); // catalog keys: moments.one / moments.other / moments.few …
|
|
532
|
+
i18n.n(1234.5); // 1.234,5 in de
|
|
533
|
+
i18n.date(snapshot.publishedAt!); // takes the ISO instants the contract hands over
|
|
534
|
+
i18n.duration(snapshot.duration!); // 'PT1H2M3S' → 1:02:03; also takes plain seconds
|
|
535
|
+
i18n.bytes(quota.usedBytes); // decimal units, locale separator — 5,2 MB in de
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
- **`plural` uses `Intl.PluralRules`.** A catalog could not express "1 highlight" / "5 highlights" at all,
|
|
539
|
+
so plugins shipped an English-shaped `if (n === 1)` — wrong in Polish, Russian and Arabic. Only the
|
|
540
|
+
`.other` form is required; a locale needing one you did not write falls back to it. `count` is
|
|
541
|
+
interpolated for you.
|
|
542
|
+
- **`duration` and `bytes` are contract-adjacent**: `DisplaySnapshot.duration` *is* an ISO-8601 string and
|
|
543
|
+
`BlobQuota` *is* three raw byte counts. The SDK produces both, so it may as well render them — and the
|
|
544
|
+
hand-rolled byte formatter hardcodes `.` as the decimal separator, which is simply wrong in `de`.
|
|
545
|
+
|
|
546
|
+
## The manifest, typed — `defineManifest` (since 0.9.0)
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
import { defineManifest, PLATFORM_API_VERSION } from '@mosaicast/plugin-sdk';
|
|
550
|
+
|
|
551
|
+
export default defineManifest({
|
|
552
|
+
id: 'sample', version: '1.0.0', platformApi: PLATFORM_API_VERSION, name: 'Sample',
|
|
553
|
+
frontend: { entry: 'sample.es.js', elements: ['sample-card'] },
|
|
554
|
+
slots: [{ scope: 'episode', element: 'sample-card', placement: 'main', visibleTo: 'anonymous' }],
|
|
555
|
+
nav: [{ path: '', label: 'Sample', icon: 'star' }],
|
|
556
|
+
data: { writableBy: 'podcaster', readableBy: 'anonymous' },
|
|
557
|
+
});
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
|
|
561
|
+
`ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
|
|
562
|
+
the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
|
|
563
|
+
|
|
564
|
+
What it buys you: emit `plugin.json` from a build step and the manifest stops being a second, unchecked
|
|
565
|
+
copy of what your code already knows. `nav[]` and a plugin's own in-page tab bar are the same list of
|
|
566
|
+
entrances declared twice, and a renamed path otherwise becomes a menu entry leading to an empty view with
|
|
567
|
+
nothing to catch it. Note where the two *legitimately* diverge: `path`, `icon` and `role` are pinned
|
|
568
|
+
between them, but the menu **label cannot be translated** — core has no plugin catalogs — while your
|
|
569
|
+
in-page tab can.
|
|
570
|
+
|
|
571
|
+
## Optional extension points — `SearchProvider` / `UserDataHandler` (since 0.9.0)
|
|
572
|
+
|
|
573
|
+
Both are `ExtensionPoint`s a plugin MAY implement alongside `PluginBackend`, like `SitemapProvider`. No
|
|
574
|
+
manifest declaration: not implementing one is how a plugin says it has nothing to contribute.
|
|
575
|
+
|
|
576
|
+
**`SearchProvider`** puts plugin content into the site's search, instead of each plugin growing a private
|
|
577
|
+
search box a visitor has no way to discover:
|
|
578
|
+
|
|
579
|
+
```java
|
|
580
|
+
public List<SearchHit> search(String query, Role role, int limit) {
|
|
581
|
+
return pages.matching(query, role).stream()
|
|
582
|
+
.map(p -> new SearchHit("glossary/" + p.slug(), p.title(), p.excerpt(), p.rank()))
|
|
583
|
+
.toList();
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
`subpath` resolves to `/p/<pluginId>/<subpath>`, exactly as `ShareMetadataProvider.metaFor` works in
|
|
588
|
+
reverse, so a hit is a real deep-linkable URL and the host keeps the URL shape. Results are **grouped by
|
|
589
|
+
source**, not merged — your `score` and Postgres `ts_rank` are not on one scale — and a slow provider costs
|
|
590
|
+
its own section, not the visitor's query.
|
|
591
|
+
|
|
592
|
+
**Access is your job here, unusually.** Everywhere else the host resolves access; it cannot for objects it
|
|
593
|
+
has no model of. A provider returning a draft page to an anonymous visitor is a leak the host will not
|
|
594
|
+
catch, so `role` is `null` for anonymous and `SearchProviderHarness` calls you once per role:
|
|
595
|
+
|
|
596
|
+
```java
|
|
597
|
+
var results = new SearchProviderHarness(new WikiSearch(store)).search("kraken");
|
|
598
|
+
assertFalse(results.leakedToAnonymous("draft/lighthouse"));
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**`UserDataHandler`** is what §12's promise needs to be keepable — core cannot pseudonymise a plugin's
|
|
602
|
+
contributions, cannot find them, and cannot know that pseudonymising is right rather than deleting:
|
|
603
|
+
|
|
604
|
+
```java
|
|
605
|
+
public void eraseUser(String userId) { revisions.anonymise(userId); } // or a hard delete — your call
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
The `USER` scope is host-owned, so core drops `data/user/<id>/…` itself. Implement this for the hard half:
|
|
609
|
+
**schema columns and blobs**, where identity lives in a column a plugin chose and the host never learned
|
|
610
|
+
which one is a person. Handlers run *before* the account row goes, and **must be idempotent** — a failed
|
|
611
|
+
deletion is retried. `UserDataHandlerHarness.eraseTwice(userId)` is that test, and it is the one authors
|
|
612
|
+
skip: the second call is the one that throws, during a retry, when the alternative is a half-done deletion.
|
|
613
|
+
|
|
284
614
|
## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
|
|
285
615
|
|
|
286
616
|
```java
|
|
@@ -426,6 +756,33 @@ expect(el.shadowRoot!.querySelector('.card')).not.toBeNull();
|
|
|
426
756
|
// el.ctx.api is a MockApiClient: assert el.ctx.api.calls after interactions
|
|
427
757
|
```
|
|
428
758
|
|
|
759
|
+
**Awaiting a component's fetches** (`flushMockApi`, since 0.9.0). The mock resolves its promise before the
|
|
760
|
+
component's `.then(setState)` runs, so `await Promise.resolve()` covers one hop and not two — which is why
|
|
761
|
+
the symptom is an assertion that fails *only sometimes*, depending on how many hops the component happens
|
|
762
|
+
to have:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
mount(ctx);
|
|
766
|
+
await flushMockApi(ctx.api); // waits for the calls, then drains the hops behind them
|
|
767
|
+
expect(root.querySelector('.title')?.textContent).toBe('The Kraken');
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Driving a failure.** `apiError(status, problem)` as a canned response makes the client reject with the
|
|
771
|
+
host's error shape, so a component's 404 branch and its 500 branch are separately testable:
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
const ctx = makeMockCtx({ apiResponses: { 'get data/site/main/stats': apiError(403, { detail: 'backendOwned' }) } });
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
**The doubles refuse what the host refuses.** `makeMockTags()` throws on `tagEpisode` unless you pass
|
|
778
|
+
`{ writesEpisodes: true }`, and its `untagEpisode` leaves a `withFeedTag` assignment alone. `makeMockBlobs`
|
|
779
|
+
enforces the ceilings and the allow-list. A component that has only ever met a permissive double meets its
|
|
780
|
+
first refusal in front of a podcaster.
|
|
781
|
+
|
|
782
|
+
**The extension-point harnesses** (Java): `SearchProviderHarness` calls a provider once per role including
|
|
783
|
+
anonymous; `UserDataHandlerHarness.eraseTwice(userId)` proves erasure survives the host's retry. Both are
|
|
784
|
+
shown under [Optional extension points](#optional-extension-points--searchprovider--userdatahandler-since-090).
|
|
785
|
+
|
|
429
786
|
## Contributing
|
|
430
787
|
Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). In short: `git commit -s` (DCO, required), SPDX header in new files, add tests.
|
|
431
788
|
|