@mosaicast/plugin-sdk
v0.10.0
Published
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.
Downloads
1,367
Readme
mosaicast-plugin-sdk
Versioned plugin contract SDK (Java
plugin-api+@mosaicast/plugin-sdkTS) plus a test kit.
Part of Mosaicast — an extensible website platform for podcasts. Status: v1 in development.
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.
Layout
plugin-api/ Java: the plugin contract (interfaces + records, no impl) → dev.mosaicast.plugin.api.*
plugin-testkit/ Java: test doubles (FakePluginContext, InMemoryDocStore, …) → dev.mosaicast.plugin.testkit.*
src/index.ts TS: @mosaicast/plugin-sdk — PluginContext, defineMosaicastElement, createPluginI18n
src/testing.ts TS: @mosaicast/plugin-sdk/testing — makeMockCtxThe contract version is a single SemVer anchor mirrored in four places that MUST move together
(CI enforces it): build.gradle.kts · package.json · PlatformApi.VERSION · PLATFORM_API_VERSION.
How the host matches it: core compares major.minor exactly. A plugin declaring 0.3.x is
rejected the moment the host runs 0.7.0 — with the reason shown in the admin log viewer. While the SDK
is pre-1.0 a breaking change is therefore a minor bump (0.6.0 → 0.7.0), not a major one; from
1.0.0 on, normal SemVer applies and breaking means major. The patch floats — a plugin declaring
0.9.0 loads on a 0.9.1 host, which is what makes a purely additive release (a new optional extension
point, a test double) cheap: it rejects nothing already installed.
Build & test
./gradlew build publishToMavenLocal # Java: both JARs (+ sources & javadoc) → ~/.m2
npm ci && npm run build # TypeScript: src → dist (.js + .d.ts)
./gradlew test && npm test # all testsUsing it
- Java (plugin backend) — three ways to obtain the artifacts:
- Composite build (nicest during parallel dev):
includeBuild("../mosaicast-plugin-sdk"). - Local:
./gradlew publishToMavenLocal, then depend ondev.mosaicast:plugin-api:<version>. - Released: from GitHub Packages (see below).
Sources + Javadoc JARs give IDE hover docs automatically.dependencies { compileOnly("dev.mosaicast:plugin-api:0.9.1") // contract, provided by the host testImplementation("dev.mosaicast:plugin-testkit:0.9.1") // test doubles only } - Composite build (nicest during parallel dev):
- TypeScript (plugin frontend):
npm install @mosaicast/plugin-sdk(published on npm), ornpm link/ a tarball for local dev..d.ts+ TSDoc give IDE hover docs. - The source of truth for signatures is this built SDK + its docs.
Consuming released Java artifacts (GitHub Packages)
GitHub Packages requires authentication even for public reads. In the consumer's build.gradle.kts:
repositories {
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/Mosaicast/mosaicast-plugin-sdk")
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
}
}
}The token is any GitHub PAT with read:packages. (Anonymous-pull via Maven Central is a possible future move.)
Plugin data access (v1) — plugins do not define HTTP routes
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).
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.)
Frontend. A Web Component reaches plugin data through ctx.api (PluginApiClient), or — for a plugin that declares a relational schema — through ctx.schema (see below). Neither hits plugin-authored routes: both hit fixed, generic surfaces the host exposes, namespaced per plugin. ctx.api mirrors DocStore one-to-one — get / put / list / delete, and no more:
GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
→ one JSON doc; 404 if absent
GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
→ { items: [{ key, value }], page, size, totalElements, totalPages }
PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
→ upsert, last-write-wins
DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
→ remove; idempotent| Operation | Backend (Java) | Frontend (TS) |
|---|---|---|
| read one | store().get(scope, key, T.class) → Optional<T> | ctx.api.get<T>('data/…/{key}') |
| list by prefix | store().query(scope, prefix) → List<DocEntry> | ctx.api.get<PagedDocs<T>>('data/…?prefix=…') |
| upsert | store().put(scope, key, value) | ctx.api.put('data/…/{key}', value) |
| remove | store().delete(scope, key) → boolean | ctx.api.delete('data/…/{key}') |
scopeTypeissite | feed | season | episode | userandscopeIdis that entity's id — mirroringScope/ScopeType, the same addressing the backend uses. Two are singletons whose id the SDK and the host both pin:site's is alwaysmain(Scope.SITE_ID, one site) anduser's alwaysme(Scope.SELF_ID, the calling user). SoScope.site()and…/data/site/main/{key}address the same document, and the path always has four non-empty segments.keymust matchDocStore.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%2Fthere). Structure keys with:/./-instead, e.g.mark:s2e04:b3(entity ids are slugs, not UUIDs). The host answers 400 on a bad key, andInMemoryDocStorethrowsIllegalArgumentException— so it fails in your tests, not in production.- The list is paginated (core's standard
PagedResponseenvelope) and carries keys (DocEntry), because neither end can address a doc without one. deleteis idempotent: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
The typed client — ctx.docs (since 0.9.0)
ctx.api is the raw surface and stays available. ctx.docs is the same endpoints with the path building,
the key validation and the 404 handling done for you — which removes a whole class of bug, since every doc
access above was string concatenation the plugin had to get right four segments at a time:
await ctx.docs.put('self', `mark:${ctx.scope.id}`, marks); // data/user/me/mark:<slug>
const board = await ctx.docs.get<Board>('site', 'leaderboard'); // null when absent, not a rejection
const page = await ctx.docs.list<Marks>('self', { prefix: 'mark:' });
await ctx.docs.remove(ctx.scope, 'draft'); // any Scope addresses its own partition'self'isdata/user/meand'site'isdata/site/main— the two singletons. Making the per-user partition the shortest thing to write is deliberate: it is the most security-relevant convention in the contract, and a convention only sticks when it is also the easy path.getresolvesnullon 404, because a key nothing has written yet is a normal state rather than a failure. The same is true ofctx.api.getOrNull(path).- A malformed key throws at the call site, with
DOC_KEY_PATTERNin the message, instead of costing a 400 round-trip whose body you then have to read. - Everything else is unchanged and still the host's: both access floors,
backendOwned, the 400 on an unknown scope, the 401 on an anonymoususerrequest.
Typed failures — PluginApiError (since 0.9.0)
Every ctx.api method rejects with an error carrying the status and the RFC 7807 body. Before 0.9.0 the
rejection was untyped, so the only way to survive a 404 was catch(() => undefined) — which also swallowed
the 500, the 403 and the network failure, and showed the visitor an empty widget for all four:
import { isPluginApiError } from '@mosaicast/plugin-sdk';
try {
await ctx.docs.put('site', 'stats', computed);
} catch (e) {
if (isPluginApiError(e) && e.status === 403) {
ctx.log('warn', e.problem?.detail ?? 'refused'); // backendOwned, or the write floor
return;
}
throw e; // a real failure — surface it
}Use isPluginApiError, not instanceof: the error is constructed by the host and crosses a bundle
boundary, so it does not share a constructor with anything in your plugin.
Per-user data — the user scope (since 0.5.0)
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.
Scope.user() / …/data/user/me/{key} fixes that by construction:
- The id is always the sentinel
me(Scope.SELF_ID), resolved server-side from the session. The canonicalScopeconstructor normalizes anyUSERid to it, so there is no expression in the Java API that names another person's partition — and there is deliberately noScope.user(String)overload. - Naming any other
userid over HTTP is a 400, never a silent substitution. An anonymoususerrequest is a 401 whatever the read floor says: no session, no partition. - The partition is flat — one per user, not one per user and entity — so the entity goes in the key:
mark:<episodeSlug>:cell. readableBydoes not apply to it. No floor makes someone else's partition readable.
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:
// Backend-only, read-only, and no HTTP surface: no visitor's request can reach another's data.
List<OwnedDocEntry> marks = ctx.store().queryAcrossUsers("mark:");
// record OwnedDocEntry(UUID userId, String key, JsonNode value) — the owner is host-resolved, never
// a value the browser supplied, which is what makes a leaderboard built from it true.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.
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}.
What the host enforces, so a plugin doesn't have to:
- Data is hard-scoped to the plugin id — a plugin can only ever see its own data.
- Access is what the manifest declares, not what the slots imply:
"data": { "readableBy": "fan", "writableBy": "podcaster" }. Values areanonymous | fan | podcaster | admin;writableBymay not beanonymous, and an absent block defaultsreadableByto the write floor rather than to anonymous — saying nothing gets the safe answer. A slot'svisibleTogoverns rendering only; deriving the data floor from unrelated UI slots is what once let a plugin with one anonymous slot expose its whole store. - The floors say who, not which key — see below.
- The host validates that the scope exists (and that the feed is enabled).
ctx.apicarries the user's auth (session or personal access token).
Backend-owned keys (since 0.6.0) — a shared-scope document has no owner
Authorization on the data surface is per plugin, not per document. Every caller above your writableBy floor can overwrite or delete any key in a site/feed/season/episode scope — including a value your backend computed on a schedule, because the host cannot tell your write from a curl:
# podcaster, with a plugin declaring writableBy: "podcaster" — the floor did exactly what it says
curl -b cookie.podcaster -X PUT .../api/plugins/sample/data/site/main/stats \
-H "X-XSRF-TOKEN: $XT" -d '{"totalEpisodes":9999}' # 204, and every visitor now reads itIf your backend authors a key, declare it:
"data": {
"readableBy": "anonymous",
"writableBy": "podcaster",
"backendOwned": ["stats", "agg:*"]
}- Each entry is an exact key, a prefix ending in
*, or the bare*(the whole store is computed) —DocStore.BACKEND_OWNED_PATTERN=^(\*|[A-Za-z0-9._:-]{1,200}\*?)$. A*in the middle, or an empty entry, is rejected at load. A pattern can never be a key, since*is not inKEY_PATTERN. - A client
PUT/DELETEto a matching key is 403, worded differently from the role-floor 403 so you can tell which rule refused you. Reads are untouched and still governed byreadableBy.ctx.store()— the backend — is unaffected, which is the whole point. - Write those keys in
register()too, not only on a schedule. The declaration refuses new client writes; it does not remove a value forged before it existed, so otherwise a bogus document survives until the next tick. - It is ignored for
userscopes: the backend cannot write a partition at all, so even a bare*leaves those to their owner. PluginDataDeclarationin the TS SDK types this block (documentation only — the host validates it), andInMemoryDocStore.withBackendOwned(...)enforces it in tests, so you can prove the forgedPUTfails without a running host.
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.
Roadmap: custom plugin-defined server routes may arrive in a later
platformApiversion; plugins use the doc store.
Relational storage — ctx.schema (since 0.4.0; frontend since 0.7.0)
The doc store is the default and covers nearly everything. Declare a schema when you need what a JSON document cannot give you: full-text search, revisions, backlinks. Any plugin may; most declare nothing.
You declare entities and fields in the manifest, and the platform provisions namespaced tables
(plugin_<id>_*) through its managed migration runner, dropping them when an admin purges the plugin.
The plugin never writes DDL and never names a table:
"storage": { "schema": { "page": {
"slug": "string:indexed:unique", "title": "string",
"markdown": "text:fulltext", "updatedAt": "timestamp:indexed" } } }record Page(long id, String slug, String title, String markdown, Instant updatedAt) {}
SchemaStore schema = ctx.schema(); // null unless the manifest declares one
long id = schema.insert("page", Map.of(
"slug", "getting-started", "title", "Getting started",
"markdown", "# Hello", "updatedAt", Instant.now()));
Optional<Page> byId = schema.find("page", id, Page.class);
List<Page> recent = schema.select("page",
Criteria.all().orderBy("updatedAt", Direction.DESC).limit(20), Page.class);
List<Page> hits = schema.search("page", "markdown", "lighthouse", Criteria.all(), Page.class);
long total = schema.count("page", Criteria.all());
schema.update("page", id, Map.of("markdown", "# Hello again"));
schema.delete("page", Criteria.where("slug", Op.EQ, "getting-started"));- Everything is addressed by declared entity and field name — never SQL, never a table. That is the scoping guarantee: reaching another plugin's tables isn't blocked, it's inexpressible. The host binds every value as a JDBC parameter and checks every field name against your manifest.
- Rows map to your own record types, component names matching field names — the same convention as
store().get(...)andconfig().get(...). Each entity carries a platform-assignedlong id. Criteriais immutable; predicates combine with AND (noorin 0.4.0).searchneeds a field declared:fulltext; for a plain match useOp.LIKEwithselect.- Naming an undeclared entity or field throws
IllegalArgumentException— against the test kit too, so a manifest that drifted from the code fails in your tests.
Test it with FakeSchemaStore, declaring the same entities your manifest does. Its search is a
substring match, not Postgres full-text: no stemming, no ranking. Assert on which rows come back, not
on their order.
From the frontend — ctx.schema (since 0.7.0)
Provisioning without access was half a feature: the search box lives in the browser. ctx.schema is the
frontend counterpart, and it is null unless the manifest declares a schema — the same shape as the
Java call, so a doc-store plugin gets null rather than a client that 404s on everything.
if (!ctx.schema) return; // doc-store plugin: nothing to query
interface Page { id: number; slug: string; title: string; markdown: string; updatedAt: string }
const hits = await ctx.schema.search<Page>('page', 'markdown', term, {
where: [{ field: 'published', op: 'eq', value: true }],
orderBy: [{ field: 'updatedAt', direction: 'desc' }],
size: 20,
}); // → { items, page, size, totalElements, totalPages }
const one = await ctx.schema.find<Page>('page', 42); // null when there is no such row
const n = await ctx.schema.count('page');| Operation | Backend (Java) | Frontend (TS) |
|---|---|---|
| rows by criteria | schema.select(entity, criteria, T.class) | ctx.schema.select<T>(entity, query) |
| full-text | schema.search(entity, field, text, criteria, T.class) | ctx.schema.search<T>(entity, field, text, query) |
| one by id | schema.find(entity, id, T.class) → Optional<T> | ctx.schema.find<T>(entity, id) → T \| null |
| how many | schema.count(entity, criteria) | ctx.schema.count(entity, query) |
- A
SchemaQuerydescribes the same thingCriteriadoes — declared field names, an op from a closed vocabulary, values bound by the host — withpage/sizeinstead oflimit/offsetbecause it crosses HTTP (pagefrom 0,size50 by default, capped at 200). - Reads only. There is no write half, deliberately: a v1 plugin authors no HTTP routes, so no plugin code runs at request time to enforce slug uniqueness or append a revision atomically. Your backend stays the only writer — a frontend that must write puts a document in the doc store and the backend ingests it on its schedule, which makes such a write eventually consistent.
- An undeclared entity is a 404, an undeclared field or a
searchon a field that is not:fulltexta 400. Access is the samedata.readableByfloor as the doc surface — one rule, both surfaces.
Test it with makeMockSchema({ page: [...] }) from /testing, which records every query. Its search is
a substring match with the same caveat as FakeSchemaStore's.
Episode snapshots — ctx.feeds (since 0.9.0)
The Java contract could read an episode's display snapshot and the frontend could not, so a plugin that
wanted to draw an episode card copied the host's own data into its doc store and kept it fresh on a
schedule — a backend, a scheduled ingest, a backendOwned key and a copy that is stale between runs, for
fields the host already has.
const cards = await ctx.feeds.displayMany(ctx.episodes.slice(0, 20)); // one request, not N
for (const slug of ctx.episodes) {
const snap = cards[slug];
if (!snap) continue; // filtered out for this visitor — normal, not an error
render(slug, snap.title, resolveArtwork(snap), i18n.duration(snap.duration ?? 0));
}
const one = await ctx.feeds.display('kraken'); // null when absent or not visible- The host filters, the plugin consumes. A
WITHDRAWNor tier-gated episode is absent rather than redacted, and this cannot enumerate episodesctx.episodesdid not already give you. - No
readableBygate of its own — it returns host data the same visitor can read from/api/episodes/*anyway. It exists so a plugin need not know that URL shape, the same argumentctx.linksmakes. - Not authoritative. The snapshot is overwritten on every feed refetch — that is the feature, and the reason to read it live. Cache per render, never per install.
displayManyclamps at 200 slugs rather than erroring, so check what came back.
Test it with makeMockFeeds().withDisplay(slug, snapshot), mirroring the Java FakeFeedAccess.withDisplay.
Site-wide tags — ctx.tags / ctx.tags() (since 0.9.0)
Tags existed in core only as a feed-derived filter axis over episodes: no vocabulary, no plugin surface. So
every plugin that wanted tags grew a private free-text column, and a wiki's lore and an episode's lore
were unrelated strings that could not be suggested, linked or counted together.
Opt in from the manifest — reading and tagging your own subjects is one thing, tagging episodes is another:
"tags": { "readsVocabulary": true, "writesEpisodes": false }ctx.tags (TS) and ctx.tags() (Java) are null without it, the same shape as schema and blobs.
const tags = ctx.tags;
if (!tags) return;
for (const t of await tags.all()) suggest(t.label, t.tag); // the site's real vocabulary
await tags.tagSubject(`page:${slug}`, 'Maritime Lore'); // your own namespace
const also = await tags.episodesWith('maritime lore'); // what else is about this
a.href = ctx.links.feed('main', { tag: 'maritime lore' }); // and it links to the feed viewtagis the canonical key (trim, collapse whitespace, casefold), applied on every path into the vocabulary including feed ingest;labelis presentation, kept from first use. Send any spelling, store and compare on the key.- Tagging an episode is a capability, not a convenience: it changes the shell's filter options and what core recommends beside that episode. Hence the second flag.
- What no plugin may do: delete a tag from the vocabulary (it is shared), rename one (a vocabulary-wide
edit, and admin's), or remove another writer's assignment — the feed's included. Every plugin write
carries
source = plugin:<id>, which makes the last one enforceable rather than merely discouraged. - A tag stops existing when nothing carries it.
subjectKey is opaque and yours to invent — the same namespacing ctx.schema has for tables. Use the same
key a SearchProvider hit resolves to, so a tag and a search result name one object.
Test with makeMockTags({ writesEpisodes }) / FakeTags, both of which refuse what the host refuses.
Deep links & navigation — ctx.route (navigate since 0.7.0)
A plugin declaring a page slot owns the URL subtree /p/<pluginId>/*. The host hands it the subpath
below that prefix as ctx.route.path, which is what makes plugin content linkable and shareable at all.
const slug = ctx.route.path || 'index'; // '' when not rendered as a page
link.addEventListener('click', (e) => {
e.preventDefault();
ctx.route.navigate(`glossary/${target}`); // → /p/wiki/glossary/<target>
});
ctx.route.navigate('index', { replace: true }); // no back-button step (tab/filter state)navigateis SPA navigation: a history entry, a working back button, and no re-fetch of the shell or of any plugin bundle. An<a href>to your own page is a full document load, which on a densely cross-linked page set is the common path, not an edge case.subpathis relative to/p/<pluginId>/, the same coordinatepathgives you. The host prefixes your namespace and drops any attempt to climb out of it, so another plugin's route or a core one is not blocked so much as unnameable — the same propertyctx.schemahas.- Keep the real
hrefon your anchors. Middle-click, "open in new tab" and crawlers need it;navigateonly takes over the plain-click path. - Do not reach past this handle.
history.pushStateplus a syntheticpopstatehappens to work against the host's current router and is not part of the contract. - Pair it with
ShareMetadataProvider(OpenGraph per subpath) andSitemapProvideron the backend so the URLs you navigate to also preview and index properly — and withPageRouteProvider(since 0.9.1) so the ones that do not exist answer 404 instead of a 200 with a not-found view in it.
Reading the query, and matching a route (since 0.9.0)
navigate always accepted a ?query, and until 0.9.0 there was no supported way to read one back —
location.search is exactly the "do not reach past this handle" the rule above forbids. Filters, sort
order and pagination are the obvious shareable-link state:
const sort = ctx.route.query.get('sort') ?? 'newest'; // host-parsed URLSearchParams
ctx.route.navigate(`moments?sort=${next}`, { replace: true });matchRoute replaces the prefix matcher every page plugin hand-rolls — and the hand-rolled one is usually
a startsWith, which matches moments-archive too:
import { matchRoute } from '@mosaicast/plugin-sdk';
const match = matchRoute(ctx.route.path, ['', 'moments', 'highlight/:slug'] as const);
switch (match?.pattern) {
case 'highlight/:slug': return renderDetail(match.params.slug);
case 'moments': return renderMoments();
case '': return renderIndex();
default: return renderNotFound(); // null — nothing matched
}The whole path must be consumed, :param captures one non-empty segment (decoded), surrounding slashes and
any ?query/#hash are ignored, and the first matching pattern wins.
Linking to core pages — ctx.links (since 0.8.0)
navigate deliberately cannot name a core route. Producing a link to one is a different thing — the
visitor still clicks — and ctx.links gives you the host's URL shapes instead of a hardcoded string that
breaks when a route changes:
a.href = ctx.links.episode('kraken', { t: 724 }); // /episodes/kraken?t=724
a.href = ctx.links.feed('main', { season: '2' }); // /feeds/main?season=2?t= is the host's timestamp deep link: it seeks the player to that second, and beats the listener's
stored position for that navigation without overwriting it. Frontend only — this is where links get
rendered. Strings, not navigation: put them in a real href.
File storage — ctx.blobs (since 0.8.0)
A plugin that has to accept a file — a wiki's diagrams, a show-notes image — rather than link to somebody else's host. Declare it or you do not get it, and what you declare is what an operator sees you asking for:
"blobs": { "maxFileBytes": 5242880, "quotaBytes": 268435456,
"mimeTypes": ["image/png", "image/jpeg", "image/webp"] }ctx.blobs is null without it, exactly like ctx.schema. From a Web Component:
const blobs = ctx.blobs;
if (!blobs) return;
const stored = await blobs.upload(file); // a File from <input type="file">
img.src = blobs.urlFor(stored.ref);
await ctx.api.put('data/site/main/logo', { ref: stored.ref });and from a backend, for what only a backend can do — fetching on a schedule, deleting what a document no longer points at:
try (InputStream in = Files.newInputStream(path)) {
BlobInfo stored = ctx.blobs().put("architecture.png", "image/png", in);
}- Store the
ref, never the URL. The URL is derived and the host may change how; the ref is the identity. - Writes are the point here, unlike
ctx.schema. A file has no relational invariant for plugin code to enforce, sodata.writableByplus a quota is the whole authorization story and a client may upload directly. Reads followdata.readableBy. - Uploads are refused for reasons you can predict: size against your effective ceiling, declared type
against the effective allow-list, then the actual type read from the leading bytes. A file whose content
contradicts its extension is refused, and SVG is never accepted — it is a script container wearing an
image's file extension. Call
quota()to warn someone before they pick a 200 MB file. - The operator's numbers win. They cap both ceilings and intersect the type list with the install's own,
so you may be granted less than you asked for;
quota()is the only honest source. - Nothing collects orphans. A file outlives the document that named it, and only your plugin knows which those are.
- Blobs are served same-origin under
/api/plugins/<id>/blob/<ref>, so rendering one needs no CSP host and makes no consent decision — which an external image URL cannot say.
Test it with makeMockBlobs() from /testing and InMemoryPluginBlobs in the Java kit. Both enforce the
ceilings and the allow-list: a component that has only ever met an accepting double meets its first
refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
(rejectContent) to exercise that path.
Host icons — iconCss / iconMask (since 0.9.0)
The host publishes its icon set as --mc-icon-* custom properties, which inherit through the shadow
boundary — so a plugin built against this SDK picks up an icon the day core publishes it, with no SDK
release and no manifest bump. That is why the icon set is deliberately not on ctx, and why these
helpers take a plain string rather than a closed union: either would pin the set to an SDK version.
What they own is the mechanics, which are subtle enough to get wrong three ways:
import { iconCss } from '@mosaicast/plugin-sdk';
const style = document.createElement('style');
style.textContent = iconCss(['star', 'clock']); // put it in YOUR shadow root
root.append(style);
root.insertAdjacentHTML('beforeend', '<i class="mc-icon mc-icon-star" aria-hidden="true"></i>');- Mask, never
background-image.background: currentColorbehind a mask is what makes the icon re-theme with the text beside it. - Every reference needs a blank-image fallback. An unresolved
var()invalidates the declaration, somask-imagefalls back to its initialnone— leaving an unmasked element paintingcurrentColoracross its whole box. A missing icon renders as a solid square, not as nothing, andmask-image: noneis the obvious fallback and the one that causes it.iconMaskemits a real blank SVG instead. - A bundled
.cssfile lands in the host document and cannot reach any shadow root. It is the first thing everyone tries and it silently does nothing.
Don't declare into --mc-* yourself — that is the host's namespace.
Formatting — createPluginI18n (plurals & units since 0.9.0)
t(key, params) interpolates; the rest formats against ctx.locale:
const i18n = createPluginI18n(catalogs, ctx.locale);
i18n.plural('moments', n); // catalog keys: moments.one / moments.other / moments.few …
i18n.n(1234.5); // 1.234,5 in de
i18n.date(snapshot.publishedAt!); // takes the ISO instants the contract hands over
i18n.duration(snapshot.duration!); // 'PT1H2M3S' → 1:02:03; also takes plain seconds
i18n.bytes(quota.usedBytes); // decimal units, locale separator — 5,2 MB in depluralusesIntl.PluralRules. A catalog could not express "1 highlight" / "5 highlights" at all, so plugins shipped an English-shapedif (n === 1)— wrong in Polish, Russian and Arabic. Only the.otherform is required; a locale needing one you did not write falls back to it.countis interpolated for you.durationandbytesare contract-adjacent:DisplaySnapshot.durationis an ISO-8601 string andBlobQuotais three raw byte counts. The SDK produces both, so it may as well render them — and the hand-rolled byte formatter hardcodes.as the decimal separator, which is simply wrong inde.
Which languages the site has — ctx.locale.available() / .content() (since 0.10.0)
ctx.locale used to answer one question: what language the UI is in right now. It now also answers what
languages this instance has — because an operator can drop a catalog into MOSAICAST_LOCALES_DIR and
enable it, and a plugin that hardcoded a language list would be wrong the moment they did.
ctx.locale.current(); // 'de' — the active UI language, as before
ctx.locale.available(); // [{ code, nativeName, isDefault }] — what the shell can render in
ctx.locale.content(); // …what the admin permits content to be *authored* inThey are different lists, and the difference is the whole point. A language needs a catalog to be
offered in the shell and needs nothing at all to be one an imprint is written in — so a site can require a
Dutch imprint with an English-only UI. An editor wants content(). A plugin that built its authoring
tabs from available() would silently refuse the language its operator actually asked for.
Your own catalogs and content() will routinely disagree, and that is fine: your UI falls back to English
while the content is written in Dutch. Do not assume they line up.
On the backend the same thing is ctx.locales() — never null, because every site has at least one
language — and it is where a per-locale write should be checked:
if (!ctx.locales().isContentLocale(locale)) {
throw new IllegalArgumentException("not a content language: " + locale);
}The browser's list is a hint. What arrives at your storage is input, and a row stored under a language nobody offers is invisible to every reader and to your own editor's tab strip.
Machine translation — ctx.translation (since 0.10.0)
if (ctx.translation?.available()) {
const { text, providerId } = await ctx.translation.translate({ text: body, to: 'nl' });
}Translation translation = ctx.translation();
if (translation != null && translation.available()) {
TranslationResult result = translation.translate(TranslationRequest.of(body, "nl"));
}null when the site admin configured no provider — which is every site until somebody chooses one.
Same shape as schema/blobs/tags, different decision: those are gated by your manifest, this by the
operator's choice, and it can appear or disappear while your plugin is running. Do not cache the handle.
The host owns the provider, the credentials and the cache. You never call a translation service directly. That spares operators having to trust every plugin with an API key, and it means two plugins translating the same paragraph cost one call.
Machine output is a draft. Store it marked as one. Core's own legal-page prefill hands the admin an unsaved draft for exactly this reason. A translation nobody has read is not made safer by being automatic.
Three things that will bite:
- Markdown is not a format. Send
'text'or'html'; markdown sent as text comes back with mangled links and code fences, because a translator does not know they are markup. Splitting markdown into translatable blocks is your job, and a real one. - Calls are slow, metered and refusable. Do it on
ctx.onSchedule(...)or behind an explicit editor action, never on a path a visitor waits behind.TranslationExceptionis checked, and carries areason()plusretryable()so you can retry aBUSYand give up on aNO_PROVIDER. - Do not fall back to the untranslated string. A reader who cannot tell a translation from an original is worse off than one who sees an error.
Test doubles: makeMockTranslation() (TS) and FakeTranslation.marking() / .failing(reason) plus
FakeLocales (Java). Both mark the text — "[nl] Hello" — rather than faking Dutch, so an assertion
pins that you asked for the right target language.
The manifest, typed — defineManifest (since 0.9.0)
import { defineManifest, PLATFORM_API_VERSION } from '@mosaicast/plugin-sdk';
export default defineManifest({
id: 'sample', version: '1.0.0', platformApi: PLATFORM_API_VERSION, name: 'Sample',
frontend: { entry: 'sample.es.js', elements: ['sample-card'] },
slots: [{ scope: 'episode', element: 'sample-card', placement: 'main', visibleTo: 'anonymous' }],
nav: [{ path: '', label: 'Sample', icon: 'star' }],
data: { writableBy: 'podcaster', readableBy: 'anonymous' },
});Documentation, not enforcement — the same caveat PluginDataDeclaration and
ConsentServiceDeclaration have carried since 0.4.0. The manifest is owned and validated by the host;
the SDK never reads plugin.json, and if this type and core disagree, core wins.
What it buys you: emit plugin.json from a build step and the manifest stops being a second, unchecked
copy of what your code already knows. nav[] and a plugin's own in-page tab bar are the same list of
entrances declared twice, and a renamed path otherwise becomes a menu entry leading to an empty view with
nothing to catch it. Note where the two legitimately diverge: path, icon and role are pinned
between them, but the menu label cannot be translated — core has no plugin catalogs — while your
in-page tab can.
Optional extension points — SearchProvider, UserDataHandler, PageRouteProvider
All three are ExtensionPoints a plugin MAY implement alongside PluginBackend, like SitemapProvider. No
manifest declaration: not implementing one is how a plugin says it has nothing to contribute.
SearchProvider puts plugin content into the site's search, instead of each plugin growing a private
search box a visitor has no way to discover:
public List<SearchHit> search(String query, Role role, int limit) {
return pages.matching(query, role).stream()
.map(p -> new SearchHit("glossary/" + p.slug(), p.title(), p.excerpt(), p.rank()))
.toList();
}subpath resolves to /p/<pluginId>/<subpath>, exactly as ShareMetadataProvider.metaFor works in
reverse, so a hit is a real deep-linkable URL and the host keeps the URL shape. Results are grouped by
source, not merged — your score and Postgres ts_rank are not on one scale — and a slow provider costs
its own section, not the visitor's query.
Access is your job here, unusually. Everywhere else the host resolves access; it cannot for objects it
has no model of. A provider returning a draft page to an anonymous visitor is a leak the host will not
catch, so role is null for anonymous and SearchProviderHarness calls you once per role:
var results = new SearchProviderHarness(new WikiSearch(store)).search("kraken");
assertFalse(results.leakedToAnonymous("draft/lighthouse"));UserDataHandler is what §12's promise needs to be keepable — core cannot pseudonymise a plugin's
contributions, cannot find them, and cannot know that pseudonymising is right rather than deleting:
public void eraseUser(String userId) { revisions.anonymise(userId); } // or a hard delete — your callThe USER scope is host-owned, so core drops data/user/<id>/… itself. Implement this for the hard half:
schema columns and blobs, where identity lives in a column a plugin chose and the host never learned
which one is a person. Handlers run before the account row goes, and must be idempotent — a failed
deletion is retried. UserDataHandlerHarness.eraseTwice(userId) is that test, and it is the one authors
skip: the second call is the one that throws, during a retry, when the alternative is a half-done deletion.
PageRouteProvider (since 0.9.1) is how a plugin's unknown subpaths become real 404s. Declare a page
slot and every subpath under /p/<id>/ answers 200 — a page never written, a mistyped slug, the URL of
a page deleted last year — each rendering your not-found view inside a 200 OK. §6.6 rules that soft-404
out for core's own routes, and the host cannot fix it alone: only the plugin knows whether a subpath is a
thing.
public boolean hasRoute(String subpath) {
return subpath.isEmpty() || subpath.startsWith("_search/") || pages.exists(slugOf(subpath));
}Not ShareMetadataProvider — the tempting shortcut, and wrong: _search/<term> and _admin return an
empty metaFor on purpose, so reading "no share metadata" as "no page" would 404 working routes. Absent
means today's behaviour, the root is a route (subpath is empty there), it runs on a request so keep it
a lookup, and a provider that throws is logged and skipped and the route answers 200 — a broken plugin
must not turn a working page into a 404. It decides the status line only; the shell still renders the
not-found view. PageRouteProviderHarness asks about a handful of subpaths at once, root included:
var routes = new PageRouteProviderHarness(new WikiRoutes(store)).check("glossary/kraken", "glossary/tpyo");
assertEquals(List.of("glossary/tpyo"), routes.notFound());Logging — ctx.logger() / ctx.log() (since 0.4.0)
ctx.logger().info("indexed {} pages", count); // org.slf4j.Logger, named plugin.<pluginId>ctx.log('warn', `no board for episode ${ctx.scope.id}`);A plain SLF4J Logger on the backend: authors know the API, and parameterised messages and throwables
come free. Attribution rides in the logger name, which is why the host hands you a named logger rather
than offering log(level, message) — the name travels with the event, so output is still attributed to
your plugin from a thread you started or from an onSchedule task, where a thread-local MDC arrives
empty. Don't build your own LoggerFactory.getLogger(...): it won't sit under the plugin. prefix and
the host can't attribute it.
The host stores info and above, surfaces warn and above in the admin log viewer, and rate-limits
this path. On the frontend, ctx.log replaces POSTing to /api/plugins/{id}/log through ctx.api.
FakePluginContext.logger() returns a RecordingLogger, so a test asserts on logging the way it asserts
on stored documents; makeMockCtx collects ctx.logs.
Consent & the manifest (core-owned schema)
Core runs banner-free — strictly necessary cookies need no consent. The only reason a visitor sees a consent prompt is a plugin that asked for one, so treat it as the cost it is.
ctx.consent (frontend)
if (ctx.consent.has('analytics')) mountChart(root);
else button.onclick = async () => {
if (await ctx.consent.request('analytics')) mountChart(root); // from a user gesture, never on mount
};
const off = ctx.consent.onChange(() => rerender()); // fires on every change
return off; // detach on cleanuprequest(category) opens the host's settings for that one purpose and resolves with the visitor's answer
(false if dismissed). granted() lists everything currently granted. onChange fires on every
change — including a withdrawal made in the settings page while you are mounted — and returns an
unsubscribe. Since 0.4.0 filter, route, locale and player.on return one too.
Services describe, categories decide. The manifest is per-service (provider, privacyUrl,
thirdCountryTransfer, each storage[] item) but every ctx.consent method takes a category. The
per-service detail exists so the notice can name who stores what for how long; what the visitor toggles is
the category. So if two plugins each declare an analytics service with different providers, the visitor
sees one decision listing both plugins, and granting it grants both — there is no way to accept one
provider and refuse the other. If you need your own services to be refusable independently, declare them
under different categories (a plugin-declared category is fine; the shell just has no translated label
for it). That is the only lever the contract gives you.
necessary is never asked about. has('necessary') is always true and the host never prompts for
it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
service necessary means "this loads unconditionally"; use it only for what genuinely cannot be refused.
Concurrent request() calls. A page of plugin tiles will produce them, so the contract is explicit:
there is one consent surface host-wide (a second call joins the one in flight rather than opening
another); the visitor decides all categories in one interaction, so other categories may move too;
every call resolves exactly once and is never dropped; and grants are shared across plugins — another
plugin's accepted request('analytics') resolves yours too. Don't serialize calls or build a queue, and
re-read has(...) after a change instead of caching what a request resolved with.
consent.services[] in plugin.json
The manifest type is core-owned — the SDK has no manifest type and will not grow one — but this is
where plugin authors look, so the required shape is documented here. From 0.4.0 the legacy
{ "categories": [...], "externalSources": [...] } form is rejected and the plugin will not load.
"consent": {
"services": [{
"id": "plausible",
"name": "Plausible Analytics",
"provider": "Plausible Insights OÜ",
"category": "necessary | functional | analytics | <plugin-declared>",
"privacyUrl": "https://plausible.io/privacy",
"hosts": ["https://plausible.example"],
"thirdCountryTransfer": false,
"storage": [
{ "name": "plausible_ignore", "type": "cookie | localStorage | sessionStorage",
"purpose": "Remembers that you opted out of statistics", "duration": "persistent | session | 12 months" }
]
}]
}| Field | Meaning |
|---|---|
| id | Stable identifier for this service within your plugin. |
| name | The service as a visitor would recognise it, e.g. "Plausible Analytics". |
| provider | The legal entity operating it — the company name, not your plugin's. |
| 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. |
| privacyUrl | The provider's own privacy policy. |
| hosts | Every origin the service is contacted on. Also the CSP allow-list — see below. |
| thirdCountryTransfer | Whether personal data leaves the EU/EEA. |
| storage[] | Each item the service stores on the device: name, type, purpose, duration. |
Why the shape changed. A cookie notice that satisfies §25 TDDDG / Art. 5(3) ePD has to name each stored item, what it is for, how long it lasts, who the provider is, and whether data leaves the country. Category slugs and bare hostnames cannot produce that — and they force the notice to talk about "plugins" to visitors who only care about cookies and named companies.
hosts doubles as the CSP allow-list. Core widens script-src / frame-src / connect-src by
exactly these origins. An origin you did not declare stays blocked even after consent is given — if a
third-party embed silently fails to load with consent granted, an undeclared host is the first thing to
check.
Authoring help: the SDK exports ConsentServiceDeclaration (and ConsentStorageDeclaration) as a
documentation-only type. Nothing in the SDK reads plugin.json or validates it — the host owns and
enforces the manifest — but typing a literal against it gives you completion and catches a typo before
core rejects the plugin at load, which is otherwise your first feedback. If the type and core ever
disagree, core wins; treat the mismatch as an SDK bug. The manifest as a whole stays core-owned.
Releasing (maintainers)
Publishing is automated and fires on a published GitHub Release, not on PR merge
(.github/workflows/release.yml).
scripts/set-version.sh <version>— bumps the SemVer anchor in all four sources at once.- Commit on a branch, open a PR, merge to
master(CIversion-parityguards drift). - Draft a GitHub Release with tag
v<version>and publish it. - The release workflow verifies the tag equals the anchor, rebuilds + tests, then publishes
@mosaicast/plugin-sdkto npm (OIDC trusted publishing, with provenance) and the Java artifacts to GitHub Packages. A tag/anchor mismatch fails the release before anything is published.
Test kit — mini examples
Java (plugin-testkit, no core, no DB):
var ctx = new FakePluginContext(); // in-memory store, config, feeds; sync onSchedule
myPlugin.register(ctx); // exercise the backend
assertEquals(Optional.of("world"),
ctx.store().get(Scope.site(), "hello", String.class));TypeScript (@mosaicast/plugin-sdk/testing, jsdom):
import { makeMockCtx } from '@mosaicast/plugin-sdk/testing';
const el = document.createElement('bingo-episode-card') as HTMLElement & { ctx: PluginContext };
el.ctx = makeMockCtx({ scope: { type: 'episode', id: 'ep-1' } });
document.body.appendChild(el);
expect(el.shadowRoot!.querySelector('.card')).not.toBeNull();
// el.ctx.api is a MockApiClient: assert el.ctx.api.calls after interactionsAwaiting a component's fetches (flushMockApi, since 0.9.0). The mock resolves its promise before the
component's .then(setState) runs, so await Promise.resolve() covers one hop and not two — which is why
the symptom is an assertion that fails only sometimes, depending on how many hops the component happens
to have:
mount(ctx);
await flushMockApi(ctx.api); // waits for the calls, then drains the hops behind them
expect(root.querySelector('.title')?.textContent).toBe('The Kraken');Driving a failure. apiError(status, problem) as a canned response makes the client reject with the
host's error shape, so a component's 404 branch and its 500 branch are separately testable:
const ctx = makeMockCtx({ apiResponses: { 'get data/site/main/stats': apiError(403, { detail: 'backendOwned' }) } });The doubles refuse what the host refuses. makeMockTags() throws on tagEpisode unless you pass
{ writesEpisodes: true }, and its untagEpisode leaves a withFeedTag assignment alone. makeMockBlobs
enforces the ceilings and the allow-list. A component that has only ever met a permissive double meets its
first refusal in front of a podcaster.
The extension-point harnesses (Java): SearchProviderHarness calls a provider once per role including
anonymous; UserDataHandlerHarness.eraseTwice(userId) proves erasure survives the host's retry;
PageRouteProviderHarness.check(...) answers which subpaths 404, always probing the plugin root. All three
are shown under
Optional extension points.
Contributing
Contributions welcome — see CONTRIBUTING.md. In short: git commit -s (DCO, required), SPDX header in new files, add tests.
License
Apache License 2.0 — see LICENSE. Header per source file:
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Mosaicast AuthorsName & trademark
"Mosaicast" and the logo denote the official project. Please rename forks.
