@mosaicast/plugin-sdk 0.3.0 → 0.5.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
@@ -29,7 +29,11 @@ src/testing.ts TS: @mosaicast/plugin-sdk/testing — makeMockCtx
29
29
 
30
30
  The contract version is a **single SemVer anchor** mirrored in four places that MUST move together
31
31
  (CI enforces it): `build.gradle.kts` · `package.json` · `PlatformApi.VERSION` · `PLATFORM_API_VERSION`.
32
- 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.5.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.4.0` → `0.5.0`), not a major one; from
36
+ `1.0.0` on, normal SemVer applies and breaking means major.
33
37
 
34
38
  ## Build & test
35
39
  ```bash
@@ -45,8 +49,8 @@ npm ci && npm run build # TypeScript: src → dist (.js + .d.ts)
45
49
  - Released: from **GitHub Packages** (see below).
46
50
  ```kotlin
47
51
  dependencies {
48
- compileOnly("dev.mosaicast:plugin-api:0.3.0") // contract, provided by the host
49
- testImplementation("dev.mosaicast:plugin-testkit:0.3.0") // test doubles only
52
+ compileOnly("dev.mosaicast:plugin-api:0.5.0") // contract, provided by the host
53
+ testImplementation("dev.mosaicast:plugin-testkit:0.5.0") // test doubles only
50
54
  }
51
55
  ```
52
56
  Sources + Javadoc JARs give IDE hover docs automatically.
@@ -95,21 +99,205 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
95
99
  | upsert | `store().put(scope, key, value)` | `ctx.api.put('data/…/{key}', value)` |
96
100
  | remove | `store().delete(scope, key)` → `boolean` | `ctx.api.delete('data/…/{key}')` |
97
101
 
98
- - `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.
99
- - `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.
102
+ - `scopeType` is `site | feed | season | episode | user` and `scopeId` is that entity's id — mirroring `Scope`/`ScopeType`, the same addressing the backend uses. Two are **singletons** whose id the SDK and the host both pin: `site`'s is always `main` (`Scope.SITE_ID`, one site) and `user`'s always `me` (`Scope.SELF_ID`, the calling user). So `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:s2e04:b3` (entity ids are slugs, not UUIDs). The host answers 400 on a bad key, and `InMemoryDocStore` throws `IllegalArgumentException` — so it fails in your tests, not in production.
100
104
  - The list is **paginated** (core's standard `PagedResponse` envelope) and **carries keys** (`DocEntry`), because neither end can address a doc without one.
101
105
  - `delete` is **idempotent**: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
102
106
 
107
+ ### Per-user data — the `user` scope (since 0.5.0)
108
+
109
+ **Per-user data belongs in the `user` scope, never in the key.** A key is client-supplied, so the convention this README used to suggest — `mark:<userId>:cell` under an episode scope — was an access-control decision the host could not enforce: any caller past the plugin's read floor could address someone else's key directly, and scope ids are public slugs, so nothing had to be guessed.
110
+
111
+ `Scope.user()` / `…/data/user/me/{key}` fixes that by construction:
112
+
113
+ - The id is always the sentinel `me` (`Scope.SELF_ID`), resolved **server-side from the session**. The canonical `Scope` constructor normalizes any `USER` id to it, so there is no expression in the Java API that names another person's partition — and there is deliberately **no `Scope.user(String)` overload**.
114
+ - Naming any other `user` id over HTTP is a **400**, never a silent substitution. An anonymous `user` request is a **401** whatever the read floor says: no session, no partition.
115
+ - The partition is **flat** — one per user, not one per user and entity — so the entity goes in the key: `mark:<episodeSlug>:cell`.
116
+ - `readableBy` does not apply to it. No floor makes someone else's partition readable.
117
+
118
+ **A backend has no calling user**, so every `DocStore` method throws `UnsupportedOperationException` for a `USER` scope — reads included, since resolving "me" without a caller would have to pick someone. Aggregate instead:
119
+
120
+ ```java
121
+ // Backend-only, read-only, and no HTTP surface: no visitor's request can reach another's data.
122
+ List<OwnedDocEntry> marks = ctx.store().queryAcrossUsers("mark:");
123
+ // record OwnedDocEntry(UUID userId, String key, JsonNode value) — the owner is host-resolved, never
124
+ // a value the browser supplied, which is what makes a leaderboard built from it true.
125
+ ```
126
+
127
+ Write the aggregate back to an entity scope (`…/data/episode/s2e04/leaderboard`) and let the component read it there. In tests, `InMemoryDocStore.asUser(uuid)` stands in for the host resolving `me`, so you can seed what a frontend would have written and then assert on `queryAcrossUsers`.
128
+
103
129
  **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}`.
104
130
 
105
131
  **What the host enforces**, so a plugin doesn't have to:
106
132
  - Data is **hard-scoped to the plugin id** — a plugin can only ever see its own data.
107
- - **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).
133
+ - **Access is what the manifest declares**, not what the slots imply: `"data": { "readableBy": "fan", "writableBy": "podcaster" }`. Values are `anonymous | fan | podcaster | admin`; `writableBy` may not be `anonymous`, and an absent block defaults `readableBy` to the **write** floor rather than to anonymous — saying nothing gets the safe answer. A slot's `visibleTo` governs **rendering only**; deriving the data floor from unrelated UI slots is what once let a plugin with one anonymous slot expose its whole store.
134
+ - 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).
108
135
 
109
136
  **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.
110
137
 
111
138
  > Roadmap: custom plugin-defined server routes may arrive in a later `platformApi` version; plugins use the doc store.
112
139
 
140
+ ## Relational storage — `ctx.schema()` (since 0.4.0)
141
+
142
+ The doc store is the default and covers nearly everything. Declare a **schema** when you need what a JSON
143
+ document cannot give you: full-text search, revisions, backlinks. Any plugin may; most declare nothing.
144
+
145
+ You declare entities and fields in the manifest, and the **platform** provisions namespaced tables
146
+ (`plugin_<id>_*`) through its managed migration runner, dropping them when an admin purges the plugin.
147
+ **The plugin never writes DDL and never names a table:**
148
+
149
+ ```json
150
+ "storage": { "schema": { "page": {
151
+ "slug": "string:indexed:unique", "title": "string",
152
+ "markdown": "text:fulltext", "updatedAt": "timestamp:indexed" } } }
153
+ ```
154
+
155
+ ```java
156
+ record Page(long id, String slug, String title, String markdown, Instant updatedAt) {}
157
+
158
+ SchemaStore schema = ctx.schema(); // null unless the manifest declares one
159
+
160
+ long id = schema.insert("page", Map.of(
161
+ "slug", "getting-started", "title", "Getting started",
162
+ "markdown", "# Hello", "updatedAt", Instant.now()));
163
+
164
+ Optional<Page> byId = schema.find("page", id, Page.class);
165
+ List<Page> recent = schema.select("page",
166
+ Criteria.all().orderBy("updatedAt", Direction.DESC).limit(20), Page.class);
167
+ List<Page> hits = schema.search("page", "markdown", "lighthouse", Criteria.all(), Page.class);
168
+ long total = schema.count("page", Criteria.all());
169
+ schema.update("page", id, Map.of("markdown", "# Hello again"));
170
+ schema.delete("page", Criteria.where("slug", Op.EQ, "getting-started"));
171
+ ```
172
+
173
+ - Everything is addressed by **declared entity and field name — never SQL, never a table**. That is the
174
+ scoping guarantee: reaching another plugin's tables isn't blocked, it's inexpressible. The host binds
175
+ every value as a JDBC parameter and checks every field name against your manifest.
176
+ - Rows map to **your own record types**, component names matching field names — the same convention as
177
+ `store().get(...)` and `config().get(...)`. Each entity carries a platform-assigned `long id`.
178
+ - `Criteria` is immutable; predicates combine with **AND** (no `or` in 0.4.0). `search` needs a field
179
+ declared `:fulltext`; for a plain match use `Op.LIKE` with `select`.
180
+ - Naming an undeclared entity or field throws `IllegalArgumentException` — against the test kit too, so a
181
+ manifest that drifted from the code fails in your tests.
182
+
183
+ Test it with `FakeSchemaStore`, declaring the same entities your manifest does. Its `search` is a
184
+ substring match, **not** Postgres full-text: no stemming, no ranking. Assert on which rows come back, not
185
+ on their order.
186
+
187
+ ## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
188
+
189
+ ```java
190
+ ctx.logger().info("indexed {} pages", count); // org.slf4j.Logger, named plugin.<pluginId>
191
+ ```
192
+ ```ts
193
+ ctx.log('warn', `no board for episode ${ctx.scope.id}`);
194
+ ```
195
+
196
+ A plain SLF4J `Logger` on the backend: authors know the API, and parameterised messages and throwables
197
+ come free. **Attribution rides in the logger name**, which is why the host hands you a named logger rather
198
+ than offering `log(level, message)` — the name travels with the event, so output is still attributed to
199
+ your plugin from a thread you started or from an `onSchedule` task, where a thread-local MDC arrives
200
+ empty. Don't build your own `LoggerFactory.getLogger(...)`: it won't sit under the `plugin.` prefix and
201
+ the host can't attribute it.
202
+
203
+ The host stores `info` and above, surfaces `warn` and above in the admin log viewer, and **rate-limits**
204
+ this path. On the frontend, `ctx.log` replaces POSTing to `/api/plugins/{id}/log` through `ctx.api`.
205
+ `FakePluginContext.logger()` returns a `RecordingLogger`, so a test asserts on logging the way it asserts
206
+ on stored documents; `makeMockCtx` collects `ctx.logs`.
207
+
208
+ ## Consent & the manifest (core-owned schema)
209
+
210
+ Core runs **banner-free** — strictly necessary cookies need no consent. The only reason a visitor sees a
211
+ consent prompt is a plugin that asked for one, so treat it as the cost it is.
212
+
213
+ ### `ctx.consent` (frontend)
214
+
215
+ ```ts
216
+ if (ctx.consent.has('analytics')) mountChart(root);
217
+ else button.onclick = async () => {
218
+ if (await ctx.consent.request('analytics')) mountChart(root); // from a user gesture, never on mount
219
+ };
220
+
221
+ const off = ctx.consent.onChange(() => rerender()); // fires on every change
222
+ return off; // detach on cleanup
223
+ ```
224
+
225
+ `request(category)` opens the host's settings for that one purpose and resolves with the visitor's answer
226
+ (`false` if dismissed). `granted()` lists everything currently granted. `onChange` fires on **every**
227
+ change — including a withdrawal made in the settings page while you are mounted — and returns an
228
+ unsubscribe. Since 0.4.0 `filter`, `route`, `locale` and `player.on` return one too.
229
+
230
+ **Services describe, categories decide.** The manifest is per-service (`provider`, `privacyUrl`,
231
+ `thirdCountryTransfer`, each `storage[]` item) but every `ctx.consent` method takes a **category**. The
232
+ per-service detail exists so the notice can name who stores what for how long; what the visitor toggles is
233
+ the category. So if two plugins each declare an `analytics` service with different providers, the visitor
234
+ sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
235
+ provider and refuse the other. If you need your own services to be refusable independently, declare them
236
+ under **different categories** (a plugin-declared category is fine; the shell just has no translated label
237
+ for it). That is the only lever the contract gives you.
238
+
239
+ **`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
240
+ it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
241
+ service `necessary` means "this loads unconditionally"; use it only for what genuinely cannot be refused.
242
+
243
+ **Concurrent `request()` calls.** A page of plugin tiles will produce them, so the contract is explicit:
244
+ there is **one consent surface host-wide** (a second call joins the one in flight rather than opening
245
+ another); the visitor decides **all** categories in one interaction, so other categories may move too;
246
+ every call resolves exactly once and is never dropped; and grants are **shared across plugins** — another
247
+ plugin's accepted `request('analytics')` resolves yours too. Don't serialize calls or build a queue, and
248
+ re-read `has(...)` after a change instead of caching what a request resolved with.
249
+
250
+ ### `consent.services[]` in `plugin.json`
251
+
252
+ The manifest type is **core-owned** — the SDK has no manifest type and will not grow one — but this is
253
+ where plugin authors look, so the required shape is documented here. **From `0.4.0` the legacy
254
+ `{ "categories": [...], "externalSources": [...] }` form is rejected** and the plugin will not load.
255
+
256
+ ```json
257
+ "consent": {
258
+ "services": [{
259
+ "id": "plausible",
260
+ "name": "Plausible Analytics",
261
+ "provider": "Plausible Insights OÜ",
262
+ "category": "necessary | functional | analytics | <plugin-declared>",
263
+ "privacyUrl": "https://plausible.io/privacy",
264
+ "hosts": ["https://plausible.example"],
265
+ "thirdCountryTransfer": false,
266
+ "storage": [
267
+ { "name": "plausible_ignore", "type": "cookie | localStorage | sessionStorage",
268
+ "purpose": "Remembers that you opted out of statistics", "duration": "persistent | session | 12 months" }
269
+ ]
270
+ }]
271
+ }
272
+ ```
273
+
274
+ | Field | Meaning |
275
+ |---|---|
276
+ | `id` | Stable identifier for this service within your plugin. |
277
+ | `name` | The service as a visitor would recognise it, e.g. "Plausible Analytics". |
278
+ | `provider` | The **legal entity** operating it — the company name, not your plugin's. |
279
+ | `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. |
280
+ | `privacyUrl` | The provider's own privacy policy. |
281
+ | `hosts` | Every origin the service is contacted on. **Also the CSP allow-list** — see below. |
282
+ | `thirdCountryTransfer` | Whether personal data leaves the EU/EEA. |
283
+ | `storage[]` | Each item the service stores on the device: `name`, `type`, `purpose`, `duration`. |
284
+
285
+ **Why the shape changed.** A cookie notice that satisfies §25 TDDDG / Art. 5(3) ePD has to name each
286
+ stored item, what it is for, how long it lasts, who the provider is, and whether data leaves the country.
287
+ Category slugs and bare hostnames cannot produce that — and they force the notice to talk about "plugins"
288
+ to visitors who only care about cookies and named companies.
289
+
290
+ **`hosts` doubles as the CSP allow-list.** Core widens `script-src` / `frame-src` / `connect-src` by
291
+ exactly these origins. An origin you did not declare **stays blocked even after consent is given** — if a
292
+ third-party embed silently fails to load with consent granted, an undeclared host is the first thing to
293
+ check.
294
+
295
+ **Authoring help:** the SDK exports `ConsentServiceDeclaration` (and `ConsentStorageDeclaration`) as a
296
+ **documentation-only** type. Nothing in the SDK reads `plugin.json` or validates it — the host owns and
297
+ enforces the manifest — but typing a literal against it gives you completion and catches a typo before
298
+ core rejects the plugin at load, which is otherwise your first feedback. If the type and core ever
299
+ disagree, **core wins**; treat the mismatch as an SDK bug. The manifest as a whole stays core-owned.
300
+
113
301
  ## Releasing (maintainers)
114
302
  Publishing is automated and fires on a **published GitHub Release**, not on PR merge
115
303
  (`.github/workflows/release.yml`).
package/dist/index.d.ts CHANGED
@@ -11,19 +11,65 @@
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.3.0';
20
+ export declare const PLATFORM_API_VERSION: '0.5.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';
20
- /** The level plus concrete entity a view is scoped to (ARCHITECTURE §6.1). */
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;
38
+ /**
39
+ * The level plus concrete entity a view is scoped to (ARCHITECTURE §6.1).
40
+ *
41
+ * This is the **slot** scope — the page your component was mounted on. It never carries the `user` level
42
+ * of {@link DataScopeType}: a slot lives in a named region of a page, and there is no user page. A
43
+ * component reading per-user data addresses `data/user/me/…` explicitly while `ctx.scope` stays whatever
44
+ * page it is on.
45
+ */
21
46
  export interface Scope {
22
47
  /** The scope level. */
23
48
  type: 'site' | 'feed' | 'season' | 'episode';
24
- /** The id of the entity at that level (e.g. the `EpisodeRef` id for `episode`). */
49
+ /** The id of the entity at that level (e.g. the `EpisodeRef` slug for `episode`). */
25
50
  id: string;
26
51
  }
52
+ /**
53
+ * The scope levels the host's **data** surface addresses — the four page levels of {@link Scope} plus
54
+ * `user`, the caller's own private partition (`ScopeType` in the Java SDK).
55
+ *
56
+ * Deliberately a second type: `user` is a storage partition, never a slot scope, so it can appear in a
57
+ * `data/{scopeType}/{scopeId}/…` path but never in `ctx.scope`.
58
+ *
59
+ * @since 0.5.0
60
+ */
61
+ export type DataScopeType = Scope['type'] | 'user';
62
+ /**
63
+ * The id every `user` data path carries: the literal `me`, mirroring the Java `Scope.SELF_ID`.
64
+ *
65
+ * The host resolves it from the session, so `data/user/me/{key}` reaches the calling user's partition and
66
+ * no one else's. **Any other `user` id is a 400**, never a silent substitution, and an anonymous request
67
+ * to a `user` path is a 401 — with no session there is no partition. Per-user data belongs here rather
68
+ * than in a key like `mark:<userId>:cell`, which the host cannot enforce because the client supplies it.
69
+ *
70
+ * @since 0.5.0
71
+ */
72
+ export declare const SELF_SCOPE_ID: 'me';
27
73
  /**
28
74
  * The host-owned filter axes for the current view (ARCHITECTURE §6.1).
29
75
  *
@@ -117,38 +163,73 @@ export interface PagedDocs<T = unknown> {
117
163
  * → remove; idempotent
118
164
  * ```
119
165
  *
120
- * `scopeType` is `site | feed | season | episode` and `scopeId` the id of that entity — i.e. the
121
- * {@link Scope} the backend addresses with. For `site` the id is the literal `main` (one site, one
122
- * singleton scope), so the path always has four non-empty segments. `key` must match
166
+ * `scopeType` is a {@link DataScopeType} and `scopeId` the id of that entity — i.e. the `Scope` the
167
+ * backend addresses with. Two of them are singletons whose id is fixed: `site` is always the literal
168
+ * `main` (one site), and `user` always {@link SELF_SCOPE_ID} (`me`), which the host resolves to the
169
+ * calling user. So the path always has four non-empty segments. `key` must match
123
170
  * `^[A-Za-z0-9._:-]{1,200}$` — the host answers 400 otherwise — and is the final path segment verbatim:
124
- * no `/`, so structure keys with `:` / `.` / `-` (e.g. `mark:userId:cell`). The list is paginated
125
- * ({@link PagedDocs}) and carries each doc's key ({@link DocEntry}), since you cannot address a doc
126
- * without it.
171
+ * no `/`, so structure keys with `:` / `.` / `-` (e.g. `mark:s2e04:b3`; entity ids are slugs). The list
172
+ * is paginated ({@link PagedDocs}) and carries each doc's key ({@link DocEntry}), since you cannot
173
+ * address a doc without it.
127
174
  *
128
175
  * The doc a backend writes with `ctx.store().put(scope, key, value)` is the one read here at
129
- * `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends.
176
+ * `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends. The `user` scope is the
177
+ * exception — it exists only here. A backend has no calling user, so it cannot write a user partition at
178
+ * all and reads them only in aggregate, through the Java `DocStore.queryAcrossUsers(prefix)`.
179
+ *
180
+ * ## Where per-user data goes
181
+ *
182
+ * **In the `user` scope, not in the key.** A key is client-supplied, so the older convention
183
+ * `mark:<userId>:cell` under an episode scope was an access-control decision the host could not check:
184
+ * any caller past the plugin's read floor could address someone else's key, and scope ids are public
185
+ * slugs, so nothing had to be guessed. `data/user/me/…` is resolved from the session instead — the only
186
+ * partition a caller can name is their own. Any other `user` id is a **400**; an anonymous `user` request
187
+ * is a **401**, whatever the read floor says. The partition is flat, so the entity goes in the key:
188
+ * `mark:<episodeSlug>:cell`.
189
+ *
190
+ * ## What the host enforces
191
+ *
192
+ * Data is hard-scoped to the plugin id — a plugin only ever sees its own — and the host validates that the
193
+ * scope exists. Access is what your **manifest** declares:
130
194
  *
131
- * Data is hard-scoped to the plugin id — a plugin can only ever see its own. Reads are gated by the
132
- * slot's `visibleTo`, writes require the mapped {@link Role}, and the host validates that the scope
133
- * exists. A write is plain persistence: no plugin code runs at request time, so anything derived or
134
- * validated server-side must be precomputed in the backend's `register`/`onSchedule` and read back from
135
- * the store.
195
+ * ```json
196
+ * "data": { "readableBy": "fan", "writableBy": "podcaster" }
197
+ * ```
198
+ *
199
+ * Values are `anonymous | fan | podcaster | admin` ({@link Role} plus `anonymous`, the absence of one);
200
+ * `writableBy` may not be `anonymous`, and if the block is absent `readableBy` falls back to the *write*
201
+ * floor rather than to anonymous — saying nothing gets the safe answer. A slot's `visibleTo` governs
202
+ * **rendering only**; it never governed data access, and inferring the data floor from unrelated UI slots
203
+ * is exactly what once let a plugin with one anonymous slot expose its whole store. Watch the default: a
204
+ * plugin with an anonymous slot and no `data` block loses its anonymous reads (403) until it declares
205
+ * `"readableBy": "anonymous"`. **Neither floor applies to the `user` scope:** no floor makes someone
206
+ * else's partition readable, none stands between a caller and their own, and `writableBy` does not gate it
207
+ * either — a write floor protects the *shared* surface, and a user partition is unshared. Any
208
+ * authenticated caller reads and writes their own `data/user/me/…` whatever the manifest declares.
209
+ *
210
+ * A write is plain persistence: no plugin code runs at request time, so anything derived or validated
211
+ * server-side must be precomputed in the backend's `register`/`onSchedule` and read back from the store.
136
212
  *
137
213
  * Custom plugin-defined server routes may arrive in a later `platformApi` version; v1 plugins use the
138
214
  * doc store.
139
215
  *
140
216
  * @example Read, list, write and remove docs from a Web Component
141
217
  * ```ts
142
- * const base = `data/${ctx.scope.type}/${ctx.scope.id}`; // scope.id is `main` on the site scope
143
- * const key = `board:${ctx.user!.id}`; // no `/` in keys
218
+ * const mine = `data/user/${SELF_SCOPE_ID}`; // the caller's own partition
219
+ * const shared = `data/${ctx.scope.type}/${ctx.scope.id}`; // scope.id is `main` on the site scope
220
+ * const key = `mark:${ctx.scope.id}`; // no `/` in keys
144
221
  *
145
- * const board = await ctx.api.get<Board>(`${base}/${key}`); // rejects with a 404 problem if absent
146
- * await ctx.api.put(`${base}/${key}`, { ...board, marked }); // upsert, last-write-wins
222
+ * const marks = await ctx.api.get<Marks>(`${mine}/${key}`); // rejects with a 404 problem if absent
223
+ * await ctx.api.put(`${mine}/${key}`, { ...marks, b3: true }); // upsert, last-write-wins
147
224
  *
148
- * const page = await ctx.api.get<PagedDocs<Board>>(`${base}?prefix=board:&page=0&size=50`);
225
+ * const page = await ctx.api.get<PagedDocs<Marks>>(`${mine}?prefix=mark:&page=0&size=50`);
149
226
  * page.items.forEach(({ key, value }) => render(key, value));
150
227
  *
151
- * await ctx.api.delete(`${base}/${key}`); // idempotent
228
+ * await ctx.api.delete(`${mine}/${key}`); // idempotent
229
+ *
230
+ * // A leaderboard is not built here: the backend aggregates every user's marks with
231
+ * // queryAcrossUsers(...) and writes the result to `${shared}/leaderboard` for this component to read.
232
+ * const board = await ctx.api.get<Leaderboard>(`${shared}/leaderboard`);
152
233
  * ```
153
234
  */
154
235
  export interface PluginApiClient {
@@ -200,15 +281,230 @@ export interface DisplaySnapshot {
200
281
  * @returns the resolved artwork URL, or `undefined` when neither the episode nor the feed declares one
201
282
  */
202
283
  export declare function resolveArtwork(snapshot: DisplaySnapshot): string | undefined;
284
+ /**
285
+ * The consent gate for anything that stores data on the visitor's device or talks to a third party
286
+ * (ARCHITECTURE §12.5).
287
+ *
288
+ * Consent is **denied until granted**. The core itself runs banner-free — strictly necessary cookies need
289
+ * no consent — so the only reason a visitor ever sees a consent prompt is a plugin that asked for one.
290
+ * Treat that as the cost it is.
291
+ *
292
+ * The categories are declared per service in your manifest's `consent.services[]` (`necessary`,
293
+ * `functional`, `analytics`, or one you declare yourself). The host builds the cookie notice from those
294
+ * declarations, and the `hosts` you list there are also the CSP allow-list: **an origin you did not
295
+ * declare stays blocked even after consent is given.**
296
+ *
297
+ * ## The decision is per category, not per service
298
+ *
299
+ * `consent.services[]` is richly per-service — `provider`, `privacyUrl`, `thirdCountryTransfer`, each
300
+ * `storage[]` item — but every method here takes a **category**. That asymmetry is deliberate and worth
301
+ * stating plainly, because the schema implies otherwise: **services describe, categories decide.** The
302
+ * per-service detail exists so the notice can name who stores what for how long; the thing a visitor
303
+ * actually toggles is the category.
304
+ *
305
+ * The consequence: if two plugins each declare a service under `analytics` with different providers, the
306
+ * visitor sees **one** decision listing both plugins, and granting it grants both. There is no way to
307
+ * consent to one provider and withhold the other. Do not build a UI that implies otherwise, and don't
308
+ * assume `has('analytics')` says anything about *which* provider was accepted — it says the category was.
309
+ *
310
+ * If you need a visitor to be able to accept one of your services and refuse another, declare them under
311
+ * **different categories** (a plugin-declared category is allowed, it just has no translated label in the
312
+ * shell). That is the only lever the contract gives you.
313
+ *
314
+ * ## `necessary` is never asked about
315
+ *
316
+ * `has('necessary')` is **always `true`** and the host never prompts for it — it is the category the core
317
+ * itself uses, and a banner-free site stays banner-free. Declaring a service as `necessary` therefore
318
+ * means "this loads unconditionally"; use it only for what genuinely cannot be refused, and expect
319
+ * `request('necessary')` to resolve `true` without showing the visitor anything.
320
+ *
321
+ * @example The click-to-load placeholder this exists for
322
+ * ```ts
323
+ * function render({ ctx, root }: { ctx: PluginContext; root: HTMLElement }) {
324
+ * if (ctx.consent.has('analytics')) {
325
+ * mountChart(root);
326
+ * return;
327
+ * }
328
+ *
329
+ * const button = document.createElement('button');
330
+ * button.textContent = 'Load chart (sets a cookie)';
331
+ * button.onclick = async () => {
332
+ * if (await ctx.consent.request('analytics')) mountChart(root);
333
+ * };
334
+ * root.append(button);
335
+ *
336
+ * // Someone may flip the purpose in the settings page while this is mounted.
337
+ * return ctx.consent.onChange(() => rerender());
338
+ * }
339
+ * ```
340
+ */
341
+ export interface ConsentApi {
342
+ /**
343
+ * Whether the visitor has granted a consent category.
344
+ *
345
+ * Check it before every load of a consent-requiring resource, not once at startup — consent can be
346
+ * withdrawn mid-session from the host's settings page.
347
+ *
348
+ * @param category the consent category, as declared on a service in your manifest
349
+ * @returns `true` if the category is currently granted
350
+ */
351
+ has(category: string): boolean;
352
+ /**
353
+ * Every category currently granted.
354
+ *
355
+ * For rendering a summary ("statistics: on, embeds: off"). Prefer {@link has} for a single gate.
356
+ *
357
+ * Includes `necessary`, which is always granted. These are categories, not service ids — a granted
358
+ * category covers every service declared under it, across all plugins.
359
+ *
360
+ * @returns the granted category names, in no guaranteed order
361
+ */
362
+ granted(): string[];
363
+ /**
364
+ * Asks the visitor to grant a category, and resolves with their answer.
365
+ *
366
+ * The host opens its consent settings for this one purpose and resolves once the visitor decides;
367
+ * dismissing it resolves `false`. **Call this from a user gesture** — the click on your placeholder —
368
+ * and never on mount: an unprompted call turns a banner-free site into one with a banner, which is
369
+ * exactly what §12.5 is arranged to avoid.
370
+ *
371
+ * Resolving `true` means the category is granted from that moment on; it does not load anything for
372
+ * you. Load the resource yourself afterwards.
373
+ *
374
+ * ## What a caller may rely on
375
+ *
376
+ * A page full of plugin tiles will produce concurrent calls, so the contract is explicit about them:
377
+ *
378
+ * - **There is one consent surface, host-wide.** Calling this does not open a dialog of your own, and a
379
+ * second call while one is already open does not open a second — it joins the one in flight. You are
380
+ * asking the host to surface *its* settings, not opening a modal.
381
+ * - **The visitor decides every category at once.** The host's settings cover all declared categories,
382
+ * so one interaction can change several. Your promise still resolves with the state of *the category
383
+ * you asked for* — but other categories may have moved too, which is why {@link onChange} fires for
384
+ * every change rather than only yours.
385
+ * - **Every call resolves exactly once, and always.** Concurrent calls are never dropped or left
386
+ * pending: each resolves when the visitor completes the decision, including calls made while the
387
+ * surface was already open.
388
+ * - **Grants are shared across plugins.** If another plugin's `request('analytics')` is what the
389
+ * visitor accepted, your pending `request('analytics')` resolves `true` too — the decision is per
390
+ * category and site-wide, not per plugin (see the note on granularity above).
391
+ *
392
+ * So: don't serialize your calls, don't build a queue, and don't assume the visitor only answered you.
393
+ * Re-read {@link has} after any change rather than caching what a request resolved with.
394
+ *
395
+ * @param category the consent category to ask for
396
+ * @returns whether the category is granted after the visitor decided
397
+ */
398
+ request(category: string): Promise<boolean>;
399
+ /**
400
+ * Subscribes to consent changes.
401
+ *
402
+ * Fires on **every** change to any category — including one made in the settings page while your
403
+ * component is mounted, and including a withdrawal. It carries no payload: re-read {@link has} or
404
+ * {@link granted} for the current state.
405
+ *
406
+ * @param cb called after each change
407
+ * @returns an {@link Unsubscribe} — return it from your render callback so the subscription dies with
408
+ * the component
409
+ */
410
+ onChange(cb: () => void): Unsubscribe;
411
+ }
412
+ /**
413
+ * One item a service stores on the visitor's device, as declared in `plugin.json`.
414
+ *
415
+ * Part of {@link ConsentServiceDeclaration} — see the caveats there before using either type.
416
+ */
417
+ export interface ConsentStorageDeclaration {
418
+ /** The cookie or storage key exactly as it appears on the device, e.g. `plausible_ignore`. */
419
+ name: string;
420
+ /** Where it is stored. */
421
+ type: 'cookie' | 'localStorage' | 'sessionStorage';
422
+ /** What it is for, in language a visitor reads — not an internal description. */
423
+ purpose: string;
424
+ /** How long it lasts: `session`, `persistent`, or a human duration such as `12 months`. */
425
+ duration: string;
426
+ }
427
+ /**
428
+ * The shape of one entry in your manifest's `consent.services[]`.
429
+ *
430
+ * **This type is documentation, not enforcement.** The manifest is owned and validated by the host — the
431
+ * SDK does not read `plugin.json`, and nothing here runs at build or load time. It exists so an author
432
+ * writing the declaration gets IDE completion and catches a typo before core rejects the plugin at load,
433
+ * which is otherwise the first feedback you get. Two consequences worth knowing:
434
+ *
435
+ * - **The host is authoritative.** If this type and core disagree, core wins. It is kept in step by hand,
436
+ * so treat a mismatch as a bug in the SDK rather than permission to ignore the host.
437
+ * - **It is not a manifest type.** The manifest as a whole stays core-owned and the SDK will not grow one;
438
+ * this covers a single nested shape that got deep enough in 0.4.0 to be worth typing.
439
+ *
440
+ * Use it by typing a literal you keep next to your manifest, or as a reference while writing the JSON:
441
+ *
442
+ * ```ts
443
+ * const services: ConsentServiceDeclaration[] = [{
444
+ * id: 'plausible',
445
+ * name: 'Plausible Analytics',
446
+ * provider: 'Plausible Insights OÜ',
447
+ * category: 'analytics',
448
+ * privacyUrl: 'https://plausible.io/privacy',
449
+ * hosts: ['https://plausible.example'],
450
+ * thirdCountryTransfer: false,
451
+ * storage: [{
452
+ * name: 'plausible_ignore', type: 'localStorage',
453
+ * purpose: 'Remembers that you opted out of statistics', duration: 'persistent',
454
+ * }],
455
+ * }];
456
+ * ```
457
+ */
458
+ export interface ConsentServiceDeclaration {
459
+ /** Stable identifier for this service within your plugin. */
460
+ id: string;
461
+ /** The service as a visitor would recognise it, e.g. `Plausible Analytics`. */
462
+ name: string;
463
+ /** The **legal entity** operating the service — the company, not your plugin. */
464
+ provider: string;
465
+ /**
466
+ * The consent category this service falls under, and therefore what {@link ConsentApi.has} gates on.
467
+ *
468
+ * `necessary` is never prompted for and always granted. Remember that the category — not the service —
469
+ * is what the visitor decides: two services sharing a category are accepted or refused together.
470
+ */
471
+ category: 'necessary' | 'functional' | 'analytics' | (string & {});
472
+ /** The provider's own privacy policy. */
473
+ privacyUrl: string;
474
+ /**
475
+ * Every origin the service is contacted on, scheme included (`https://plausible.example`).
476
+ *
477
+ * **Also the CSP allow-list**: core widens `script-src`/`frame-src`/`connect-src` by exactly these, so
478
+ * an undeclared or bare-hostname origin stays blocked even after consent is given.
479
+ */
480
+ hosts: string[];
481
+ /** Whether personal data leaves the EU/EEA. */
482
+ thirdCountryTransfer: boolean;
483
+ /** Each item the service stores on the visitor's device. */
484
+ storage: ConsentStorageDeclaration[];
485
+ }
203
486
  /**
204
487
  * Everything a frontend plugin is given, set by the host on the mounted custom element
205
488
  * (ARCHITECTURE §7.5). This is the **entire** interface a plugin author must learn.
206
489
  */
207
490
  export interface PluginContext {
208
- /** The scope this plugin instance is mounted in. */
491
+ /**
492
+ * The scope this plugin instance is mounted in. For the `episode` scope, `scope.id` is the episode's
493
+ * **public slug** (e.g. `the-sample-cast-s01e06`) — the same id used in its URL and as the doc-store
494
+ * partition (`data/episode/{slug}/…`), not the internal UUID.
495
+ */
209
496
  scope: Scope;
210
- /** The `EpisodeRef` ids in scope, resolved (and access-filtered) by the host. */
497
+ /**
498
+ * The episode ids in scope, resolved (and access-filtered) by the host — the public **slugs** (the same
499
+ * values used in URLs and doc-store paths). Pair with {@link episodeLabels} for display.
500
+ */
211
501
  episodes: string[];
502
+ /**
503
+ * Human-readable labels for {@link episodes}, keyed by slug (e.g. `"S01E06 · The Lighthouse…"`). The host
504
+ * builds them from the feed's season/episode + title; use them in pickers so users see titles, not slugs.
505
+ * Optional: absent (or partial) when the host does not provide a label for a given episode.
506
+ */
507
+ episodeLabels?: Record<string, string>;
212
508
  /** Present on the `episode` scope: lifecycle status of the current episode. */
213
509
  episode?: {
214
510
  status: 'PLANNED' | 'PUBLISHED' | 'WITHDRAWN';
@@ -224,31 +520,68 @@ export interface PluginContext {
224
520
  * uses via `ctx.store()`. See {@link PluginApiClient} for the endpoint shape and access rules.
225
521
  */
226
522
  api: PluginApiClient;
227
- /** Cookie/consent gate for third-party resources (ARCHITECTURE §12.5). */
228
- consent: {
229
- has(cat: string): boolean;
230
- onChange(cb: () => void): void;
231
- };
232
- /** Read-only access to the host's URL filter state (ARCHITECTURE §6.1). */
523
+ /**
524
+ * Writes one line to the host's log, attributed to this plugin.
525
+ *
526
+ * The counterpart of the backend's `ctx.logger()`, and the **only** supported way for a frontend
527
+ * component to log to the host — do not POST to `/api/plugins/{id}/log` through {@link api}, which is a
528
+ * data endpoint. The host attributes the entry to your plugin, stores `info` and above, surfaces `warn`
529
+ * and above in the admin log viewer, and rate-limits this path: a render loop logging per frame will be
530
+ * throttled, not stored.
531
+ *
532
+ * Messages are read by site operators, not by you — they land next to core's own output. Keep them
533
+ * short, and keep personal data out of them.
534
+ *
535
+ * @param level the severity
536
+ * @param message the message; already-formatted, since there is no placeholder syntax here
537
+ *
538
+ * @example
539
+ * ```ts
540
+ * ctx.log('warn', `no board for episode ${ctx.scope.id}`);
541
+ * ```
542
+ */
543
+ log(level: LogLevel, message: string): void;
544
+ /** Cookie/consent gate for third-party resources — see {@link ConsentApi} (ARCHITECTURE §12.5). */
545
+ consent: ConsentApi;
546
+ /**
547
+ * Read-only access to the host's URL filter state (ARCHITECTURE §6.1).
548
+ *
549
+ * Plugins *consume* filters, they never define them: the axes (season, tags, sorting) belong to the
550
+ * host and live in the URL. `onChange` returns an {@link Unsubscribe}.
551
+ */
233
552
  filter: {
234
553
  current(): FilterState;
235
- onChange(cb: (f: FilterState) => void): void;
554
+ onChange(cb: (f: FilterState) => void): Unsubscribe;
236
555
  };
237
- /** Player position + control, for sync plugins (ARCHITECTURE §6.5). */
556
+ /**
557
+ * Player position + control, for sync plugins (ARCHITECTURE §6.5).
558
+ *
559
+ * `on` returns an {@link Unsubscribe} — player events outlive a single render, so detaching matters
560
+ * here more than anywhere else on this context.
561
+ */
238
562
  player: {
239
563
  currentTime(): number;
240
564
  seekTo(s: number): void;
241
- on(ev: string, cb: (...args: unknown[]) => void): void;
565
+ on(ev: string, cb: (...args: unknown[]) => void): Unsubscribe;
242
566
  };
243
- /** The subpath under `/p/<pluginId>/`, for deep-linkable plugin content (ARCHITECTURE §6.4). */
567
+ /**
568
+ * The subpath under `/p/<pluginId>/`, for deep-linkable plugin content (ARCHITECTURE §6.4).
569
+ *
570
+ * `onChange` returns an {@link Unsubscribe}.
571
+ */
244
572
  route: {
245
573
  path: string;
246
- onChange(cb: (p: string) => void): void;
574
+ onChange(cb: (p: string) => void): Unsubscribe;
247
575
  };
248
- /** The active UI locale (ARCHITECTURE §12.7). */
576
+ /**
577
+ * The active UI locale (ARCHITECTURE §12.7).
578
+ *
579
+ * `onChange` returns an {@link Unsubscribe}. {@link createPluginI18n} subscribes to this for you and
580
+ * hands back a `dispose` to undo it.
581
+ */
249
582
  locale: {
250
583
  current(): string;
251
- onChange(cb: (l: string) => void): void;
584
+ onChange(cb: (l: string) => void): Unsubscribe;
252
585
  };
253
586
  /** Core listening progress in seconds, or `null` if unknown (ARCHITECTURE §6.5). */
254
587
  progress: {
@@ -306,6 +639,14 @@ export interface PluginI18n {
306
639
  t(key: string, params?: Record<string, string | number>): string;
307
640
  /** The currently active locale code. */
308
641
  readonly locale: string;
642
+ /**
643
+ * Detaches the translator from `ctx.locale`.
644
+ *
645
+ * A translator subscribes to locale changes for its whole life. Call this when the component that owns
646
+ * it goes away — from the cleanup callback your render returns — or the subscription keeps a dead
647
+ * translator alive.
648
+ */
649
+ dispose(): void;
309
650
  }
310
651
  /**
311
652
  * Creates a plugin-local translator bound to the host's active locale (ARCHITECTURE §12.7).
@@ -314,6 +655,9 @@ export interface PluginI18n {
314
655
  * change — the same convention as the shell, no extra i18n library required. English (`en`) is the
315
656
  * source language and the fallback.
316
657
  *
658
+ * The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
659
+ * from your render's cleanup callback when the component goes away.
660
+ *
317
661
  * @param catalogs catalogs keyed by locale code
318
662
  * @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
319
663
  * @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,24 @@
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.3.0';
22
+ export const PLATFORM_API_VERSION = '0.5.0';
23
+ /**
24
+ * The id every `user` data path carries: the literal `me`, mirroring the Java `Scope.SELF_ID`.
25
+ *
26
+ * The host resolves it from the session, so `data/user/me/{key}` reaches the calling user's partition and
27
+ * no one else's. **Any other `user` id is a 400**, never a silent substitution, and an anonymous request
28
+ * to a `user` path is a 401 — with no session there is no partition. Per-user data belongs here rather
29
+ * than in a key like `mark:<userId>:cell`, which the host cannot enforce because the client supplies it.
30
+ *
31
+ * @since 0.5.0
32
+ */
33
+ export const SELF_SCOPE_ID = 'me';
20
34
  /**
21
35
  * The artwork to display for an episode: its own {@link DisplaySnapshot.imageUrl} if present, otherwise
22
36
  * the {@link DisplaySnapshot.feedImageUrl feed cover}, otherwise `undefined`.
@@ -120,19 +134,27 @@ function interpolate(template, params) {
120
134
  * change — the same convention as the shell, no extra i18n library required. English (`en`) is the
121
135
  * source language and the fallback.
122
136
  *
137
+ * The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
138
+ * from your render's cleanup callback when the component goes away.
139
+ *
123
140
  * @param catalogs catalogs keyed by locale code
124
141
  * @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
125
142
  * @returns a translator whose `t` and `locale` reflect the currently active locale
126
143
  */
127
144
  export function createPluginI18n(catalogs, locale) {
128
145
  let active = locale.current();
129
- locale.onChange((l) => {
146
+ const unsubscribe = locale.onChange((l) => {
130
147
  active = l;
131
148
  });
132
149
  return {
133
150
  get locale() {
134
151
  return active;
135
152
  },
153
+ dispose() {
154
+ // Optional call: a host built against 0.3.x returns nothing here, and a translator that cannot be
155
+ // disposed is better than one that throws while a component is tearing down.
156
+ unsubscribe?.();
157
+ },
136
158
  t(key, params) {
137
159
  const template = catalogs[active]?.[key] ?? catalogs[SOURCE_LOCALE]?.[key] ?? key;
138
160
  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":"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"}
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.3.0",
3
+ "version": "0.5.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": {