@mosaicast/plugin-sdk 0.2.0 → 0.4.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
@@ -2,6 +2,18 @@
2
2
 
3
3
  > Versioned plugin contract SDK (Java `plugin-api` + `@mosaicast/plugin-sdk` TS) plus a test kit.
4
4
 
5
+ [![CI](https://github.com/Mosaicast/mosaicast-plugin-sdk/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Mosaicast/mosaicast-plugin-sdk/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/@mosaicast/plugin-sdk?logo=npm)](https://www.npmjs.com/package/@mosaicast/plugin-sdk)
7
+ [![GitHub Packages](https://img.shields.io/github/v/release/Mosaicast/mosaicast-plugin-sdk?include_prereleases&label=github%20packages&logo=github&color=2ea44f)](https://github.com/Mosaicast/mosaicast-plugin-sdk/packages)
8
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Mosaicast/mosaicast-plugin-sdk/badge)](https://scorecard.dev/viewer/?uri=github.com/Mosaicast/mosaicast-plugin-sdk)
9
+ [![License](https://img.shields.io/github/license/Mosaicast/mosaicast-plugin-sdk?color=blue)](LICENSE)
10
+
11
+ <!-- Badges are dynamic: CI reflects the latest master run; npm/GitHub-Packages track the published
12
+ version (GitHub Packages via the release tag — Maven coords and releases move together); the
13
+ OpenSSF Scorecard badge is populated by .github/workflows/scorecard.yml. No version is hardcoded.
14
+ The GitHub Packages badge uses include_prereleases because releases are tagged as pre-releases
15
+ while the SDK is pre-1.0. -->
16
+
5
17
  Part of **[Mosaicast](https://github.com/mosaicast)** — an extensible website platform for podcasts. Status: **v1 in development**.
6
18
 
7
19
  This repo is the **hard contract boundary** of the whole system: core AND every plugin compile against it, and it depends on neither. See `docs/ARCHITECTURE.md` for the big picture and `docs/BRIEF.md` for this repo's scope.
@@ -17,7 +29,11 @@ src/testing.ts TS: @mosaicast/plugin-sdk/testing — makeMockCtx
17
29
 
18
30
  The contract version is a **single SemVer anchor** mirrored in four places that MUST move together
19
31
  (CI enforces it): `build.gradle.kts` · `package.json` · `PlatformApi.VERSION` · `PLATFORM_API_VERSION`.
20
- A breaking change is a major bump; the host rejects plugins with an incompatible `platformApi` at startup.
32
+
33
+ **How the host matches it:** core compares `major.minor` **exactly**. A plugin declaring `0.3.x` is
34
+ rejected the moment the host runs `0.4.0` — with the reason shown in the admin log viewer. While the SDK
35
+ is pre-1.0 a breaking change is therefore a **minor** bump (`0.3.0` → `0.4.0`), not a major one; from
36
+ `1.0.0` on, normal SemVer applies and breaking means major.
21
37
 
22
38
  ## Build & test
23
39
  ```bash
@@ -33,8 +49,8 @@ npm ci && npm run build # TypeScript: src → dist (.js + .d.ts)
33
49
  - Released: from **GitHub Packages** (see below).
34
50
  ```kotlin
35
51
  dependencies {
36
- compileOnly("dev.mosaicast:plugin-api:0.2.0") // contract, provided by the host
37
- testImplementation("dev.mosaicast:plugin-testkit:0.2.0") // test doubles only
52
+ compileOnly("dev.mosaicast:plugin-api:0.4.0") // contract, provided by the host
53
+ testImplementation("dev.mosaicast:plugin-testkit:0.4.0") // test doubles only
38
54
  }
39
55
  ```
40
56
  Sources + Javadoc JARs give IDE hover docs automatically.
@@ -57,6 +73,208 @@ repositories {
57
73
  ```
58
74
  The token is any GitHub PAT with `read:packages`. (Anonymous-pull via Maven Central is a possible future move.)
59
75
 
76
+ ## Plugin data access (v1) — plugins do not define HTTP routes
77
+
78
+ This is the part plugin authors most often guess wrong, so it is stated plainly. **A plugin's server side is `register(ctx)` — that is the whole of it.** There is no route-registration or HTTP-handler API in the contract, and its absence is a design decision, not a gap (ARCHITECTURE §7.4/§7.5; the generic doc store is the default).
79
+
80
+ **Backend.** A plugin persists *everything* through `ctx.store()` — the hard-scoped `DocStore`, addressed by `(Scope, key)` — and does any aggregation in `ctx.onSchedule(...)`. (The only alternative is a relational schema *declared in the manifest* and reached via `ctx.schema()`; still not a route.)
81
+
82
+ **Frontend.** A Web Component reaches plugin data through `ctx.api` (`PluginApiClient`). Those calls do **not** hit plugin-authored routes — they hit a fixed, generic surface the **host** exposes over that same doc store, namespaced per plugin. It mirrors `DocStore` one-to-one — `get` / `put` / `list` / `delete`, and no more:
83
+
84
+ ```text
85
+ GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
86
+ → one JSON doc; 404 if absent
87
+ GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
88
+ → { items: [{ key, value }], page, size, totalElements, totalPages }
89
+ PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
90
+ → upsert, last-write-wins
91
+ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
92
+ → remove; idempotent
93
+ ```
94
+
95
+ | Operation | Backend (Java) | Frontend (TS) |
96
+ |---|---|---|
97
+ | read one | `store().get(scope, key, T.class)` → `Optional<T>` | `ctx.api.get<T>('data/…/{key}')` |
98
+ | list by prefix | `store().query(scope, prefix)` → `List<DocEntry>` | `ctx.api.get<PagedDocs<T>>('data/…?prefix=…')` |
99
+ | upsert | `store().put(scope, key, value)` | `ctx.api.put('data/…/{key}', value)` |
100
+ | remove | `store().delete(scope, key)` → `boolean` | `ctx.api.delete('data/…/{key}')` |
101
+
102
+ - `scopeType` is `site | feed | season | episode` and `scopeId` is that entity's id — mirroring `Scope`/`ScopeType`, the same addressing the backend uses. **`site`'s `scopeId` is always `main`** (`Scope.SITE_ID`): there is only one site, so both the SDK and the host normalize every site scope to that singleton — `Scope.site()` and `…/data/site/main/{key}` address the same document, and the path always has four non-empty segments.
103
+ - `key` must match `DocStore.KEY_PATTERN` = `^[A-Za-z0-9._:-]{1,200}$` and is the final path segment, verbatim — no `/`, and no percent-encoded slash either (servlet containers reject `%2F` there). Structure keys with `:` / `.` / `-` instead, e.g. `mark:userId:cell`. The host answers 400 on a bad key, and `InMemoryDocStore` throws `IllegalArgumentException` — so it fails in your tests, not in production.
104
+ - The list is **paginated** (core's standard `PagedResponse` envelope) and **carries keys** (`DocEntry`), because neither end can address a doc without one.
105
+ - `delete` is **idempotent**: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
106
+
107
+ **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}`.
108
+
109
+ **What the host enforces**, so a plugin doesn't have to:
110
+ - Data is **hard-scoped to the plugin id** — a plugin can only ever see its own data.
111
+ - **Reads** are gated by the slot's `visibleTo`; **writes** require the mapped role. The host validates that the scope exists (and that the feed is enabled). `ctx.api` carries the user's auth (session or personal access token).
112
+
113
+ **No request-time server logic.** A write is plain persistence — no plugin code runs on the request. Anything derived, validated or aggregated server-side is **precomputed** in `register`/`onSchedule` and read back from the store.
114
+
115
+ > Roadmap: custom plugin-defined server routes may arrive in a later `platformApi` version; plugins use the doc store.
116
+
117
+ ## Relational storage — `ctx.schema()` (since 0.4.0)
118
+
119
+ The doc store is the default and covers nearly everything. Declare a **schema** when you need what a JSON
120
+ document cannot give you: full-text search, revisions, backlinks. Any plugin may; most declare nothing.
121
+
122
+ You declare entities and fields in the manifest, and the **platform** provisions namespaced tables
123
+ (`plugin_<id>_*`) through its managed migration runner, dropping them when an admin purges the plugin.
124
+ **The plugin never writes DDL and never names a table:**
125
+
126
+ ```json
127
+ "storage": { "schema": { "page": {
128
+ "slug": "string:indexed:unique", "title": "string",
129
+ "markdown": "text:fulltext", "updatedAt": "timestamp:indexed" } } }
130
+ ```
131
+
132
+ ```java
133
+ record Page(long id, String slug, String title, String markdown, Instant updatedAt) {}
134
+
135
+ SchemaStore schema = ctx.schema(); // null unless the manifest declares one
136
+
137
+ long id = schema.insert("page", Map.of(
138
+ "slug", "getting-started", "title", "Getting started",
139
+ "markdown", "# Hello", "updatedAt", Instant.now()));
140
+
141
+ Optional<Page> byId = schema.find("page", id, Page.class);
142
+ List<Page> recent = schema.select("page",
143
+ Criteria.all().orderBy("updatedAt", Direction.DESC).limit(20), Page.class);
144
+ List<Page> hits = schema.search("page", "markdown", "lighthouse", Criteria.all(), Page.class);
145
+ long total = schema.count("page", Criteria.all());
146
+ schema.update("page", id, Map.of("markdown", "# Hello again"));
147
+ schema.delete("page", Criteria.where("slug", Op.EQ, "getting-started"));
148
+ ```
149
+
150
+ - Everything is addressed by **declared entity and field name — never SQL, never a table**. That is the
151
+ scoping guarantee: reaching another plugin's tables isn't blocked, it's inexpressible. The host binds
152
+ every value as a JDBC parameter and checks every field name against your manifest.
153
+ - Rows map to **your own record types**, component names matching field names — the same convention as
154
+ `store().get(...)` and `config().get(...)`. Each entity carries a platform-assigned `long id`.
155
+ - `Criteria` is immutable; predicates combine with **AND** (no `or` in 0.4.0). `search` needs a field
156
+ declared `:fulltext`; for a plain match use `Op.LIKE` with `select`.
157
+ - Naming an undeclared entity or field throws `IllegalArgumentException` — against the test kit too, so a
158
+ manifest that drifted from the code fails in your tests.
159
+
160
+ Test it with `FakeSchemaStore`, declaring the same entities your manifest does. Its `search` is a
161
+ substring match, **not** Postgres full-text: no stemming, no ranking. Assert on which rows come back, not
162
+ on their order.
163
+
164
+ ## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
165
+
166
+ ```java
167
+ ctx.logger().info("indexed {} pages", count); // org.slf4j.Logger, named plugin.<pluginId>
168
+ ```
169
+ ```ts
170
+ ctx.log('warn', `no board for episode ${ctx.scope.id}`);
171
+ ```
172
+
173
+ A plain SLF4J `Logger` on the backend: authors know the API, and parameterised messages and throwables
174
+ come free. **Attribution rides in the logger name**, which is why the host hands you a named logger rather
175
+ than offering `log(level, message)` — the name travels with the event, so output is still attributed to
176
+ your plugin from a thread you started or from an `onSchedule` task, where a thread-local MDC arrives
177
+ empty. Don't build your own `LoggerFactory.getLogger(...)`: it won't sit under the `plugin.` prefix and
178
+ the host can't attribute it.
179
+
180
+ The host stores `info` and above, surfaces `warn` and above in the admin log viewer, and **rate-limits**
181
+ this path. On the frontend, `ctx.log` replaces POSTing to `/api/plugins/{id}/log` through `ctx.api`.
182
+ `FakePluginContext.logger()` returns a `RecordingLogger`, so a test asserts on logging the way it asserts
183
+ on stored documents; `makeMockCtx` collects `ctx.logs`.
184
+
185
+ ## Consent & the manifest (core-owned schema)
186
+
187
+ Core runs **banner-free** — strictly necessary cookies need no consent. The only reason a visitor sees a
188
+ consent prompt is a plugin that asked for one, so treat it as the cost it is.
189
+
190
+ ### `ctx.consent` (frontend)
191
+
192
+ ```ts
193
+ if (ctx.consent.has('analytics')) mountChart(root);
194
+ else button.onclick = async () => {
195
+ if (await ctx.consent.request('analytics')) mountChart(root); // from a user gesture, never on mount
196
+ };
197
+
198
+ const off = ctx.consent.onChange(() => rerender()); // fires on every change
199
+ return off; // detach on cleanup
200
+ ```
201
+
202
+ `request(category)` opens the host's settings for that one purpose and resolves with the visitor's answer
203
+ (`false` if dismissed). `granted()` lists everything currently granted. `onChange` fires on **every**
204
+ change — including a withdrawal made in the settings page while you are mounted — and returns an
205
+ unsubscribe. Since 0.4.0 `filter`, `route`, `locale` and `player.on` return one too.
206
+
207
+ **Services describe, categories decide.** The manifest is per-service (`provider`, `privacyUrl`,
208
+ `thirdCountryTransfer`, each `storage[]` item) but every `ctx.consent` method takes a **category**. The
209
+ per-service detail exists so the notice can name who stores what for how long; what the visitor toggles is
210
+ the category. So if two plugins each declare an `analytics` service with different providers, the visitor
211
+ sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
212
+ provider and refuse the other. If you need your own services to be refusable independently, declare them
213
+ under **different categories** (a plugin-declared category is fine; the shell just has no translated label
214
+ for it). That is the only lever the contract gives you.
215
+
216
+ **`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
217
+ it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
218
+ service `necessary` means "this loads unconditionally"; use it only for what genuinely cannot be refused.
219
+
220
+ **Concurrent `request()` calls.** A page of plugin tiles will produce them, so the contract is explicit:
221
+ there is **one consent surface host-wide** (a second call joins the one in flight rather than opening
222
+ another); the visitor decides **all** categories in one interaction, so other categories may move too;
223
+ every call resolves exactly once and is never dropped; and grants are **shared across plugins** — another
224
+ plugin's accepted `request('analytics')` resolves yours too. Don't serialize calls or build a queue, and
225
+ re-read `has(...)` after a change instead of caching what a request resolved with.
226
+
227
+ ### `consent.services[]` in `plugin.json`
228
+
229
+ The manifest type is **core-owned** — the SDK has no manifest type and will not grow one — but this is
230
+ where plugin authors look, so the required shape is documented here. **From `0.4.0` the legacy
231
+ `{ "categories": [...], "externalSources": [...] }` form is rejected** and the plugin will not load.
232
+
233
+ ```json
234
+ "consent": {
235
+ "services": [{
236
+ "id": "plausible",
237
+ "name": "Plausible Analytics",
238
+ "provider": "Plausible Insights OÜ",
239
+ "category": "necessary | functional | analytics | <plugin-declared>",
240
+ "privacyUrl": "https://plausible.io/privacy",
241
+ "hosts": ["https://plausible.example"],
242
+ "thirdCountryTransfer": false,
243
+ "storage": [
244
+ { "name": "plausible_ignore", "type": "cookie | localStorage | sessionStorage",
245
+ "purpose": "Remembers that you opted out of statistics", "duration": "persistent | session | 12 months" }
246
+ ]
247
+ }]
248
+ }
249
+ ```
250
+
251
+ | Field | Meaning |
252
+ |---|---|
253
+ | `id` | Stable identifier for this service within your plugin. |
254
+ | `name` | The service as a visitor would recognise it, e.g. "Plausible Analytics". |
255
+ | `provider` | The **legal entity** operating it — the company name, not your plugin's. |
256
+ | `category` | The consent category the service falls under; what `ctx.consent.has(...)` gates on. **`necessary` is always granted and never prompted for.** Two services sharing a category are decided together. |
257
+ | `privacyUrl` | The provider's own privacy policy. |
258
+ | `hosts` | Every origin the service is contacted on. **Also the CSP allow-list** — see below. |
259
+ | `thirdCountryTransfer` | Whether personal data leaves the EU/EEA. |
260
+ | `storage[]` | Each item the service stores on the device: `name`, `type`, `purpose`, `duration`. |
261
+
262
+ **Why the shape changed.** A cookie notice that satisfies §25 TDDDG / Art. 5(3) ePD has to name each
263
+ stored item, what it is for, how long it lasts, who the provider is, and whether data leaves the country.
264
+ Category slugs and bare hostnames cannot produce that — and they force the notice to talk about "plugins"
265
+ to visitors who only care about cookies and named companies.
266
+
267
+ **`hosts` doubles as the CSP allow-list.** Core widens `script-src` / `frame-src` / `connect-src` by
268
+ exactly these origins. An origin you did not declare **stays blocked even after consent is given** — if a
269
+ third-party embed silently fails to load with consent granted, an undeclared host is the first thing to
270
+ check.
271
+
272
+ **Authoring help:** the SDK exports `ConsentServiceDeclaration` (and `ConsentStorageDeclaration`) as a
273
+ **documentation-only** type. Nothing in the SDK reads `plugin.json` or validates it — the host owns and
274
+ enforces the manifest — but typing a literal against it gives you completion and catches a typo before
275
+ core rejects the plugin at load, which is otherwise your first feedback. If the type and core ever
276
+ disagree, **core wins**; treat the mismatch as an SDK bug. The manifest as a whole stays core-owned.
277
+
60
278
  ## Releasing (maintainers)
61
279
  Publishing is automated and fires on a **published GitHub Release**, not on PR merge
62
280
  (`.github/workflows/release.yml`).
@@ -74,7 +292,7 @@ Publishing is automated and fires on a **published GitHub Release**, not on PR m
74
292
  var ctx = new FakePluginContext(); // in-memory store, config, feeds; sync onSchedule
75
293
  myPlugin.register(ctx); // exercise the backend
76
294
  assertEquals(Optional.of("world"),
77
- ctx.store().get(Scope.site("main"), "hello", String.class));
295
+ ctx.store().get(Scope.site(), "hello", String.class));
78
296
  ```
79
297
 
80
298
  **TypeScript** (`@mosaicast/plugin-sdk/testing`, jsdom):
package/dist/index.d.ts CHANGED
@@ -11,12 +11,30 @@
11
11
  * The plugin contract version (SemVer).
12
12
  *
13
13
  * Mirror of the Java `dev.mosaicast.plugin.api.PlatformApi.VERSION` constant and the npm package
14
- * version. The host rejects a plugin whose manifest `platformApi` is incompatible with this value
15
- * (ARCHITECTURE §7.2). **These move together — a breaking change is a major bump.**
14
+ * version — **these move together**, and CI fails the build if they drift.
15
+ *
16
+ * The host compares a plugin manifest's `platformApi` against this value on **`major.minor` exactly** and
17
+ * rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
18
+ * therefore a *minor* bump; from `1.0.0` on, breaking means major.
16
19
  */
17
- export declare const PLATFORM_API_VERSION: "0.2.0";
20
+ export declare const PLATFORM_API_VERSION: '0.4.0';
18
21
  /** A user's role (ARCHITECTURE §8.5). Anonymous visitors have no role (`user` is `null`). */
19
22
  export type Role = 'admin' | 'podcaster' | 'fan';
23
+ /**
24
+ * The severity of a {@link PluginContext.log} call.
25
+ *
26
+ * Mirrors the SLF4J levels a plugin backend gets through the Java `ctx.logger()`, minus `trace` — a
27
+ * browser has no use for it, and the host would drop it anyway.
28
+ */
29
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
30
+ /**
31
+ * Unsubscribes a callback registered through one of the `onChange` handles.
32
+ *
33
+ * Call it when your component tears down — the cleanup callback returned from your
34
+ * {@link MosaicastRender} is the natural place. A subscription that outlives its render keeps a
35
+ * reference to a detached DOM tree and keeps firing into it.
36
+ */
37
+ export type Unsubscribe = () => void;
20
38
  /** The level plus concrete entity a view is scoped to (ARCHITECTURE §6.1). */
21
39
  export interface Scope {
22
40
  /** The scope level. */
@@ -67,11 +85,89 @@ export interface ThemeTokens {
67
85
  border: string;
68
86
  }
69
87
  /**
70
- * A thin authenticated REST client for the plugin's own backend.
88
+ * One document in the plugin's doc store: its key plus its value.
89
+ *
90
+ * Mirror of the Java `dev.mosaicast.plugin.api.DocEntry` record, and the element type of the host's
91
+ * paged list endpoint — the key is carried because you cannot address a document without it.
92
+ */
93
+ export interface DocEntry<T = unknown> {
94
+ /** The document's key within its scope; matches `^[A-Za-z0-9._:-]{1,200}$`. */
95
+ key: string;
96
+ /** The document's JSON value. */
97
+ value: T;
98
+ }
99
+ /**
100
+ * The host's standard paged envelope, as returned by the doc-store list endpoint
101
+ * (`GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=`).
102
+ */
103
+ export interface PagedDocs<T = unknown> {
104
+ /** The documents on this page, keyed. */
105
+ items: DocEntry<T>[];
106
+ /** The zero-based page index. */
107
+ page: number;
108
+ /** The requested page size. */
109
+ size: number;
110
+ /** Total number of matching documents across all pages. */
111
+ totalElements: number;
112
+ /** Total number of pages. */
113
+ totalPages: number;
114
+ }
115
+ /**
116
+ * A thin authenticated REST client for the **host-provided** endpoints of this plugin's namespace.
71
117
  *
72
- * All paths are relative to the plugin's base (`/api/plugins/<id>/`); the host attaches the auth token
73
- * and base path. Methods reject on non-2xx responses (RFC 7807 `application/problem+json` body,
74
- * ARCHITECTURE §13).
118
+ * All paths are relative to the plugin's base (`/api/plugins/<id>/`); the host attaches the base path and
119
+ * the user's auth (session or personal access token). Methods reject on non-2xx responses (RFC 7807
120
+ * `application/problem+json` body, ARCHITECTURE §13).
121
+ *
122
+ * **These are not plugin-authored routes.** A v1 plugin cannot declare HTTP endpoints — its server side
123
+ * is `register(ctx)` and nothing else (see the Java `PluginBackend`). What this client talks to is a
124
+ * fixed, generic surface the host exposes over the plugin's hard-scoped doc store, mirroring the Java
125
+ * `DocStore` one-to-one — get / put / list / delete, nothing more:
126
+ *
127
+ * ```text
128
+ * GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
129
+ * → one JSON doc; 404 if absent
130
+ * GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
131
+ * → { items: [{ key, value }], page, size, totalElements, totalPages }
132
+ * PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
133
+ * → upsert, last-write-wins
134
+ * DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
135
+ * → remove; idempotent
136
+ * ```
137
+ *
138
+ * `scopeType` is `site | feed | season | episode` and `scopeId` the id of that entity — i.e. the
139
+ * {@link Scope} the backend addresses with. For `site` the id is the literal `main` (one site, one
140
+ * singleton scope), so the path always has four non-empty segments. `key` must match
141
+ * `^[A-Za-z0-9._:-]{1,200}$` — the host answers 400 otherwise — and is the final path segment verbatim:
142
+ * no `/`, so structure keys with `:` / `.` / `-` (e.g. `mark:userId:cell`). The list is paginated
143
+ * ({@link PagedDocs}) and carries each doc's key ({@link DocEntry}), since you cannot address a doc
144
+ * without it.
145
+ *
146
+ * The doc a backend writes with `ctx.store().put(scope, key, value)` is the one read here at
147
+ * `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends.
148
+ *
149
+ * Data is hard-scoped to the plugin id — a plugin can only ever see its own. Reads are gated by the
150
+ * slot's `visibleTo`, writes require the mapped {@link Role}, and the host validates that the scope
151
+ * exists. A write is plain persistence: no plugin code runs at request time, so anything derived or
152
+ * validated server-side must be precomputed in the backend's `register`/`onSchedule` and read back from
153
+ * the store.
154
+ *
155
+ * Custom plugin-defined server routes may arrive in a later `platformApi` version; v1 plugins use the
156
+ * doc store.
157
+ *
158
+ * @example Read, list, write and remove docs from a Web Component
159
+ * ```ts
160
+ * const base = `data/${ctx.scope.type}/${ctx.scope.id}`; // scope.id is `main` on the site scope
161
+ * const key = `board:${ctx.user!.id}`; // no `/` in keys
162
+ *
163
+ * const board = await ctx.api.get<Board>(`${base}/${key}`); // rejects with a 404 problem if absent
164
+ * await ctx.api.put(`${base}/${key}`, { ...board, marked }); // upsert, last-write-wins
165
+ *
166
+ * const page = await ctx.api.get<PagedDocs<Board>>(`${base}?prefix=board:&page=0&size=50`);
167
+ * page.items.forEach(({ key, value }) => render(key, value));
168
+ *
169
+ * await ctx.api.delete(`${base}/${key}`); // idempotent
170
+ * ```
75
171
  */
76
172
  export interface PluginApiClient {
77
173
  /** GET a path, resolving to the parsed JSON body. */
@@ -122,15 +218,230 @@ export interface DisplaySnapshot {
122
218
  * @returns the resolved artwork URL, or `undefined` when neither the episode nor the feed declares one
123
219
  */
124
220
  export declare function resolveArtwork(snapshot: DisplaySnapshot): string | undefined;
221
+ /**
222
+ * The consent gate for anything that stores data on the visitor's device or talks to a third party
223
+ * (ARCHITECTURE §12.5).
224
+ *
225
+ * Consent is **denied until granted**. The core itself runs banner-free — strictly necessary cookies need
226
+ * no consent — so the only reason a visitor ever sees a consent prompt is a plugin that asked for one.
227
+ * Treat that as the cost it is.
228
+ *
229
+ * The categories are declared per service in your manifest's `consent.services[]` (`necessary`,
230
+ * `functional`, `analytics`, or one you declare yourself). The host builds the cookie notice from those
231
+ * declarations, and the `hosts` you list there are also the CSP allow-list: **an origin you did not
232
+ * declare stays blocked even after consent is given.**
233
+ *
234
+ * ## The decision is per category, not per service
235
+ *
236
+ * `consent.services[]` is richly per-service — `provider`, `privacyUrl`, `thirdCountryTransfer`, each
237
+ * `storage[]` item — but every method here takes a **category**. That asymmetry is deliberate and worth
238
+ * stating plainly, because the schema implies otherwise: **services describe, categories decide.** The
239
+ * per-service detail exists so the notice can name who stores what for how long; the thing a visitor
240
+ * actually toggles is the category.
241
+ *
242
+ * The consequence: if two plugins each declare a service under `analytics` with different providers, the
243
+ * visitor sees **one** decision listing both plugins, and granting it grants both. There is no way to
244
+ * consent to one provider and withhold the other. Do not build a UI that implies otherwise, and don't
245
+ * assume `has('analytics')` says anything about *which* provider was accepted — it says the category was.
246
+ *
247
+ * If you need a visitor to be able to accept one of your services and refuse another, declare them under
248
+ * **different categories** (a plugin-declared category is allowed, it just has no translated label in the
249
+ * shell). That is the only lever the contract gives you.
250
+ *
251
+ * ## `necessary` is never asked about
252
+ *
253
+ * `has('necessary')` is **always `true`** and the host never prompts for it — it is the category the core
254
+ * itself uses, and a banner-free site stays banner-free. Declaring a service as `necessary` therefore
255
+ * means "this loads unconditionally"; use it only for what genuinely cannot be refused, and expect
256
+ * `request('necessary')` to resolve `true` without showing the visitor anything.
257
+ *
258
+ * @example The click-to-load placeholder this exists for
259
+ * ```ts
260
+ * function render({ ctx, root }: { ctx: PluginContext; root: HTMLElement }) {
261
+ * if (ctx.consent.has('analytics')) {
262
+ * mountChart(root);
263
+ * return;
264
+ * }
265
+ *
266
+ * const button = document.createElement('button');
267
+ * button.textContent = 'Load chart (sets a cookie)';
268
+ * button.onclick = async () => {
269
+ * if (await ctx.consent.request('analytics')) mountChart(root);
270
+ * };
271
+ * root.append(button);
272
+ *
273
+ * // Someone may flip the purpose in the settings page while this is mounted.
274
+ * return ctx.consent.onChange(() => rerender());
275
+ * }
276
+ * ```
277
+ */
278
+ export interface ConsentApi {
279
+ /**
280
+ * Whether the visitor has granted a consent category.
281
+ *
282
+ * Check it before every load of a consent-requiring resource, not once at startup — consent can be
283
+ * withdrawn mid-session from the host's settings page.
284
+ *
285
+ * @param category the consent category, as declared on a service in your manifest
286
+ * @returns `true` if the category is currently granted
287
+ */
288
+ has(category: string): boolean;
289
+ /**
290
+ * Every category currently granted.
291
+ *
292
+ * For rendering a summary ("statistics: on, embeds: off"). Prefer {@link has} for a single gate.
293
+ *
294
+ * Includes `necessary`, which is always granted. These are categories, not service ids — a granted
295
+ * category covers every service declared under it, across all plugins.
296
+ *
297
+ * @returns the granted category names, in no guaranteed order
298
+ */
299
+ granted(): string[];
300
+ /**
301
+ * Asks the visitor to grant a category, and resolves with their answer.
302
+ *
303
+ * The host opens its consent settings for this one purpose and resolves once the visitor decides;
304
+ * dismissing it resolves `false`. **Call this from a user gesture** — the click on your placeholder —
305
+ * and never on mount: an unprompted call turns a banner-free site into one with a banner, which is
306
+ * exactly what §12.5 is arranged to avoid.
307
+ *
308
+ * Resolving `true` means the category is granted from that moment on; it does not load anything for
309
+ * you. Load the resource yourself afterwards.
310
+ *
311
+ * ## What a caller may rely on
312
+ *
313
+ * A page full of plugin tiles will produce concurrent calls, so the contract is explicit about them:
314
+ *
315
+ * - **There is one consent surface, host-wide.** Calling this does not open a dialog of your own, and a
316
+ * second call while one is already open does not open a second — it joins the one in flight. You are
317
+ * asking the host to surface *its* settings, not opening a modal.
318
+ * - **The visitor decides every category at once.** The host's settings cover all declared categories,
319
+ * so one interaction can change several. Your promise still resolves with the state of *the category
320
+ * you asked for* — but other categories may have moved too, which is why {@link onChange} fires for
321
+ * every change rather than only yours.
322
+ * - **Every call resolves exactly once, and always.** Concurrent calls are never dropped or left
323
+ * pending: each resolves when the visitor completes the decision, including calls made while the
324
+ * surface was already open.
325
+ * - **Grants are shared across plugins.** If another plugin's `request('analytics')` is what the
326
+ * visitor accepted, your pending `request('analytics')` resolves `true` too — the decision is per
327
+ * category and site-wide, not per plugin (see the note on granularity above).
328
+ *
329
+ * So: don't serialize your calls, don't build a queue, and don't assume the visitor only answered you.
330
+ * Re-read {@link has} after any change rather than caching what a request resolved with.
331
+ *
332
+ * @param category the consent category to ask for
333
+ * @returns whether the category is granted after the visitor decided
334
+ */
335
+ request(category: string): Promise<boolean>;
336
+ /**
337
+ * Subscribes to consent changes.
338
+ *
339
+ * Fires on **every** change to any category — including one made in the settings page while your
340
+ * component is mounted, and including a withdrawal. It carries no payload: re-read {@link has} or
341
+ * {@link granted} for the current state.
342
+ *
343
+ * @param cb called after each change
344
+ * @returns an {@link Unsubscribe} — return it from your render callback so the subscription dies with
345
+ * the component
346
+ */
347
+ onChange(cb: () => void): Unsubscribe;
348
+ }
349
+ /**
350
+ * One item a service stores on the visitor's device, as declared in `plugin.json`.
351
+ *
352
+ * Part of {@link ConsentServiceDeclaration} — see the caveats there before using either type.
353
+ */
354
+ export interface ConsentStorageDeclaration {
355
+ /** The cookie or storage key exactly as it appears on the device, e.g. `plausible_ignore`. */
356
+ name: string;
357
+ /** Where it is stored. */
358
+ type: 'cookie' | 'localStorage' | 'sessionStorage';
359
+ /** What it is for, in language a visitor reads — not an internal description. */
360
+ purpose: string;
361
+ /** How long it lasts: `session`, `persistent`, or a human duration such as `12 months`. */
362
+ duration: string;
363
+ }
364
+ /**
365
+ * The shape of one entry in your manifest's `consent.services[]`.
366
+ *
367
+ * **This type is documentation, not enforcement.** The manifest is owned and validated by the host — the
368
+ * SDK does not read `plugin.json`, and nothing here runs at build or load time. It exists so an author
369
+ * writing the declaration gets IDE completion and catches a typo before core rejects the plugin at load,
370
+ * which is otherwise the first feedback you get. Two consequences worth knowing:
371
+ *
372
+ * - **The host is authoritative.** If this type and core disagree, core wins. It is kept in step by hand,
373
+ * so treat a mismatch as a bug in the SDK rather than permission to ignore the host.
374
+ * - **It is not a manifest type.** The manifest as a whole stays core-owned and the SDK will not grow one;
375
+ * this covers a single nested shape that got deep enough in 0.4.0 to be worth typing.
376
+ *
377
+ * Use it by typing a literal you keep next to your manifest, or as a reference while writing the JSON:
378
+ *
379
+ * ```ts
380
+ * const services: ConsentServiceDeclaration[] = [{
381
+ * id: 'plausible',
382
+ * name: 'Plausible Analytics',
383
+ * provider: 'Plausible Insights OÜ',
384
+ * category: 'analytics',
385
+ * privacyUrl: 'https://plausible.io/privacy',
386
+ * hosts: ['https://plausible.example'],
387
+ * thirdCountryTransfer: false,
388
+ * storage: [{
389
+ * name: 'plausible_ignore', type: 'localStorage',
390
+ * purpose: 'Remembers that you opted out of statistics', duration: 'persistent',
391
+ * }],
392
+ * }];
393
+ * ```
394
+ */
395
+ export interface ConsentServiceDeclaration {
396
+ /** Stable identifier for this service within your plugin. */
397
+ id: string;
398
+ /** The service as a visitor would recognise it, e.g. `Plausible Analytics`. */
399
+ name: string;
400
+ /** The **legal entity** operating the service — the company, not your plugin. */
401
+ provider: string;
402
+ /**
403
+ * The consent category this service falls under, and therefore what {@link ConsentApi.has} gates on.
404
+ *
405
+ * `necessary` is never prompted for and always granted. Remember that the category — not the service —
406
+ * is what the visitor decides: two services sharing a category are accepted or refused together.
407
+ */
408
+ category: 'necessary' | 'functional' | 'analytics' | (string & {});
409
+ /** The provider's own privacy policy. */
410
+ privacyUrl: string;
411
+ /**
412
+ * Every origin the service is contacted on, scheme included (`https://plausible.example`).
413
+ *
414
+ * **Also the CSP allow-list**: core widens `script-src`/`frame-src`/`connect-src` by exactly these, so
415
+ * an undeclared or bare-hostname origin stays blocked even after consent is given.
416
+ */
417
+ hosts: string[];
418
+ /** Whether personal data leaves the EU/EEA. */
419
+ thirdCountryTransfer: boolean;
420
+ /** Each item the service stores on the visitor's device. */
421
+ storage: ConsentStorageDeclaration[];
422
+ }
125
423
  /**
126
424
  * Everything a frontend plugin is given, set by the host on the mounted custom element
127
425
  * (ARCHITECTURE §7.5). This is the **entire** interface a plugin author must learn.
128
426
  */
129
427
  export interface PluginContext {
130
- /** The scope this plugin instance is mounted in. */
428
+ /**
429
+ * The scope this plugin instance is mounted in. For the `episode` scope, `scope.id` is the episode's
430
+ * **public slug** (e.g. `the-sample-cast-s01e06`) — the same id used in its URL and as the doc-store
431
+ * partition (`data/episode/{slug}/…`), not the internal UUID.
432
+ */
131
433
  scope: Scope;
132
- /** The `EpisodeRef` ids in scope, resolved (and access-filtered) by the host. */
434
+ /**
435
+ * The episode ids in scope, resolved (and access-filtered) by the host — the public **slugs** (the same
436
+ * values used in URLs and doc-store paths). Pair with {@link episodeLabels} for display.
437
+ */
133
438
  episodes: string[];
439
+ /**
440
+ * Human-readable labels for {@link episodes}, keyed by slug (e.g. `"S01E06 · The Lighthouse…"`). The host
441
+ * builds them from the feed's season/episode + title; use them in pickers so users see titles, not slugs.
442
+ * Optional: absent (or partial) when the host does not provide a label for a given episode.
443
+ */
444
+ episodeLabels?: Record<string, string>;
134
445
  /** Present on the `episode` scope: lifecycle status of the current episode. */
135
446
  episode?: {
136
447
  status: 'PLANNED' | 'PUBLISHED' | 'WITHDRAWN';
@@ -140,33 +451,74 @@ export interface PluginContext {
140
451
  id: string;
141
452
  role: Role;
142
453
  } | null;
143
- /** Authenticated client for the plugin's own backend. */
454
+ /**
455
+ * Authenticated client for this plugin's host-provided data endpoints — **not** for plugin-authored
456
+ * routes, which do not exist in v1. It reads and writes the same hard-scoped doc store the backend
457
+ * uses via `ctx.store()`. See {@link PluginApiClient} for the endpoint shape and access rules.
458
+ */
144
459
  api: PluginApiClient;
145
- /** Cookie/consent gate for third-party resources (ARCHITECTURE §12.5). */
146
- consent: {
147
- has(cat: string): boolean;
148
- onChange(cb: () => void): void;
149
- };
150
- /** Read-only access to the host's URL filter state (ARCHITECTURE §6.1). */
460
+ /**
461
+ * Writes one line to the host's log, attributed to this plugin.
462
+ *
463
+ * The counterpart of the backend's `ctx.logger()`, and the **only** supported way for a frontend
464
+ * component to log to the host — do not POST to `/api/plugins/{id}/log` through {@link api}, which is a
465
+ * data endpoint. The host attributes the entry to your plugin, stores `info` and above, surfaces `warn`
466
+ * and above in the admin log viewer, and rate-limits this path: a render loop logging per frame will be
467
+ * throttled, not stored.
468
+ *
469
+ * Messages are read by site operators, not by you — they land next to core's own output. Keep them
470
+ * short, and keep personal data out of them.
471
+ *
472
+ * @param level the severity
473
+ * @param message the message; already-formatted, since there is no placeholder syntax here
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * ctx.log('warn', `no board for episode ${ctx.scope.id}`);
478
+ * ```
479
+ */
480
+ log(level: LogLevel, message: string): void;
481
+ /** Cookie/consent gate for third-party resources — see {@link ConsentApi} (ARCHITECTURE §12.5). */
482
+ consent: ConsentApi;
483
+ /**
484
+ * Read-only access to the host's URL filter state (ARCHITECTURE §6.1).
485
+ *
486
+ * Plugins *consume* filters, they never define them: the axes (season, tags, sorting) belong to the
487
+ * host and live in the URL. `onChange` returns an {@link Unsubscribe}.
488
+ */
151
489
  filter: {
152
490
  current(): FilterState;
153
- onChange(cb: (f: FilterState) => void): void;
491
+ onChange(cb: (f: FilterState) => void): Unsubscribe;
154
492
  };
155
- /** Player position + control, for sync plugins (ARCHITECTURE §6.5). */
493
+ /**
494
+ * Player position + control, for sync plugins (ARCHITECTURE §6.5).
495
+ *
496
+ * `on` returns an {@link Unsubscribe} — player events outlive a single render, so detaching matters
497
+ * here more than anywhere else on this context.
498
+ */
156
499
  player: {
157
500
  currentTime(): number;
158
501
  seekTo(s: number): void;
159
- on(ev: string, cb: (...args: unknown[]) => void): void;
502
+ on(ev: string, cb: (...args: unknown[]) => void): Unsubscribe;
160
503
  };
161
- /** The subpath under `/p/<pluginId>/`, for deep-linkable plugin content (ARCHITECTURE §6.4). */
504
+ /**
505
+ * The subpath under `/p/<pluginId>/`, for deep-linkable plugin content (ARCHITECTURE §6.4).
506
+ *
507
+ * `onChange` returns an {@link Unsubscribe}.
508
+ */
162
509
  route: {
163
510
  path: string;
164
- onChange(cb: (p: string) => void): void;
511
+ onChange(cb: (p: string) => void): Unsubscribe;
165
512
  };
166
- /** The active UI locale (ARCHITECTURE §12.7). */
513
+ /**
514
+ * The active UI locale (ARCHITECTURE §12.7).
515
+ *
516
+ * `onChange` returns an {@link Unsubscribe}. {@link createPluginI18n} subscribes to this for you and
517
+ * hands back a `dispose` to undo it.
518
+ */
167
519
  locale: {
168
520
  current(): string;
169
- onChange(cb: (l: string) => void): void;
521
+ onChange(cb: (l: string) => void): Unsubscribe;
170
522
  };
171
523
  /** Core listening progress in seconds, or `null` if unknown (ARCHITECTURE §6.5). */
172
524
  progress: {
@@ -224,6 +576,14 @@ export interface PluginI18n {
224
576
  t(key: string, params?: Record<string, string | number>): string;
225
577
  /** The currently active locale code. */
226
578
  readonly locale: string;
579
+ /**
580
+ * Detaches the translator from `ctx.locale`.
581
+ *
582
+ * A translator subscribes to locale changes for its whole life. Call this when the component that owns
583
+ * it goes away — from the cleanup callback your render returns — or the subscription keeps a dead
584
+ * translator alive.
585
+ */
586
+ dispose(): void;
227
587
  }
228
588
  /**
229
589
  * Creates a plugin-local translator bound to the host's active locale (ARCHITECTURE §12.7).
@@ -232,6 +592,9 @@ export interface PluginI18n {
232
592
  * change — the same convention as the shell, no extra i18n library required. English (`en`) is the
233
593
  * source language and the fallback.
234
594
  *
595
+ * The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
596
+ * from your render's cleanup callback when the component goes away.
597
+ *
235
598
  * @param catalogs catalogs keyed by locale code
236
599
  * @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
237
600
  * @returns a translator whose `t` and `locale` reflect the currently active locale
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -13,10 +13,13 @@
13
13
  * The plugin contract version (SemVer).
14
14
  *
15
15
  * Mirror of the Java `dev.mosaicast.plugin.api.PlatformApi.VERSION` constant and the npm package
16
- * version. The host rejects a plugin whose manifest `platformApi` is incompatible with this value
17
- * (ARCHITECTURE §7.2). **These move together — a breaking change is a major bump.**
16
+ * version — **these move together**, and CI fails the build if they drift.
17
+ *
18
+ * The host compares a plugin manifest's `platformApi` against this value on **`major.minor` exactly** and
19
+ * rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
20
+ * therefore a *minor* bump; from `1.0.0` on, breaking means major.
18
21
  */
19
- export const PLATFORM_API_VERSION = '0.2.0';
22
+ export const PLATFORM_API_VERSION = '0.4.0';
20
23
  /**
21
24
  * The artwork to display for an episode: its own {@link DisplaySnapshot.imageUrl} if present, otherwise
22
25
  * the {@link DisplaySnapshot.feedImageUrl feed cover}, otherwise `undefined`.
@@ -120,19 +123,27 @@ function interpolate(template, params) {
120
123
  * change — the same convention as the shell, no extra i18n library required. English (`en`) is the
121
124
  * source language and the fallback.
122
125
  *
126
+ * The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
127
+ * from your render's cleanup callback when the component goes away.
128
+ *
123
129
  * @param catalogs catalogs keyed by locale code
124
130
  * @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
125
131
  * @returns a translator whose `t` and `locale` reflect the currently active locale
126
132
  */
127
133
  export function createPluginI18n(catalogs, locale) {
128
134
  let active = locale.current();
129
- locale.onChange((l) => {
135
+ const unsubscribe = locale.onChange((l) => {
130
136
  active = l;
131
137
  });
132
138
  return {
133
139
  get locale() {
134
140
  return active;
135
141
  },
142
+ dispose() {
143
+ // Optional call: a host built against 0.3.x returns nothing here, and a translator that cannot be
144
+ // disposed is better than one that throws while a component is tearing down.
145
+ unsubscribe?.();
146
+ },
136
147
  t(key, params) {
137
148
  const template = catalogs[active]?.[key] ?? catalogs[SOURCE_LOCALE]?.[key] ?? key;
138
149
  return interpolate(template, params);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,qDAAqD;AAErD;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,OAAgB,CAAC;AAyMrD;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,QAAyB;IACtD,OAAO,QAAQ,CAAC,QAAQ,IAAI,QAAQ,CAAC,YAAY,CAAC;AACpD,CAAC;AA6TD,MAAM,gBAAgB,GAA+C;IACnE,CAAC,IAAI,EAAE,SAAS,CAAC;IACjB,CAAC,SAAS,EAAE,cAAc,CAAC;IAC3B,CAAC,MAAM,EAAE,WAAW,CAAC;IACrB,CAAC,WAAW,EAAE,iBAAiB,CAAC;IAChC,CAAC,QAAQ,EAAE,aAAa,CAAC;IACzB,CAAC,gBAAgB,EAAE,sBAAsB,CAAC;IAC1C,CAAC,SAAS,EAAE,eAAe,CAAC;IAC5B,CAAC,QAAQ,EAAE,aAAa,CAAC;CAC1B,CAAC;AAEF,+DAA+D;AAC/D,SAAS,QAAQ,CAAC,KAAkB;IAClC,MAAM,KAAK,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;SACjE,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,GAAG,MAAM,KAAK,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC;SACnD,IAAI,CAAC,GAAG,CAAC,CAAC;IACb,OAAO,2BAA2B,KAAK,IAAI,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CAAC,OAA6B;IAClE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAChC,IAAI,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5B,OAAO;IACT,CAAC;IAED,MAAM,gBAAiB,SAAQ,WAAW;QACvB,OAAO,CAAmB;QAC1B,IAAI,CAAc;QAC3B,QAAQ,GAAyB,IAAI,CAAC;QACtC,OAAO,GAAwB,SAAS,CAAC;QAEjD;YACE,KAAK,EAAE,CAAC;YACR,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;YACnD,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;YAC/C,IAAI,CAAC,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAC1C,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACzC,CAAC;QAED,sDAAsD;QACtD,IAAI,GAAG,CAAC,KAAoB;YAC1B,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;YACtB,IAAI,CAAC,QAAQ,EAAE,CAAC;QAClB,CAAC;QAED,IAAI,GAAG;YACL,OAAO,IAAI,CAAC,QAAQ,CAAC;QACvB,CAAC;QAEO,QAAQ;YACd,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC;YAC1B,IAAI,CAAC,GAAG,EAAE,CAAC;gBACT,OAAO;YACT,CAAC;YACD,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBACvC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,IAAI,CAAC,OAAO,GAAG,SAAS,CAAC;YAC3B,CAAC;YACD,IAAI,CAAC,OAAO,CAAC,WAAW,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YAC/C,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;YAC5B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QAClD,CAAC;QAED,oBAAoB;YAClB,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBACvC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,IAAI,CAAC,OAAO,GAAG,SAAS,CAAC;YAC3B,CAAC;QACH,CAAC;KACF;IAED,cAAc,CAAC,MAAM,CAAC,GAAG,EAAE,gBAAgB,CAAC,CAAC;AAC/C,CAAC;AA+BD,MAAM,aAAa,GAAG,IAAI,CAAC;AAE3B,SAAS,WAAW,CAAC,QAAgB,EAAE,MAAwC;IAC7E,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,OAAO,QAAQ,CAAC,OAAO,CAAC,sBAAsB,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE,CACtE,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAClF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAsB,EACtB,MAA+B;IAE/B,IAAI,MAAM,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;IAC9B,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE;QACxC,MAAM,GAAG,CAAC,CAAC;IACb,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,IAAI,MAAM;YACR,OAAO,MAAM,CAAC;QAChB,CAAC;QACD,OAAO;YACL,kGAAkG;YAClG,6EAA6E;YAC7E,WAAW,EAAE,EAAE,CAAC;QAClB,CAAC;QACD,CAAC,CAAC,GAAG,EAAE,MAAM;YACX,MAAM,QAAQ,GACZ,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC;YACnE,OAAO,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;KACF,CAAC;AACJ,CAAC"}
package/dist/testing.d.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
- import type { PluginApiClient, PluginContext, ThemeTokens } from './index.js';
9
+ import type { ConsentApi, LogLevel, PluginApiClient, PluginContext, ThemeTokens } from './index.js';
10
+ /** One recorded {@link PluginContext.log} call. */
11
+ export interface LogRecord {
12
+ /** The severity it was logged at. */
13
+ level: LogLevel;
14
+ /** The logged message. */
15
+ message: string;
16
+ }
10
17
  /** One recorded {@link MockApiClient} call. */
11
18
  export interface RecordedCall {
12
19
  /** HTTP method, lowercase. */
@@ -26,8 +33,66 @@ export interface MockApiClient extends PluginApiClient {
26
33
  */
27
34
  responses: Record<string, unknown>;
28
35
  }
36
+ /** A {@link ConsentApi} whose grants a test can drive. */
37
+ export interface MockConsent extends ConsentApi {
38
+ /** Grants a category and notifies subscribers. Granting an already-granted category does nothing. */
39
+ grant(category: string): void;
40
+ /**
41
+ * Withdraws a category and notifies subscribers, as the host's settings page can mid-session.
42
+ *
43
+ * Revoking `necessary` does nothing — the host cannot withdraw it either.
44
+ */
45
+ revoke(category: string): void;
46
+ /** Every category passed to {@link ConsentApi.request}, in order. */
47
+ readonly requests: string[];
48
+ /**
49
+ * What {@link ConsentApi.request} resolves with — i.e. what the visitor decides.
50
+ *
51
+ * `false` by default: the visitor says no. Set it to `true` to test the accepted path, which also
52
+ * grants the category as the host would.
53
+ */
54
+ autoGrantOnRequest: boolean;
55
+ }
56
+ /**
57
+ * Builds a {@link ConsentApi} for tests, **denying everything** unless told otherwise.
58
+ *
59
+ * Deny is the default on purpose. The host denies until granted, so a component written against a
60
+ * permissive mock is a component whose placeholder path was never exercised — and that path is the entire
61
+ * point of the consent contract (ARCHITECTURE §12.5).
62
+ *
63
+ * The one exception is `necessary`, which is granted from the start and cannot be revoked in the host
64
+ * either — it is the category the core itself uses.
65
+ *
66
+ * @param initial categories granted from the start, on top of `necessary`
67
+ * @returns a consent double with `grant`/`revoke` and a recorded `requests` list
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const consent = makeMockConsent();
72
+ * const ctx = makeMockCtx({ consent });
73
+ *
74
+ * mount(ctx); // renders the click-to-load placeholder
75
+ * consent.autoGrantOnRequest = true;
76
+ * await clickPlaceholder();
77
+ * expect(consent.requests).toEqual(['analytics']);
78
+ *
79
+ * consent.revoke('analytics'); // fires onChange; the component should go back to the placeholder
80
+ * ```
81
+ */
82
+ export declare function makeMockConsent(initial?: string[]): MockConsent;
29
83
  /** Default theme tokens (neutral light values) used when a test does not override them. */
30
84
  export declare const DEFAULT_THEME: ThemeTokens;
85
+ /** A {@link PluginContext} wired for tests: the mock `api` and the recorded `log` calls are reachable. */
86
+ export type MockPluginContext = PluginContext & {
87
+ /** The recording client every `api` call goes through. */
88
+ api: MockApiClient;
89
+ /**
90
+ * Every {@link PluginContext.log} call, in order.
91
+ *
92
+ * Stays empty if you override `log` yourself — the recording lives in the default implementation.
93
+ */
94
+ logs: LogRecord[];
95
+ };
31
96
  /** Overrides accepted by {@link makeMockCtx}. */
32
97
  export interface MockCtxOverrides extends Partial<Omit<PluginContext, 'api'>> {
33
98
  /** Canned responses for the mock `api`, keyed as described on {@link MockApiClient.responses}. */
@@ -38,14 +103,18 @@ export interface MockCtxOverrides extends Partial<Omit<PluginContext, 'api'>> {
38
103
  /**
39
104
  * Builds a {@link PluginContext} wired with in-memory doubles for unit tests.
40
105
  *
41
- * Defaults: `site` scope, no episodes, anonymous user, all consent denied, empty filter, a player
106
+ * Defaults: `site` scope, no episodes, anonymous user, **all consent denied** (a {@link makeMockConsent}
107
+ * double — pass your own to drive grants), empty filter, a player
42
108
  * parked at 0s, empty route, `en` locale, unknown progress, and {@link DEFAULT_THEME}. Pass overrides to
43
- * change any field; the returned `api` is a {@link MockApiClient} recording every call.
109
+ * change any field; the returned `api` is a {@link MockApiClient} recording every call, and `logs`
110
+ * collects every `ctx.log(...)`.
111
+ *
112
+ * `episodeLabels` is deliberately **absent** by default. It is optional on the real context and the host
113
+ * may supply it partially, so a component that renders labels has to survive their absence — the mock
114
+ * makes you face that unless you pass labels in.
44
115
  *
45
116
  * @param overrides partial context plus optional `apiResponses`
46
- * @returns a full context whose `api` is a {@link MockApiClient}
117
+ * @returns a full context whose `api` is a {@link MockApiClient} and whose `log` calls are recorded
47
118
  */
48
- export declare function makeMockCtx(overrides?: MockCtxOverrides): PluginContext & {
49
- api: MockApiClient;
50
- };
119
+ export declare function makeMockCtx(overrides?: MockCtxOverrides): MockPluginContext;
51
120
  //# sourceMappingURL=testing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAEV,eAAe,EACf,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC;IAC/B;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAoBD,2FAA2F;AAC3F,eAAO,MAAM,aAAa,EAAE,WAS3B,CAAC;AAEF,iDAAiD;AACjD,MAAM,WAAW,gBAAiB,SAAQ,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;IAC3E,kGAAkG;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,kFAAkF;IAClF,GAAG,CAAC,EAAE,aAAa,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,SAAS,GAAE,gBAAqB,GAAG,aAAa,GAAG;IAAE,GAAG,EAAE,aAAa,CAAA;CAAE,CAoBpG"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,UAAU,EAEV,QAAQ,EACR,eAAe,EACf,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,qCAAqC;IACrC,KAAK,EAAE,QAAQ,CAAC;IAChB,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC;IAC/B;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAuBD,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,qGAAqG;IACrG,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;OAIG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,MAAM,EAAO,GAAG,WAAW,CAwCnE;AAED,2FAA2F;AAC3F,eAAO,MAAM,aAAa,EAAE,WAS3B,CAAC;AAEF,0GAA0G;AAC1G,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG;IAC9C,0DAA0D;IAC1D,GAAG,EAAE,aAAa,CAAC;IACnB;;;;OAIG;IACH,IAAI,EAAE,SAAS,EAAE,CAAC;CACnB,CAAC;AAEF,iDAAiD;AACjD,MAAM,WAAW,gBAAiB,SAAQ,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;IAC3E,kGAAkG;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,kFAAkF;IAClF,GAAG,CAAC,EAAE,aAAa,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,SAAS,GAAE,gBAAqB,GAAG,iBAAiB,CA2B/E"}
package/dist/testing.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 The Mosaicast Authors
3
+ /** The unsubscribe returned by the inert `onChange` doubles — there is nothing to detach. */
4
+ const noop = () => { };
3
5
  function makeMockApi(responses) {
4
6
  const calls = [];
5
7
  const resolve = (method, path, body) => {
@@ -17,6 +19,72 @@ function makeMockApi(responses) {
17
19
  };
18
20
  return api;
19
21
  }
22
+ /**
23
+ * Builds a {@link ConsentApi} for tests, **denying everything** unless told otherwise.
24
+ *
25
+ * Deny is the default on purpose. The host denies until granted, so a component written against a
26
+ * permissive mock is a component whose placeholder path was never exercised — and that path is the entire
27
+ * point of the consent contract (ARCHITECTURE §12.5).
28
+ *
29
+ * The one exception is `necessary`, which is granted from the start and cannot be revoked in the host
30
+ * either — it is the category the core itself uses.
31
+ *
32
+ * @param initial categories granted from the start, on top of `necessary`
33
+ * @returns a consent double with `grant`/`revoke` and a recorded `requests` list
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const consent = makeMockConsent();
38
+ * const ctx = makeMockCtx({ consent });
39
+ *
40
+ * mount(ctx); // renders the click-to-load placeholder
41
+ * consent.autoGrantOnRequest = true;
42
+ * await clickPlaceholder();
43
+ * expect(consent.requests).toEqual(['analytics']);
44
+ *
45
+ * consent.revoke('analytics'); // fires onChange; the component should go back to the placeholder
46
+ * ```
47
+ */
48
+ export function makeMockConsent(initial = []) {
49
+ // `necessary` is what the core itself uses: always granted, never prompted for. Seeding it means a
50
+ // component gated on it behaves in tests the way it will in the host.
51
+ const granted = new Set(['necessary', ...initial]);
52
+ const subscribers = new Set();
53
+ const requests = [];
54
+ const notify = () => {
55
+ subscribers.forEach((cb) => cb());
56
+ };
57
+ return {
58
+ requests,
59
+ autoGrantOnRequest: false,
60
+ has: (category) => granted.has(category),
61
+ granted: () => [...granted],
62
+ request(category) {
63
+ requests.push(category);
64
+ if (this.autoGrantOnRequest && !granted.has(category)) {
65
+ granted.add(category);
66
+ notify();
67
+ }
68
+ return Promise.resolve(granted.has(category));
69
+ },
70
+ grant(category) {
71
+ if (!granted.has(category)) {
72
+ granted.add(category);
73
+ notify();
74
+ }
75
+ },
76
+ revoke(category) {
77
+ // The host cannot withdraw `necessary`, so neither can the double.
78
+ if (category !== 'necessary' && granted.delete(category)) {
79
+ notify();
80
+ }
81
+ },
82
+ onChange(cb) {
83
+ subscribers.add(cb);
84
+ return () => subscribers.delete(cb);
85
+ },
86
+ };
87
+ }
20
88
  /** Default theme tokens (neutral light values) used when a test does not override them. */
21
89
  export const DEFAULT_THEME = {
22
90
  bg: '#ffffff',
@@ -31,30 +99,43 @@ export const DEFAULT_THEME = {
31
99
  /**
32
100
  * Builds a {@link PluginContext} wired with in-memory doubles for unit tests.
33
101
  *
34
- * Defaults: `site` scope, no episodes, anonymous user, all consent denied, empty filter, a player
102
+ * Defaults: `site` scope, no episodes, anonymous user, **all consent denied** (a {@link makeMockConsent}
103
+ * double — pass your own to drive grants), empty filter, a player
35
104
  * parked at 0s, empty route, `en` locale, unknown progress, and {@link DEFAULT_THEME}. Pass overrides to
36
- * change any field; the returned `api` is a {@link MockApiClient} recording every call.
105
+ * change any field; the returned `api` is a {@link MockApiClient} recording every call, and `logs`
106
+ * collects every `ctx.log(...)`.
107
+ *
108
+ * `episodeLabels` is deliberately **absent** by default. It is optional on the real context and the host
109
+ * may supply it partially, so a component that renders labels has to survive their absence — the mock
110
+ * makes you face that unless you pass labels in.
37
111
  *
38
112
  * @param overrides partial context plus optional `apiResponses`
39
- * @returns a full context whose `api` is a {@link MockApiClient}
113
+ * @returns a full context whose `api` is a {@link MockApiClient} and whose `log` calls are recorded
40
114
  */
41
115
  export function makeMockCtx(overrides = {}) {
42
116
  const { apiResponses, api, ...rest } = overrides;
43
117
  const mockApi = api ?? makeMockApi(apiResponses ?? {});
44
118
  const filter = rest.filter?.current() ?? {};
119
+ const logs = [];
45
120
  const base = {
46
121
  scope: { type: 'site', id: 'main' },
47
122
  episodes: [],
48
123
  user: null,
49
124
  api: mockApi,
50
- consent: { has: () => false, onChange: () => { } },
51
- filter: { current: () => filter, onChange: () => { } },
52
- player: { currentTime: () => 0, seekTo: () => { }, on: () => { } },
53
- route: { path: '', onChange: () => { } },
54
- locale: { current: () => 'en', onChange: () => { } },
125
+ logs,
126
+ log: (level, message) => {
127
+ logs.push({ level, message });
128
+ },
129
+ consent: makeMockConsent(),
130
+ // The no-op handles still return a working unsubscribe, so a component that detaches on cleanup
131
+ // behaves the same against the mock as against the host.
132
+ filter: { current: () => filter, onChange: () => noop },
133
+ player: { currentTime: () => 0, seekTo: () => { }, on: () => noop },
134
+ route: { path: '', onChange: () => noop },
135
+ locale: { current: () => 'en', onChange: () => noop },
55
136
  progress: { get: () => Promise.resolve(null) },
56
137
  theme: DEFAULT_THEME,
57
138
  };
58
- return { ...base, ...rest, api: mockApi };
139
+ return { ...base, ...rest, api: mockApi, logs };
59
140
  }
60
141
  //# sourceMappingURL=testing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mosaicast/plugin-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Versioned plugin contract for Mosaicast: the frontend PluginContext types, a Web Component base helper, an i18n helper, plus a test kit under the /testing subpath.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -33,9 +33,9 @@
33
33
  "test:watch": "vitest"
34
34
  },
35
35
  "devDependencies": {
36
- "jsdom": "^24.1.0",
37
- "typescript": "^5.5.4",
38
- "vitest": "^2.0.5"
36
+ "jsdom": "^29.1.1",
37
+ "typescript": "^7.0.2",
38
+ "vitest": "^4.1.10"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"